// =============================================================================
// TDD proof for AereDelegate7702V2 — the scope-hardened EIP-7702 delegate.
//
// Proves the two session-key SCOPE-ESCAPE facets of the original are CLOSED,
// while the legitimate owner / scoped-session-key / passkey paths still work.
//
//   FACET 1 (drain): a selector-scoped key could send the account's whole
//     native balance to an ARBITRARY target. V2 requires a per-key TARGET
//     allow-list AND a per-key native SPEND CAP. Proven: over-cap value and
//     non-allow-listed target both revert; a legit in-scope, in-cap call works;
//     cumulative spend is enforced across calls.
//
//   FACET 2 (escalation): a key granted the setPasskey / addSessionKey /
//     revokeSessionKey selector could self-call target=address(this) and pass
//     onlyOwner. V2 forbids target==address(this) for every session-key call.
//     Proven: self-call setPasskey and self-call addSessionKey both revert
//     TargetNotAllowed and the account passkey / grants are untouched.
//
//   PRESERVED: legit scoped call; owner executeBatch; immediate revocation;
//     expiry; passkey single-use / no-replay / no cross-call reuse.
//
// EIP-7702 makes the delegating EOA's address == address(this); the owner gate
// is msg.sender == address(this). Hardhat does not run a real 7702 tuple, so we
// impersonate the deployed delegate's OWN address (getImpersonatedSigner) to
// exercise the true onlyOwner path; every other signer is a third party / key.
// The P256Verify precompile (0x100) is stubbed via hardhat_setCode.
//
// Run in ISOLATION (full suite OOMs on unrelated PQC KATs):
//   npx hardhat test test/delegate7702v2-scope-fix.test.js
//
// No em-dashes in this file.
// =============================================================================

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

const E = (n) => ethers.parseEther(String(n));
const P256_VERIFY = "0x0000000000000000000000000000000000000100";
const STUB_ACCEPT = "0x600160005260206000f3"; // returns 32-byte 0x..01
const SINK_INIT = "0x60016000f3";            // runtime = STOP: any call/value succeeds, keeps value

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 rc = await (await deployer.sendTransaction({ data: initCode })).wait();
  return rc.contractAddress;
}
// callItem.data = selector (4 bytes) + padWords*32 zero bytes
const dataFor = (selector, padWords) => selector + "00".repeat(padWords * 32);

