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.
235 lines
10 KiB
JavaScript
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"));
|
|
});
|
|
});
|