// 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 AereSettlementHubV2 - 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) then bonded permissionless (Phase 2) * - 50bps protocol fee, immutable, routed to AereSink * - RWA receiver allowlist for restricted assets * - 6-hour challenge window on settlement claims * * @dev CORRECTED FORK of AereSettlementHub (canonical * 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02). This is a FRESH INERT * redeploy (deployer-owned, holds nothing, not wired live). It fixes * two audited defects in the V1 logic: * * F-SWEEP (HIGH griefing) - V1 sweepResidual() was permissionless and * took an arbitrary amount with no residual accounting, so any EOA * could push a live intent's parked settlement funds and posted * solver bonds into the sink, making settle() revert TransferFailed * and permanently bricking the settlement (depositor funds burned, * solver unpaid). V2 tracks a per-asset committed-liabilities total * (parked intent remainders + posted bonds) and gates sweepResidual * to the TRUE residual only: balance minus committed. A permissionless * sweep can therefore only ever take genuine surplus (fees or slashed * bonds that could not reach the sink) and can never touch funds owed * to a solver or a depositor. * * F-LOCK (liveness) - V1 had no refund/cancel/timeout, so an unclaimed * deposit was locked forever. V2 records the deposit timestamp and * adds cancelIntent(), a depositor-only refund path for an intent * that no solver ever claimed, callable after REFUND_TIMEOUT. It * returns the parked remainder to the depositor and closes the intent. * * AereSink wiring is in the constructor and IMMUTABLE. Fee bps is * constant 50. */ contract AereSettlementHubV2 is Ownable, ReentrancyGuard { /* ------------------------------- immutable ------------------------------- */ address public immutable SINK; // AereSink (immutable) uint16 public constant PROTOCOL_FEE_BPS = 50; uint256 public constant CHALLENGE_WINDOW = 6 hours; /// @notice How long after deposit an unclaimed intent may be refunded by /// its depositor. Generous so a legitimate in-flight settlement is /// never at risk; a solver claims long before this elapses. uint256 public constant REFUND_TIMEOUT = 7 days; /* --------------------------------- 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 (parked remainder) address recipient; bool open; // true while the intent is live; false once terminal bool settled; // true once released to the solver address solver; uint64 claimedAt; uint64 createdAt; // deposit timestamp, drives the refund timeout bytes fillEvidence; uint256 bondLocked; // solver bond reserved at claim, released at settle } /* --------------------------------- state -------------------------------- */ /// @notice asset then config mapping(address => AssetConfig) public assetConfig; /// @notice asset then allowed recipient then true mapping(address => mapping(address => bool)) public recipientAllowed; /// @notice intentId then 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 then solver then bool /// @notice solver bonds, per (asset, solver). mapping(address => mapping(address => uint256)) public solverBond; /// @notice Portion of a solver's bond currently RESERVED to back its own /// outstanding claimed-but-unsettled intents, per (asset, solver). /// At claim() we lock the asset's minBond; at settle() we release the /// exact amount locked for that intent. A solver may only withdraw the /// FREE remainder (solverBond - lockedBond), so a claimed intent's /// backing stays slashable through settlement and a solver can never /// withdraw to dodge an imminent slash. mapping(address => mapping(address => uint256)) public lockedBond; /// @notice Per-asset committed liabilities: the exact amount the hub OWES, /// equal to the sum of every open intent's parked remainder plus /// every posted solver bond for that asset. Maintained in lockstep /// with deposit / bond / settle / cancel / slash. The genuine /// residual sweepResidual may touch is balanceOf(hub) minus this. mapping(address => uint256) public committedLiabilities; /* --------------------------------- 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 Refunded(bytes32 indexed intentId, address indexed depositor, uint256 amount); event SolverBondDeposited(address indexed asset, address indexed solver, uint256 amount); event SolverBondWithdrawn(address indexed asset, address indexed solver, uint256 amount); event SolverBondSlashed(address indexed asset, address indexed solver, uint256 amount); event ResidualSwept(address indexed asset, uint256 amount, address indexed caller); /* --------------------------------- 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 InsufficientFreeBond(uint256 free, uint256 requested); error IntentUnknown(); error IntentAlreadyClaimed(); error IntentAlreadySettled(); error WithinChallengeWindow(); error ChallengeWindowElapsed(); error TransferFailed(); error DuplicateIntent(); error NotDepositor(); error RefundTimeoutNotReached(uint256 nowTs, uint256 unlockTs); error ExceedsResidual(uint256 amount, uint256 residual); /* ------------------------------- 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. The posted bond becomes a /// committed liability so sweepResidual can never touch it. 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; committedLiabilities[asset] += amount; emit SolverBondDeposited(asset, solver, amount); } /// @notice Solver-callable withdrawal of its OWN FREE bond. This is the F-BOND /// fix: V2 previously had no path to return an honest bond, so a /// solver's posted bond was permanently locked (only slashSolverBond, /// owner-only, could ever reduce it, and that sends to the sink not the /// solver). Now a solver reclaims the portion of its bond that is NOT /// reserved to back an outstanding claimed-but-unsettled intent. /// /// @dev Guarantees: /// - only msg.sender's own bond is ever touched (ownership); /// - only the FREE bond (solverBond - lockedBond) is withdrawable, so /// a claimed intent's minBond backing stays slashable until settle /// and a solver cannot dodge an imminent slash; /// - solverBond and committedLiabilities decrement in lockstep with /// the outbound transfer, preserving the sweep-reserve invariant /// (balance >= committed) so sweepResidual can never reach a bond; /// - nonReentrant. function withdrawSolverBond(address asset, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); uint256 bonded = solverBond[asset][msg.sender]; uint256 locked = lockedBond[asset][msg.sender]; uint256 free = bonded > locked ? bonded - locked : 0; if (amount > free) revert InsufficientFreeBond(free, amount); // Decrement bookkeeping BEFORE the transfer; both drop in lockstep so the // committed reserve stays exactly balance-backed. solverBond[asset][msg.sender] = bonded - amount; committedLiabilities[asset] -= amount; bool ok = IERC20(asset).transfer(msg.sender, amount); if (!ok) revert TransferFailed(); emit SolverBondWithdrawn(asset, msg.sender, 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; // The slashed bond is no longer owed to the solver, so it stops being a // committed liability. It is destined for the sink. committedLiabilities[asset] -= amount; // Slash then 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). The // tokens remain in the hub as GENUINE residual (no longer committed), // which permissionless sweepResidual can later re-flush. Clear the // dangling approval so it cannot be reused. IERC20(asset).approve(SINK, 0); } 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 as GENUINE residual (never committed) // and sweepResidual handles it. if (fee > 0) { IERC20(asset).approve(SINK, fee); (bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, fee)); if (!sinkOk) { // Clear the dangling approval; the fee is now genuine residual. IERC20(asset).approve(SINK, 0); } } // The parked remainder is owed to whichever solver settles this intent // (or refunded to the depositor on timeout): it is a committed liability. committedLiabilities[asset] += amountAfterFee; intents[intentId] = Intent({ depositor: msg.sender, asset: asset, amount: amountAfterFee, recipient: recipient, open: true, settled: false, solver: address(0), claimedAt: 0, createdAt: uint64(block.timestamp), fillEvidence: "", bondLocked: 0 }); 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]; // The solver must have at least minBond FREE (not already reserved to back // another of its outstanding claimed intents). Reserving minBond here keeps // this intent's backing slashable until it settles, so a solver cannot // withdrawSolverBond to escape a slash of a claimed-but-unsettled intent. uint256 lockedNow = lockedBond[i.asset][msg.sender]; // Defensive against a prior slash having pushed the bond below the locked // amount; treat a shortfall as zero free bond rather than underflowing. uint256 free = bond > lockedNow ? bond - lockedNow : 0; if (free < cfg.minBond) revert InsufficientBond(free, cfg.minBond); lockedBond[i.asset][msg.sender] += cfg.minBond; i.bondLocked = 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; uint256 amt = i.amount; // The parked remainder leaves to the solver, so it is no longer committed. committedLiabilities[i.asset] -= amt; // Release the bond this intent reserved at claim: it is now free to withdraw // (or to keep backing other claims). Guard against underflow if the bond was // slashed below the locked amount in the interim. uint256 bl = i.bondLocked; if (bl > 0) { i.bondLocked = 0; uint256 lb = lockedBond[i.asset][i.solver]; lockedBond[i.asset][i.solver] = lb > bl ? lb - bl : 0; } bool ok = IERC20(i.asset).transfer(i.solver, amt); if (!ok) revert TransferFailed(); emit Settled(intentId, i.solver, amt); } /// @notice Depositor refund for an intent that no solver ever claimed. /// Callable only by the depositor, only once REFUND_TIMEOUT has /// elapsed since deposit, and only while the intent is unclaimed and /// unsettled. Returns the parked remainder to the depositor and /// closes the intent. This is the F-LOCK liveness fix: a never-claimed /// deposit can no longer be trapped forever. function cancelIntent(bytes32 intentId) external nonReentrant { Intent storage i = intents[intentId]; if (!i.open) revert IntentUnknown(); if (i.settled) revert IntentAlreadySettled(); if (i.claimedAt != 0) revert IntentAlreadyClaimed(); if (msg.sender != i.depositor) revert NotDepositor(); uint256 unlockTs = uint256(i.createdAt) + REFUND_TIMEOUT; if (block.timestamp < unlockTs) revert RefundTimeoutNotReached(block.timestamp, unlockTs); i.open = false; uint256 amt = i.amount; // The parked remainder returns to the depositor, so it is no longer committed. committedLiabilities[i.asset] -= amt; bool ok = IERC20(i.asset).transfer(i.depositor, amt); if (!ok) revert TransferFailed(); emit Refunded(intentId, i.depositor, amt); } /* ----------------------------- dust / residual -------------------------- */ /// @notice Genuine residual for an asset: hub balance minus committed /// liabilities (parked intent remainders + posted bonds). This is /// the only value sweepResidual may ever move. function residualOf(address asset) public view returns (uint256) { uint256 bal = IERC20(asset).balanceOf(address(this)); uint256 committed = committedLiabilities[asset]; // committed can never exceed the balance under correct accounting; guard // against underflow defensively. return bal > committed ? bal - committed : 0; } /// @notice Anyone may sweep GENUINE residual balances (fees or slashed bonds /// that could not reach the sink on their first try) by re-attempting /// the flush. Permissionless, but strictly bounded to residualOf(asset) /// so it can NEVER touch a live intent's parked funds or a posted /// solver bond. This is the F-SWEEP griefing fix. function sweepResidual(address asset, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); uint256 residual = residualOf(asset); if (amount > residual) revert ExceedsResidual(amount, residual); IERC20(asset).approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount)); if (!ok) { IERC20(asset).approve(SINK, 0); revert TransferFailed(); } emit ResidualSwept(asset, amount, msg.sender); } }