const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("AereAgentBond + AereAIReputation", () => { let foundation, op1, op2, attestor1, outsider; let aere, sink, bond, rep; const AGENT_A = ethers.keccak256(ethers.toUtf8Bytes("agent-A")); const AGENT_B = ethers.keccak256(ethers.toUtf8Bytes("agent-B")); const ONE_AERE = ethers.parseUnits("1", 18); const TEN_AERE = ethers.parseUnits("10", 18); beforeEach(async () => { [foundation, op1, op2, attestor1, outsider] = await ethers.getSigners(); const T = await ethers.getContractFactory("MockERC20Lending"); aere = await T.deploy("AERE", "AERE", 18); await aere.waitForDeployment(); const S = await ethers.getContractFactory("MockSinkSimple"); sink = await S.deploy(); await sink.waitForDeployment(); const B = await ethers.getContractFactory("AereAgentBond"); bond = await B.deploy(await aere.getAddress(), await sink.getAddress(), [foundation.address]); await bond.waitForDeployment(); const R = await ethers.getContractFactory("AereAIReputation"); rep = await R.deploy(await bond.getAddress(), [attestor1.address]); await rep.waitForDeployment(); // Seed operators with AERE balance. await aere.mint(op1.address, ethers.parseUnits("100", 18)); await aere.mint(op2.address, ethers.parseUnits("100", 18)); await aere.connect(op1).approve(await bond.getAddress(), ethers.MaxUint256); await aere.connect(op2).approve(await bond.getAddress(), ethers.MaxUint256); }); describe("AgentBond", () => { it("postBond pulls AERE + sets state + emits", async () => { await expect(bond.connect(op1).postBond(AGENT_A, TEN_AERE)) .to.emit(bond, "BondPosted").withArgs(op1.address, AGENT_A, TEN_AERE, TEN_AERE); const b = await bond.bondOf(op1.address, AGENT_A); expect(b.amount).to.equal(TEN_AERE); expect(b.exists).to.equal(true); expect(b.requestedAt).to.equal(0n); }); it("top-up emits BondToppedUp + cancels in-flight withdrawal", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await bond.connect(op1).requestWithdraw(AGENT_A); expect((await bond.bondOf(op1.address, AGENT_A)).requestedAt).to.be.gt(0n); await expect(bond.connect(op1).postBond(AGENT_A, TEN_AERE)) .to.emit(bond, "BondToppedUp"); expect((await bond.bondOf(op1.address, AGENT_A)).requestedAt).to.equal(0n); expect((await bond.bondOf(op1.address, AGENT_A)).amount).to.equal(TEN_AERE * 2n); }); it("requestWithdraw + withdraw after cooldown", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await bond.connect(op1).requestWithdraw(AGENT_A); await expect(bond.connect(op1).requestWithdraw(AGENT_A)) .to.be.revertedWithCustomError(bond, "WithdrawalAlreadyRequested"); await expect(bond.connect(outsider).withdraw(op1.address, AGENT_A)) .to.be.revertedWithCustomError(bond, "WithdrawalCooldownOpen"); await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); const balBefore = await aere.balanceOf(op1.address); await expect(bond.connect(outsider).withdraw(op1.address, AGENT_A)) .to.emit(bond, "Withdrawn"); const balAfter = await aere.balanceOf(op1.address); expect(balAfter - balBefore).to.equal(TEN_AERE); }); it("withdraw without request reverts", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await expect(bond.connect(op1).withdraw(op1.address, AGENT_A)) .to.be.revertedWithCustomError(bond, "WithdrawalNotRequested"); }); it("slash only from oracle; forwards to sink; reduces amount; bumps lifetimeSlashed", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await expect( bond.connect(outsider).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, "") ).to.be.revertedWithCustomError(bond, "NotOracle"); const reason = ethers.keccak256(ethers.toUtf8Bytes("aifraud-001")); // AUDIT FIX (HIGH #18): 25% cap per call → max 2.5 AERE on 10 AERE bond. const slashAmt = ONE_AERE * 2n; await expect( bond.connect(foundation).slash(op1.address, AGENT_A, slashAmt, reason, "ipfs://evidence") ).to.emit(bond, "Slashed").withArgs(op1.address, AGENT_A, foundation.address, slashAmt, reason, "ipfs://evidence"); const b = await bond.bondOf(op1.address, AGENT_A); expect(b.amount).to.equal(TEN_AERE - slashAmt); expect(b.lifetimeSlashed).to.equal(slashAmt); expect(await sink.lastFlushAmount()).to.equal(slashAmt); expect(await bond.totalSlashedToSink()).to.equal(slashAmt); }); it("slash beyond available bond reverts", async () => { await bond.connect(op1).postBond(AGENT_A, ONE_AERE); await expect( bond.connect(foundation).slash(op1.address, AGENT_A, TEN_AERE, ethers.ZeroHash, "") ).to.be.revertedWithCustomError(bond, "SlashAmountExceedsBond"); }); it("slash beyond 25% per-call cap reverts (audit fix HIGH #18)", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); // 30% of 10 AERE = 3 AERE > 25% cap (2.5 AERE) await expect( bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE * 3n, ethers.ZeroHash, "") ).to.be.revertedWithCustomError(bond, "SlashExceedsCallCap"); }); it("slash cooldown 24h (audit fix HIGH #18)", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, ""); // Second slash within 24h reverts. await expect( bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, "") ).to.be.revertedWithCustomError(bond, "SlashCooldownActive"); // After 24h+ second slash succeeds. await ethers.provider.send("evm_increaseTime", [24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, ""); }); it("constructor rejects empty oracles array (audit fix MED #33)", async () => { const F = await ethers.getContractFactory("AereAgentBond"); await expect(F.deploy(await aere.getAddress(), await sink.getAddress(), [])).to.be.revertedWith("no-oracles"); }); it("postBond top-up emits WithdrawalCancelled (audit fix MED #36)", async () => { await bond.connect(op1).postBond(AGENT_A, ONE_AERE); await bond.connect(op1).requestWithdraw(AGENT_A); await expect(bond.connect(op1).postBond(AGENT_A, ONE_AERE)).to.emit(bond, "WithdrawalCancelled"); }); it("isBonded threshold + withdraw-flag semantics", async () => { await bond.connect(op1).postBond(AGENT_A, TEN_AERE); expect(await bond.isBonded(op1.address, AGENT_A, TEN_AERE)).to.equal(true); expect(await bond.isBonded(op1.address, AGENT_A, TEN_AERE + 1n)).to.equal(false); await bond.connect(op1).requestWithdraw(AGENT_A); // After withdraw request, isBonded false even though amount is still there. expect(await bond.isBonded(op1.address, AGENT_A, ONE_AERE)).to.equal(false); }); }); describe("AIReputation", () => { it("attest +1 / -1 / 0 are only callable by attestor; record stats", async () => { // AUDIT FIX (HIGH #20): each evidenceHash can only be used once per attestor. const ev1 = ethers.keccak256(ethers.toUtf8Bytes("ev-1")); const ev2 = ethers.keccak256(ethers.toUtf8Bytes("ev-2")); const ev3 = ethers.keccak256(ethers.toUtf8Bytes("ev-3")); const ev4 = ethers.keccak256(ethers.toUtf8Bytes("ev-4")); await expect(rep.connect(outsider).attest(op1.address, AGENT_A, 1, ev1, "")) .to.be.revertedWithCustomError(rep, "NotAttestor"); await rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev1, ""); await rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev2, ""); await rep.connect(attestor1).attest(op1.address, AGENT_A, -1, ev3, ""); await rep.connect(attestor1).attest(op1.address, AGENT_A, 0, ev4, "ipfs://memo"); const s = await rep.statsOf(op1.address, AGENT_A); expect(s.positiveCount).to.equal(2n); expect(s.disputeCount).to.equal(1n); // Replay rejected. await expect(rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev1, "")) .to.be.revertedWithCustomError(rep, "EvidenceReplay"); }); it("attest rejects out-of-range delta + zero evidence hash", async () => { const ev = ethers.keccak256(ethers.toUtf8Bytes("ev-1")); await expect(rep.connect(attestor1).attest(op1.address, AGENT_A, 2, ev, "")) .to.be.revertedWithCustomError(rep, "InvalidDelta"); await expect(rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ethers.ZeroHash, "")) .to.be.revertedWithCustomError(rep, "EmptyEvidenceHash"); }); it("score formula: positive +10, slash -10/AERE, dispute -20; clamped", async () => { // Setup: post bond + slash 2 AERE (under 25% cap on 10 AERE); +5 positives; 1 dispute. await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE * 2n, ethers.keccak256(ethers.toUtf8Bytes("s1")), ""); for (let i = 0; i < 5; i++) { const ev = ethers.keccak256(ethers.toUtf8Bytes(`ev-pos-${i}`)); await rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev, ""); } await rep.connect(attestor1).attest(op1.address, AGENT_A, -1, ethers.keccak256(ethers.toUtf8Bytes("ev-dispute")), ""); // raw = 5*10 - 2*10 - 1*20 = 50 - 20 - 20 = 10 expect(await rep.scoreOf(op1.address, AGENT_A)).to.equal(10n); }); it("score clamps at zero when malus exceeds positives", async () => { // Post 1 AERE bond, slash 0.2 AERE (= 20% < 25% cap). No positives → score 0. await bond.connect(op1).postBond(AGENT_A, ONE_AERE); await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE / 5n, ethers.keccak256(ethers.toUtf8Bytes("s")), ""); expect(await rep.scoreOf(op1.address, AGENT_A)).to.equal(0n); }); it("score reflects slashed bond even after operator withdraws clean", async () => { // Post + slash 2 AERE (20% of 10, under cap) + request withdraw + cooldown + withdraw. await bond.connect(op1).postBond(AGENT_A, TEN_AERE); await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE * 2n, ethers.keccak256(ethers.toUtf8Bytes("s2")), ""); await bond.connect(op1).requestWithdraw(AGENT_A); await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await bond.connect(op1).withdraw(op1.address, AGENT_A); // lifetimeSlashed persists; score still reflects -20 from slash. const s = await rep.statsOf(op1.address, AGENT_A); expect(s.lifetimeSlashed).to.equal(ONE_AERE * 2n); }); }); });