// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereDelegate7702 EIP-7702 delegation target.
//
// Source under test (matched to the live address in sdk-js/src/addresses.ts):
//   contracts/delegation/AereDelegate7702.sol -> 0x5673D92080efbd0987402E9335c14200d0a5EaeF
//
// EIP-7702 makes the delegating EOA's address == address(this). The contract's
// owner gate is `onlyOwner: msg.sender == address(this)`. Hardhat's local chain
// does not execute a real 7702 authorization tuple, so we reproduce the exact
// owner-call semantics by IMPERSONATING the deployed delegate's OWN address and
// sending owner transactions FROM it (ethers.getImpersonatedSigner) -> then
// msg.sender == address(this) genuinely, exercising the real onlyOwner path.
// Every other signer models a third party / a granted session key / an attacker.
//
// The passkey (RIP-7951 P256Verify at 0x100) is not present on hardhat, so the
// anti-replay / challenge-binding logic is driven with a stub precompile
// installed via hardhat_setCode (a "always-accept" 32-byte 0x..01 return and an
// "always-reject" 0x..00 return). This isolates the nonce/challenge state
// machine from the (real, on-chain) P-256 cryptography, which is what the
// replay invariant is about.
//
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT Foundry:
// `forge` is not installed; the repo builds through hardhat with OZ 4.9.6 +
// viaIR). Every random choice comes from mulberry32(seed); the base seed is
// printed at suite start and pinnable via FUZZ_SEED. On any break the failing
// (campaign, step, actor, action, values) is embedded in the assertion message
// for a minimal repro.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/delegate7702-authorization-invariant-property.test.js
// No deploy to any live node, no contract-logic change, one new test file only.
//
// INVARIANTS FUZZED (exactly the brief):
//   * AUTHORIZATION: a delegation (session key / passkey) can be created ONLY by
//     the delegating account itself (msg.sender == address(this)); ANY third
//     party calling addSessionKey / revokeSessionKey / setPasskey / executeBatch
//     reverts NotEoaOwner. No third party can author a delegation.
//   * SCOPE: a session key can act ONLY while active AND unexpired AND with a
//     call whose 4-byte selector is in its granted allow-list; every other
//     combination reverts. It can never act with a selector it was not granted.
//   * REVOCATION: revokeSessionKey takes effect immediately -- in the very next
//     call (same block) a revoked key reverts SessionKeyExpired.
//   * NO REPLAY: a passkey authorization is single-use. The signed challenge
//     binds address(this)+passkeyNonce+the exact Call, and passkeyNonce
//     increments on success, so re-submitting the identical (Call, signature)
//     reverts InvalidChallenge; a signature for Call A cannot authorize Call B.
//   * NO UNAUTHORIZED FUND MOVEMENT: an actor holding neither the owner identity
//     nor an active in-scope session key nor a valid passkey cannot move ANY of
//     the account's native balance.
//
// A real invariant violation is a valuable result: the two FINDING tests at the
// bottom are deterministic minimal repros, not papered over. They demonstrate a
// scope-granularity weakness (see comments there) honestly, without altering the
// contract.
//
// No em-dashes anywhere in this file.
// =============================================================================

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");

/* ------------------------------- seeded PRNG ------------------------------ */
function mulberry32(a) {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0xDE1E7702;
function rngFor(tag, campaign) {
  let h = BASE_SEED ^ (campaign * 0x9e3779b1);
  for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0;
  return mulberry32(h >>> 0);
}
const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive
const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
const chance = (rng, p) => rng() < p;

/* ------------------------------- misc helpers ----------------------------- */
const E = (n) => ethers.parseEther(String(n));
const P256_VERIFY = "0x0000000000000000000000000000000000000100";
// Stub precompile runtimes: return one 32-byte word. Accept -> 0x..01, reject -> 0x..00.
const STUB_ACCEPT = "0x600160005260206000f3"; // PUSH1 1; MSTORE; RETURN 32 bytes -> 0x..01
const STUB_REJECT = "0x600060005260206000f3"; // PUSH1 0; MSTORE; RETURN 32 bytes -> 0x..00
// Universal payable sink: runtime is a single STOP (0x00) -> any call with any
// data and any value succeeds and keeps the value. Deployed from raw init code.
const SINK_INIT = "0x60016000f3"; // return 1 byte (mem[0]==0x00) -> runtime = STOP

async function setBal(addr, amountWei) {
  await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]);
}
async function latestTs() {
  return Number((await ethers.provider.getBlock("latest")).timestamp);
}
async function deployRaw(deployer, initCode) {
  const tx = await deployer.sendTransaction({ data: initCode });
  const rc = await tx.wait();
  return rc.contractAddress;
}
function randSelector(rng) {
  let s = "0x";
  for (let i = 0; i < 4; i++) s += ("0" + ri(rng, 0, 255).toString(16)).slice(-2);
  return s;
}
// callItem.data = selector (4 bytes) + `padWords` * 32 zero bytes.
function dataFor(selector, padWords) {
  return selector + "00".repeat(padWords * 32);
}

