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.
372 lines
20 KiB
JavaScript
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);
|
|
});
|
|
});
|