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

197 lines
7.9 KiB
Solidity

// 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: <hex>.
* 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);
}
}