// 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); }); }); });