// Iteration counts (env-overridable so a quick smoke can shrink them).
const N = {
  CAMPAIGNS: Number(process.env.D7_CAMPAIGNS || 5),
  STEPS: Number(process.env.D7_STEPS || 90),
};

// The delegate's OWN privileged 4-byte selectors (computed, cross-checked below).
let OWNER_SELECTORS;

// =============================================================================
describe("INVARIANT: AereDelegate7702 (EIP-7702 delegation authorization + scope)", function () {
  this.timeout(0);

  let signers, delegateF;

  before(async function () {
    signers = await ethers.getSigners();
    delegateF = await ethers.getContractFactory("AereDelegate7702");
    OWNER_SELECTORS = {
      executeBatch: delegateF.interface.getFunction("executeBatch").selector,
      addSessionKey: delegateF.interface.getFunction("addSessionKey").selector,
      revokeSessionKey: delegateF.interface.getFunction("revokeSessionKey").selector,
      setPasskey: delegateF.interface.getFunction("setPasskey").selector,
    };
    console.log(
      `      [d7702] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} authorization/scope`
    );
  });

  // Deploy a fresh delegate, impersonate its OWN address (the 7702 owner), fund
  // it richly, and deploy a universal payable sink. Returns handles.
  async function stand(deployer) {
    const delegate = await delegateF.deploy();
    await delegate.waitForDeployment();
    const delegateAddr = await delegate.getAddress();

    // The 7702 "owner": send from address(this). Impersonate + fund for gas AND
    // for the native value the account moves during authorized flows.
    const owner = await ethers.getImpersonatedSigner(delegateAddr);
    await setBal(delegateAddr, E(1_000_000));

    const sink = await deployRaw(deployer, SINK_INIT);
    return { delegate, delegateAddr, owner, sink };
  }

  // ---- predict executeWithSessionKey outcome from the JS shadow model, in the
  //      EXACT branch order the contract checks. Returns null on success or the
  //      custom-error name on the expected revert. ----
  function predictSessionKeyOutcome(model, key, now, data) {
    const sk = model.get(key);
    const active = !!(sk && sk.active);
    const expiry = sk ? sk.expiry : 0;
    if (!active || expiry < now) return "SessionKeyExpired";
    const bytes = (data.length - 2) / 2;
    if (bytes < 4) return "SelectorNotAllowed";
    const selector = data.slice(0, 10).toLowerCase();
    if (!sk.selectors.has(selector)) return "SelectorNotAllowed";
    return null; // success
  }

  // ---- full invariant battery over the shadow model vs on-chain state ----
  async function assertModel(ctx, delegate, model, actorKeys, probeSelectors) {
    // sessionKeyInfo(key, selector) must equal the shadow model for every known
    // key across a set of probe selectors (granted + not-granted).
    for (const key of actorKeys) {
      const sk = model.get(key) || { active: false, expiry: 0, selectors: new Set() };
      for (const sel of probeSelectors) {
        const info = await delegate.sessionKeyInfo(key, sel);
        expect(info.active, `[d7702 SK-ACTIVE] ${ctx} key=${key.slice(0, 10)}`).to.equal(sk.active);
        expect(Number(info.expiry), `[d7702 SK-EXPIRY] ${ctx} key=${key.slice(0, 10)}`).to.equal(sk.expiry);
        expect(info.selectorAllowed, `[d7702 SK-SEL] ${ctx} key=${key.slice(0, 10)} sel=${sel}`)
          .to.equal(sk.selectors.has(sel.toLowerCase()));
      }
    }
  }

  // =========================================================================
  // MAIN BATTERY: authorization (owner-only), scope, revocation, and
  // no-unauthorized-fund-movement, driven by long randomized multi-actor runs.
  // =========================================================================
  it("owner-only authorization + session-key scope + immediate revocation + fund safety", async function () {
    let steps = 0;
    const cnt = {
      ownerAdd: 0, ownerRevoke: 0, ownerBatch: 0,
      skOk: 0, skReject: 0,
      thirdPartyDenied: 0, attackerFundDenied: 0,
      revokeImmediate: 0, expireChecks: 0,
    };

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("auth", c);
      const deployer = signers[0];
      const { delegate, delegateAddr, owner, sink } = await stand(deployer);

      // Candidate session-key identities + third parties + a dedicated attacker.
      const keyPool = [signers[1], signers[2], signers[3], signers[4]].map((s) => s.address);
      const keySigners = { [signers[1].address]: signers[1], [signers[2].address]: signers[2], [signers[3].address]: signers[3], [signers[4].address]: signers[4] };
      const thirdParties = [signers[5], signers[6], signers[7]];
      const attacker = signers[8];

      // A palette of "known" selectors used across grants + probes, plus the
      // delegate's own privileged selectors (so probes cover the escalation
      // surface too). None of these are granted unless an owner grants them.
      const palette = [];
      for (let i = 0; i < 6; i++) palette.push(randSelector(rng));
      const probeSelectors = [...palette, OWNER_SELECTORS.executeBatch, OWNER_SELECTORS.addSessionKey, "0xa9059cbb"];

      // shadow model: key -> { active, expiry, selectors:Set<selectorHexLower> }
      const model = new Map();
      let sinkExpected = 0n; // native value the account has intentionally paid to `sink`

      await assertModel(`c${c} init`, delegate, model, keyPool, probeSelectors);

      for (let s = 0; s < N.STEPS; s++) {
        const action = pick(rng, [
          "ownerAdd", "ownerAdd", "ownerRevoke", "ownerBatch",
          "sessionCall", "sessionCall", "sessionCall",
          "thirdPartyOwnerCall", "thirdPartyOwnerCall",
          "attackerFundGrab", "advance", "reAddExtend",
        ]);
        const now = await latestTs();
        const ctx = `c${c} s${s} action=${action}`;

        if (action === "ownerAdd") {
          // Owner (address(this)) grants a session key: authorized delegation.
          const key = pick(rng, keyPool);
          const inFuture = chance(rng, 0.8);
          const expiry = inFuture ? now + ri(rng, 3600, 30 * 24 * 3600) : now - ri(rng, 100, 10000);
          const grant = [];
          const gCount = ri(rng, 1, 3);
          for (let g = 0; g < gCount; g++) grant.push(pick(rng, palette));
          await (await delegate.connect(owner).addSessionKey(key, expiry, grant)).wait();
          // model: expiry+active overwritten; selectors accumulate (contract only sets true).
          const sk = model.get(key) || { active: false, expiry: 0, selectors: new Set() };
          sk.active = true;
          sk.expiry = expiry;
          for (const gsel of grant) sk.selectors.add(gsel.toLowerCase());
          model.set(key, sk);
          cnt.ownerAdd++;
        } else if (action === "reAddExtend") {
          // Re-grant an existing key with new selectors: still owner-authorized,
          // selectors accumulate, expiry refreshes.
          const existing = [...model.keys()];
          if (existing.length === 0) { steps++; continue; }
          const key = pick(rng, existing);
          const expiry = now + ri(rng, 3600, 30 * 24 * 3600);
          const grant = [pick(rng, palette), pick(rng, palette)];
          await (await delegate.connect(owner).addSessionKey(key, expiry, grant)).wait();
          const sk = model.get(key);
          sk.active = true;
          sk.expiry = expiry;
          for (const gsel of grant) sk.selectors.add(gsel.toLowerCase());
          cnt.ownerAdd++;
        } else if (action === "ownerRevoke") {
          const existing = [...model.keys()];
          if (existing.length === 0) { steps++; continue; }
          const key = pick(rng, existing);
          await (await delegate.connect(owner).revokeSessionKey(key)).wait();
          const sk = model.get(key);
          sk.active = false; // expiry + selectors retained by contract, only active flips
          cnt.ownerRevoke++;

          // REVOCATION IS IMMEDIATE: right now, in the next call, the revoked key
          // must be unable to act, even on a selector it still "has" and even if
          // unexpired.
          const someSel = sk.selectors.size ? [...sk.selectors][0] : palette[0];
          const ks = keySigners[key];
          await expect(
            delegate.connect(ks).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(someSel, 1) }),
            `[d7702 REVOKE-IMMEDIATE] revoked key still acted ${ctx} key=${key.slice(0, 10)}`
          ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired");
          cnt.revokeImmediate++;
        } else if (action === "ownerBatch") {
          // Authorized native-value movement by the owner via executeBatch.
          const val = E(ri(rng, 1, 50));
          const sinkBefore = await ethers.provider.getBalance(sink);
          await (await delegate.connect(owner).executeBatch([{ target: sink, value: val, data: "0x" }])).wait();
          const sinkAfter = await ethers.provider.getBalance(sink);
          expect(sinkAfter - sinkBefore, `[d7702 OWNER-BATCH] value mismatch ${ctx}`).to.equal(val);
          sinkExpected += val;
          cnt.ownerBatch++;
        } else if (action === "sessionCall") {
          // A session key (or a random pool identity) attempts to act. Outcome
          // must EXACTLY match the contract's active/expiry/selector gate.
          const key = pick(rng, keyPool);
          const ks = keySigners[key];
          const useGranted = chance(rng, 0.6);
          const sk = model.get(key);
          let sel;
          if (useGranted && sk && sk.selectors.size) sel = [...sk.selectors][ri(rng, 0, sk.selectors.size - 1)];
          else sel = pick(rng, palette);
          let data;
          if (chance(rng, 0.12)) {
            // sub-4-byte data (0..3 bytes) -> SelectorNotAllowed via the length gate.
            const nb = ri(rng, 0, 3);
            data = "0x" + sel.slice(2, 2 + nb * 2); // valid even-length hex
          } else {
            data = dataFor(sel, ri(rng, 1, 2));
          }
          const value = chance(rng, 0.5) ? E(ri(rng, 1, 10)) : 0n;
          const predicted = predictSessionKeyOutcome(model, key, now, data);

          const sinkBefore = await ethers.provider.getBalance(sink);
          if (predicted === null) {
            await (await delegate.connect(ks).executeWithSessionKey({ target: sink, value, data })).wait();
            const sinkAfter = await ethers.provider.getBalance(sink);
            expect(sinkAfter - sinkBefore, `[d7702 SK-VALUE] in-scope call moved wrong value ${ctx}`).to.equal(value);
            sinkExpected += value;
            cnt.skOk++;
          } else {
            await expect(
              delegate.connect(ks).executeWithSessionKey({ target: sink, value, data }),
              `[d7702 SK-GATE] expected ${predicted} ${ctx} key=${key.slice(0, 10)} data=${data}`
            ).to.be.revertedWithCustomError(delegate, predicted);
            const sinkAfter = await ethers.provider.getBalance(sink);
            expect(sinkAfter - sinkBefore, `[d7702 SK-REJECT-NOMOVE] rejected call still moved value ${ctx}`).to.equal(0n);
            cnt.skReject++;
          }
        } else if (action === "thirdPartyOwnerCall") {
          // AUTHORIZATION: NO third party can author a delegation or batch.
          const tp = pick(rng, thirdParties);
          const which = pick(rng, ["add", "revoke", "setPasskey", "batch"]);
          const key = pick(rng, keyPool);
          let call;
          if (which === "add") call = delegate.connect(tp).addSessionKey(key, now + 100000, [pick(rng, palette)]);
          else if (which === "revoke") call = delegate.connect(tp).revokeSessionKey(key);
          else if (which === "setPasskey") call = delegate.connect(tp).setPasskey(ethers.hexlify(ethers.randomBytes(32)), ethers.hexlify(ethers.randomBytes(32)));
          else call = delegate.connect(tp).executeBatch([{ target: sink, value: E(1), data: "0x" }]);
          await expect(
            call,
            `[d7702 AUTH-3P] third party authored/executed as owner ${ctx} which=${which} tp=${tp.address.slice(0, 10)}`
          ).to.be.revertedWithCustomError(delegate, "NotEoaOwner");
          cnt.thirdPartyDenied++;
        } else if (action === "attackerFundGrab") {
          // NO UNAUTHORIZED FUND MOVEMENT: an actor with no owner identity, no
          // session key, and no passkey cannot move ANY native balance by ANY
          // of the fund-moving entrypoints.
          const balBefore = await ethers.provider.getBalance(delegateAddr);
          const sinkBefore = await ethers.provider.getBalance(sink);
          // executeBatch -> NotEoaOwner
          await expect(delegate.connect(attacker).executeBatch([{ target: attacker.address, value: E(100), data: "0x" }]),
            `[d7702 FUND-BATCH] attacker drained via executeBatch ${ctx}`).to.be.reverted;
          // executeWithSessionKey with no key -> SessionKeyExpired
          await expect(delegate.connect(attacker).executeWithSessionKey({ target: attacker.address, value: E(100), data: dataFor(pick(rng, palette), 1) }),
            `[d7702 FUND-SK] attacker drained via session key it never held ${ctx}`).to.be.revertedWithCustomError(delegate, "SessionKeyExpired");
          // executeWithPasskey with no passkey configured (fresh delegate) OR bogus -> reverts, no movement
          await expect(delegate.connect(attacker).executeWithPasskey(
            { target: attacker.address, value: E(100), data: "0x" },
            { r: ethers.ZeroHash, s: ethers.ZeroHash, authenticatorData: "0x", clientDataJSON: "0x" }
          ), `[d7702 FUND-PK] attacker moved funds via bogus passkey ${ctx}`).to.be.reverted;
          const balAfter = await ethers.provider.getBalance(delegateAddr);
          const sinkAfter = await ethers.provider.getBalance(sink);
          expect(balAfter, `[d7702 FUND-BAL] account balance changed under attacker pressure ${ctx}`).to.equal(balBefore);
          expect(sinkAfter, `[d7702 FUND-SINK] sink balance changed under attacker pressure ${ctx}`).to.equal(sinkBefore);
          cnt.attackerFundDenied++;
        } else if (action === "advance") {
          await time.increase(ri(rng, 1, 5 * 24 * 3600));
          await network.provider.send("evm_mine");
          // EXPIRY: any key whose expiry now lies in the past must be unable to
          // act even though `active` is still true.
          const nowT = await latestTs();
          for (const [key, sk] of model.entries()) {
            if (sk.active && sk.expiry < nowT && sk.selectors.size) {
              const ks = keySigners[key];
              await expect(
                delegate.connect(ks).executeWithSessionKey({ target: sink, value: 0n, data: dataFor([...sk.selectors][0], 1) }),
                `[d7702 EXPIRE] expired key still acted ${ctx} key=${key.slice(0, 10)} expiry=${sk.expiry} now=${nowT}`
              ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired");
              cnt.expireChecks++;
            }
          }
        }

        await assertModel(ctx, delegate, model, keyPool, probeSelectors);
        // FUND CONSERVATION on the intended path: everything the account paid to
        // `sink` equals exactly the sum of authorized owner-batch + in-scope
        // session-key values. No unauthorized inflow ever reached the sink.
        expect(await ethers.provider.getBalance(sink), `[d7702 SINK-CONSERVE] ${ctx}`).to.equal(sinkExpected);
        steps++;
      }
    }

    console.log(
      `      [d7702 auth] steps=${steps} ownerAdd=${cnt.ownerAdd} ownerRevoke=${cnt.ownerRevoke} ownerBatch=${cnt.ownerBatch} ` +
        `skOk=${cnt.skOk} skReject=${cnt.skReject} 3pDenied=${cnt.thirdPartyDenied} attackerDenied=${cnt.attackerFundDenied} ` +
        `revokeImmediate=${cnt.revokeImmediate} expireChecks=${cnt.expireChecks}`
    );
    const totalSteps = N.CAMPAIGNS * N.STEPS;
    expect(steps).to.equal(totalSteps);
    expect(cnt.ownerAdd).to.be.greaterThan(totalSteps * 0.05);
    expect(cnt.skOk).to.be.greaterThan(5);
    expect(cnt.skReject).to.be.greaterThan(5);
    expect(cnt.thirdPartyDenied).to.be.greaterThan(totalSteps * 0.05);
    expect(cnt.attackerFundDenied).to.be.greaterThan(3);
    expect(cnt.revokeImmediate).to.be.greaterThan(0);
  });

  // =========================================================================
  // NO REPLAY of a passkey authorization. Driven with a stub P256Verify at
  // 0x100 so we exercise the nonce/challenge state machine (the real P-256
  // math is a separate on-chain concern). Randomized over multiple seeds.
  // =========================================================================
  it("passkey authorization is single-use: challenge binds nonce+call, no replay, no cross-call reuse", async function () {
    let replays = 0, crossCall = 0, wrongChallenge = 0, successes = 0;

    // base64url (no padding) of a 32-byte 0x-hex value, matching the contract's
    // _b64url32 (standard base64url of the 32 bytes).
    const chalB64 = (hex32) => Buffer.from(hex32.slice(2), "hex").toString("base64url");
    const clientData = (b64) => "0x" + Buffer.from(`{"type":"webauthn.get","challenge":"${b64}","origin":"https://aere.network"}`, "utf8").toString("hex");

    for (let c = 0; c < 3; c++) {
      const rng = rngFor("passkey", c);
      const deployer = signers[0];
      const { delegate, owner, sink } = await stand(deployer);

      // Install the ALWAYS-ACCEPT stub so a well-formed challenge reaches success.
      await network.provider.send("hardhat_setCode", [P256_VERIFY, STUB_ACCEPT]);

      // Owner configures the single passkey (authorization by owner only).
      const pubX = ethers.hexlify(ethers.randomBytes(32));
      const pubY = ethers.hexlify(ethers.randomBytes(32));
      await (await delegate.connect(owner).setPasskey(pubX, pubY)).wait();

      for (let s = 0; s < 8; s++) {
        const value = chance(rng, 0.5) ? E(ri(rng, 1, 20)) : 0n;
        const callA = { target: sink, value, data: "0x" + "ab".repeat(ri(rng, 0, 8)) };

        // Correct challenge for the CURRENT nonce binds this exact call.
        const expected = await delegate.nextPasskeyChallenge(callA);
        const sigOk = { r: ethers.hexlify(ethers.randomBytes(32)), s: ethers.hexlify(ethers.randomBytes(32)), authenticatorData: "0x" + "11".repeat(37), clientDataJSON: clientData(chalB64(expected)) };

        // (a) WRONG challenge (base64url of an unrelated value) -> InvalidChallenge,
        //     even though the stub would accept the signature.
        const bogus = ethers.hexlify(ethers.randomBytes(32));
        const sigBad = { ...sigOk, clientDataJSON: clientData(chalB64(bogus)) };
        await expect(
          delegate.connect(signers[9]).executeWithPasskey(callA, sigBad),
          `[d7702 PK-CHAL] wrong-challenge signature accepted c${c} s${s}`
        ).to.be.revertedWithCustomError(delegate, "InvalidChallenge");
        wrongChallenge++;

        // (b) A signature bound to callA cannot authorize a DIFFERENT call callB.
        const callB = { target: sink, value: value + 1n, data: callA.data };
        await expect(
          delegate.connect(signers[9]).executeWithPasskey(callB, sigOk),
          `[d7702 PK-XCALL] signature reused across a different call c${c} s${s}`
        ).to.be.revertedWithCustomError(delegate, "InvalidChallenge");
        crossCall++;

        // (c) Correct challenge -> success; nonce increments by exactly 1.
        const nonceBefore = await delegate.passkeyNonce();
        const sinkBefore = await ethers.provider.getBalance(sink);
        await (await delegate.connect(signers[9]).executeWithPasskey(callA, sigOk)).wait();
        const nonceAfter = await delegate.passkeyNonce();
        expect(nonceAfter - nonceBefore, `[d7702 PK-NONCE] nonce did not advance by 1 c${c} s${s}`).to.equal(1n);
        expect((await ethers.provider.getBalance(sink)) - sinkBefore, `[d7702 PK-VALUE] c${c} s${s}`).to.equal(value);
        successes++;

        // (d) REPLAY the identical (callA, sigOk): the challenge now targets the
        //     advanced nonce, so the stale clientDataJSON no longer binds ->
        //     InvalidChallenge. The single-use property holds.
        await expect(
          delegate.connect(signers[9]).executeWithPasskey(callA, sigOk),
          `[d7702 PK-REPLAY] identical passkey authorization replayed c${c} s${s}`
        ).to.be.revertedWithCustomError(delegate, "InvalidChallenge");
        replays++;
      }

      // Restore: clear the stub so we do not leak a fake precompile across files.
      await network.provider.send("hardhat_setCode", [P256_VERIFY, "0x"]);
    }

    console.log(`      [d7702 passkey] successes=${successes} replaysBlocked=${replays} crossCallBlocked=${crossCall} wrongChalBlocked=${wrongChallenge}`);
    expect(successes).to.be.greaterThan(15);
    expect(replays).to.equal(successes);
    expect(crossCall).to.equal(successes);
  });

  // =========================================================================
  // FINDING 1 (session-key scope granularity): a session key is bound ONLY by a
  // 4-byte selector allow-list. There is NO target allow-list and NO value /
  // allowance cap. So a key provisioned for the documented "narrow scope (e.g.
  // swap router only) ... low-risk operations" can instead move the account's
  // ENTIRE native balance to an ARBITRARY attacker-controlled address, merely by
  // pointing `target` at any contract that exposes a function with the granted
  // selector (trivial for an attacker to craft) and setting `value` to the whole
  // balance. This exceeds the intended granted scope ("cannot exceed / escalate")
  // even though it stays inside the contract's coarse selector-only gate.
  // Deterministic minimal repro, contract unchanged.
  // =========================================================================
  it("FINDING (scope): a selector-scoped session key can drain the account's entire native balance to an arbitrary address", async function () {
    const deployer = signers[0];
    const { delegate, delegateAddr, owner, sink } = await stand(deployer);
    const sessionKey = signers[1]; // a low-risk dApp session key
    const attackerSink = sink;     // stands in for "any contract exposing selector S"

    // Owner grants the session key ONE ordinary selector (imagine a swap router's
    // `swap(...)` selector). No value cap, no target binding is expressible.
    const grantedSelector = "0x12345678";
    const farExpiry = (await latestTs()) + 365 * 24 * 3600;
    await (await delegate.connect(owner).addSessionKey(sessionKey.address, farExpiry, [grantedSelector])).wait();

    // Read the account's balance AFTER owner setup (the impersonated owner IS the
    // account, so its own txs pay gas from this balance). This is the user's money.
    const accountBal = await ethers.provider.getBalance(delegateAddr);

    // The key sends the account's ENTIRE balance to an arbitrary address, by
    // pointing target at a contract that "answers" the granted selector (here the
    // universal sink; an attacker deploys a one-line contract with that selector).
    const attackerBefore = await ethers.provider.getBalance(attackerSink);
    await (await delegate.connect(sessionKey).executeWithSessionKey({
      target: attackerSink,
      value: accountBal,             // the whole account
      data: dataFor(grantedSelector, 1),
    })).wait();
    const stolen = (await ethers.provider.getBalance(attackerSink)) - attackerBefore;

    expect(stolen, "[FINDING scope] full-balance drain did not occur").to.equal(accountBal);
    expect(await ethers.provider.getBalance(delegateAddr), "[FINDING scope] account not emptied").to.equal(0n);
    console.log(`      [d7702 FINDING scope] session key drained ${ethers.formatEther(stolen)} AERE to an arbitrary address via a single granted selector (no value cap, no target allow-list)`);
  });

  // =========================================================================
  // FINDING 2 (privilege escalation via self-call): `onlyOwner` is
  // `msg.sender == address(this)`, and executeWithSessionKey makes its outbound
  // call with msg.sender == address(this). So if a session key is ever granted a
  // selector that COINCIDES with one of the delegate's own privileged selectors
  // (a 4-byte space; a real dApp function selector can collide, or an operator
  // can grant it by mistake), the key can target the account ITSELF and invoke an
  // owner-only function -> escalation to that owner power. We demonstrate the
  // clean, working vector: setPasskey (which is onlyOwner but NOT nonReentrant),
  // letting a mere session key OVERWRITE the account's passkey with an attacker-
  // controlled key -> a persistent takeover of the passkey auth path.
  //
  // HONEST BOUNDARY (asserted below too): the same trick aimed at executeBatch is
  // instead blocked, because executeWithSessionKey and executeBatch SHARE the
  // nonReentrant `_entered` flag, so the self-call reverts Reentrant(). The
  // escalation therefore reaches the NON-reentrancy-guarded owner functions
  // (setPasskey / addSessionKey / revokeSessionKey), not executeBatch.
  // Deterministic repro, contract unchanged.
  // =========================================================================
  it("FINDING (escalation): a session key granted setPasskey's selector overwrites the account passkey; the executeBatch vector is blocked by the shared reentrancy guard", async function () {
    const deployer = signers[0];
    const { delegate, delegateAddr, owner } = await stand(deployer);
    const sessionKey = signers[2];

    const farExpiry = (await latestTs()) + 365 * 24 * 3600;

    // (A) WORKING escalation via setPasskey (onlyOwner, not nonReentrant).
    const setPkSelector = OWNER_SELECTORS.setPasskey; // 0xab0f91b1
    await (await delegate.connect(owner).addSessionKey(sessionKey.address, farExpiry, [setPkSelector])).wait();

    // Account passkey starts unset.
    expect(await delegate.passkeyPubX()).to.equal(ethers.ZeroHash);
    const attackerX = ethers.hexlify(ethers.randomBytes(32));
    const attackerY = ethers.hexlify(ethers.randomBytes(32));
    const innerSetPk = delegate.interface.encodeFunctionData("setPasskey", [attackerX, attackerY]);
    expect(innerSetPk.slice(0, 10).toLowerCase(), "selector mismatch").to.equal(setPkSelector.toLowerCase());

    // The session key calls executeWithSessionKey(target=self, data=setPasskey).
    // Inner msg.sender == address(this) -> onlyOwner passes -> passkey overwritten.
    await (await delegate.connect(sessionKey).executeWithSessionKey({
      target: delegateAddr,
      value: 0n,
      data: innerSetPk,
    })).wait();
    expect(await delegate.passkeyPubX(), "[FINDING escalation] passkey X not overwritten by session key").to.equal(attackerX);
    expect(await delegate.passkeyPubY(), "[FINDING escalation] passkey Y not overwritten by session key").to.equal(attackerY);

    // (B) HONEST BOUNDARY: the executeBatch vector is blocked by nonReentrant.
    const escalBatch = OWNER_SELECTORS.executeBatch; // 0x34fcd5be
    await (await delegate.connect(owner).addSessionKey(sessionKey.address, farExpiry, [escalBatch])).wait();
    const innerBatch = delegate.interface.encodeFunctionData("executeBatch", [
      [{ target: sessionKey.address, value: E(1), data: "0x" }],
    ]);
    await expect(
      delegate.connect(sessionKey).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerBatch }),
      "[FINDING escalation] executeBatch self-call was NOT blocked by the shared reentrancy guard"
    ).to.be.revertedWithCustomError(delegate, "CallFailed");

    console.log(`      [d7702 FINDING escalation] a session key granted setPasskey's selector overwrote the account passkey (owner-only) via a self-call; the executeBatch vector reverted Reentrant() as documented`);
  });
});
