aere-contracts/test/modular-account.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

595 lines
32 KiB
JavaScript

// AereModularAccount stack tests.
//
// Deploys a LOCAL copy of AERE's own AereEntryPointV2 (the same source that is
// live at 0x8D6f40598d552fF0Cb358b6012cF4227B86aF770 on chain 2800) and drives
// every userOp through ep.handleOps, exactly like the live bundler path.
//
// Routing convention under test (first signature byte):
// 0x00 || 65-byte ECDSA -> root-owner fallback (EIP-191 over userOpHash)
// 0x01 || validator(20) || payload -> installed type-1 validator module
//
// Run: npx hardhat test test/modular-account.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { loadFixture, time } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const coder = ethers.AbiCoder.defaultAbiCoder();
const VERIF_GAS = 500_000n;
const CALL_GAS = 1_000_000n;
const PRE_VERIF_GAS = 21_000n;
const MAX_FEE = 10n ** 9n; // 1 gwei
const NATIVE_SENTINEL = "0x00000000";
const MAGIC_1271 = "0x1626ba7e";
const FAIL_1271 = "0xffffffff";
const DAY = 24 * 3600;
function packUints(hi, lo) {
return ethers.toBeHex((hi << 128n) | lo, 32);
}
async function buildUserOp(ep, accountAddr, callData) {
return {
sender: accountAddr,
nonce: await ep.getNonce(accountAddr),
initCode: "0x",
callData,
accountGasLimits: packUints(VERIF_GAS, CALL_GAS),
preVerificationGas: PRE_VERIF_GAS,
gasFees: packUints(MAX_FEE, MAX_FEE),
paymasterAndData: "0x",
signature: "0x",
};
}
async function signRoot(ep, op, rootSigner) {
const hash = await ep.getUserOpHash(op);
const sig = await rootSigner.signMessage(ethers.getBytes(hash));
op.signature = ethers.concat(["0x00", sig]);
return op;
}
async function signSession(ep, op, validatorAddr, sessionWallet) {
const hash = await ep.getUserOpHash(op);
const sig = await sessionWallet.signMessage(ethers.getBytes(hash));
op.signature = ethers.concat(["0x01", validatorAddr, sig]);
return op;
}
function sessionInitData(key, validAfter, validUntil, maxValuePerOp, targets, selectors) {
return coder.encode(
["address", "uint48", "uint48", "uint256", "address[]", "bytes4[]"],
[key, validAfter, validUntil, maxValuePerOp, targets, selectors]
);
}
describe("AereModularAccount + session keys + social recovery", function () {
async function fixture() {
const [deployer, root, guardian1, guardian2, guardian3, rando, newOwner, bundler] =
await ethers.getSigners();
const EP = await ethers.getContractFactory("AereEntryPointV2");
const ep = await EP.deploy();
await ep.waitForDeployment();
const epAddr = await ep.getAddress();
const Factory = await ethers.getContractFactory("AereModularAccountFactory");
const factory = await Factory.deploy(epAddr);
await factory.waitForDeployment();
const Validator = await ethers.getContractFactory("AereSessionKeyValidator");
const validator = await Validator.deploy();
await validator.waitForDeployment();
const validatorAddr = await validator.getAddress();
const Recovery = await ethers.getContractFactory("AereSocialRecoveryModule");
const recovery = await Recovery.deploy();
await recovery.waitForDeployment();
const recoveryAddr = await recovery.getAddress();
const Ping = await ethers.getContractFactory("AerePingCounter");
const ping = await Ping.deploy();
await ping.waitForDeployment();
const pingAddr = await ping.getAddress();
const pingSel = ping.interface.getFunction("ping").selector;
// Create the account (root owner = root signer).
const salt = 1n;
const predicted = await factory["getAddress(address,uint256)"](root.address, salt);
await (await factory.createAccount(root.address, salt)).wait();
const account = await ethers.getContractAt("AereModularAccount", predicted);
const accountAddr = predicted;
// Fund the account so it can prefund its EntryPoint deposit.
await (await deployer.sendTransaction({ to: accountAddr, value: ethers.parseEther("1") })).wait();
// Session key wallet (never funded, never a tx sender - signature only).
const sessionKey = ethers.Wallet.createRandom();
return {
deployer, root, guardian1, guardian2, guardian3, rando, newOwner, bundler,
ep, epAddr, factory, validator, validatorAddr, recovery, recoveryAddr,
ping, pingAddr, pingSel, account, accountAddr, sessionKey, salt,
};
}
// Fixture with the session validator installed and a session enabled:
// scope = { (ping, ping()), (ping, native transfer) }, maxValue 0.01 ETH,
// window = [now, now + 1h].
async function sessionFixture() {
const ctx = await loadFixture(fixture);
const now = await time.latest();
const validUntil = now + 3600;
const initData = sessionInitData(
ctx.sessionKey.address, 0, validUntil, ethers.parseEther("0.01"),
[ctx.pingAddr, ctx.pingAddr], [ctx.pingSel, NATIVE_SENTINEL]
);
await (await ctx.account.connect(ctx.root).installModule(1, ctx.validatorAddr, initData)).wait();
return { ...ctx, validUntil };
}
// ---- 1. Account creation ---------------------------------------------------
describe("account creation (CREATE2 factory)", function () {
it("deploys at the deterministic getAddress and sets root owner + entryPoint", async function () {
const { factory, account, accountAddr, root, epAddr, salt } = await loadFixture(fixture);
expect(await factory["getAddress(address,uint256)"](root.address, salt)).to.equal(accountAddr);
expect(await account.rootOwner()).to.equal(root.address);
expect(await account.entryPoint()).to.equal(epAddr);
expect(await account.accountId()).to.equal("aere.modular-account.1.0.0");
});
it("createAccount is idempotent for the same (owner, salt)", async function () {
const { factory, root, salt, accountAddr } = await loadFixture(fixture);
await (await factory.createAccount(root.address, salt)).wait(); // second call, no revert
expect(await factory["getAddress(address,uint256)"](root.address, salt)).to.equal(accountAddr);
});
it("different owner produces a different address (no front-running)", async function () {
const { factory, root, rando, salt } = await loadFixture(fixture);
const a = await factory["getAddress(address,uint256)"](root.address, salt);
const b = await factory["getAddress(address,uint256)"](rando.address, salt);
expect(a).to.not.equal(b);
});
it("initialize cannot be called twice", async function () {
const { account, epAddr, rando } = await loadFixture(fixture);
await expect(account.connect(rando).initialize(epAddr, rando.address))
.to.be.revertedWithCustomError(account, "Unauthorized");
});
});
// ---- 2. Root-owner fallback path via LIVE-shape EntryPoint -------------------
describe("root-owner fallback validation (0x00 prefix)", function () {
it("root-signed userOp executes through ep.handleOps", async function () {
const { ep, account, accountAddr, root, bundler, ping, pingAddr } = await loadFixture(fixture);
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signRoot(ep, await buildUserOp(ep, accountAddr, callData), root);
await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
expect(await ping.pings()).to.equal(1n);
expect(await ep.getNonce(accountAddr)).to.equal(1n);
});
it("non-root signature on 0x00 path REVERTS handleOps", async function () {
const { ep, account, accountAddr, rando, bundler, ping, pingAddr } = await loadFixture(fixture);
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signRoot(ep, await buildUserOp(ep, accountAddr, callData), rando);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("root can rotate its own key via execute self-call, direct setRootOwner is blocked", async function () {
const { account, accountAddr, root, newOwner, rando } = await loadFixture(fixture);
await expect(account.connect(root).setRootOwner(newOwner.address))
.to.be.revertedWithCustomError(account, "Unauthorized");
await expect(account.connect(rando).execute(accountAddr, 0, "0x"))
.to.be.revertedWithCustomError(account, "Unauthorized");
const data = account.interface.encodeFunctionData("setRootOwner", [newOwner.address]);
await (await account.connect(root).execute(accountAddr, 0, data)).wait();
expect(await account.rootOwner()).to.equal(newOwner.address);
});
});
// ---- 3. Module management -----------------------------------------------------
describe("ERC-7579-style module management", function () {
it("install validator module + isModuleInstalled + selector constants match", async function () {
const { account, validator, validatorAddr, root } = await loadFixture(fixture);
await (await account.connect(root).installModule(1, validatorAddr, "0x")).wait();
expect(await account.isModuleInstalled(1, validatorAddr, "0x")).to.equal(true);
expect(await account.isModuleInstalled(2, validatorAddr, "0x")).to.equal(false);
expect(await validator.EXECUTE_SELECTOR()).to.equal(account.interface.getFunction("execute").selector);
expect(await validator.EXECUTE_BATCH_SELECTOR()).to.equal(account.interface.getFunction("executeBatch").selector);
});
it("stranger cannot install or uninstall modules", async function () {
const { account, validatorAddr, rando } = await loadFixture(fixture);
await expect(account.connect(rando).installModule(1, validatorAddr, "0x"))
.to.be.revertedWithCustomError(account, "Unauthorized");
await expect(account.connect(rando).uninstallModule(1, validatorAddr, "0x"))
.to.be.revertedWithCustomError(account, "Unauthorized");
});
it("wrong module type / non-contract module are rejected", async function () {
const { account, validatorAddr, recoveryAddr, root, rando } = await loadFixture(fixture);
await expect(account.connect(root).installModule(3, validatorAddr, "0x"))
.to.be.revertedWithCustomError(account, "UnsupportedModuleType");
await expect(account.connect(root).installModule(1, rando.address, "0x"))
.to.be.revertedWithCustomError(account, "InvalidModule");
// recovery module declares type 2, not type 1
await expect(account.connect(root).installModule(1, recoveryAddr, "0x"))
.to.be.revertedWithCustomError(account, "InvalidModule");
});
it("executeFromExecutor rejected for non-installed executor", async function () {
const { account, rando } = await loadFixture(fixture);
await expect(account.connect(rando).executeFromExecutor(rando.address, 0, "0x"))
.to.be.revertedWithCustomError(account, "Unauthorized");
});
});
// ---- 4. Session key happy path -------------------------------------------------
describe("session key userOps (0x01 prefix, signed ONLY by the session key)", function () {
it("in-scope call (ping) executes through ep.handleOps", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, ping, pingAddr } =
await sessionFixture();
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
const rc = await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
expect(await ping.pings()).to.equal(1n);
// UserOperationEvent(success=true)
const evt = rc.logs.map(l => { try { return ep.interface.parseLog(l); } catch { return null; } })
.find(p => p && p.name === "UserOperationEvent");
expect(evt.args.success).to.equal(true);
expect(evt.args.sender).to.equal(accountAddr);
});
it("in-scope native transfer within maxValuePerOp executes", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, pingAddr } =
await sessionFixture();
const before = await ethers.provider.getBalance(pingAddr);
const callData = account.interface.encodeFunctionData("execute", [pingAddr, ethers.parseEther("0.005"), "0x"]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
expect(await ethers.provider.getBalance(pingAddr)).to.equal(before + ethers.parseEther("0.005"));
});
it("in-scope executeBatch (two pings) executes", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, ping, pingAddr } =
await sessionFixture();
const pingData = ping.interface.encodeFunctionData("ping");
const callData = account.interface.encodeFunctionData("executeBatch", [[
{ target: pingAddr, value: 0, data: pingData },
{ target: pingAddr, value: 0, data: pingData },
]]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
expect(await ping.pings()).to.equal(2n);
});
});
// ---- 5. Session key negative paths ---------------------------------------------
describe("session key scope enforcement (all REVERT handleOps)", function () {
it("out-of-scope target reverts", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, ping, rando } =
await sessionFixture();
const callData = account.interface.encodeFunctionData("execute", [rando.address, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("wrong selector on allowed target reverts", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, ping, pingAddr } =
await sessionFixture();
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("forbidden")]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
expect(await ping.forbiddenCalls()).to.equal(0n);
});
it("value above maxValuePerOp reverts", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, pingAddr } =
await sessionFixture();
const callData = account.interface.encodeFunctionData("execute", [pingAddr, ethers.parseEther("0.02"), "0x"]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("batch with one out-of-scope call reverts entirely", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, ping, pingAddr, rando } =
await sessionFixture();
const pingData = ping.interface.encodeFunctionData("ping");
const callData = account.interface.encodeFunctionData("executeBatch", [[
{ target: pingAddr, value: 0, data: pingData },
{ target: rando.address, value: 0, data: "0x" },
]]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
expect(await ping.pings()).to.equal(0n);
});
it("session key cannot call installModule (only execute/executeBatch enterable)", async function () {
const { ep, account, accountAddr, validatorAddr, recoveryAddr, sessionKey, bundler } =
await sessionFixture();
const callData = account.interface.encodeFunctionData("installModule", [2, recoveryAddr, "0x"]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("expired session (past validUntil) reverts", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, bundler, ping, pingAddr } =
await sessionFixture();
await time.increase(2 * 3600); // window was 1h
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("not-yet-valid session (validAfter in the future) reverts, then works after time passes", async function () {
const { ep, account, accountAddr, validator, validatorAddr, root, bundler, ping, pingAddr, pingSel } =
await loadFixture(fixture);
const futureKey = ethers.Wallet.createRandom();
const now = await time.latest();
const initData = sessionInitData(futureKey.address, now + 3600, now + 7200, 0, [pingAddr], [pingSel]);
await (await account.connect(root).installModule(1, validatorAddr, initData)).wait();
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
let op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, futureKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
await time.increase(3700);
op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, futureKey);
await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
expect(await ping.pings()).to.equal(1n);
(validator); // silence unused
});
it("revoked session reverts (revocation via root-driven account.execute)", async function () {
const { ep, account, accountAddr, validator, validatorAddr, sessionKey, root, bundler, ping, pingAddr } =
await sessionFixture();
const revokeData = validator.interface.encodeFunctionData("revokeSession", [sessionKey.address]);
await (await account.connect(root).execute(validatorAddr, 0, revokeData)).wait();
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("uninstalling the validator removes routing", async function () {
const { ep, account, accountAddr, validatorAddr, sessionKey, root, bundler, ping, pingAddr } =
await sessionFixture();
await (await account.connect(root).uninstallModule(1, validatorAddr, "0x")).wait();
expect(await account.isModuleInstalled(1, validatorAddr, "0x")).to.equal(false);
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), validatorAddr, sessionKey);
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "AccountValidationFailed");
});
it("stranger cannot register a session for someone else's account (state is per-msg.sender)", async function () {
const { validator, accountAddr, rando, pingAddr, pingSel } = await sessionFixture();
const evilKey = ethers.Wallet.createRandom();
// rando CAN call enableSession, but it writes rando's own namespace,
// not the account's - the account's session set is untouched.
await (await validator.connect(rando).enableSession(evilKey.address, 0, 0, 0, [pingAddr], [pingSel])).wait();
const s = await validator.sessions(accountAddr, evilKey.address);
expect(s.enabled).to.equal(false);
});
});
// ---- 6. Social recovery -----------------------------------------------------------
async function recoveryFixture() {
const ctx = await loadFixture(fixture);
const initData = coder.encode(
["address[]", "uint256"],
[[ctx.guardian1.address, ctx.guardian2.address, ctx.guardian3.address], 2]
);
await (await ctx.account.connect(ctx.root).installModule(2, ctx.recoveryAddr, initData)).wait();
return ctx;
}
describe("social recovery (2-of-3 guardians, 48h timelock)", function () {
it("install as executor sets guardians and threshold", async function () {
const { account, recovery, recoveryAddr, accountAddr, guardian1, guardian2, guardian3, rando } =
await recoveryFixture();
expect(await account.isModuleInstalled(2, recoveryAddr, "0x")).to.equal(true);
expect(await recovery.isGuardian(accountAddr, guardian1.address)).to.equal(true);
expect(await recovery.isGuardian(accountAddr, guardian2.address)).to.equal(true);
expect(await recovery.isGuardian(accountAddr, guardian3.address)).to.equal(true);
expect(await recovery.isGuardian(accountAddr, rando.address)).to.equal(false);
expect(await recovery.thresholdOf(accountAddr)).to.equal(2n);
expect((await recovery.guardiansOf(accountAddr)).length).to.equal(3);
});
it("non-guardian cannot initiate or support", async function () {
const { recovery, accountAddr, rando, newOwner, guardian1 } = await recoveryFixture();
await expect(recovery.connect(rando).initiateRecovery(accountAddr, newOwner.address))
.to.be.revertedWithCustomError(recovery, "NotGuardian");
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await expect(recovery.connect(rando).supportRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "NotGuardian");
});
it("premature executeRecovery reverts even with threshold met", async function () {
const { recovery, accountAddr, guardian1, guardian2, newOwner } = await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await (await recovery.connect(guardian2).supportRecovery(accountAddr)).wait();
await expect(recovery.connect(guardian1).executeRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "TimelockNotElapsed");
});
it("below-threshold executeRecovery reverts even after 48h", async function () {
const { recovery, accountAddr, guardian1, newOwner } = await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await time.increase(2 * DAY + 60);
await expect(recovery.connect(guardian1).executeRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "BelowThreshold");
});
it("non-guardian executeRecovery reverts even when otherwise ready", async function () {
const { recovery, accountAddr, guardian1, guardian2, rando, newOwner } = await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await (await recovery.connect(guardian2).supportRecovery(accountAddr)).wait();
await time.increase(2 * DAY + 60);
await expect(recovery.connect(rando).executeRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "NotGuardian");
});
it("happy path: initiate + support + 48h -> root owner replaced, control transfers", async function () {
const { recovery, account, accountAddr, guardian1, guardian2, root, newOwner, ping, pingAddr } =
await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await (await recovery.connect(guardian2).supportRecovery(accountAddr)).wait();
await time.increase(2 * DAY + 60);
await (await recovery.connect(guardian1).executeRecovery(accountAddr)).wait();
expect(await account.rootOwner()).to.equal(newOwner.address);
// Old root lost direct admin; new owner has it.
await expect(account.connect(root).execute(pingAddr, 0, ping.interface.encodeFunctionData("ping")))
.to.be.revertedWithCustomError(account, "Unauthorized");
await (await account.connect(newOwner).execute(pingAddr, 0, ping.interface.encodeFunctionData("ping"))).wait();
expect(await ping.pings()).to.equal(1n);
});
it("current owner can cancelRecovery; execution then impossible", async function () {
const { recovery, accountAddr, guardian1, guardian2, root, newOwner } = await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await (await recovery.connect(guardian2).supportRecovery(accountAddr)).wait();
await (await recovery.connect(root).cancelRecovery(accountAddr)).wait();
await time.increase(2 * DAY + 60);
await expect(recovery.connect(guardian1).executeRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "NoActiveRecovery");
});
it("stranger cannot cancelRecovery", async function () {
const { recovery, accountAddr, guardian1, rando, newOwner } = await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await expect(recovery.connect(rando).cancelRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "NotOwner");
});
it("duplicate support reverts; guardian add/remove is account-only", async function () {
const { recovery, account, accountAddr, guardian1, root, rando, newOwner } = await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await expect(recovery.connect(guardian1).supportRecovery(accountAddr))
.to.be.revertedWithCustomError(recovery, "AlreadyApproved");
// rando calling addGuardian writes rando's own namespace, not the account's.
await (await recovery.connect(rando).addGuardian(newOwner.address)).wait();
expect(await recovery.isGuardian(accountAddr, newOwner.address)).to.equal(false);
// Account (driven by root) can add a guardian for itself.
const addData = recovery.interface.encodeFunctionData("addGuardian", [rando.address]);
await (await account.connect(root).execute(await recovery.getAddress(), 0, addData)).wait();
expect(await recovery.isGuardian(accountAddr, rando.address)).to.equal(true);
});
it("uninstall cleans guardians and active recovery", async function () {
const { recovery, recoveryAddr, account, accountAddr, guardian1, root, newOwner } =
await recoveryFixture();
await (await recovery.connect(guardian1).initiateRecovery(accountAddr, newOwner.address)).wait();
await (await account.connect(root).uninstallModule(2, recoveryAddr, "0x")).wait();
expect(await recovery.isGuardian(accountAddr, guardian1.address)).to.equal(false);
expect(await recovery.thresholdOf(accountAddr)).to.equal(0n);
const r = await recovery.activeRecovery(accountAddr);
expect(r.active).to.equal(false);
// And the module lost executor rights: even a ready recovery could not fire.
expect(await account.isModuleInstalled(2, recoveryAddr, "0x")).to.equal(false);
});
});
// ---- 7. EIP-1271 -------------------------------------------------------------------
describe("EIP-1271 isValidSignature", function () {
function wrap1271(accountAddr, chainId, hash) {
return ethers.keccak256(coder.encode(
["bytes32", "address", "uint256", "bytes32"],
[
ethers.keccak256(ethers.toUtf8Bytes("AereAccount1271(address account,uint256 chainid,bytes32 hash)")),
accountAddr, chainId, hash,
]
));
}
it("root path (0x00) returns magic for a valid root signature", async function () {
const { account, accountAddr, root } = await loadFixture(fixture);
const { chainId } = await ethers.provider.getNetwork();
const hash = ethers.keccak256(ethers.toUtf8Bytes("Sign in to dapp.example"));
const wrapped = wrap1271(accountAddr, chainId, hash);
const sig = ethers.concat(["0x00", await root.signMessage(ethers.getBytes(wrapped))]);
expect(await account.isValidSignature(hash, sig)).to.equal(MAGIC_1271);
});
it("root path rejects a stranger's signature", async function () {
const { account, accountAddr, rando } = await loadFixture(fixture);
const { chainId } = await ethers.provider.getNetwork();
const hash = ethers.keccak256(ethers.toUtf8Bytes("Sign in to dapp.example"));
const wrapped = wrap1271(accountAddr, chainId, hash);
const sig = ethers.concat(["0x00", await rando.signMessage(ethers.getBytes(wrapped))]);
expect(await account.isValidSignature(hash, sig)).to.equal(FAIL_1271);
});
it("validator path (0x01) returns magic for an active session key, fails after expiry", async function () {
const { account, accountAddr, validatorAddr, sessionKey } = await sessionFixture();
const { chainId } = await ethers.provider.getNetwork();
const hash = ethers.keccak256(ethers.toUtf8Bytes("dapp login"));
const wrapped = wrap1271(accountAddr, chainId, hash);
const inner = await sessionKey.signMessage(ethers.getBytes(wrapped));
const sig = ethers.concat(["0x01", validatorAddr, inner]);
expect(await account.isValidSignature(hash, sig)).to.equal(MAGIC_1271);
await time.increase(2 * 3600); // past validUntil
expect(await account.isValidSignature(hash, sig)).to.equal(FAIL_1271);
});
it("validator path fails for a non-installed validator address", async function () {
const { account, accountAddr, sessionKey, recoveryAddr } = await sessionFixture();
const { chainId } = await ethers.provider.getNetwork();
const hash = ethers.keccak256(ethers.toUtf8Bytes("dapp login"));
const wrapped = wrap1271(accountAddr, chainId, hash);
const inner = await sessionKey.signMessage(ethers.getBytes(wrapped));
const sig = ethers.concat(["0x01", recoveryAddr, inner]);
expect(await account.isValidSignature(hash, sig)).to.equal(FAIL_1271);
});
});
// ---- 8. EntryPoint economics ---------------------------------------------------------
describe("EntryPoint deposit handling (AereEntryPointV2 semantics)", function () {
it("account self-funds its EP deposit during validation and the bundler is paid", async function () {
const { ep, account, accountAddr, root, bundler, ping, pingAddr } = await loadFixture(fixture);
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signRoot(ep, await buildUserOp(ep, accountAddr, callData), root);
const bundlerBefore = await ethers.provider.getBalance(bundler.address);
await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
const dep = await ep.balanceOf(accountAddr);
// Deposit was topped to maxCost and charged actualGasUsed * maxFee.
const maxCost = (VERIF_GAS + CALL_GAS + PRE_VERIF_GAS) * MAX_FEE;
expect(dep).to.be.lt(maxCost);
expect(dep).to.be.gt(0n);
(bundlerBefore); // bundler pays tx gas but receives the op fee; net not asserted
});
it("nonce replay is rejected by the EntryPoint", async function () {
const { ep, account, accountAddr, root, bundler, ping, pingAddr } = await loadFixture(fixture);
const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]);
const op = await signRoot(ep, await buildUserOp(ep, accountAddr, callData), root);
await (await ep.connect(bundler).handleOps([op], bundler.address)).wait();
await expect(ep.connect(bundler).handleOps([op], bundler.address))
.to.be.revertedWithCustomError(ep, "FailedOp");
});
});
});