// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; interface IAereAgent { function debit(uint256 agentId, address token, address payee, uint256 amount) external; function signerOf(uint256 agentId) external view returns (address); } interface IAereSinkFacilitator { function flush(address token, uint256 amount) external; } /** * @title AERE402Facilitator — HTTP 402 agentic settlement * @notice Service providers (web APIs, MCP servers, agent-to-agent endpoints) * exchange HTTP 402 (Payment Required) for an EIP-712 signed * payment authorisation from an AereAgent. The provider then * submits the authorisation to this contract; the facilitator * debits the agent's prepaid balance (via the AereAgent registry), * credits the provider, and routes a 25-bps protocol fee to * AereSink (the flywheel). * * CORE FLOW per call: * 1. Provider responds to a request with HTTP 402 + * X-AERE402-Quote: { facilitator, token, amount, resourceId, * providerAddress, nonce, deadline }. * 2. Agent signs the authorisation (EIP-712 typed data) using * its registered signer key. * 3. Agent retries the request with X-AERE402-Sig: . * 4. Provider POSTs the (auth + signature) to this contract via * `settle(...)`. The facilitator: * a. Validates signature against AereAgent.signerOf(agentId). * b. Checks nonce hasn't been spent + deadline. * c. Splits amount: (amount - fee) → provider, fee → sink. * d. Calls AereAgent.debit(agentId, token, address(this), amount). * e. Forwards the splits in one tx. * 5. Provider serves the response now that settlement is final. * * IMMUTABILITY: * - AereAgent address fixed in constructor. * - SINK address fixed. * - 25-bps fee constant. * - DOMAIN_SEPARATOR pinned. * * REPLAY-PROTECTION: * - Per-(agentId, nonce) bitmap of consumed nonces. * - Provider chooses nonces; convention: monotonic per provider- * agent pair. */ contract AERE402Facilitator is ReentrancyGuard { using ECDSA for bytes32; /* ------------------------------- immutable ------------------------------- */ address public immutable AGENT_REGISTRY; address public immutable SINK; uint16 public constant PROTOCOL_FEE_BPS = 25; // 0.25% bytes32 public immutable DOMAIN_SEPARATOR; bytes32 public constant AUTH_TYPEHASH = keccak256( "PaymentAuth(uint256 agentId,address token,address payee,uint256 amount,bytes32 resourceId,uint256 nonce,uint256 deadline)" ); /* --------------------------------- state -------------------------------- */ /// @notice agentId → nonce → consumed mapping(uint256 => mapping(uint256 => bool)) public consumed; /* --------------------------------- events ------------------------------- */ event Settled( uint256 indexed agentId, address indexed payee, address indexed token, uint256 amount, uint256 protocolFee, bytes32 resourceId, uint256 nonce ); /* --------------------------------- errors ------------------------------- */ error ZeroAddress(); error InvalidSignature(); error NonceAlreadyConsumed(); error DeadlinePassed(); error PayeeMismatch(); error ZeroAmount(); error TransferFailed(); error SelfDealForbidden(); // payee == agent's signer key /* ------------------------------- constructor ---------------------------- */ constructor(address agentRegistry, address sink) { if (agentRegistry == address(0) || sink == address(0)) revert ZeroAddress(); AGENT_REGISTRY = agentRegistry; SINK = sink; DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("AERE402Facilitator"), keccak256("1"), block.chainid, address(this) )); } /* --------------------------------- settle ------------------------------- */ /// @notice Provider settles a payment auth from an agent. /// @dev msg.sender MUST equal the `payee` in the auth; this prevents /// a third party from replaying someone else's auth toward an /// attacker-controlled payee even if the agent's signature leaks. function settle( uint256 agentId, address token, address payee, uint256 amount, bytes32 resourceId, uint256 nonce, uint256 deadline, bytes calldata signature ) external nonReentrant { if (block.timestamp > deadline) revert DeadlinePassed(); if (msg.sender != payee) revert PayeeMismatch(); if (amount == 0) revert ZeroAmount(); if (consumed[agentId][nonce]) revert NonceAlreadyConsumed(); bytes32 structHash = keccak256(abi.encode( AUTH_TYPEHASH, agentId, token, payee, amount, resourceId, nonce, deadline )); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); address signer = digest.recover(signature); address expected = IAereAgent(AGENT_REGISTRY).signerOf(agentId); if (signer == address(0) || signer != expected) revert InvalidSignature(); // Block self-deal: a compromised signer key cannot drain the agent // balance by paying itself. The signer MUST sign a payment to a // different address — typically an actual service provider. if (payee == expected) revert SelfDealForbidden(); consumed[agentId][nonce] = true; // Pull the full amount from the agent via the registry. The registry // sends the AERE to address(this) (the facilitator); we then split // into payee + sink. IAereAgent(AGENT_REGISTRY).debit(agentId, token, address(this), amount); uint256 fee = (amount * PROTOCOL_FEE_BPS) / 10_000; uint256 toPayee = amount - fee; if (toPayee > 0) { bool ok = IERC20(token).transfer(payee, toPayee); if (!ok) revert TransferFailed(); } if (fee > 0) { // Forward to sink for flywheel; if the sink doesn't accept, // the fee stays in the facilitator as residual (sweepable). // ROUND-3 FIX: zero-then-set approve (USDT-style tokens revert // on non-zero→non-zero); also re-zero after the call so no // lingering allowance survives a silent sink failure. IERC20(token).approve(SINK, 0); IERC20(token).approve(SINK, fee); (bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", token, fee)); sinkOk; // intentional: don't gate user flow on sink success IERC20(token).approve(SINK, 0); } emit Settled(agentId, payee, token, amount, fee, resourceId, nonce); } /* ------------------------------- residual flush -------------------------- */ /// @notice Anyone can re-attempt the sink flush for a residual balance. function flushResidual(address token, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); IERC20(token).approve(SINK, 0); IERC20(token).approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", token, amount)); if (!ok) { IERC20(token).approve(SINK, 0); revert TransferFailed(); } IERC20(token).approve(SINK, 0); } }