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