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.
574 lines
30 KiB
JavaScript
574 lines
30 KiB
JavaScript
// =============================================================================
|
||
// 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
|
||
});
|
||
});
|