aere-contracts/test/mining-distributor.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

235 lines
10 KiB
JavaScript

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"));
});
});