// AereCompliancePool — property-based fuzz test // 1500 randomized actions across 10 depositors + 4 CASPs + 5 challengers. // Deterministic LCG seed so failures reproduce exactly. // // Invariants enforced after EVERY action: // I1. TOKEN.balanceOf(pool) == // depositCount*DENOM - withdrawnTotal - bondsBurned + bondsLocked // I2. nextLeafIndex == depositCount // I3. spentNullifiers monotone non-decreasing (once true, stays true) // I4. isKnownRoot(root) == true for every root recorded within the last // ROOT_HISTORY_SIZE deposits // I5. Each Deposited event leaf == keccak256(commitment, sender) and the // contract-emitted newRoot matches an off-chain mirror tree // I6. Challenge bond accounting: bondsLocked == (active challenges × BOND); // bondsBurned increments by exactly BOND on every dismissal // I7. associationRootDismissCount[r] <= MAX_DISMISSALS_PER_ROOT and a // challenge on a maxed-out root reverts with MaxDismissalsReached // // CommonJS, ethers v6. "use strict"; const { expect } = require("chai"); const { ethers } = require("hardhat"); // ---------- deterministic RNG (Mulberry32) ------------------------------- function mulberry32(seed) { let s = seed >>> 0; return function () { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ---------- off-chain Merkle mirror (matches MerkleTreeWithHistory) ----- const TREE_DEPTH = 20; const ROOT_HISTORY_SIZE = 256; const ZERO_VALUE = ethers.keccak256( ethers.toUtf8Bytes("aere.compliance-pool.v1.empty-leaf") ); function buildMirror() { const zeros = new Array(TREE_DEPTH); const filled = new Array(TREE_DEPTH); let z = ZERO_VALUE; for (let i = 0; i < TREE_DEPTH; i++) { zeros[i] = z; filled[i] = z; z = ethers.keccak256(ethers.concat([zeros[i], zeros[i]])); } return { zeros, filled, nextLeafIndex: 0, rootHistory: new Array(ROOT_HISTORY_SIZE).fill(ethers.ZeroHash), currentRootIndex: 0, knownRoots: new Set(), // for fast lookup; reflects rolling window rootOrder: [], // chronological list of roots inserted }; } function mirrorInsert(m, leaf) { const idx = m.nextLeafIndex; m.nextLeafIndex = idx + 1; let cur = idx; let node = leaf; for (let i = 0; i < TREE_DEPTH; i++) { let left, right; if ((cur & 1) === 0) { left = node; right = m.zeros[i]; m.filled[i] = node; } else { left = m.filled[i]; right = node; } node = ethers.keccak256(ethers.concat([left, right])); cur >>= 1; } const newRootIdx = (m.currentRootIndex + 1) % ROOT_HISTORY_SIZE; m.currentRootIndex = newRootIdx; // evict const evicted = m.rootHistory[newRootIdx]; if (evicted !== ethers.ZeroHash) m.knownRoots.delete(evicted); m.rootHistory[newRootIdx] = node; m.knownRoots.add(node); m.rootOrder.push(node); return { idx, newRoot: node }; } // ---------- main test --------------------------------------------------- describe("AereCompliancePool — fuzz (1500 actions)", function () { this.timeout(600000); const SEED = 0xA37E0001; const DENOM = ethers.parseUnits("100", 18); const BOND = ethers.parseEther("100"); const BURN = "0x000000000000000000000000000000000000dEaD"; const MAX_DISMISSALS = 2; const CHALLENGE_WINDOW = 24 * 3600; let rng; let foundation, depositors, casps, challengers; let token, verifier, pool, poolAddr, tokenAddr; let mirror; // off-chain tracking let depositCount = 0n; let withdrawnTotal = 0n; let bondsLockedExpected = 0n; let bondsBurnedExpected = 0n; let usedNullifiers = new Set(); let prevSpentSnapshot = new Map(); // nullifierHash -> true // association root lifecycle: // "proposed" — proposed, not challenged, not published, window not elapsed // "challenged" — challenge active, awaiting dismissal // "publishable" — window elapsed, no active challenge // "published" — final // and dismissCount tracked. let assocRoots = new Map(); // root -> { state, dismissCount, proposedAt, challenger } // recorded deposit roots in order (used to pick known/unknown roots) let depositRootHistory = []; before(async () => { rng = mulberry32(SEED); const signers = await ethers.getSigners(); // 1 foundation + 10 depositors + 4 casps + 5 challengers = 20 signers if (signers.length < 20) throw new Error("need >=20 hardhat accounts"); foundation = signers[0]; depositors = signers.slice(1, 11); casps = signers.slice(11, 15); challengers = signers.slice(15, 20); const T = await ethers.getContractFactory("MockERC20Lending"); token = await T.deploy("USDC.e", "USDCe", 18); await token.waitForDeployment(); tokenAddr = await token.getAddress(); const V = await ethers.getContractFactory("MockPrivacyPoolVerifier"); verifier = await V.deploy(); await verifier.waitForDeployment(); const P = await ethers.getContractFactory("AereCompliancePool"); pool = await P.deploy(tokenAddr, DENOM, foundation.address, await verifier.getAddress()); await pool.waitForDeployment(); poolAddr = await pool.getAddress(); // fund + approve depositors and challengers const fat = DENOM * 1000n + BOND * 200n; for (const a of [...depositors, ...challengers]) { await token.mint(a.address, fat); await token.connect(a).approve(poolAddr, ethers.MaxUint256); } // register all 4 CASPs for (const c of casps) { await pool.connect(foundation).setComplianceProvider(c.address, true); } mirror = buildMirror(); }); // ---------- helpers -------------------------------------------------- function pick(arr) { return arr[Math.floor(rng() * arr.length)]; } function pickInt(n) { return Math.floor(rng() * n); } function randBytes32(tag) { return ethers.keccak256( ethers.toUtf8Bytes(`${tag}:${pickInt(1 << 30)}:${pickInt(1 << 30)}`) ); } function knownDepositRoot() { // grab a root from the last ROOT_HISTORY_SIZE-1 inserts (to leave headroom) if (depositRootHistory.length === 0) return null; const startWindow = Math.max(0, depositRootHistory.length - (ROOT_HISTORY_SIZE - 1)); const i = startWindow + pickInt(depositRootHistory.length - startWindow); return depositRootHistory[i]; } function pickAssocByState(state) { const matches = []; for (const [r, info] of assocRoots) if (info.state === state) matches.push(r); if (matches.length === 0) return null; return matches[pickInt(matches.length)]; } // ---------- actions -------------------------------------------------- async function actDeposit() { const sender = pick(depositors); const casp = pick(casps); const commitment = randBytes32("commit"); const trh = randBytes32("trh"); const expectedLeaf = ethers.keccak256( ethers.solidityPacked(["bytes32", "address"], [commitment, sender.address]) ); const tx = await pool .connect(sender) .deposit(commitment, trh, casp.address, "ipfs://tr"); const rc = await tx.wait(); // parse Deposited event const ev = rc.logs.find((l) => { try { return pool.interface.parseLog(l)?.name === "Deposited"; } catch { return false; } }); const parsed = pool.interface.parseLog(ev); const emittedLeafIndex = Number(parsed.args.leafIndex); const emittedRoot = parsed.args.newRoot; // mirror insert const { idx, newRoot } = mirrorInsert(mirror, expectedLeaf); expect(emittedLeafIndex).to.equal(idx); expect(emittedRoot).to.equal(newRoot); depositCount += 1n; depositRootHistory.push(newRoot); } async function actPropose() { const root = randBytes32("assoc"); if (assocRoots.has(root)) return; await pool.connect(foundation).proposeAssociationRoot(root); assocRoots.set(root, { state: "proposed", dismissCount: 0, proposedAt: await currentTime(), challenger: null, }); } async function actChallenge() { const root = pickAssocByState("proposed"); if (!root) return; const info = assocRoots.get(root); const ch = pick(challengers); if (info.dismissCount >= MAX_DISMISSALS) { await expect( pool.connect(ch).challengeAssociationRoot(root, "max-out") ).to.be.revertedWithCustomError(pool, "MaxDismissalsReached"); return; } await pool.connect(ch).challengeAssociationRoot(root, "fuzz-reason"); info.state = "challenged"; info.challenger = ch.address; bondsLockedExpected += BOND; } async function actDismiss() { const root = pickAssocByState("challenged"); if (!root) return; const info = assocRoots.get(root); await pool.connect(foundation).dismissAssociationRootChallenge(root); bondsLockedExpected -= BOND; bondsBurnedExpected += BOND; info.dismissCount += 1; info.challenger = null; info.state = "proposed"; // R6 CRITICAL fix: dismissAssociationRootChallenge now RESETS the // proposedAt timer so each dismissal genuinely costs 24h. Reflect that // in the model so actPublish correctly waits the new window. info.proposedAt = await currentTime(); } async function actPublish() { // need state proposed AND window elapsed AND not challenged let candidate = null; const now = await currentTime(); for (const [r, info] of assocRoots) { if (info.state === "proposed" && now >= info.proposedAt + CHALLENGE_WINDOW) { candidate = r; break; } } if (!candidate) return; await pool.publishAssociationRoot(candidate); assocRoots.get(candidate).state = "published"; } async function actWithdraw() { if (depositCount === 0n) return; const publishedRoot = pickAssocByState("published"); if (!publishedRoot) return; const depRoot = knownDepositRoot(); if (!depRoot) return; // unique nullifier let nh; do { nh = randBytes32("nh"); } while (usedNullifiers.has(nh)); usedNullifiers.add(nh); const recipient = pick(depositors).address; const feeRecipient = pick(depositors).address; const fee = 0n; const refund = 0n; await pool .connect(pick(depositors)) .withdraw("0x", depRoot, publishedRoot, nh, recipient, feeRecipient, fee, refund); withdrawnTotal += DENOM - fee - refund; prevSpentSnapshot.set(nh, true); } async function actSync() { // jump forward 1-3h const dt = 3600 + pickInt(2 * 3600); await ethers.provider.send("evm_increaseTime", [dt]); await ethers.provider.send("evm_mine", []); } async function currentTime() { const b = await ethers.provider.getBlock("latest"); return b.timestamp; } // ---------- invariants ---------------------------------------------- async function checkInvariants(stepLabel) { // I1: pool token balance const bal = await token.balanceOf(poolAddr); const expectedBal = depositCount * DENOM - withdrawnTotal + bondsLockedExpected; // bondsBurnedExpected was transferred OUT to dead address, so already not in pool. expect(bal, `I1 bal @ ${stepLabel}`).to.equal(expectedBal); // burn-sink balance reflects bondsBurnedExpected const burnBal = await token.balanceOf(BURN); expect(burnBal, `I1b burn @ ${stepLabel}`).to.equal(bondsBurnedExpected); // I2: leaf index == deposit count expect(await pool.nextLeafIndex(), `I2 @ ${stepLabel}`).to.equal( Number(depositCount) ); expect(await pool.depositCount(), `I2b @ ${stepLabel}`).to.equal(depositCount); // I3: spent nullifiers monotone — every previously-spent nullifier still spent for (const nh of prevSpentSnapshot.keys()) { expect(await pool.spentNullifiers(nh), `I3 @ ${stepLabel}`).to.equal(true); } // I7: dismiss count bounded for (const [r, info] of assocRoots) { expect(await pool.associationRootDismissCount(r), `I7 @ ${stepLabel}`).to.equal( info.dismissCount ); expect(info.dismissCount).to.be.at.most(MAX_DISMISSALS); } } async function checkKnownRootSample() { // I4: sample up to 8 roots within the last ROOT_HISTORY_SIZE window if (depositRootHistory.length === 0) return; const tail = depositRootHistory.slice(-ROOT_HISTORY_SIZE); const sampleSize = Math.min(8, tail.length); for (let i = 0; i < sampleSize; i++) { const r = tail[pickInt(tail.length)]; expect(await pool.isKnownRoot(r), "I4 known root").to.equal(true); } // also: a root we know is far older than the window (if we have one) // should NOT be known anymore. if (depositRootHistory.length > ROOT_HISTORY_SIZE + 4) { const stale = depositRootHistory[depositRootHistory.length - ROOT_HISTORY_SIZE - 2]; expect(await pool.isKnownRoot(stale), "I4b stale evicted").to.equal(false); } } it("survives 1500 randomized actions with all invariants intact", async () => { const N = 1500; // action weights — favor deposits and assoc-root lifecycle to keep the // system productive but exercise every code path. const weights = [ ["deposit", 35, actDeposit], ["propose", 14, actPropose], ["challenge", 12, actChallenge], ["dismiss", 8, actDismiss], ["publish", 10, actPublish], ["withdraw", 11, actWithdraw], ["sync", 10, actSync], ]; const totalW = weights.reduce((s, w) => s + w[1], 0); let invariantSpotChecks = 0; for (let step = 0; step < N; step++) { const r = rng() * totalW; let acc = 0; let chosen = weights[0]; for (const w of weights) { acc += w[1]; if (r < acc) { chosen = w; break; } } const [name, , fn] = chosen; try { await fn(); } catch (e) { // Some actions are inherently "noop if no candidate"; failures here // are real — re-throw with context. throw new Error(`action ${name} @ step ${step}: ${e.message}`); } // Fast-path invariants every step (cheap). Heavy checks every 50 steps. await checkInvariants(`${name}#${step}`); if (step % 50 === 0) { await checkKnownRootSample(); invariantSpotChecks++; } } // final sanity: a fresh challenge against a max-dismissed root must revert let maxedRoot = null; for (const [r, info] of assocRoots) { if (info.dismissCount >= MAX_DISMISSALS && info.state === "proposed") { maxedRoot = r; break; } } if (maxedRoot) { await expect( pool.connect(pick(challengers)).challengeAssociationRoot(maxedRoot, "post") ).to.be.revertedWithCustomError(pool, "MaxDismissalsReached"); } // ensure we actually exercised deposits + at least one withdrawal expect(depositCount).to.be.greaterThan(50n); expect(invariantSpotChecks).to.be.greaterThan(10); }); });