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