// 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;
    }
}
