aere-contracts/test/invariant-audit-v2-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

872 lines
44 KiB
JavaScript

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