aere-contracts/test/audit-fix-v2-sessionkey-2026-07.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

500 lines
27 KiB
JavaScript

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