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

399 lines
21 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @dev The AereAgentDID surface this facilitator consumes. The DID is the single
/// authorization oracle: one call to `authorize` enforces the Falcon ROOT
/// lifecycle, the session scope, the cumulative spend cap, the secp256k1 session
/// signature (ecrecover) and per-session anti-replay, and records the spend, or
/// reverts. `getSession` is used only to resolve which agent (Falcon root) funds
/// the payment and to guard self-deal. `keyRegistry` lets this contract locate the
/// registry so it can read the current key owner (the agent controller).
interface IAereAgentDID {
function authorize(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, bytes calldata sig)
external
returns (bool);
function getSession(uint256 sessionId)
external
view
returns (
uint256 rootKeyId,
address sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint256 spent,
uint64 issuedBlock,
uint64 expiry,
uint64 actionNonce,
bool revoked
);
function isSessionValid(uint256 sessionId) external view returns (bool);
function agentExists(uint256 rootKeyId) external view returns (bool);
function keyRegistry() external view returns (address);
}
/// @dev The one AerePQCKeyRegistry read this facilitator needs: the current owner of a
/// key record. That owner is the agent's controller (the party that proved Falcon
/// possession at registration). ownerOf returns the owner for ACTIVE, ROTATED and
/// REVOKED records alike, so an operator can always withdraw an agent's idle vault
/// even after rotating or revoking its Falcon root; only session-authorized SPENDING
/// halts on a rotated/revoked root.
interface IAerePQCKeyRegistryLite {
function ownerOf(uint256 keyId) external view returns (address);
}
/**
* @title AERE402FacilitatorPQC — HTTP 402 agentic settlement rooted in a post-quantum identity
*
* @notice A settlement facilitator for machine (agent-to-service) payments whose spending
* authority is rooted in a quantum-durable Falcon key, not a bare secp256k1 signer.
*
* WHAT IS DIFFERENT FROM AERE402Facilitator / V2. The classic facilitators debit an
* AereAgent registry and authorize each payment with an EIP-712 signature from the
* agent's rotatable secp256k1 signer (AereAgent.signerOf). Here the payer is an
* AereAgentDID: an agent whose ROOT authority is a Falcon key held in
* AerePQCKeyRegistry, and whose day-to-day work is signed by cheap, short-lived,
* revocable secp256k1 SESSION keys. A payment is authorized by the agent's DID
* SESSION signature, and the whole payment path inherits the DID's guarantees:
*
* - ROOT LIFECYCLE ENFORCED. A session is valid only while its Falcon root is
* still ACTIVE in the registry. Rotating or revoking the Falcon root instantly
* halts every downstream payment under it — this facilitator delegates the
* check to AereAgentDID.authorize, which fails closed the moment the root is no
* longer ACTIVE Falcon. Spending authority lives in the quantum-durable key.
* - CHEAP HOT PATH. Per payment, the agent signs a raw secp256k1 signature over
* the DID action digest; verification is a single ecrecover (~3k gas). The
* Falcon key is a cold, high-value authority that only ever issues sessions; it
* is never exposed to the hot payment path.
* - PER-SESSION SCOPE + SPEND CAP. Each session is locked to an opaque scope tag
* and a cumulative spend cap. This facilitator passes the caller-supplied scope
* to the DID (which reverts on a mismatch) and passes the payment amount as the
* session spend (which reverts if the cap would be exceeded). A leaked session
* key is contained by its scope, cap, expiry and the on-chain revocation list.
*
* PREPAID VAULT. This contract holds a prepaid balance per (agent, token), keyed by
* the agent's Falcon root key id (the DID agent id). Anyone may `topUp` a registered
* agent's vault; only the agent's controller (the current registry owner of the root
* key) may `withdraw` idle balance or `setPaused`. On `settle`, the debited amount is
* split: (amount - fee) to the payee and a 25-bps protocol fee to AereSink (the
* flywheel), exactly as the classic facilitators, with the same best-effort flush.
*
* SETTLE FLOW per call:
* 1. Provider answers a request with HTTP 402 + an AERE402-PQC quote
* { facilitator, sessionId, scope, token, payee, amount, resourceId, deadline }.
* 2. The agent signs the DID action digest for its session with the session key
* (raw secp256k1) and returns the signature.
* 3. Provider calls settle(...) with the quote + signature. This contract:
* a. Checks the payment deadline, msg.sender == payee, amount, self-deal, and
* the agent's pause flag.
* b. Binds the payment terms into an actionHash committed to THIS facilitator.
* c. Delegates authorization to AereAgentDID.authorize — the single call that
* enforces root lifecycle + scope + spend cap + session signature +
* anti-replay, and records the spend.
* d. Debits the agent's prepaid vault and splits amount -> payee + sink.
* 4. Provider serves the response now that settlement is final.
*
* HONEST SCOPE. Application-layer identity + settlement. This does NOT change AERE
* consensus (blocks are still Besu QBFT with classical ECDSA). Falcon verification
* is performed by AERE's live native precompile through AerePQCKeyRegistry, reached
* via the DID. This contract custodies prepaid ERC-20 balances only; it mints no
* token and has no owner.
*
* IMMUTABILITY. AereAgentDID address, AerePQCKeyRegistry address (read from the DID
* at construction), SINK address and the 25-bps fee are all pinned. No admin can
* move funds, change the fee, or repoint the DID.
*/
contract AERE402FacilitatorPQC is ReentrancyGuard {
/* ------------------------------- immutable ------------------------------- */
/// @notice The AereAgentDID that roots authorization in a Falcon key and owns the
/// session model. The sole authorization oracle for `settle`.
IAereAgentDID public immutable DID;
/// @notice The AerePQCKeyRegistry the DID roots into (read from DID at construction).
/// Used only to resolve an agent's current controller (registry key owner).
IAerePQCKeyRegistryLite public immutable KEY_REGISTRY;
/// @notice AereSink (the flywheel) that receives the protocol fee.
address public immutable SINK;
/// @notice 0.25% protocol fee, identical to the classic facilitators.
uint16 public constant PROTOCOL_FEE_BPS = 25;
/// @notice Domain tag binding a payment's terms to THIS facilitator inside the DID
/// action commitment. The session key signs the DID action digest over this
/// actionHash, so the same session action can never be replayed against a
/// different facilitator or with different terms.
bytes32 public constant PAYMENT_DOMAIN = keccak256("AERE402FacilitatorPQC.v1.payment");
/* --------------------------------- state -------------------------------- */
/// @notice rootKeyId (DID agent id) => token => prepaid balance.
mapping(uint256 => mapping(address => uint256)) public balances;
/// @notice rootKeyId => paused. When true, `settle` reverts for this agent. Controlled
/// by the agent's controller as a cheap incident-response switch (independent of,
/// and in addition to, per-session revocation and root rotation/revocation).
mapping(uint256 => bool) public paused;
/// @notice token => failed-flush fee residual custodied by this contract but NOT owed to
/// any agent vault. It grows ONLY when a `settle` fee flush to the SINK fails
/// best-effort (the fee tokens then sit idle here), and shrinks only when
/// `flushResidual` successfully forwards them. It is the EXACT and only pool
/// `flushResidual` may move; prepaid vault balances are never reachable from it.
/// This is what keeps the vault-solvency invariant true:
/// tokenBalance(this) >= sum(balances[*][token]) + residual[token].
mapping(address => uint256) public residual;
/* --------------------------------- events ------------------------------- */
event SettledPQC(
uint256 indexed rootKeyId,
uint256 indexed sessionId,
address indexed payee,
address token,
uint256 amount,
uint256 protocolFee,
bytes32 resourceId
);
event TopUp(uint256 indexed rootKeyId, address indexed token, address indexed funder, uint256 amount);
event Withdrawn(uint256 indexed rootKeyId, address indexed token, address indexed recipient, uint256 amount);
event PausedSet(uint256 indexed rootKeyId, bool paused, address by);
/// @notice A `settle` fee flush to the SINK failed; `amount` of fee is now tracked residual.
event ResidualAccrued(address indexed token, uint256 amount);
/// @notice `flushResidual` forwarded `amount` of tracked residual to the SINK.
event ResidualFlushed(address indexed token, address indexed by, uint256 amount);
/* --------------------------------- errors ------------------------------- */
error ZeroAddress();
error ZeroAmount();
error DeadlinePassed();
error PayeeMismatch();
error SelfDealForbidden(); // payee == the session's own key
error AgentPaused();
error UnknownAgent();
error NotController();
error SessionAuthFailed(); // DID.authorize did not confirm the payment
error InsufficientBalance(uint256 have, uint256 want);
error InsufficientResidual(uint256 have, uint256 want); // flushResidual bounded to tracked residual
error TransferFailed();
/* ------------------------------- constructor ---------------------------- */
constructor(address did, address sink) {
if (did == address(0) || sink == address(0)) revert ZeroAddress();
DID = IAereAgentDID(did);
address reg = IAereAgentDID(did).keyRegistry();
if (reg == address(0)) revert ZeroAddress();
KEY_REGISTRY = IAerePQCKeyRegistryLite(reg);
SINK = sink;
}
/* ------------------------------- funding --------------------------------- */
/// @notice Fund an agent's prepaid vault. Permissionless: anyone may top up a
/// registered agent (the operator, a sponsor, a treasury). Pulls `amount` of
/// `token` from msg.sender.
function topUp(uint256 rootKeyId, address token, uint256 amount) external nonReentrant {
if (!DID.agentExists(rootKeyId)) revert UnknownAgent();
if (amount == 0) revert ZeroAmount();
if (!IERC20(token).transferFrom(msg.sender, address(this), amount)) revert TransferFailed();
balances[rootKeyId][token] += amount;
emit TopUp(rootKeyId, token, msg.sender, amount);
}
/// @notice Withdraw idle vault balance. Only the agent's controller (the current
/// registry owner of the Falcon root key) may withdraw. A rotated or revoked
/// root does NOT strand funds: the registry still reports the same owner, so
/// the operator can always recover the prepaid balance.
function withdraw(uint256 rootKeyId, address token, uint256 amount, address recipient) external nonReentrant {
if (recipient == address(0)) revert ZeroAddress();
if (amount == 0) revert ZeroAmount();
_requireController(rootKeyId);
uint256 bal = balances[rootKeyId][token];
if (bal < amount) revert InsufficientBalance(bal, amount);
unchecked {
balances[rootKeyId][token] = bal - amount;
}
if (!IERC20(token).transfer(recipient, amount)) revert TransferFailed();
emit Withdrawn(rootKeyId, token, recipient, amount);
}
/// @notice Pause or unpause an agent's settlement path. Only the agent's controller.
/// A cheap ECDSA incident switch that halts all `settle` calls for this agent
/// without touching the DID or the Falcon root.
function setPaused(uint256 rootKeyId, bool value) external {
_requireController(rootKeyId);
paused[rootKeyId] = value;
emit PausedSet(rootKeyId, value, msg.sender);
}
/* --------------------------------- settle -------------------------------- */
/// @notice Settle a machine payment authorized by an AereAgentDID session.
/// @dev msg.sender MUST equal `payee`: a third party cannot redirect a leaked
/// session signature to an attacker-controlled payee, and the payee's identity
/// is bound into the authorization commitment either way. All session/root
/// authorization (root lifecycle, scope, spend cap, secp256k1 session signature,
/// anti-replay) is enforced by the single DID.authorize call; this function adds
/// the payment-deadline, self-deal, pause and prepaid-balance layers.
/// @param sessionId the DID session authorizing the payment.
/// @param scope the scope tag for this payment (must equal the session's scopeHash).
/// @param token the settlement ERC-20.
/// @param payee the service provider being paid (== msg.sender).
/// @param amount gross payment (fee is taken from this); charged against the session cap.
/// @param resourceId opaque resource tag (e.g. keccak256("GET /v1/inference")).
/// @param deadline unix seconds after which the payment auth is stale.
/// @param sessionSig 65-byte secp256k1 signature by the session key over the DID action
/// digest for (sessionId, scope, paymentActionHash, amount, actionNonce).
function settle(
uint256 sessionId,
bytes32 scope,
address token,
address payee,
uint256 amount,
bytes32 resourceId,
uint256 deadline,
bytes calldata sessionSig
) external nonReentrant {
if (block.timestamp > deadline) revert DeadlinePassed();
if (msg.sender != payee) revert PayeeMismatch();
if (payee == address(0)) revert ZeroAddress();
if (amount == 0) revert ZeroAmount();
// Resolve the funding agent (Falcon root) and the session key, for the self-deal
// guard and the vault debit. getSession reverts on an unknown session.
(uint256 rootKeyId, address sessionAddr,,,,,,,) = DID.getSession(sessionId);
// A compromised session key cannot drain the agent by paying itself.
if (payee == sessionAddr) revert SelfDealForbidden();
if (paused[rootKeyId]) revert AgentPaused();
// Bind the payment terms to THIS facilitator. The session key's signature (checked
// inside DID.authorize) is over the DID action digest, which commits to sessionId,
// scope, this actionHash, the amount and the session's own actionNonce — so every
// term below is authorized and this authorization cannot be replayed elsewhere.
bytes32 actionHash = _paymentActionHash(token, payee, resourceId, deadline);
// The single authorization gate: enforces root lifecycle + scope + spend cap +
// session signature + anti-replay, and records the spend, or reverts.
bool ok = DID.authorize(sessionId, scope, actionHash, amount, sessionSig);
if (!ok) revert SessionAuthFailed();
// Debit the prepaid vault (keyed by the Falcon root). Checks-effects before any
// token transfer; the whole tx (including the DID spend record) reverts if short.
uint256 bal = balances[rootKeyId][token];
if (bal < amount) revert InsufficientBalance(bal, amount);
unchecked {
balances[rootKeyId][token] = bal - amount;
}
uint256 fee = (amount * PROTOCOL_FEE_BPS) / 10_000;
uint256 toPayee = amount - fee;
if (toPayee > 0) {
if (!IERC20(token).transfer(payee, toPayee)) revert TransferFailed();
}
if (fee > 0) {
// Best-effort forward to the sink; never gate the user flow on sink success. On
// failure the fee tokens stay idle in this facilitator, so we RECORD them as
// tracked residual — the ONLY pool flushResidual is ever allowed to move. Without
// this accounting, an unbounded flushResidual could push prepaid vault balances to
// the sink and strand user funds; here every token flushResidual can touch is
// matched one-for-one by a residual credit, so vault balances stay solvent.
if (!_tryFlushToSink(token, fee)) {
residual[token] += fee;
emit ResidualAccrued(token, fee);
}
}
emit SettledPQC(rootKeyId, sessionId, payee, token, amount, fee, resourceId);
}
/* ------------------------------- residual flush -------------------------- */
/// @notice Anyone can re-attempt the sink flush for a FAILED-FLUSH fee residual.
/// @dev HARD BOUND: `amount` may not exceed `residual[token]`, the tokens this contract
/// holds that are NOT owed to any agent vault. This function can therefore never
/// move prepaid vault balances to the sink — the very drain that an unbounded,
/// unauthenticated flush would allow. The residual is debited FIRST
/// (checks-effects-interactions); a failed re-flush reverts the whole call, so the
/// residual is preserved for a later retry.
function flushResidual(address token, uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 r = residual[token];
if (amount > r) revert InsufficientResidual(r, amount);
unchecked {
residual[token] = r - amount;
}
if (!_tryFlushToSink(token, amount)) revert TransferFailed();
emit ResidualFlushed(token, msg.sender, amount);
}
/* --------------------------------- views --------------------------------- */
/// @notice The actionHash a session key must commit to (inside the DID action digest)
/// to authorize a payment with these exact terms on THIS facilitator. Off-chain
/// signers compute this, then sign DID.actionDigest(sessionId, scope, actionHash,
/// amount, actionNonce) with the session key.
function paymentActionHash(address token, address payee, bytes32 resourceId, uint256 deadline)
external
view
returns (bytes32)
{
return _paymentActionHash(token, payee, resourceId, deadline);
}
/// @notice The agent's current controller (registry owner of the Falcon root key), or
/// address(0) if the agent does not exist / the key has no owner.
function controllerOf(uint256 rootKeyId) public view returns (address) {
if (!DID.agentExists(rootKeyId)) return address(0);
return KEY_REGISTRY.ownerOf(rootKeyId);
}
/// @notice True iff `account` is the agent's current controller.
function isController(uint256 rootKeyId, address account) external view returns (bool) {
return account != address(0) && controllerOf(rootKeyId) == account;
}
/// @notice Convenience: the DID's live validity for a session (root ACTIVE Falcon,
/// not revoked, not expired). A settle for an invalid session always reverts.
function isSessionValid(uint256 sessionId) external view returns (bool) {
return DID.isSessionValid(sessionId);
}
/* -------------------------------- internal ------------------------------- */
function _paymentActionHash(address token, address payee, bytes32 resourceId, uint256 deadline)
internal
view
returns (bytes32)
{
return keccak256(abi.encode(PAYMENT_DOMAIN, block.chainid, address(this), token, payee, resourceId, deadline));
}
function _requireController(uint256 rootKeyId) internal view {
if (!DID.agentExists(rootKeyId)) revert UnknownAgent();
address controller = KEY_REGISTRY.ownerOf(rootKeyId);
if (controller == address(0) || msg.sender != controller) revert NotController();
}
/// @dev Best-effort forward of `amount` of `token` to the SINK via its flush(token,amount)
/// surface. Zero-then-set approve (USDT-style tokens revert on non-zero -> non-zero);
/// re-zero after so no lingering allowance survives a silent sink failure. Returns
/// whether the sink call reported success; callers decide how to account for a miss.
function _tryFlushToSink(address token, uint256 amount) internal returns (bool ok) {
IERC20(token).approve(SINK, 0);
IERC20(token).approve(SINK, amount);
(ok,) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", token, amount));
IERC20(token).approve(SINK, 0);
}
}