const { expect } = require("chai"); const { ethers } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); const E = ethers.parseEther; /* ----------------------------- merkle helpers ----------------------------- * AereMiningDistributor leaf = keccak256(abi.encodePacked(index, account, amount)) * index : uint256 * account: address * amount : uint256 * Internal nodes use sorted-pair keccak256 hashing (matches contract _verify). * -------------------------------------------------------------------------- */ function leafHash(index, account, amount) { return ethers.solidityPackedKeccak256( ["uint256", "address", "uint256"], [index, account, amount] ); } 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]); // promote odd node } 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; } // entries: [{ index, account, amount }] -> { root, proofFor(index) } function makeTree(entries) { const leaves = entries.map((e) => leafHash(e.index, e.account, e.amount)); const layers = buildLayers(leaves); const root = layers[layers.length - 1][0]; return { root, proofFor: (index) => getProof(layers, index) }; } /* ================================= TESTS ================================== */ describe("AereMiningDistributor", () => { let dist, owner, alice, bob, carol; beforeEach(async () => { [owner, alice, bob, carol] = await ethers.getSigners(); dist = await (await ethers.getContractFactory("AereMiningDistributor")).deploy(); }); async function fund(amount) { await owner.sendTransaction({ to: await dist.getAddress(), value: amount }); } /* --------------------------- baseline behaviour --------------------------- */ it("deploys with no epochs and owner = deployer", async () => { expect(await dist.epochCount()).to.equal(0); expect(await dist.owner()).to.equal(owner.address); expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0); }); it("posts an epoch and pays a valid claimant in full", async () => { const tree = makeTree([ { index: 0, account: alice.address, amount: E("60") }, { index: 1, account: bob.address, amount: E("40") }, ]); await fund(E("100")); const deadline = (await time.latest()) + 30 * 24 * 3600; await dist.postEpoch(tree.root, E("100"), deadline); const before = await ethers.provider.getBalance(alice.address); // relayer (owner) submits; alice receives, pays no gas await dist.connect(owner).claim(0, 0, alice.address, E("60"), tree.proofFor(0)); const after = await ethers.provider.getBalance(alice.address); expect(after - before).to.equal(E("60")); expect(await dist.isClaimed(0, 0)).to.equal(true); // double-claim reverts await expect( dist.claim(0, 0, alice.address, E("60"), tree.proofFor(0)) ).to.be.revertedWith("MiningDist: already claimed"); }); it("rejects a bad proof / wrong amount", async () => { const tree = makeTree([ { index: 0, account: alice.address, amount: E("60") }, { index: 1, account: bob.address, amount: E("40") }, ]); await fund(E("100")); const deadline = (await time.latest()) + 30 * 24 * 3600; await dist.postEpoch(tree.root, E("100"), deadline); await expect( dist.claim(0, 0, alice.address, E("999"), tree.proofFor(0)) ).to.be.revertedWith("MiningDist: bad proof"); }); it("postEpoch reverts underfunded when balance < live + total", async () => { const tree = makeTree([{ index: 0, account: alice.address, amount: E("100") }]); await fund(E("99")); // one wei short of a single 100 epoch const deadline = (await time.latest()) + 30 * 24 * 3600; await expect( dist.postEpoch(tree.root, E("100"), deadline) ).to.be.revertedWith("MiningDist: underfunded"); }); it("reserves funds for TWO concurrent in-window epochs (must fund both)", async () => { const a = makeTree([{ index: 0, account: alice.address, amount: E("100") }]); const b = makeTree([{ index: 0, account: bob.address, amount: E("100") }]); await fund(E("100")); const d1 = (await time.latest()) + 30 * 24 * 3600; await dist.postEpoch(a.root, E("100"), d1); // epoch0 still live -> its 100 is reserved, so epoch1 needs another 100 const d2 = (await time.latest()) + 30 * 24 * 3600; await expect(dist.postEpoch(b.root, E("100"), d2)).to.be.revertedWith( "MiningDist: underfunded" ); await fund(E("100")); // total 200 await dist.postEpoch(b.root, E("100"), d2); expect(await dist.epochCount()).to.equal(2); }); /* --------------------------- sweep behaviour ------------------------------ */ it("sweepUnclaimed reverts before deadline, sends unclaimed to owner after", async () => { const tree = makeTree([{ index: 0, account: alice.address, amount: E("100") }]); await fund(E("100")); const deadline = (await time.latest()) + 1000; await dist.postEpoch(tree.root, E("100"), deadline); await expect(dist.sweepUnclaimed(0)).to.be.revertedWith("MiningDist: too early"); await time.increaseTo(deadline + 1); const distAddr = await dist.getAddress(); expect(await ethers.provider.getBalance(distAddr)).to.equal(E("100")); // measure owner delta net of gas via a non-owner-less path: use static call then tx await dist.sweepUnclaimed(0); expect(await ethers.provider.getBalance(distAddr)).to.equal(0); // double-sweep reverts await expect(dist.sweepUnclaimed(0)).to.be.revertedWith("MiningDist: already swept"); }); /* ---------------- THE FIX: expired-unswept reserve is protected ----------- * * Regression for the confirmed HIGH: an epoch past its claimDeadline but not * yet swept still physically holds its unclaimed AERE. Before the fix, * _liveFunds() dropped it the instant it expired, so postEpoch treated that * still-present reserve as free and let it back a brand-new epoch — while * sweepUnclaimed could independently send the SAME wei to the owner. The * same funds were counted twice and an honest next-epoch claimant was * stranded on a zero balance. * ------------------------------------------------------------------------- */ it("expired-unswept reserve can NOT back a new epoch until it is swept (double-count fix)", async () => { // balance = 100 backs epoch0. const a = makeTree([{ index: 0, account: alice.address, amount: E("100") }]); await fund(E("100")); const dA = (await time.latest()) + 1000; await dist.postEpoch(a.root, E("100"), dA); // epoch0 // Nobody claims; epoch0 expires (unswept, totalClaimed = 0). await time.increaseTo(dA + 1); // The exact strand sequence's post1 step: with the SAME 100 balance, posting // epoch1 for another 100 MUST now revert — epoch0's 100 is still reserved // because it has not been swept. const b = makeTree([{ index: 0, account: bob.address, amount: E("100") }]); const dB = (await time.latest()) + 30 * 24 * 3600; await expect(dist.postEpoch(b.root, E("100"), dB)).to.be.revertedWith( "MiningDist: underfunded" ); // Sweep epoch0 first (recovers the 100 to the owner, sets swept = true so the // reserve leaves _liveFunds exactly once); contract balance -> 0. await dist.sweepUnclaimed(0); expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0); // Even now epoch1 cannot be posted against an empty contract — the swept // funds went to the owner, who must re-fund to legitimately back epoch1. await expect(dist.postEpoch(b.root, E("100"), dB)).to.be.revertedWith( "MiningDist: underfunded" ); // Owner re-funds with the recovered 100; now epoch1 posts and its claimant // is paid IN FULL — no strand. await fund(E("100")); await dist.postEpoch(b.root, E("100"), dB); // epoch1 const before = await ethers.provider.getBalance(bob.address); await dist.connect(owner).claim(1, 0, bob.address, E("100"), b.proofFor(0)); const after = await ethers.provider.getBalance(bob.address); expect(after - before).to.equal(E("100")); }); it("the pre-fix strand sequence (post0 -> expire -> post1 -> sweep0 -> claim1) can no longer strand a claimant", async () => { // This reproduces the reported failure path. Under the OLD code, post1 // succeeded (100 >= 0 + 100 because expired epoch0 was dropped from // _liveFunds), then sweep0 drained the 100 to the owner, and epoch1's // claimant reverted on "payout failed". With the fix, the sequence is // stopped dead at post1 (underfunded), so the claimant can never be // stranded via this path. const a = makeTree([{ index: 0, account: alice.address, amount: E("100") }]); const b = makeTree([{ index: 0, account: bob.address, amount: E("100") }]); await fund(E("100")); const dA = (await time.latest()) + 1000; await dist.postEpoch(a.root, E("100"), dA); // epoch0 await time.increaseTo(dA + 1); // epoch0 expired, unswept, unclaimed const dB = (await time.latest()) + 30 * 24 * 3600; // The double-spend attempt: reuse epoch0's still-present 100 to back epoch1. await expect(dist.postEpoch(b.root, E("100"), dB)).to.be.revertedWith( "MiningDist: underfunded" ); // Because post1 reverted, epochCount is still 1 and no epoch1 claim exists // to be stranded. The funds remain fully backing epoch0 until swept. expect(await dist.epochCount()).to.equal(1); expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(E("100")); }); });