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.
500 lines
27 KiB
JavaScript
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);
|
|
});
|
|
});
|