// ============================================================================= // DEFENSE-IN-DEPTH: seeded randomized PROPERTY / STATEFUL-INVARIANT tests for the // three corrected, audit-fixed V2 contracts the founder will grant governance // authority and funds to. // // 1. AereGovernorV2 (robust quorum, FINDING 1) + AereGovLock vote source + TimelockController // 2. AereRewardDistributorV2 (confiscation closed, FINDING 2) // 3. AereHistoryStateRootAnchorV2 (double-verify removed, FINDING 4; SP1 verifier mocked) // // TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry). Foundry // `forge` is not installed in this environment, and the repo builds through // hardhat with OZ 4.9.6 resolved from node_modules plus viaIR + a cancun hardfork // override, so a clean forge remapping/foundry.toml setup would fight the layout. // Per the task's explicit fallback we drive long random sequences of actions with // varied actors / amounts / timing via a SEEDED PRNG (reproducible), and assert // the invariants after every step. // // REPRODUCIBILITY: every random choice comes from mulberry32(seed). The seed is // printed at suite start and can be pinned with FUZZ_SEED. On any invariant break // the failing action (campaign, step, actor, call, values, timing) is in the // assertion message, giving a minimal repro. // // SCOPE: run this file in ISOLATION (the full hardhat suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests): // npx hardhat test test/invariant-audit-v2-property.test.js // No deploy, no logic change, no live node. // ============================================================================= 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) : 0xA33E2800; function rngFor(tag, campaign) { // Distinct, deterministic stream per (contract, 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 abi = ethers.AbiCoder.defaultAbiCoder(); async function setBal(addr, amountWei) { await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]); } async function blockNum() { return await ethers.provider.getBlockNumber(); } async function mineTo(target) { const cur = await blockNum(); if (target > cur) { await network.provider.send("hardhat_mine", ["0x" + (target - cur).toString(16)]); } } // Iteration counts (env-overridable so a quick smoke can shrink them). const N = { GOV_CAMPAIGNS: Number(process.env.GOV_CAMPAIGNS || 14), GOV_ROUNDS: Number(process.env.GOV_ROUNDS || 12), DIST_CAMPAIGNS: Number(process.env.DIST_CAMPAIGNS || 22), DIST_STEPS: Number(process.env.DIST_STEPS || 60), ANCHOR_CAMPAIGNS: Number(process.env.ANCHOR_CAMPAIGNS || 12), ANCHOR_STEPS: Number(process.env.ANCHOR_STEPS || 45), }; // ============================================================================= // 1. AereGovernorV2 — robust quorum (FINDING 1) // ============================================================================= describe("INVARIANT: AereGovernorV2 robust quorum (FINDING 1)", function () { this.timeout(0); const STATE = { Pending: 0, Active: 1, Canceled: 2, Defeated: 3, Succeeded: 4, Queued: 5, Expired: 6, Executed: 7 }; const DAY = 24 * 60 * 60; let govLockFactory, govFactory, tlFactory, targetFactory; let signers; before(async function () { signers = await ethers.getSigners(); govLockFactory = await ethers.getContractFactory("AereGovLock"); govFactory = await ethers.getContractFactory("AereGovernorV2"); tlFactory = await ethers.getContractFactory("TimelockController"); targetFactory = await ethers.getContractFactory("GovSelfTestTarget"); console.log(` [gov] base seed 0x${BASE_SEED.toString(16)}, ${N.GOV_CAMPAIGNS} campaigns x ${N.GOV_ROUNDS} rounds`); }); // clamp(fractional, floor, cap) exactly as the contract does. function expectedQuorum(supply, num, den, floor, cap) { const frac = (supply * num) / den; const capped = frac < cap ? frac : cap; return capped > floor ? capped : floor; } it("post-creation locks never inflate a proposal's quorum; bounds + turnout rule hold", async function () { let totalRounds = 0; let immunityChecks = 0; let votedRounds = 0; let succeeded = 0; let defeated = 0; let whaleRoundsWithContrast = 0; for (let c = 0; c < N.GOV_CAMPAIGNS; c++) { const rng = rngFor("gov", c); // Random-but-valid governor config for this campaign. const vd = ri(rng, 1, 6); // votingDelay (blocks) const period = ri(rng, 8, 16); // votingPeriod (blocks) — wide enough for all turnout votes await setBal(signers[0].address, E("100000000000")); // keep the deployer/gas-payer solvent const pct = ri(rng, 1, 30); // quorum % const lookback = ri(rng, 1, vd + 6); // may be < vd to exercise the max(lookback, vd) fold const floor = E(ri(rng, 1000, 200000)); const cap = floor + E(ri(rng, 0, 2000000)); // >= floor const gl = await govLockFactory.deploy(0); await gl.waitForDeployment(); const timelock = await tlFactory.deploy(0, [], [], signers[0].address); await timelock.waitForDeployment(); const gov = await govFactory.deploy( await gl.getAddress(), await timelock.getAddress(), vd, period, 0 /* proposalThreshold */, pct, floor, cap, lookback ); await gov.waitForDeployment(); const target = await targetFactory.deploy(); await target.waitForDeployment(); await (await target.transferOwnership(await timelock.getAddress())).wait(); { const P = await timelock.PROPOSER_ROLE(); const Ex = await timelock.EXECUTOR_ROLE(); await (await timelock.grantRole(P, await gov.getAddress())).wait(); await (await timelock.grantRole(Ex, await gov.getAddress())).wait(); } expect(await gov.quorumDenominator()).to.equal(100n); const num = BigInt(pct); const den = 100n; // Actor set: three persistent honest voters, one transient (short-lock) voter, one silent whale. const persistent = [signers[1], signers[2], signers[3]]; const transient = signers[4]; const whale = signers[5]; for (const s of [...persistent, transient, whale]) { await setBal(s.address, E("100000000000")); // 100B fake AERE headroom } // Honest setup: persistent voters lock (far-future) and self-delegate. for (const s of persistent) { const amt = E(ri(rng, 1000, 500000)); const unlock = (await time.latest()) + 1000 * DAY; await (await gl.connect(s).lock(amt, unlock, { value: amt })).wait(); await (await gl.connect(s).delegate(s.address)).wait(); } for (let r = 0; r < N.GOV_ROUNDS; r++) { // ---- pre-proposal churn: honest top-up locks + a transient lock/unlock ---- if (chance(rng, 0.5)) { const s = pick(rng, persistent); const amt = E(ri(rng, 1000, 200000)); const unlock = (await time.latest()) + 1000 * DAY; await (await gl.connect(s).lock(amt, unlock, { value: amt })).wait(); } // Transient locker: lock short, and try to withdraw if a previous short lock matured. try { const [tAmt] = await gl.locks(transient.address); if (tAmt === 0n && chance(rng, 0.5)) { const amt = E(ri(rng, 1000, 50000)); const unlock = (await time.latest()) + ri(rng, 10, 60); await (await gl.connect(transient).lock(amt, unlock, { value: amt })).wait(); await (await gl.connect(transient).delegate(transient.address)).wait(); } else if (tAmt > 0n) { await time.increase(ri(rng, 1, 120)); // maybe cross the unlock time const unlockT = await gl.unlockTimeOf(transient.address); if (BigInt(await time.latest()) >= unlockT) { await (await gl.connect(transient).withdraw()).wait(); // UNLOCK action } } } catch (_) { /* best-effort churn */ } // Mine a margin so all honest locks are strictly before the lookback point. await network.provider.send("hardhat_mine", ["0x" + (lookback + vd + 3).toString(16)]); // ---- create the proposal ---- const targets = [await target.getAddress()]; const values = [0n]; const calldatas = [target.interface.encodeFunctionData("setValue", [ri(rng, 1, 1e9)])]; const description = `c${c}r${r}-${Math.floor(rng() * 1e9)}`; const descHash = ethers.id(description); const proposalId = await gov.hashProposal(targets, values, calldatas, descHash); const proposer = persistent[0]; await (await gov.connect(proposer).propose(targets, values, calldatas, description)).wait(); const snapshot = await gov.proposalSnapshot(proposalId); // = C + vd const deadline = await gov.proposalDeadline(proposalId); const C = await blockNum(); // propose tx block // Pre-whale reference supply, measured BEFORE the whale exists (C-1 < C). const refSupply = await gl.getPastTotalSupply(C - 1); // ---- ADVERSARY: silent whale reactively locks AFTER creation (blocks in (C, snapshot]) ---- const whaleAmount = chance(rng, 0.75) ? E(ri(rng, 1000, 2000000000)) : 0n; if (whaleAmount > 0n) { const curBefore = await blockNum(); expect(curBefore, "whale must be able to lock before snapshot").to.be.lessThan(Number(snapshot)); const unlock = (await time.latest()) + 1000 * DAY; await (await gl.connect(whale).lock(whaleAmount, unlock, { value: whaleAmount })).wait(); // The whale stays SILENT: it never delegates-to-vote and never casts a vote. // (Delegation is irrelevant to total-supply denominator anyway.) } // Mine to Active (snapshot + 1) so quorum(snapshot) and votes are queryable. await mineTo(Number(snapshot) + 1); // Denominator the contract actually uses. const maxLb = BigInt(Math.max(lookback, vd)); const lookbackPoint = snapshot > maxLb ? snapshot - maxLb : 0n; const denomSupply = await gl.getPastTotalSupply(lookbackPoint); const Q = await gov.quorum(snapshot); const ctx = `campaign ${c} round ${r} | vd=${vd} lb=${lookback} pct=${pct} floor=${floor} cap=${cap} whale=${whaleAmount}`; // ---- INVARIANT A (core immunity): the quorum denominator can never rise // above the pre-proposal honest supply, no matter how much the whale // locked after creation. refSupply was measured before the whale existed. expect(denomSupply, `[gov IMMUNITY] denominator inflated by reactive whale — ${ctx}`) .to.be.lte(refSupply); // ---- INVARIANT (mechanism + bounds): Q is exactly the clamped fraction of // that immune denominator, and always inside [floor, cap]. expect(Q, `[gov MECH] quorum != clamp(denom) — ${ctx} denom=${denomSupply}`) .to.equal(expectedQuorum(denomSupply, num, den, floor, cap)); expect(Q, `[gov BOUND-lo] quorum below floor — ${ctx}`).to.be.gte(floor); expect(Q, `[gov BOUND-hi] quorum above cap — ${ctx}`).to.be.lte(cap); immunityChecks++; // ---- CONTRAST: a naive GovernorVotesQuorumFraction would use the live // (whale-inflated) supply at the snapshot; show the whale genuinely // inflates that naive denominator while Q is unmoved. if (whaleAmount >= E(1000)) { const naiveSupply = await gl.getPastTotalSupply(snapshot); expect(naiveSupply, `[gov CONTRAST] whale did not inflate naive supply — ${ctx}`) .to.be.gt(denomSupply); whaleRoundsWithContrast++; } // ---- INVARIANT C (turnout rule): drive a subset of rounds to completion. if (chance(rng, 0.5)) { // Random honest turnout: each persistent voter independently votes For. // Each castVote mines a block; guard against exhausting the voting window // (a short random votingPeriod) so we only ever vote while Active. The // turnout predicate below reads the ACTUAL on-chain tally, so a skipped // voter simply lowers forVotes — the invariant assertion stays exact. const voters = persistent.filter(() => chance(rng, 0.6)); for (const v of voters) { if (Number(await gov.state(proposalId)) !== STATE.Active) break; const w = await gov.getVotes(v.address, snapshot); // weight at snapshot (delegated + locked) if (w > 0n) await (await gov.connect(v).castVote(proposalId, 1)).wait(); // 1 = For } // transient voter may also vote if it currently has weight and window is open. if (chance(rng, 0.4) && Number(await gov.state(proposalId)) === STATE.Active) { const w = await gov.getVotes(transient.address, snapshot); if (w > 0n) await (await gov.connect(transient).castVote(proposalId, 1)).wait(); } await mineTo(Number(deadline) + 1); const [againstV, forV, abstainV] = await gov.proposalVotes(proposalId); const st = Number(await gov.state(proposalId)); const quorumReached = forV + abstainV >= Q; const majority = forV > againstV; const expectSucceeded = quorumReached && majority; expect(st === STATE.Succeeded, `[gov TURNOUT] succeeded mismatch — ${ctx} for=${forV} Q=${Q} st=${st}`) .to.equal(expectSucceeded); expect(st === STATE.Defeated || st === STATE.Succeeded, `[gov STATE] not resolved — ${ctx} st=${st}`) .to.equal(true); // A genuinely low-turnout proposal (for below the floor) can NEVER reach quorum, // because Q >= floor. Assert that direction explicitly. if (forV + abstainV < floor) { expect(quorumReached, `[gov FLOOR] sub-floor turnout reached quorum — ${ctx}`).to.equal(false); expect(st, `[gov FLOOR] sub-floor turnout not defeated — ${ctx}`).to.equal(STATE.Defeated); } if (st === STATE.Succeeded) succeeded++; else defeated++; votedRounds++; } totalRounds++; } } console.log(` [gov] rounds=${totalRounds} immunityChecks=${immunityChecks} whaleContrast=${whaleRoundsWithContrast} voted=${votedRounds} (succeeded=${succeeded} defeated=${defeated})`); expect(immunityChecks).to.be.greaterThan(100); // hundreds+ of quorum-immunity assertions expect(votedRounds).to.be.greaterThan(20); expect(succeeded).to.be.greaterThan(0); expect(defeated).to.be.greaterThan(0); }); // Directed end-to-end sanity: a healthy proposal still queues + executes through // the (untouched) TimelockController — the fix does not break normal governance. it("healthy proposal still executes end-to-end through the timelock", async function () { const gl = await govLockFactory.deploy(0); await gl.waitForDeployment(); const timelock = await tlFactory.deploy(0, [], [], signers[0].address); await timelock.waitForDeployment(); const gov = await govFactory.deploy( await gl.getAddress(), await timelock.getAddress(), 2, 6, 0, 10, E("100000"), E("500000"), 4 ); await gov.waitForDeployment(); const target = await targetFactory.deploy(); await target.waitForDeployment(); await (await target.transferOwnership(await timelock.getAddress())).wait(); await (await timelock.grantRole(await timelock.PROPOSER_ROLE(), await gov.getAddress())).wait(); await (await timelock.grantRole(await timelock.EXECUTOR_ROLE(), await gov.getAddress())).wait(); const honest = signers[1]; await setBal(honest.address, E("10000000")); const LOCKED = E("3000000"); // 10% = 300k inside [100k,500k] await (await gl.connect(honest).lock(LOCKED, (await time.latest()) + 1000 * DAY, { value: LOCKED })).wait(); await (await gl.connect(honest).delegate(honest.address)).wait(); await network.provider.send("hardhat_mine", ["0x10"]); const targets = [await target.getAddress()]; const values = [0n]; const calldatas = [target.interface.encodeFunctionData("setValue", [42])]; const description = "healthy e2e"; const descHash = ethers.id(description); const id = await gov.hashProposal(targets, values, calldatas, descHash); await (await gov.connect(honest).propose(targets, values, calldatas, description)).wait(); const snap = await gov.proposalSnapshot(id); const dl = await gov.proposalDeadline(id); await mineTo(Number(snap) + 1); await (await gov.connect(honest).castVote(id, 1)).wait(); await mineTo(Number(dl) + 1); expect(Number(await gov.state(id))).to.equal(STATE.Succeeded); await (await gov.queue(targets, values, calldatas, descHash)).wait(); await (await gov.execute(targets, values, calldatas, descHash)).wait(); expect(Number(await gov.state(id))).to.equal(STATE.Executed); expect(await target.value()).to.equal(42n); }); }); // ============================================================================= // 2. AereRewardDistributorV2 — confiscation closed (FINDING 2) // ============================================================================= describe("INVARIANT: AereRewardDistributorV2 confiscation closed (FINDING 2)", function () { this.timeout(0); let signers, waereF, saereF, sinkF, distF; before(async function () { signers = await ethers.getSigners(); waereF = await ethers.getContractFactory("WAERE"); saereF = await ethers.getContractFactory("MockSAERE"); sinkF = await ethers.getContractFactory("MockSink"); distF = await ethers.getContractFactory("AereRewardDistributorV2"); console.log(` [dist] base seed 0x${BASE_SEED.toString(16)}, ${N.DIST_CAMPAIGNS} campaigns x ${N.DIST_STEPS} steps`); }); /* ------------------------- merkle (OZ StandardMerkleTree leaf) ------------ */ function leafHash(account, total) { return ethers.keccak256(ethers.keccak256(abi.encode(["address", "uint256"], [account, total]))); } function hashPair(a, b) { return BigInt(a) < BigInt(b) ? ethers.keccak256(ethers.concat([a, b])) : ethers.keccak256(ethers.concat([b, a])); } function buildLayers(leaves) { const layers = [leaves.slice()]; while (layers[layers.length - 1].length > 1) { const prev = layers[layers.length - 1]; const next = []; for (let i = 0; i < prev.length; i += 2) { if (i + 1 < prev.length) next.push(hashPair(prev[i], prev[i + 1])); else next.push(prev[i]); } layers.push(next); } return 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 = Math.floor(idx / 2); } return proof; } function makeTree(entries) { const leaves = entries.map((e) => leafHash(e.account, e.total)); const layers = buildLayers(leaves); const indexOf = {}; entries.forEach((e, i) => (indexOf[e.account.toLowerCase()] = i)); return { root: layers[layers.length - 1][0], proofFor: (a) => getProof(layers, indexOf[a.toLowerCase()]) }; } // JS mirror of the contract's _vested(). function vestedJS(total, atTime, start, cliff, vesting) { if (atTime < start + cliff) return 0n; if (atTime >= start + vesting) return total; return (total * BigInt(atTime - start)) / BigInt(vesting); } it("clawback can never breach the reserve; no over/double/excluded claim; legit dust clawback works", async function () { let steps = 0; let claims = 0, clawbacks = 0, excludedBlocks = 0, pauseClawbackBlocks = 0, dustRecovered = 0; for (let c = 0; c < N.DIST_CAMPAIGNS; c++) { const rng = rngFor("dist", c); const waere = await waereF.deploy(); const saere = await saereF.deploy(await waere.getAddress()); const sink = await sinkF.deploy(); const dist = await distF.deploy(await waere.getAddress(), await saere.getAddress(), await sink.getAddress()); await dist.waitForDeployment(); const distAddr = await dist.getAddress(); const owner = signers[0]; const relayer = signers[19]; // Keep the owner (funds the reserve + pays gas) and relayer (pays claim gas) // solvent across all campaigns; reserves permanently leave the owner to claimants. await setBal(owner.address, E("100000000000")); await setBal(relayer.address, E("100000000000")); // Random claimant set + entitlements. Some addresses are NON-claimants (used // to exercise BadProof). const nClaim = ri(rng, 2, 6); const claimants = []; const totals = {}; for (let i = 0; i < nClaim; i++) { const acc = signers[3 + i]; const t = E(ri(rng, 1, 1000)); claimants.push(acc); totals[acc.address] = t; } const nonClaimant = signers[3 + nClaim]; // not in the tree const tree = makeTree(claimants.map((a) => ({ account: a.address, total: totals[a.address] }))); // HONEST totalAllocated = exact sum of published leaves (the documented dependency). let totalAllocated = 0n; for (const a of claimants) totalAllocated += totals[a.address]; const cliff = ri(rng, 0, 500); const vesting = cliff + ri(rng, 1, 5000); const nowTs = await time.latest(); const expiry = nowTs + vesting + ri(rng, 100, 100000); // must outlast vesting const deliverAsSAERE = chance(rng, 0.5); await (await dist.publishRoot(tree.root, cliff, vesting, expiry, deliverAsSAERE, totalAllocated)).wait(); const start = Number(await dist.rootPublishedAt()); // Fund honestly: allocation + random overfunded dust (>= 0). const dust0 = E(ri(rng, 0, 200)); await owner.sendTransaction({ to: distAddr, value: totalAllocated + dust0 }); // JS shadow state. const claimedJS = {}; for (const a of claimants) claimedJS[a.address] = 0n; let totalClaimedJS = 0n; const excludedJS = {}; let paused = false; let curTime = Number(await time.latest()); let prevTotalClaimed = 0n; async function advance(delta) { curTime += delta; await time.setNextBlockTimestamp(curTime); } async function assertInvariants(tag) { const bal = await ethers.provider.getBalance(distAddr); const onTotalClaimed = await dist.totalClaimed(); const onAllocated = await dist.totalAllocated(); const reserved = onAllocated > onTotalClaimed ? onAllocated - onTotalClaimed : 0n; // CORE confiscation invariant: a claimant's vested-but-unclaimed entitlement // is never sweepable — balance always covers the reserve. expect(bal, `[dist RESERVE] balance < reserved after ${tag} (c${c} s${steps})`).to.be.gte(reserved); // Accounting integrity. expect(onTotalClaimed, `[dist CAP] totalClaimed > totalAllocated after ${tag} (c${c} s${steps})`).to.be.lte(onAllocated); expect(onAllocated, `[dist FROZEN] totalAllocated drifted after ${tag} (c${c} s${steps})`).to.equal(totalAllocated); expect(onTotalClaimed, `[dist SHADOW] on-chain totalClaimed != shadow after ${tag}`).to.equal(totalClaimedJS); expect(onTotalClaimed, `[dist MONO] totalClaimed decreased after ${tag}`).to.be.gte(prevTotalClaimed); prevTotalClaimed = onTotalClaimed; // Per-leaf: never claim more than entitlement, on-chain matches shadow. for (const a of claimants) { const onClaimed = await dist.claimed(a.address); expect(onClaimed, `[dist LEAF] claimed>entitlement ${a.address} after ${tag}`).to.be.lte(totals[a.address]); expect(onClaimed, `[dist LEAF-SHADOW] mismatch ${a.address} after ${tag}`).to.equal(claimedJS[a.address]); } } await assertInvariants("publish+fund"); for (let s = 0; s < N.DIST_STEPS; s++) { const action = pick(rng, [ "claim", "claim", "claim", "setExcluded", "pause", "unpause", "clawback", "clawback", "advance", ]); if (action === "advance") { await advance(ri(rng, 1, Math.max(2, Math.floor(vesting / 3)))); // advancing time needs a block to take effect for subsequent reads; mine one via a no-op tx-free jump await network.provider.send("evm_mine"); } else if (action === "claim") { // Usually a real claimant; occasionally the non-claimant (BadProof). const useBad = chance(rng, 0.12); const acc = useBad ? nonClaimant : pick(rng, claimants); const total = useBad ? E(ri(rng, 1, 1000)) : totals[acc.address]; const proof = useBad ? tree.proofFor(claimants[0].address) : tree.proofFor(acc.address); await advance(ri(rng, 1, Math.max(2, Math.floor(vesting / 4)))); // Predict. const vested = useBad ? 0n : vestedJS(total, curTime, start, cliff, vesting); const already = useBad ? 0n : claimedJS[acc.address]; let expectRevert = null; if (paused) expectRevert = "Pausable: paused"; else if (!useBad && excludedJS[acc.address]) expectRevert = "IsExcluded"; else if (useBad) expectRevert = "BadProof"; else if (vested <= already) expectRevert = "NothingToClaim"; if (expectRevert) { if (expectRevert === "Pausable: paused") { await expect(dist.connect(relayer).claim(acc.address, total, proof)).to.be.revertedWith("Pausable: paused"); } else { await expect(dist.connect(relayer).claim(acc.address, total, proof)).to.be.revertedWithCustomError(dist, expectRevert); } if (expectRevert === "IsExcluded") excludedBlocks++; } else { const amount = vested - already; const before = await ethers.provider.getBalance(distAddr); const accBefore = deliverAsSAERE ? 0n : await ethers.provider.getBalance(acc.address); const sharesBefore = deliverAsSAERE ? await saere.balanceOf(acc.address) : 0n; await (await dist.connect(relayer).claim(acc.address, total, proof)).wait(); const after = await ethers.provider.getBalance(distAddr); // Contract native balance decreases by exactly `amount` on BOTH delivery paths. expect(before - after, `[dist DELIVER] contract delta != amount (c${c} s${s})`).to.equal(amount); if (deliverAsSAERE) { expect(await saere.balanceOf(acc.address), `[dist SAERE] no shares minted (c${c} s${s})`).to.be.gt(sharesBefore); } else { // relayer paid gas, so the claimant's balance rises by exactly `amount`. expect((await ethers.provider.getBalance(acc.address)) - accBefore, `[dist LIQUID] payout != amount (c${c} s${s})`).to.equal(amount); } claimedJS[acc.address] = vested; totalClaimedJS += amount; claims++; } } else if (action === "setExcluded") { const acc = pick(rng, claimants); const val = chance(rng, 0.7); const shouldRevert = val && claimedJS[acc.address] > 0n; if (shouldRevert) { await expect(dist.setExcluded(acc.address, true)).to.be.revertedWithCustomError(dist, "AlreadyClaimed"); } else { await (await dist.setExcluded(acc.address, val)).wait(); excludedJS[acc.address] = val; } } else if (action === "pause") { if (paused) { await expect(dist.pause()).to.be.revertedWith("Pausable: paused"); } else { await (await dist.pause()).wait(); paused = true; } } else if (action === "unpause") { if (!paused) { await expect(dist.unpause()).to.be.revertedWith("Pausable: not paused"); } else { await (await dist.unpause()).wait(); paused = false; } } else if (action === "clawback") { const toSink = chance(rng, 0.5); await advance(ri(rng, 1, Math.max(2, Math.floor(vesting / 2)))); const bal = await ethers.provider.getBalance(distAddr); const reserved = totalAllocated > totalClaimedJS ? totalAllocated - totalClaimedJS : 0n; let expectRevert = null; if (paused) expectRevert = "Pausable: paused"; else if (curTime <= expiry) expectRevert = "ClawbackTooEarly"; else if (bal <= reserved) expectRevert = "NothingToClawback"; if (expectRevert) { if (expectRevert === "Pausable: paused") { await expect(dist.clawbackUnclaimed(toSink)).to.be.revertedWith("Pausable: paused"); if (curTime > expiry) pauseClawbackBlocks++; // paused blocks a post-expiry sweep } else { await expect(dist.clawbackUnclaimed(toSink)).to.be.revertedWithCustomError(dist, expectRevert); } } else { const amount = bal - reserved; const dest = toSink ? await sink.getAddress() : owner.address; const destBefore = await ethers.provider.getBalance(dest); const tx = await dist.clawbackUnclaimed(toSink); const rc = await tx.wait(); const after = await ethers.provider.getBalance(distAddr); // Contract keeps EXACTLY the reserve; only overfunded dust left. expect(after, `[dist CLAWBACK] contract not left at reserve (c${c} s${s})`).to.equal(reserved); if (toSink) { expect((await ethers.provider.getBalance(dest)) - destBefore, `[dist CLAWBACK-DEST] sink delta wrong`).to.equal(amount); } else { // owner pays gas; net = amount - gasCost. const gas = rc.gasUsed * rc.gasPrice; expect((await ethers.provider.getBalance(dest)) - destBefore, `[dist CLAWBACK-DEST] owner delta wrong`).to.equal(amount - gas); } dustRecovered++; clawbacks++; } } await assertInvariants(action); // Resync the clock: non-advance actions (setExcluded/pause/unpause and // reverting txs) auto-bump the block timestamp by 1 without touching // curTime, so re-anchor curTime to the real chain head each step. This // keeps setNextBlockTimestamp strictly increasing and keeps claim/clawback // vesting predictions exact. curTime = Number(await time.latest()); steps++; } // ---- Campaign finale part A: GUARANTEED pause-then-clawback residual probe ---- // The exact FINDING-2 residual attack: owner pauses, jumps PAST expiry, then // tries to sweep the balance (which still reserves every claimant's vested-but- // unclaimed entitlement). Both sink and owner destinations MUST be blocked by // whenNotPaused, and the contract balance MUST be untouched. if (!paused) { await (await dist.pause()).wait(); paused = true; } curTime = Math.max(Number(await time.latest()) + 1, expiry + 1 + ri(rng, 1, 1000)); await time.setNextBlockTimestamp(curTime); await network.provider.send("evm_mine"); { const balBefore = await ethers.provider.getBalance(distAddr); await expect(dist.clawbackUnclaimed(false), `[dist PAUSE-CLAWBACK owner]`).to.be.revertedWith("Pausable: paused"); await expect(dist.clawbackUnclaimed(true), `[dist PAUSE-CLAWBACK sink]`).to.be.revertedWith("Pausable: paused"); expect(await ethers.provider.getBalance(distAddr), `[dist PAUSE-CLAWBACK] balance moved while paused`).to.equal(balBefore); pauseClawbackBlocks++; } await assertInvariants("paused-clawback-blocked"); // ---- Campaign finale part B: legit post-expiry dust recovery still works ---- await (await dist.unpause()).wait(); paused = false; const balF = await ethers.provider.getBalance(distAddr); const reservedF = totalAllocated > totalClaimedJS ? totalAllocated - totalClaimedJS : 0n; if (balF > reservedF) { await (await dist.clawbackUnclaimed(true)).wait(); expect(await ethers.provider.getBalance(distAddr), `[dist FINALE] not at reserve`).to.equal(reservedF); dustRecovered++; } else { await expect(dist.clawbackUnclaimed(true)).to.be.revertedWithCustomError(dist, "NothingToClawback"); } // The full reserve (every claimant's remaining entitlement) survives forever. await assertInvariants("finale"); } console.log(` [dist] steps=${steps} claims=${claims} clawbacks=${clawbacks} dustSweeps=${dustRecovered} excludedBlocked=${excludedBlocks} pausedClawbackBlocked=${pauseClawbackBlocks}`); expect(steps).to.be.greaterThan(500); expect(claims).to.be.greaterThan(50); expect(dustRecovered).to.be.greaterThan(0); expect(pauseClawbackBlocks).to.be.greaterThan(0); // the pause-then-clawback variant was exercised & blocked }); }); // ============================================================================= // 3. AereHistoryStateRootAnchorV2 — double-verify removed (FINDING 4) // ============================================================================= describe("INVARIANT: AereHistoryStateRootAnchorV2 single-verify (FINDING 4)", function () { this.timeout(0); const { encodeHeader } = require("./header-rlp"); const genesis = require("./fixtures/aere-genesis-header.json"); let verifierF, anchorF; before(async function () { verifierF = await ethers.getContractFactory("CountingStorageProofVerifier"); anchorF = await ethers.getContractFactory("MockHistoryAnchorV2"); console.log(` [anchor] base seed 0x${BASE_SEED.toString(16)}, ${N.ANCHOR_CAMPAIGNS} campaigns x ${N.ANCHOR_STEPS} steps`); }); function publicValues({ chainId, blockNumber, stateRoot, account, slot, value }) { return abi.encode( ["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"], [chainId, blockNumber, stateRoot, account, slot, value] ); } const rand32 = (rng) => "0x" + Array.from({ length: 32 }, () => ri(rng, 0, 255).toString(16).padStart(2, "0")).join(""); const randAddr = (rng) => ethers.getAddress("0x" + Array.from({ length: 20 }, () => ri(rng, 0, 255).toString(16).padStart(2, "0")).join("")); it("exactly one SP1 verify per prove; only anchored+bound roots record; verify() never called", async function () { let steps = 0, successes = 0, notAnchored = 0, rootMismatch = 0, badLen = 0, invalidProof = 0, crossBlock = 0; for (let c = 0; c < N.ANCHOR_CAMPAIGNS; c++) { const rng = rngFor("anchor", c); const verifier = await verifierF.deploy(); await verifier.waitForDeployment(); const anchor = await anchorF.deploy(await verifier.getAddress()); await anchor.waitForDeployment(); // Anchor a random set of blocks, each with a DISTINCT well-formed state root. // Trick (from the existing suite): overwrite only the genesis header's // stateRoot, re-encode -> new keccak canonical hash -> inject -> anchor. const anchored = {}; // blockNumber(string) -> root const anchoredNums = []; const nAnchor = ri(rng, 1, 5); const usedRoots = new Set(); for (let i = 0; i < nAnchor; i++) { const bn = BigInt(ri(rng, 1, 2000000)); if (anchored[bn.toString()]) continue; let root = rand32(rng); while (usedRoots.has(root)) root = rand32(rng); usedRoots.add(root); const header = encodeHeader({ ...genesis, stateRoot: root }); const hash = ethers.keccak256(header); await (await anchor.setHistoryBlockHash(bn, hash)).wait(); await (await anchor.anchorHistoricalHeader(bn, header)).wait(); expect(await anchor.anchoredStateRoot(bn)).to.equal(root); anchored[bn.toString()] = root; anchoredNums.push(bn); } let expectedSubmitCalls = 0n; let shouldValidate = true; for (let s = 0; s < N.ANCHOR_STEPS; s++) { const action = pick(rng, ["prove", "prove", "prove", "prove", "toggleValidate", "anchorMore"]); if (action === "toggleValidate") { shouldValidate = chance(rng, 0.5); await (await verifier.setValidate(shouldValidate)).wait(); expect(await verifier.submitCalls(), `[anchor CALLS] setValidate changed submitCalls`).to.equal(expectedSubmitCalls); steps++; continue; } if (action === "anchorMore" && chance(rng, 0.7)) { const bn = BigInt(ri(rng, 1, 2000000)); if (!anchored[bn.toString()]) { let root = rand32(rng); while (usedRoots.has(root)) root = rand32(rng); usedRoots.add(root); const header = encodeHeader({ ...genesis, stateRoot: root }); const hash = ethers.keccak256(header); await (await anchor.setHistoryBlockHash(bn, hash)).wait(); await (await anchor.anchorHistoricalHeader(bn, header)).wait(); anchored[bn.toString()] = root; anchoredNums.push(bn); } expect(await verifier.submitCalls(), `[anchor CALLS] anchoring changed submitCalls`).to.equal(expectedSubmitCalls); steps++; continue; } // ---- build a random proveAgainstAnchor call ---- let mode = pick(rng, ["valid", "valid", "wrongRoot", "notAnchored", "badLen", "crossBlock"]); const proof = "0x"; let pv; const account = randAddr(rng); const slot = rand32(rng); const value = rand32(rng); const chainId = BigInt(ri(rng, 1, 5000)); // Degrade modes that need anchored blocks when none / too few exist yet. if ((mode === "valid" || mode === "wrongRoot" || mode === "crossBlock") && anchoredNums.length === 0) { mode = chance(rng, 0.5) ? "badLen" : "notAnchored"; } if (mode === "crossBlock" && anchoredNums.length < 2) mode = "valid"; if (mode === "badLen") { const lens = [0, 32, 64, 96, 128, 160, 224, 256, 191, 193, 320]; // all != 192 const len = pick(rng, lens); pv = "0x" + "11".repeat(len); await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor BADLEN len=${len}] (c${c} s${s})`) .to.be.revertedWithCustomError(anchor, "BadPublicValuesLength"); expect(await verifier.submitCalls(), `[anchor CALLS] badLen moved submitCalls`).to.equal(expectedSubmitCalls); badLen++; steps++; continue; } if (mode === "notAnchored") { let naBn; do { naBn = BigInt(ri(rng, 2000001, 4000000)); } while (anchored[naBn.toString()]); pv = publicValues({ chainId, blockNumber: naBn, stateRoot: rand32(rng), account, slot, value }); await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor NOTANCHORED bn=${naBn}] (c${c} s${s})`) .to.be.revertedWithCustomError(anchor, "NotAnchored"); expect(await verifier.submitCalls(), `[anchor CALLS] NotAnchored moved submitCalls`).to.equal(expectedSubmitCalls); notAnchored++; steps++; continue; } // The remaining modes (crossBlock/wrongRoot/valid) reach the mock verifier's // submit. Its FIRST gate is shouldValidate (reverts "InvalidProof") BEFORE the // root-binding check, so a submit-reaching call with validation OFF reverts // "InvalidProof" regardless of the root; with validation ON, a root mismatch // reverts "StateRootMismatch". Either way submitCalls must NOT advance and // nothing is recorded. if (mode === "crossBlock") { const a = pick(rng, anchoredNums); let b = pick(rng, anchoredNums), guard = 0; while (anchored[b.toString()] === anchored[a.toString()] && guard++ < 20) b = pick(rng, anchoredNums); if (anchored[b.toString()] === anchored[a.toString()]) { mode = "valid"; // could not find a differing block; fall through to a valid prove } else { // Proof genuinely valid for block A's root, redirected at block B (different anchored root). pv = publicValues({ chainId, blockNumber: b, stateRoot: anchored[a.toString()], account, slot, value }); const reason = shouldValidate ? "StateRootMismatch" : "InvalidProof"; await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor CROSSBLOCK a=${a} b=${b} validate=${shouldValidate}] (c${c} s${s})`) .to.be.revertedWith(reason); expect(await verifier.submitCalls(), `[anchor CALLS] crossBlock moved submitCalls`).to.equal(expectedSubmitCalls); expect(await verifier.recorded(ethers.keccak256(pv)), `[anchor RECORD] crossBlock recorded`).to.equal(false); crossBlock++; steps++; continue; } } const bn = pick(rng, anchoredNums); const trueRoot = anchored[bn.toString()]; if (mode === "wrongRoot") { let bad = rand32(rng); while (bad === trueRoot) bad = rand32(rng); pv = publicValues({ chainId, blockNumber: bn, stateRoot: bad, account, slot, value }); const reason = shouldValidate ? "StateRootMismatch" : "InvalidProof"; await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor WRONGROOT bn=${bn} validate=${shouldValidate}] (c${c} s${s})`) .to.be.revertedWith(reason); expect(await verifier.submitCalls(), `[anchor CALLS] wrongRoot moved submitCalls`).to.equal(expectedSubmitCalls); expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(false); rootMismatch++; steps++; continue; } // mode === "valid": committed root == anchored root. pv = publicValues({ chainId, blockNumber: bn, stateRoot: trueRoot, account, slot, value }); if (!shouldValidate) { // Invalid underlying SP1 proof: the single verification still gates recording. await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor INVALIDPROOF bn=${bn}] (c${c} s${s})`) .to.be.revertedWith("InvalidProof"); expect(await verifier.submitCalls(), `[anchor CALLS] invalidProof moved submitCalls`).to.equal(expectedSubmitCalls); expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(false); invalidProof++; steps++; continue; } // Happy path: EXACTLY ONE verification, records, emits with the ANCHORED root. await expect(anchor.proveAgainstAnchor(pv, proof), `[anchor VALID bn=${bn}] (c${c} s${s})`) .to.emit(anchor, "ProvenAgainstAnchor") .withArgs(bn, trueRoot, account, slot, value); expectedSubmitCalls += 1n; expect(await verifier.submitCalls(), `[anchor ONCE] submitCalls != expected (c${c} s${s})`).to.equal(expectedSubmitCalls); expect(await verifier.recorded(ethers.keccak256(pv)), `[anchor RECORD] valid not recorded`).to.equal(true); successes++; steps++; } // End-of-campaign: the cumulative verify count equals exactly the successes. expect(await verifier.submitCalls(), `[anchor CUMULATIVE] verify count != successes (c${c})`).to.equal(expectedSubmitCalls); } console.log(` [anchor] steps=${steps} success=${successes} notAnchored=${notAnchored} wrongRoot=${rootMismatch} crossBlock=${crossBlock} badLen=${badLen} invalidProof=${invalidProof}`); expect(steps).to.be.greaterThan(300); expect(successes).to.be.greaterThan(30); expect(rootMismatch + crossBlock).to.be.greaterThan(0); }); });