[evm]: cross-chain partial fills for IntentGatewayV2#980
Conversation
|
Would be cool to add simplex, sdk & indexer support to this PR as well |
…al-fills # Conflicts: # evm/src/apps/intentsv2/ExtrinsicIntents.sol # evm/src/apps/intentsv2/IntentsBase.sol # evm/tests/foundry/IntentGatewayV2Test.sol # sdk/packages/simplex/src/strategies/fx.ts # sdk/packages/simplex/src/strategies/stable.ts
|
Fix: guard RefundEscrow against an already-finalized commitment The two cancellation paths can independently refund the same unfilled slice of a partially filled order. onGetResponse (source-side GET cancellation) rejects an already-set _filled[commitment], but the RefundEscrow branch of onAccept (destination-side cancellation) does not. _withdraw then overwrites _filled and debits whatever escrow remains, so the marker provides idempotency within the GET callback but not across the two cancellation message types. Both messages are independently reachable through the public cancelOrder entry point on different chains, each sees its own local _filled[commitment] == address(0), and handler replay protection treats them as distinct valid messages. With a partial fill this is exploitable. At a 50% fill with a RedeemEscrowPartial still in flight: Solver fills 50%, beneficiary receives the output, destination dispatches RedeemEscrowPartial for 50% of the input. This is not reachable on main, which has no partial-fill settlement: a single refund zeroes the escrow, so any second refund reverts UnknownOrder. The bug is introduced by the reserved-remainder semantics this branch adds. Fix Reject RefundEscrow on the source chain when a prior finalizing redeem or cancellation has already set the commitment marker: if (kind == RequestKind.RefundEscrow && _filled[body.commitment] != address(0)) { Tests GET response → RefundEscrow: the second cancellation reverts Filled; escrow reserved for the pending redeem is preserved. |
Summary
Adds partial fills for cross-chain orders to
IntentGatewayV2. Previously cross-chain fills were all-or-nothing; this lets multiple solvers each fill a slice of a cross-chain order, mirroring the existing same-chain partial-fill behaviour.Design
The core invariant: every escrow movement uses a single monotonic
_cumulativeReleased(escrowTotal, filled, totalRequired)function, so across any split — and regardless of cross-chain message arrival order — the redeemed slices plus any cancel refund sum to exactly the escrowed amount, with rounding dust deterministically landing in the completing fill._fillCrossChain— partial-aware: accumulates_partialFills, pays the beneficiary pro-rata, clears_filledon partial / keeps it on completion, and dispatchesRedeemEscrowPartial(non-finalizing) orRedeemEscrow(finalizing). Output-calldata orders still require single-fill completion (PartialFillNotAllowed).onAccept— newRedeemEscrowPartialkind releases a proportional slice without finalizing (escrow stays open, fee pot left for the completing redeem). The completingRedeemEscrowfinalizes and forwards the fee pot (completing-solver-takes-all)._cancelFromSource/onGetResponse— proves each output's_partialFillson the destination via a GET request and refunds only the proven-unredeemed fraction, never the raw remaining escrow, so in-flight redeems stay covered. The deadline gate makes the post-deadline proof a final snapshot._cancelFromDest— reads_partialFillslocally and refunds the unredeemed fraction (no proof needed; freeze + snapshot are atomic).Escrow proofs match values to inputs by storage key (GET responses come back sorted by key, not request order).
EscrowReleasednow fires for every source-side release (including partial redeems) and carries the solver.placeOrdernow enforcesinputs.length == outputs.lengthand rejects zero-amount outputs (both would otherwise strand escrow).Testing
forge test --match-contract IntentGatewayV2and the reentrancy suite — 125 tests pass. New tests cover: proportional release + two-solver completion, non-finalizing source redeem, cancel refunding the unredeemed fraction, in-flight-redeem-after-cancel consistency, the fully-filled-cancel race (refund 0, fee pot withheld for the completing solver), multi-token value-by-key matching, double-cancel idempotency, the calldata guard, the placeOrder validations, and a_partialFillsstorage-slot guard.Follow-ups (out of scope)
RedeemEscrowPartial+ cross-chainPartialFill, construct partial cross-chain fills.intents-coprocessorpartial support.