// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT test for AereCompliancePoolV2 // (CANONICAL compliant privacy pool, live 0xB144c923572E5Ac1B6B961C4ccfec36917173465). // // This is a defense-in-depth fuzz of the SECURITY-CRITICAL "no theft / no // double-spend / conservation" invariants of the withdraw path. The membership // / inclusion proof is mocked via MockPrivacyPoolVerifier (a real SP1/Groth16 // circuit is wired in production); toggling the mock lets us prove that a // withdrawal is IMPOSSIBLE without a valid proof. // // INVARIANTS ENFORCED AFTER EVERY STEP (these are the point): // V1 PROOF-GATED (no theft without a proof): a withdraw with the verifier // returning false ALWAYS reverts InvalidProof and moves ZERO funds. A // withdraw only ever succeeds when accept==true AND every gate passes. // V2 NO-DOUBLE-SPEND: a nullifier can never be spent twice. A second // withdraw reusing a spent nullifier reverts AlreadySpent (checked before // the verifier, so even accept==true cannot double-withdraw). spent // nullifiers are monotone (once true, forever true). // V3 CONSERVATION: pool TOKEN balance == deposits*DENOM - withdrawnTotal // + bondsLocked, at all times. The deposit-backed portion // (balance - bondsLocked) == deposits*DENOM - withdrawnTotal >= 0. // V4 SINGLE-NOTE CAP: every completed withdraw removes EXACTLY one // DENOMINATION from the pool (toRecipient + fee + refund == DENOMINATION), // distributed to (recipient, feeRecipient, msg.sender). fee+refund can // never exceed a note (FeeTooLarge). Total notes withdrawn <= notes // deposited. // V5 DENOMINATION is immutable / fixed across the whole run. // V6 NO-ADMIN-DRAIN: no Foundation-only path reduces the deposit-backed // balance. After every Foundation action the deposit-backed ledger holds. // V7 GATES: withdraw requires a PUBLISHED association root (UnknownAssociationRoot // otherwise) and a KNOWN deposit root (UnknownDepositRoot otherwise). // V8 HONEST-BOND (V2's F8 fix): a DISMISSED challenge burns the bond to dEaD // and does NOT refund the challenger; an UPHELD challenge (window elapsed) // refunds the challenger EXACTLY the bond via reclaimUpheldChallenge (the // honest challenger is never robbed). // // TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (Foundry `forge` // is not installed; the repo builds through hardhat + OZ 4.9.6 + viaIR). Every // random choice comes from mulberry32(seed); the base seed is printed and can be // pinned with FUZZ_SEED. On any break, the failing (campaign, step, action, // actor, values) is embedded in the assertion message for a minimal repro. // // SCOPE: run this file IN ISOLATION (the full suite OOM/segfaults on unrelated // ML-DSA PQC KAT tests): // npx hardhat test test/compliance-pool-v2-invariant-property.test.js // TEST-ONLY: one new file, no contract logic changed, nothing deployed to any // live node. // ============================================================================= "use strict"; const { expect } = require("chai"); const { ethers } = 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) : 0xC0119002; 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; /* ------------------------------- constants -------------------------------- */ const DENOM = ethers.parseUnits("100", 18); // 1 note = 100 tokens const BOND = ethers.parseEther("100"); // CHALLENGE_BOND const DEAD = "0x000000000000000000000000000000000000dEaD"; const MAX_DISMISSALS = 2; const WINDOW = 3600; // ASSOCIATION_ROOT_CHALLENGE_WINDOW (short, twin-style) const N = { CAMPAIGNS: Number(process.env.CPV2_CAMPAIGNS || 6), STEPS: Number(process.env.CPV2_STEPS || 240), }; async function nowTs() { return (await ethers.provider.getBlock("latest")).timestamp; } async function advance(dt) { await ethers.provider.send("evm_increaseTime", [dt]); await ethers.provider.send("evm_mine", []); } describe("INVARIANT: AereCompliancePoolV2 no-theft / no-double-spend / conservation", function () { this.timeout(0); let signers, foundation; let poolF, tokenF, verifierF; before(async function () { signers = await ethers.getSigners(); if (signers.length < 20) throw new Error("need >= 20 hardhat accounts"); foundation = signers[0]; tokenF = await ethers.getContractFactory("MockERC20Lending"); verifierF = await ethers.getContractFactory("MockPrivacyPoolVerifier"); poolF = await ethers.getContractFactory("AereCompliancePoolV2"); console.log( ` [cpv2] base seed 0x${BASE_SEED.toString(16)}, ${N.CAMPAIGNS} campaigns x ${N.STEPS} steps, window=${WINDOW}s` ); }); it("drives hundreds of randomized withdraw/deposit/challenge sequences; all security invariants hold", async function () { // Actor sets (disjoint so token-balance deltas stay exact). const depositors = signers.slice(1, 7); // 6 const casps = [signers[7], signers[8]]; // 2 registered compliance providers const challengers = [signers[9], signers[10], signers[11]]; // 3 const recipients = [signers[12], signers[13], signers[14], signers[15]]; // withdraw recipients / feeRecipients const callers = [signers[16], signers[17], signers[18], signers[19]]; // withdraw callers (get refund) // Aggregate counters across all campaigns (for final coverage asserts). let totalSteps = 0; let okWithdrawals = 0; let proofBlocked = 0; let doubleSpendBlocked = 0; let unpubBlocked = 0; let unknownRootBlocked = 0; let feeTooLargeBlocked = 0; let dismissals = 0; let reclaims = 0; let ledgerChecks = 0; for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("cpv2", c); // Fresh deploy per campaign. const token = await tokenF.deploy("WAERE", "WAERE", 18); await token.waitForDeployment(); const tokenAddr = await token.getAddress(); const verifier = await verifierF.deploy(); await verifier.waitForDeployment(); const pool = await poolF.deploy(tokenAddr, DENOM, foundation.address, await verifier.getAddress(), WINDOW); await pool.waitForDeployment(); const poolAddr = await pool.getAddress(); // Sanity on immutables. expect(await pool.DENOMINATION(), `[cpv2 c${c}] denom set`).to.equal(DENOM); const denomImmutable = await pool.DENOMINATION(); // Fund + approve depositors and challengers with plenty of headroom. const fat = DENOM * 400n + BOND * 100n; for (const a of [...depositors, ...challengers]) { await token.mint(a.address, fat); await token.connect(a).approve(poolAddr, ethers.MaxUint256); } for (const cp of casps) { await pool.connect(foundation).setComplianceProvider(cp.address, true); } /* --------------------------- JS shadow model -------------------------- */ let depositCount = 0n; let withdrawnTotal = 0n; // sum of DENOM per completed withdraw let bondsLocked = 0n; // BOND per active (open) challenge held in pool let bondsBurned = 0n; // BOND per dismissal, sent to dEaD let acceptState = true; // mirrors verifier.accept const depositRoots = []; // every newRoot from a deposit (all stay known; << 256) const spentNullifiers = new Set(); // nullifierHashes known-spent let usedSalt = 0; // association roots reserved for the WITHDRAW pipeline (propose -> wait -> publish) const pubRoots = []; // published, withdrawable // model of every proposed root for the publish pipeline const proposeModel = new Map(); // root -> { proposedAt } // model of challenge-lifecycle roots (separate, for bond testing) const chalModel = new Map(); // root -> { state, proposedAt, challenger, challengedAt, dismissCount } const randBytes32 = (tag) => ethers.keccak256(ethers.toUtf8Bytes(`${tag}:${c}:${usedSalt++}`)); // Expected token balances of challengers (to prove bond no-theft precisely). const chalBal = {}; for (const ch of challengers) chalBal[ch.address] = await token.balanceOf(ch.address); async function assertLedger(tag) { // V3 CONSERVATION const bal = await token.balanceOf(poolAddr); const expected = depositCount * DENOM - withdrawnTotal + bondsLocked; expect(bal, `[cpv2 V3 CONSERVATION] pool balance != deposits-withdrawn+bonds | c${c} after ${tag}`).to.equal( expected ); const depositBacked = bal - bondsLocked; expect(depositBacked, `[cpv2 V3 NEG] deposit-backed balance underflow | c${c} after ${tag}`).to.equal( depositCount * DENOM - withdrawnTotal ); expect(depositBacked >= 0n, `[cpv2 V3 NEG] deposit-backed < 0 | c${c} after ${tag}`).to.equal(true); // V4 single-note cap on the aggregate: notes out <= notes in expect(withdrawnTotal <= depositCount * DENOM, `[cpv2 V4 CAP] withdrawn>deposited | c${c} after ${tag}`).to.equal( true ); // burned bonds live at dEaD, never in the pool expect(await token.balanceOf(DEAD), `[cpv2 V8 BURN] dEaD balance != bondsBurned | c${c} after ${tag}`).to.equal( bondsBurned ); // V5 DENOMINATION fixed expect(await pool.DENOMINATION(), `[cpv2 V5 DENOM] denomination drifted | c${c} after ${tag}`).to.equal( denomImmutable ); // V2 monotone spent-nullifiers (sample to bound cost) const sample = [...spentNullifiers]; for (let i = 0; i < Math.min(sample.length, 6); i++) { const nh = sample[ri(rng, 0, sample.length - 1)]; expect(await pool.spentNullifiers(nh), `[cpv2 V2 MONO] spent nullifier flipped false | c${c} after ${tag}`).to.equal( true ); } ledgerChecks++; } // Pick a fresh withdraw triple with distinct addresses so deltas are exact. function withdrawParties() { const recipient = pick(rng, recipients); let feeRecipient = pick(rng, recipients); let guard = 0; while (feeRecipient.address === recipient.address && guard++ < 8) feeRecipient = pick(rng, recipients); let caller = pick(rng, callers); return { recipient, feeRecipient, caller }; } await assertLedger("deploy+setup"); for (let s = 0; s < N.STEPS; s++) { // Weighted actions. Early steps favor deposit + propose so withdrawals // have material to work with. const early = s < N.STEPS * 0.25; const menu = early ? ["deposit", "deposit", "deposit", "proposePub", "advance", "publish", "toggleVerifier"] : [ "deposit", "withdrawValid", "withdrawValid", "withdrawNoProof", "withdrawDouble", "withdrawUnpub", "withdrawBadRoot", "withdrawFee", "proposePub", "publish", "advance", "toggleVerifier", "proposeChal", "challenge", "dismiss", "reclaim", ]; const action = pick(rng, menu); try { if (action === "deposit") { const sender = pick(rng, depositors); const casp = pick(rng, casps); const commitment = randBytes32("commit"); const leaf = ethers.keccak256(ethers.solidityPacked(["bytes32", "address"], [commitment, sender.address])); const tx = await pool.connect(sender).deposit(commitment, randBytes32("trh"), casp.address, "ipfs://tr"); const rc = await tx.wait(); const ev = rc.logs.map((l) => { try { return pool.interface.parseLog(l); } catch { return null; } }).find((p) => p && p.name === "Deposited"); expect(ev, `[cpv2 c${c} s${s}] no Deposited event`).to.not.equal(undefined); expect(ev.args.leafIndex, `[cpv2 c${c} s${s}] leafIndex != depositCount`).to.equal(depositCount); depositCount += 1n; depositRoots.push(ev.args.newRoot); } else if (action === "toggleVerifier") { acceptState = chance(rng, 0.5); await (await verifier.setAccept(acceptState)).wait(); } else if (action === "proposePub") { const root = randBytes32("assocpub"); if (!proposeModel.has(root) && !pubRoots.includes(root)) { await (await pool.connect(foundation).proposeAssociationRoot(root)).wait(); proposeModel.set(root, { proposedAt: await nowTs() }); } } else if (action === "publish") { // find a proposed root whose window has elapsed (with margin vs the +1 auto-mine) const t = await nowTs(); let target = null; for (const [root, m] of proposeModel) { if (t >= m.proposedAt + WINDOW) { target = root; break; } } if (target) { await (await pool.publishAssociationRoot(target)).wait(); expect(await pool.isAssociationRootValid(target), `[cpv2 c${c} s${s}] publish did not mark valid`).to.equal(true); proposeModel.delete(target); pubRoots.push(target); } } else if (action === "advance") { await advance(ri(rng, WINDOW / 2, WINDOW * 2)); } else if (action === "withdrawValid") { // Only when there is an unspent note (withdrawn notes < deposited notes) const notesOut = withdrawnTotal / DENOM; if (pubRoots.length === 0 || depositRoots.length === 0 || notesOut >= depositCount) continue; const assoc = pick(rng, pubRoots); const depRoot = pick(rng, depositRoots); const nh = randBytes32("nh"); if (spentNullifiers.has(nh)) continue; const { recipient, feeRecipient, caller } = withdrawParties(); const fee = chance(rng, 0.5) ? BigInt(ri(rng, 0, 25)) * ethers.parseEther("1") : 0n; const refund = chance(rng, 0.5) ? BigInt(ri(rng, 0, 25)) * ethers.parseEther("1") : 0n; if (fee + refund > DENOM) continue; // keep this path a genuine "valid" case const toRecipient = DENOM - fee - refund; if (!acceptState) { // V1: without a valid proof the withdraw MUST revert and move nothing. const balBefore = await token.balanceOf(poolAddr); await expect( pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, fee, refund), `[cpv2 V1 PROOF] no-proof withdraw did not revert | c${c} s${s}` ).to.be.revertedWithCustomError(pool, "InvalidProof"); expect(await token.balanceOf(poolAddr), `[cpv2 V1 PROOF] balance moved on failed proof | c${c} s${s}`).to.equal(balBefore); expect(await pool.spentNullifiers(nh), `[cpv2 V1 PROOF] nullifier spent on failed proof | c${c} s${s}`).to.equal(false); proofBlocked++; } else { const rBefore = await token.balanceOf(recipient.address); const fBefore = await token.balanceOf(feeRecipient.address); const cBefore = await token.balanceOf(caller.address); const poolBefore = await token.balanceOf(poolAddr); // recipient / feeRecipient / caller might collide; capture per-address deltas by re-reading. await (await pool .connect(caller) .withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, fee, refund)).wait(); const poolAfter = await token.balanceOf(poolAddr); // V4: exactly one note leaves the pool. expect(poolBefore - poolAfter, `[cpv2 V4 NOTE] pool delta != DENOM | c${c} s${s} fee=${fee} refund=${refund}`).to.equal(DENOM); // Distribution correctness (handle possible address collisions by expected-sum per address). const exp = {}; const add = (addr, v) => { exp[addr] = (exp[addr] || 0n) + v; }; add(recipient.address, toRecipient); add(feeRecipient.address, fee); add(caller.address, refund); const before = { [recipient.address]: rBefore, [feeRecipient.address]: fBefore, [caller.address]: cBefore }; for (const addr of Object.keys(exp)) { const after = await token.balanceOf(addr); expect(after - before[addr], `[cpv2 V4 DIST] payout to ${addr} wrong | c${c} s${s} fee=${fee} refund=${refund}`).to.equal(exp[addr]); } spentNullifiers.add(nh); withdrawnTotal += DENOM; okWithdrawals++; } } else if (action === "withdrawDouble") { // V2: reuse a spent nullifier -> AlreadySpent (before verifier, even if accept==true) if (spentNullifiers.size === 0 || pubRoots.length === 0 || depositRoots.length === 0) continue; const nh = pick(rng, [...spentNullifiers]); const assoc = pick(rng, pubRoots); const depRoot = pick(rng, depositRoots); const { recipient, feeRecipient, caller } = withdrawParties(); const balBefore = await token.balanceOf(poolAddr); await expect( pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n), `[cpv2 V2 DOUBLE] double-spend did not revert | c${c} s${s} nh=${nh}` ).to.be.revertedWithCustomError(pool, "AlreadySpent"); expect(await token.balanceOf(poolAddr), `[cpv2 V2 DOUBLE] balance moved on double-spend | c${c} s${s}`).to.equal(balBefore); doubleSpendBlocked++; } else if (action === "withdrawUnpub") { // V7: association root not published -> UnknownAssociationRoot if (depositRoots.length === 0) continue; const nh = randBytes32("nh"); if (spentNullifiers.has(nh)) continue; // use a proposed-but-unpublished root if available, else a random unknown root const proposedOnly = [...proposeModel.keys()]; const assoc = proposedOnly.length > 0 && chance(rng, 0.6) ? pick(rng, proposedOnly) : randBytes32("assocX"); const depRoot = pick(rng, depositRoots); const { recipient, feeRecipient, caller } = withdrawParties(); const balBefore = await token.balanceOf(poolAddr); await expect( pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n), `[cpv2 V7 UNPUB] unpublished-assoc withdraw did not revert | c${c} s${s}` ).to.be.revertedWithCustomError(pool, "UnknownAssociationRoot"); expect(await token.balanceOf(poolAddr), `[cpv2 V7 UNPUB] balance moved | c${c} s${s}`).to.equal(balBefore); expect(await pool.spentNullifiers(nh), `[cpv2 V7 UNPUB] nullifier spent | c${c} s${s}`).to.equal(false); unpubBlocked++; } else if (action === "withdrawBadRoot") { // V7: deposit root unknown -> UnknownDepositRoot (needs a PUBLISHED assoc so we pass gate 2) if (pubRoots.length === 0) continue; const nh = randBytes32("nh"); if (spentNullifiers.has(nh)) continue; const assoc = pick(rng, pubRoots); const depRoot = randBytes32("fakeDepRoot"); // never inserted const { recipient, feeRecipient, caller } = withdrawParties(); const balBefore = await token.balanceOf(poolAddr); await expect( pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n), `[cpv2 V7 BADROOT] unknown-deposit-root withdraw did not revert | c${c} s${s}` ).to.be.revertedWithCustomError(pool, "UnknownDepositRoot"); expect(await token.balanceOf(poolAddr), `[cpv2 V7 BADROOT] balance moved | c${c} s${s}`).to.equal(balBefore); expect(await pool.spentNullifiers(nh), `[cpv2 V7 BADROOT] nullifier spent | c${c} s${s}`).to.equal(false); unknownRootBlocked++; } else if (action === "withdrawFee") { // V4: fee + refund > DENOMINATION -> FeeTooLarge (can't exceed a single note) if (pubRoots.length === 0 || depositRoots.length === 0) continue; const nh = randBytes32("nh"); if (spentNullifiers.has(nh)) continue; const assoc = pick(rng, pubRoots); const depRoot = pick(rng, depositRoots); const { recipient, feeRecipient, caller } = withdrawParties(); // Construct fee+refund STRICTLY > DENOM: fee = DENOM - k, refund = k + j (j>=1) const k = BigInt(ri(rng, 0, 10)) * ethers.parseEther("1"); const j = BigInt(ri(rng, 1, 50)) * ethers.parseEther("1"); const fee = DENOM - k; const refund = k + j; // fee + refund = DENOM + j > DENOM const balBefore = await token.balanceOf(poolAddr); await expect( pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, fee, refund), `[cpv2 V4 FEE] over-note fee+refund did not revert | c${c} s${s} fee=${fee} refund=${refund}` ).to.be.revertedWithCustomError(pool, "FeeTooLarge"); expect(await token.balanceOf(poolAddr), `[cpv2 V4 FEE] balance moved | c${c} s${s}`).to.equal(balBefore); feeTooLargeBlocked++; } else if (action === "withdrawNoProof") { // V1 (direct): with the verifier rejecting, a fully-valid-looking withdraw // MUST fail. Force accept=false for this probe, then restore. if (pubRoots.length === 0 || depositRoots.length === 0) continue; const prev = acceptState; if (acceptState) { await (await verifier.setAccept(false)).wait(); acceptState = false; } const nh = randBytes32("nh"); if (spentNullifiers.has(nh)) { if (prev) { await (await verifier.setAccept(true)).wait(); acceptState = true; } continue; } const assoc = pick(rng, pubRoots); const depRoot = pick(rng, depositRoots); const { recipient, feeRecipient, caller } = withdrawParties(); const balBefore = await token.balanceOf(poolAddr); await expect( pool.connect(caller).withdraw("0x", depRoot, assoc, nh, recipient.address, feeRecipient.address, 0n, 0n), `[cpv2 V1 NOPROOF] rejected-proof withdraw did not revert | c${c} s${s}` ).to.be.revertedWithCustomError(pool, "InvalidProof"); expect(await token.balanceOf(poolAddr), `[cpv2 V1 NOPROOF] balance moved | c${c} s${s}`).to.equal(balBefore); expect(await pool.spentNullifiers(nh), `[cpv2 V1 NOPROOF] nullifier spent | c${c} s${s}`).to.equal(false); proofBlocked++; if (prev) { await (await verifier.setAccept(true)).wait(); acceptState = true; } } else if (action === "proposeChal") { const root = randBytes32("assocchal"); if (!chalModel.has(root)) { await (await pool.connect(foundation).proposeAssociationRoot(root)).wait(); chalModel.set(root, { state: "proposed", proposedAt: await nowTs(), challenger: null, challengedAt: 0, dismissCount: 0 }); } } else if (action === "challenge") { const candidates = [...chalModel.entries()].filter(([, m]) => m.state === "proposed"); if (candidates.length === 0) continue; const [root, m] = pick(rng, candidates); const ch = pick(rng, challengers); if (m.dismissCount >= MAX_DISMISSALS) { await expect( pool.connect(ch).challengeAssociationRoot(root, "maxed"), `[cpv2 c${c} s${s}] maxed challenge did not revert` ).to.be.revertedWithCustomError(pool, "MaxDismissalsReached"); continue; } const before = await token.balanceOf(ch.address); await (await pool.connect(ch).challengeAssociationRoot(root, "fuzz")).wait(); expect(before - (await token.balanceOf(ch.address)), `[cpv2 V8 BONDIN] challenger did not pay exactly BOND | c${c} s${s}`).to.equal(BOND); m.state = "challenged"; m.challenger = ch.address; m.challengedAt = await nowTs(); bondsLocked += BOND; } else if (action === "dismiss") { // Dismiss must be WITHIN the window of the challenge. Only pick ones // safely inside (margin vs the +1 auto-mine). const t = await nowTs(); const candidates = [...chalModel.entries()].filter( ([, m]) => m.state === "challenged" && t + 2 < m.challengedAt + WINDOW ); if (candidates.length === 0) continue; const [root, m] = pick(rng, candidates); const challenger = m.challenger; const chBefore = await token.balanceOf(challenger); const deadBefore = await token.balanceOf(DEAD); await (await pool.connect(foundation).dismissAssociationRootChallenge(root)).wait(); // V8: bond BURNED to dEaD, challenger NOT refunded. expect((await token.balanceOf(DEAD)) - deadBefore, `[cpv2 V8 DISMISS-BURN] dEaD did not gain BOND | c${c} s${s}`).to.equal(BOND); expect(await token.balanceOf(challenger), `[cpv2 V8 DISMISS-NOREFUND] challenger refunded on dismissal | c${c} s${s}`).to.equal(chBefore); bondsLocked -= BOND; bondsBurned += BOND; m.state = "proposed"; m.dismissCount += 1; m.proposedAt = await nowTs(); m.challenger = null; m.challengedAt = 0; dismissals++; } else if (action === "reclaim") { // Reclaim requires the dismissal window to have ELAPSED with the // challenge still standing. const t = await nowTs(); const candidates = [...chalModel.entries()].filter( ([, m]) => m.state === "challenged" && t >= m.challengedAt + WINDOW ); if (candidates.length === 0) continue; const [root, m] = pick(rng, candidates); const challenger = m.challenger; const chBefore = await token.balanceOf(challenger); // anyone can finalise; use a random caller await (await pool.connect(pick(rng, callers)).reclaimUpheldChallenge(root)).wait(); // V8: honest challenger refunded EXACTLY the bond. expect((await token.balanceOf(challenger)) - chBefore, `[cpv2 V8 RECLAIM-REFUND] challenger not made whole | c${c} s${s}`).to.equal(BOND); bondsLocked -= BOND; m.state = "rejected"; m.challenger = null; reclaims++; } } catch (e) { throw new Error(`[cpv2] action ${action} @ c${c} s${s}: ${e.message}`); } await assertLedger(`${action}#${s}`); totalSteps++; } // ---- Campaign finale: NO-ADMIN-DRAIN directed probe (V6) -------------- // The Foundation is the only privileged actor. Prove that after exercising // EVERY Foundation-only entrypoint, the deposit-backed balance is untouched // and none of them transfers user deposits to the Foundation. { const backedBefore = (await token.balanceOf(poolAddr)) - bondsLocked; const foundationBalBefore = await token.balanceOf(foundation.address); // setComplianceProvider (toggle a fresh provider on then off) const probe = recipients[0].address; await (await pool.connect(foundation).setComplianceProvider(probe, true)).wait(); await (await pool.connect(foundation).setComplianceProvider(probe, false)).wait(); // proposeAssociationRoot (a fresh root) — moves no funds await (await pool.connect(foundation).proposeAssociationRoot(randBytes32("adminProbe"))).wait(); const backedAfter = (await token.balanceOf(poolAddr)) - bondsLocked; expect(backedAfter, `[cpv2 V6 NO-DRAIN] Foundation action changed deposit-backed balance | c${c}`).to.equal(backedBefore); expect(await token.balanceOf(foundation.address), `[cpv2 V6 NO-DRAIN] Foundation received user funds | c${c}`).to.equal( foundationBalBefore ); // No non-Foundation caller can invoke a Foundation-gated fn. await expect( pool.connect(pick(rng, depositors)).setComplianceProvider(probe, true), `[cpv2 V6 GATE] non-foundation setComplianceProvider not gated | c${c}` ).to.be.revertedWithCustomError(pool, "NotFoundation"); await expect( pool.connect(pick(rng, depositors)).proposeAssociationRoot(randBytes32("hijack")), `[cpv2 V6 GATE] non-foundation propose not gated | c${c}` ).to.be.revertedWithCustomError(pool, "NotFoundation"); } await assertLedger("finale"); } console.log( ` [cpv2] steps=${totalSteps} okWithdrawals=${okWithdrawals} proofBlocked=${proofBlocked} ` + `doubleSpendBlocked=${doubleSpendBlocked} unpubBlocked=${unpubBlocked} unknownRootBlocked=${unknownRootBlocked} ` + `feeTooLarge=${feeTooLargeBlocked} dismissals=${dismissals} reclaims=${reclaims} ledgerChecks=${ledgerChecks}` ); // Coverage floors — prove the security-critical paths were actually driven. expect(totalSteps, "steps floor").to.be.greaterThan(1000); expect(okWithdrawals, "successful withdrawals floor").to.be.greaterThan(30); expect(proofBlocked, "proof-gated blocks floor").to.be.greaterThan(20); expect(doubleSpendBlocked, "double-spend blocks floor").to.be.greaterThan(10); expect(unpubBlocked + unknownRootBlocked, "gate blocks floor").to.be.greaterThan(10); expect(feeTooLargeBlocked, "fee-too-large blocks floor").to.be.greaterThan(5); expect(dismissals + reclaims, "bond lifecycle floor").to.be.greaterThan(5); }); });