aere-contracts/test/audit-fix-v2-raas-compliance-2026-07.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

372 lines
20 KiB
JavaScript

// AUDIT FIX VERIFICATION — AereRollupSettlementV2 + AereRaaSFactoryV2 + AereCompliancePoolV2 — 2026-07-10
//
// For each confirmed finding: V1 REPRODUCES the bug, V2 BLOCKS/CORRECTS it.
// Runs fully local against the REAL contract sources (same source deployed to chain 2800).
//
// F6 last-block challenge grief — V2 grace lets the sequencer defend; griefer bond slashed to sink
// F7 rejected epoch freezes final — V2 re-proposes a rejected epoch; finalisation resumes
// ANC isFinalised ancestor unsafe — V2 isFinalised is ancestor-aware (descendant of a gap != final)
// F8 compliance challenge bond — V2 refunds an UPHELD challenger; only a DISMISSED bond is burned
const { expect } = require("chai");
const { ethers } = require("hardhat");
const ONE = 10n ** 18n;
const DEAD = "0x000000000000000000000000000000000000dEaD";
async function setNextTs(t) { await ethers.provider.send("evm_setNextBlockTimestamp", [Number(t)]); }
async function mine() { await ethers.provider.send("evm_mine", []); }
async function nowTs() { return BigInt((await ethers.provider.getBlock("latest")).timestamp); }
// ═══════════════════════════════ ROLLUP ═══════════════════════════════
describe("AereRollupSettlementV2 / AereRaaSFactoryV2 — audit fixes (V1 reproduces, V2 corrects)", function () {
this.timeout(300000);
const WINDOW = 3600n;
const GRACE = 1800n;
async function tokenAndSigners() {
const [deployer, owner, sequencer, sinkEOA, challenger] = await ethers.getSigners();
const T = await ethers.getContractFactory("MockERC20Lending");
const dbt = await T.deploy("WAERE", "WAERE", 18);
await dbt.waitForDeployment();
await dbt.mint(challenger.address, 1000n * ONE);
return { deployer, owner, sequencer, sinkEOA, challenger, dbt };
}
async function deployV1() {
const { owner, sequencer, sinkEOA, challenger, dbt } = await tokenAndSigners();
const RS = await ethers.getContractFactory("AereRollupSettlement");
const cfg = [
ethers.id("rollup:v1"), owner.address, sequencer.address, sinkEOA.address,
await dbt.getAddress(), WINDOW, 1000, 8000, 1000, ONE,
];
const rs = await RS.deploy(cfg);
await rs.waitForDeployment();
await dbt.connect(challenger).approve(await rs.getAddress(), ethers.MaxUint256);
return { rs, dbt, sequencer, challenger };
}
async function deployV2() {
const { owner, sequencer, challenger, dbt } = await tokenAndSigners();
const Sink = await ethers.getContractFactory("MockSinkSimple");
const sink = await Sink.deploy();
await sink.waitForDeployment();
const RS = await ethers.getContractFactory("AereRollupSettlementV2");
const cfg = [
ethers.id("rollup:v2"), owner.address, sequencer.address, await sink.getAddress(),
await dbt.getAddress(), WINDOW, GRACE, 1000, 8000, 1000, ONE,
];
const rs = await RS.deploy(cfg);
await rs.waitForDeployment();
await dbt.connect(challenger).approve(await rs.getAddress(), ethers.MaxUint256);
return { rs, dbt, sink, owner, sequencer, challenger };
}
// ───────────────────────── F6 ─────────────────────────
describe("F6 last-block challenge grief", function () {
it("V1 REPRODUCES: last-block challenge cannot be defended; valid root rejected, bond fully refunded, no slash", async () => {
const { rs, dbt, sequencer, challenger } = await deployV1();
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("root1"), ethers.id("l2#1"));
const proposedAt = (await rs.roots(1)).proposedAt;
const bondBefore = await dbt.balanceOf(challenger.address);
await setNextTs(proposedAt + WINDOW - 1n);
await rs.connect(challenger).challenge(1, ONE, ethers.id("reason"));
await setNextTs(proposedAt + WINDOW);
await expect(rs.connect(sequencer).resolveChallenge(1, false)).to.be.revertedWithCustomError(rs, "WindowClosed");
await rs.resolveChallengeExpired(1);
expect((await rs.roots(1)).resolvedRejected).to.equal(true); // valid root killed
expect(await rs.isFinalised(1)).to.equal(false);
expect(await dbt.balanceOf(challenger.address)).to.equal(bondBefore); // full refund, no slash
});
it("V2 CORRECTS: sequencer defends a last-block challenge in the grace; root survives + finalises; griefer bond SLASHED to sink", async () => {
const { rs, dbt, sink, sequencer, challenger } = await deployV2();
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("root1"), ethers.id("l2#1"));
const proposedAt = (await rs.roots(1)).proposedAt;
const bondBefore = await dbt.balanceOf(challenger.address);
// challenge in the LAST in-window block
await setNextTs(proposedAt + WINDOW - 1n);
await rs.connect(challenger).challenge(1, ONE, ethers.id("reason"));
expect(await rs.activeBondEscrow()).to.equal(ONE);
// window is CLOSED, but the grace is still open -> sequencer CAN defend
await setNextTs(proposedAt + WINDOW + 10n);
await rs.connect(sequencer).resolveChallenge(1, false);
const s = await rs.roots(1);
expect(s.resolvedRejected).to.equal(false); // valid root SURVIVES
expect(s.challenged).to.equal(false);
expect(await rs.activeBondEscrow()).to.equal(0n);
// griefer's bond slashed to sink (not refunded)
expect(await dbt.balanceOf(challenger.address)).to.equal(bondBefore - ONE);
expect(await dbt.balanceOf(await sink.getAddress())).to.equal(ONE);
expect(await sink.lastFlushAmount()).to.equal(ONE);
// settled + finalises
expect(await rs.isEpochSettled(1)).to.equal(true);
await rs.advanceFinalisation();
expect(await rs.latestFinalisedEpoch()).to.equal(1n);
expect(await rs.isFinalised(1)).to.equal(true);
});
});
// ───────────────────────── F7 ─────────────────────────
describe("F7 rejected epoch freezes finalisation", function () {
it("V1 REPRODUCES: one rejected epoch permanently freezes latestFinalisedEpoch and cannot be re-proposed", async () => {
const { rs, sequencer, challenger } = await deployV1();
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1"));
const p1 = (await rs.roots(1)).proposedAt;
await setNextTs(p1 + WINDOW - 1n);
await rs.connect(challenger).challenge(1, ONE, ethers.id("x"));
await setNextTs(p1 + WINDOW);
await rs.resolveChallengeExpired(1);
expect((await rs.roots(1)).resolvedRejected).to.equal(true);
await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2"));
const p2 = (await rs.roots(2)).proposedAt;
await setNextTs(p2 + WINDOW + 1n); await mine();
expect(await rs.isFinalised(2)).to.equal(true); // V1 local notion (unsafe)
await rs.advanceFinalisation();
expect(await rs.latestFinalisedEpoch()).to.equal(0n); // frozen
// epoch 1 can NEVER be re-proposed (strict continuity needs latestEpoch+1 = 3)
await expect(
rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1b"), ethers.id("h1b"))
).to.be.revertedWithCustomError(rs, "EpochOutOfOrder");
});
it("V2 CORRECTS: a rejected epoch is re-proposable; finalisation resumes; one griefing challenge cannot freeze it", async () => {
const { rs, sequencer, challenger } = await deployV2();
// epoch 1 rejected via the sequencer-absent expired path (worst case)
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1"));
const p1 = (await rs.roots(1)).proposedAt;
await setNextTs(p1 + 10n);
await rs.connect(challenger).challenge(1, ONE, ethers.id("x"));
await setNextTs(p1 + WINDOW + GRACE);
await rs.resolveChallengeExpired(1);
expect((await rs.roots(1)).resolvedRejected).to.equal(true);
await rs.advanceFinalisation();
expect(await rs.latestFinalisedEpoch()).to.equal(0n);
// epoch 2 proposed + individually settled
await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2"));
const p2 = (await rs.roots(2)).proposedAt;
await setNextTs(p2 + WINDOW + 1n); await mine();
expect(await rs.isEpochSettled(2)).to.equal(true);
// ancestor-aware: epoch 2 is NOT final while epoch 1 is a gap
await rs.advanceFinalisation();
expect(await rs.latestFinalisedEpoch()).to.equal(0n);
expect(await rs.isFinalised(2)).to.equal(false);
// sequencer heals the gap: re-propose epoch 1 (replacement)
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1b"), ethers.id("h1b"));
expect((await rs.roots(1)).resolvedRejected).to.equal(false);
const p1b = (await rs.roots(1)).proposedAt;
await setNextTs(p1b + WINDOW + 1n); await mine();
// now the whole prefix finalises
await rs.advanceFinalisation();
expect(await rs.latestFinalisedEpoch()).to.equal(2n);
expect(await rs.isFinalised(1)).to.equal(true);
expect(await rs.isFinalised(2)).to.equal(true);
});
});
// ───────────────────────── ANCESTOR ─────────────────────────
describe("isFinalised ancestor safety", function () {
it("V1 REPRODUCES: a descendant of a rejected epoch reads isFinalised == true (unsafe)", async () => {
const { rs, sequencer, challenger } = await deployV1();
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1"));
const p1 = (await rs.roots(1)).proposedAt;
await setNextTs(p1 + WINDOW - 1n);
await rs.connect(challenger).challenge(1, ONE, ethers.id("x"));
await setNextTs(p1 + WINDOW);
await rs.resolveChallengeExpired(1);
await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2"));
const p2 = (await rs.roots(2)).proposedAt;
await setNextTs(p2 + WINDOW + 1n); await mine();
expect(await rs.isFinalised(1)).to.equal(false);
expect(await rs.isFinalised(2)).to.equal(true); // descendant of a rejected ancestor reads final
});
it("V2 CORRECTS: a descendant of a rejected epoch reads isFinalised == false until the gap heals", async () => {
const { rs, sequencer, challenger } = await deployV2();
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1"));
const p1 = (await rs.roots(1)).proposedAt;
await setNextTs(p1 + 10n);
await rs.connect(challenger).challenge(1, ONE, ethers.id("x"));
await setNextTs(p1 + WINDOW + GRACE);
await rs.resolveChallengeExpired(1);
await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2"));
const p2 = (await rs.roots(2)).proposedAt;
await setNextTs(p2 + WINDOW + 1n); await mine();
await rs.advanceFinalisation();
expect(await rs.isEpochSettled(2)).to.equal(true); // locally settled
expect(await rs.isFinalised(2)).to.equal(false); // but NOT final (ancestor gap)
});
});
// ───────────────────────── FACTORY ─────────────────────────
describe("AereRaaSFactoryV2 deploys the fixed template", function () {
async function deployFactory() {
const [deployer] = await ethers.getSigners();
const T = await ethers.getContractFactory("MockERC20Lending");
const bond = await T.deploy("WAERE", "WAERE", 18);
await bond.waitForDeployment();
const Sink = await ethers.getContractFactory("MockSinkSimple");
const sink = await Sink.deploy();
await sink.waitForDeployment();
const F = await ethers.getContractFactory("AereRaaSFactoryV2");
// bondToken, sink, foundation, registrationBond=0, minSinkBps=1000, defWindow, defGrace
const f = await F.deploy(await bond.getAddress(), await sink.getAddress(), deployer.address, 0, 1000, WINDOW, GRACE);
await f.waitForDeployment();
return { f, bond, sink, deployer };
}
it("registers a rollup and the settlement is a V2 with DEFENSE_GRACE defaulted", async () => {
const { f, bond, sink, deployer } = await deployFactory();
const rid = ethers.id("rollup:factory-demo");
// defenseGrace = 0 -> falls back to DEFAULT_DEFENSE_GRACE
await f.registerRollup(rid, deployer.address, deployer.address, await bond.getAddress(),
1000, 8000, 1000, ONE, 0, 0, "ipfs://demo");
const r = await f.rollups(rid);
expect(r.registered).to.equal(true);
const RS = await ethers.getContractFactory("AereRollupSettlementV2");
const s = RS.attach(r.settlement);
expect(await s.DEFENSE_GRACE()).to.equal(GRACE);
expect(await s.CHALLENGE_WINDOW()).to.equal(WINDOW);
expect(await s.SINK()).to.equal(await sink.getAddress());
// V2-only surface exists
expect(await s.isEpochSettled(1)).to.equal(false);
});
it("enforces the sink floor and split invariants", async () => {
const { f, bond, deployer } = await deployFactory();
await expect(f.registerRollup(ethers.id("a"), deployer.address, deployer.address, await bond.getAddress(),
500, 8500, 1000, ONE, 0, 0, "x")).to.be.revertedWithCustomError(f, "SinkBpsTooLow");
await expect(f.registerRollup(ethers.id("b"), deployer.address, deployer.address, await bond.getAddress(),
1000, 8000, 2000, ONE, 0, 0, "x")).to.be.revertedWithCustomError(f, "InvalidSplits");
});
});
});
// ═══════════════════════════════ COMPLIANCE POOL ═══════════════════════════════
describe("AereCompliancePoolV2 — F8 challenge bond (V1 reproduces, V2 corrects)", function () {
this.timeout(300000);
const CW = 100n; // short challenge/dismissal window for tests
async function base() {
const [foundation, challenger] = await ethers.getSigners();
const T = await ethers.getContractFactory("MockERC20Lending");
const token = await T.deploy("WAERE", "WAERE", 18);
await token.waitForDeployment();
const V = await ethers.getContractFactory("MockPrivacyPoolVerifier");
const verifier = await V.deploy();
await verifier.waitForDeployment();
await token.mint(challenger.address, 1000n * ONE);
return { foundation, challenger, token, verifier };
}
async function deployV1() {
const { foundation, challenger, token, verifier } = await base();
const P = await ethers.getContractFactory("AereCompliancePool");
const pool = await P.deploy(await token.getAddress(), ONE, foundation.address, await verifier.getAddress());
await pool.waitForDeployment();
await token.connect(challenger).approve(await pool.getAddress(), ethers.MaxUint256);
return { pool, token, foundation, challenger };
}
async function deployV2() {
const { foundation, challenger, token, verifier } = await base();
const P = await ethers.getContractFactory("AereCompliancePoolV2");
const pool = await P.deploy(await token.getAddress(), ONE, foundation.address, await verifier.getAddress(), CW);
await pool.waitForDeployment();
await token.connect(challenger).approve(await pool.getAddress(), ethers.MaxUint256);
return { pool, token, foundation, challenger };
}
it("V1 REPRODUCES: an UPHELD challenger never recovers the bond; a DISMISSED bond is burned; no refund fn", async () => {
const { pool, token, foundation, challenger } = await deployV1();
const BOND = await pool.CHALLENGE_BOND();
// upheld branch: Foundation simply never dismisses -> bond stuck forever
const rootA = ethers.id("assoc-A");
await pool.connect(foundation).proposeAssociationRoot(rootA);
const balBefore = await token.balanceOf(challenger.address);
await pool.connect(challenger).challengeAssociationRoot(rootA, "fraudulent");
expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND);
await expect(pool.publishAssociationRoot(rootA)).to.be.revertedWithCustomError(pool, "UnknownAssociationRoot");
const names = pool.interface.fragments.filter(f => f.type === "function").map(f => f.name);
for (const n of ["refundChallenger", "reclaimUpheldChallenge", "reclaimBond", "withdrawBond"]) {
expect(names).to.not.include(n);
}
expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND); // permanently out
// dismissed branch: bond burned to dEaD
const rootB = ethers.id("assoc-B");
await pool.connect(foundation).proposeAssociationRoot(rootB);
await pool.connect(challenger).challengeAssociationRoot(rootB, "reason");
const deadBefore = await token.balanceOf(DEAD);
await pool.connect(foundation).dismissAssociationRootChallenge(rootB);
expect(await token.balanceOf(DEAD)).to.equal(deadBefore + BOND);
});
it("V2 CORRECTS: an UPHELD challenger is refunded + root rejected; a DISMISSED bond is still burned; timing guards hold", async () => {
const { pool, token, foundation, challenger } = await deployV2();
const BOND = await pool.CHALLENGE_BOND();
// ── UPHELD: Foundation fails to dismiss in time -> challenger refunded, root rejected ──
const rootA = ethers.id("assoc-A");
await pool.connect(foundation).proposeAssociationRoot(rootA);
const balBefore = await token.balanceOf(challenger.address);
await pool.connect(challenger).challengeAssociationRoot(rootA, "fraudulent");
expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND);
// reclaim before the window elapses reverts
await expect(pool.reclaimUpheldChallenge(rootA)).to.be.revertedWithCustomError(pool, "DismissalWindowOpen");
// advance past the dismissal window
await ethers.provider.send("evm_increaseTime", [Number(CW) + 5]);
await mine();
// Foundation can no longer dismiss (too late)
await expect(pool.connect(foundation).dismissAssociationRootChallenge(rootA))
.to.be.revertedWithCustomError(pool, "DismissalWindowClosed");
// anyone reclaims: bond refunded, root permanently rejected
await pool.reclaimUpheldChallenge(rootA);
expect(await token.balanceOf(challenger.address)).to.equal(balBefore); // WHOLE again
expect(await pool.associationRootRejected(rootA)).to.equal(true);
await expect(pool.publishAssociationRoot(rootA)).to.be.revertedWithCustomError(pool, "UnknownAssociationRoot");
// a rejected root cannot be re-challenged
await expect(pool.connect(challenger).challengeAssociationRoot(rootA, "again"))
.to.be.revertedWithCustomError(pool, "UnknownAssociationRoot");
// ── DISMISSED: provably-bad challenge still burns the bond ──
const rootB = ethers.id("assoc-B");
await pool.connect(foundation).proposeAssociationRoot(rootB);
await pool.connect(challenger).challengeAssociationRoot(rootB, "griefing a valid set");
const deadBefore = await token.balanceOf(DEAD);
const balB = await token.balanceOf(challenger.address);
await pool.connect(foundation).dismissAssociationRootChallenge(rootB);
expect(await token.balanceOf(DEAD)).to.equal(deadBefore + BOND); // burned
expect(await token.balanceOf(challenger.address)).to.equal(balB); // griefer NOT refunded
// after a legit dismissal + a fresh window, the valid root can publish
await ethers.provider.send("evm_increaseTime", [Number(CW) + 5]);
await mine();
await pool.publishAssociationRoot(rootB);
expect(await pool.isAssociationRootValid(rootB)).to.equal(true);
});
});