// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereZKScreen v3 SP1-based on-chain compliance screen (canonical mainnet
// 0x3A097A459FD26aC79573aCB5adB51430e473C2f1 — the root-binding soundness fix +
// authorizedRoot rotation + per-user revoke; v1 0xE9da9c…04ee dead, v2
// 0x140572…8aFa7 had the "self-clear" prover-chosen-root hole).
//
// Source under test: contracts/compliance/AereZKScreen.sol (matched to the live
// address via sdk-js/src/addresses.ts).
//
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT Foundry —
// `forge` is not installed; the repo builds through hardhat with OZ 4.9.6 +
// viaIR/cancun). 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.
//
// SP1 VERIFIER IS MOCKED — DISCLOSED HONESTLY. The real Succinct SP1 gateway
// reverts on an invalid proof and returns NOTHING (empty returndata) on a valid
// one. We use the repo's existing contracts/mocks/MockSp1Verifier.sol, which
// mirrors that exact ABI: verifyProof is `view`, returns nothing, and reverts
// (MockInvalidProof) unless a specific keccak(vKey,publicValues,proof) marker
// was toggled on via setValid(). A toggled marker models "a GENUINE SP1 proof
// for these exact public values exists" — i.e. a prover really ran the program
// and it attests these values (including whatever root the prover fed the zkVM).
// The soundness question the contract must answer: even when such a genuine
// proof exists for a self-chosen / substituted root or a different subject, does
// the contract refuse to grant a clearance? We fuzz the AereZKScreen state
// machine AROUND that accept/reject toggle.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/zkscreen-soundness-invariant-property.test.js
// TEST-ONLY: one new file, NO contract-logic change, deploy NOTHING to the live
// node.
//
// INVARIANTS FUZZED (exactly the AereZKScreen brief):
//   * ROOT-BINDING SOUNDNESS: a clearance is bound to the Foundation's CURRENT
//     authorizedRoot. A prover holding a genuine proof for ANY other root
//     (including an allowlist of only themselves) is rejected (UnauthorizedRoot).
//     The v2 self-clear hole stays closed.
//   * NO FORGERY / VOID-GATEWAY: a clearance cannot be created without a genuine
//     valid proof (verifier revert -> InvalidProof, no state change). An empty /
//     wrong-length gateway response can never be decoded into a passing result
//     (BadPublicValuesLength before any decode; the void-gateway-decode bug that
//     made VALID proofs un-submittable stays fixed — valid proofs DO clear).
//   * SUBJECT/ACTION BINDING (anti-replay): a proof authorizes exactly its own
//     (chainId, subject, program). Replaying it as another sender -> UserMismatch;
//     on another chainId -> WrongChain; against another program's vKey -> the
//     marker differs -> InvalidProof; re-submitting the same/older attestedAt ->
//     StaleProof (cannot downgrade a fresher clearance).
//   * FULL STATE RECONCILIATION: an independent JS reference model of the exact
//     submitProof gate ordering + registerProgram/setAuthorizedRoot/revoke
//     reproduces clearedAt[user][vKey], revokedAt[user][vKey], and isCleared(...,
//     minTimestamp) for EVERY (actor × program) pair after EVERY step, under long
//     interleaved multi-actor sequences and time jumps (expiry).
//   * ONLY-GENUINELY-VALID-PROOFS-CLEAR: every on-chain clearedAt>0 corresponds
//     to a model transition that passed ALL gates against the authorized root.
//
// No em-dashes anywhere in this file.
// =============================================================================

const { expect } = require("chai");
const { ethers, network } = require("hardhat");

