aere-contracts/test/audit-fix-v2-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

171 lines
9.4 KiB
JavaScript

// AUDIT FIX VERIFICATION — 2026-07-10
// Proves the two CONFIRMED bugs (findings 9 & 10 in audit-verify-2026-07.test.js)
// are FIXED in the V2 redeploys, by running the ORIGINAL exploit against BOTH:
// * V1 -> the exploit STILL reproduces (regression anchor)
// * V2 -> the exploit is BLOCKED (the attack call reverts) while legit flows work
//
// Runs fully local against the REAL V1 + V2 sources (same bytecode as deployed).
const { expect } = require("chai");
const { ethers } = require("hardhat");
const ONE = 10n ** 18n;
describe("AUDIT FIX V2 — findings 9 & 10", function () {
this.timeout(300000);
// ─────────────────────────────────────────────────────────────
// Finding 9 — AereDACommittee removeMember -> threshold 0 -> forge availability
// ─────────────────────────────────────────────────────────────
describe("F9 AereDACommittee fail-open (removeMember drives threshold to 0)", function () {
async function committeeSigs(dac, members, rollupId, epoch, dc) {
// members must already be sorted ascending by address
const net = await ethers.provider.getNetwork();
const inner = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["string", "uint256", "address", "bytes32", "uint256", "bytes32"],
["AereDACommittee:attest", net.chainId, await dac.getAddress(), rollupId, epoch, dc]
)
);
const sigs = [];
for (const m of members) sigs.push(await m.signMessage(ethers.getBytes(inner)));
return sigs;
}
it("V1 CONFIRMED: admin removes all members; attest([]) forges availability with 0 sigs", async () => {
const [admin] = await ethers.getSigners();
const members = [ethers.Wallet.createRandom(), ethers.Wallet.createRandom(), ethers.Wallet.createRandom()]
.sort((a, b) => (a.address.toLowerCase() < b.address.toLowerCase() ? -1 : 1));
const DAC = await ethers.getContractFactory("AereDACommittee");
const dac = await DAC.deploy(admin.address, members.map(m => m.address), 2);
await dac.waitForDeployment();
for (const m of members) await dac.connect(admin).removeMember(m.address);
expect(await dac.memberCount()).to.equal(0n);
expect(await dac.threshold()).to.equal(0n); // clamped to 0
const rollupId = ethers.id("rollup:forge");
await dac.attest(rollupId, 42n, ethers.id("forged"), []); // zero sigs accepted
expect(await dac.isAvailable(rollupId, 42n)).to.equal(true);
const [available, , , sigs] = await dac.getAttestation(rollupId, 42n);
expect(available).to.equal(true);
expect(sigs).to.equal(0n); // forged with zero committee signatures
});
it("V2 BLOCKED: threshold can never reach 0; removeMember reverts; attest([]) reverts; legit M-of-N still works", async () => {
const [admin] = await ethers.getSigners();
const members = [ethers.Wallet.createRandom(), ethers.Wallet.createRandom(), ethers.Wallet.createRandom()]
.sort((a, b) => (a.address.toLowerCase() < b.address.toLowerCase() ? -1 : 1));
const DAC = await ethers.getContractFactory("AereDACommitteeV2");
const dac = await DAC.deploy(admin.address, members.map(m => m.address), 2);
await dac.waitForDeployment();
// ── the original attack: try to remove all members ──
// memberCount 3 -> 2 is fine (>= threshold 2)
await dac.connect(admin).removeMember(members[2].address);
expect(await dac.memberCount()).to.equal(2n);
expect(await dac.threshold()).to.equal(2n); // NOT clamped
// memberCount 2 -> 1 would drop below threshold 2 -> REVERT
await expect(dac.connect(admin).removeMember(members[1].address))
.to.be.revertedWithCustomError(dac, "WouldBreakThreshold").withArgs(1n, 2n);
// admin must lower threshold explicitly first (still can't be 0)
await expect(dac.connect(admin).setThreshold(0)).to.be.revertedWithCustomError(dac, "BadThreshold");
await dac.connect(admin).setThreshold(1);
await dac.connect(admin).removeMember(members[1].address); // now ok, memberCount 1
expect(await dac.memberCount()).to.equal(1n);
expect(await dac.threshold()).to.equal(1n);
// the LAST member can never be removed -> threshold can never reach 0
await expect(dac.connect(admin).removeMember(members[0].address))
.to.be.revertedWithCustomError(dac, "WouldBreakThreshold").withArgs(0n, 1n);
expect(await dac.threshold()).to.equal(1n);
// even so, attest([]) is rejected outright (belt-and-suspenders)
await expect(dac.attest(ethers.id("r"), 1n, ethers.id("d"), []))
.to.be.revertedWithCustomError(dac, "EmptySignatureSet");
expect(await dac.isAvailable(ethers.id("r"), 1n)).to.equal(false);
// ── legit flows on a fresh 2-of-3 committee ──
const m2 = [ethers.Wallet.createRandom(), ethers.Wallet.createRandom(), ethers.Wallet.createRandom()]
.sort((a, b) => (a.address.toLowerCase() < b.address.toLowerCase() ? -1 : 1));
const dac2 = await DAC.deploy(admin.address, m2.map(m => m.address), 2);
await dac2.waitForDeployment();
const rollupId = ethers.id("rollup:legit");
const dc = ethers.id("batch-commitment");
const allSigs = await committeeSigs(dac2, m2, rollupId, 7n, dc);
// 1 sig reverts ThresholdNotMet(1,2)
await expect(dac2.attest(rollupId, 7n, dc, [allSigs[0]]))
.to.be.revertedWithCustomError(dac2, "ThresholdNotMet").withArgs(1n, 2n);
// 2 distinct ascending sigs -> recorded
await dac2.attest(rollupId, 7n, dc, [allSigs[0], allSigs[1]]);
expect(await dac2.isAvailable(rollupId, 7n)).to.equal(true);
const [avail, , , cnt] = await dac2.getAttestation(rollupId, 7n);
expect(avail).to.equal(true);
expect(cnt).to.equal(2n);
// empty sigs still rejected on this live committee too
await expect(dac2.attest(ethers.id("rollup:x"), 1n, dc, []))
.to.be.revertedWithCustomError(dac2, "EmptySignatureSet");
});
});
// ─────────────────────────────────────────────────────────────
// Finding 10 — AereSlashingInsurance matured claim griefed via cancel
// ─────────────────────────────────────────────────────────────
describe("F10 AereSlashingInsurance griefing (cancel a matured payout)", function () {
it("V1 CONFIRMED: griefer cancels a matured claim, permanently blocking the payout", async () => {
const [foundation, griefer, delegatee] = await ethers.getSigners();
const SI = await ethers.getContractFactory("AereSlashingInsurance");
const si = await SI.deploy(foundation.address, 5000, 10n ** 24n, 0, 100);
await si.waitForDeployment();
await si.connect(foundation).donate({ value: 100n * ONE });
await si.connect(foundation).proposeCoverage(delegatee.address, 20n * ONE, ethers.id("evidence"));
await ethers.provider.send("evm_increaseTime", [101]);
await ethers.provider.send("evm_mine", []);
// matured — but cancel has no timelock guard, so griefer deletes it
await si.connect(griefer).cancelCoverage(0, "griefing a valid claim");
await expect(si.connect(delegatee).executeCoverage(0)).to.be.revertedWithCustomError(si, "NoClaim");
});
it("V2 BLOCKED: matured claim can NOT be cancelled (grief reverts) and executes; un-matured claim still cancellable", async () => {
const [foundation, griefer, delegatee] = await ethers.getSigners();
const SI = await ethers.getContractFactory("AereSlashingInsuranceV2");
const si = await SI.deploy(foundation.address, 5000, 10n ** 24n, 0, 100);
await si.waitForDeployment();
await si.connect(foundation).donate({ value: 100n * ONE });
// ── claim 0: matured -> grief blocked, executes ──
await si.connect(foundation).proposeCoverage(delegatee.address, 20n * ONE, ethers.id("evidence"));
const claim = await si.claims(0);
const payout = claim.payout; // 20 * 50% = 10 AERE
expect(payout).to.equal(10n * ONE);
await ethers.provider.send("evm_increaseTime", [101]);
await ethers.provider.send("evm_mine", []);
// griefer front-run now REVERTS (ClaimMatured)
await expect(si.connect(griefer).cancelCoverage(0, "grief"))
.to.be.revertedWithCustomError(si, "ClaimMatured");
// payout executes to the frozen delegator
const before = await ethers.provider.getBalance(delegatee.address);
await si.connect(griefer).executeCoverage(0); // anyone can trigger; recipient frozen
const after = await ethers.provider.getBalance(delegatee.address);
expect(after - before).to.equal(payout);
// ── claim 1: NOT matured -> community veto still works (permissionless cancel) ──
await si.connect(foundation).proposeCoverage(delegatee.address, 20n * ONE, ethers.id("evidence2"));
// do NOT advance time past readyAt
await si.connect(griefer).cancelCoverage(1, "legit community veto during window");
await expect(si.connect(delegatee).executeCoverage(1)).to.be.revertedWithCustomError(si, "NoClaim");
});
});
});