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.
200 lines
8.4 KiB
Solidity
200 lines
8.4 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 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);
|
|
}
|
|
}
|