diff --git a/evm/src/apps/IntentGatewayV2.sol b/evm/src/apps/IntentGatewayV2.sol index 7e0006cf8..22a80e9fd 100644 --- a/evm/src/apps/IntentGatewayV2.sol +++ b/evm/src/apps/IntentGatewayV2.sol @@ -161,10 +161,14 @@ contract IntentGatewayV2 is IntrinsicIntents, ExtrinsicIntents, ReentrancyGuardT */ function placeOrder(Order memory order, bytes32 graffiti) public payable nonReentrant { if (order.inputs.length == 0) revert InvalidInput(); + // Inputs and outputs pair 1:1 by index; reject mismatched orders that could never be filled. + if (order.inputs.length != order.output.assets.length) revert InvalidInput(); - // Reject duplicate output tokens + // Reject duplicate output tokens uint256 outputsLen_ = order.output.assets.length; for (uint256 i; i < outputsLen_;) { + // A zero-amount output would strand its paired input escrow + if (order.output.assets[i].amount == 0) revert InvalidInput(); bytes32 token = order.output.assets[i].token; assembly ("memory-safe") { if tload(token) { diff --git a/evm/src/apps/intentsv2/ExtrinsicIntents.sol b/evm/src/apps/intentsv2/ExtrinsicIntents.sol index 694d78443..b073a5afa 100644 --- a/evm/src/apps/intentsv2/ExtrinsicIntents.sol +++ b/evm/src/apps/intentsv2/ExtrinsicIntents.sol @@ -31,6 +31,7 @@ import { } from "@hyperbridge/core/apps/IntentGatewayV2.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {RLPReader} from "@polytope-labs/solidity-merkle-trees/src/trie/ethereum/RLPReader.sol"; import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; @@ -42,6 +43,8 @@ import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.s */ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { using SafeERC20 for IERC20; + using RLPReader for bytes; + using RLPReader for RLPReader.RLPItem; /** * @dev Returns the Hyperbridge host contract address. Overrides both IntentsBase and @@ -67,20 +70,28 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { } /** - * @dev Fills a cross-chain order on the destination chain. The solver provides output - * tokens directly to the beneficiary, and a Hyperbridge post request is dispatched - * back to the source chain to release the escrowed input tokens to the solver. + * @dev Fills a cross-chain order on the destination chain, supporting both partial and full + * fills. The solver provides output tokens directly to the beneficiary, and a Hyperbridge post + * request is dispatched back to the source chain to release the escrowed input tokens. * - * Unlike same-chain fills, cross-chain fills are all-or-nothing — partial fills - * are not supported. The solver must provide at least the full required amount - * for every output asset. + * Partial-fill tracking mirrors the same-chain path: cumulative progress per output token is + * recorded in `_partialFills`, and the escrow released for each fill is computed via + * `_cumulativeReleased` over `order.inputs[i].amount`. Because the escrow itself lives on the + * source chain, the proportional slice is carried in the dispatched message rather than + * released locally. The monotonic release function guarantees that, across any number of + * partial fills, the redeemed slices sum to exactly the escrowed amount. * - * Surplus handling (when solver overpays): + * - Partial fill: clears `_filled` (so the next solver can continue) and dispatches a + * `RedeemEscrowPartial` message (non-finalizing on the source). Emits `PartialFill`. + * - Full fill: keeps `_filled` set, executes any attached calldata, and dispatches a + * `RedeemEscrow` message (finalizing, forwarding accumulated fees). Emits `OrderFilled`. + * + * Surplus handling (only when a solver overpays on a fresh, unfilled output): * - If the order has attached calldata, all surplus goes to the protocol. * - Otherwise, surplus is split between beneficiary and protocol per `surplusShareBps`. * - * After transferring tokens and executing any attached calldata, dispatches a - * RedeemEscrow message to the source chain gateway via Hyperbridge. + * Orders carrying output calldata cannot be partially filled — the attached call only runs on + * a full fill, so an incomplete fill reverts with PartialFillNotAllowed. * * @param order The cross-chain order to fill. * @param options Fill options including output amounts, relayer fee, and native dispatch fee. @@ -93,6 +104,9 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { uint256 msgValue = msg.value; address beneficiary = address(uint160(uint256(order.output.beneficiary))); + bool isFullyFilled = true; + + TokenInfo[] memory escrowReleases = new TokenInfo[](outputsLen); TokenInfo[] memory outputFills = new TokenInfo[](outputsLen); for (uint256 i; i < outputsLen; i++) { @@ -103,45 +117,75 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { uint256 totalRequired = order.output.assets[i].amount; uint256 solverAmount = options.outputs[i].amount; - if (solverAmount < totalRequired) revert InvalidInput(); + uint256 alreadyFilled = _partialFills[commitment][outputToken]; + uint256 remaining = totalRequired - alreadyFilled; + if (remaining == 0 || solverAmount == 0) { + if (solverAmount == 0 && remaining > 0) isFullyFilled = false; + // Record the real tokens (with zero amounts) so emitted events carry token identity. + escrowReleases[i] = TokenInfo({token: order.inputs[i].token, amount: 0}); + outputFills[i] = TokenInfo({token: outputToken, amount: 0}); + continue; + } + uint256 fillAmount; - uint256 dust = solverAmount - totalRequired; uint256 beneficiaryShare = 0; uint256 protocolShare = 0; - - if (dust > 0) { + if (alreadyFilled == 0 && solverAmount > totalRequired) { + fillAmount = totalRequired; + uint256 dust = solverAmount - totalRequired; if (order.output.call.length > 0) { protocolShare = dust; } else { protocolShare = (dust * _params.surplusShareBps) / 10_000; beneficiaryShare = dust - protocolShare; } + } else { + fillAmount = solverAmount > remaining ? remaining : solverAmount; } + uint256 amountFilled = alreadyFilled + fillAmount; + _partialFills[commitment][outputToken] = amountFilled; + uint256 beneficiaryTotal = fillAmount + beneficiaryShare; + if (token == address(0)) { - if (msgValue < solverAmount) revert InsufficientNativeToken(); - uint256 beneficiaryTotal = totalRequired + beneficiaryShare; + if (msgValue < beneficiaryTotal + protocolShare) revert InsufficientNativeToken(); + msgValue -= (beneficiaryTotal + protocolShare); (bool sent,) = beneficiary.call{value: beneficiaryTotal}(""); if (!sent) revert InsufficientNativeToken(); - msgValue -= (beneficiaryTotal + protocolShare); } else { - IERC20(token).safeTransferFrom(msg.sender, beneficiary, totalRequired + beneficiaryShare); + IERC20(token).safeTransferFrom(msg.sender, beneficiary, beneficiaryTotal); if (protocolShare > 0) { IERC20(token).safeTransferFrom(msg.sender, address(this), protocolShare); } } + + if (totalRequired > amountFilled) isFullyFilled = false; if (protocolShare > 0) emit DustCollected(token, protocolShare); - outputFills[i] = TokenInfo({token: outputToken, amount: totalRequired}); + + // Escrow lives on the source chain; carry this fill's proportional slice in the message. + uint256 escrowTotal = order.inputs[i].amount; + uint256 releaseNow = _cumulativeReleased(escrowTotal, amountFilled, totalRequired) + - _cumulativeReleased(escrowTotal, alreadyFilled, totalRequired); + escrowReleases[i] = TokenInfo({token: order.inputs[i].token, amount: releaseNow}); + outputFills[i] = TokenInfo({token: outputToken, amount: fillAmount}); } - _execute(order, outputsLen); + // Orders with output calldata can't be partially filled; the call only runs on a full fill. + if (order.output.call.length > 0 && !isFullyFilled) revert PartialFillNotAllowed(); + + if (isFullyFilled) { + _execute(order, outputsLen); + } else { + // Clear the optimistic claim so the next solver can fill the remainder. + delete _filled[commitment]; + } address hostAddr = host(); bytes memory body = bytes.concat( - bytes1(uint8(RequestKind.RedeemEscrow)), + bytes1(uint8(isFullyFilled ? RequestKind.RedeemEscrow : RequestKind.RedeemEscrowPartial)), abi.encode( WithdrawalRequest({ - commitment: commitment, tokens: order.inputs, beneficiary: bytes32(uint256(uint160(msg.sender))) + commitment: commitment, tokens: escrowReleases, beneficiary: bytes32(uint256(uint160(msg.sender))) }) ) ); @@ -167,19 +211,30 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { if (!sent) revert InsufficientNativeToken(); } - emit OrderFilled({commitment: commitment, filler: msg.sender, outputs: outputFills, inputs: order.inputs}); + if (isFullyFilled) { + emit OrderFilled({commitment: commitment, filler: msg.sender, outputs: outputFills, inputs: escrowReleases}); + } else { + emit PartialFill({commitment: commitment, filler: msg.sender, outputs: outputFills, inputs: escrowReleases}); + } } /** * @dev Initiates cancellation of a cross-chain order from the source chain. * * Only the order creator may cancel, and only after the order deadline has passed - * (verified by `options.height > order.deadline`). Dispatches a Hyperbridge GET - * request to the destination chain to verify that the `_filled` storage slot for - * this commitment is empty (i.e., the order was never filled on the destination). + * (verified by `options.height > order.deadline`). The deadline gate is what makes a + * proof at `options.height` a *final* snapshot of fill progress: once `block.number` + * passes the deadline no further fills can occur on the destination, so the proven + * `_partialFills` values can no longer change. * - * The GET response is handled by `onGetResponse`, which refunds the escrow if - * the slot is indeed empty. + * Dispatches a Hyperbridge GET request reading the destination's + * `_partialFills[commitment][token]` slot for each output token. The response is handled by + * `onGetResponse`, which refunds the proven-unredeemed fraction of each escrowed input — never + * the raw remaining escrow, so that any `RedeemEscrow` messages still in flight for fills that + * happened before the deadline remain covered. + * + * `placeOrder` guarantees `order.inputs.length == order.output.assets.length`, so each input is + * paired with the output at the same index. * * @param order The order to cancel. * @param options Cancel options including the proof height and relayer fee. @@ -191,19 +246,22 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { if (options.height <= order.deadline) revert NotExpired(); uint256 inputsLen = order.inputs.length; - for (uint256 i; i < inputsLen;) { - if (_orders[commitment][address(uint160(uint256(order.inputs[i].token)))] == 0) revert UnknownOrder(); + address destGateway = _instance(order.destination); + bytes[] memory keys = new bytes[](inputsLen); + uint256[] memory totalRequired = new uint256[](inputsLen); + for (uint256 i; i < inputsLen;) { + keys[i] = bytes.concat( + abi.encodePacked(destGateway), + _calculatePartialFillSlotHash(commitment, order.output.assets[i].token) + ); + totalRequired[i] = order.output.assets[i].amount; unchecked { ++i; } } + bytes memory context = abi.encode(commitment, order.user, order.inputs, totalRequired); - bytes memory context = - abi.encode(WithdrawalRequest({commitment: commitment, tokens: order.inputs, beneficiary: order.user})); - - bytes[] memory keys = new bytes[](1); - keys[0] = bytes.concat(abi.encodePacked(_instance(order.destination)), _calculateCommitmentSlotHash(commitment)); DispatchGet memory request = DispatchGet({ dest: order.destination, keys: keys, @@ -230,8 +288,11 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { * on behalf of the user). * * Marks the order as filled (to prevent future fill attempts) and dispatches a - * RefundEscrow message via Hyperbridge to the source chain to release the escrowed - * tokens back to the original user. + * RefundEscrow message via Hyperbridge to the source chain. Because this runs on the + * destination, `_partialFills` is read directly: only the unredeemed fraction of each escrowed + * input is refunded, leaving the portion already (or about to be) redeemed by partial-fill + * solvers untouched. Setting `_filled` and snapshotting `_partialFills` happen in the same + * transaction, so the snapshot is final without needing a deadline gate. * * @param order The order to cancel. * @param options Cancel options including the relayer fee. @@ -242,11 +303,24 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { if (order.user != bytes32(uint256(uint160(msg.sender)))) revert Unauthorized(); } + // Freeze the order, then snapshot fill progress in the same tx and refund the unredeemed rest. _filled[commitment] = address(uint160(uint256(order.user))); + uint256 inputsLen = order.inputs.length; + TokenInfo[] memory refunds = new TokenInfo[](inputsLen); + for (uint256 i; i < inputsLen;) { + uint256 escrowTotal = order.inputs[i].amount; + uint256 filled = _partialFills[commitment][order.output.assets[i].token]; + uint256 refund = escrowTotal - _cumulativeReleased(escrowTotal, filled, order.output.assets[i].amount); + refunds[i] = TokenInfo({token: order.inputs[i].token, amount: refund}); + unchecked { + ++i; + } + } + bytes memory body = bytes.concat( bytes1(uint8(RequestKind.RefundEscrow)), - abi.encode(WithdrawalRequest({commitment: commitment, tokens: order.inputs, beneficiary: order.user})) + abi.encode(WithdrawalRequest({commitment: commitment, tokens: refunds, beneficiary: order.user})) ); DispatchPost memory request = DispatchPost({ @@ -271,8 +345,12 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { * The first byte of the request body encodes the `RequestKind`, which determines * the action to take: * - * - RedeemEscrow: Releases escrowed tokens to the solver who filled the order - * on the destination chain. Authenticated against the registered gateway instance. + * - RedeemEscrow: Releases escrowed tokens to the solver who completed (fully filled) the + * order on the destination chain, finalizing it and forwarding accumulated fees. + * Authenticated against the registered gateway instance. + * - RedeemEscrowPartial: Releases a proportional slice of escrowed tokens to a solver who + * partially filled the order, without finalizing it (so further redeems and the user's + * cancel refund remain possible). Authenticated against the registered gateway instance. * - RefundEscrow: Refunds escrowed tokens to the original user after a successful * cancellation from the destination chain. Authenticated against the registered gateway. * - NewDeployment: Registers a new gateway instance for a state machine. Only @@ -288,10 +366,18 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { */ function onAccept(IncomingPostRequest calldata incoming) external override onlyHost { RequestKind kind = RequestKind(uint8(incoming.request.body[0])); - if (kind == RequestKind.RedeemEscrow || kind == RequestKind.RefundEscrow) { + if ( + kind == RequestKind.RedeemEscrow || + kind == RequestKind.RefundEscrow || + kind == RequestKind.RedeemEscrowPartial + ) { _authenticate(incoming.request); WithdrawalRequest memory body = abi.decode(incoming.request.body[1:], (WithdrawalRequest)); - return _withdraw(body, kind == RequestKind.RefundEscrow, true); + // A partial redeem must not finalize: escrow stays open for further redeems / a cancel + // refund, and the fee pot is left for the completing redeem. _withdraw emits EscrowReleased + // regardless of finalize, so the partial release is still observable on the source chain. + bool finalize = kind != RequestKind.RedeemEscrowPartial; + return _withdraw(body, kind == RequestKind.RefundEscrow, finalize); } // only hyperbridge is permitted to perform these actions @@ -310,16 +396,78 @@ abstract contract ExtrinsicIntents is IntentsBase, HyperApp { /** * @dev Handles the response to a Hyperbridge GET request dispatched during - * `_cancelFromSource`. Verifies that the `_filled` storage slot on the destination - * chain is empty (meaning the order was never filled), then refunds the escrowed - * tokens to the original user. Reverts with `Filled` if the slot is non-empty. + * `_cancelFromSource`. The response carries the destination's `_partialFills[commitment][token]` + * value for each output token; for each escrowed input this refunds the proven-unredeemed + * fraction (`escrowTotal - _cumulativeReleased(escrowTotal, filled, totalRequired)`) to the + * user, leaving exactly enough escrow to cover redeems still in flight. The order is marked + * filled for idempotency, and the user's prepaid fees are returned only if the order did not + * fully fill on the destination. Reverts with `Filled` on a duplicate cancel response. * - * @param incoming The incoming GET response from Hyperbridge containing the storage proof. + * @param incoming The incoming GET response from Hyperbridge containing the storage proofs. */ function onGetResponse(IncomingGetResponse calldata incoming) external override onlyHost { - if (incoming.response.values[0].value.length != 0) revert Filled(); + (bytes32 commitment, bytes32 beneficiary, TokenInfo[] memory inputs, uint256[] memory totalRequired) = + abi.decode(incoming.response.request.context, (bytes32, bytes32, TokenInfo[], uint256[])); + + // Idempotency: block duplicate/concurrent cancel responses before releasing any funds. + if (_filled[commitment] != address(0)) revert Filled(); + _filled[commitment] = address(uint160(uint256(beneficiary))); + + uint256 len = inputs.length; + TokenInfo[] memory refunds = new TokenInfo[](len); + bool fullyFilled = true; + for (uint256 i; i < len;) { + // Values come back sorted by key, not in request order, so match by key. request.keys[i] + // is the slot for input i's output, and the request is verified against its committed hash. + bytes calldata raw = _proofValueForKey(incoming, incoming.response.request.keys[i]); + uint256 filled = raw.length == 0 ? 0 : raw.toRlpItem().toUint(); + + // Refund only the unredeemed fraction; the complement is what pre-deadline fills will redeem. + uint256 escrowTotal = inputs[i].amount; + uint256 refund = escrowTotal - _cumulativeReleased(escrowTotal, filled, totalRequired[i]); + if (filled < totalRequired[i]) fullyFilled = false; + refunds[i] = TokenInfo({token: inputs[i].token, amount: refund}); + unchecked { + ++i; + } + } - WithdrawalRequest memory body = abi.decode(incoming.response.request.context, (WithdrawalRequest)); - _withdraw(body, true, true); + // `_filled` is already set above for idempotency. Finalize — which flushes the prepaid fee + // pot to the user — only when the order did not fully fill; a fully-filled order's fees belong + // to the completing solver. _withdraw emits EscrowRefunded for the refunded tokens. + _withdraw( + WithdrawalRequest({ + commitment: commitment, + tokens: refunds, + beneficiary: beneficiary + }), + true, + !fullyFilled + ); + } + + /** + * @dev Returns the proof value whose storage key matches `key`. GET responses return values + * sorted by key (the responder iterates a BTreeMap), so positional indexing would mispair + * values with inputs for multi-token orders. Absent slots are still returned (with an empty + * value), so a matching key is always expected; reverts if none is found. + * @param incoming The incoming GET response. + * @param key The expected storage key (one of the request's keys). + * @return The raw (RLP-encoded) proof value bytes for that key. + */ + function _proofValueForKey(IncomingGetResponse calldata incoming, bytes calldata key) + internal + pure + returns (bytes calldata) + { + bytes32 want = keccak256(key); + uint256 n = incoming.response.values.length; + for (uint256 j; j < n;) { + if (keccak256(incoming.response.values[j].key) == want) return incoming.response.values[j].value; + unchecked { + ++j; + } + } + revert InvalidInput(); } } diff --git a/evm/src/apps/intentsv2/IntentsBase.sol b/evm/src/apps/intentsv2/IntentsBase.sol index 4a6e06ce3..7035e7cb6 100644 --- a/evm/src/apps/intentsv2/IntentsBase.sol +++ b/evm/src/apps/intentsv2/IntentsBase.sol @@ -63,6 +63,14 @@ abstract contract IntentsBase is EIP712 { bytes32 constant FILLED_SLOT_BIG_ENDIAN_BYTES = hex"0000000000000000000000000000000000000000000000000000000000000002"; + /** + * @dev Big-endian encoding of storage slot 11 (the `_partialFills` mapping slot). + * Used to construct storage proof keys for cross-chain partial-fill cancel verification. + * Asserted against the compiled storage layout in the test suite to catch layout drift. + */ + bytes32 constant PARTIAL_FILLS_SLOT_BIG_ENDIAN_BYTES = + hex"000000000000000000000000000000000000000000000000000000000000000b"; + /** * @dev Discriminator for cross-chain request types dispatched via Hyperbridge. * Encoded as the first byte of the request body in onAccept. @@ -91,7 +99,13 @@ abstract contract IntentsBase is EIP712 { /** * @dev Upgrade the gateway implementation behind its ERC-1967 proxy. */ - UpgradeContract + UpgradeContract, + /** + * @dev Release a proportional slice of escrowed tokens to the solver after a + * cross-chain partial fill, without finalizing the order. The completing fill + * uses `RedeemEscrow` (which finalizes and forwards accumulated fees). + */ + RedeemEscrowPartial } /** @@ -243,11 +257,13 @@ abstract contract IntentsBase is EIP712 { event PartialFill(bytes32 indexed commitment, address filler, TokenInfo[] outputs, TokenInfo[] inputs); /** - * @dev Emitted when escrowed tokens are released to the solver after a successful fill. + * @dev Emitted when escrowed tokens are released to the solver after a successful (full or + * partial) fill. For cross-chain partial fills this is the only source-chain signal of release. * @param commitment The order commitment hash. + * @param solver The recipient of the released escrow. * @param tokens The tokens and amounts released. */ - event EscrowReleased(bytes32 indexed commitment, TokenInfo[] tokens); + event EscrowReleased(bytes32 indexed commitment, address solver, TokenInfo[] tokens); /** * @dev Emitted when escrowed tokens are refunded to the original user after cancellation. @@ -335,6 +351,46 @@ abstract contract IntentsBase is EIP712 { return abi.encodePacked(keccak256(abi.encodePacked(commitment, FILLED_SLOT_BIG_ENDIAN_BYTES))); } + /** + * @dev Computes the storage slot hash for `_partialFills[commitment][token]` on a remote + * chain. `_partialFills` is a nested mapping at slot 12, so the key is derived as + * keccak256(token . keccak256(commitment . 12)) — the standard Solidity nested-mapping layout. + * Used to construct GET storage-proof keys for cross-chain partial-fill cancel verification. + * @param commitment The order commitment hash. + * @param token The output token (bytes32-encoded address) whose fill progress is being proven. + * @return The ABI-encoded storage slot hash for the nested mapping entry. + */ + function _calculatePartialFillSlotHash(bytes32 commitment, bytes32 token) internal pure returns (bytes memory) { + bytes32 innerSlot = keccak256(abi.encodePacked(commitment, PARTIAL_FILLS_SLOT_BIG_ENDIAN_BYTES)); + return abi.encodePacked(keccak256(abi.encodePacked(token, innerSlot))); + } + + /** + * @dev Computes the cumulative escrow released for an input token given how much of its + * paired output has been filled. Defined as a single monotonic function so that the sum of + * per-fill release deltas exactly equals `escrowTotal` once the output is fully filled, with + * all integer-division rounding dust deterministically landing in the completing fill. + * + * Released(filled) = filled >= totalRequired ? escrowTotal : escrowTotal * filled / totalRequired + * + * This same function is used on the destination chain to size each `RedeemEscrow(Partial)` + * message and on the source chain to size cancel refunds, guaranteeing that + * (sum of redeems) + (cancel refund) == escrowTotal regardless of message arrival order. + * + * @param escrowTotal The full escrowed input amount for this token (order.inputs[i].amount). + * @param filled The cumulative amount of the paired output filled so far. + * @param totalRequired The total output amount required (order.output.assets[i].amount). + * @return The cumulative escrow that should have been released to solvers at this fill level. + */ + function _cumulativeReleased(uint256 escrowTotal, uint256 filled, uint256 totalRequired) + internal + pure + returns (uint256) + { + if (totalRequired == 0 || filled >= totalRequired) return escrowTotal; + return (escrowTotal * filled) / totalRequired; + } + /** * @dev Releases escrowed tokens to a beneficiary. Iterates over the withdrawal request's * token list, decrements the escrow balance for each, and transfers tokens out. @@ -372,18 +428,21 @@ abstract contract IntentsBase is EIP712 { } } + // Fees and the filled-marker are only settled on finalization; the release/refund event is + // emitted for every withdrawal (including non-finalizing partial redeems and cancel refunds) + // so escrow movement is always observable. if (finalize) { uint256 fees = _orders[body.commitment][TRANSACTION_FEES]; if (fees > 0) { delete _orders[body.commitment][TRANSACTION_FEES]; IERC20(IDispatcher(host()).feeToken()).safeTransfer(beneficiary, fees); } + } - if (isRefund) { - emit EscrowRefunded({commitment: body.commitment, tokens: body.tokens}); - } else { - emit EscrowReleased({commitment: body.commitment, tokens: body.tokens}); - } + if (isRefund) { + emit EscrowRefunded({commitment: body.commitment, tokens: body.tokens}); + } else { + emit EscrowReleased({commitment: body.commitment, solver: beneficiary, tokens: body.tokens}); } } diff --git a/evm/src/apps/intentsv2/IntrinsicIntents.sol b/evm/src/apps/intentsv2/IntrinsicIntents.sol index 219c5a954..45a0eb575 100644 --- a/evm/src/apps/intentsv2/IntrinsicIntents.sol +++ b/evm/src/apps/intentsv2/IntrinsicIntents.sol @@ -75,6 +75,9 @@ abstract contract IntrinsicIntents is IntentsBase { uint256 remaining = totalRequired - alreadyFilled; if (remaining == 0 || solverAmount == 0) { if (solverAmount == 0 && remaining > 0) isFullyFilled = false; + // Record the real tokens (with zero amounts) so emitted events carry token identity. + escrowedInputs[i] = TokenInfo({token: order.inputs[i].token, amount: 0}); + outputFills[i] = TokenInfo({token: outputToken, amount: 0}); continue; } uint256 fillAmount; diff --git a/evm/tests/foundry/IntentGatewayV2SameChainTest.sol b/evm/tests/foundry/IntentGatewayV2SameChainTest.sol index cf4982922..416080991 100644 --- a/evm/tests/foundry/IntentGatewayV2SameChainTest.sol +++ b/evm/tests/foundry/IntentGatewayV2SameChainTest.sol @@ -60,7 +60,7 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest { PaymentInfo output ); event OrderFilled(bytes32 indexed commitment, address indexed filler, TokenInfo[] outputs, TokenInfo[] inputs); - event EscrowReleased(bytes32 indexed commitment, TokenInfo[] tokens); + event EscrowReleased(bytes32 indexed commitment, address solver, TokenInfo[] tokens); event EscrowRefunded(bytes32 indexed commitment, TokenInfo[] tokens); event DustCollected(address indexed token, uint256 amount); @@ -847,7 +847,8 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest { } function testSameChainSwap_MismatchedLengths_ShouldRevert() public { - // 2 inputs, 1 output should revert with InvalidInput + // 2 inputs, 1 output is rejected at placeOrder: the 1:1 input/output pairing is required, + // so a mismatched order is unfillable and must never be escrowed. TokenInfo[] memory inputs = new TokenInfo[](2); inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6}); inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 500 * 1e18}); @@ -874,21 +875,8 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest { vm.startPrank(user); usdc.approve(address(intentGateway), 1000 * 1e6); dai.approve(address(intentGateway), 500 * 1e18); - intentGateway.placeOrder(order, bytes32(0)); - vm.stopPrank(); - - order.user = bytes32(uint256(uint160(user))); - order.source = host.host(); - order.nonce = 0; - - vm.startPrank(solver); - TokenInfo[] memory solverOutputs = new TokenInfo[](1); - solverOutputs[0] = TokenInfo({token: bytes32(0), amount: 1 ether}); - vm.expectRevert(IntentsBase.InvalidInput.selector); - intentGateway.fillOrder{value: 1 ether}( - order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: solverOutputs}) - ); + intentGateway.placeOrder(order, bytes32(0)); vm.stopPrank(); } @@ -1931,14 +1919,15 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest { /// @notice Placing an order with duplicate input tokens must revert. /// Regression test for: same-chain partial fills over-release repeated input escrow. function testRevert_PlaceOrder_DuplicateInputTokens() public { - // Two input legs both using USDC — this previously merged into one escrow bucket + // Two input legs both using USDC — this previously merged into one escrow bucket. + // Output tokens are distinct so only the input duplicate can trigger the revert. TokenInfo[] memory inputs = new TokenInfo[](2); inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1200 * 1e6}); inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6}); TokenInfo[] memory outputAssets = new TokenInfo[](2); outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 500 * 1e18}); - outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6}); PaymentInfo memory output = PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}); @@ -1976,13 +1965,14 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest { }); gatewayWithFees.initialize(intentParams, new bytes[](0)); + // Output tokens are distinct so only the input duplicate can trigger the revert. TokenInfo[] memory inputs = new TokenInfo[](2); inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 600 * 1e6}); inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6}); TokenInfo[] memory outputAssets = new TokenInfo[](2); outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 300 * 1e18}); - outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 400 * 1e18}); + outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6}); PaymentInfo memory output = PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}); diff --git a/evm/tests/foundry/IntentGatewayV2Test.sol b/evm/tests/foundry/IntentGatewayV2Test.sol index f73cee8b2..0da5b77b6 100644 --- a/evm/tests/foundry/IntentGatewayV2Test.sol +++ b/evm/tests/foundry/IntentGatewayV2Test.sol @@ -2881,20 +2881,23 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { bytes32 commitment = keccak256(abi.encode(order)); - // Create GET response with empty value (order not filled) - bytes memory context = abi.encode( - WithdrawalRequest({commitment: commitment, tokens: inputs, beneficiary: bytes32(uint256(uint160(user)))}) - ); - + // Partial-fill-aware cancel context: per-token _partialFills proof. An empty proof value + // decodes to filled=0, so the full escrow is refundable. + uint256[] memory totalRequired = new uint256[](1); + totalRequired[0] = outputAssets[0].amount; + bytes memory context = abi.encode(commitment, bytes32(uint256(uint160(user))), inputs, totalRequired); + + bytes[] memory keys = new bytes[](1); + keys[0] = abi.encodePacked(_partialFillSlot(commitment, bytes32(uint256(uint160(address(dai)))))); StorageValue[] memory values = new StorageValue[](1); - values[0] = StorageValue({key: new bytes(0), value: new bytes(0)}); // Empty value = not filled + values[0] = StorageValue({key: keys[0], value: new bytes(0)}); // Empty value = not filled GetRequest memory getRequest = GetRequest({ source: host.host(), dest: order.destination, nonce: 0, from: abi.encodePacked(address(intentGateway)), - keys: new bytes[](0), + keys: keys, height: 0, timeoutTimestamp: 0, context: context @@ -3213,12 +3216,14 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { session: address(0), predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), inputs: new TokenInfo[](2), - output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: new TokenInfo[](1), call: ""}) + output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: new TokenInfo[](2), call: ""}) }); order.inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: usdcAmount}); order.inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: daiAmount}); + // Two distinct outputs to satisfy the 1:1 input/output pairing invariant. order.output.assets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 2000 * 1e18}); + order.output.assets[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 2000 * 1e6}); vm.startPrank(user); usdc.approve(address(customGateway), usdcAmount); @@ -3548,6 +3553,564 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { intentGateway.onAccept(IncomingPostRequest({relayer: address(0), request: request})); } + // ============================================ + // Cross-chain partial fill tests + // ============================================ + + /// @dev Minimal big-endian RLP encoding of a uint, matching what an Ethereum storage proof + /// returns for a slot value (`RLP(slotValueTrimmed)`). Returns empty bytes for 0 (absent slot). + function _rlpEncodeUint(uint256 x) internal pure returns (bytes memory) { + if (x == 0) return bytes(""); + bytes32 be = bytes32(x); + uint256 firstNonZero = 0; + while (firstNonZero < 32 && be[firstNonZero] == 0) firstNonZero++; + uint256 len = 32 - firstNonZero; + bytes memory trimmed = new bytes(len); + for (uint256 i; i < len; i++) { + trimmed[i] = be[firstNonZero + i]; + } + if (len == 1 && uint8(trimmed[0]) < 0x80) return trimmed; + return abi.encodePacked(bytes1(uint8(0x80 + len)), trimmed); + } + + /// @dev Recomputes the `_partialFills[commitment][token]` storage slot independently of the + /// contract, to guard against storage-layout drift (slot 11). + function _partialFillSlot(bytes32 commitment, bytes32 token) internal pure returns (bytes32) { + bytes32 inner = keccak256(abi.encodePacked(commitment, bytes32(uint256(11)))); + return keccak256(abi.encodePacked(token, inner)); + } + + /// @dev Builds a single-input/single-output cross-chain order (USDC -> DAI) with the given + /// source/destination and amounts. `user`/`nonce` are left as the literals the caller expects. + function _xchainOrder(bytes memory source, bytes memory destination, uint256 inputAmount, uint256 outputAmount) + internal + view + returns (Order memory order) + { + TokenInfo[] memory inputs = new TokenInfo[](1); + inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: inputAmount}); + TokenInfo[] memory outputAssets = new TokenInfo[](1); + outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: outputAmount}); + order = Order({ + user: bytes32(uint256(uint160(user))), + source: source, + destination: destination, + deadline: block.number + 1000, + nonce: 0, + fees: 0, + session: address(0), + predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), + inputs: inputs, + output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}) + }); + } + + /// @dev Replays a RedeemEscrow / RedeemEscrowPartial message arriving on the source chain. + function _replayRedeem(IntentsBase.RequestKind kind, bytes32 commitment, TokenInfo[] memory tokens, address solver) + internal + { + bytes memory body = bytes.concat( + bytes1(uint8(kind)), + abi.encode( + WithdrawalRequest({commitment: commitment, tokens: tokens, beneficiary: bytes32(uint256(uint160(solver)))}) + ) + ); + PostRequest memory request = PostRequest({ + source: bytes("DEST_CHAIN"), + dest: host.host(), + nonce: 0, + from: abi.encodePacked(address(intentGateway)), + to: abi.encodePacked(address(intentGateway)), + body: body, + timeoutTimestamp: 0 + }); + vm.prank(address(host)); + intentGateway.onAccept(IncomingPostRequest({relayer: address(0), request: request})); + } + + /// @dev Drives onGetResponse for a single-token partial-fill-aware cancel with the given proven + /// fill amount on the destination. + function _replayCancel(bytes32 commitment, uint256 inputAmount, uint256 totalOutput, uint256 provenFilled) + internal + { + TokenInfo[] memory inputs = new TokenInfo[](1); + inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: inputAmount}); + uint256[] memory totalRequired = new uint256[](1); + totalRequired[0] = totalOutput; + bytes memory context = abi.encode(commitment, bytes32(uint256(uint160(user))), inputs, totalRequired); + + // request.keys[i] is the _partialFills slot key for output i; the value carries the same key. + bytes[] memory keys = new bytes[](1); + keys[0] = abi.encodePacked(_partialFillSlot(commitment, bytes32(uint256(uint160(address(dai)))))); + + StorageValue[] memory values = new StorageValue[](1); + values[0] = StorageValue({key: keys[0], value: _rlpEncodeUint(provenFilled)}); + + GetRequest memory getRequest = GetRequest({ + source: host.host(), + dest: bytes("DEST_CHAIN"), + nonce: 0, + from: abi.encodePacked(address(intentGateway)), + keys: keys, + height: 0, + timeoutTimestamp: 0, + context: context + }); + IncomingGetResponse memory incoming = + IncomingGetResponse({response: GetResponse({request: getRequest, values: values}), relayer: address(0)}); + vm.prank(address(host)); + intentGateway.onGetResponse(incoming); + } + + /// @dev Destination-side: a partial fill pays the beneficiary pro-rata, records cumulative + /// progress, clears `_filled`, and dispatches a proportional escrow release (asserted via the + /// PartialFill event's `inputs`). A second solver then completes the order. + function testCrossChainPartialFill_ReleasesProportionalEscrowAndCompletes() public { + uint256 inputAmount = 1000 * 1e6; // 1000 USDC escrowed on the source chain + uint256 outputAmount = 1000 * 1e18; // 1000 DAI requested on this (destination) chain + Order memory order = _xchainOrder(bytes("SOURCE_CHAIN"), host.host(), inputAmount, outputAmount); + bytes32 commitment = keccak256(abi.encode(order)); + bytes32 daiToken = bytes32(uint256(uint160(address(dai)))); + + // Solver A fills 40%. + TokenInfo[] memory outA = new TokenInfo[](1); + outA[0] = TokenInfo({token: daiToken, amount: 400 * 1e18}); + TokenInfo[] memory expOutA = new TokenInfo[](1); + expOutA[0] = TokenInfo({token: daiToken, amount: 400 * 1e18}); + TokenInfo[] memory expInA = new TokenInfo[](1); + expInA[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6}); + + uint256 userDaiBefore = dai.balanceOf(user); + vm.startPrank(filler); + dai.approve(address(intentGateway), type(uint256).max); + vm.expectEmit(true, false, false, true); + emit IntentsBase.PartialFill(commitment, filler, expOutA, expInA); + intentGateway.fillOrder(order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: outA})); + vm.stopPrank(); + + assertEq(dai.balanceOf(user) - userDaiBefore, 400 * 1e18, "beneficiary gets 40% output"); + assertEq(intentGateway._partialFills(commitment, daiToken), 400 * 1e18, "cumulative fill recorded"); + assertEq(intentGateway._filled(commitment), address(0), "filled cleared so next solver can continue"); + + // Solver B completes the remaining 60%. + address solverB = makeAddr("solverB"); + deal(address(dai), solverB, 10000 * 1e18); + TokenInfo[] memory outB = new TokenInfo[](1); + outB[0] = TokenInfo({token: daiToken, amount: 600 * 1e18}); + TokenInfo[] memory expOutB = new TokenInfo[](1); + expOutB[0] = TokenInfo({token: daiToken, amount: 600 * 1e18}); + TokenInfo[] memory expInB = new TokenInfo[](1); + expInB[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 600 * 1e6}); + + vm.startPrank(solverB); + dai.approve(address(intentGateway), type(uint256).max); + vm.expectEmit(true, false, false, true); + emit IntentsBase.OrderFilled(commitment, solverB, expOutB, expInB); + intentGateway.fillOrder(order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: outB})); + vm.stopPrank(); + + assertEq(dai.balanceOf(user) - userDaiBefore, 1000 * 1e18, "beneficiary fully paid"); + assertEq(intentGateway._partialFills(commitment, daiToken), 1000 * 1e18, "order fully filled"); + assertEq(intentGateway._filled(commitment), solverB, "completing solver recorded"); + } + + /// @dev Source-side: a RedeemEscrowPartial releases only its slice without finalizing (no + /// `_filled`, fees retained); the completing RedeemEscrow finalizes and forwards the fee pot. + function testCrossChainPartialRedeem_DoesNotFinalizeUntilComplete() public { + uint256 inputAmount = 1000 * 1e6; + uint256 feeAmount = 5 * 1e18; // fees are paid in the fee token (DAI) + Order memory order = _xchainOrder(host.host(), bytes("DEST_CHAIN"), inputAmount, 1000 * 1e18); + order.fees = feeAmount; + + vm.startPrank(user); + usdc.approve(address(intentGateway), inputAmount); + dai.approve(address(intentGateway), feeAmount); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + + bytes32 commitment = keccak256(abi.encode(order)); + address solver = makeAddr("xchainSolver"); + + // Partial redeem of 40% of the escrow. The source emits EscrowReleased for the slice even + // though it does not finalize, so partial releases are observable. + TokenInfo[] memory slice = new TokenInfo[](1); + slice[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6}); + vm.expectEmit(true, false, false, true); + emit IntentsBase.EscrowReleased(commitment, solver, slice); + _replayRedeem(IntentsBase.RequestKind.RedeemEscrowPartial, commitment, slice, solver); + + assertEq(usdc.balanceOf(solver), 400 * 1e6, "solver received partial slice"); + assertEq(intentGateway._orders(commitment, address(usdc)), 600 * 1e6, "escrow reduced, not drained"); + assertEq(intentGateway._filled(commitment), address(0), "partial redeem does not finalize"); + assertEq(dai.balanceOf(solver), 0, "fees not forwarded on partial redeem"); + + // Completing redeem of the remaining 60% finalizes and forwards the fee pot. + TokenInfo[] memory rest = new TokenInfo[](1); + rest[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 600 * 1e6}); + _replayRedeem(IntentsBase.RequestKind.RedeemEscrow, commitment, rest, solver); + + assertEq(usdc.balanceOf(solver), 1000 * 1e6, "solver received full escrow"); + assertEq(intentGateway._orders(commitment, address(usdc)), 0, "escrow drained"); + assertEq(intentGateway._filled(commitment), solver, "completing redeem finalizes"); + assertEq(dai.balanceOf(solver), feeAmount, "completing solver takes the fee pot"); + } + + /// @dev Cancel after a partial fill refunds only the proven-unredeemed fraction, never the raw + /// remaining escrow, so an already-redeemed slice plus the refund sum to the full escrow. + function testCrossChainCancel_RefundsUnfilledFractionOnly() public { + uint256 inputAmount = 1000 * 1e6; + Order memory order = _xchainOrder(host.host(), bytes("DEST_CHAIN"), inputAmount, 1000 * 1e18); + + vm.startPrank(user); + usdc.approve(address(intentGateway), inputAmount); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + + bytes32 commitment = keccak256(abi.encode(order)); + address solver = makeAddr("xchainSolver"); + + // A 40% partial fill was already redeemed on the source chain. + TokenInfo[] memory slice = new TokenInfo[](1); + slice[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6}); + _replayRedeem(IntentsBase.RequestKind.RedeemEscrowPartial, commitment, slice, solver); + + // User cancels with a proof showing 40% of the output filled on the destination. + uint256 userUsdcBefore = usdc.balanceOf(user); + _replayCancel(commitment, inputAmount, 1000 * 1e18, 400 * 1e18); + + assertEq(usdc.balanceOf(user) - userUsdcBefore, 600 * 1e6, "user refunded only the unfilled 60%"); + assertEq(intentGateway._orders(commitment, address(usdc)), 0, "escrow fully accounted for"); + assertEq(intentGateway._filled(commitment), user, "cancel finalizes for idempotency"); + assertEq(usdc.balanceOf(solver), 400 * 1e6, "solver keeps its redeemed slice"); + } + + /// @dev Cancel can reach onGetResponse even for an order fully filled on the destination, when + /// the completing RedeemEscrow is still in flight to the source (source _filled is still 0). The + /// proof then shows full fill: refund is 0 and the fee pot must be withheld for the completing solver. + function testCrossChainCancel_FullyFilledRaceRefundsNothingAndWithholdsFees() public { + uint256 inputAmount = 1000 * 1e6; + uint256 feeAmount = 5 * 1e18; + Order memory order = _xchainOrder(host.host(), bytes("DEST_CHAIN"), inputAmount, 1000 * 1e18); + order.fees = feeAmount; + + vm.startPrank(user); + usdc.approve(address(intentGateway), inputAmount); + dai.approve(address(intentGateway), feeAmount); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + + bytes32 commitment = keccak256(abi.encode(order)); + + // Fully filled on the destination, completing RedeemEscrow still in flight. User cancels; + // the proof shows 100% filled. + uint256 userUsdcBefore = usdc.balanceOf(user); + uint256 userDaiBefore = dai.balanceOf(user); + _replayCancel(commitment, inputAmount, 1000 * 1e18, 1000 * 1e18); + + assertEq(usdc.balanceOf(user) - userUsdcBefore, 0, "nothing refunded for a fully-filled order"); + assertEq(dai.balanceOf(user) - userDaiBefore, 0, "fees withheld for the completing solver"); + assertEq(intentGateway._orders(commitment, address(usdc)), inputAmount, "escrow intact for in-flight redeem"); + address txFeeKey = address(uint160(uint256(keccak256("txFees")))); + assertEq(intentGateway._orders(commitment, txFeeKey), feeAmount, "fee pot intact"); + assertEq(intentGateway._filled(commitment), user, "cancel still records idempotency"); + + // The in-flight completing redeem lands: the solver receives the full escrow and the fee pot. + address solver = makeAddr("xchainSolver"); + TokenInfo[] memory full = new TokenInfo[](1); + full[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: inputAmount}); + _replayRedeem(IntentsBase.RequestKind.RedeemEscrow, commitment, full, solver); + + assertEq(usdc.balanceOf(solver), inputAmount, "completing solver redeems full escrow"); + assertEq(dai.balanceOf(solver), feeAmount, "completing solver receives the fee pot"); + } + + /// @dev A RedeemEscrowPartial that arrives AFTER the user has cancelled still succeeds: the + /// cancel refunded only the unfilled fraction, leaving exactly enough escrow for the in-flight slice. + function testCrossChainCancel_InFlightRedeemAfterCancelStaysConsistent() public { + uint256 inputAmount = 1000 * 1e6; + Order memory order = _xchainOrder(host.host(), bytes("DEST_CHAIN"), inputAmount, 1000 * 1e18); + + vm.startPrank(user); + usdc.approve(address(intentGateway), inputAmount); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + + bytes32 commitment = keccak256(abi.encode(order)); + address solver = makeAddr("xchainSolver"); + + // User cancels first; proof shows 40% filled before the deadline, redeem still in flight. + uint256 userUsdcBefore = usdc.balanceOf(user); + _replayCancel(commitment, inputAmount, 1000 * 1e18, 400 * 1e18); + assertEq(usdc.balanceOf(user) - userUsdcBefore, 600 * 1e6, "user refunded unfilled 60%"); + assertEq(intentGateway._orders(commitment, address(usdc)), 400 * 1e6, "escrow reserved for in-flight redeem"); + + // The in-flight 40% redeem now lands and is fully covered. + TokenInfo[] memory slice = new TokenInfo[](1); + slice[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6}); + _replayRedeem(IntentsBase.RequestKind.RedeemEscrowPartial, commitment, slice, solver); + + assertEq(usdc.balanceOf(solver), 400 * 1e6, "in-flight redeem paid in full"); + assertEq(intentGateway._orders(commitment, address(usdc)), 0, "escrow fully settled"); + } + + /// @dev A second cancel response for the same commitment is rejected (idempotency). + function testCrossChainCancel_DoubleCancelBlocked() public { + uint256 inputAmount = 1000 * 1e6; + Order memory order = _xchainOrder(host.host(), bytes("DEST_CHAIN"), inputAmount, 1000 * 1e18); + + vm.startPrank(user); + usdc.approve(address(intentGateway), inputAmount); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + + bytes32 commitment = keccak256(abi.encode(order)); + _replayCancel(commitment, inputAmount, 1000 * 1e18, 0); + assertEq(intentGateway._filled(commitment), user, "first cancel finalized"); + + TokenInfo[] memory inputs = new TokenInfo[](1); + inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: inputAmount}); + uint256[] memory totalRequired = new uint256[](1); + totalRequired[0] = 1000 * 1e18; + bytes memory context = abi.encode(commitment, bytes32(uint256(uint160(user))), inputs, totalRequired); + bytes[] memory keys = new bytes[](1); + keys[0] = abi.encodePacked(_partialFillSlot(commitment, bytes32(uint256(uint160(address(dai)))))); + StorageValue[] memory values = new StorageValue[](1); + values[0] = StorageValue({key: keys[0], value: _rlpEncodeUint(0)}); + GetRequest memory getRequest = GetRequest({ + source: host.host(), + dest: bytes("DEST_CHAIN"), + nonce: 0, + from: abi.encodePacked(address(intentGateway)), + keys: keys, + height: 0, + timeoutTimestamp: 0, + context: context + }); + IncomingGetResponse memory incoming = + IncomingGetResponse({response: GetResponse({request: getRequest, values: values}), relayer: address(0)}); + vm.prank(address(host)); + vm.expectRevert(IntentsBase.Filled.selector); + intentGateway.onGetResponse(incoming); + } + + /// @dev Cross-chain orders carrying output calldata cannot be partially filled. + function testCrossChainPartialFill_CalldataRevertsPartialFillNotAllowed() public { + Order memory order = _xchainOrder(bytes("SOURCE_CHAIN"), host.host(), 1000 * 1e6, 1000 * 1e18); + // Attach a (harmless) output call so the order requires single-fill completion. + Call[] memory calls = new Call[](1); + calls[0] = Call({to: address(dai), value: 0, data: abi.encodeWithSelector(IERC20.balanceOf.selector, user)}); + order.output.call = abi.encode(calls); + bytes32 daiToken = bytes32(uint256(uint160(address(dai)))); + + TokenInfo[] memory partialOut = new TokenInfo[](1); + partialOut[0] = TokenInfo({token: daiToken, amount: 400 * 1e18}); + + vm.startPrank(filler); + dai.approve(address(intentGateway), type(uint256).max); + vm.expectRevert(IntentsBase.PartialFillNotAllowed.selector); + intentGateway.fillOrder(order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: partialOut})); + vm.stopPrank(); + } + + /// @dev Multi-token cancel: the host returns proof values sorted by storage key, NOT in + /// request-key order. Each value must be matched to its input by key, otherwise fill amounts + /// pair with the wrong escrow. Here the two values are supplied in reversed order. + function testCrossChainCancel_MultiTokenMatchesValuesByKey() public { + // input0 USDC funds output0 DAI; input1 DAI funds output1 USDC. + TokenInfo[] memory inputs = new TokenInfo[](2); + inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6}); + inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 500 * 1e18}); + TokenInfo[] memory outputAssets = new TokenInfo[](2); + outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 500 * 1e6}); + + Order memory order = Order({ + user: bytes32(uint256(uint160(user))), + source: host.host(), + destination: bytes("DEST_CHAIN"), + deadline: block.number + 1000, + nonce: 0, + fees: 0, + session: address(0), + predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), + inputs: inputs, + output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}) + }); + + vm.startPrank(user); + usdc.approve(address(intentGateway), 1000 * 1e6); + dai.approve(address(intentGateway), 500 * 1e18); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + + bytes32 commitment = keccak256(abi.encode(order)); + uint256[] memory totalRequired = new uint256[](2); + totalRequired[0] = 1000 * 1e18; // output0 DAI + totalRequired[1] = 500 * 1e6; // output1 USDC + bytes memory context = abi.encode(commitment, bytes32(uint256(uint160(user))), inputs, totalRequired); + + // request.keys aligned with inputs: keys[0] -> output0 (DAI), keys[1] -> output1 (USDC). + bytes[] memory keys = new bytes[](2); + keys[0] = abi.encodePacked(_partialFillSlot(commitment, bytes32(uint256(uint160(address(dai)))))); + keys[1] = abi.encodePacked(_partialFillSlot(commitment, bytes32(uint256(uint160(address(usdc)))))); + + // Values returned in REVERSED order (key-sorted by the host): output0 DAI 40% filled, + // output1 USDC 60% filled. + StorageValue[] memory values = new StorageValue[](2); + values[0] = StorageValue({key: keys[1], value: _rlpEncodeUint(300 * 1e6)}); // output1 filled + values[1] = StorageValue({key: keys[0], value: _rlpEncodeUint(400 * 1e18)}); // output0 filled + + GetRequest memory getRequest = GetRequest({ + source: host.host(), + dest: bytes("DEST_CHAIN"), + nonce: 0, + from: abi.encodePacked(address(intentGateway)), + keys: keys, + height: 0, + timeoutTimestamp: 0, + context: context + }); + + uint256 userUsdcBefore = usdc.balanceOf(user); + uint256 userDaiBefore = dai.balanceOf(user); + vm.prank(address(host)); + intentGateway.onGetResponse( + IncomingGetResponse({response: GetResponse({request: getRequest, values: values}), relayer: address(0)}) + ); + + // input0 USDC: 40% of output0 redeemed -> refund 60% = 600 USDC. + // input1 DAI: 60% of output1 redeemed -> refund 40% = 200 DAI. + assertEq(usdc.balanceOf(user) - userUsdcBefore, 600 * 1e6, "USDC refund matched output0 fill by key"); + assertEq(dai.balanceOf(user) - userDaiBefore, 200 * 1e18, "DAI refund matched output1 fill by key"); + } + + /// @dev External helper so the test can use calldata slicing to strip the RequestKind prefix. + function decodeWithdrawalBody(bytes calldata body) external pure returns (uint8 kind, WithdrawalRequest memory wr) { + kind = uint8(body[0]); + wr = abi.decode(body[1:], (WithdrawalRequest)); + } + + /// @dev Destination-side cancel after a partial fill must refund only the unredeemed fraction + /// of escrow (read from local `_partialFills`), not the full `order.inputs`. + function testCrossChainCancelFromDest_RefundsUnredeemedFractionOnly() public { + uint256 inputAmount = 1000 * 1e6; + uint256 outputAmount = 1000 * 1e18; + Order memory order = _xchainOrder(bytes("SOURCE_CHAIN"), host.host(), inputAmount, outputAmount); + bytes32 commitment = keccak256(abi.encode(order)); + bytes32 daiToken = bytes32(uint256(uint160(address(dai)))); + + // Solver fills 40% on this (destination) chain. + TokenInfo[] memory outA = new TokenInfo[](1); + outA[0] = TokenInfo({token: daiToken, amount: 400 * 1e18}); + vm.startPrank(filler); + dai.approve(address(intentGateway), type(uint256).max); + intentGateway.fillOrder(order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: outA})); + vm.stopPrank(); + assertEq(intentGateway._partialFills(commitment, daiToken), 400 * 1e18, "40% recorded"); + + // User cancels from the destination; capture the dispatched RefundEscrow. + vm.recordLogs(); + vm.prank(user); + intentGateway.cancelOrder(order, CancelOptions({relayerFee: 0, height: 0})); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 postTopic = keccak256("PostRequestEvent(string,string,address,bytes,uint256,uint256,bytes,uint256)"); + bytes memory body; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == postTopic) { + (,,,,, bytes memory b,) = + abi.decode(logs[i].data, (string, string, bytes, uint256, uint256, bytes, uint256)); + body = b; + } + } + assertGt(body.length, 0, "RefundEscrow dispatched"); + + (uint8 kind, WithdrawalRequest memory wr) = this.decodeWithdrawalBody(body); + assertEq(kind, uint8(IntentsBase.RequestKind.RefundEscrow), "refund escrow kind"); + assertEq(wr.tokens.length, 1, "one input"); + // 40% of output filled -> 40% of USDC escrow redeemed -> refund the unredeemed 60% = 600 USDC. + assertEq(wr.tokens[0].amount, 600 * 1e6, "refunds only the unredeemed 60%"); + assertEq(wr.beneficiary, bytes32(uint256(uint160(user))), "refund to user"); + assertEq(intentGateway._filled(commitment), user, "order frozen on destination"); + } + + /// @dev placeOrder rejects a zero-amount output, which would otherwise strand its paired escrow. + function testPlaceOrder_RevertsOnZeroAmountOutput() public { + TokenInfo[] memory inputs = new TokenInfo[](1); + inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6}); + TokenInfo[] memory outputAssets = new TokenInfo[](1); + outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 0}); + + Order memory order = Order({ + user: bytes32(uint256(uint160(user))), + source: host.host(), + destination: bytes("DEST_CHAIN"), + deadline: block.number + 1000, + nonce: 0, + fees: 0, + session: address(0), + predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), + inputs: inputs, + output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}) + }); + + vm.startPrank(user); + usdc.approve(address(intentGateway), type(uint256).max); + vm.expectRevert(IntentsBase.InvalidInput.selector); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + } + + /// @dev Guards the `_partialFills` storage slot (12) used to build cross-chain cancel proofs. + function testPartialFillsStorageSlotIsTwelve() public { + Order memory order = _xchainOrder(bytes("SOURCE_CHAIN"), host.host(), 1000 * 1e6, 1000 * 1e18); + bytes32 commitment = keccak256(abi.encode(order)); + bytes32 daiToken = bytes32(uint256(uint160(address(dai)))); + + TokenInfo[] memory outA = new TokenInfo[](1); + outA[0] = TokenInfo({token: daiToken, amount: 250 * 1e18}); + vm.startPrank(filler); + dai.approve(address(intentGateway), type(uint256).max); + intentGateway.fillOrder(order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: outA})); + vm.stopPrank(); + + uint256 viaGetter = intentGateway._partialFills(commitment, daiToken); + bytes32 raw = vm.load(address(intentGateway), _partialFillSlot(commitment, daiToken)); + assertEq(viaGetter, 250 * 1e18, "partial fill recorded via getter"); + assertEq(uint256(raw), viaGetter, "slot-12 derivation matches public getter"); + } + + /// @dev placeOrder rejects orders whose input/output array lengths differ, since the 1:1 + /// index pairing is required for fills and cancels. + function testPlaceOrder_RevertsOnInputOutputLengthMismatch() public { + TokenInfo[] memory inputs = new TokenInfo[](2); + inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6}); + inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + + TokenInfo[] memory outputAssets = new TokenInfo[](1); + outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + + Order memory order = Order({ + user: bytes32(uint256(uint160(user))), + source: host.host(), + destination: bytes("DEST_CHAIN"), + deadline: block.number + 1000, + nonce: 0, + fees: 0, + session: address(0), + predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), + inputs: inputs, + output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}) + }); + + vm.startPrank(user); + usdc.approve(address(intentGateway), type(uint256).max); + dai.approve(address(intentGateway), type(uint256).max); + vm.expectRevert(IntentsBase.InvalidInput.selector); + intentGateway.placeOrder(order, bytes32(0)); + vm.stopPrank(); + } + // ============================================================ // UpgradeContract (cross-chain governance upgrade) Tests // ============================================================ diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index bc3501907..c70da6876 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -429,6 +429,10 @@ enum IntentGatewayRequestKind { SweepDust = 3, RefundEscrow = 4, UpgradeContract = 5, + /// Releases a proportional escrow slice after a cross-chain partial fill without + /// finalizing the order. Dispatched only by the gateway itself, never by this pallet. + #[allow(dead_code)] + RedeemEscrowPartial = 6, } /// Mirrors the `RequestKind` enum in `VWAPOracle.sol`. diff --git a/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs b/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs index 61d696698..9230a1aed 100644 --- a/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs +++ b/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs @@ -66,7 +66,12 @@ dataSources: handler: handleEscrowReleasedEventV3 filter: topics: - - 'EscrowReleased(bytes32,(bytes32,uint256)[])' + - 'EscrowReleased(bytes32,address,(bytes32,uint256)[])' + # Pre-partial-fills EscrowReleased (no solver). Its signature is no longer in the + # ABI and subql-cli rejects raw topic hashes, so the handler has no topic filter + # and matches the legacy topic0 itself. + - kind: ethereum/LogHandler + handler: handleEscrowReleasedEventV3Legacy - kind: ethereum/LogHandler handler: handleEscrowRefundedEventV3 filter: diff --git a/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json b/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json index 35a0602c1..87eb99132 100644 --- a/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json +++ b/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json @@ -1192,6 +1192,12 @@ "indexed": true, "internalType": "bytes32" }, + { + "name": "solver", + "type": "address", + "indexed": false, + "internalType": "address" + }, { "name": "tokens", "type": "tuple[]", diff --git a/sdk/packages/indexer/src/configs/schema.graphql b/sdk/packages/indexer/src/configs/schema.graphql index 9a5a3a746..e42d533ac 100644 --- a/sdk/packages/indexer/src/configs/schema.graphql +++ b/sdk/packages/indexer/src/configs/schema.graphql @@ -965,6 +965,12 @@ type IOrderV3InputAsset @entity { Position index in the input assets array """ index: Int! + + """ + Cumulative amount of this escrowed input released to solvers across all + (partial) redeems. Equals `amount` once the order is fully redeemed. + """ + released: BigInt } """ @@ -1000,6 +1006,12 @@ type IOrderV3OutputAsset @entity { Beneficiary address for this output """ beneficiary: String! @index + + """ + Cumulative amount of this output filled across all (partial) fills. + Equals `amount` once the order is fully filled. + """ + filled: BigInt } """ @@ -1304,6 +1316,12 @@ type IOrderV3EscrowRelease @entity { """ order: IOrderV3! + """ + The solver the escrow was released to. Null for events emitted before the + contract carried the solver in EscrowReleased. + """ + solver: String @index + """ The chain on which the release occurred (source chain) """ diff --git a/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowRefundedV3.event.handler.ts b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowRefundedV3.event.handler.ts index 3ea1704f7..0330c101c 100644 --- a/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowRefundedV3.event.handler.ts +++ b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowRefundedV3.event.handler.ts @@ -23,23 +23,30 @@ export const handleEscrowRefundedEventV3 = wrap(async (event: EscrowRefundedLog) })}, tokens: ${stringify(tokens)}`, ) - await IntentGatewayV3Service.recordEscrowRefund( - commitment, - tokens.map((token) => ({ - token: token.token as Hex, - amount: BigInt(token.amount.toString()), - })), - { - transactionHash, - blockNumber, - timestamp, - logIndex, - }, - ) + const refundTokens = tokens.map((token) => ({ + token: token.token as Hex, + amount: BigInt(token.amount.toString()), + })) - await IntentGatewayV3Service.updateOrderStatus(commitment, OrderStatus.REFUNDED, { + await IntentGatewayV3Service.recordEscrowRefund(commitment, refundTokens, { transactionHash, blockNumber, timestamp, + logIndex, }) + + // A cancel of an already fully-filled order refunds nothing (the escrow went to + // solvers) but still emits EscrowRefunded with all-zero amounts — the order must + // not be marked REFUNDED then. + if (refundTokens.some((token) => token.amount > 0n)) { + await IntentGatewayV3Service.updateOrderStatus(commitment, OrderStatus.REFUNDED, { + transactionHash, + blockNumber, + timestamp, + }) + } else { + logger.info( + `[Intent Gateway V3] Escrow Refunded with zero amounts for ${stringify({ commitment })}, leaving order status unchanged`, + ) + } }) diff --git a/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler.ts b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler.ts index b42227b3c..7c9a95fb8 100644 --- a/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler.ts +++ b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler.ts @@ -2,7 +2,6 @@ import { getBlockTimestamp } from "@/utils/rpc.helpers" import stringify from "safe-stable-stringify" import { EscrowReleasedLog } from "@/configs/src/types/abi-interfaces/IntentGatewayV3Abi" import { IntentGatewayV3Service } from "@/services/intentGatewayV3.service" -import { OrderStatus } from "@/configs/src/types" import { getHostStateMachine } from "@/utils/substrate.helpers" import { Hex } from "viem" import { wrap } from "@/utils/event.utils" @@ -12,7 +11,7 @@ export const handleEscrowReleasedEventV3 = wrap(async (event: EscrowReleasedLog) const { blockNumber, transactionHash, args, blockHash, logIndex } = event if (!args) return - const { commitment, tokens } = args + const { commitment, solver, tokens } = args const chain = getHostStateMachine(chainId) const timestamp = await getBlockTimestamp(blockHash, chain) @@ -20,11 +19,15 @@ export const handleEscrowReleasedEventV3 = wrap(async (event: EscrowReleasedLog) logger.info( `[Intent Gateway V3] Escrow Released: ${stringify({ commitment, + solver, })}, tokens: ${stringify(tokens)}`, ) + // recordEscrowRelease decides whether this release completes the order (REDEEMED) + // or is a non-finalizing partial redeem that leaves the escrow open. await IntentGatewayV3Service.recordEscrowRelease( commitment, + solver, tokens.map((token) => ({ token: token.token as Hex, amount: BigInt(token.amount.toString()), @@ -36,10 +39,4 @@ export const handleEscrowReleasedEventV3 = wrap(async (event: EscrowReleasedLog) logIndex, }, ) - - await IntentGatewayV3Service.updateOrderStatus(commitment, OrderStatus.REDEEMED, { - transactionHash, - blockNumber, - timestamp, - }) }) diff --git a/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3Legacy.event.handler.ts b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3Legacy.event.handler.ts new file mode 100644 index 000000000..3d1b5ac1b --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/escrowReleasedV3Legacy.event.handler.ts @@ -0,0 +1,59 @@ +import { getBlockTimestamp } from "@/utils/rpc.helpers" +import stringify from "safe-stable-stringify" +import { EthereumLog, EthereumResult } from "@subql/types-ethereum" +import { IntentGatewayV3Service } from "@/services/intentGatewayV3.service" +import { getHostStateMachine } from "@/utils/substrate.helpers" +import { Hex } from "viem" +import { wrap } from "@/utils/event.utils" +import { Interface } from "@ethersproject/abi" + +// The pre-partial-fills EscrowReleased shape, without the solver. The project ABI carries +// the current signature and subql-cli rejects raw topic hashes in manifest filters, so this +// handler is registered without a topic filter and matches the legacy topic itself. +// Decoded with ethers (not viem) because viem's decoders break inside the VM2 sandbox — +// see the note in `utils/phantom-decode.ts`. +const legacyInterface = new Interface([ + "event EscrowReleased(bytes32 indexed commitment, tuple(bytes32 token, uint256 amount)[] tokens)", +]) +const LEGACY_ESCROW_RELEASED_TOPIC = legacyInterface.getEventTopic("EscrowReleased") + +export const handleEscrowReleasedEventV3Legacy = wrap( + async (event: EthereumLog): Promise => { + // Unfiltered handler: every gateway log lands here, so bail fast on anything + // that isn't a legacy-shape EscrowReleased. + if (event.topics[0]?.toLowerCase() !== LEGACY_ESCROW_RELEASED_TOPIC.toLowerCase()) return + + logger.info(`[Intent Gateway V3] Legacy Escrow Released Event: ${stringify(event)}`) + + const { blockNumber, transactionHash, blockHash, logIndex } = event + const { commitment, tokens } = legacyInterface.decodeEventLog( + "EscrowReleased", + event.data, + event.topics, + ) as unknown as { commitment: string; tokens: { token: string; amount: { toString(): string } }[] } + + const chain = getHostStateMachine(chainId) + const timestamp = await getBlockTimestamp(blockHash, chain) + + logger.info( + `[Intent Gateway V3] Legacy Escrow Released: ${stringify({ + commitment, + })}, tokens: ${stringify(tokens)}`, + ) + + await IntentGatewayV3Service.recordEscrowRelease( + commitment, + undefined, + tokens.map((token) => ({ + token: token.token as Hex, + amount: BigInt(token.amount.toString()), + })), + { + transactionHash, + blockNumber, + timestamp, + logIndex, + }, + ) + }, +) diff --git a/sdk/packages/indexer/src/mappings/mappingHandlers.ts b/sdk/packages/indexer/src/mappings/mappingHandlers.ts index a72758748..bca2d54ef 100644 --- a/sdk/packages/indexer/src/mappings/mappingHandlers.ts +++ b/sdk/packages/indexer/src/mappings/mappingHandlers.ts @@ -15,6 +15,7 @@ export { handleOrderPlacedEventV3 } from "@/handlers/events/intentGatewayV3/orde export { handleOrderFilledEventV3 } from "@/handlers/events/intentGatewayV3/orderFilledV3.event.handler" export { handlePartialFilledEventV3 } from "@/handlers/events/intentGatewayV3/partialFilledV3.event.handler" export { handleEscrowReleasedEventV3 } from "@/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler" +export { handleEscrowReleasedEventV3Legacy } from "@/handlers/events/intentGatewayV3/escrowReleasedV3Legacy.event.handler" export { handleEscrowRefundedEventV3 } from "@/handlers/events/intentGatewayV3/escrowRefundedV3.event.handler" export { handleDustCollectedEventV3 } from "@/handlers/events/intentGatewayV3/dustCollected.event.handler" export { handleDustSweptEventV3 } from "@/handlers/events/intentGatewayV3/dustSwept.event.handler" diff --git a/sdk/packages/indexer/src/services/intentGatewayV3.service.ts b/sdk/packages/indexer/src/services/intentGatewayV3.service.ts index 05a35c0f9..89b44e5eb 100644 --- a/sdk/packages/indexer/src/services/intentGatewayV3.service.ts +++ b/sdk/packages/indexer/src/services/intentGatewayV3.service.ts @@ -210,6 +210,7 @@ export class IntentGatewayV3Service { ) await this.flushPendingStatuses(order.id!) + await this.backfillEarlyFills(order.id!) logger.info("Now awarding points for the OrderV3 Placed Event") @@ -393,64 +394,35 @@ export class IntentGatewayV3Service { orderPlaced.status = status === OrderStatus.PLACED ? orderPlaced.status : status await orderPlaced.save() - // Award points for order filling - using USD value directly + // Once-per-order accounting on completion. Filler volume/points are NOT awarded + // here: with partial fills an order can be completed by several solvers, so the + // filler is credited per fill slice in awardFillRewards instead. if (status === OrderStatus.FILLED && filler) { - // Get output assets from the new entity relationships - const outputAssets: TokenInfo[] = [] - for (let index = 0; index < 100; index++) { - const assetId = `${commitment}-output-${index}` - const asset = await IOrderV3OutputAsset.get(assetId) - if (!asset) break - outputAssets.push({ - token: asset.token as Hex, - amount: asset.amount, - }) - } - - if (outputAssets.length > 0) { - // Volume - let outputUSD = await this.getOutputValuesUSD(outputAssets) - - await VolumeService.updateVolume(`IntentGatewayV3.FILLER.${filler}`, outputUSD.total, timestamp) + const orderValue = new Decimal(orderPlaced.inputUSD.toString()) + const pointsToAward = orderValue.floor().toNumber() - const orderValue = new Decimal(orderPlaced.inputUSD.toString()) - const pointsToAward = orderValue.floor().toNumber() + // User - convert to 20 bytes for UserActivityV2 ID, referrer is already 32 bytes + const userAddress20 = bytes32ToBytes20(orderPlaced.user) + let user = await getOrCreateUser(userAddress20, orderPlaced.referrer) + user.totalOrderFilledVolumeUSD = new Decimal(user.totalOrderFilledVolumeUSD) + .plus(new Decimal(orderPlaced.inputUSD.toString())) + .toString() + user.totalFilledOrders = user.totalFilledOrders + BigInt(1) + await user.save() - // Rewards + // Referrer + if (user.referrer) { + const referrerPointsToAward = Math.floor(pointsToAward / 2) await PointsService.awardPoints( - filler, - decodeChain(orderPlaced.destChain), - BigInt(pointsToAward), - ProtocolParticipantType.FILLER, - PointsActivityType.ORDER_FILLED_POINTS, + user.referrer, + decodeChain(orderPlaced.sourceChain), + BigInt(referrerPointsToAward), + ProtocolParticipantType.REFERRER, + PointsActivityType.ORDER_REFERRED_POINTS, transactionHash, `Points awarded for filling orderV3 ${commitment} with value ${orderPlaced.inputUSD} USD`, timestamp, ) - - // User - convert to 20 bytes for UserActivityV2 ID, referrer is already 32 bytes - const userAddress20 = bytes32ToBytes20(orderPlaced.user) - let user = await getOrCreateUser(userAddress20, orderPlaced.referrer) - user.totalOrderFilledVolumeUSD = new Decimal(user.totalOrderFilledVolumeUSD) - .plus(new Decimal(orderPlaced.inputUSD.toString())) - .toString() - user.totalFilledOrders = user.totalFilledOrders + BigInt(1) - await user.save() - - // Referrer - if (user.referrer) { - const referrerPointsToAward = Math.floor(pointsToAward / 2) - await PointsService.awardPoints( - user.referrer, - decodeChain(orderPlaced.sourceChain), - BigInt(referrerPointsToAward), - ProtocolParticipantType.REFERRER, - PointsActivityType.ORDER_REFERRED_POINTS, - transactionHash, - `Points awarded for filling orderV3 ${commitment} with value ${orderPlaced.inputUSD} USD`, - timestamp, - ) - } } } @@ -501,6 +473,132 @@ export class IntentGatewayV3Service { } } + /** + * Credits a solver for one fill slice, valued from that fill's own event outputs. + * With partial fills an order can be filled by several solvers, so per-order + * crediting would attribute other solvers' slices to the completing filler. + */ + private static async awardFillRewards( + commitment: string, + filler: string, + outputs: TokenInfo[], + transactionHash: string, + timestamp: bigint, + ): Promise { + const provided = outputs.filter((output) => output.amount > 0n) + if (provided.length === 0) return + + const outputUSD = await this.getOutputValuesUSD(provided) + const sliceUSD = new Decimal(outputUSD.total) + if (sliceUSD.lte(0)) return + + await VolumeService.updateVolume(`IntentGatewayV3.FILLER.${filler}`, outputUSD.total, timestamp) + + await this.awardFillPoints(commitment, filler, sliceUSD, transactionHash, timestamp) + } + + /** + * Points for one fill slice. A no-op while the order row is missing (a destination + * fill can index before the source-chain OrderPlaced) — {@link backfillEarlyFills} + * replays it when the order is created. + */ + private static async awardFillPoints( + commitment: string, + filler: string, + sliceUSD: Decimal, + transactionHash: string, + timestamp: bigint, + ): Promise { + const orderPlaced = await OrderV3Placed.get(commitment) + if (!orderPlaced) return + + const pointsToAward = sliceUSD.floor().toNumber() + if (pointsToAward <= 0) return + + await PointsService.awardPoints( + filler, + decodeChain(orderPlaced.destChain), + BigInt(pointsToAward), + ProtocolParticipantType.FILLER, + PointsActivityType.ORDER_FILLED_POINTS, + transactionHash, + `Points awarded for filling orderV3 ${commitment} slice worth ${sliceUSD.toString()} USD`, + timestamp, + ) + } + + /** + * Replays fills that were indexed before the order was placed (the destination + * chain can run ahead of the source chain). Such fills were recorded linked by + * commitment, but the cumulative `filled` accounting and the filler's points were + * skipped — the order's asset rows and dest chain did not exist yet. Volume was + * already credited at event time so it is not replayed. Exactly-once holds because + * any fill found here was recorded while the order row was absent (its points were + * certainly skipped), and fills recorded after creation are credited inline. + */ + private static async backfillEarlyFills(commitment: string): Promise { + const [partialFills, fills] = await Promise.all([ + IOrderV3PartialFill.getByOrderId(commitment, { limit: 100 }), + IOrderV3Fill.getByOrderId(commitment, { limit: 100 }), + ]) + if (partialFills.length === 0 && fills.length === 0) return + + const earlyFills = [ + ...partialFills.map((fill) => ({ fill, isPartial: true })), + ...fills.map((fill) => ({ fill, isPartial: false })), + ] + + for (const { fill, isPartial } of earlyFills) { + const outputs: TokenInfo[] = [] + for (let index = 0; ; index++) { + const assetId = `${fill.id}-output-${index}` + const asset = isPartial + ? await IOrderV3PartialFillOutputAsset.get(assetId) + : await IOrderV3FillOutputAsset.get(assetId) + if (!asset) break + outputs.push({ token: asset.token as Hex, amount: asset.amount }) + } + + const provided = outputs.filter((output) => output.amount > 0n) + if (provided.length === 0) continue + + await this.accumulateFilled(commitment, outputs) + + const outputUSD = await this.getOutputValuesUSD(provided) + const sliceUSD = new Decimal(outputUSD.total) + if (sliceUSD.lte(0)) continue + await this.awardFillPoints(commitment, fill.filler, sliceUSD, fill.transactionHash, fill.timestamp) + + logger.info( + `OrderV3 ${commitment}: backfilled early ${isPartial ? "partial fill" : "fill"} ${fill.id} on order placement`, + ) + } + } + + /** + * Accumulates a fill's output amounts into the order's per-output `filled` totals. + * The order is placed on the source chain while fills land on the destination, so + * the output-asset rows may not exist yet when a fill is indexed — progress for + * such fills is still recoverable from the fill entities themselves. + */ + private static async accumulateFilled(commitment: string, outputs: TokenInfo[]): Promise { + for (let index = 0; index < outputs.length; index++) { + const output = outputs[index] + if (output.amount === 0n) continue + + const asset = await IOrderV3OutputAsset.get(`${commitment}-output-${index}`) + if (!asset || asset.token.toLowerCase() !== output.token.toLowerCase()) { + logger.warn( + `OrderV3 ${commitment} output asset ${index} missing or token mismatch, skipping fill accumulation`, + ) + continue + } + + asset.filled = (asset.filled ?? 0n) + output.amount + await asset.save() + } + } + static async recordPartialFill( commitment: string, filler: string, @@ -527,20 +625,24 @@ export class IntentGatewayV3Service { const partialFillId = `${transactionHash}.${logIndex}` - let partialFill = await IOrderV3PartialFill.get(partialFillId) - if (!partialFill) { - partialFill = await IOrderV3PartialFill.create({ - id: partialFillId, - orderId: commitment, - chain: chainId, - filler, - timestamp, - blockNumber: blockNumber.toString(), - transactionHash, - createdAt: timestampToDate(timestamp), - }) + // The partial-fill record doubles as the idempotency marker for the cumulative + // accounting and rewards below, so a replayed event must not double-count. + if (await IOrderV3PartialFill.get(partialFillId)) { + logger.info(`OrderV3 PartialFill ${partialFillId} already recorded, skipping`) + return } + const partialFill = await IOrderV3PartialFill.create({ + id: partialFillId, + orderId: commitment, + chain: chainId, + filler, + timestamp, + blockNumber: blockNumber.toString(), + transactionHash, + createdAt: timestampToDate(timestamp), + }) + await partialFill.save() // Create/update input assets for this partial fill @@ -589,6 +691,9 @@ export class IntentGatewayV3Service { }), ) + await this.accumulateFilled(commitment, outputs) + await this.awardFillRewards(commitment, filler, outputs, transactionHash, timestamp) + logger.info( `OrderV3 PartialFill recorded: ${stringify({ commitment, @@ -623,19 +728,23 @@ export class IntentGatewayV3Service { const fillId = `${transactionHash}.${logIndex}` - let fill = await IOrderV3Fill.get(fillId) - if (!fill) { - fill = await IOrderV3Fill.create({ - id: fillId, - orderId: commitment, - chain: chainId, - filler, - timestamp, - blockNumber: blockNumber.toString(), - transactionHash, - createdAt: timestampToDate(timestamp), - }) + // The fill record doubles as the idempotency marker for the cumulative + // accounting and rewards below, so a replayed event must not double-count. + if (await IOrderV3Fill.get(fillId)) { + logger.info(`OrderV3 Fill ${fillId} already recorded, skipping`) + return } + + const fill = await IOrderV3Fill.create({ + id: fillId, + orderId: commitment, + chain: chainId, + filler, + timestamp, + blockNumber: blockNumber.toString(), + transactionHash, + createdAt: timestampToDate(timestamp), + }) await fill.save() await Promise.all( @@ -672,6 +781,9 @@ export class IntentGatewayV3Service { }), ) + await this.accumulateFilled(commitment, outputs) + await this.awardFillRewards(commitment, filler, outputs, transactionHash, timestamp) + logger.info( `OrderV3 Fill recorded: ${stringify({ commitment, @@ -683,6 +795,7 @@ export class IntentGatewayV3Service { static async recordEscrowRelease( commitment: string, + solver: string | undefined, tokens: TokenInfo[], logsData: { transactionHash: string @@ -694,36 +807,82 @@ export class IntentGatewayV3Service { const { transactionHash, blockNumber, timestamp, logIndex } = logsData const releaseId = `${transactionHash}.${logIndex}` - let release = await IOrderV3EscrowRelease.get(releaseId) - if (!release) { - release = await IOrderV3EscrowRelease.create({ - id: releaseId, - orderId: commitment, - chain: chainId, - timestamp, - blockNumber: blockNumber.toString(), - transactionHash, - createdAt: timestampToDate(timestamp), - }) + // The release record doubles as the idempotency marker for the cumulative + // accounting below, so a replayed event must not double-count. + if (await IOrderV3EscrowRelease.get(releaseId)) { + logger.info(`OrderV3 EscrowRelease ${releaseId} already recorded, skipping`) + return } + + const release = await IOrderV3EscrowRelease.create({ + id: releaseId, + orderId: commitment, + chain: chainId, + solver, + timestamp, + blockNumber: blockNumber.toString(), + transactionHash, + createdAt: timestampToDate(timestamp), + }) await release.save() await Promise.all( tokens.map(async (token, index) => { const tokenId = `${releaseId}-token-${index}` - let tokenEntity = await IOrderV3EscrowReleaseToken.get(tokenId) - if (!tokenEntity) { - tokenEntity = await IOrderV3EscrowReleaseToken.create({ - id: tokenId, - releaseId, - token: token.token, - amount: token.amount, - index, - }) - } + const tokenEntity = await IOrderV3EscrowReleaseToken.create({ + id: tokenId, + releaseId, + token: token.token, + amount: token.amount, + index, + }) await tokenEntity.save() }), ) + + // EscrowReleased fires for every redeem — including non-finalizing partial + // redeems — so the order is REDEEMED only once every escrowed input has been + // fully released. The contract's release formula sends integer-division dust + // to the completing fill, so cumulative releases sum to exactly the escrowed + // amount. Release events fire on the source chain (same chain as OrderPlaced), + // so the input-asset rows exist by the time a release is indexed. + const inputAssets: IOrderV3InputAsset[] = [] + for (let index = 0; ; index++) { + const asset = await IOrderV3InputAsset.get(`${commitment}-input-${index}`) + if (!asset) break + inputAssets.push(asset) + } + + if (inputAssets.length === 0) { + logger.warn(`OrderV3 ${commitment} has no input assets yet, skipping release accumulation`) + return + } + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index] + if (token.amount === 0n) continue + + const asset = inputAssets[index] + if (!asset || asset.token.toLowerCase() !== token.token.toLowerCase()) { + logger.warn( + `OrderV3 ${commitment} input asset ${index} missing or token mismatch, skipping release accumulation`, + ) + continue + } + + asset.released = (asset.released ?? 0n) + token.amount + await asset.save() + } + + const fullyReleased = inputAssets.every((asset) => (asset.released ?? 0n) >= asset.amount) + if (fullyReleased) { + await this.updateOrderStatus( + commitment, + OrderStatus.REDEEMED, + { transactionHash, blockNumber, timestamp }, + solver, + ) + } } static async recordEscrowRefund( diff --git a/sdk/packages/sdk/src/abis/IntentGatewayV2.ts b/sdk/packages/sdk/src/abis/IntentGatewayV2.ts index a846b969f..941380a3a 100644 --- a/sdk/packages/sdk/src/abis/IntentGatewayV2.ts +++ b/sdk/packages/sdk/src/abis/IntentGatewayV2.ts @@ -1340,6 +1340,12 @@ export const ABI = [ indexed: true, internalType: "bytes32", }, + { + name: "solver", + type: "address", + indexed: false, + internalType: "address", + }, { name: "tokens", type: "tuple[]", diff --git a/sdk/packages/sdk/src/index.ts b/sdk/packages/sdk/src/index.ts index 93a82bbae..dc9e41d72 100644 --- a/sdk/packages/sdk/src/index.ts +++ b/sdk/packages/sdk/src/index.ts @@ -18,6 +18,9 @@ export { constructRedeemEscrowRequestBody, constructRefundEscrowRequestBody, encodeWithdrawalRequest, + calculatePartialFillSlotHash, + encodeCancelFromSourceContext, + cumulativeReleased, estimateGasForPost, getStorageSlot, getOrFetchStorageSlot, diff --git a/sdk/packages/sdk/src/protocols/intents/Bid.ts b/sdk/packages/sdk/src/protocols/intents/Bid.ts index e9e82fd99..fb71eba53 100644 --- a/sdk/packages/sdk/src/protocols/intents/Bid.ts +++ b/sdk/packages/sdk/src/protocols/intents/Bid.ts @@ -180,8 +180,10 @@ export class BidImpl implements Bid { /** * Signs the `SelectSolver` message with the session key, appends it to the * solver's existing UserOp signature, and submits the UserOperation to the - * bundler. For same-chain orders, waits for the receipt and reads - * `OrderFilled` / `PartialFill` logs to determine fill status. + * bundler. Waits for the destination-chain receipt and reads `OrderFilled` / + * `PartialFill` logs to determine fill status — for both same-chain and + * cross-chain orders, since the fill (and its events) always land on the + * destination chain. * * @returns A {@link SelectBidResult} with the submitted UserOperation, its hash, * the solver address, transaction hash, and fill status. @@ -230,35 +232,37 @@ export class BidImpl implements Bid { ) txnHash = receipt.receipt.transactionHash - if (this.order.source === this.order.destination) { - try { - const chainReceipt = await this.ctx.dest.client.waitForTransactionReceipt({ - hash: txnHash, - confirmations: 1, - }) - const events = parseEventLogs({ - abi: IntentGatewayV2ABI, - logs: chainReceipt.logs, - eventName: ["OrderFilled", "PartialFill"], - }) - - const matched = events.find((e) => { - if (e.eventName === "OrderFilled") - return e.args.commitment.toLowerCase() === commitment.toLowerCase() - if (e.eventName === "PartialFill") - return e.args.commitment.toLowerCase() === commitment.toLowerCase() - return false - }) - - if (matched?.eventName === "OrderFilled") { - fillStatus = "full" - } else if (matched?.eventName === "PartialFill") { - fillStatus = "partial" - filledAssets = (matched.args.outputs ?? []) as TokenInfo[] - } - } catch { - throw new Error("Failed to determine fill status from logs") + // The fill executes on the destination chain and emits OrderFilled (full) or + // PartialFill (partial) there for both same-chain and cross-chain orders. Cross-chain + // escrow settlement is confirmed asynchronously via Hyperbridge, but the fill status is + // already observable on the destination receipt, so we read it here in both cases. + try { + const chainReceipt = await this.ctx.dest.client.waitForTransactionReceipt({ + hash: txnHash, + confirmations: 1, + }) + const events = parseEventLogs({ + abi: IntentGatewayV2ABI, + logs: chainReceipt.logs, + eventName: ["OrderFilled", "PartialFill"], + }) + + const matched = events.find((e) => { + if (e.eventName === "OrderFilled") + return e.args.commitment.toLowerCase() === commitment.toLowerCase() + if (e.eventName === "PartialFill") + return e.args.commitment.toLowerCase() === commitment.toLowerCase() + return false + }) + + if (matched?.eventName === "OrderFilled") { + fillStatus = "full" + } else if (matched?.eventName === "PartialFill") { + fillStatus = "partial" + filledAssets = (matched.args.outputs ?? []) as TokenInfo[] } + } catch { + throw new Error("Failed to determine fill status from logs") } } catch (err) { throw new Error(`Failed to execute bid: ${err instanceof Error ? err.message : String(err)}`) diff --git a/sdk/packages/sdk/src/protocols/intents/OrderCanceller.ts b/sdk/packages/sdk/src/protocols/intents/OrderCanceller.ts index f787ef7da..eb503e365 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderCanceller.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderCanceller.ts @@ -5,7 +5,8 @@ import { getRequestCommitment, postRequestCommitment, constructRefundEscrowRequestBody, - encodeWithdrawalRequest, + calculatePartialFillSlotHash, + encodeCancelFromSourceContext, adjustDecimals, normalizeStateMachineId, parseStateMachineId, @@ -86,15 +87,16 @@ export class OrderCanceller { const destIntentGateway = this.ctx.dest.configService.getIntentGatewayAddress( normalizeStateMachineId(order.destination), ) - const slotHash = await this.ctx.dest.client.readContract({ - abi: IntentGatewayV2ABI, - address: destIntentGateway, - functionName: "calculateCommitmentSlotHash", - args: [order.id as HexString], - }) - const key = concatHex([destIntentGateway as HexString, slotHash as HexString]) as HexString + // One GET key per output token: the destination's `_partialFills[commitment][outputToken]` slot. + const keys = order.output.assets.map( + (asset) => + concatHex([ + destIntentGateway as HexString, + calculatePartialFillSlotHash(order.id as HexString, asset.token as HexString), + ]) as HexString, + ) - const context = encodeWithdrawalRequest(order, order.user as HexString) + const context = encodeCancelFromSourceContext(order) const getRequest: IGetRequest = { source: sourceStateMachine, @@ -102,7 +104,7 @@ export class OrderCanceller { from: this.ctx.source.configService.getIntentGatewayAddress(normalizeStateMachineId(order.destination)), nonce: await this.ctx.source.getHostNonce(), height, - keys: [key], + keys, timeoutTimestamp: 0n, context, } @@ -515,14 +517,14 @@ export class OrderCanceller { this.ctx.dest.config.stateMachineId, ) const orderId = order.id! - const slotHash = (await this.ctx.dest.client.readContract({ - abi: IntentGatewayV2ABI, - address: intentGatewayV2Address, - functionName: "calculateCommitmentSlotHash", - args: [orderId as HexString], - })) as HexString - - const proofHex = await this.ctx.dest.queryStateProof(latestHeight, [slotHash], intentGatewayV2Address) + // Prove the destination's per-output-token `_partialFills` slots. These must match the + // GET request keys built in `quoteCancelFromSource`/`cancelOrderFromSource` so the + // response carries a value for every escrowed input. + const slotHashes = order.output.assets.map((asset) => + calculatePartialFillSlotHash(orderId as HexString, asset.token as HexString), + ) + + const proofHex = await this.ctx.dest.queryStateProof(latestHeight, slotHashes, intentGatewayV2Address) const proof: IProof = { consensusStateId: this.ctx.dest.config.consensusStateId, diff --git a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts index 9624083e7..2ca6ad9c0 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts @@ -222,11 +222,14 @@ export class OrderExecutor { * terminates or continues polling for the remaining amount. Feeding back * `undefined` (no bid executed this round) causes it to keep polling. * - * **Same-chain:** `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED` + * Both same-chain and cross-chain orders follow the same shape, since cross-chain + * fills now support partial fills and emit their fill events on the destination: + * `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED` * → (`FILLED` | `PARTIAL_FILL`)* → (`FILLED` | `EXPIRED`) * - * **Cross-chain:** `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED` - * (terminates — settlement is confirmed async via Hyperbridge) + * The only difference is that cross-chain escrow settlement (release/refund on the + * source chain) is confirmed asynchronously via Hyperbridge, out of band from this + * fill-progress lifecycle. */ async *executeOrder( options: ExecuteIntentOrderOptions, diff --git a/sdk/packages/sdk/src/protocols/intents/OrderStatusChecker.ts b/sdk/packages/sdk/src/protocols/intents/OrderStatusChecker.ts index 78d0fe53d..a9d72ecd1 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderStatusChecker.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderStatusChecker.ts @@ -1,10 +1,27 @@ import { isHex, hexToString } from "viem" import { ABI as IntentGatewayV2ABI } from "@/abis/IntentGatewayV2" -import { bytes32ToBytes20 } from "@/utils" +import { bytes32ToBytes20, normalizeAddressForEvmBytes32 } from "@/utils" import { orderCommitment } from "./utils" -import type { Order, HexString } from "@/types" +import type { Order, HexString, TokenInfo } from "@/types" import type { IntentGatewayContext } from "./types" +/** Per-output-token fill progress read from the destination `_partialFills` mapping. */ +export interface TokenFillProgress { + /** Output token, as provided in `order.output.assets[i].token`. */ + token: TokenInfo["token"] + /** Cumulative amount of this output filled so far across all (partial) fills. */ + filled: bigint + /** Total amount required for this output (`order.output.assets[i].amount`). */ + total: bigint +} + +/** Aggregate fill state of an order derived from its per-token progress. */ +export interface OrderFillProgress { + perToken: TokenFillProgress[] + /** `unfilled` when nothing filled, `full` when every output is satisfied, otherwise `partial`. */ + status: "unfilled" | "partial" | "full" +} + /** * Checks the on-chain fill and refund status of IntentGatewayV2 orders. * @@ -22,11 +39,13 @@ export class OrderStatusChecker { * Checks if a V2 order has been filled by reading the commitment storage slot on the destination chain. * * Reads the storage slot returned by `calculateCommitmentSlotHash` on the IntentGatewayV2 contract. - * A non-zero value at that slot means the solver has called `fillOrder` and the order is complete - * from the user's perspective (the beneficiary has received their tokens). + * A non-zero value at that slot means the order has been finalized — either fully filled or + * cancelled. Note that partial fills clear this slot so the next solver can continue, so during an + * in-progress cross-chain partial fill this returns `false` until the order is completed. Use + * {@link getFillProgress} to observe intermediate per-token progress. * * @param order - The V2 order to check. `order.id` is used as the commitment; if not set it is computed. - * @returns True if the order has been filled on the destination chain, false otherwise. + * @returns True if the order has been finalized on the destination chain, false otherwise. */ async isOrderFilled(order: Order): Promise { const commitment = (order.id ?? orderCommitment(order)) as HexString @@ -51,6 +70,44 @@ export class OrderStatusChecker { return filledStatus !== "0x0000000000000000000000000000000000000000000000000000000000000000" } + /** + * Reads per-output-token fill progress from the destination `_partialFills` mapping. + * + * Unlike {@link isOrderFilled} (which reads the terminal `_filled` slot), this reflects + * intermediate progress across repeated partial fills, so callers can track a cross-chain order + * as multiple solvers fill successive slices. + * + * @param order - The V2 order to check. `order.id` is used as the commitment; if not set it is computed. + * @returns Per-token filled/total amounts and an aggregate `unfilled | partial | full` status. + */ + async getFillProgress(order: Order): Promise { + const commitment = (order.id ?? orderCommitment(order)) as HexString + const destStateMachineId = isHex(order.destination) + ? hexToString(order.destination as HexString) + : order.destination + + const intentGatewayV2Address = this.ctx.dest.configService.getIntentGatewayAddress(destStateMachineId) + + const perToken = await Promise.all( + order.output.assets.map(async (asset) => { + const filled = (await this.ctx.dest.client.readContract({ + abi: IntentGatewayV2ABI, + address: intentGatewayV2Address, + functionName: "_partialFills", + args: [commitment, normalizeAddressForEvmBytes32(asset.token)], + })) as bigint + + return { token: asset.token, filled, total: asset.amount } + }), + ) + + const anyFilled = perToken.some((t) => t.filled > 0n) + const allFilled = perToken.every((t) => t.filled >= t.total) + const status = allFilled ? "full" : anyFilled ? "partial" : "unfilled" + + return { perToken, status } + } + /** * Checks if a V2 order has been refunded by reading the `_orders` mapping on the source chain. * diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 130246a7f..aa6a4554b 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -727,6 +727,17 @@ export enum RequestKind { * Identifies a request for refunding escrowed tokens after cancellation */ RefundEscrow = 4, + + /** + * Identifies a request for upgrading the gateway implementation behind its ERC-1967 proxy + */ + UpgradeContract = 5, + + /** + * Identifies a request for releasing a proportional slice of escrowed tokens to a + * solver after a cross-chain partial fill, without finalizing the order. + */ + RedeemEscrowPartial = 6, } /** diff --git a/sdk/packages/sdk/src/utils.ts b/sdk/packages/sdk/src/utils.ts index b64e8c859..65eb8a45e 100644 --- a/sdk/packages/sdk/src/utils.ts +++ b/sdk/packages/sdk/src/utils.ts @@ -544,6 +544,78 @@ export function encodeWithdrawalRequest(order: Order | Order, beneficiary: HexSt ) as HexString } +/** + * Big-endian encoding of storage slot 11, the `_partialFills` mapping slot in IntentGatewayV2. + * Kept in sync with `PARTIAL_FILLS_SLOT_BIG_ENDIAN_BYTES` in `evm/src/apps/intentsv2/IntentsBase.sol`, + * which asserts the slot index against the compiled storage layout in its test suite. + */ +const PARTIAL_FILLS_SLOT_BIG_ENDIAN = + "0x000000000000000000000000000000000000000000000000000000000000000b" as HexString + +/** + * Computes the storage slot hash for `_partialFills[commitment][token]` on a remote IntentGatewayV2. + * + * `_partialFills` is a nested mapping at slot 11, so the key follows the standard Solidity + * nested-mapping layout: `keccak256(token . keccak256(commitment . slot))`. This mirrors the + * contract's `_calculatePartialFillSlotHash` exactly and is used to build GET storage-proof keys + * for cross-chain partial-fill cancel verification. Computed off-chain because the contract exposes + * no public getter for this slot. + * + * @param commitment - The order commitment hash (bytes32). + * @param token - The output token whose fill progress is being proven (20- or 32-byte hex). + * @returns The storage slot hash for the nested-mapping entry. + */ +export function calculatePartialFillSlotHash(commitment: HexString, token: HexString): HexString { + const innerSlot = keccak256(concatHex([commitment, PARTIAL_FILLS_SLOT_BIG_ENDIAN])) + return keccak256(concatHex([normalizeAddressForEvmBytes32(token), innerSlot])) +} + +/** + * Mirrors IntentGatewayV2's `_cumulativeReleased`: the cumulative input escrow released to + * solvers once `filled` of `totalRequired` output has been provided. Integer-division dust + * is deferred to the completing fill. + */ +export function cumulativeReleased(escrowTotal: bigint, filled: bigint, totalRequired: bigint): bigint { + if (totalRequired === 0n || filled >= totalRequired) return escrowTotal + return (escrowTotal * filled) / totalRequired +} + +/** + * ABI-encodes the GET-request context for a cross-chain cancel initiated from the source chain. + * + * Matches the IntentGatewayV2 `_cancelFromSource` context: `abi.encode(commitment, user, inputs, + * totalRequired)` where `totalRequired[i]` is `order.output.assets[i].amount`. This is the bare + * multi-argument `abi.encode` form (not a tuple-wrapped struct), so `encodeAbiParameters` over the + * four parameter types is the correct match. The destination decodes it as + * `(bytes32, bytes32, TokenInfo[], uint256[])`. + */ +export function encodeCancelFromSourceContext(order: Order): HexString { + return encodeAbiParameters( + [ + { name: "commitment", type: "bytes32" }, + { name: "user", type: "bytes32" }, + { + name: "inputs", + type: "tuple[]", + components: [ + { name: "token", type: "bytes32" }, + { name: "amount", type: "uint256" }, + ], + }, + { name: "totalRequired", type: "uint256[]" }, + ], + [ + order.id as HexString, + normalizeAddressForEvmBytes32(order.user), + order.inputs.map((input) => ({ + token: normalizeAddressForEvmBytes32(input.token), + amount: input.amount, + })), + order.output.assets.map((asset) => asset.amount), + ], + ) as HexString +} + function constructEscrowRequestBody(kind: RequestKind, order: Order | Order, beneficiary: HexString): HexString { const requestKind = encodePacked(["uint8"], [kind]) return concatHex([requestKind, encodeWithdrawalRequest(order, beneficiary)]) as HexString diff --git a/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts b/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts index 89d7f76a1..6890245b3 100644 --- a/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts +++ b/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts @@ -1335,6 +1335,12 @@ export const INTENT_GATEWAY_V2_ABI = [ indexed: true, internalType: "bytes32", }, + { + name: "solver", + type: "address", + indexed: false, + internalType: "address", + }, { name: "tokens", type: "tuple[]", diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 299ad130d..dd559e7cc 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -730,7 +730,7 @@ export class IntentFiller { private enqueueRetraction(commitment: HexString): void { this.retractionQueue.add(async () => { try { - this.logger.info({ commitment }, "Retracting bid after on-chain OrderFilled") + this.logger.info({ commitment }, "Retracting bid after on-chain fill") const coprocessor = await this.hyperbridge! const result = await coprocessor.retractBid(commitment) diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 4878a5da0..d9c91c59c 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -16,6 +16,7 @@ import { type ERC7821Call, transformOrderForContract, TokenInfo, + normalizeAddressForEvmBytes32, } from "@hyperbridge/sdk" import { ERC20_ABI } from "@/config/abis/ERC20" import { ChainClientManager } from "./ChainClientManager" @@ -232,6 +233,36 @@ export class ContractInteractionService { } } + /** + * Reads cumulative filled amounts from the destination `_partialFills` mapping, + * one entry per `order.output.assets` leg. + */ + async getPartialFills(order: Order): Promise { + const client = this.clientManager.getPublicClient(order.destination) + const intentGatewayAddress = this.configService.getIntentGatewayAddress(order.destination) + const commitment = (order.id ?? orderCommitment(order)) as HexString + + return Promise.all( + order.output.assets.map( + (asset) => + retryPromise( + () => + client.readContract({ + abi: INTENT_GATEWAY_V2_ABI, + address: intentGatewayAddress, + functionName: "_partialFills", + args: [commitment, normalizeAddressForEvmBytes32(asset.token)], + }), + { + maxRetries: 3, + backoffMs: 250, + logMessage: "Failed to read partial fill progress", + }, + ) as Promise, + ), + ) + } + /** * Gets the fee token address and decimals for a given chain. * diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 5e627f8f1..ce872186e 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -9,6 +9,7 @@ import { TokenInfo, IntentsCoprocessor, ADDRESS_ZERO, + cumulativeReleased, } from "@hyperbridge/sdk" import { ChainClientManager, ContractInteractionService } from "@/services" import { FillerConfigService } from "@/services/FillerConfigService" @@ -306,6 +307,12 @@ export class FXFiller implements FillerStrategy { * * Note: we may intentionally overfill relative to the user's requested * outputs if the price policy makes that attractive. This is how we stay competitive. + * + * Cross-chain orders may already be partially filled by other solvers; each leg is + * sized against its unfilled remainder. The contract forwards the fee pot to the + * completing solver only, so a completing fill is priced on fee profit as before, + * while a non-completing slice books no fees and is only worth doing when its FX + * margin covers the execution cost. */ async calculateProfitability(order: Order): Promise { if (this.halted) { @@ -353,17 +360,31 @@ export class FXFiller implements FillerStrategy { return 0 } + const isCrossChain = sourceChain !== destChain + const alreadyFilled = isCrossChain + ? await this.contractService.getPartialFills(order) + : order.output.assets.map(() => 0n) + + if (isCrossChain && order.output.assets.every((asset, i) => alreadyFilled[i] >= asset.amount)) { + this.logger.info({ orderId: order.id }, "Order already fully filled, skipping") + return 0 + } + // Compute bid and ask prices at the capped order size once, then pick per leg. // - askPrice: used when filler sells exotic (stable->exotic). Lower rate = fewer exotic sent. // - bidPrice: used when filler buys exotic (exotic->stable). Higher rate = fewer USD paid out. const policyBidPrice = this.bidPricePolicy.getPrice(cappedOrderUsd) const policyAskPrice = this.askPricePolicy.getPrice(cappedOrderUsd) + // One entry per order leg, in leg order. The contract requires `options.outputs` + // to align 1:1 with `order.output.assets` by index and token, so legs the filler + // won't provide (insufficient balance, exhausted budget, already filled) are + // recorded with a zero amount rather than dropped. const fillerOutputs: TokenInfo[] = [] - // Original leg index for each entry in `fillerOutputs`. Legs can be skipped - // (insufficient balance, exhausted budget), so `fillerOutputs[k]` is the k-th - // *surviving* leg, not the k-th leg. The valuation pass below realigns to the - // original input/pair via this array rather than by position. const fillerOutputLegs: number[] = [] + const skipLeg = (token: HexString, legIndex: number) => { + fillerOutputs.push({ token, amount: 0n }) + fillerOutputLegs.push(legIndex) + } let remainingUsd = cappedOrderUsd const fundingCalls: ERC7821Call[] = [] @@ -398,6 +419,15 @@ export class FXFiller implements FillerStrategy { const output = order.output.assets[i] const pair = pairs[i] + const remaining = output.amount > alreadyFilled[i] ? output.amount - alreadyFilled[i] : 0n + if (remaining === 0n) { + skipLeg(output.token, i) + continue + } + + // The escrow share our fill can still earn on this leg + const remainingInput = input.amount - cumulativeReleased(input.amount, alreadyFilled[i], output.amount) + const inputDecimals = await this.contractService.getTokenDecimals( bytes32ToBytes20(input.token) as HexString, sourceChain, @@ -415,7 +445,7 @@ export class FXFiller implements FillerStrategy { const askPrice = venuePrice?.ask ?? policyAskPrice const legResult = this.computeLegPolicyOutput( - input.amount, + remainingInput, pair.inputIsStable, stableDecimals, exoticTokenDecimals, @@ -424,6 +454,7 @@ export class FXFiller implements FillerStrategy { ) if (!legResult) { + skipLeg(output.token, i) continue } @@ -468,10 +499,14 @@ export class FXFiller implements FillerStrategy { } const usableWallet = balance > reserve ? balance - reserve : 0n - const walletContribution = policyMaxOutput < usableWallet ? policyMaxOutput : usableWallet + // Overfilling a started leg buys no competitiveness: escrow release is + // capped at the total required, so fund no more than the remainder. + const legTarget = alreadyFilled[i] > 0n && policyMaxOutput > remaining ? remaining : policyMaxOutput + + const walletContribution = legTarget < usableWallet ? legTarget : usableWallet let credited = 0n - let needed = policyMaxOutput - walletContribution + let needed = legTarget - walletContribution for (const venue of this.fundingVenues) { if (needed <= 0n) break const planned = await venue.planWithdrawalForToken(destChain, walletAddress, tokenAddress, needed, deadlineTimestamp) @@ -484,7 +519,7 @@ export class FXFiller implements FillerStrategy { const effectiveBalance = walletContribution + credited - const finalOutputAmount = effectiveBalance > policyMaxOutput ? policyMaxOutput : effectiveBalance + const finalOutputAmount = effectiveBalance > legTarget ? legTarget : effectiveBalance if (finalOutputAmount === 0n) { this.logger.info( @@ -495,31 +530,19 @@ export class FXFiller implements FillerStrategy { }, "Skipping leg: no available balance for required output token", ) + skipLeg(output.token, i) continue } - if (policyMaxOutput < output.amount) { + if (policyMaxOutput < remaining) { this.logger.info( { orderId: order.id, token: output.token, policyOutput: policyMaxOutput.toString(), - userRequested: output.amount.toString(), + unfilledRemainder: remaining.toString(), }, - "Skipping order: filler price yields less than user's requested amount", - ) - return 0 - } - - if (sourceChain !== destChain && finalOutputAmount < output.amount) { - this.logger.info( - { - orderId: order.id, - token: output.token, - fillerBalance: balance.toString(), - userRequested: output.amount.toString(), - }, - "Skipping cross-chain order: insufficient balance for full fill", + "Skipping order: filler price yields less than the unfilled remainder", ) return 0 } @@ -532,13 +555,9 @@ export class FXFiller implements FillerStrategy { fillerOutputs.push({ token: output.token, amount: finalOutputAmount }) fillerOutputLegs.push(i) - - if (remainingUsd.lte(0)) { - break - } } - if (fillerOutputs.length === 0) { + if (fillerOutputs.every((o) => o.amount === 0n)) { this.logger.info( { orderId: order.id, @@ -551,6 +570,12 @@ export class FXFiller implements FillerStrategy { return 0 } + // `fillerOutputs` is per-leg aligned, so index i is leg i. + const willComplete = order.output.assets.every((asset, i) => { + const legRemaining = asset.amount > alreadyFilled[i] ? asset.amount - alreadyFilled[i] : 0n + return fillerOutputs[i].amount >= legRemaining + }) + this.contractService.cacheService.setFillerOutputs(order.id!, fillerOutputs) if (order.id) { @@ -561,18 +586,24 @@ export class FXFiller implements FillerStrategy { } } - // Realized FX margin, report-only — never rejects an order. A single fill is half a + // Realized FX margin. Report-only for completing fills; for a non-completing slice + // it is the only bookable profit and gates the fill below. A single fill is half a // round-trip, so the open leg is marked at the opposite side of the spread: // - sells exotic (stable→exotic): value the exotic given at bid (rebuy cost). // - buys exotic (exotic→stable): value the exotic received at ask (resale value). - // Positive by construction when bid ≥ ask. `fillerOutputs[i]` is the i-th *surviving* - // leg; realign to its original input/pair via `fillerOutputLegs`. + // Positive by construction when bid ≥ ask. Each leg only receives the + // input-escrow slice its fill releases, not the full input. let fxMarginUsd = new Decimal(0) for (let i = 0; i < fillerOutputs.length; i++) { const legIndex = fillerOutputLegs[i] const input = order.inputs[legIndex] const output = fillerOutputs[i] const pair = pairs[legIndex] + const totalRequired = order.output.assets[legIndex].amount + + const released = + cumulativeReleased(input.amount, alreadyFilled[legIndex] + output.amount, totalRequired) - + cumulativeReleased(input.amount, alreadyFilled[legIndex], totalRequired) const inputDecimals = await this.contractService.getTokenDecimals( bytes32ToBytes20(input.token) as HexString, @@ -591,12 +622,12 @@ export class FXFiller implements FillerStrategy { if (pair.inputIsStable) { // Sells exotic: receives stable, gives exotic valued at bid (rebuy cost). - const inputUsd = new Decimal(formatUnits(input.amount, stableDecimals)) + const inputUsd = new Decimal(formatUnits(released, stableDecimals)) const outputExotic = new Decimal(formatUnits(output.amount, exoticDecimalsLeg)) fxMarginUsd = fxMarginUsd.plus(inputUsd.minus(outputExotic.div(bidPrice))) } else { // Buys exotic: gives stable, receives exotic valued at ask (resale value). - const inputExotic = new Decimal(formatUnits(input.amount, exoticDecimalsLeg)) + const inputExotic = new Decimal(formatUnits(released, exoticDecimalsLeg)) const outputUsd = new Decimal(formatUnits(output.amount, stableDecimals)) fxMarginUsd = fxMarginUsd.plus(inputExotic.div(askPrice).minus(outputUsd)) } @@ -607,22 +638,30 @@ export class FXFiller implements FillerStrategy { this.recordOrderOutcome(false, order.id) const { totalCostInSourceFeeToken } = await this.contractService.estimateGasFillPost(order) - // Reject only when the user's attached fees can't cover what we expect to spend on the fill. - if (order.fees < totalCostInSourceFeeToken) { - this.logger.info( - { - orderId: order.id, - orderFees: formatUnits(order.fees, feeTokenDecimals), - estimatedCost: formatUnits(totalCostInSourceFeeToken, feeTokenDecimals), - }, - "Skipping order: attached fees do not cover estimated execution cost", - ) - return 0 + + // The fee pot is forwarded to the solver whose fill completes the order. A completing + // fill is gated on fee profit only (fxMarginUsd stays report-only there); a + // non-completing slice books no fees and must clear execution costs from its FX margin. + let feeProfit = 0n + let totalProfit: number + if (willComplete) { + if (order.fees < totalCostInSourceFeeToken) { + this.logger.info( + { + orderId: order.id, + orderFees: formatUnits(order.fees, feeTokenDecimals), + estimatedCost: formatUnits(totalCostInSourceFeeToken, feeTokenDecimals), + }, + "Skipping order: attached fees do not cover estimated execution cost", + ) + return 0 + } + feeProfit = order.fees - totalCostInSourceFeeToken + totalProfit = parseFloat(formatUnits(feeProfit, feeTokenDecimals)) + } else { + const costUsd = parseFloat(formatUnits(totalCostInSourceFeeToken, feeTokenDecimals)) + totalProfit = fxMarginUsd.toNumber() - costUsd } - const feeProfit = order.fees - totalCostInSourceFeeToken - // FX bids are gated on fee profit only. fxMarginUsd is a theoretical mark-to-model - // value (open leg priced at the opposite curve) and is reported separately, never summed in. - const totalProfit = parseFloat(formatUnits(feeProfit, feeTokenDecimals)) this.logger.info( { @@ -639,6 +678,7 @@ export class FXFiller implements FillerStrategy { estimatedFees: formatUnits(totalCostInSourceFeeToken, feeTokenDecimals), feeProfit: formatUnits(feeProfit, feeTokenDecimals), fxMarginUsd: fxMarginUsd.toString(), + willComplete, totalProfit, profitable: totalProfit > 0, }, diff --git a/sdk/packages/simplex/src/strategies/stable.ts b/sdk/packages/simplex/src/strategies/stable.ts index 44b156271..1d04ac804 100644 --- a/sdk/packages/simplex/src/strategies/stable.ts +++ b/sdk/packages/simplex/src/strategies/stable.ts @@ -9,6 +9,7 @@ import { ADDRESS_ZERO, TokenInfo, adjustDecimals, + cumulativeReleased, IntentsCoprocessor, type ERC7821Call, } from "@hyperbridge/sdk" @@ -142,8 +143,15 @@ export class StableFiller implements FillerStrategy { * what will the filler receive and what will the filler pay. * Also validates that the order output amounts meet the filler's minimum requirements * based on the configured bps (basis points) curve. + * + * Cross-chain orders may already be partially filled by other solvers, and this filler + * may itself provide only a slice when its balance cannot cover the full remainder. The + * spread is priced on the proportional input-escrow slice the fill releases, and + * `order.fees` is credited only when this fill completes the order, since the contract + * forwards the fee pot to the completing solver. + * * @param order The order to calculate the USD value for - * @returns The profit in USD (Number), or 0 if not profitable or output amounts don't meet minimum + * @returns The profit in USD (Number), or <= 0 if not profitable or output amounts don't meet minimum */ async calculateProfitability(order: Order): Promise { try { @@ -155,13 +163,19 @@ export class StableFiller implements FillerStrategy { const inputUsdValue = await this.contractService.getInputUsdValue(order) const fillerBps = this.bpsPolicy.getBps(inputUsdValue) - // Validate that order outputs meet filler's minimum bps requirements - // and calculate profit from slippage (normalized to dest fee token decimals) - const { isValid, profitFromSlippage } = await this.calculateSlippageProfit( - order, - destFeeTokenDecimals, - fillerBps, - ) + const isCrossChain = order.source !== order.destination + const alreadyFilled = isCrossChain + ? await this.contractService.getPartialFills(order) + : order.output.assets.map(() => 0n) + + if (isCrossChain && order.output.assets.every((asset, i) => alreadyFilled[i] >= asset.amount)) { + this.logger.info({ orderId: order.id }, "Order already fully filled, skipping") + return 0 + } + + // Validate that order outputs meet filler's minimum bps requirements and size + // competitive outputs, capped to the unfilled remainder per leg + const { isValid } = await this.sizeFillerOutputs(order, fillerBps, alreadyFilled) if (!isValid) { this.logger.info( { orderId: order.id, orderValueUsd: inputUsdValue.toString(), fillerBps: fillerBps.toString() }, @@ -170,14 +184,21 @@ export class StableFiller implements FillerStrategy { return 0 } - // Source output-token shortfalls from funding venues (e.g. ERC-4626 vaults). - // Runs before gas estimation so the prepend gas bump is accounted for. - const fillerOutputs = this.contractService.cacheService.getFillerOutputs(order.id!) - if (fillerOutputs && this.fundingVenues.length > 0) { - const fundable = await this.planFunding(order, fillerOutputs) - if (!fundable) return 0 - this.contractService.cacheService.setFillerOutputs(order.id!, fillerOutputs) - } + // Source output-token shortfalls from the wallet and funding venues (e.g. ERC-4626 + // vaults). Cross-chain legs may be reduced to a partial slice. Runs before gas + // estimation so the prepend gas bump is accounted for. + const fillerOutputs = this.contractService.cacheService.getFillerOutputs(order.id!)! + const fundable = await this.planFunding(order, fillerOutputs, alreadyFilled) + if (!fundable) return 0 + this.contractService.cacheService.setFillerOutputs(order.id!, fillerOutputs) + + // Priced after funding since planFunding may have reduced the outputs + const profitFromSlippage = await this.spreadProfit( + order, + fillerOutputs, + alreadyFilled, + destFeeTokenDecimals, + ) const { totalCostInSourceFeeToken } = await this.contractService.estimateGasFillPost(order) @@ -185,20 +206,30 @@ export class StableFiller implements FillerStrategy { order.source, ) - // Profit from fees: order.fees - gas costs - const feeProfit = order.fees > totalCostInSourceFeeToken ? order.fees - totalCostInSourceFeeToken : 0n + const willComplete = order.output.assets.every( + (asset, i) => alreadyFilled[i] + fillerOutputs[i].amount >= asset.amount, + ) + + // Cross-chain, the fee pot goes to the completing solver only, so a non-completing + // slice must clear its costs from spread alone (netFee may go negative) + const netFee = isCrossChain + ? (willComplete ? order.fees : 0n) - totalCostInSourceFeeToken + : order.fees > totalCostInSourceFeeToken + ? order.fees - totalCostInSourceFeeToken + : 0n - // Total profit = fee profit + profit from slippage (both normalized to dest fee token decimals) - const feeProfitInDestDecimals = adjustDecimals(feeProfit, sourceFeeTokenDecimals, destFeeTokenDecimals) - const totalProfit = feeProfitInDestDecimals + profitFromSlippage + // Total profit = net fee + profit from slippage (both normalized to dest fee token decimals) + const netFeeInDestDecimals = adjustDecimals(netFee, sourceFeeTokenDecimals, destFeeTokenDecimals) + const totalProfit = netFeeInDestDecimals + profitFromSlippage this.logger.info( { orderFeesUSD: formatUnits(order.fees, sourceFeeTokenDecimals), totalCostInSourceFeeTokenUSD: formatUnits(totalCostInSourceFeeToken, sourceFeeTokenDecimals), - feeProfitUSD: formatUnits(feeProfitInDestDecimals, destFeeTokenDecimals), + netFeeUSD: formatUnits(netFeeInDestDecimals, destFeeTokenDecimals), slippageProfitUSD: formatUnits(profitFromSlippage, destFeeTokenDecimals), totalProfitUSD: formatUnits(totalProfit, destFeeTokenDecimals), + willComplete, profitable: totalProfit > 0n, }, "Profitability evaluation", @@ -212,33 +243,34 @@ export class StableFiller implements FillerStrategy { /** * Validates that the filler can meet the user's minimum output requirements - * based on the configured bps (basis points), and calculates the profit from slippage. - * Also caches the filler's calculated outputs for use during order execution. + * based on the configured bps (basis points), and caches the filler's + * competitive outputs for use during order execution. * * The logic: * - User sends X tokens and expects minimum Y tokens (order.output.amount) * - Filler calculates max they will provide: X * (10000 - fillerBps) / 10000 - * - If filler can provide >= user's minimum → valid, proceed + * - If filler can provide >= user's minimum, the order is valid * - Filler pays out their calculated max (to be competitive) - * - Profit = X - fillerMaxOutput (filler keeps their bps as profit) * * Example: User sends 100 USDC, expects minimum 99.4 USDC, filler has 50 bps (0.5%) * - Filler will provide: 100 * (10000 - 50) / 10000 = 99.5 USDC - * - User expects 99.4 USDC, filler provides 99.5 → valid (99.5 >= 99.4) - * - Profit = 100 - 99.5 = 0.5 USDC (filler receives 100, pays out 99.5) + * - User expects 99.4 USDC, filler provides 99.5, so the order is valid (99.5 >= 99.4) + * + * On a partially-filled order each leg is capped to its unfilled remainder: + * escrow release is capped at the total required, so overfilling a started leg + * buys no competitiveness. * * @param order The order to validate (assumed to have passed canFill validation) - * @param normalizeToDecimals The decimal precision to normalize the profit to (e.g., dest fee token decimals) * @param fillerBps The basis points to use for this order (determined by order value) - * @returns Object with isValid boolean and profitFromSlippage (normalized to specified decimals) + * @param alreadyFilled Cumulative filled amount per output leg + * @returns Object with isValid boolean */ - private async calculateSlippageProfit( + private async sizeFillerOutputs( order: Order, - normalizeToDecimals: number, fillerBps: bigint, - ): Promise<{ isValid: boolean; profitFromSlippage: bigint }> { + alreadyFilled: bigint[], + ): Promise<{ isValid: boolean }> { const basisPoints = 10000n - let totalProfitNormalized = 0n const fillerOutputs: TokenInfo[] = [] for (let i = 0; i < order.inputs.length; i++) { @@ -272,7 +304,7 @@ export class StableFiller implements FillerStrategy { }, "User expects more than filler can provide based on bps", ) - return { isValid: false, profitFromSlippage: 0n } + return { isValid: false } } // Clamp to at most (1 + maxOverfillBps) × user-requested to bound loss on pricing errors. @@ -293,19 +325,16 @@ export class StableFiller implements FillerStrategy { fillerMaxOutput = overfillCeiling } + const remaining = output.amount > alreadyFilled[i] ? output.amount - alreadyFilled[i] : 0n + if (alreadyFilled[i] > 0n && fillerMaxOutput > remaining) { + fillerMaxOutput = remaining + } + // Store the filler's calculated output for this token fillerOutputs.push({ token: output.token, amount: fillerMaxOutput, }) - - // Calculate profit: filler receives input, pays out their max (to be competitive) - // Profit = input - fillerMaxOutput (filler keeps their bps as profit) - const profitInOutputDecimals = convertedInputAmount - fillerMaxOutput - - // Normalize profit to the target decimals for summing across different tokens - const profitNormalized = adjustDecimals(profitInOutputDecimals, outputDecimals, normalizeToDecimals) - totalProfitNormalized += profitNormalized } // Cache filler outputs for use during order execution @@ -318,7 +347,49 @@ export class StableFiller implements FillerStrategy { "Cached filler outputs for order", ) - return { isValid: true, profitFromSlippage: totalProfitNormalized } + return { isValid: true } + } + + /** + * Spread profit for the planned outputs, normalized to `normalizeToDecimals`. + * + * Each leg receives the input-escrow slice released for taking the fill from + * `alreadyFilled` to `alreadyFilled + provided` (the completing slice picks up + * the integer-division dust), and pays out `provided`. On a full fill of an + * untouched order this reduces to input minus output. + */ + private async spreadProfit( + order: Order, + fillerOutputs: TokenInfo[], + alreadyFilled: bigint[], + normalizeToDecimals: number, + ): Promise { + let totalProfitNormalized = 0n + + for (let i = 0; i < order.inputs.length; i++) { + const input = order.inputs[i] + const output = order.output.assets[i] + const provided = fillerOutputs[i].amount + if (provided === 0n) continue + + const [inputDecimals, outputDecimals] = await Promise.all([ + this.contractService.getTokenDecimals(input.token, order.source), + this.contractService.getTokenDecimals(output.token, order.destination), + ]) + + const released = + cumulativeReleased(input.amount, alreadyFilled[i] + provided, output.amount) - + cumulativeReleased(input.amount, alreadyFilled[i], output.amount) + + const releasedInOutputDecimals = adjustDecimals(released, inputDecimals, outputDecimals) + totalProfitNormalized += adjustDecimals( + releasedInOutputDecimals - provided, + outputDecimals, + normalizeToDecimals, + ) + } + + return totalProfitNormalized } /** @@ -354,17 +425,23 @@ export class StableFiller implements FillerStrategy { } /** - * Sources each output token from the solver's wallet, topping up shortfalls - * via funding venues (e.g. an ERC-4626 vault `withdraw`). When a venue can only - * partially cover the deficit, the competitive output is reduced to the - * coverable amount — never below the user's requested minimum. The venue + * Sources each output token from the solver's wallet (above the venue reserve + * floor), topping up shortfalls via funding venues (e.g. an ERC-4626 vault + * `withdraw`). Without venues the wallet balance alone is the budget, so the + * fill is never sized beyond what the solver can actually transfer. When the + * sourceable amount falls short, the competitive output is reduced to the + * coverable amount. Same-chain legs are never reduced below the user's requested + * minimum; cross-chain legs may be reduced to a partial slice of the remainder, + * with profitability deciding whether the slice is worth filling. The venue * withdrawal calls are recorded as ERC-7821 prepends so they execute atomically * before `fillOrder` in the same batch. * - * Mutates `fillerOutputs` in place. Returns false when an output cannot be - * sourced down to the user's minimum, signalling the order should be skipped. + * Mutates `fillerOutputs` in place. Returns false when nothing can be sourced — + * a same-chain output below the user's minimum, or every cross-chain leg reduced + * to zero — signalling the order should be skipped. */ - private async planFunding(order: Order, fillerOutputs: TokenInfo[]): Promise { + private async planFunding(order: Order, fillerOutputs: TokenInfo[], alreadyFilled: bigint[]): Promise { + const isCrossChain = order.source !== order.destination const destClient = this.clientManager.getPublicClient(order.destination) const solver = this.signer.account.address as HexString const balanceCache = new Map() @@ -378,6 +455,9 @@ export class StableFiller implements FillerStrategy { // Native outputs can't be sourced from token venues. if (tokenLower === ADDRESS_ZERO.toLowerCase()) continue + // Leg already fully filled by other solvers + if (out.amount === 0n) continue + const walletBalance = await this.getAndCacheBalance(tokenLower, solver, destClient, balanceCache) // Wallet floor reserved for gas/paymaster (the vault minBalance) — never filled from. @@ -406,7 +486,7 @@ export class StableFiller implements FillerStrategy { const available = walletContribution + credited const effectiveOutput = out.amount < available ? out.amount : available - if (effectiveOutput < userMin) { + if (!isCrossChain && effectiveOutput < userMin) { this.logger.info( { orderId: order.id, @@ -425,6 +505,7 @@ export class StableFiller implements FillerStrategy { { orderId: order.id, token: out.token, + alreadyFilled: alreadyFilled[i].toString(), competitive: out.amount.toString(), coverable: effectiveOutput.toString(), }, @@ -437,6 +518,12 @@ export class StableFiller implements FillerStrategy { balanceCache.set(tokenLower, walletBalance - walletContribution) } + if (isCrossChain && fillerOutputs.every((o) => o.amount === 0n)) { + this.logger.info({ orderId: order.id }, "Skipping order: no output can be sourced") + this.contractService.cacheService.clearFundingPrepends(order.id!) + return false + } + if (fundingCalls.length > 0) { this.contractService.cacheService.setFundingPrepends(order.id!, fundingCalls) } else {