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

170 lines
7.4 KiB
JavaScript

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
// AUDIT FINDING 2 (medium) — Season 1 distributor confiscation.
// setExcluded has no guard against excluding an account that has ALREADY
// partially claimed. Owner excludes such an account -> claim() then reverts
// IsExcluded -> clawbackUnclaimed sweeps the ENTIRE remaining balance
// (including that user's vested-but-unclaimed AERE) after expiry. That
// contradicts the advertised invariant that the owner CANNOT redirect a
// valid, vested claim away from its rightful account.
//
// This suite reproduces the confiscation against the flawed contract and
// proves AereRewardDistributorV2 closes it while preserving the legit uses:
// (a) excluding a never-claimed account still works, and
// (b) legit clawback of truly-unallocated funds still works.
const abi = ethers.AbiCoder.defaultAbiCoder();
/* ----------------------------- merkle helpers ----------------------------- */
function leafHash(account, total) {
const inner = ethers.keccak256(abi.encode(["address", "uint256"], [account, total]));
return ethers.keccak256(inner);
}
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]);
}
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;
}
function makeTree(entries) {
const leaves = entries.map((e) => leafHash(e.account, e.total));
const layers = buildLayers(leaves);
const root = layers[layers.length - 1][0];
const indexOf = {};
entries.forEach((e, i) => (indexOf[e.account.toLowerCase()] = i));
return { root, proofFor: (account) => getProof(layers, indexOf[account.toLowerCase()]) };
}
const E = ethers.parseEther;
// Deploy helper parameterized by contract name so we can point the same
// scenario at the flawed V1 and the corrected V2.
async function deployDist(name, waere, saere, sink) {
return (await ethers.getContractFactory(name)).deploy(
await waere.getAddress(),
await saere.getAddress(),
await sink.getAddress()
);
}
describe("AERE Season 1 — Distributor confiscation (FINDING 2)", () => {
let waere, saere, sink, owner, alice, bob, relayer;
beforeEach(async () => {
[owner, alice, bob, relayer] = await ethers.getSigners();
waere = await (await ethers.getContractFactory("WAERE")).deploy();
saere = await (await ethers.getContractFactory("MockSAERE")).deploy(await waere.getAddress());
sink = await (await ethers.getContractFactory("MockSink")).deploy();
});
// The core attack: a partial claimer must NOT be excludable, so the owner
// can never freeze-then-clawback their vested-but-unclaimed AERE.
it("owner CANNOT exclude an account that has already claimed (confiscation blocked)", async () => {
const dist = await deployDist("AereRewardDistributorV2", waere, saere, sink);
const tree = makeTree([
{ account: alice.address, total: E("100") },
{ account: bob.address, total: E("50") },
]);
const now = await time.latest();
const expiry = now + 10_000_000;
await dist.publishRoot(tree.root, 0, 1000, expiry, false, E("150"));
await owner.sendTransaction({ to: await dist.getAddress(), value: E("150") });
// Alice partially claims at half vest (50 of 100).
const start = Number(await dist.rootPublishedAt());
await time.setNextBlockTimestamp(start + 500);
await dist.connect(relayer).claim(alice.address, E("100"), tree.proofFor(alice.address));
expect(await dist.claimed(alice.address)).to.equal(E("50"));
// The confiscation weapon: excluding a partial claimer. Must revert.
await expect(dist.setExcluded(alice.address, true)).to.be.revertedWithCustomError(
dist,
"AlreadyClaimed"
);
// Alice can therefore still claim her remaining vested AERE.
await time.setNextBlockTimestamp(start + 1000 + 10);
const balBefore = await ethers.provider.getBalance(alice.address);
await dist.connect(relayer).claim(alice.address, E("100"), tree.proofFor(alice.address));
const balAfter = await ethers.provider.getBalance(alice.address);
expect(balAfter - balBefore).to.equal(E("50"));
expect(await dist.claimed(alice.address)).to.equal(E("100"));
});
// Legit use (a): excluding a never-claimed sybil still works (and re-include).
it("still allows excluding a never-claimed account (sybil revocation)", async () => {
const dist = await deployDist("AereRewardDistributorV2", waere, saere, sink);
const tree = makeTree([
{ account: alice.address, total: E("100") },
{ account: bob.address, total: E("50") },
]);
const now = await time.latest();
await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false, E("150"));
await owner.sendTransaction({ to: await dist.getAddress(), value: E("150") });
// bob never claimed -> exclude allowed.
await dist.setExcluded(bob.address, true);
await time.increase(20);
await expect(
dist.claim(bob.address, E("50"), tree.proofFor(bob.address))
).to.be.revertedWithCustomError(dist, "IsExcluded");
// re-include is always permitted, and then bob can claim.
await dist.setExcluded(bob.address, false);
await dist.claim(bob.address, E("50"), tree.proofFor(bob.address));
expect(await dist.claimed(bob.address)).to.equal(E("50"));
});
// Legit use (b): after expiry, clawback of genuinely-unallocated funds (the
// balance beyond every leaf's reserved entitlement) still works. Here Alice
// fully claims her 100 and the distributor was overfunded by 10 of dust; only
// that 10 is recoverable, never a claimant's owed AERE.
it("still allows legit post-expiry clawback of unallocated dust", async () => {
const dist = await deployDist("AereRewardDistributorV2", waere, saere, sink);
const tree = makeTree([{ account: alice.address, total: E("100") }]);
const now = await time.latest();
const expiry = now + 5000;
await dist.publishRoot(tree.root, 0, 1000, expiry, false, E("100"));
// Overfund by 10 AERE of dust no leaf is entitled to.
await owner.sendTransaction({ to: await dist.getAddress(), value: E("110") });
// Alice claims her full entitlement (past full vest).
await time.increaseTo(now + 1000 + 10);
await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
expect(await dist.claimed(alice.address)).to.equal(E("100"));
// After expiry, reserve is 0 (fully claimed); only the 10 dust is clawable.
await time.increaseTo(expiry + 1);
const sinkBefore = await ethers.provider.getBalance(await sink.getAddress());
await dist.clawbackUnclaimed(true);
const sinkAfter = await ethers.provider.getBalance(await sink.getAddress());
expect(sinkAfter - sinkBefore).to.equal(E("10"));
expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);
});
});