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