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.
595 lines
32 KiB
JavaScript
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");
|
|
});
|
|
});
|
|
});
|