describe("AereDelegate7702V2 — session-key scope fix (FACET 1 drain + FACET 2 escalation closed)", function () {
  this.timeout(0);

  let signers, F;

  before(async function () {
    signers = await ethers.getSigners();
    F = await ethers.getContractFactory("AereDelegate7702V2");
  });

  // Fresh delegate, impersonate its OWN address (the 7702 owner), fund it, and
  // deploy a universal payable sink to stand in for an allow-listed dApp target.
  async function stand() {
    const deployer = signers[0];
    const delegate = await F.deploy();
    await delegate.waitForDeployment();
    const delegateAddr = await delegate.getAddress();
    const owner = await ethers.getImpersonatedSigner(delegateAddr);
    await setBal(delegateAddr, E(1_000_000));
    const sink = await deployRaw(deployer, SINK_INIT);
    const sink2 = await deployRaw(deployer, SINK_INIT);
    return { delegate, delegateAddr, owner, sink, sink2 };
  }

  // ------------------------------------------------------------------ FACET 1
  it("FACET 1 CLOSED: a scoped session key cannot drain to an arbitrary target nor exceed its native spend cap", async function () {
    const { delegate, delegateAddr, owner, sink, sink2 } = await stand();
    const key = signers[1];
    const selector = "0x12345678";
    const farExpiry = (await latestTs()) + 365 * 24 * 3600;
    const cap = E(10);

    // Owner grants: selector + target=sink + spendCap 10. sink2 is NOT allowed.
    await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, cap, [selector], [sink])).wait();

    const accountBal = await ethers.provider.getBalance(delegateAddr);

    // (1a) Drain attempt: allow-listed target but value = entire balance > cap -> revert.
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: sink, value: accountBal, data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded");

    // (1b) Arbitrary target (not allow-listed), even within cap -> revert.
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: sink2, value: E(1), data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed");

    // (1c) Just-over-cap value at an allowed target -> revert.
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: sink, value: cap + 1n, data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded");

    // Account balance is fully intact after all the drain attempts.
    expect(await ethers.provider.getBalance(delegateAddr)).to.equal(accountBal);
    expect(await ethers.provider.getBalance(sink)).to.equal(0n);
  });

  it("FACET 1: cumulative spend cap is enforced across multiple in-scope calls", async function () {
    const { delegate, owner, sink } = await stand();
    const key = signers[1];
    const selector = "0x12345678";
    const farExpiry = (await latestTs()) + 365 * 24 * 3600;
    await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(10), [selector], [sink])).wait();

    // Spend 6 then 4 -> exactly at cap, both succeed.
    await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(6), data: dataFor(selector, 1) })).wait();
    await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(4), data: dataFor(selector, 1) })).wait();
    expect(await ethers.provider.getBalance(sink)).to.equal(E(10));

    // One more wei over the cumulative cap -> revert, no movement.
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: sink, value: 1n, data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded");
    expect(await ethers.provider.getBalance(sink)).to.equal(E(10));

    // Default cap is 0: a key granted no cap cannot move any native value.
    const key0 = signers[2];
    await (await delegate.connect(owner).addSessionKey(key0.address, farExpiry, 0, [selector], [sink])).wait();
    await expect(
      delegate.connect(key0).executeWithSessionKey({ target: sink, value: 1n, data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "SpendCapExceeded");
    // But a zero-value in-scope call still works.
    await (await delegate.connect(key0).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(selector, 1) })).wait();
  });

  // ------------------------------------------------------------------ FACET 2
  it("FACET 2 CLOSED: a session key granted an owner-selector cannot self-call the account to escalate", async function () {
    const { delegate, delegateAddr, owner } = await stand();
    const key = signers[2];
    const farExpiry = (await latestTs()) + 365 * 24 * 3600;

    const setPk = delegate.interface.getFunction("setPasskey").selector;
    const addSk = delegate.interface.getFunction("addSessionKey").selector;

    // Owner (mistakenly / via selector collision) grants the setPasskey and
    // addSessionKey selectors, with a huge cap. Even the target allow-list is
    // irrelevant: target==address(this) is forbidden outright. To be maximally
    // adversarial we do NOT even need it allow-listed; try the self-call anyway.
    await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(1000), [setPk, addSk], [])).wait();

    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()).to.equal(setPk.toLowerCase());

    // Self-call setPasskey -> reverts TargetNotAllowed (never reaches onlyOwner).
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerSetPk })
    ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed");
    expect(await delegate.passkeyPubX()).to.equal(ethers.ZeroHash);
    expect(await delegate.passkeyPubY()).to.equal(ethers.ZeroHash);

    // Self-call addSessionKey (grant attacker its own powerful key) -> reverts.
    const innerAddSk = delegate.interface.encodeFunctionData("addSessionKey", [
      key.address, farExpiry, E(1000), [setPk], [],
    ]);
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerAddSk })
    ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed");

    // Self-call executeBatch -> also blocked (target==address(this)).
    const innerBatch = delegate.interface.encodeFunctionData("executeBatch", [
      [{ target: key.address, value: E(1), data: "0x" }],
    ]);
    // grant executeBatch selector too, prove target guard fires regardless.
    const eb = delegate.interface.getFunction("executeBatch").selector;
    await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(1000), [eb], [])).wait();
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: delegateAddr, value: 0n, data: innerBatch })
    ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed");
  });

  it("FACET 2: address(this) cannot be added to a session key's target allow-list", async function () {
    const { delegate, delegateAddr, owner } = await stand();
    const key = signers[3];
    const farExpiry = (await latestTs()) + 365 * 24 * 3600;
    const setPk = delegate.interface.getFunction("setPasskey").selector;
    await expect(
      delegate.connect(owner).addSessionKey(key.address, farExpiry, E(1), [setPk], [delegateAddr])
    ).to.be.revertedWithCustomError(delegate, "TargetNotAllowed");
  });

  // --------------------------------------------------------------- PRESERVED
  it("LEGIT: an in-scope session-key call (allowed target + selector + within cap) still works", async function () {
    const { delegate, owner, sink } = await stand();
    const key = signers[4];
    const selector = "0xa9059cbb"; // e.g. a swap/transfer selector on the allowed dApp
    const farExpiry = (await latestTs()) + 24 * 3600;
    await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(5), [selector], [sink])).wait();

    const before = await ethers.provider.getBalance(sink);
    await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(3), data: dataFor(selector, 1) })).wait();
    expect((await ethers.provider.getBalance(sink)) - before).to.equal(E(3));

    // Wrong selector at the allowed target -> SelectorNotAllowed.
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: sink, value: 0n, data: dataFor("0xdeadbeef", 1) })
    ).to.be.revertedWithCustomError(delegate, "SelectorNotAllowed");

    // View reflects scope.
    const scope = await delegate.sessionKeyScope(key.address, selector, sink);
    expect(scope.active).to.equal(true);
    expect(scope.selectorAllowed).to.equal(true);
    expect(scope.targetAllowed).to.equal(true);
    expect(scope.spendCap).to.equal(E(5));
    expect(scope.spent).to.equal(E(3));
  });

  it("PRESERVED: owner executeBatch moves value; third parties are denied", async function () {
    const { delegate, owner, sink } = await stand();
    const before = await ethers.provider.getBalance(sink);
    await (await delegate.connect(owner).executeBatch([{ target: sink, value: E(7), data: "0x" }])).wait();
    expect((await ethers.provider.getBalance(sink)) - before).to.equal(E(7));

    // Third party cannot authorize or execute as owner.
    await expect(
      delegate.connect(signers[5]).addSessionKey(signers[6].address, (await latestTs()) + 1000, E(1), ["0x12345678"], [sink])
    ).to.be.revertedWithCustomError(delegate, "NotEoaOwner");
    await expect(
      delegate.connect(signers[5]).executeBatch([{ target: sink, value: E(1), data: "0x" }])
    ).to.be.revertedWithCustomError(delegate, "NotEoaOwner");
  });

  it("PRESERVED: revocation is immediate and expiry is enforced", async function () {
    const { delegate, owner, sink } = await stand();
    const key = signers[6];
    const selector = "0x12345678";
    const farExpiry = (await latestTs()) + 365 * 24 * 3600;
    await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(5), [selector], [sink])).wait();
    // works before revoke
    await (await delegate.connect(key).executeWithSessionKey({ target: sink, value: E(1), data: dataFor(selector, 1) })).wait();
    // revoke -> immediate
    await (await delegate.connect(owner).revokeSessionKey(key.address)).wait();
    await expect(
      delegate.connect(key).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired");

    // expiry path
    const key2 = signers[7];
    const shortExpiry = (await latestTs()) + 3600;
    await (await delegate.connect(owner).addSessionKey(key2.address, shortExpiry, E(5), [selector], [sink])).wait();
    await time.increase(7200);
    await network.provider.send("evm_mine");
    await expect(
      delegate.connect(key2).executeWithSessionKey({ target: sink, value: 0n, data: dataFor(selector, 1) })
    ).to.be.revertedWithCustomError(delegate, "SessionKeyExpired");
  });

  it("PRESERVED: passkey single-use — no replay, no cross-call reuse, wrong challenge rejected", async function () {
    const { delegate, owner, sink } = await stand();
    await network.provider.send("hardhat_setCode", [P256_VERIFY, STUB_ACCEPT]);
    try {
      await (await delegate.connect(owner).setPasskey(ethers.hexlify(ethers.randomBytes(32)), ethers.hexlify(ethers.randomBytes(32)))).wait();
      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");

      const callA = { target: sink, value: E(2), data: "0x" + "ab".repeat(4) };
      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)),
      };

      // wrong challenge
      const sigBad = { ...sigOk, clientDataJSON: clientData(chalB64(ethers.hexlify(ethers.randomBytes(32)))) };
      await expect(delegate.connect(signers[9]).executeWithPasskey(callA, sigBad)).to.be.revertedWithCustomError(delegate, "InvalidChallenge");

      // cross-call reuse
      const callB = { target: sink, value: E(3), data: callA.data };
      await expect(delegate.connect(signers[9]).executeWithPasskey(callB, sigOk)).to.be.revertedWithCustomError(delegate, "InvalidChallenge");

      // success + nonce advance
      const nb = await delegate.passkeyNonce();
      const sb = await ethers.provider.getBalance(sink);
      await (await delegate.connect(signers[9]).executeWithPasskey(callA, sigOk)).wait();
      expect((await delegate.passkeyNonce()) - nb).to.equal(1n);
      expect((await ethers.provider.getBalance(sink)) - sb).to.equal(E(2));

      // replay identical -> InvalidChallenge (nonce advanced)
      await expect(delegate.connect(signers[9]).executeWithPasskey(callA, sigOk)).to.be.revertedWithCustomError(delegate, "InvalidChallenge");
    } finally {
      await network.provider.send("hardhat_setCode", [P256_VERIFY, "0x"]);
    }
  });
});
