// 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 AERE402FacilitatorV2 — HTTP 402 agentic settlement (F13 fix) * @notice Bug-fix redeploy of AERE402Facilitator * (0xbA6e6700D629a5E3C885778a42885a944CA84E56). * * FINDING F13 (liveness): V1's replay key was `consumed[agentId][nonce]` * — a SINGLE nonce namespace shared by every provider settling against * an agent. But the documented convention is a "monotonic nonce per * provider-agent pair", so two independent providers naturally pick * overlapping nonce sequences (both start at 1, 2, ...). When provider A * settles (agentId, nonce=1), provider B's fully valid, agent-signed * (agentId, nonce=1) toward a DIFFERENT payee reverts * NonceAlreadyConsumed. No theft — a legitimate settlement is wrongly * blocked (denial of settlement). * * FIX: the replay key is now `consumed[agentId][payee][nonce]`. Each * (agent, payee) pair owns an independent nonce namespace, matching the * per-provider convention. True replay — the SAME (agentId, payee, * nonce) — still reverts NonceAlreadyConsumed, so replay protection is * unchanged in strength; only the false collision across distinct * payees is removed. * * Everything else is byte-for-byte identical to V1: EIP-712 typed data, * signer check via AereAgent.signerOf, msg.sender == payee guard, * self-deal block, 25-bps fee to the sink, and the best-effort sink * flush. The EIP-712 domain `name` is intentionally unchanged * ("AERE402Facilitator") — the new deployment is domain-separated by * its `verifyingContract` address, so existing signer SDKs work by * swapping only the address. * * CROSS-WIRING: AereAgent.debit is gated to the immutable * AERE402_FACILITATOR, so this V2 facilitator is paired with an * AereAgentV2 whose AERE402_FACILITATOR points here. See AereAgentV2. * * IMMUTABILITY: AereAgent (registry) address, SINK, 25-bps fee, and * DOMAIN_SEPARATOR all pinned in the constructor, exactly as V1. */ contract AERE402FacilitatorV2 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 F13 FIX: replay key is per (agentId, payee, nonce) — each /// (agent, payee) pair owns an independent nonce namespace. mapping(uint256 => mapping(address => 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(); // F13 FIX: check the per-(agentId, payee, nonce) namespace. if (consumed[agentId][payee][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(); // F13 FIX: consume the per-(agentId, payee, nonce) slot. consumed[agentId][payee][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 via flushResidual). // Zero-then-set approve (USDT-style tokens revert on non-zero->non- // zero); 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); } }