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