aere-contracts/test/zkscreen-soundness-invariant-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

574 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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