// Hardhat tests for AereRetroPGFRound1. // Covers: fund → propose → challenge → dismiss → finalize → claim path, // per-category cap enforcement, leaf replay protection, and sweep window. // // Run: npx hardhat test test/retropgf-round1.test.js const { expect } = require("chai"); const { ethers } = require("hardhat"); const ONE = 10n ** 18n; const TOTAL = 10_000_000n * ONE; // Per immediate_build_plan default split (10M / 6 buckets): // 1 Infrastructure, 2 Dev tooling, 3 Applications, 4 Education, 5 Security, 6 Buffer const CATEGORY_CAPS = [ 2_500_000n * ONE, // 0 Infrastructure 2_000_000n * ONE, // 1 Dev tooling 2_500_000n * ONE, // 2 Applications 1_000_000n * ONE, // 3 Education 1_500_000n * ONE, // 4 Security 500_000n * ONE, // 5 Buffer ]; async function deployWithMerkle(leaves) { const [deployer, reserve, alice, bob] = await ethers.getSigners(); // WAERE (reuse existing). const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy(); await WAERE.waitForDeployment(); // Round1. const Round = await ethers.getContractFactory("AereRetroPGFRound1"); const round = await Round.deploy(await WAERE.getAddress(), reserve.address, TOTAL, CATEGORY_CAPS); await round.waitForDeployment(); // Fund the deployer with 10M WAERE. // We can wrap from hardhat funded ETH. Hardhat default is 10k ETH; mainnet // genesis allocation here is irrelevant. Wrap 10M ETH? hardhat default isn't // enough. Instead, monkey-patch by directly minting via .deposit() chunks // is impossible — we don't have 10M ETH. Use a smaller TOTAL for tests // by deploying with smaller caps. // Alternative: deploy a fresh round with smaller TOTAL for tests. return { deployer, reserve, alice, bob, WAERE, round }; } // Smaller test fixture so we can fund within hardhat default balance. async function deploySmall() { const [deployer, reserve, alice, bob, carol] = await ethers.getSigners(); const TOTAL_SMALL = 600n * ONE; const CATEGORY_CAPS_SMALL = [100n * ONE, 100n * ONE, 100n * ONE, 100n * ONE, 100n * ONE, 100n * ONE]; const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy(); await WAERE.waitForDeployment(); const Round = await ethers.getContractFactory("AereRetroPGFRound1"); const round = await Round.deploy(await WAERE.getAddress(), reserve.address, TOTAL_SMALL, CATEGORY_CAPS_SMALL); await round.waitForDeployment(); return { deployer, reserve, alice, bob, carol, WAERE, round, TOTAL: TOTAL_SMALL }; } // We use a custom leaf-encoding that matches the contract's keccak256(abi.encode(recipient, category, amount)). // OpenZeppelin's StandardMerkleTree double-hashes; the contract uses single-hash. Implement minimal Merkle manually. function leafOf(recipient, category, amount) { return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["address", "uint8", "uint256"], [recipient, category, amount])); } function buildSimpleTree(leaves) { // For tests: small N. Sort + pair + hash. let layer = leaves.slice().map(l => l.toLowerCase()); layer.sort(); const layers = [layer]; while (layer.length > 1) { const next = []; for (let i = 0; i < layer.length; i += 2) { const a = layer[i]; const b = layer[i + 1] ?? layer[i]; const [lo, hi] = a < b ? [a, b] : [b, a]; next.push(ethers.keccak256(ethers.concat([lo, hi]))); } layer = next; layers.push(layer); } return { root: layer[0], layers }; } function proofFor(tree, leaf) { const proof = []; let target = leaf.toLowerCase(); for (let i = 0; i < tree.layers.length - 1; i++) { const lay = tree.layers[i]; const idx = lay.indexOf(target); if (idx < 0) throw new Error("leaf not in layer"); const sib = idx ^ 1; if (sib < lay.length) proof.push(lay[sib]); // Move up. const a = lay[idx]; const b = lay[sib] ?? lay[idx]; const [lo, hi] = a < b ? [a, b] : [b, a]; target = ethers.keccak256(ethers.concat([lo, hi])); } return proof; } describe("AereRetroPGFRound1", function () { it("constructor requires category caps sum == total", async function () { const [deployer, reserve] = await ethers.getSigners(); const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy(); const Round = await ethers.getContractFactory("AereRetroPGFRound1"); const badCaps = [50n * ONE, 50n * ONE, 50n * ONE, 50n * ONE, 50n * ONE, 50n * ONE]; // 300, not 600 await expect(Round.deploy(await WAERE.getAddress(), reserve.address, 600n * ONE, badCaps)).to.be.revertedWithCustomError(Round, "CapsMustSumToTotal"); }); it("fund + propose + finalize + claim flow", async function () { const { deployer, reserve, alice, bob, carol, WAERE, round, TOTAL } = await deploySmall(); // Fund WAERE. await WAERE.connect(deployer).deposit({ value: TOTAL }); await WAERE.connect(deployer).approve(await round.getAddress(), TOTAL); await round.connect(deployer).fund(); // Build a small payout tree: alice gets 50e18 in cat 0; bob gets 30e18 in cat 1. const a_amt = 50n * ONE; const b_amt = 30n * ONE; const lAlice = leafOf(alice.address, 0, a_amt); const lBob = leafOf(bob.address, 1, b_amt); const tree = buildSimpleTree([lAlice, lBob]); await round.connect(deployer).proposePayoutRoot(tree.root); // Try to claim before finalize: should revert WrongState. await expect(round.connect(alice).claim(alice.address, 0, a_amt, proofFor(tree, lAlice))).to.be.revertedWithCustomError(round, "WrongState"); // Try to finalize early. await expect(round.finalize()).to.be.revertedWithCustomError(round, "TimelockNotElapsed"); // Advance 14 days. await ethers.provider.send("evm_increaseTime", [14 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await round.finalize(); // Alice claims. const aliceBefore = await WAERE.balanceOf(alice.address); await round.connect(alice).claim(alice.address, 0, a_amt, proofFor(tree, lAlice)); expect(await WAERE.balanceOf(alice.address)).to.equal(aliceBefore + a_amt); // Double-claim reverts. await expect(round.connect(alice).claim(alice.address, 0, a_amt, proofFor(tree, lAlice))).to.be.revertedWithCustomError(round, "AlreadyClaimed"); // Bob claims. await round.connect(bob).claim(bob.address, 1, b_amt, proofFor(tree, lBob)); expect(await WAERE.balanceOf(bob.address)).to.equal(b_amt); }); it("rejects claim with wrong proof", async function () { const { deployer, alice, WAERE, round, TOTAL } = await deploySmall(); await WAERE.connect(deployer).deposit({ value: TOTAL }); await WAERE.connect(deployer).approve(await round.getAddress(), TOTAL); await round.connect(deployer).fund(); const l = leafOf(alice.address, 0, 50n * ONE); const tree = buildSimpleTree([l, leafOf("0x" + "00".repeat(20), 1, 10n * ONE)]); await round.connect(deployer).proposePayoutRoot(tree.root); await ethers.provider.send("evm_increaseTime", [14 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await round.finalize(); // Use a junk proof. await expect( round.connect(alice).claim(alice.address, 0, 50n * ONE, [ethers.ZeroHash]) ).to.be.revertedWithCustomError(round, "InvalidProof"); }); it("category cap enforcement: overflow reverts", async function () { const { deployer, alice, WAERE, round, TOTAL } = await deploySmall(); await WAERE.connect(deployer).deposit({ value: TOTAL }); await WAERE.connect(deployer).approve(await round.getAddress(), TOTAL); await round.connect(deployer).fund(); // Each category cap is 100 ONE; this leaf claims 101 from cat 2. const l = leafOf(alice.address, 2, 101n * ONE); const tree = buildSimpleTree([l]); await round.connect(deployer).proposePayoutRoot(tree.root); await ethers.provider.send("evm_increaseTime", [14 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await round.finalize(); await expect( round.connect(alice).claim(alice.address, 2, 101n * ONE, proofFor(tree, l)) ).to.be.revertedWithCustomError(round, "CategoryOverflow"); }); it("challenge flow: anyone can challenge; owner can dismiss; window doesn't reset", async function () { const { deployer, alice, bob, WAERE, round, TOTAL } = await deploySmall(); await WAERE.connect(deployer).deposit({ value: TOTAL }); await WAERE.connect(deployer).approve(await round.getAddress(), TOTAL); await round.connect(deployer).fund(); const l = leafOf(alice.address, 0, 50n * ONE); const tree = buildSimpleTree([l]); await round.connect(deployer).proposePayoutRoot(tree.root); // Bob challenges. await round.connect(bob).challenge("alice doesnt deserve cat 0"); // State is Challenged → cannot finalize even after window. await ethers.provider.send("evm_increaseTime", [14 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await expect(round.finalize()).to.be.revertedWithCustomError(round, "WrongState"); // Owner dismisses; state returns to Proposed; CAN finalize now (window already passed). await round.connect(deployer).dismissChallenge(0, "evidence below shows alice DOES deserve"); await round.finalize(); // Alice claims. await round.connect(alice).claim(alice.address, 0, 50n * ONE, proofFor(tree, l)); }); it("sweep unclaimed after 365 days returns funds to ecosystem reserve", async function () { const { deployer, reserve, alice, WAERE, round, TOTAL } = await deploySmall(); await WAERE.connect(deployer).deposit({ value: TOTAL }); await WAERE.connect(deployer).approve(await round.getAddress(), TOTAL); await round.connect(deployer).fund(); const l = leafOf(alice.address, 0, 50n * ONE); const tree = buildSimpleTree([l]); await round.connect(deployer).proposePayoutRoot(tree.root); await ethers.provider.send("evm_increaseTime", [14 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await round.finalize(); // Alice claims her 50. await round.connect(alice).claim(alice.address, 0, 50n * ONE, proofFor(tree, l)); const remaining = TOTAL - 50n * ONE; // Try to sweep early. await expect(round.sweepUnclaimed()).to.be.revertedWithCustomError(round, "SweepWindowNotElapsed"); await ethers.provider.send("evm_increaseTime", [365 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await round.sweepUnclaimed(); expect(await WAERE.balanceOf(reserve.address)).to.equal(remaining); }); });