aere-contracts/test/agent-bond-reputation.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

210 lines
11 KiB
JavaScript

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