aere-contracts/test/retropgf-round1-invariant-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

526 lines
24 KiB
JavaScript

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