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.
184 lines
9.4 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|