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.
210 lines
11 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|