// AUDIT FIX VERIFICATION — AereSessionKeyValidator per-batch value-cap bypass — 2026-07-10 // // CONFIRMED finding on the LIVE AereSessionKeyValidator // (0x6e03A3D7A4c90d6f8dD6F0BA1F6e8aB1F8990D26, chain 2800): _checkCallData validates // an executeBatch by running _checkSingle over each Call INDEPENDENTLY, enforcing // value <= maxValuePerOp PER SUB-CALL with no running sum. A session key can therefore // move maxValuePerOp * N native AERE in a single executeBatch (N-way drain) — the // per-op cap does not bound the whole userOp. // // Every scenario deploys a LOCAL copy of AERE's own AereEntryPointV2 (the exact source // live at 0x8D6f40598d552fF0Cb358b6012cF4227B86aF770) + AereModularAccount(Factory) and // drives real userOps through ep.handleOps, exactly like the live bundler path. For each // property the V1 source (byte-identical to the deployed validator, no immutables) // REPRODUCES the bug and AereSessionKeyValidatorV2 CORRECTS it. // // (1) per-batch value cap: V1 lets a 50x batch drain; V2 rejects it in validation, a // batch summing <= cap still passes, single-execute is unchanged. // (2) defense-in-depth: a scoped sub-call back into the account with an admin selector // (setRootOwner / installModule / execute / executeBatch) is ALWAYS rejected on V2, // even when mistakenly allowlisted — on V1 the SAME allowlist lets a session key // STEAL ownership via execute(account, setRootOwner(attacker)). // (3) rolling cumulative cap (maxTotalValue): total native outflow is bounded across // ops, not just per-op; backward-compatible (default unlimited). // plus a legitimate scoped session-key userOp still executes end-to-end via handleOps. // // Run: npx hardhat test test/audit-fix-v2-sessionkey-2026-07.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 = 800_000n; const CALL_GAS = 3_000_000n; const PRE_VERIF_GAS = 21_000n; const MAX_FEE = 10n ** 9n; // 1 gwei const NATIVE = "0x00000000"; const E = (s) => ethers.parseEther(s); 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 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] ); } // UserOperationEvent for a given sender, or null if none. function opEvent(ep, receipt, sender) { return receipt.logs .map((l) => { try { return ep.interface.parseLog(l); } catch { return null; } }) .find((p) => p && p.name === "UserOperationEvent" && p.args.sender === sender) || null; } describe("AereSessionKeyValidator per-batch value-cap bypass — V1 reproduces / V2 corrects", function () { this.timeout(120000); async function fixture() { const [deployer, root, bundler, rando] = await ethers.getSigners(); const ep = await (await ethers.getContractFactory("AereEntryPointV2")).deploy(); await ep.waitForDeployment(); const epAddr = await ep.getAddress(); const factory = await (await ethers.getContractFactory("AereModularAccountFactory")).deploy(epAddr); await factory.waitForDeployment(); // V1: byte-identical source to the deployed 0x6e03…0D26 validator (no immutables). const v1 = await (await ethers.getContractFactory("AereSessionKeyValidator")).deploy(); await v1.waitForDeployment(); const v1Addr = await v1.getAddress(); // V2: the fix. const v2 = await (await ethers.getContractFactory("AereSessionKeyValidatorV2")).deploy(); await v2.waitForDeployment(); const v2Addr = await v2.getAddress(); const ping = await (await ethers.getContractFactory("AerePingCounter")).deploy(); await ping.waitForDeployment(); const pingAddr = await ping.getAddress(); const pingSel = ping.interface.getFunction("ping").selector; const salt = 1n; const accountAddr = await factory["getAddress(address,uint256)"](root.address, salt); await (await factory.createAccount(root.address, salt)).wait(); const account = await ethers.getContractAt("AereModularAccount", accountAddr); // Fund the account generously so it can prefund gas AND move real value. await (await deployer.sendTransaction({ to: accountAddr, value: E("3") })).wait(); const sessionKey = ethers.Wallet.createRandom(); const attacker = ethers.Wallet.createRandom(); // Account-admin selectors (as seen on AereModularAccount). const setRootOwnerSel = account.interface.getFunction("setRootOwner").selector; const installModuleSel = account.interface.getFunction("installModule").selector; const executeSel = account.interface.getFunction("execute").selector; const executeBatchSel = account.interface.getFunction("executeBatch").selector; return { deployer, root, bundler, rando, ep, epAddr, factory, v1, v1Addr, v2, v2Addr, ping, pingAddr, pingSel, account, accountAddr, sessionKey, attacker, setRootOwnerSel, installModuleSel, executeSel, executeBatchSel, salt, }; } // Install `validator` on the account and enable a native-transfer-to-ping session. async function installNativeSession(ctx, validatorAddr, maxValuePerOp, validUntil) { const initData = sessionInitData( ctx.sessionKey.address, 0, validUntil, maxValuePerOp, [ctx.pingAddr], [NATIVE] ); await (await ctx.account.connect(ctx.root).installModule(1, validatorAddr, initData)).wait(); } function batchToPing(account, pingAddr, n, perValue) { const calls = []; for (let i = 0; i < n; i++) calls.push({ target: pingAddr, value: perValue, data: "0x" }); return account.interface.encodeFunctionData("executeBatch", [calls]); } // ───────────────────────── (1) per-batch value cap ────────────────────────── it("V1 REPRODUCES: a 50-call batch (each == maxValuePerOp) DRAINS 50x the per-op cap", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v1Addr, sessionKey, bundler, pingAddr } = ctx; const now = await time.latest(); const cap = E("0.01"); await installNativeSession(ctx, v1Addr, cap, now + 3600); const before = await ethers.provider.getBalance(pingAddr); const callData = batchToPing(account, pingAddr, 50, cap); // 50 * 0.01 = 0.5 ETH const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v1Addr, sessionKey); const rc = await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); const evt = opEvent(ep, rc, accountAddr); expect(evt, "UserOperationEvent present").to.not.equal(null); expect(evt.args.success, "V1 batch succeeded").to.equal(true); // 0.5 ETH moved through a session key whose per-op cap was 0.01 ETH. expect(await ethers.provider.getBalance(pingAddr)).to.equal(before + E("0.5")); }); it("V2 CORRECTS: the SAME 50x batch REVERTS in validation and moves nothing", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2Addr, sessionKey, bundler, pingAddr } = ctx; const now = await time.latest(); const cap = E("0.01"); await installNativeSession(ctx, v2Addr, cap, now + 3600); const before = await ethers.provider.getBalance(pingAddr); const callData = batchToPing(account, pingAddr, 50, cap); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); expect(await ethers.provider.getBalance(pingAddr)).to.equal(before); // nothing moved }); it("V2: a batch whose values SUM to exactly maxValuePerOp still passes", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2Addr, sessionKey, bundler, pingAddr } = ctx; const now = await time.latest(); const cap = E("0.01"); await installNativeSession(ctx, v2Addr, cap, now + 3600); const before = await ethers.provider.getBalance(pingAddr); // 4 x 0.0025 = 0.01 == cap. const callData = batchToPing(account, pingAddr, 4, E("0.0025")); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v2Addr, sessionKey); const rc = await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(opEvent(ep, rc, accountAddr).args.success).to.equal(true); expect(await ethers.provider.getBalance(pingAddr)).to.equal(before + E("0.01")); }); it("V2: a batch that sums to cap+1 wei REVERTS (boundary)", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2Addr, sessionKey, bundler, pingAddr } = ctx; const now = await time.latest(); const cap = E("0.01"); await installNativeSession(ctx, v2Addr, cap, now + 3600); const calls = [ { target: pingAddr, value: E("0.006"), data: "0x" }, { target: pingAddr, value: E("0.004") + 1n, data: "0x" }, // sum = cap + 1 wei ]; const callData = account.interface.encodeFunctionData("executeBatch", [calls]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); }); it("V2: single execute unchanged — within cap passes, above cap reverts", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2Addr, sessionKey, bundler, pingAddr } = ctx; const now = await time.latest(); const cap = E("0.01"); await installNativeSession(ctx, v2Addr, cap, now + 3600); // within cap const before = await ethers.provider.getBalance(pingAddr); let cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.008"), "0x"]); let op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(await ethers.provider.getBalance(pingAddr)).to.equal(before + E("0.008")); // above cap cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.02"), "0x"]); op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); }); // ───────────────────── (2) defense-in-depth: admin selectors ───────────────── // Helper: enable a session that (mistakenly) allowlists a self-target admin call. async function enableSelfAdminSession(ctx, validator, validatorAddr, target, selector) { await (await ctx.account.connect(ctx.root).installModule(1, validatorAddr, "0x")).wait(); // grant via the account (msg.sender = account) so state lands in the account's namespace const data = validator.interface.encodeFunctionData( "enableSession", [ctx.sessionKey.address, 0, 0, E("0.01"), [target], [selector]] ); await (await ctx.account.connect(ctx.root).execute(validatorAddr, 0, data)).wait(); } it("V1 REPRODUCES: a session allowlisted for (account, setRootOwner) STEALS ownership", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v1, v1Addr, sessionKey, attacker, bundler, setRootOwnerSel, root } = ctx; await enableSelfAdminSession(ctx, v1, v1Addr, accountAddr, setRootOwnerSel); expect(await account.rootOwner()).to.equal(root.address); const inner = account.interface.encodeFunctionData("setRootOwner", [attacker.address]); const callData = account.interface.encodeFunctionData("execute", [accountAddr, 0, inner]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v1Addr, sessionKey); const rc = await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(opEvent(ep, rc, accountAddr).args.success).to.equal(true); // The session key just replaced the account's root owner. expect(await account.rootOwner()).to.equal(attacker.address); }); it("V2 CORRECTS: the SAME (account, setRootOwner) session is rejected; ownership intact", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, attacker, bundler, setRootOwnerSel, root } = ctx; await enableSelfAdminSession(ctx, v2, v2Addr, accountAddr, setRootOwnerSel); // The permission really IS in the allowlist (so it is the guard, not scope, that blocks). expect(await v2.allowedCall(accountAddr, sessionKey.address, accountAddr, setRootOwnerSel)).to.equal(true); const inner = account.interface.encodeFunctionData("setRootOwner", [attacker.address]); const callData = account.interface.encodeFunctionData("execute", [accountAddr, 0, inner]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); expect(await account.rootOwner()).to.equal(root.address); // unchanged }); it("V2: every forbidden self-target selector is rejected even when allowlisted", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, setRootOwnerSel, installModuleSel, executeSel, executeBatchSel, root } = ctx; // uninstallModule selector too const uninstallSel = account.interface.getFunction("uninstallModule").selector; const forbidden = [setRootOwnerSel, installModuleSel, uninstallSel, executeSel, executeBatchSel]; await (await account.connect(root).installModule(1, v2Addr, "0x")).wait(); // allowlist ALL of them on the account itself (the dangerous misconfiguration) const grant = v2.interface.encodeFunctionData( "enableSession", [sessionKey.address, 0, 0, E("0.01"), forbidden.map(() => accountAddr), forbidden] ); await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); // build a harmless-looking payload per selector; validation must reject each. const payloads = { [setRootOwnerSel]: account.interface.encodeFunctionData("setRootOwner", [ctx.attacker.address]), [installModuleSel]: account.interface.encodeFunctionData("installModule", [1, v2Addr, "0x"]), [uninstallSel]: account.interface.encodeFunctionData("uninstallModule", [1, v2Addr, "0x"]), [executeSel]: account.interface.encodeFunctionData("execute", [ctx.pingAddr, 0, "0x"]), [executeBatchSel]: account.interface.encodeFunctionData("executeBatch", [[{ target: ctx.pingAddr, value: 0, data: "0x" }]]), }; for (const sel of forbidden) { const callData = account.interface.encodeFunctionData("execute", [accountAddr, 0, payloads[sel]]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address), `selector ${sel}`) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); } }); it("V2: re-entrant executeBatch nesting (batch that nests executeBatch on the account) is rejected", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, executeBatchSel, pingAddr, root } = ctx; await (await account.connect(root).installModule(1, v2Addr, "0x")).wait(); const grant = v2.interface.encodeFunctionData( "enableSession", [sessionKey.address, 0, 0, E("0.01"), [accountAddr, pingAddr], [executeBatchSel, NATIVE]] ); await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); const nested = account.interface.encodeFunctionData("executeBatch", [[{ target: pingAddr, value: 0, data: "0x" }]]); const outer = account.interface.encodeFunctionData("executeBatch", [[ { target: pingAddr, value: 0, data: "0x" }, { target: accountAddr, value: 0, data: nested }, // re-entrant nest ]]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, outer), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); }); it("V2: a scoped call to a DIFFERENT contract with the same selector bytes is still allowed", async function () { // The guard is target==account specific; an ordinary allowlisted call to ping is untouched. const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, pingAddr, pingSel, root } = ctx; await (await account.connect(root).installModule(1, v2Addr, "0x")).wait(); const grant = v2.interface.encodeFunctionData( "enableSession", [sessionKey.address, 0, 0, E("0.01"), [pingAddr], [pingSel]] ); await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); const callData = account.interface.encodeFunctionData("execute", [pingAddr, 0, ctx.ping.interface.encodeFunctionData("ping")]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, callData), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(await ctx.ping.pings()).to.equal(1n); }); // ─────────────────────── (3) rolling cumulative cap ────────────────────────── it("V2: maxTotalValue bounds TOTAL native outflow across ops (per-op cap not enough alone)", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, pingAddr, root } = ctx; // per-op cap 0.01, but rolling TOTAL cap 0.01 across all ops. await (await account.connect(root).installModule(1, v2Addr, "0x")).wait(); const grant = v2.interface.encodeFunctionData( "enableSessionWithCap", [sessionKey.address, 0, 0, E("0.01"), E("0.01"), [pingAddr], [NATIVE]] ); await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); expect(await v2.maxTotalValue(accountAddr, sessionKey.address)).to.equal(E("0.01")); expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(0n); const before = await ethers.provider.getBalance(pingAddr); // op1: 0.006 (<= per-op AND leaves 0.004 of total) -> passes let cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.006"), "0x"]); let op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(E("0.006")); // op2: another 0.006 (<= per-op) but would make total 0.012 > 0.01 -> REJECTED cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.006"), "0x"]); op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); // failed validation reverts the whole handleOps: spent + nonce unchanged expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(E("0.006")); // op3: 0.004 brings total to exactly 0.01 -> passes cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.004"), "0x"]); op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(E("0.01")); // op4: any further value -> REJECTED (budget exhausted) cd = account.interface.encodeFunctionData("execute", [pingAddr, 1n, "0x"]); op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); expect(await ethers.provider.getBalance(pingAddr)).to.equal(before + E("0.01")); // total moved == cap }); it("V2: default (no cap) is unlimited and touches no spend storage — backward compatible", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, pingAddr, root } = ctx; const now = await time.latest(); await installNativeSession({ account, root, sessionKey, pingAddr }, v2Addr, E("0.01"), now + 3600); // no cap set expect(await v2.maxTotalValue(accountAddr, sessionKey.address)).to.equal(0n); // three ops well over any single total; all pass because uncapped (V1 behavior) for (let i = 0; i < 3; i++) { const cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.009"), "0x"]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); } // spend counter never advances while uncapped expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(0n); }); it("V2: re-enabling a session resets the rolling spend to zero", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, pingAddr, root } = ctx; await (await account.connect(root).installModule(1, v2Addr, "0x")).wait(); const grant = v2.interface.encodeFunctionData( "enableSessionWithCap", [sessionKey.address, 0, 0, E("0.01"), E("0.01"), [pingAddr], [NATIVE]] ); await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); const cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.008"), "0x"]); const op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(E("0.008")); // re-grant -> fresh budget await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); expect(await v2.spentTotal(accountAddr, sessionKey.address)).to.equal(0n); expect(await v2.maxTotalValue(accountAddr, sessionKey.address)).to.equal(E("0.01")); }); it("V2: setMaxTotalValue is owner-namespaced (msg.sender = account) and requires an enabled session", async function () { const ctx = await loadFixture(fixture); const { account, accountAddr, v2, v2Addr, sessionKey, rando, root } = ctx; await (await account.connect(root).installModule(1, v2Addr, "0x")).wait(); const grant = v2.interface.encodeFunctionData( "enableSession", [sessionKey.address, 0, 0, E("0.01"), [ctx.pingAddr], [NATIVE]] ); await (await account.connect(root).execute(v2Addr, 0, grant)).wait(); // account sets its own cap const setData = v2.interface.encodeFunctionData("setMaxTotalValue", [sessionKey.address, E("0.05")]); await (await account.connect(root).execute(v2Addr, 0, setData)).wait(); expect(await v2.maxTotalValue(accountAddr, sessionKey.address)).to.equal(E("0.05")); // a stranger calling directly writes THEIR OWN namespace, never the account's await expect(v2.connect(rando).setMaxTotalValue(sessionKey.address, E("1"))) .to.be.revertedWithCustomError(v2, "SessionNotEnabled"); expect(await v2.maxTotalValue(accountAddr, sessionKey.address)).to.equal(E("0.05")); // untouched }); // ─────────────────────── legitimate happy path (V2) ────────────────────────── it("V2: a legitimate scoped session-key userOp executes end-to-end via handleOps", async function () { const ctx = await loadFixture(fixture); const { ep, account, accountAddr, v2, v2Addr, sessionKey, bundler, ping, pingAddr, pingSel, root } = ctx; const now = await time.latest(); const initData = sessionInitData(sessionKey.address, 0, now + 3600, E("0.01"), [pingAddr, pingAddr], [pingSel, NATIVE]); await (await account.connect(root).installModule(1, v2Addr, initData)).wait(); // in-scope function call let cd = account.interface.encodeFunctionData("execute", [pingAddr, 0, ping.interface.encodeFunctionData("ping")]); let op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); let rc = await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(opEvent(ep, rc, accountAddr).args.success).to.equal(true); expect(await ping.pings()).to.equal(1n); // in-scope native transfer within the per-op cap const before = await ethers.provider.getBalance(pingAddr); cd = account.interface.encodeFunctionData("execute", [pingAddr, E("0.005"), "0x"]); op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await (await ep.connect(bundler).handleOps([op], bundler.address)).wait(); expect(await ethers.provider.getBalance(pingAddr)).to.equal(before + E("0.005")); // out-of-scope target still rejected cd = account.interface.encodeFunctionData("execute", [ctx.rando.address, 0, "0x"]); op = await signSession(ep, await buildUserOp(ep, accountAddr, cd), v2Addr, sessionKey); await expect(ep.connect(bundler).handleOps([op], bundler.address)) .to.be.revertedWithCustomError(ep, "AccountValidationFailed"); }); it("V2 exposes the same module surface as V1 (drop-in install by address)", async function () { const { v2, account } = await loadFixture(fixture); expect(await v2.isModuleType(1)).to.equal(true); expect(await v2.isModuleType(2)).to.equal(false); expect(await v2.EXECUTE_SELECTOR()).to.equal(account.interface.getFunction("execute").selector); expect(await v2.EXECUTE_BATCH_SELECTOR()).to.equal(account.interface.getFunction("executeBatch").selector); expect(await v2.INSTALL_MODULE_SELECTOR()).to.equal(account.interface.getFunction("installModule").selector); expect(await v2.UNINSTALL_MODULE_SELECTOR()).to.equal(account.interface.getFunction("uninstallModule").selector); expect(await v2.SET_ROOT_OWNER_SELECTOR()).to.equal(account.interface.getFunction("setRootOwner").selector); }); });