/* ------------------------------- 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) : 0x2C5EE711;
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 coder = ethers.AbiCoder.defaultAbiCoder();

async function latestTs() {
  return BigInt((await ethers.provider.getBlock("latest")).timestamp);
}
async function setNextTs(ts) {
  await network.provider.send("evm_setNextBlockTimestamp", ["0x" + ts.toString(16)]);
}
async function mineAt(ts) {
  await setNextTs(ts);
  await network.provider.send("evm_mine");
}

// The AereZKScreen public-values convention.
function encodePV(chainId, user, attestedAt, extraDataHash) {
  return coder.encode(
    ["uint256", "address", "uint256", "bytes32"],
    [chainId, user, attestedAt, extraDataHash]
  );
}
// keccak(abi.encode(bytes32 vKey, bytes publicValues, bytes proof)) — the mock's
// setValid marker AND our model's "genuine proof exists" key. Identical formula.
function markerOf(vKey, pv, proof) {
  return ethers.keccak256(coder.encode(["bytes32", "bytes", "bytes"], [vKey, pv, proof]));
}

// Iteration counts (env-overridable so a quick smoke can shrink them).
const N = {
  CAMPAIGNS: Number(process.env.ZK_CAMPAIGNS || 6),
  STEPS: Number(process.env.ZK_STEPS || 60),
};

// =============================================================================
describe("INVARIANT: AereZKScreen v3 SP1 compliance screen (root-binding soundness)", function () {
  this.timeout(0);

  let signers, verifierF, zkF, CHAIN;

  before(async function () {
    signers = await ethers.getSigners();
    verifierF = await ethers.getContractFactory("MockSp1Verifier");
    zkF = await ethers.getContractFactory("AereZKScreen");
    CHAIN = (await ethers.provider.getNetwork()).chainId;
    console.log(
      `      [zkscreen] base seed 0x${BASE_SEED.toString(16)} | chainId ${CHAIN} | ` +
        `${N.CAMPAIGNS}x${N.STEPS} randomized invariant steps (SP1 verifier MOCKED, disclosed)`
    );
  });

  async function deployStack() {
    const verifier = await verifierF.deploy();
    await verifier.waitForDeployment();
    const zk = await zkF.deploy(await verifier.getAddress());
    await zk.waitForDeployment();
    return { verifier, zk };
  }

  // ---- JS reference model of the EXACT contract state + submitProof gate order.
  const key = (u, v) => `${u.toLowerCase()}|${v}`;
  function freshModel() {
    return {
      programs: new Map(),   // vKey -> {registered, expirySeconds(bigint), authorizedRoot}
      clearedAt: new Map(),  // `${user}|${vKey}` -> bigint
      revokedAt: new Map(),  // `${user}|${vKey}` -> bigint
      validMarkers: new Set() // genuine-proof markers made available on the mock
    };
  }
  const getCleared = (m, u, v) => m.clearedAt.get(key(u, v)) || 0n;
  const getRevoked = (m, u, v) => m.revokedAt.get(key(u, v)) || 0n;

  // Predict submitProof outcome. Mirrors AereZKScreen.submitProof line-for-line,
  // including gate ORDER. Returns {err} or {ok:true, attestedAt}.
  function predictSubmit(m, now, c) {
    const p = m.programs.get(c.vKeyUsed);
    if (!p || !p.registered) return { err: "UnknownProgram" };
    if (c.pvLen !== 128) return { err: "BadPublicValuesLength" };
    if (!m.validMarkers.has(c.marker)) return { err: "InvalidProof" };
    if (c.chainId !== CHAIN) return { err: "WrongChain" };
    if (c.user.toLowerCase() !== c.msgSender.toLowerCase()) return { err: "UserMismatch" };
    if (c.extraDataHash !== p.authorizedRoot) return { err: "UnauthorizedRoot" };
    if (c.attestedAt > now) return { err: "AttestationInFuture" };
    const cur = getCleared(m, c.user, c.vKeyUsed);
    if (c.attestedAt <= cur) return { err: "StaleProof" };
    return { ok: true, attestedAt: c.attestedAt };
  }

  // Model of isCleared.
  function modelIsCleared(m, u, v, minTs, now) {
    const p = m.programs.get(v);
    if (!p || !p.registered) return false;
    const ts = getCleared(m, u, v);
    if (ts === 0n) return false;
    if (ts < minTs) return false;
    if (ts <= getRevoked(m, u, v)) return false;
    if (p.expirySeconds > 0n && now > ts + p.expirySeconds) return false;
    return true;
  }

  // Full reconciliation of every (actor × program) pair against the chain.
  async function assertReconcile(ctx, zk, m, actors, vKeys, now) {
    for (const v of vKeys) {
      const onp = await zk.programs(v);
      const mp = m.programs.get(v);
      if (!mp) {
        expect(onp.registered, `[zk PROG-REG] unexpected registered ${ctx} v=${v.slice(0, 10)}`).to.equal(false);
      } else {
        expect(onp.registered, `[zk PROG-REG] ${ctx} v=${v.slice(0, 10)}`).to.equal(mp.registered);
        expect(onp.authorizedRoot, `[zk PROG-ROOT] ${ctx} v=${v.slice(0, 10)}`).to.equal(mp.authorizedRoot);
        expect(onp.expirySeconds, `[zk PROG-EXP] ${ctx} v=${v.slice(0, 10)}`).to.equal(mp.expirySeconds);
      }
      for (const a of actors) {
        const onCleared = await zk.clearedAt(a.address, v);
        const onRevoked = await zk.revokedAt(a.address, v);
        expect(onCleared, `[zk CLEARED-AT] model!=chain ${ctx} user=${a.address.slice(0, 8)} v=${v.slice(0, 10)}`)
          .to.equal(getCleared(m, a.address, v));
        expect(onRevoked, `[zk REVOKED-AT] model!=chain ${ctx} user=${a.address.slice(0, 8)} v=${v.slice(0, 10)}`)
          .to.equal(getRevoked(m, a.address, v));

        // SOUNDNESS COROLLARY: any live clearance must trace to a genuine proof
        // against the authorized root (the model only ever sets clearedAt through
        // predictSubmit, which enforces exactly that). The equality above IS the
        // proof; here we additionally assert isCleared across several minTs.
        const cand = [0n, now, now > 1000n ? now - 1000n : 0n, onCleared];
        for (const minTs of cand) {
          const onIC = await zk.isCleared(a.address, v, minTs);
          const mIC = modelIsCleared(m, a.address, v, minTs, now);
          expect(onIC, `[zk ISCLEARED] model!=chain ${ctx} user=${a.address.slice(0, 8)} v=${v.slice(0, 10)} minTs=${minTs}`)
            .to.equal(mIC);
        }
      }
    }
  }

  // ---------------------------------------------------------------------------
  it("randomized multi-actor: root-binding, no-forgery, anti-replay, expiry all hold", async function () {
    let steps = 0, successfulClears = 0, rejects = 0;
    const tally = {
      register: 0, rotate: 0, revoke: 0, advance: 0,
      good: 0, forged: 0, wrongRoot: 0, wrongUser: 0, wrongChain: 0,
      future: 0, stale: 0, voidPV: 0, unknownProg: 0, replaySubject: 0, crossProg: 0,
    };

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("main", c);
      const { verifier, zk } = await deployStack();
      const owner = signers[0];
      const actors = [signers[1], signers[2], signers[3], signers[4], signers[5]];
      const outsiders = [signers[6], signers[7]]; // never a subject on a good path
      const allWho = [...actors, ...outsiders];
      const vKeys = [
        ethers.id(`OFAC-screen-${c}`),
        ethers.id(`accredited-investor-${c}`),
        ethers.id(`mica-jurisdiction-${c}`),
        ethers.id(`never-registered-${c}`), // stays unregistered on purpose
      ];
      const registrable = vKeys.slice(0, 3);
      const m = freshModel();
      let proofNonce = 0;
      const freshProof = () => "0x" + (0xC0FFEE00 + proofNonce++).toString(16).padStart(8, "0");

      async function makeGenuine(vKey, pv, proof) {
        const mk = markerOf(vKey, pv, proof);
        await (await verifier.setValid(mk, true)).wait(); // auto-mines a block
        m.validMarkers.add(mk);
        return mk;
      }

      // Shared submit executor. `now` is the exact block.timestamp the submit tx
      // will mine at (we pin it via setNextTs just before sending). Any helper
      // makeGenuine tx has already auto-mined at latest+1 << now (headroom below).
      async function exec(spec, now, ctx) {
        if (spec.makeValid) await makeGenuine(spec.vKeyUsed, spec.pv, spec.proof);
        const marker = markerOf(spec.vKeyUsed, spec.pv, spec.proof);
        const cand = {
          vKeyUsed: spec.vKeyUsed, pvLen: spec.pvLen, marker,
          chainId: spec.chainId, user: spec.user, attestedAt: spec.attestedAt,
          extraDataHash: spec.root, msgSender: spec.sender.address,
        };
        const pred = predictSubmit(m, now, cand);
        await setNextTs(now);
        if (pred.ok) {
          await (await zk.connect(spec.sender).submitProof(spec.vKeyUsed, spec.pv, spec.proof)).wait();
          m.clearedAt.set(key(spec.user, spec.vKeyUsed), pred.attestedAt);
          successfulClears++;
        } else {
          await expect(
            zk.connect(spec.sender).submitProof(spec.vKeyUsed, spec.pv, spec.proof),
            `${ctx} expected revert ${pred.err}`
          ).to.be.revertedWithCustomError(zk, pred.err);
          rejects++;
        }
      }

      await assertReconcile(`c${c} init`, zk, m, allWho, vKeys, await latestTs());

      for (let s = 0; s < N.STEPS; s++) {
        // `now` sits comfortably ahead of the current head so any helper block
        // (setValid at head+1) cannot overtake the timestamp we pin for the tx.
        const now = (await latestTs()) + BigInt(ri(rng, 80, 4000));

        const action = pick(rng, [
          "register", "register", "rotate", "revoke", "advance",
          "good", "good", "good", "forged", "wrongRoot", "wrongRoot",
          "wrongUser", "wrongChain", "future", "stale", "voidPV",
          "unknownProg", "replaySubject", "crossProg",
        ]);
        const actor = pick(rng, actors);
        const ctx = `c${c} s${s} actor=${actor.address.slice(0, 8)} action=${action} now=${now}`;

        if (action === "register") {
          const v = pick(rng, registrable);
          await setNextTs(now);
          if (m.programs.has(v)) {
            await expect(zk.connect(owner).registerProgram(v, 0, ethers.id("x"), ""), ctx)
              .to.be.revertedWithCustomError(zk, "AlreadyRegistered");
          } else {
            const expiry = chance(rng, 0.5) ? 0n : BigInt(ri(rng, 100, 200000));
            const root = ethers.id(`root-${c}-${v.slice(2, 8)}-r0`);
            await (await zk.connect(owner).registerProgram(v, expiry, root, `prog ${v.slice(0, 8)}`)).wait();
            m.programs.set(v, { registered: true, expirySeconds: expiry, authorizedRoot: root });
            tally.register++;
          }
        } else if (action === "rotate") {
          const v = pick(rng, registrable);
          await setNextTs(now);
          if (!m.programs.has(v)) {
            await expect(zk.connect(owner).setAuthorizedRoot(v, ethers.id("y")), ctx)
              .to.be.revertedWithCustomError(zk, "UnknownProgram");
          } else {
            const nr = ethers.id(`root-${c}-${v.slice(2, 8)}-s${s}`);
            await (await zk.connect(owner).setAuthorizedRoot(v, nr)).wait();
            m.programs.get(v).authorizedRoot = nr;
            tally.rotate++;
          }
        } else if (action === "revoke") {
          const v = pick(rng, registrable);
          const u = pick(rng, actors);
          await setNextTs(now);
          await (await zk.connect(owner).revoke(u.address, v)).wait();
          m.revokedAt.set(key(u.address, v), now); // revokedAt == block.timestamp == now
          tally.revoke++;
        } else if (action === "advance") {
          const jump = BigInt(ri(rng, 1, 400000)); // stress expiry windows
          await mineAt(now + jump);
          tally.advance++;
        } else {
          // -------------------- submitProof family --------------------
          const v = pick(rng, vKeys); // may be unregistered / never-registered
          const proof = freshProof();
          const prog = m.programs.get(v);
          const authRoot = prog ? prog.authorizedRoot : ethers.id("no-prog-root");
          const cur = getCleared(m, actor.address, v);
          const past = now - BigInt(ri(rng, 5, 60));

          if (action === "good") {
            const attestedAt = now - BigInt(ri(rng, 1, 50)); // in (cur, now], not future
            const pv = encodePV(CHAIN, actor.address, attestedAt, authRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt, root: authRoot, proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.good++;
          } else if (action === "forged") {
            const pv = encodePV(CHAIN, actor.address, past, authRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt: past, root: authRoot, proof, pv, pvLen: 128, makeValid: false, sender: actor }, now, ctx);
            tally.forged++;
          } else if (action === "wrongRoot") {
            const attackerRoot = ethers.id(`attacker-root-${c}-${s}`);
            const pv = encodePV(CHAIN, actor.address, past, attackerRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt: past, root: attackerRoot, proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.wrongRoot++;
          } else if (action === "wrongUser") {
            const subject = pick(rng, allWho.filter((a) => a.address !== actor.address)).address;
            const pv = encodePV(CHAIN, subject, past, authRoot);
            await exec({ vKeyUsed: v, user: subject, chainId: CHAIN, attestedAt: past, root: authRoot, proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.wrongUser++;
          } else if (action === "wrongChain") {
            const badChain = CHAIN + BigInt(ri(rng, 1, 9));
            const pv = encodePV(badChain, actor.address, past, authRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: badChain, attestedAt: past, root: authRoot, proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.wrongChain++;
          } else if (action === "future") {
            const attestedAt = now + BigInt(ri(rng, 5, 100000));
            const pv = encodePV(CHAIN, actor.address, attestedAt, authRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt, root: authRoot, proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.future++;
          } else if (action === "stale") {
            // <= current clearance (replay / downgrade). cur==0 -> attestedAt 0 is
            // also <= 0 so StaleProof still fires (contract: attestedAt <= currentTs).
            const attestedAt = cur > 0n ? (chance(rng, 0.5) ? cur : (cur > 1n ? cur - 1n : cur)) : 0n;
            const pv = encodePV(CHAIN, actor.address, attestedAt, authRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt, root: authRoot, proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.stale++;
          } else if (action === "voidPV") {
            // empty / wrong-length gateway response must never decode to a pass.
            const badPV = pick(rng, ["0x", "0x00", "0x" + "ab".repeat(64), "0x" + "cd".repeat(200)]);
            const pvLen = (badPV.length - 2) / 2;
            // sometimes toggle the marker anyway to prove the LENGTH gate precedes
            // the verifier (a void response can never be "verified" into a clear).
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt: 1n, root: authRoot, proof, pv: badPV, pvLen, makeValid: chance(rng, 0.5), sender: actor }, now, ctx);
            tally.voidPV++;
          } else if (action === "unknownProg") {
            const uv = ethers.id(`ephemeral-unregistered-${c}-${s}`);
            const pv = encodePV(CHAIN, actor.address, past, ethers.id("whatever"));
            await exec({ vKeyUsed: uv, user: actor.address, chainId: CHAIN, attestedAt: past, root: ethers.id("whatever"), proof, pv, pvLen: 128, makeValid: true, sender: actor }, now, ctx);
            tally.unknownProg++;
          } else if (action === "replaySubject") {
            // Genuine proof whose subject is `actor`; a DIFFERENT sender replays the
            // exact calldata -> UserMismatch, nobody cleared.
            const other = pick(rng, allWho.filter((a) => a.address !== actor.address));
            const pv = encodePV(CHAIN, actor.address, past, authRoot);
            await exec({ vKeyUsed: v, user: actor.address, chainId: CHAIN, attestedAt: past, root: authRoot, proof, pv, pvLen: 128, makeValid: true, sender: other }, now, ctx);
            tally.replaySubject++;
          } else if (action === "crossProg") {
            // Genuine proof registered under program A, submitted under program C.
            // The marker embeds the vKey, so C's verifyProof call misses -> InvalidProof
            // (or UnknownProgram if C is unregistered). Either way, no clearance.
            const vA = pick(rng, registrable);
            const rootA = m.programs.has(vA) ? m.programs.get(vA).authorizedRoot : ethers.id("ra");
            const pvA = encodePV(CHAIN, actor.address, past, rootA);
            await makeGenuine(vA, pvA, proof); // genuine ONLY for vA
            const vC = pick(rng, registrable.filter((x) => x !== vA));
            // makeValid:false -> exec does NOT toggle a marker for vC; predict sees
            // the vC-keyed marker absent -> InvalidProof (matches contract).
            await exec({ vKeyUsed: vC, user: actor.address, chainId: CHAIN, attestedAt: past, root: rootA, proof, pv: pvA, pvLen: 128, makeValid: false, sender: actor }, now, ctx);
            tally.crossProg++;
          }
        }

        // Reconcile FULL state after every step at the current head timestamp.
        await assertReconcile(ctx, zk, m, allWho, vKeys, await latestTs());
        steps++;
      }
    }

    console.log(
      `      [zkscreen] steps=${steps} clears=${successfulClears} rejects=${rejects} | ` +
        `reg=${tally.register} rot=${tally.rotate} rev=${tally.revoke} adv=${tally.advance} ` +
        `good=${tally.good} forged=${tally.forged} wrongRoot=${tally.wrongRoot} wrongUser=${tally.wrongUser} ` +
        `wrongChain=${tally.wrongChain} future=${tally.future} stale=${tally.stale} void=${tally.voidPV} ` +
        `unk=${tally.unknownProg} replay=${tally.replaySubject} cross=${tally.crossProg}`
    );
    expect(steps).to.be.greaterThan(300);
    expect(successfulClears).to.be.greaterThan(10); // genuinely-valid proofs DO clear
    expect(tally.wrongRoot + tally.forged + tally.wrongUser).to.be.greaterThan(20); // attacks exercised
  });

  // ---------------------------------------------------------------------------
  // Deterministic guard 1: the void-gateway-decode fix stays closed BOTH ways.
  it("void-gateway: valid proof clears, invalid reverts InvalidProof, short pv reverts BadPublicValuesLength", async function () {
    const [owner, alice] = signers;
    const { verifier, zk } = await deployStack();
    const vKey = ethers.id("void-gateway-prog");
    const root = ethers.id("auth-root-void");
    await (await zk.registerProgram(vKey, 0, root, "void gateway prog")).wait();

    const now = (await latestTs()) + 50n;
    const at = now - 30n;

    // (a) valid proof — verifier returns NOTHING; submitProof must NOT revert.
    const pv = encodePV(CHAIN, alice.address, at, root);
    const proof = "0xd00dfeed";
    await (await verifier.setValid(markerOf(vKey, pv, proof), true)).wait();
    await setNextTs(now);
    await (await zk.connect(alice).submitProof(vKey, pv, proof)).wait();
    expect(await zk.clearedAt(alice.address, vKey)).to.equal(at);
    expect(await zk.isCleared(alice.address, vKey, 0)).to.equal(true);

    // (b) invalid proof — verifier reverts; must surface as InvalidProof, no clear.
    const pv2 = encodePV(CHAIN, alice.address, at + 1n, root);
    await expect(zk.connect(alice).submitProof(vKey, pv2, "0xbadbadbad0"))
      .to.be.revertedWithCustomError(zk, "InvalidProof");
    expect(await zk.clearedAt(alice.address, vKey)).to.equal(at); // unchanged

    // (c) empty / short gateway response can never decode to a pass — even with a
    //     toggled marker, the length gate precedes the verifier.
    for (const bad of ["0x", "0x00", "0x" + "ab".repeat(64)]) {
      await (await verifier.setValid(markerOf(vKey, bad, "0xaa"), true)).wait();
      await expect(zk.connect(alice).submitProof(vKey, bad, "0xaa"))
        .to.be.revertedWithCustomError(zk, "BadPublicValuesLength");
    }
    expect(await zk.clearedAt(alice.address, vKey)).to.equal(at); // still unchanged
  });

  // ---------------------------------------------------------------------------
  // Deterministic guard 2: the prover-chosen-root (v2 self-clear) hole stays closed.
  it("root-binding: prover-chosen root rejected; rotation rebinds; only the current authorized root clears", async function () {
    const [owner, mallory] = signers;
    const { verifier, zk } = await deployStack();
    const vKey = ethers.id("root-binding-prog");
    const rootA = ethers.id("foundation-root-A");
    await (await zk.registerProgram(vKey, 0, rootA, "root binding")).wait();

    let now = (await latestTs()) + 50n;

    // Attacker holds a genuine proof for an allowlist of ONLY themselves.
    const selfRoot = ethers.id("mallory-only-allowlist");
    const pvBad = encodePV(CHAIN, mallory.address, now - 10n, selfRoot);
    await (await verifier.setValid(markerOf(vKey, pvBad, "0x01"), true)).wait();
    await setNextTs(now);
    await expect(zk.connect(mallory).submitProof(vKey, pvBad, "0x01"))
      .to.be.revertedWithCustomError(zk, "UnauthorizedRoot");
    expect(await zk.clearedAt(mallory.address, vKey)).to.equal(0n);

    // A genuine proof for the CURRENT authorized root clears.
    now = (await latestTs()) + 50n;
    const pvGood = encodePV(CHAIN, mallory.address, now - 8n, rootA);
    await (await verifier.setValid(markerOf(vKey, pvGood, "0x02"), true)).wait();
    await setNextTs(now);
    await (await zk.connect(mallory).submitProof(vKey, pvGood, "0x02")).wait();
    expect(await zk.clearedAt(mallory.address, vKey)).to.equal(now - 8n);
    const clearedTs = now - 8n;

    // Foundation rotates the root. A freshly-genuine proof for the OLD root no
    // longer clears (UnauthorizedRoot); only the NEW root does.
    now = (await latestTs()) + 50n;
    const rootB = ethers.id("foundation-root-B");
    await setNextTs(now);
    await (await zk.connect(owner).setAuthorizedRoot(vKey, rootB)).wait();

    now = (await latestTs()) + 50n;
    const pvOld = encodePV(CHAIN, mallory.address, clearedTs + 5n, rootA); // newer ts, OLD root
    await (await verifier.setValid(markerOf(vKey, pvOld, "0x03"), true)).wait();
    await setNextTs(now);
    await expect(zk.connect(mallory).submitProof(vKey, pvOld, "0x03"))
      .to.be.revertedWithCustomError(zk, "UnauthorizedRoot");
    expect(await zk.clearedAt(mallory.address, vKey)).to.equal(clearedTs); // unchanged

    now = (await latestTs()) + 50n;
    const pvNew = encodePV(CHAIN, mallory.address, now - 4n, rootB);
    await (await verifier.setValid(markerOf(vKey, pvNew, "0x04"), true)).wait();
    await setNextTs(now);
    await (await zk.connect(mallory).submitProof(vKey, pvNew, "0x04")).wait();
    expect(await zk.clearedAt(mallory.address, vKey)).to.equal(now - 4n);
  });

  // ---------------------------------------------------------------------------
  // Deterministic guard 3: subject binding + anti-replay + revoke.
  it("subject + anti-replay + revoke: cannot re-target subject, cannot downgrade, revoke needs fresher proof", async function () {
    const [owner, alice, bob] = signers;
    const { verifier, zk } = await deployStack();
    const vKey = ethers.id("subject-bind-prog");
    const root = ethers.id("auth-root-subj");
    await (await zk.registerProgram(vKey, 0, root, "subject bind")).wait();

    let now = (await latestTs()) + 50n;
    const aliceTs = now - 10n;

    // Alice clears.
    const pvA = encodePV(CHAIN, alice.address, aliceTs, root);
    await (await verifier.setValid(markerOf(vKey, pvA, "0xa1"), true)).wait();
    await setNextTs(now);
    await (await zk.connect(alice).submitProof(vKey, pvA, "0xa1")).wait();
    expect(await zk.clearedAt(alice.address, vKey)).to.equal(aliceTs);

    // Bob replays Alice's exact calldata (proof genuine, subject == alice).
    await expect(zk.connect(bob).submitProof(vKey, pvA, "0xa1"))
      .to.be.revertedWithCustomError(zk, "UserMismatch");
    expect(await zk.clearedAt(bob.address, vKey)).to.equal(0n);

    // Alice replays her own proof -> StaleProof (attestedAt == current clearance).
    await expect(zk.connect(alice).submitProof(vKey, pvA, "0xa1"))
      .to.be.revertedWithCustomError(zk, "StaleProof");

    // An OLDER genuine proof also cannot downgrade her fresher clearance.
    const pvOlder = encodePV(CHAIN, alice.address, aliceTs - 40n, root);
    await (await verifier.setValid(markerOf(vKey, pvOlder, "0xa2"), true)).wait();
    await expect(zk.connect(alice).submitProof(vKey, pvOlder, "0xa2"))
      .to.be.revertedWithCustomError(zk, "StaleProof");
    expect(await zk.clearedAt(alice.address, vKey)).to.equal(aliceTs);

    // Foundation revokes Alice. isCleared flips false even though clearedAt>0.
    now = (await latestTs()) + 50n;
    await setNextTs(now);
    await (await zk.connect(owner).revoke(alice.address, vKey)).wait();
    const revTs = now;
    expect(await zk.revokedAt(alice.address, vKey)).to.equal(revTs);
    expect(await zk.isCleared(alice.address, vKey, 0)).to.equal(false);

    // A fresh proof with attestedAt STRICTLY after the revoke re-clears her.
    now = (await latestTs()) + 50n;
    const freshTs = revTs + 5n;
    const pvFresh = encodePV(CHAIN, alice.address, freshTs, root);
    await (await verifier.setValid(markerOf(vKey, pvFresh, "0xa3"), true)).wait();
    await setNextTs(now);
    await (await zk.connect(alice).submitProof(vKey, pvFresh, "0xa3")).wait();
    expect(await zk.clearedAt(alice.address, vKey)).to.equal(freshTs);
    expect(await zk.isCleared(alice.address, vKey, 0)).to.equal(true); // freshTs > revTs
  });
});
