aere-contracts/test/compliance-pool-v2-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

556 lines
30 KiB
JavaScript

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