aere-contracts/contracts/agentic/AereAgentBond.sol
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

271 lines
12 KiB
Solidity

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