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

427 lines
23 KiB
JavaScript

// AUDIT VERIFICATION SUITE — 2026-07-10
// Adversarial verification of candidate bugs flagged in LIVE AERE contracts.
// Each test either REPRODUCES the bug (CONFIRMED) or shows a guard REFUTES it.
// Runs fully local against the REAL contract sources (same bytecode as deployed).
// NO contract sources modified. NO deploys to mainnet. NO mainnet txs.
//
// Findings covered here: 1,2,3,4,5,6,7,8,9,10,11,12 (13 lives in a sibling file).
const { expect } = require("chai");
const { ethers } = require("hardhat");
const ONE = 10n ** 18n;
describe("AUDIT VERIFY — AERE live contracts", function () {
this.timeout(300000);
// ───────────────────────── Finding 1 ─────────────────────────
describe("F1 AereFeeMonetization.register squat + permanent DoS", function () {
it("CONFIRMED: attacker squats a victim contract's fee stream; victim can never register; attacker drains distributed fees", async () => {
const [owner, attacker, dist, victimContract, victimDev] = await ethers.getSigners();
const FM = await ethers.getContractFactory("AereFeeMonetization");
const fm = await FM.deploy(owner.address);
await fm.waitForDeployment();
await fm.connect(owner).setDistributor(dist.address, true);
// Attacker registers the VICTIM contract's address, minting the NFT to itself.
// No check that msg.sender controls victimContract.
const rc = await (await fm.connect(attacker).register(victimContract.address, attacker.address)).wait();
const tokenId = await fm.tokenIdOf(victimContract.address);
expect(tokenId).to.equal(1n);
expect(await fm.ownerOf(tokenId)).to.equal(attacker.address);
// The real developer can NEVER register their own contract now (permanent DoS).
await expect(
fm.connect(victimDev).register(victimContract.address, victimDev.address)
).to.be.revertedWith("FeeMon: already registered");
// Foundation distributor credits fees for that contract -> attacker's tokenId.
const amt = 5n * ONE;
await fm.connect(dist).distribute([tokenId], [amt], 0n, { value: amt });
expect(await fm.pendingRewards(tokenId)).to.equal(amt);
// Attacker claims the victim's fee stream.
const before = await ethers.provider.getBalance(attacker.address);
const claimRc = await (await fm.connect(attacker).claim(tokenId)).wait();
const gas = claimRc.gasUsed * claimRc.gasPrice;
const after = await ethers.provider.getBalance(attacker.address);
expect(after + gas - before).to.equal(amt);
// There is no unregister / reclaim path (interface has none).
const names = fm.interface.fragments.filter(f => f.type === "function").map(f => f.name);
expect(names).to.not.include("unregister");
expect(names).to.not.include("deregister");
});
});
// ───────────────────────── Finding 2 ─────────────────────────
describe("F2 AereStaking.delegate top-up does NOT reset lastClaimBlock", function () {
it("CONFIRMED: freshly-added stake retroactively earns a full period of rewards", async () => {
const [v, d] = await ethers.getSigners();
const S = await ethers.getContractFactory("AereStaking");
const st = await S.deploy();
await st.waitForDeployment();
await st.connect(v).registerValidator(0, { value: 500n * ONE }); // commission 0
const X = 100n * ONE;
await st.connect(d).delegate(v.address, { value: X });
const del0 = await st.delegations(v.address, d.address);
const firstClaimBlock = del0.lastClaimBlock;
// Let a period elapse.
await ethers.provider.send("hardhat_mine", ["0x3E8"]); // 1000 blocks
const pBefore = await st.pendingRewards(v.address, d.address);
// Top up with a large amount. Because d.amount != 0, lastClaimBlock is NOT reset.
const Y = 900n * ONE;
await st.connect(d).delegate(v.address, { value: Y });
const del1 = await st.delegations(v.address, d.address);
// EVIDENCE 1: lastClaimBlock unchanged (no reset on top-up).
expect(del1.lastClaimBlock).to.equal(firstClaimBlock);
expect(del1.amount).to.equal(X + Y);
// EVIDENCE 2: pending now scales with the FULL new balance over the whole
// elapsed window — the +900 AERE earns rewards for blocks before it existed.
const cur = BigInt(await ethers.provider.getBlockNumber());
const elapsed = cur - firstClaimBlock;
const expectedBuggy = ((X + Y) * 800n * elapsed) / (315360000n * 10000n);
const pAfter = await st.pendingRewards(v.address, d.address);
expect(pAfter).to.equal(expectedBuggy);
// Fair value would credit Y only from the top-up block onward (~0 blocks here).
const fair = (X * 800n * elapsed) / (315360000n * 10000n); // Y contributes ~nothing fairly
expect(pAfter).to.be.gt(fair * 9n); // ~10x inflated (Y=9X earning full retro period)
});
});
// ───────────────────────── Finding 3 ─────────────────────────
describe("F3 AereStaking validator self-stake is locked forever", function () {
it("CONFIRMED: no code path returns registerValidator's selfStake to the validator", async () => {
const [v] = await ethers.getSigners();
const S = await ethers.getContractFactory("AereStaking");
const st = await S.deploy();
await st.waitForDeployment();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const info = await st.validators(v.address);
expect(info.selfStake).to.equal(500n * ONE);
expect(await ethers.provider.getBalance(await st.getAddress())).to.equal(500n * ONE);
// No deregister / withdrawSelfStake / exitValidator function exists.
const names = st.interface.fragments.filter(f => f.type === "function").map(f => f.name);
for (const n of ["deregisterValidator", "withdrawSelfStake", "exitValidator", "unregisterValidator", "withdrawValidator"]) {
expect(names).to.not.include(n);
}
// The only "withdraw" primitives (unbond/claimUnbonded) act on DELEGATIONS,
// and the validator never delegated to itself -> unbond reverts.
await expect(
st.connect(v).unbond(v.address, 500n * ONE)
).to.be.revertedWith("AereStaking: insufficient delegation");
// claimUnbonded has nothing queued either.
await expect(st.connect(v).claimUnbonded()).to.be.revertedWith("AereStaking: nothing to claim");
// selfStake remains stuck in the contract.
expect(await ethers.provider.getBalance(await st.getAddress())).to.equal(500n * ONE);
});
});
// ───────────────────────── Finding 4 ─────────────────────────
describe("F4 AereStaking re-registration after slash underflows delegator unbond", function () {
it("CONFIRMED: re-register zeroes totalDelegated while delegations persist -> unbond reverts (panic 0x11)", async () => {
const [owner, v, d] = await ethers.getSigners();
const S = await ethers.getContractFactory("AereStaking");
const st = await S.connect(owner).deploy();
await st.waitForDeployment();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const D = 50n * ONE;
await st.connect(d).delegate(v.address, { value: D });
expect((await st.validators(v.address)).totalDelegated).to.equal(D);
// One slash (5%) drops selfStake to 475 < 500 -> validator deactivated.
await st.connect(owner).slash(v.address, "double-sign");
expect((await st.validators(v.address)).active).to.equal(false);
// Deactivated validator re-registers: overwrites struct, totalDelegated = 0.
await st.connect(v).registerValidator(0, { value: 500n * ONE });
expect((await st.validators(v.address)).totalDelegated).to.equal(0n);
// Delegator still holds a 50 AERE delegation, but unbond underflows totalDelegated.
expect((await st.delegations(v.address, d.address)).amount).to.equal(D);
await expect(st.connect(d).unbond(v.address, D)).to.be.revertedWithPanic(0x11);
});
});
// ───────────────────────── Finding 5 ─────────────────────────
describe("F5 AereStaking reward constant assumes 0.1s blocks; chain runs ~0.5s", function () {
it("CONFIRMED: realized APY is ~5x below the intended 8% (constant 315,360,000)", async () => {
const [v, d] = await ethers.getSigners();
const S = await ethers.getContractFactory("AereStaking");
const st = await S.deploy();
await st.waitForDeployment();
await st.connect(v).registerValidator(0, { value: 500n * ONE });
const stake = 100n * ONE;
await st.connect(d).delegate(v.address, { value: stake });
const del = await st.delegations(v.address, d.address);
const MINE = 500000n;
await ethers.provider.send("hardhat_mine", ["0x" + MINE.toString(16)]);
const cur = BigInt(await ethers.provider.getBlockNumber());
const elapsed = cur - del.lastClaimBlock;
const pending = await st.pendingRewards(v.address, d.address);
// Reproduce the contract's constant to prove it is 315,360,000 (0.1s blocks).
const expected = (stake * 800n * elapsed) / (315360000n * 10000n);
expect(pending).to.equal(expected);
// Realized APY at the REAL 0.5s block time (measured on-chain: 0.5055 s/block).
// blocks/year @0.5s = 31,536,000 / 0.5 = 63,072,000.
const BLOCKS_PER_YEAR_HALF_SEC = 63072000n;
// annual reward fraction (bps) = 800 * blocksPerYear / 315,360,000
const realizedBps = (800n * BLOCKS_PER_YEAR_HALF_SEC) / 315360000n;
expect(realizedBps).to.equal(160n); // 1.60% realized vs 800 bps (8%) intended
expect(800n / realizedBps).to.equal(5n); // exactly 5x underpay at 0.5s
});
});
// ─────────────────────── Findings 6 & 7 ───────────────────────
describe("F6/F7 AereRollupSettlement last-block griefing + permanent finalisation freeze", function () {
const WINDOW = 3600n;
async function deployRS() {
const [deployer, owner, sequencer, sink, challenger] = await ethers.getSigners();
const T = await ethers.getContractFactory("MockERC20Lending");
const dbt = await T.deploy("WAERE", "WAERE", 18);
await dbt.waitForDeployment();
const RS = await ethers.getContractFactory("AereRollupSettlement");
const cfg = [
ethers.id("rollup:test"), // rollupId
owner.address, // rollupOwner
sequencer.address, // sequencer
sink.address, // sink
await dbt.getAddress(), // debtToken
WINDOW, // challengeWindow
1000, // sinkBps
8000, // rollupBps
1000, // sequencerBps
ONE, // minChallengeBond
];
const rs = await RS.deploy(cfg);
await rs.waitForDeployment();
await dbt.mint(challenger.address, 100n * ONE);
await dbt.connect(challenger).approve(await rs.getAddress(), ethers.MaxUint256);
return { rs, dbt, owner, sequencer, challenger };
}
it("CONFIRMED F6: challenge in last in-window block cannot be defended; valid root rejected, bond fully refunded, no slash", async () => {
const { rs, dbt, sequencer, challenger } = await deployRS();
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("root1"), ethers.id("l2#1"));
const s = await rs.roots(1);
const proposedAt = s.proposedAt;
const bondBefore = await dbt.balanceOf(challenger.address);
// Place the challenge tx ITSELF in the last in-window block (timestamp = T+W-1).
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(proposedAt + WINDOW - 1n)]);
await rs.connect(challenger).challenge(1, ONE, ethers.id("reason"));
// The very next block is already at/after window end -> sequencer cannot resolve.
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(proposedAt + WINDOW)]);
await expect(rs.connect(sequencer).resolveChallenge(1, false)).to.be.revertedWithCustomError(rs, "WindowClosed");
await expect(rs.connect(sequencer).resolveChallenge(1, true)).to.be.revertedWithCustomError(rs, "WindowClosed");
// Only the expired path remains: rejects the (valid) root + full refund, NO slash.
await rs.resolveChallengeExpired(1);
const after = await rs.roots(1);
expect(after.resolvedRejected).to.equal(true);
expect(await rs.isFinalised(1)).to.equal(false);
// challenger got the ENTIRE bond back (griefing is costless beyond gas).
expect(await dbt.balanceOf(challenger.address)).to.equal(bondBefore);
});
it("CONFIRMED F7: one rejected epoch permanently freezes latestFinalisedEpoch and cannot be re-proposed", async () => {
const { rs, sequencer, challenger } = await deployRS();
// Epoch 1 gets rejected via the last-block griefing path.
await rs.connect(sequencer).proposeStateRoot(1, ethers.id("r1"), ethers.id("h1"));
let s1 = await rs.roots(1);
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(s1.proposedAt + WINDOW - 1n)]);
await rs.connect(challenger).challenge(1, ONE, ethers.id("x"));
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(s1.proposedAt + WINDOW)]);
await rs.resolveChallengeExpired(1);
expect((await rs.roots(1)).resolvedRejected).to.equal(true);
// Epoch 2 proposed cleanly and left to finalise.
await rs.connect(sequencer).proposeStateRoot(2, ethers.id("r2"), ethers.id("h2"));
const s2 = await rs.roots(2);
await ethers.provider.send("evm_setNextBlockTimestamp", [Number(s2.proposedAt + WINDOW + 1n)]);
await ethers.provider.send("evm_mine", []);
expect(await rs.isFinalised(2)).to.equal(true); // epoch 2 individually finalisable
// But the contiguous tracker is stuck at 0 because epoch 1 never finalises.
await rs.advanceFinalisation();
expect(await rs.latestFinalisedEpoch()).to.equal(0n);
// And 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");
});
});
// ───────────────────────── Finding 8 ─────────────────────────
describe("F8 AereCompliancePool challenge bond: honest challenger never recovers bond", function () {
async function deployPool() {
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();
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.mint(challenger.address, 500n * ONE);
await token.connect(challenger).approve(await pool.getAddress(), ethers.MaxUint256);
return { pool, token, foundation, challenger };
}
it("CONFIRMED: bond is either burned on dismissal or locked forever if upheld — no refund path exists", async () => {
const { pool, token, foundation, challenger } = await deployPool();
const BOND = await pool.CHALLENGE_BOND();
// Branch A: an UPHELD challenge (Foundation simply never dismisses).
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 set");
expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND);
expect(await token.balanceOf(await pool.getAddress())).to.equal(BOND);
// The root can never publish (stays challenged) AND there is no refund fn.
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", "upholdChallenge", "reclaimBond", "withdrawBond"]) {
expect(names).to.not.include(n);
}
// Bond is stuck in the contract; challenger is permanently out 100 AERE.
expect(await token.balanceOf(challenger.address)).to.equal(balBefore - BOND);
// Branch B: a DISMISSED challenge burns the bond to dEaD (challenger still loses it).
const rootB = ethers.id("assoc-B");
await pool.connect(foundation).proposeAssociationRoot(rootB);
await pool.connect(challenger).challengeAssociationRoot(rootB, "reason");
const dead = "0x000000000000000000000000000000000000dEaD";
const deadBefore = await token.balanceOf(dead);
await pool.connect(foundation).dismissAssociationRootChallenge(rootB);
expect(await token.balanceOf(dead)).to.equal(deadBefore + BOND); // burned, not refunded
});
});
// ───────────────────────── Finding 9 ─────────────────────────
describe("F9 AereDACommittee removeMember drives threshold to 0 -> forge availability with zero sigs", function () {
it("CONFIRMED: admin can remove all members; attest([]) then records availability with 0 signatures", async () => {
const [admin, m1, m2, m3] = await ethers.getSigners();
const members = [m1, m2, m3];
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 past the setThreshold(>0) guard
const rollupId = ethers.id("rollup:forge");
const epoch = 42n;
const dc = ethers.id("forged-commitment");
// Zero signatures: valid(0) < threshold(0) is FALSE -> passes.
await dac.attest(rollupId, epoch, dc, []);
expect(await dac.isAvailable(rollupId, epoch)).to.equal(true);
const [available, , , sigs] = await dac.getAttestation(rollupId, epoch);
expect(available).to.equal(true);
expect(sigs).to.equal(0n); // availability forged with zero committee signatures
});
});
// ───────────────────────── Finding 10 ────────────────────────
describe("F10 AereSlashingInsurance.cancelCoverage front-runs a matured payout", function () {
it("CONFIRMED: a non-Foundation 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); // coverage 50%, cooldown 0, delay 100s
await si.waitForDeployment();
await si.connect(foundation).donate({ value: 100n * ONE });
await si.connect(foundation).proposeCoverage(delegatee.address, 20n * ONE, ethers.id("evidence"));
// Mature the claim.
await ethers.provider.send("evm_increaseTime", [101]);
await ethers.provider.send("evm_mine", []);
// Griefer front-runs executeCoverage with a cancel — no readyAt/timelock guard on cancel.
await si.connect(griefer).cancelCoverage(0, "griefing a valid claim");
await expect(si.connect(delegatee).executeCoverage(0)).to.be.revertedWithCustomError(si, "NoClaim");
// Re-proposal is cancellable again the same way (repeatable DoS).
await si.connect(foundation).proposeCoverage(delegatee.address, 20n * ONE, ethers.id("evidence2"));
await ethers.provider.send("evm_increaseTime", [101]);
await ethers.provider.send("evm_mine", []);
await si.connect(griefer).cancelCoverage(1, "again");
await expect(si.connect(delegatee).executeCoverage(1)).to.be.revertedWithCustomError(si, "NoClaim");
});
});
// ───────────────────────── Finding 11 ────────────────────────
describe("F11 AereOracle.getPrice: a single fresh reporter controls the price", function () {
it("CONFIRMED: with all others stale, one fresh reporter sets the median (contributors == 1)", async () => {
const [owner, r1, r2, r3] = await ethers.getSigners();
const O = await ethers.getContractFactory("AereOracle");
const o = await O.connect(owner).deploy();
await o.waitForDeployment();
for (const r of [r1, r2, r3]) await o.connect(owner).addReporter(r.address);
const sym = ethers.encodeBytes32String("AERE/USD");
await o.connect(r1).submit(sym, 100n * 10n ** 8n);
await o.connect(r2).submit(sym, 101n * 10n ** 8n);
await o.connect(r3).submit(sym, 99n * 10n ** 8n);
// Age everything past maxStaleness (5 min).
await ethers.provider.send("evm_increaseTime", [301]);
await ethers.provider.send("evm_mine", []);
// Only r1 refreshes — to an arbitrary manipulated value.
const manipulated = 500000n * 10n ** 8n;
await o.connect(r1).submit(sym, manipulated);
const [price, , contributors] = await o.getPrice(sym);
expect(contributors).to.equal(1n);
expect(price).to.equal(manipulated); // single reporter fully controls the feed
});
});
// ───────────────────────── Finding 12 ────────────────────────
describe("F12 AereLendingOracle NavOracle feed is a dead path", function () {
it("CONFIRMED: configureFeed(NavOracle -> AereNavOracle) reverts (no latestForAsset on the deployed NAV oracle)", async () => {
const [owner] = await ethers.getSigners();
const LO = await ethers.getContractFactory("AereLendingOracle");
const lo = await LO.connect(owner).deploy();
await lo.waitForDeployment();
const NAV = await ethers.getContractFactory("AereNavOracle");
const nav = await NAV.deploy();
await nav.waitForDeployment();
// Sanity: the deployed AereNavOracle exposes NO latestForAsset(bytes32) selector.
const navNames = nav.interface.fragments.filter(f => f.type === "function").map(f => f.name);
expect(navNames).to.not.include("latestForAsset");
const asset = ethers.Wallet.createRandom().address;
// FeedType.NavOracle = 1. Auto-poke in configureFeed calls latestForAsset -> revert.
await expect(
lo.connect(owner).configureFeed(asset, 1, await nav.getAddress(), ethers.id("BUIDL"), 86400, 0, 18)
).to.be.reverted;
});
});
});