// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IAereSinkSettlement { function flush(address token, uint256 amount) external; } /** * @title AereSettlementHub — institutional cross-asset settlement venue * @notice Counterparties deposit tokenised collateral (BUIDL, USDY, OUSG, * LBTC, SolvBTC, pumpBTC, USDC.e, etc.) for batched solver-driven * settlement on AERE Chain 2800. The hub enforces: * - per-asset configuration (bond, perTxLimit, paused, decimals) * - solver allowlist (Phase 1) → bonded permissionless (Phase 2) * - 50bps protocol fee, immutable, routed to AereSink * - RWA receiver allowlist for restricted assets (Foundation * Seychelles whitelisted for BUIDL/USDY/OUSG/LBTC/SolvBTC/pumpBTC) * - 6-hour challenge window on settlement claims * * @dev LIFECYCLE PER INTENT: * 1. Counterparty calls deposit(asset, amount, intentId, recipient). * Hub pulls the asset, routes 50bps to AereSink, parks the * remainder under intentId. * 2. Allowed solver calls claim(intentId, fillEvidence). * Starts the 6h challenge window. * 3. Anyone may call challenge(intentId, reason) during the * window; Foundation reviews off-chain. * 4. After window with no open challenge, anyone calls * settle(intentId) — hub releases asset to the solver. * * BOND HANDLING: * Each solver posts a bond (per-asset configurable) before being * allowed to claim that asset class. Bond is slashable by owner * on validated fraud — slashed amount routes to AereSink. * * RWA RECEIVER ALLOWLIST: * For restricted assets, the `recipient` field on deposit must * be on a per-asset allowlist (e.g. only the AERE Foundation * Seychelles address can receive BUIDL on AERE). Mass-market * assets (USDC.e, USDT.e, etc.) have an empty allowlist meaning * any recipient is allowed. * * AereSink wiring is in the constructor and IMMUTABLE — no * setSink, no upgrade proxy. Fee bps is constant 50. */ contract AereSettlementHub is Ownable, ReentrancyGuard { /* ------------------------------- immutable ------------------------------- */ address public immutable SINK; // AereSink (immutable) uint16 public constant PROTOCOL_FEE_BPS = 50; uint256 public constant CHALLENGE_WINDOW = 6 hours; /* --------------------------------- structs ------------------------------ */ struct AssetConfig { bool listed; bool paused; uint8 decimals; uint256 minBond; // bond required to claim this asset uint256 perTxLimit; // max single-deposit amount, 0 = no cap bool restrictedRecipient; // if true, recipient must be on allowlist } struct Intent { address depositor; address asset; uint256 amount; // post-fee solverPortion address recipient; bool open; bool settled; address solver; uint64 claimedAt; bytes fillEvidence; } /* --------------------------------- state -------------------------------- */ /// @notice asset → config mapping(address => AssetConfig) public assetConfig; /// @notice asset → allowed recipient → true mapping(address => mapping(address => bool)) public recipientAllowed; /// @notice intentId → Intent mapping(bytes32 => Intent) public intents; /// @notice solver allowlist per asset (Phase-1). Phase-2 will switch /// to bond-only permissionless gating. mapping(address => mapping(address => bool)) public solverAllowed; // asset → solver → bool /// @notice solver bonds, per (asset, solver). mapping(address => mapping(address => uint256)) public solverBond; /* --------------------------------- events ------------------------------- */ event AssetListed(address indexed asset, uint8 decimals, uint256 minBond, uint256 perTxLimit, bool restrictedRecipient); event AssetPausedSet(address indexed asset, bool paused); event RecipientAllowedSet(address indexed asset, address indexed recipient, bool allowed); event SolverAllowedSet(address indexed asset, address indexed solver, bool allowed); event Deposited(bytes32 indexed intentId, address indexed depositor, address indexed asset, uint256 amountAfterFee, uint256 feeAmount, address recipient); event Claimed(bytes32 indexed intentId, address indexed solver, uint64 claimedAt); event Settled(bytes32 indexed intentId, address indexed solver, uint256 amount); event Challenged(bytes32 indexed intentId, address indexed challenger, string reason); event SolverBondDeposited(address indexed asset, address indexed solver, uint256 amount); event SolverBondSlashed(address indexed asset, address indexed solver, uint256 amount); /* --------------------------------- errors ------------------------------- */ error ZeroAddress(); error ZeroAmount(); error AssetNotListed(); error AssetPausedErr(); error PerTxLimitExceeded(uint256 amount, uint256 limit); error RecipientNotAllowed(); error NotAllowedSolver(); error InsufficientBond(uint256 have, uint256 need); error IntentUnknown(); error IntentAlreadyClaimed(); error IntentAlreadySettled(); error WithinChallengeWindow(); error ChallengeWindowElapsed(); error TransferFailed(); error DuplicateIntent(); /* ------------------------------- constructor ---------------------------- */ constructor(address sink) { if (sink == address(0)) revert ZeroAddress(); SINK = sink; } /* ------------------------------- asset admin ---------------------------- */ function listAsset( address asset, uint8 decimals, uint256 minBond, uint256 perTxLimit, bool restrictedRecipient ) external onlyOwner { if (asset == address(0)) revert ZeroAddress(); assetConfig[asset] = AssetConfig({ listed: true, paused: false, decimals: decimals, minBond: minBond, perTxLimit: perTxLimit, restrictedRecipient: restrictedRecipient }); emit AssetListed(asset, decimals, minBond, perTxLimit, restrictedRecipient); } function setAssetPaused(address asset, bool paused) external onlyOwner { if (!assetConfig[asset].listed) revert AssetNotListed(); assetConfig[asset].paused = paused; emit AssetPausedSet(asset, paused); } function setRecipientAllowed(address asset, address recipient, bool allowed) external onlyOwner { if (!assetConfig[asset].listed) revert AssetNotListed(); recipientAllowed[asset][recipient] = allowed; emit RecipientAllowedSet(asset, recipient, allowed); } /* ------------------------------ solver admin ---------------------------- */ function setSolverAllowed(address asset, address solver, bool allowed) external onlyOwner { if (!assetConfig[asset].listed) revert AssetNotListed(); if (solver == address(0)) revert ZeroAddress(); solverAllowed[asset][solver] = allowed; emit SolverAllowedSet(asset, solver, allowed); } /// @notice Permissionless solver bond top-up. function depositSolverBond(address asset, address solver, uint256 amount) external nonReentrant { if (!assetConfig[asset].listed) revert AssetNotListed(); if (amount == 0) revert ZeroAmount(); bool ok = IERC20(asset).transferFrom(msg.sender, address(this), amount); if (!ok) revert TransferFailed(); solverBond[asset][solver] += amount; emit SolverBondDeposited(asset, solver, amount); } function slashSolverBond(address asset, address solver, uint256 amount) external onlyOwner nonReentrant { uint256 cur = solverBond[asset][solver]; if (cur < amount) revert InsufficientBond(cur, amount); solverBond[asset][solver] = cur - amount; // Slash → AereSink to feed the burn flywheel. IERC20(asset).approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount)); if (!ok) { // Sink couldn't accept (e.g. no swap route for an exotic asset). // Residual sits in the contract; permissionless sweepResidual handles it. } emit SolverBondSlashed(asset, solver, amount); } /* --------------------------------- core --------------------------------- */ function deposit( address asset, uint256 amount, bytes32 intentId, address recipient ) external nonReentrant returns (uint256 amountAfterFee) { AssetConfig memory cfg = assetConfig[asset]; if (!cfg.listed) revert AssetNotListed(); if (cfg.paused) revert AssetPausedErr(); if (amount == 0) revert ZeroAmount(); if (recipient == address(0)) revert ZeroAddress(); if (cfg.perTxLimit != 0 && amount > cfg.perTxLimit) revert PerTxLimitExceeded(amount, cfg.perTxLimit); if (cfg.restrictedRecipient && !recipientAllowed[asset][recipient]) revert RecipientNotAllowed(); if (intents[intentId].depositor != address(0)) revert DuplicateIntent(); uint256 fee = (amount * PROTOCOL_FEE_BPS) / 10_000; amountAfterFee = amount - fee; // Pull full amount. bool pulled = IERC20(asset).transferFrom(msg.sender, address(this), amount); if (!pulled) revert TransferFailed(); // Route the fee to the sink. If the sink rejects (unsupported asset), // the fee stays in the contract — sweepResidual handles it. if (fee > 0) { IERC20(asset).approve(SINK, fee); (bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, fee)); // Intentional: ignore sinkOk; user flow continues regardless. sinkOk; } intents[intentId] = Intent({ depositor: msg.sender, asset: asset, amount: amountAfterFee, recipient: recipient, open: true, settled: false, solver: address(0), claimedAt: 0, fillEvidence: "" }); emit Deposited(intentId, msg.sender, asset, amountAfterFee, fee, recipient); } function claim(bytes32 intentId, bytes calldata fillEvidence) external nonReentrant { Intent storage i = intents[intentId]; if (!i.open) revert IntentUnknown(); if (i.settled) revert IntentAlreadySettled(); if (i.claimedAt != 0) revert IntentAlreadyClaimed(); if (!solverAllowed[i.asset][msg.sender]) revert NotAllowedSolver(); AssetConfig memory cfg = assetConfig[i.asset]; uint256 bond = solverBond[i.asset][msg.sender]; if (bond < cfg.minBond) revert InsufficientBond(bond, cfg.minBond); i.solver = msg.sender; i.claimedAt = uint64(block.timestamp); i.fillEvidence = fillEvidence; emit Claimed(intentId, msg.sender, uint64(block.timestamp)); } function challenge(bytes32 intentId, string calldata reason) external { Intent storage i = intents[intentId]; if (!i.open) revert IntentUnknown(); if (i.settled) revert IntentAlreadySettled(); if (i.claimedAt == 0) revert IntentUnknown(); if (block.timestamp >= uint256(i.claimedAt) + CHALLENGE_WINDOW) revert ChallengeWindowElapsed(); emit Challenged(intentId, msg.sender, reason); } function settle(bytes32 intentId) external nonReentrant { Intent storage i = intents[intentId]; if (!i.open) revert IntentUnknown(); if (i.settled) revert IntentAlreadySettled(); if (i.claimedAt == 0) revert IntentUnknown(); if (block.timestamp < uint256(i.claimedAt) + CHALLENGE_WINDOW) revert WithinChallengeWindow(); i.settled = true; bool ok = IERC20(i.asset).transfer(i.solver, i.amount); if (!ok) revert TransferFailed(); emit Settled(intentId, i.solver, i.amount); } /* ----------------------------- dust / residual -------------------------- */ /// @notice Anyone may sweep residual balances (fees that couldn't reach /// the sink on first try) by re-attempting the flush. Permissionless. function sweepResidual(address asset, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); IERC20(asset).approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount)); if (!ok) revert TransferFailed(); } }