// 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"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "./IERC7683.sol"; import {PQCGaslessOrder, IAerePQCOrderAuthorizer} from "./IAerePQCOrder.sol"; /** * @title AereSpokePool — ERC-7683 OriginSettler + Across V3 compatible * intent-based bridging endpoint on AERE Network. * * @notice Users deposit input tokens and emit an Open event encoding their * desired output token, amount, recipient, and destination chain. * An allowed solver (Phase-1 permissioned set; Phase-2 bonded * permissionless) fills the order on the destination chain and * later submits proof-of-fill back here for settlement. * * @dev PHASE 1 (Q3 2026 ship): * - Foundation-governed solver allowlist via setSolverAllowed(). * - Settlement is optimistic with a 6-hour challenge window * during which anyone can submit a fraud proof referencing the * destination-chain inclusion. Phase-1 fraud proofs are off- * chain (Foundation arbitration; bond is forfeit on validated * challenge); Phase-2 replaces with UMA optimistic oracle * + 2 WETH refundable bond per onboarding. * * - Each solver deposits a bond at onboarding (configurable; * default 1000 AERE). Bond is slashed on fraud. * * PHASE 2 (Q4 2026): * - UMA optimistic oracle plug-in via setOptimisticAdjudicator. * - Permissionless solver entry with on-chain bond. * * PROTOCOL FEE: * A fixed 0.05% (50 bps, immutable) of every deposit is routed * to AereSink for the protocol-revenue flywheel. Solvers * internalise this cost in their quote. * * ERC-7683 compliance: * - open(OnchainCrossChainOrder) * - openFor(GaslessCrossChainOrder, sig, fillerData) via EIP-712 * - resolve(...) / resolveFor(...) view shape resolvers * - Open(orderId, resolvedOrder) event with FillInstructions * * Cross-chain proof flow: * 1. User opens order on AERE; AereSpokePool locks input + emits * Open. Solver decodes the orderData. * 2. Solver fills via IDestinationSettler.fill on the dest chain. * 3. Solver submits a "claim" tx on AERE referencing the dest- * chain inclusion + a 6-hour challenge timer. * 4. If no challenge, AereSpokePool releases the locked input * token plus an integer-rounded sliver of AERE as relay * incentive (default 1 AERE per claim). */ contract AereSpokePool is Ownable, ReentrancyGuard, IOriginSettler { using ECDSA for bytes32; /* ------------------------------- immutable ------------------------------- */ /// @notice WAERE address (used to wrap inbound native value if needed, /// and as the bond / relay incentive token). address public immutable WAERE_ADDR; /// @notice AereSink — receives the 50 bps protocol fee on every deposit. address public immutable SINK; /// @notice Protocol fee in basis points (50 = 0.5%). Immutable. uint16 public constant PROTOCOL_FEE_BPS = 50; /// @notice Challenge window for solver claims before settlement is final. uint256 public constant CHALLENGE_WINDOW = 6 hours; /// @notice This chain's id (used as origin chain id in resolved orders). uint256 public immutable LOCAL_CHAIN_ID; /// @notice The orderDataType discriminator AERE uses. Solvers expect this. bytes32 public constant AERE_ORDER_DATA_TYPE = keccak256("AereV3Order"); /* --------------------------------- state -------------------------------- */ struct Order { address user; address inputToken; uint256 inputAmount; address outputToken; // bytes20 of the bytes32 token field uint256 outputAmount; bytes32 recipient; uint64 destinationChainId; uint32 fillDeadline; bool open; bool settled; address solver; // who claimed uint64 claimedAt; // when claim was submitted (0 if unclaimed) } /// @notice orderId → Order mapping(bytes32 => Order) public orders; /// @notice Permissioned solver allowlist (Phase 1). mapping(address => bool) public solverAllowed; /// @notice Per-solver bond amount. mapping(address => uint256) public solverBond; /// @notice Default bond required for new solver onboarding. uint256 public defaultBond = 1000 ether; /// @notice Nonce for openFor gasless orders, per user. mapping(address => uint256) public gaslessNonce; /// @notice ROUND-3 FIX: per-user open nonce for direct opens (non-gasless). /// Two identical opens in the same block previously produced the /// same orderId → collision. Now each open consumes a fresh nonce. mapping(address => uint256) public openNonce; /// @notice ROUND-3 FIX: total tokens locked across all unsettled orders /// per inputToken. sweepResidual subtracts this to prevent /// draining user-locked funds even with a mis-set sweepFloor. mapping(address => uint256) public totalLocked; /// @notice BOND-ACCOUNTING FIX (SMT-found composition): total solver bonds /// held per token. Bonds are always posted in WAERE, but because /// WAERE is also an allowed bridge input token, a WAERE-input order /// and solver bonds share the same balance. sweepResidual reserves /// this ON TOP of totalLocked so bonded funds can never be swept to /// SINK. Without it, sweepResidual(WAERE) swept the bonds while the /// solverBond ledger stayed stale, and a later slashSolverBond then /// drove balanceOf(WAERE) below totalLocked, denying a user's /// settle(). Maintained in lockstep by depositSolverBond (+) and /// slashSolverBond (-). Invariant: balance[T] >= totalLocked[T] + totalBond[T]. mapping(address => uint256) public totalBond; /// @notice EIP-712 domain separator (computed in constructor). bytes32 public immutable DOMAIN_SEPARATOR; bytes32 public constant GASLESS_ORDER_TYPEHASH = keccak256( "GaslessCrossChainOrder(address originSettler,address user,uint256 nonce,uint256 originChainId,uint32 openDeadline,uint32 fillDeadline,bytes32 orderDataType,bytes orderData)" ); /* ---------------------------- PQC intent path --------------------------- */ /// @notice The post-quantum order authorizer this pool routes PQC orders through. /// It is a crypto-agility CONSUMER over AereCryptoRegistry, so a Foundation /// scheme swap (deprecate -> successor -> new ACTIVE) migrates the accepted /// PQC scheme with ZERO redeploy of this pool. Optional: unset (address(0)) /// disables the PQC path and leaves the classical ECDSA paths untouched. IAerePQCOrderAuthorizer public pqcAuthorizer; /// @notice Per-user replay nonce for openForPQC. A PQC order is accepted only when /// its nonce equals the user's current value, which is then incremented, so /// a captured order can never be replayed. mapping(address => uint256) public pqcNonce; /// @notice keccak256 commitment of the post-quantum public key a user has bound to /// their funding address. openForPQC pulls a user's approved input tokens /// only when the presented pubKey matches this commitment, so a third party's /// own PQC key can never spend a victim's approval. 0 means "no key bound". mapping(address => bytes32) public boundPQCKey; /* --------------------------------- events ------------------------------- */ event Opened(bytes32 indexed orderId, address indexed user, uint256 inputAmount, bytes32 recipient, uint64 destinationChainId); event Claimed(bytes32 indexed orderId, address indexed solver, uint256 claimedAt); event Settled(bytes32 indexed orderId, address indexed solver, uint256 amountReleased); event Challenged(bytes32 indexed orderId, address indexed challenger); event SolverAllowedSet(address indexed solver, bool allowed); event SolverBondDeposited(address indexed solver, uint256 amount); event SolverBondSlashed(address indexed solver, uint256 amount); event Cancelled(bytes32 indexed orderId, address indexed user, uint256 amountReturned); event PQCAuthorizerSet(address indexed authorizer); event PQCKeyBound(address indexed user, bytes32 indexed pubKeyCommit); event PQCOrderOpened(bytes32 indexed orderId, address indexed user, uint256 nonce, uint256 algorithmId, bytes32 pubKeyCommit); /* --------------------------------- errors ------------------------------- */ error ZeroAmount(); error ZeroAddress(); error InvalidOrderDataType(); error FillDeadlineExceeded(); error UnknownOrder(); error AlreadySettled(); error AlreadyClaimed(); error NotAllowedSolver(); error InsufficientBond(); error ChallengeWindowNotElapsed(); error WithinChallengeWindow(); error TransferFailed(); error InvalidSignature(); error NonceMismatch(); error DeadlinePassed(); error FillDeadlineNotPassed(); error PQCAuthorizerUnset(); error PQCKeyNotBound(); error EmptyPubKey(); /* ------------------------------- constructor ---------------------------- */ constructor(address waere, address sink) { if (waere == address(0) || sink == address(0)) revert ZeroAddress(); WAERE_ADDR = waere; SINK = sink; LOCAL_CHAIN_ID = block.chainid; DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("AereSpokePool"), keccak256("1"), block.chainid, address(this) ) ); } /* ------------------------------- solver admin --------------------------- */ function setSolverAllowed(address solver, bool allowed) external onlyOwner { if (solver == address(0)) revert ZeroAddress(); solverAllowed[solver] = allowed; emit SolverAllowedSet(solver, allowed); } function setDefaultBond(uint256 amount) external onlyOwner { defaultBond = amount; } /// @notice Wire (or rewire) the post-quantum order authorizer. Owner only. Setting /// it to address(0) disables the PQC intent path; the classical open/openFor /// paths are unaffected either way. function setPQCAuthorizer(address authorizer) external onlyOwner { pqcAuthorizer = IAerePQCOrderAuthorizer(authorizer); emit PQCAuthorizerSet(authorizer); } /// @notice Solver deposits their bond. Permissionless funding. function depositSolverBond(address solver, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); bool ok = IERC20(WAERE_ADDR).transferFrom(msg.sender, address(this), amount); if (!ok) revert TransferFailed(); solverBond[solver] += amount; totalBond[WAERE_ADDR] += amount; // BOND-ACCOUNTING FIX: reserve bonds from sweepResidual emit SolverBondDeposited(solver, amount); } /// @notice Foundation slashes a solver's bond on validated fraud. /// Phase-1: Foundation arbitration; Phase-2: UMA settlement. function slashSolverBond(address solver, uint256 amount, address toRecipient) external onlyOwner nonReentrant { if (solverBond[solver] < amount) revert InsufficientBond(); solverBond[solver] -= amount; totalBond[WAERE_ADDR] -= amount; // BOND-ACCOUNTING FIX: keep the sweep reserve in lockstep // Slash → routes to AereSink so the burn flywheel benefits. address dest = toRecipient == address(0) ? SINK : toRecipient; bool ok = IERC20(WAERE_ADDR).transfer(dest, amount); if (!ok) revert TransferFailed(); emit SolverBondSlashed(solver, amount); } /* -------------------------------- open paths ---------------------------- */ function open(OnchainCrossChainOrder calldata order) external override nonReentrant { _open(msg.sender, order); } function openFor( GaslessCrossChainOrder calldata order, bytes calldata signature, bytes calldata /* originFillerData */ ) external override nonReentrant { if (block.timestamp > order.openDeadline) revert DeadlinePassed(); if (order.originSettler != address(this)) revert InvalidSignature(); if (order.originChainId != LOCAL_CHAIN_ID) revert InvalidSignature(); if (order.nonce != gaslessNonce[order.user]) revert NonceMismatch(); bytes32 structHash = keccak256(abi.encode( GASLESS_ORDER_TYPEHASH, order.originSettler, order.user, order.nonce, order.originChainId, order.openDeadline, order.fillDeadline, order.orderDataType, keccak256(order.orderData) )); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); address signer = digest.recover(signature); if (signer != order.user) revert InvalidSignature(); gaslessNonce[order.user] = order.nonce + 1; _openInternal(order.user, order.fillDeadline, order.orderDataType, order.orderData); } function _open(address user, OnchainCrossChainOrder calldata order) internal { _openInternal(user, order.fillDeadline, order.orderDataType, order.orderData); } /* ----------------------------- PQC open path ---------------------------- */ /// @notice Bind a post-quantum public key to msg.sender's funding address. After /// binding, msg.sender's approved input tokens can be spent by openForPQC /// only when the caller presents THIS pubKey and a valid PQC signature over /// the order. Re-calling rotates the bound key. Stores the keccak256 /// commitment (the full key is supplied and checked at open time). /// @dev This one-time designation is the only ECDSA-signed step; every subsequent /// cross-chain order is authorized purely by a Falcon/ML-DSA signature and /// may be relayed gaslessly by anyone. function bindPQCKey(bytes calldata pubKey) external { if (pubKey.length == 0) revert EmptyPubKey(); bytes32 commit = keccak256(pubKey); boundPQCKey[msg.sender] = commit; emit PQCKeyBound(msg.sender, commit); } /// @notice Open a gasless ERC-7683 order authorized by a POST-QUANTUM signature /// (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s), quantum-durable end to end. /// Permissionless relayer: anyone may submit the order; the input tokens are /// pulled from order.user (who bound `pubKey` and approved this pool). /// /// Verification routes through {pqcAuthorizer} -> AereCryptoRegistry -> /// the live native PQC precompile under whatever scheme is currently ACTIVE /// (resolveActive), so a Foundation scheme migration is picked up with no /// redeploy here. A bad signature, a wrong/hash-only/revoked scheme, a wrong /// bound key, an expired open deadline, or a replayed nonce all revert. /// @param order the PQC gasless order (see {PQCGaslessOrder}). /// @param pubKey the authorized post-quantum public key (must match the user's /// bound commitment). /// @param signature the PQC signature envelope over the order digest, in the /// registry's wire format for the scheme. function openForPQC(PQCGaslessOrder calldata order, bytes calldata pubKey, bytes calldata signature) external nonReentrant { IAerePQCOrderAuthorizer authorizer = pqcAuthorizer; if (address(authorizer) == address(0)) revert PQCAuthorizerUnset(); if (block.timestamp > order.openDeadline) revert DeadlinePassed(); if (order.originChainId != LOCAL_CHAIN_ID) revert InvalidSignature(); if (order.nonce != pqcNonce[order.user]) revert NonceMismatch(); // Bind: only the funder's own registered PQC key can spend their approval. bytes32 commit = boundPQCKey[order.user]; if (commit == bytes32(0)) revert PQCKeyNotBound(); if (keccak256(pubKey) != commit) revert PQCKeyNotBound(); // Fail-closed PQC verification through the crypto-agility registry. if (!authorizer.checkOrder(address(this), order, pubKey, signature)) revert InvalidSignature(); // Consume the nonce BEFORE opening (replay protection, checks-effects-interactions). pqcNonce[order.user] = order.nonce + 1; _openInternal(order.user, order.fillDeadline, order.orderDataType, order.orderData); // Recompute the orderId the same way _openInternal did to tag the PQC event. // (openNonce was incremented inside _openInternal, so subtract 1 here.) emit PQCOrderOpened( _lastOrderIdFor(order.user, order.fillDeadline, order.orderData), order.user, order.nonce, order.algorithmId, commit ); } /// @dev Reproduce the orderId that _openInternal just stored, for event tagging. function _lastOrderIdFor(address user, uint32 fillDeadline_, bytes calldata orderData) internal view returns (bytes32) { ( address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, bytes32 recipient, uint64 destinationChainId ) = abi.decode(orderData, (address, uint256, address, uint256, bytes32, uint64)); uint256 protocolFee = (inputAmount * PROTOCOL_FEE_BPS) / 10_000; uint256 solverPortion = inputAmount - protocolFee; // openNonce[user] was post-incremented in _openInternal, so the consumed nonce is (current - 1). uint256 consumedOpenNonce = openNonce[user] - 1; return keccak256(abi.encode( user, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, fillDeadline_, block.number, consumedOpenNonce )); } function _openInternal( address user, uint32 fillDeadline_, bytes32 orderDataType, bytes memory orderData ) internal { if (orderDataType != AERE_ORDER_DATA_TYPE) revert InvalidOrderDataType(); if (block.timestamp > fillDeadline_) revert FillDeadlineExceeded(); // orderData decoding: // (address inputToken, uint256 inputAmount, address outputToken, // uint256 outputAmount, bytes32 recipient, uint64 destinationChainId) ( address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, bytes32 recipient, uint64 destinationChainId ) = abi.decode(orderData, (address, uint256, address, uint256, bytes32, uint64)); if (inputAmount == 0) revert ZeroAmount(); if (inputToken == address(0) || outputToken == address(0)) revert ZeroAddress(); // Compute the protocol fee bucket and the solver-fillable amount. uint256 protocolFee = (inputAmount * PROTOCOL_FEE_BPS) / 10_000; uint256 solverPortion = inputAmount - protocolFee; // Pull full inputAmount. bool ok = IERC20(inputToken).transferFrom(user, address(this), inputAmount); if (!ok) revert TransferFailed(); // Forward the protocol fee to AereSink (route to flywheel). if (protocolFee > 0) { IERC20(inputToken).approve(SINK, protocolFee); (bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", inputToken, protocolFee)); if (!sinkOk) { // If the sink call reverts (e.g. unknown asset), the fee stays // in this contract as residual — sweepable to sink later via a // permissionless call. Phase-1 doesn't gate user flow on this. } } // ROUND-3 FIX: include per-user openNonce to prevent orderId collisions // across identical orders in the same block. uint256 thisOpenNonce = openNonce[user]++; bytes32 orderId = keccak256(abi.encode( user, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, fillDeadline_, block.number, thisOpenNonce )); // Track locked tokens for safe-sweep accounting. totalLocked[inputToken] += solverPortion; orders[orderId] = Order({ user: user, inputToken: inputToken, inputAmount: solverPortion, outputToken: outputToken, outputAmount: outputAmount, recipient: recipient, destinationChainId: destinationChainId, fillDeadline: fillDeadline_, open: true, settled: false, solver: address(0), claimedAt: 0 }); emit Opened(orderId, user, solverPortion, recipient, destinationChainId); // ResolvedCrossChainOrder emission deferred to view path (resolve / resolveFor); // emitting the full struct from _openInternal would require duplicating // the assembly used in _buildResolved. Phase-2 will refactor. } /* ------------------------------- claim + settle ------------------------- */ /// @notice Allowed solver claims an order after filling on destination. /// Settlement is delayed by CHALLENGE_WINDOW. function claim(bytes32 orderId) external nonReentrant { if (!solverAllowed[msg.sender]) revert NotAllowedSolver(); Order storage o = orders[orderId]; if (!o.open) revert UnknownOrder(); if (o.settled) revert AlreadySettled(); if (o.claimedAt != 0) revert AlreadyClaimed(); o.solver = msg.sender; o.claimedAt = uint64(block.timestamp); emit Claimed(orderId, msg.sender, block.timestamp); } /// @notice Permissionless settlement after the challenge window with no /// outstanding challenge. Releases solverPortion of input token /// to the solver. function settle(bytes32 orderId) external nonReentrant { Order storage o = orders[orderId]; if (!o.open) revert UnknownOrder(); if (o.settled) revert AlreadySettled(); if (o.claimedAt == 0) revert UnknownOrder(); if (block.timestamp < uint256(o.claimedAt) + CHALLENGE_WINDOW) revert WithinChallengeWindow(); o.settled = true; // ROUND-3 FIX: decrement totalLocked when funds leave the contract. totalLocked[o.inputToken] -= o.inputAmount; bool ok = IERC20(o.inputToken).transfer(o.solver, o.inputAmount); if (!ok) revert TransferFailed(); emit Settled(orderId, o.solver, o.inputAmount); } /// @notice REFUND PATH (adversarial-review-found HIGH, 2026-07-12). /// Without this, a user's locked input principal is stranded /// forever whenever no allowed solver ever claims the order — the /// routine case for an unprofitable, stale, or deadline-missed /// order. settle() can only pay a solver (it reverts unless /// claimedAt != 0), and there was no other exit for the locked /// funds. After the fillDeadline passes with the order still /// unclaimed, this returns the locked principal to the original /// user. Mirrors Across V3 / ERC-7683 expired-deposit refund. /// Permissionless (funds always go to o.user), so a depositor who /// has lost gas access can still be made whole by any keeper. /// Keeps the balance >= totalLocked + totalBond invariant: it /// decrements totalLocked by exactly the amount transferred out. function cancel(bytes32 orderId) external nonReentrant { Order storage o = orders[orderId]; if (!o.open) revert UnknownOrder(); if (o.settled) revert AlreadySettled(); if (o.claimedAt != 0) revert AlreadyClaimed(); if (block.timestamp <= uint256(o.fillDeadline)) revert FillDeadlineNotPassed(); o.settled = true; // Decrement totalLocked in lockstep with the outbound transfer. totalLocked[o.inputToken] -= o.inputAmount; bool ok = IERC20(o.inputToken).transfer(o.user, o.inputAmount); if (!ok) revert TransferFailed(); emit Cancelled(orderId, o.user, o.inputAmount); } /// @notice Phase-1: anyone can mark a claim as challenged off-chain; /// the Foundation reviews and either slashes the solver's bond /// (via slashSolverBond) or dismisses. Phase-2 replaces with /// UMA optimistic oracle. function challenge(bytes32 orderId) external { Order storage o = orders[orderId]; if (!o.open) revert UnknownOrder(); if (o.settled) revert AlreadySettled(); if (o.claimedAt == 0) revert UnknownOrder(); if (block.timestamp >= uint256(o.claimedAt) + CHALLENGE_WINDOW) revert ChallengeWindowNotElapsed(); // Phase-1: emit only. Foundation off-chain handles the rest. // Phase-2 hook: call into the optimistic adjudicator. emit Challenged(orderId, msg.sender); } /* ------------------------------- view: resolve -------------------------- */ function resolve(OnchainCrossChainOrder calldata order) external view override returns (ResolvedCrossChainOrder memory) { return _resolveShape(msg.sender, order); } function resolveFor(GaslessCrossChainOrder calldata order, bytes calldata /* originFillerData */) external view override returns (ResolvedCrossChainOrder memory) { return _resolveShapeMem(order.user, order.fillDeadline, order.orderData); } function _resolveShape(address user, OnchainCrossChainOrder calldata order) internal view returns (ResolvedCrossChainOrder memory) { return _resolveShapeMem(user, order.fillDeadline, order.orderData); } function _resolveShapeMem(address user, uint32 fillDeadline_, bytes memory orderData) internal view returns (ResolvedCrossChainOrder memory) { ( address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, bytes32 recipient, uint64 destinationChainId ) = abi.decode(orderData, (address, uint256, address, uint256, bytes32, uint64)); uint256 protocolFee = (inputAmount * PROTOCOL_FEE_BPS) / 10_000; uint256 solverPortion = inputAmount - protocolFee; bytes32 orderId = keccak256(abi.encode( user, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, fillDeadline_, block.number )); return _buildResolved(user, fillDeadline_, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, orderId); } function _buildResolved( address user, uint32 fillDeadline_, address inputToken, uint256 solverPortion, address outputToken, uint256 outputAmount, bytes32 recipient, uint64 destinationChainId, bytes32 orderId ) internal view returns (ResolvedCrossChainOrder memory) { Input[] memory minReceived = new Input[](1); minReceived[0] = Input({ token: bytes32(uint256(uint160(inputToken))), amount: solverPortion }); Output[] memory maxSpent = new Output[](1); maxSpent[0] = Output({ token: bytes32(uint256(uint160(outputToken))), amount: outputAmount, recipient: recipient, chainId: destinationChainId }); Output[] memory receivers = new Output[](1); receivers[0] = maxSpent[0]; FillInstruction[] memory fills = new FillInstruction[](1); fills[0] = FillInstruction({ destinationChainId: destinationChainId, destinationSettler: bytes32(0), // resolved off-chain via the registry originData: abi.encode(orderId, outputToken, outputAmount, recipient) }); return ResolvedCrossChainOrder({ user: user, originChainId: LOCAL_CHAIN_ID, openDeadline: uint32(block.timestamp + 1 hours), fillDeadline: fillDeadline_, orderId: orderId, minReceived: minReceived, maxSpent: maxSpent, receivers: receivers, fillInstructions: fills }); } /* ------------------------------- dust sweep ----------------------------- */ /// @notice Anyone can flush leftover input tokens (e.g. from failed sink /// flush calls) through to AereSink. Permissionless. function sweepResidual(address token) external nonReentrant { uint256 bal = IERC20(token).balanceOf(address(this)); // ROUND-3 FIX: subtract totalLocked[token] AND the Foundation-set // sweepFloor. The previous logic relied solely on sweepFloor, which // a mis-setting Foundation could underset and accidentally drain // active user orders. Now even with sweepFloor=0, the contract // refuses to sweep tokens currently locked in open orders. // BOND-ACCOUNTING FIX: reserve solver bonds (totalBond) ON TOP of locked // user principal (totalLocked), so bonded WAERE can never be swept to SINK. // For non-bond tokens totalBond is 0, so behavior is unchanged there. uint256 locked = totalLocked[token] + totalBond[token]; uint256 floor = sweepFloor[token]; uint256 reserved = locked > floor ? locked : floor; if (bal <= reserved) revert ZeroAmount(); uint256 amount = bal - reserved; IERC20(token).approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", token, amount)); if (!ok) revert TransferFailed(); } mapping(address => uint256) public sweepFloor; function setSweepFloor(address token, uint256 amount) external onlyOwner { sweepFloor[token] = amount; } }