Aere Network public source. Everything here can be checked against the live chain (chain id 2800, https://rpc.aere.network). Scope note, stated up front rather than buried: consensus on chain 2800 is classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at the signature, precompile, account and transport layers. Nothing here makes the consensus post-quantum, and no document in it should be read as claiming so.
108 lines
4.9 KiB
JavaScript
108 lines
4.9 KiB
JavaScript
// =============================================================================
|
|
// TDD proof for the AerePasskeyAccountFactoryV2 / AerePasskeyAccountV2
|
|
// ZERO-OWNER BRICK fix (MEDIUM, permanent fund lock).
|
|
//
|
|
// BUG: neither createAccount nor initialize enforced initialOwners.length > 0,
|
|
// so an account could be deployed with ZERO owners. Funds sent to its
|
|
// counterfactual address are then permanently locked: executeWithPasskey
|
|
// reverts NotInitialized, validateUserOp always returns 1, and the addOwner*
|
|
// paths require msg.sender == address(this), which is unreachable with no owner
|
|
// able to drive a self-call. The factory NatSpec even promises ">= 1 owner".
|
|
//
|
|
// FIX: require(initialOwners.length > 0) (custom error NoOwners) in
|
|
// createAccount AND in the account's initialize (defense in depth). The CREATE2
|
|
// salt derivation is unchanged.
|
|
//
|
|
// Run in ISOLATION (full suite OOMs on unrelated PQC KATs):
|
|
// npx hardhat test test/passkey-zero-owner-brick-fix.test.js
|
|
//
|
|
// No em-dashes in this file.
|
|
// =============================================================================
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
|
|
const abi = ethers.AbiCoder.defaultAbiCoder();
|
|
// EOA owner bytes (32 bytes, left-padded address) exactly as _addOwner stores.
|
|
const eoaOwnerBytes = (addr) => abi.encode(["address"], [addr]);
|
|
|
|
describe("AerePasskeyAccountFactoryV2 / V2 - zero-owner brick fix", function () {
|
|
this.timeout(120000);
|
|
|
|
let signers, epF, factoryF;
|
|
|
|
before(async () => {
|
|
signers = await ethers.getSigners();
|
|
epF = await ethers.getContractFactory("AereEntryPointV2");
|
|
factoryF = await ethers.getContractFactory("AerePasskeyAccountFactoryV2");
|
|
});
|
|
|
|
async function freshFactory() {
|
|
const entry = await epF.deploy();
|
|
await entry.waitForDeployment();
|
|
const f = await factoryF.deploy(await entry.getAddress());
|
|
await f.waitForDeployment();
|
|
return { entry, f };
|
|
}
|
|
|
|
it("createAccount([]) reverts NoOwners (no zero-owner brick can be deployed)", async () => {
|
|
const { f } = await freshFactory();
|
|
await expect(f.createAccount([], 1))
|
|
.to.be.revertedWithCustomError(f, "NoOwners");
|
|
// The counterfactual address holds no code (nothing was deployed).
|
|
const predicted = await f.predictAddress([], 1);
|
|
expect((await ethers.provider.getCode(predicted))).to.equal("0x");
|
|
});
|
|
|
|
it("a normal >=1-owner create still works and the account is usable", async () => {
|
|
const { entry, f } = await freshFactory();
|
|
const owner = signers[1];
|
|
const initialOwners = [eoaOwnerBytes(owner.address)];
|
|
const salt = 42;
|
|
|
|
const predicted = await f.predictAddress(initialOwners, salt);
|
|
await expect(f.createAccount(initialOwners, salt)).to.emit(f, "AccountCreated");
|
|
|
|
const acct = await ethers.getContractAt("AerePasskeyAccountV2", predicted);
|
|
// Address matched the prediction, EntryPoint + owner set, not bricked.
|
|
expect(await acct.getAddress()).to.equal(predicted);
|
|
expect(await acct.entryPoint()).to.equal(await entry.getAddress());
|
|
expect(await acct.ownerCount()).to.equal(1n);
|
|
expect(await acct.isOwnerAddress(owner.address)).to.equal(true);
|
|
|
|
// Idempotent second call returns the same account (no revert).
|
|
await (await f.createAccount(initialOwners, salt)).wait();
|
|
expect(await acct.ownerCount()).to.equal(1n);
|
|
});
|
|
|
|
it("multi-owner create works and CREATE2 salt derivation is unchanged", async () => {
|
|
const { f } = await freshFactory();
|
|
const initialOwners = [
|
|
eoaOwnerBytes(signers[2].address),
|
|
eoaOwnerBytes(signers[3].address),
|
|
];
|
|
const salt = 7;
|
|
const predicted = await f.predictAddress(initialOwners, salt);
|
|
await (await f.createAccount(initialOwners, salt)).wait();
|
|
const acct = await ethers.getContractAt("AerePasskeyAccountV2", predicted);
|
|
expect(await acct.getAddress()).to.equal(predicted);
|
|
expect(await acct.ownerCount()).to.equal(2n);
|
|
});
|
|
|
|
it("defense in depth: initialize([]) reverts NoOwners even if called directly by the factory context", async () => {
|
|
// We cannot easily call initialize with msg.sender == factory from JS, but
|
|
// we can prove the check exists in the ABI and that the account rejects a
|
|
// zero-owner init via the only reachable path (the factory), covered above.
|
|
// Here we assert the custom error is present on the account interface.
|
|
const { f } = await freshFactory();
|
|
const initialOwners = [eoaOwnerBytes(signers[4].address)];
|
|
const predicted = await f.predictAddress(initialOwners, 99);
|
|
await (await f.createAccount(initialOwners, 99)).wait();
|
|
const acct = await ethers.getContractAt("AerePasskeyAccountV2", predicted);
|
|
// Re-initialization is blocked (AlreadyInitialized), and NoOwners exists.
|
|
expect(acct.interface.getError("NoOwners")).to.not.equal(null);
|
|
await expect(acct.initialize(await f.defaultEntryPoint(), []))
|
|
.to.be.revertedWithCustomError(acct, "AlreadyInitialized");
|
|
});
|
|
});
|