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.
526 lines
24 KiB
JavaScript
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);
|
|
});
|
|
});
|