aere-contracts/test/passkey-zero-owner-brick-fix.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

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");
});
});