// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for // AereRetroPGFRound1 (contracts/retropgf/AereRetroPGFRound1.sol) — the // retroactive public-goods funding distributor (Merkle-rooted optimistic // payout list, 14-day challenge window, 365-day sweep-to-reserve). // // LIVE-VERSION NOTE: sdk-js/src/addresses.ts contains NO RetroPGF entry (grep // for "RetroPGF"/"Round1" returns nothing), so there is no canonical live // address to pin. The contract under test is therefore the source-tree // version at contracts/retropgf/AereRetroPGFRound1.sol, stood up fresh on the // in-process hardhat network. Disclosed honestly. // // TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry — `forge` // is not installed; the repo builds through hardhat with OZ 4.9.6 + viaIR). // Every random choice comes from mulberry32(seed); the base seed is printed at // suite start and pinnable via FUZZ_SEED. On any break, the failing // (campaign, step, action, values) is embedded in the assertion message for a // minimal repro. // // MOCKS (honestly disclosed): the funding token is the repo's MockERC20Lending // (a plain mintable ERC20). The Merkle tree is built in JS with sorted-pair // keccak256 hashing that matches OpenZeppelin MerkleProof.verify exactly (leaf // = keccak256(abi.encode(recipient, uint8 category, uint256 amount))). // // SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests): // npx hardhat test test/retropgf-round1-invariant-property.test.js // No deploy to any live node, no contract-logic change, one new test file only. // // INVARIANTS FUZZED (exactly the RetroPGF brief): // * DISTRIBUTION INTEGRITY: sum of all claimed AERE never exceeds the funded // TOTAL_POOL; every per-category claimed total stays <= its immutable cap; // the on-chain categoryClaimed[] mirrors the JS ledger of successful claims. // * NO OVER-DISTRIBUTION / CONSERVATION: contract token balance == // TOTAL_POOL - sum(claimed) at all times pre-sweep (exactly TOTAL_POOL was // funded and nothing else ever transfers in); claims + final sweep move // out EXACTLY TOTAL_POOL, never more. // * SINGLE-CLAIM / OWN-ALLOCATION-ONLY: a leaf can be claimed at most once // (second attempt reverts AlreadyClaimed, balances unchanged); a claim for // an amount/category/recipient NOT in the tree reverts InvalidProof and // moves no funds; funds always route to the leaf's encoded recipient, never // to the (arbitrary) caller — so a relayer/griefer cannot redirect a payout. // * NO RECIPIENT OVER-PAY: each recipient's real token balance equals the sum // of its own successfully-claimed leaf amounts, and never exceeds what it // was allocated. // * BOUNDED ADMIN/SWEEP: sweep is blocked before earliestSweepTs; when it // fires it sends EXACTLY the residual balance to the immutable // ECOSYSTEM_RESERVE and nothing to anyone else; owner has no path to move // funds outside claim/sweep (non-owner propose/fund/dismiss revert). // // No em-dashes anywhere in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); /* ------------------------------- 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) : 0x2E70_9F01; function rngFor(tag, campaign) { let h = BASE_SEED ^ (campaign * 0x9e3779b1); for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0; return mulberry32(h >>> 0); } const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; const chance = (rng, p) => rng() < p; /* ------------------------------- misc helpers ----------------------------- */ const E = (n) => ethers.parseEther(String(n)); const CATEGORIES = 6; const abiCoder = ethers.AbiCoder.defaultAbiCoder(); function randAddr(rng) { let hex = "0x"; for (let i = 0; i < 40; i++) hex += "0123456789abcdef"[Math.floor(rng() * 16)]; return ethers.getAddress(hex); } /* --------------------------- OZ-compatible Merkle ------------------------- */ // leaf = keccak256(abi.encode(address recipient, uint8 category, uint256 amount)) function leafHash(recipient, category, amount) { return ethers.keccak256( abiCoder.encode(["address", "uint8", "uint256"], [recipient, category, amount]) ); } // OZ _hashPair: commutative (sorted) keccak256 of the two 32-byte children. function hashPair(a, b) { const [lo, hi] = BigInt(a) <= BigInt(b) ? [a, b] : [b, a]; return ethers.keccak256(ethers.concat([lo, hi])); } // Standard binary Merkle with odd-node promotion; verifies under OZ MerkleProof // because every internal hash is sorted-pair. Single-leaf tree => root == leaf. function buildTree(leaves) { if (leaves.length === 0) return { root: ethers.ZeroHash, layers: [[]] }; let layer = leaves.slice(); const layers = [layer]; while (layer.length > 1) { const next = []; for (let i = 0; i < layer.length; i += 2) { if (i + 1 < layer.length) next.push(hashPair(layer[i], layer[i + 1])); else next.push(layer[i]); // promote lone node } layers.push(next); layer = next; } return { root: layer[0], layers }; } function getProof(layers, index) { const proof = []; let idx = index; for (let l = 0; l < layers.length - 1; l++) { const layer = layers[l]; const sib = idx ^ 1; if (sib < layer.length) proof.push(layer[sib]); idx = idx >> 1; } return proof; } /* -------------------------- iteration counts (env) ------------------------ */ const N = { CAMPAIGNS: Number(process.env.RPGF_CAMPAIGNS || 10), CLAIM_STEPS: Number(process.env.RPGF_CLAIM_STEPS || 60), }; // ============================================================================= describe("INVARIANT: AereRetroPGFRound1 Merkle payout distribution", function () { this.timeout(0); let signers, tokenF, rpgfF, owner, reserve, callers; before(async function () { signers = await ethers.getSigners(); owner = signers[0]; reserve = signers[19]; // immutable ECOSYSTEM_RESERVE; kept out of the caller pool callers = signers.slice(1, 8); // arbitrary third-party claim submitters / challengers tokenF = await ethers.getContractFactory("MockERC20Lending"); rpgfF = await ethers.getContractFactory("AereRetroPGFRound1"); console.log( ` [rpgf] base seed 0x${BASE_SEED.toString(16)} | ` + `${N.CAMPAIGNS} campaigns x ${N.CLAIM_STEPS} claim-steps | reserve=${reserve.address.slice(0, 10)}` ); }); // Random caps that sum EXACTLY to totalPool (constructor requires it). function makeCaps(rng, totalPool) { const w = []; let wsum = 0n; for (let i = 0; i < CATEGORIES; i++) { const x = BigInt(ri(rng, 1, 100)); w.push(x); wsum += x; } const caps = []; let acc = 0n; for (let i = 0; i < CATEGORIES - 1; i++) { const c = (totalPool * w[i]) / wsum; caps.push(c); acc += c; } caps.push(totalPool - acc); // last cap absorbs the rounding so the sum is exact // guarantee every cap is > 0 so we can always place at least one leaf for (let i = 0; i < CATEGORIES; i++) { if (caps[i] === 0n) { // steal 1 wei from the largest cap let mi = 0; for (let j = 1; j < CATEGORIES; j++) if (caps[j] > caps[mi]) mi = j; caps[mi] -= 1n; caps[i] += 1n; } } return caps; } // Build the allocation list. `overCat` (or -1) marks a category whose leaf // amounts intentionally exceed its cap so claim-time CategoryOverflow fires. function makeAllocations(rng, caps, overCat) { const allocs = []; // {recipient, category, amount} for (let cat = 0; cat < CATEGORIES; cat++) { const cap = caps[cat]; const n = ri(rng, 1, 4); let placed = 0n; for (let k = 0; k < n; k++) { const headroom = cap > placed ? cap - placed : 0n; let amt; if (cat === overCat && k === n - 1) { // final leaf blows past the cap on purpose amt = (headroom > 0n ? headroom : 1n) + BigInt(ri(rng, 1, 1000)); } else { if (headroom <= 1n) { amt = 1n; // still add a tiny leaf (may itself overflow -> handled at claim) } else { // leave room for later leaves in honest categories const frac = BigInt(ri(rng, 1, 60)); amt = (headroom * frac) / 100n; if (amt === 0n) amt = 1n; } } allocs.push({ recipient: randAddr(rng), category: cat, amount: amt }); placed += amt; } } return allocs; } async function deployRound(totalPool, caps) { const token = await tokenF.deploy("MockAERE", "mAERE", 18); await token.waitForDeployment(); const rpgf = await rpgfF.connect(owner).deploy( await token.getAddress(), reserve.address, totalPool, caps ); await rpgf.waitForDeployment(); return { token, rpgf, addr: await rpgf.getAddress(), tokenAddr: await token.getAddress() }; } // The core invariant battery. `led` is the JS ledger mirror. async function assertInvariants(ctx, rpgf, token, addr, caps, totalPool, led, swept) { // ---- per-category cap + on-chain vs JS ledger reconciliation ---- let sumClaimed = 0n; for (let i = 0; i < CATEGORIES; i++) { const oc = await rpgf.categoryClaimed(i); expect(oc, `[rpgf LEDGER] categoryClaimed[${i}] on-chain=${oc} != JS=${led.claimedByCat[i]} ${ctx}`) .to.equal(led.claimedByCat[i]); expect(oc, `[rpgf CAP] categoryClaimed[${i}]=${oc} > cap=${caps[i]} ${ctx}`).to.be.lte(caps[i]); sumClaimed += oc; } // ---- DISTRIBUTION INTEGRITY: total claimed never exceeds the funded pool ---- expect(sumClaimed, `[rpgf NO-OVER-DIST] sum(claimed)=${sumClaimed} > TOTAL_POOL=${totalPool} ${ctx}`) .to.be.lte(totalPool); // ---- CONSERVATION: exactly TOTAL_POOL was funded; nothing else transfers in. // Pre-sweep the contract must hold TOTAL_POOL - sum(claimed); post-sweep 0. ---- const bal = await token.balanceOf(addr); if (!swept) { expect(bal, `[rpgf CONSERVE] balance=${bal} != TOTAL_POOL-claimed=${totalPool - sumClaimed} ${ctx}`) .to.equal(totalPool - sumClaimed); } else { expect(bal, `[rpgf CONSERVE-SWEPT] balance=${bal} != 0 after sweep ${ctx}`).to.equal(0n); } // ---- NO RECIPIENT OVER-PAY: real balance == sum of own claimed leaves, and // never exceeds the recipient's total allocation ---- for (const r of Object.keys(led.claimedByRecipient)) { const rb = await token.balanceOf(r); expect(rb, `[rpgf RECIP-BAL] ${r} balance=${rb} != claimed=${led.claimedByRecipient[r]} ${ctx}`) .to.equal(led.claimedByRecipient[r]); expect(rb, `[rpgf RECIP-OVERPAY] ${r} got ${rb} > allocated ${led.allocByRecipient[r]} ${ctx}`) .to.be.lte(led.allocByRecipient[r]); } } // ------------------------------------------------------------------------- it("distribution integrity, single-claim, conservation & bounded sweep across interleaved multi-actor sequences", async function () { let campaigns = 0, claimsOk = 0, doubleClaims = 0, badProofs = 0, overflows = 0, challenges = 0, dismissals = 0, earlyFinalizeReverts = 0, earlySweepReverts = 0, unauthReverts = 0, griefRoutes = 0, sweeps = 0, dupLeafSafe = 0; for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("main", c); // -- randomized immutable config -- const totalPool = E(ri(rng, 100, 10_000_000)); const caps = makeCaps(rng, totalPool); const overCat = chance(rng, 0.45) ? ri(rng, 0, CATEGORIES - 1) : -1; const { token, rpgf, addr } = await deployRound(totalPool, caps); // JS ledger mirror const led = { claimedByCat: new Array(CATEGORIES).fill(0n), claimedByRecipient: {}, allocByRecipient: {}, }; // -- allocation list + Merkle tree -- const allocs = makeAllocations(rng, caps, overCat); // Occasionally inject an exact-duplicate tuple to probe the same-leaf // collapse (both map to one leafClaimed key -> at most one payout). let dupIdx = -1; if (chance(rng, 0.3) && allocs.length > 0) { const src = pick(rng, allocs); allocs.push({ recipient: src.recipient, category: src.category, amount: src.amount }); dupIdx = allocs.length - 1; } for (const a of allocs) { led.allocByRecipient[a.recipient] = (led.allocByRecipient[a.recipient] || 0n) + a.amount; } const leaves = allocs.map((a) => leafHash(a.recipient, a.category, a.amount)); const { root, layers } = buildTree(leaves); allocs.forEach((a, i) => { a.leaf = leaves[i]; a.proof = getProof(layers, i); a.claimed = false; }); // -- unauthorized checks before funding -- await expect(rpgf.connect(callers[0]).fund()).to.be.reverted; // onlyOwner unauthReverts++; await expect(rpgf.connect(callers[0]).proposePayoutRoot(root)).to.be.reverted; // onlyOwner unauthReverts++; // propose before funding -> InsufficientFunding await expect(rpgf.connect(owner).proposePayoutRoot(root)).to.be.reverted; // -- fund exactly TOTAL_POOL -- await (await token.mint(owner.address, totalPool)).wait(); await (await token.connect(owner).approve(addr, totalPool)).wait(); await (await rpgf.connect(owner).fund()).wait(); await assertInvariants(`c${c} funded`, rpgf, token, addr, caps, totalPool, led, false); // -- propose root -- await (await rpgf.connect(owner).proposePayoutRoot(root)).wait(); // -- optional challenge / dismiss dance (only owner may dismiss) -- if (chance(rng, 0.6)) { const ch = pick(rng, callers); await (await rpgf.connect(ch).challenge("suspected misallocation")).wait(); challenges++; expect(await rpgf.state(), `[rpgf STATE] not Challenged after challenge c${c}`).to.equal(2n); // non-owner cannot dismiss await expect(rpgf.connect(callers[1]).dismissChallenge(0, "nope")).to.be.reverted; unauthReverts++; // owner may re-propose (allowed from Challenged) or dismiss to clear if (chance(rng, 0.5)) { await (await rpgf.connect(owner).proposePayoutRoot(root)).wait(); // re-propose leaves the open challenge counted; dismiss to reach 0 await (await rpgf.connect(owner).dismissChallenge(0, "reviewed, valid")).wait(); } else { await (await rpgf.connect(owner).dismissChallenge(0, "reviewed, valid")).wait(); } dismissals++; expect(await rpgf.state(), `[rpgf STATE] not Proposed after dismiss c${c}`).to.equal(1n); } // -- finalize BEFORE window elapses must revert -- await expect(rpgf.connect(pick(rng, callers)).finalize()).to.be.reverted; earlyFinalizeReverts++; // advance a partial amount sometimes, then finish the window if (chance(rng, 0.5)) { await time.increase(ri(rng, 1, 13) * 24 * 60 * 60); await expect(rpgf.connect(owner).finalize()).to.be.reverted; // still inside 14d earlyFinalizeReverts++; } await time.increase(14 * 24 * 60 * 60 + 1); await (await rpgf.connect(pick(rng, callers)).finalize()).wait(); expect(await rpgf.state(), `[rpgf STATE] not Finalized c${c}`).to.equal(3n); await assertInvariants(`c${c} finalized`, rpgf, token, addr, caps, totalPool, led, false); // sweep before the 365d window must revert await expect(rpgf.connect(pick(rng, callers)).sweepUnclaimed()).to.be.reverted; earlySweepReverts++; // -- interleaved claim phase -- for (let s = 0; s < N.CLAIM_STEPS; s++) { const action = pick(rng, [ "claim", "claim", "claim", "double", "badproof", "grief", "sweepEarly", "unauth", "advance", ]); const caller = pick(rng, callers); // ANY address may submit a claim const ctx = `c${c} s${s} action=${action} caller=${caller.address.slice(0, 8)}`; if (action === "claim") { const open = allocs.filter((a) => !a.claimed); if (open.length === 0) continue; const a = pick(rng, open); const wouldBe = led.claimedByCat[a.category] + a.amount; if (wouldBe > caps[a.category]) { // category cap enforced at claim time -> must revert, no movement await expect( rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof), `[rpgf OVERFLOW-EXPECTED] ${ctx} cat=${a.category} wouldBe=${wouldBe} cap=${caps[a.category]}` ).to.be.reverted; overflows++; } else { // duplicate-tuple leaf: if the twin already paid, this reverts AlreadyClaimed const twin = allocs.find( (x) => x !== a && x.claimed && x.leaf === a.leaf ); if (twin) { await expect( rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof), `[rpgf DUP-LEAF] second identical tuple must revert AlreadyClaimed ${ctx}` ).to.be.reverted; a.claimed = true; // consumed for our bookkeeping (unclaimable henceforth) dupLeafSafe++; } else { await (await rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof)).wait(); a.claimed = true; led.claimedByCat[a.category] += a.amount; led.claimedByRecipient[a.recipient] = (led.claimedByRecipient[a.recipient] || 0n) + a.amount; claimsOk++; } } } else if (action === "double") { const done = allocs.filter((a) => a.claimed && a.leaf); if (done.length === 0) continue; const a = pick(rng, done); const rbBefore = await token.balanceOf(a.recipient); await expect( rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof), `[rpgf DOUBLE] re-claim of consumed leaf must revert ${ctx}` ).to.be.reverted; expect(await token.balanceOf(a.recipient), `[rpgf DOUBLE-BAL] balance moved on failed re-claim ${ctx}`) .to.equal(rbBefore); doubleClaims++; } else if (action === "badproof") { // fabricate a leaf NOT in the tree: tweak amount, category, or recipient const a = pick(rng, allocs); const variant = ri(rng, 0, 2); let recip = a.recipient, cat = a.category, amt = a.amount; if (variant === 0) amt = a.amount + BigInt(ri(rng, 1, 1000)); else if (variant === 1) cat = (a.category + 1) % CATEGORIES; else recip = randAddr(rng); const rbBefore = await token.balanceOf(recip); await expect( rpgf.connect(caller).claim(recip, cat, amt, a.proof), `[rpgf BADPROOF] forged leaf must revert InvalidProof/overflow ${ctx}` ).to.be.reverted; expect(await token.balanceOf(recip), `[rpgf BADPROOF-BAL] balance moved on forged claim ${ctx}`) .to.equal(rbBefore); badProofs++; } else if (action === "grief") { // A griefer submits a VALID unclaimed leaf. Funds must route to the // leaf's encoded recipient, never to the caller. const open = allocs.filter( (a) => !a.claimed && led.claimedByCat[a.category] + a.amount <= caps[a.category] && !allocs.find((x) => x !== a && x.claimed && x.leaf === a.leaf) ); if (open.length === 0) continue; const a = pick(rng, open); const callerBefore = await token.balanceOf(caller.address); const recipBefore = await token.balanceOf(a.recipient); await (await rpgf.connect(caller).claim(a.recipient, a.category, a.amount, a.proof)).wait(); a.claimed = true; led.claimedByCat[a.category] += a.amount; led.claimedByRecipient[a.recipient] = (led.claimedByRecipient[a.recipient] || 0n) + a.amount; // caller (griefer) gains nothing unless caller IS the recipient (random addrs => never) expect(await token.balanceOf(caller.address), `[rpgf GRIEF-CALLER] caller siphoned funds ${ctx}`) .to.equal(callerBefore); expect((await token.balanceOf(a.recipient)) - recipBefore, `[rpgf GRIEF-ROUTE] recipient not credited exactly amount ${ctx}`).to.equal(a.amount); griefRoutes++; claimsOk++; } else if (action === "sweepEarly") { await expect( rpgf.connect(caller).sweepUnclaimed(), `[rpgf SWEEP-EARLY] sweep before 365d must revert ${ctx}` ).to.be.reverted; earlySweepReverts++; } else if (action === "unauth") { // owner has no fund-exfil path once finalized await expect(rpgf.connect(owner).proposePayoutRoot(root)).to.be.reverted; // not in Funding/Challenged await expect(rpgf.connect(caller).dismissChallenge(0, "x")).to.be.reverted; // non-owner unauthReverts += 2; } else if (action === "advance") { await time.increase(ri(rng, 1, 30) * 24 * 60 * 60); } await assertInvariants(ctx, rpgf, token, addr, caps, totalPool, led, false); } // -- finale: cross the 365d sweep window and roll unclaimed to reserve -- await time.increase(365 * 24 * 60 * 60 + 1); const reserveBefore = await token.balanceOf(reserve.address); const residual = await token.balanceOf(addr); let sumClaimedFinal = 0n; for (let i = 0; i < CATEGORIES; i++) sumClaimedFinal += led.claimedByCat[i]; // residual must equal TOTAL_POOL - claimed (conservation) BEFORE the sweep expect(residual, `[rpgf RESIDUAL] c${c} residual=${residual} != pool-claimed=${totalPool - sumClaimedFinal}`) .to.equal(totalPool - sumClaimedFinal); await (await rpgf.connect(pick(rng, callers)).sweepUnclaimed()).wait(); sweeps++; expect(await rpgf.state(), `[rpgf STATE] not Swept c${c}`).to.equal(4n); // reserve received EXACTLY the residual, nothing more expect((await token.balanceOf(reserve.address)) - reserveBefore, `[rpgf SWEEP-EXACT] reserve delta != residual c${c}`).to.equal(residual); // conservation closes: claims + sweep == TOTAL_POOL exactly expect(sumClaimedFinal + residual, `[rpgf CLOSE] claims+sweep != TOTAL_POOL c${c}`).to.equal(totalPool); await assertInvariants(`c${c} swept`, rpgf, token, addr, caps, totalPool, led, true); // sweeping again reverts (state == Swept); claims after sweep revert too await expect(rpgf.connect(owner).sweepUnclaimed()).to.be.reverted; const leftover = allocs.find((a) => !a.claimed); if (leftover) { await expect( rpgf.connect(owner).claim(leftover.recipient, leftover.category, leftover.amount, leftover.proof) ).to.be.reverted; } campaigns++; } console.log( ` [rpgf] campaigns=${campaigns} claimsOk=${claimsOk} grief-routed=${griefRoutes} ` + `doubleReverts=${doubleClaims} badProofReverts=${badProofs} capOverflowReverts=${overflows} ` + `dupLeafSafe=${dupLeafSafe} challenges=${challenges} dismissals=${dismissals} ` + `earlyFinalizeReverts=${earlyFinalizeReverts} earlySweepReverts=${earlySweepReverts} ` + `unauthReverts=${unauthReverts} sweeps=${sweeps}` ); // coverage floors so a green run genuinely exercised the surface expect(campaigns).to.equal(N.CAMPAIGNS); expect(claimsOk, "expected many successful claims").to.be.greaterThan(30); expect(doubleClaims + badProofs, "expected double-claim & bad-proof rejections").to.be.greaterThan(20); expect(sweeps, "expected every campaign to sweep").to.equal(N.CAMPAIGNS); }); });