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.
271 lines
12 KiB
Solidity
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;
|
|
}
|
|
}
|