// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title AereAgentBond — slashable bond for autonomous AI agents * @notice The agentic economy needs accountable agents. A wallet address * claiming to be "OpenAI's research agent" or "an audited TPM-bound * inference node" is a claim, not a property. AereAgentBond turns * the claim into a financial commitment. * * An operator (the human / org behind an agent) posts a bond in AERE. * A registry of slashing oracles — entities the protocol has * committed to listen to — can slash the bond when they observe * provable misbehaviour (model-attestation perjury, refusal of * observation challenges, AERE402 payment fraud, etc.). * * Slashed AERE goes to AereSink — directly into the burn engine. * No discretion, no recovery, no "we'll give it back if you fix it". * The cost of a slash is a real economic loss, not a corporate * knuckle-rap. * * Operators can WITHDRAW a bond, but only after a cooldown * (default 7 days, immutable) during which the bond is still * slashable. This prevents bad actors from withdrawing the moment * they detect they're about to get caught. * * AGENT IDENTITY: * This contract does NOT establish who "the agent" is — it just * keys on (operator address, agentId). The agentId can be the * hash of an OpenAI API key fingerprint, an AereAgent registry * id, an EAS attestation id, or any external identifier. Read * consistency is the consumer's problem. * * IMMUTABILITY: * - WITHDRAWAL_COOLDOWN constant. * - SINK + AERE token addresses immutable at deploy. * - Slashing oracles registered immutably at deploy. Cannot add * new oracles post-deploy. Phase 2 will use a multi-sig * quorum across oracles; Phase 1 supports a single Foundation * oracle for fastest iteration. * * GOTCHA — cooldown semantics: * When an operator calls `requestWithdraw()`, their bond is * "marked for withdrawal". The bond is STILL slashable until * the cooldown elapses. `withdraw()` then transfers the * remaining (post-any-slashes) bond to the operator. Anyone * can call `withdraw()` for an operator whose cooldown has * elapsed (defensive: prevents griefing where operator never * calls withdraw). */ contract AereAgentBond is ReentrancyGuard { /* ================================ config ================================ */ IERC20 public immutable AERE; // bond token address public immutable SINK; // AereSink for slashed-AERE forward uint256 public constant WITHDRAWAL_COOLDOWN = 7 days; /// @notice immutable set of slashing oracles. Each can slash any bond. /// Phase 1: a single Foundation oracle. Phase 2: multi-sig quorum /// across N oracles (would require redeploy, by design). mapping(address => bool) public isOracle; address[] public oracleList; /* ================================= state ================================= */ struct Bond { uint256 amount; // current AERE bonded (post any slashes) uint64 requestedAt; // 0 if no withdrawal requested; else unix seconds uint256 lifetimeSlashed; // cumulative AERE slashed (informational; never resets) bool exists; } /// @notice (operator, agentId) → bond mapping(address => mapping(bytes32 => Bond)) internal _bonds; /// @notice cumulative AERE forwarded to sink (informational). uint256 public totalSlashedToSink; /// AUDIT FIX (HIGH #21): operator-level cumulative slash across all /// agentIds. Rotating agentId cannot wipe this — reputation reads it /// to apply a residual penalty on the operator's new agents. mapping(address => uint256) public operatorLifetimeSlashed; /* ================================= events ================================= */ event BondPosted(address indexed operator, bytes32 indexed agentId, uint256 amount, uint256 newTotal); event BondToppedUp(address indexed operator, bytes32 indexed agentId, uint256 added, uint256 newTotal); event WithdrawalRequested(address indexed operator, bytes32 indexed agentId, uint64 readyAt); event WithdrawalCancelled(address indexed operator, bytes32 indexed agentId); // AUDIT FIX (MED #36) event Withdrawn(address indexed operator, bytes32 indexed agentId, address indexed recipient, uint256 amount); event Slashed( address indexed operator, bytes32 indexed agentId, address indexed oracle, uint256 amount, bytes32 reasonHash, string reasonUri ); /* ================================= errors ================================= */ error NotOracle(); error BondNotFound(); error WithdrawalAlreadyRequested(); error WithdrawalNotRequested(); error WithdrawalCooldownOpen(uint256 nowTs, uint256 readyAt); error ZeroAmount(); error SlashAmountExceedsBond(uint256 amount, uint256 have); error SlashExceedsCallCap(uint256 amount, uint256 cap); error SlashCooldownActive(uint64 nowTs, uint64 earliest); error TransferFailed(); /* =============================== modifiers ============================== */ modifier onlyOracle() { if (!isOracle[msg.sender]) revert NotOracle(); _; } /* ============================== constructor ============================= */ /// AUDIT FIX (HIGH #18): per-call slash cap (25% of bond) + per-(operator,agentId) /// slash cooldown (24h) to limit single-key oracle blast. uint16 public constant MAX_SLASH_BPS_PER_CALL = 2500; // 25% uint64 public constant SLASH_COOLDOWN = 24 hours; mapping(address => mapping(bytes32 => uint64)) public lastSlashAt; constructor(IERC20 aere, address sink, address[] memory oracles) { require(address(aere) != address(0), "zero-aere"); require(sink != address(0), "zero-sink"); require(oracles.length > 0, "no-oracles"); // AUDIT FIX (MED #33) AERE = aere; SINK = sink; for (uint256 i = 0; i < oracles.length; i++) { require(oracles[i] != address(0), "zero-oracle"); require(!isOracle[oracles[i]], "dup-oracle"); isOracle[oracles[i]] = true; oracleList.push(oracles[i]); } } /* ================================= write ================================= */ /// @notice Post or top-up a bond for (msg.sender, agentId). Pulls AERE /// from the caller. Top-up clears any pending withdrawal request /// — re-committing the bond. function postBond(bytes32 agentId, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); if (!AERE.transferFrom(msg.sender, address(this), amount)) revert TransferFailed(); Bond storage b = _bonds[msg.sender][agentId]; bool isNew = !b.exists; b.exists = true; b.amount += amount; // Re-committing the bond cancels any in-flight withdrawal request. if (b.requestedAt != 0) { b.requestedAt = 0; emit WithdrawalCancelled(msg.sender, agentId); // AUDIT FIX (MED #36) } if (isNew) { emit BondPosted(msg.sender, agentId, amount, b.amount); } else { emit BondToppedUp(msg.sender, agentId, amount, b.amount); } } /// @notice Operator requests withdrawal. Bond stays slashable for /// WITHDRAWAL_COOLDOWN. Re-requesting before cooldown elapses /// is forbidden (would extend exposure of slashers who already /// observed the original request). function requestWithdraw(bytes32 agentId) external { Bond storage b = _bonds[msg.sender][agentId]; if (!b.exists || b.amount == 0) revert BondNotFound(); if (b.requestedAt != 0) revert WithdrawalAlreadyRequested(); b.requestedAt = uint64(block.timestamp); emit WithdrawalRequested(msg.sender, agentId, uint64(block.timestamp + WITHDRAWAL_COOLDOWN)); } /// @notice Permissionless withdraw once cooldown has elapsed. Transfers /// the (post-slash) remaining bond to the operator. function withdraw(address operator, bytes32 agentId) external nonReentrant { Bond storage b = _bonds[operator][agentId]; if (!b.exists || b.amount == 0) revert BondNotFound(); if (b.requestedAt == 0) revert WithdrawalNotRequested(); uint256 readyAt = uint256(b.requestedAt) + WITHDRAWAL_COOLDOWN; if (block.timestamp < readyAt) revert WithdrawalCooldownOpen(block.timestamp, readyAt); uint256 amount = b.amount; b.amount = 0; b.requestedAt = 0; if (!AERE.transfer(operator, amount)) revert TransferFailed(); emit Withdrawn(operator, agentId, operator, amount); } /// @notice Slash a bond. Only registered oracles can call. Slashed AERE /// forwarded immediately to AereSink — no holding period, no /// "release to whitelist" route. Burn engine eats it. /// @param reasonHash on-chain hash of the violation evidence (e.g. /// keccak256 of the AereAIProof attestation that /// contradicts the agent's claim, or the AERE402 /// dispute proof). /// @param reasonUri optional human-readable evidence pointer /// (ipfs://… or https://…). Stored as event arg only. function slash( address operator, bytes32 agentId, uint256 amount, bytes32 reasonHash, string calldata reasonUri ) external nonReentrant onlyOracle { if (amount == 0) revert ZeroAmount(); Bond storage b = _bonds[operator][agentId]; if (!b.exists || b.amount == 0) revert BondNotFound(); if (amount > b.amount) revert SlashAmountExceedsBond(amount, b.amount); // AUDIT FIX (HIGH #18): per-call cap (25%) limits blast radius of a // compromised single-oracle key. uint256 cap = (b.amount * MAX_SLASH_BPS_PER_CALL) / 10_000; if (cap == 0) cap = b.amount; // tiny dust bonds — slash all (still ≤ b.amount) if (amount > cap) revert SlashExceedsCallCap(amount, cap); // Cooldown between slashes per (operator, agentId). uint64 lastTs = lastSlashAt[operator][agentId]; if (lastTs != 0 && block.timestamp < uint256(lastTs) + SLASH_COOLDOWN) { revert SlashCooldownActive(uint64(block.timestamp), lastTs + uint64(SLASH_COOLDOWN)); } lastSlashAt[operator][agentId] = uint64(block.timestamp); b.amount -= amount; b.lifetimeSlashed += amount; operatorLifetimeSlashed[operator] += amount; // AUDIT FIX (HIGH #21) totalSlashedToSink += amount; // Forward slashed AERE to sink via approve + flush. The sink // requires transferFrom semantics from msg.sender. AERE.approve(SINK, 0); AERE.approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", address(AERE), amount)); AERE.approve(SINK, 0); if (!ok) revert TransferFailed(); emit Slashed(operator, agentId, msg.sender, amount, reasonHash, reasonUri); } /* ================================= views ================================= */ function bondOf(address operator, bytes32 agentId) external view returns ( uint256 amount, uint64 requestedAt, uint256 lifetimeSlashed, bool exists ) { Bond storage b = _bonds[operator][agentId]; return (b.amount, b.requestedAt, b.lifetimeSlashed, b.exists); } /// @notice Convenience: true if the bond is currently >= `minBond` and /// not requested for withdrawal. Consumers (AERE402 facilitator, /// AereAIProof verifier) should require this before honoring /// agent claims. function isBonded(address operator, bytes32 agentId, uint256 minBond) external view returns (bool) { Bond storage b = _bonds[operator][agentId]; return b.exists && b.amount >= minBond && b.requestedAt == 0; } function oracles() external view returns (address[] memory) { return oracleList; } }