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