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

184 lines
9.4 KiB
JavaScript

// AUDIT FIX VERIFICATION — 2026-07-10
// For findings #1 (AereFeeMonetization squat/DoS) and #11 (AereOracle single-reporter
// median), each test REPRODUCES the bug on the real V1 source and shows the V2 BLOCKS
// it. Runs fully local against the real contract sources (same bytecode as deployed).
const { expect } = require("chai");
const { ethers } = require("hardhat");
const ONE = 10n ** 18n;
describe("AUDIT FIX V2 — AereFeeMonetizationV2 + AereOracleV2 (findings 1 & 11)", function () {
this.timeout(300000);
// ═══════════════════════ Finding 1 — AereFeeMonetization ═══════════════════════
describe("F1 fee-stream squat + permanent DoS", function () {
it("V1 REPRODUCES: attacker squats a victim contract's fee stream", 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);
// No control check in V1: attacker registers a contract it does not own.
await fm.connect(attacker).register(victimContract.address, attacker.address);
const tokenId = await fm.tokenIdOf(victimContract.address);
expect(await fm.ownerOf(tokenId)).to.equal(attacker.address);
// Real dev can never register -> permanent DoS.
await expect(
fm.connect(victimDev).register(victimContract.address, victimDev.address)
).to.be.revertedWith("FeeMon: already registered");
// No unregister path exists in V1.
const names = fm.interface.fragments.filter(f => f.type === "function").map(f => f.name);
expect(names).to.not.include("unregister");
});
it("V2 BLOCKS squat: non-controller register reverts; self + owner paths work; unregister frees it", async () => {
const [deployer, attacker, legitOwner, payout, dist] = await ethers.getSigners();
const FM = await ethers.getContractFactory("AereFeeMonetizationV2");
const fm = await FM.connect(deployer).deploy(deployer.address);
await fm.waitForDeployment();
// A "victim" contract the attacker does NOT control: an Ownable owned by legitOwner.
const OP = await ethers.getContractFactory("OwnableProbe", legitOwner);
const victim = await OP.deploy();
await victim.waitForDeployment();
// (a) attacker cannot squat the victim: not self, not its owner.
expect(await fm.controlsContract(attacker.address, await victim.getAddress())).to.equal(false);
await expect(
fm.connect(attacker).register(await victim.getAddress(), attacker.address)
).to.be.revertedWith("FeeMon: not controller");
// (b) an address the attacker doesn't equal / doesn't own -> also rejected.
await expect(
fm.connect(attacker).register(deployer.address, attacker.address)
).to.be.revertedWith("FeeMon: not controller");
// (c) OWNER path: the victim's Ownable owner CAN register it.
expect(await fm.controlsContract(legitOwner.address, await victim.getAddress())).to.equal(true);
await fm.connect(legitOwner).register(await victim.getAddress(), payout.address);
const tid = await fm.tokenIdOf(await victim.getAddress());
expect(tid).to.equal(1n);
expect(await fm.ownerOf(tid)).to.equal(payout.address);
// (d) SELF path: a contract can register itself.
const SP = await ethers.getContractFactory("SelfRegisteringProbe");
const self = await SP.deploy();
await self.waitForDeployment();
await self.selfRegister(await fm.getAddress(), payout.address);
const selfTid = await fm.tokenIdOf(await self.getAddress());
expect(selfTid).to.equal(2n);
expect(await fm.ownerOf(selfTid)).to.equal(payout.address);
// (e) UNREGISTER: registrant (NFT owner) clears it, paying out pending first.
await fm.connect(deployer).setDistributor(dist.address, true);
const amt = ONE / 100n;
await fm.connect(dist).distribute([tid], [amt], 0n, { value: amt });
expect(await fm.pendingRewards(tid)).to.equal(amt);
const balBefore = await ethers.provider.getBalance(payout.address);
await fm.connect(payout).unregister(await victim.getAddress()); // payout != gas payer? payout IS the caller here
// payout is the NFT owner AND the caller; net = +amt - gas. Assert pending cleared + NFT burned instead of exact bal.
expect(await fm.pendingRewards(tid)).to.equal(0n);
expect(await fm.tokenIdOf(await victim.getAddress())).to.equal(0n);
await expect(fm.ownerOf(tid)).to.be.reverted; // NFT burned
// sanity: balance moved by at most amt (received the pending, minus gas)
const balAfter = await ethers.provider.getBalance(payout.address);
expect(balAfter).to.be.gt(balBefore - ONE / 100n); // didn't lose more than a tiny gas amount
// (f) after unregister the SAME contract can be registered again cleanly.
await fm.connect(legitOwner).register(await victim.getAddress(), payout.address);
expect(await fm.tokenIdOf(await victim.getAddress())).to.equal(3n);
// (g) reassignPayout moves the fee-stream NFT.
await fm.connect(payout).reassignPayout(await victim.getAddress(), legitOwner.address);
expect(await fm.ownerOf(3n)).to.equal(legitOwner.address);
// (h) unregister is authorized: a stranger cannot unregister someone else's stream.
await expect(fm.connect(attacker).unregister(await victim.getAddress()))
.to.be.revertedWith("FeeMon: not authorized");
});
});
// ═══════════════════════ Finding 11 — AereOracle ═══════════════════════
describe("F11 single fresh reporter controls the median", function () {
const sym = ethers.encodeBytes32String("AERE/USD");
async function seedThree(o, owner, r1, r2, r3) {
for (const r of [r1, r2, r3]) await o.connect(owner).addReporter(r.address);
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);
}
it("V1 REPRODUCES: with others stale, one fresh reporter sets the price (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();
await seedThree(o, owner, r1, r2, r3);
await ethers.provider.send("evm_increaseTime", [301]);
await ethers.provider.send("evm_mine", []);
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);
});
it("V2 BLOCKS: single fresh reporter -> getPrice reverts InsufficientQuorum; 3 fresh -> median", async () => {
const [owner, r1, r2, r3] = await ethers.getSigners();
const O2 = await ethers.getContractFactory("AereOracleV2");
const o = await O2.connect(owner).deploy(3); // minContributors = 3
await o.waitForDeployment();
expect(await o.minContributors()).to.equal(3n);
await seedThree(o, owner, r1, r2, r3);
// All 3 fresh -> proper median.
{
const [price, , contributors] = await o.getPrice(sym);
expect(contributors).to.equal(3n);
expect(price).to.equal(100n * 10n ** 8n);
}
// Age everything stale, only r1 refreshes to a manipulated value.
await ethers.provider.send("evm_increaseTime", [301]);
await ethers.provider.send("evm_mine", []);
const manipulated = 500000n * 10n ** 8n;
await o.connect(r1).submit(sym, manipulated);
// Only 1 fresh -> quorum guard reverts (V1 would have returned `manipulated`).
await expect(o.getPrice(sym)).to.be.revertedWithCustomError(o, "InsufficientQuorum").withArgs(1n, 3n);
// 2 fresh -> still below quorum.
await o.connect(r2).submit(sym, 101n * 10n ** 8n);
await expect(o.getPrice(sym)).to.be.revertedWithCustomError(o, "InsufficientQuorum").withArgs(2n, 3n);
// 3 fresh -> quorum restored; a single reporter can no longer swing it:
// median of (manipulated, 101, 99) = 101 (the manipulated 500000 is an outlier).
await o.connect(r3).submit(sym, 99n * 10n ** 8n);
const [price2, , c2] = await o.getPrice(sym);
expect(c2).to.equal(3n);
expect(price2).to.equal(101n * 10n ** 8n);
});
it("V2 minContributors is configurable (floored at 1)", async () => {
const [owner, r1] = await ethers.getSigners();
const O2 = await ethers.getContractFactory("AereOracleV2");
const o = await O2.connect(owner).deploy(3);
await o.waitForDeployment();
await expect(o.connect(owner).setMinContributors(0)).to.be.revertedWith("minContributors");
await expect(O2.connect(owner).deploy(0)).to.be.revertedWith("minContributors");
// Lower to 1 -> a single fresh reporter is allowed again (Foundation's explicit choice).
await o.connect(owner).setMinContributors(1);
await o.connect(owner).addReporter(r1.address);
await o.connect(r1).submit(sym, 42n * 10n ** 8n);
const [price, , c] = await o.getPrice(sym);
expect(c).to.equal(1n);
expect(price).to.equal(42n * 10n ** 8n);
});
});
});