// AUDIT FIX VERIFICATION — F12 + F13 — 2026-07-10 // // Closes the campaign at 13/13. For each finding the live V1 REPRODUCES the bug // and the V2 fix CORRECTS it. Runs fully local against the REAL sources that // get deployed to chain 2800 (no mainnet txs here). // // F12 AereLendingOracle NavOracle feed is a DEAD PATH: configureFeed // (NavOracle -> AereNavOracle) reverts because the deployed NAV oracle // (Merkle reserve snapshots) has no latestForAsset(bytes32). FIX: a // purpose-built owner-attested AereNavOracleAdapter implements // latestForAsset, so a NavOracle-type feed configures + reads cleanly. // // F13 AERE402Facilitator replay key consumed[agentId][nonce] is GLOBAL per // agent, so two providers with colliding per-provider nonces collide and // a valid settlement reverts. FIX: AERE402FacilitatorV2 keys replay per // (agentId, payee, nonce); AereAgentV2 is cross-wired to it. const { expect } = require("chai"); const { ethers } = require("hardhat"); const ONE = 10n ** 18n; // ───────────────────────────────────────────────────────────────────────────── // F12 // ───────────────────────────────────────────────────────────────────────────── describe("F12 AereLendingOracle NavOracle dead path — V1 reproduces / V2 corrects", function () { this.timeout(120000); it("V1 REPRODUCES: configureFeed(NavOracle -> raw AereNavOracle) reverts (no latestForAsset)", async () => { const [owner] = await ethers.getSigners(); const lo = await (await ethers.getContractFactory("AereLendingOracle")).connect(owner).deploy(); await lo.waitForDeployment(); const nav = await (await ethers.getContractFactory("AereNavOracle")).deploy(); await nav.waitForDeployment(); // The deployed AereNavOracle exposes NO latestForAsset(bytes32). 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 calls latestForAsset -> revert. await expect( lo.connect(owner).configureFeed(asset, 1, await nav.getAddress(), ethers.id("BUIDL"), 86400, 0, 18) ).to.be.reverted; }); it("V2 CORRECTS: a NavOracle feed pointed at AereNavOracleAdapter configures + reads without reverting", async () => { const [owner] = await ethers.getSigners(); const lo = await (await ethers.getContractFactory("AereLendingOracle")).connect(owner).deploy(); await lo.waitForDeployment(); const nav = await (await ethers.getContractFactory("AereNavOracle")).deploy(); await nav.waitForDeployment(); // Adapter references the (real) NavOracle for provenance but attests prices itself. const adapter = await (await ethers.getContractFactory("AereNavOracleAdapter")).connect(owner).deploy(await nav.getAddress()); await adapter.waitForDeployment(); expect(await adapter.NAV_ORACLE()).to.equal(await nav.getAddress()); // The adapter implements the exact interface AereLendingOracle expects. const adNames = adapter.interface.fragments.filter(f => f.type === "function").map(f => f.name); expect(adNames).to.include("latestForAsset"); const asset = ethers.Wallet.createRandom().address; const assetKey = ethers.id("BUIDL"); // Un-attested asset -> (0,0) -> LendingOracle treats as InvalidFeedResponse // (the auto-poke's ts==0 check fires before the zero-price check). const empty = await adapter.latestForAsset(assetKey); expect(empty[0]).to.equal(0n); expect(empty[1]).to.equal(0n); await expect( lo.connect(owner).configureFeed(asset, 1, await adapter.getAddress(), assetKey, 86400, 0, 18) ).to.be.revertedWithCustomError(lo, "InvalidFeedResponse"); // Foundation attests a $1.02 NAV (18-dec convention). const navValue = 102n * ONE / 100n; // 1.02e18 await adapter.connect(owner).setNav(assetKey, navValue); const reading = await adapter.latestForAsset(assetKey); expect(reading[0]).to.equal(navValue); expect(reading[1]).to.be.gt(0n); // NavOracle feed now configures WITHOUT reverting (F12 fixed). await expect( lo.connect(owner).configureFeed(asset, 1, await adapter.getAddress(), assetKey, 86400, 500, 18) ).to.not.be.reverted; // And getPrice returns the normalised NAV. const price = await lo.getPrice(asset); expect(price).to.equal(navValue); // Sanity: the feed really is FeedType.NavOracle (1) pointed at the adapter. const feed = await lo.feeds(asset); expect(feed.feedType).to.equal(1n); expect(feed.source).to.equal(await adapter.getAddress()); }); it("V2 guards: setNav is owner-only and rejects a zero NAV; staleness still enforced by the LendingOracle", async () => { const [owner, stranger] = await ethers.getSigners(); const nav = await (await ethers.getContractFactory("AereNavOracle")).deploy(); await nav.waitForDeployment(); const adapter = await (await ethers.getContractFactory("AereNavOracleAdapter")).connect(owner).deploy(await nav.getAddress()); await adapter.waitForDeployment(); const key = ethers.id("USDY"); await expect(adapter.connect(stranger).setNav(key, ONE)).to.be.revertedWith("Ownable: caller is not the owner"); await expect(adapter.connect(owner).setNav(key, 0)).to.be.revertedWithCustomError(adapter, "ZeroNav"); // Configure a NavOracle feed with a 1s staleness, then let it go stale. const lo = await (await ethers.getContractFactory("AereLendingOracle")).connect(owner).deploy(); await lo.waitForDeployment(); await adapter.connect(owner).setNav(key, ONE); const asset = ethers.Wallet.createRandom().address; await lo.connect(owner).configureFeed(asset, 1, await adapter.getAddress(), key, 1, 0, 18); // fresh read works expect(await lo.getPrice(asset)).to.equal(ONE); // age it past staleness await ethers.provider.send("evm_increaseTime", [5]); await ethers.provider.send("evm_mine", []); await expect(lo.getPrice(asset)).to.be.revertedWithCustomError(lo, "FeedStale"); }); }); // ───────────────────────────────────────────────────────────────────────────── // F13 // ───────────────────────────────────────────────────────────────────────────── async function deployAgenticStack(Version) { // Version: "V1" (AereAgent + AERE402Facilitator) or "V2" (AereAgentV2 + AERE402FacilitatorV2) const [deployer] = await ethers.getSigners(); const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy(); await WAERE.waitForDeployment(); const nonce = await ethers.provider.getTransactionCount(deployer.address); const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 }); await WAERE.connect(deployer).deposit({ value: 1000n }); await WAERE.connect(deployer).approve(futureSaere, 1000n); const sAERE = await (await ethers.getContractFactory("sAERE")).deploy(await WAERE.getAddress()); await sAERE.waitForDeployment(); const burnVault = await (await ethers.getContractFactory("AereFeeBurnVault")).deploy(); await burnVault.waitForDeployment(); const fakeRouter = await (await ethers.getContractFactory("WAERE")).deploy(); await fakeRouter.waitForDeployment(); const sink = await (await ethers.getContractFactory("AereSink")).deploy( await WAERE.getAddress(), await burnVault.getAddress(), await sAERE.getAddress(), await fakeRouter.getAddress(), 1500, 4000, 4500, 200, ethers.ZeroAddress ); await sink.waitForDeployment(); const agentName = Version === "V2" ? "AereAgentV2" : "AereAgent"; const facName = Version === "V2" ? "AERE402FacilitatorV2" : "AERE402Facilitator"; const nonce2 = await ethers.provider.getTransactionCount(deployer.address); const futureFacilitator = ethers.getCreateAddress({ from: deployer.address, nonce: nonce2 + 1 }); const agent = await (await ethers.getContractFactory(agentName)).deploy(futureFacilitator); await agent.waitForDeployment(); const fac = await (await ethers.getContractFactory(facName)).deploy(await agent.getAddress(), await sink.getAddress()); await fac.waitForDeployment(); expect((await fac.getAddress()).toLowerCase()).to.equal(futureFacilitator.toLowerCase()); // cross-wiring expect(await agent.AERE402_FACILITATOR()).to.equal(await fac.getAddress()); expect(await fac.AGENT_REGISTRY()).to.equal(await agent.getAddress()); return { WAERE, sink, agent, fac }; } async function signAuth(signerWallet, fac, agentId, token, payee, amount, resourceId, nonce, deadline) { const domain = { name: "AERE402Facilitator", version: "1", chainId: (await ethers.provider.getNetwork()).chainId, verifyingContract: await fac.getAddress(), }; const types = { PaymentAuth: [ { name: "agentId", type: "uint256" }, { name: "token", type: "address" }, { name: "payee", type: "address" }, { name: "amount", type: "uint256" }, { name: "resourceId", type: "bytes32" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }; return signerWallet.signTypedData(domain, types, { agentId, token, payee, amount, resourceId, nonce, deadline }); } async function setupAgent(agent, WAERE, operator) { const signer = ethers.Wallet.createRandom(); const tx = await agent.connect(operator).registerAgent(signer.address, ethers.ZeroHash, ethers.ZeroHash, 10n * ONE, 0, 0, ""); const agentId = (await tx.wait()).logs.find(l => l.fragment?.name === "AgentRegistered").args.agentId; await WAERE.connect(operator).deposit({ value: 100n * ONE }); await WAERE.connect(operator).approve(await agent.getAddress(), 100n * ONE); await agent.connect(operator).topUp(agentId, await WAERE.getAddress(), 100n * ONE); return { signer, agentId }; } describe("F13 AERE402Facilitator per-agent nonce collision — V1 reproduces / V2 corrects", function () { this.timeout(120000); it("V1 REPRODUCES: two payees, same nonce -> provider B's valid settlement reverts NonceAlreadyConsumed", async () => { const [, operator, , payeeA, payeeB] = await ethers.getSigners(); const { WAERE, agent, fac } = await deployAgenticStack("V1"); const { signer, agentId } = await setupAgent(agent, WAERE, operator); const amount = 2n * ONE; const token = await WAERE.getAddress(); const deadline = (await ethers.provider.getBlock("latest")).timestamp + 3600; const sigA = await signAuth(signer, fac, agentId, token, payeeA.address, amount, ethers.id("A:/gpu"), 1n, deadline); await fac.connect(payeeA).settle(agentId, token, payeeA.address, amount, ethers.id("A:/gpu"), 1n, deadline, sigA); expect(await fac.consumed(agentId, 1n)).to.equal(true); // Provider B, own monotonic nonce=1, DIFFERENT payee, fully valid -> collides. const sigB = await signAuth(signer, fac, agentId, token, payeeB.address, amount, ethers.id("B:/api"), 1n, deadline); await expect( fac.connect(payeeB).settle(agentId, token, payeeB.address, amount, ethers.id("B:/api"), 1n, deadline, sigB) ).to.be.revertedWithCustomError(fac, "NonceAlreadyConsumed"); }); it("V2 CORRECTS: two payees, same nonce BOTH settle; true replay (same agentId+payee+nonce) still reverts", async () => { const [, operator, , payeeA, payeeB] = await ethers.getSigners(); const { WAERE, agent, fac } = await deployAgenticStack("V2"); const { signer, agentId } = await setupAgent(agent, WAERE, operator); const amount = 2n * ONE; const token = await WAERE.getAddress(); const deadline = (await ethers.provider.getBlock("latest")).timestamp + 3600; // Both providers use nonce=1 (per-provider monotonic convention). const sigA = await signAuth(signer, fac, agentId, token, payeeA.address, amount, ethers.id("A:/gpu"), 1n, deadline); await fac.connect(payeeA).settle(agentId, token, payeeA.address, amount, ethers.id("A:/gpu"), 1n, deadline, sigA); const balBBefore = await WAERE.balanceOf(payeeB.address); const sigB = await signAuth(signer, fac, agentId, token, payeeB.address, amount, ethers.id("B:/api"), 1n, deadline); await fac.connect(payeeB).settle(agentId, token, payeeB.address, amount, ethers.id("B:/api"), 1n, deadline, sigB); // Per-payee namespace: both consumed independently. expect(await fac.consumed(agentId, payeeA.address, 1n)).to.equal(true); expect(await fac.consumed(agentId, payeeB.address, 1n)).to.equal(true); // Provider B actually got paid (amount - 25bps fee). const fee = amount * 25n / 10_000n; expect(await WAERE.balanceOf(payeeB.address)).to.equal(balBBefore + (amount - fee)); // TRUE replay still blocked: same (agentId, payeeB, nonce=1). const sigReplay = await signAuth(signer, fac, agentId, token, payeeB.address, amount, ethers.id("B:/api"), 1n, deadline); await expect( fac.connect(payeeB).settle(agentId, token, payeeB.address, amount, ethers.id("B:/api"), 1n, deadline, sigReplay) ).to.be.revertedWithCustomError(fac, "NonceAlreadyConsumed"); }); });