Skip to content

[evm]: cross-chain partial fills for IntentGatewayV2#980

Open
seunlanlege wants to merge 10 commits into
mainfrom
feat/crosschain-partial-fills
Open

[evm]: cross-chain partial fills for IntentGatewayV2#980
seunlanlege wants to merge 10 commits into
mainfrom
feat/crosschain-partial-fills

Conversation

@seunlanlege

Copy link
Copy Markdown
Member

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 _filled on partial / keeps it on completion, and dispatches RedeemEscrowPartial (non-finalizing) or RedeemEscrow (finalizing). Output-calldata orders still require single-fill completion (PartialFillNotAllowed).
  • onAccept — new RedeemEscrowPartial kind releases a proportional slice without finalizing (escrow stays open, fee pot left for the completing redeem). The completing RedeemEscrow finalizes and forwards the fee pot (completing-solver-takes-all).
  • _cancelFromSource / onGetResponse — proves each output's _partialFills on 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 _partialFills locally 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). EscrowReleased now fires for every source-side release (including partial redeems) and carries the solver. placeOrder now enforces inputs.length == outputs.length and rejects zero-amount outputs (both would otherwise strand escrow).

Testing

forge test --match-contract IntentGatewayV2 and 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 _partialFills storage-slot guard.

Follow-ups (out of scope)

  • TS SDK: handle RedeemEscrowPartial + cross-chain PartialFill, construct partial cross-chain fills.
  • Substrate intents-coprocessor partial support.
  • Independent/cloud review recommended before mainnet (cross-chain fund movement).

@seunlanlege

Copy link
Copy Markdown
Member Author

Would be cool to add simplex, sdk & indexer support to this PR as well

@royvardhan
royvardhan marked this pull request as ready for review July 3, 2026 08:02
@Wizdave97

Copy link
Copy Markdown
Member

Fix: guard RefundEscrow against an already-finalized commitment
Issue

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.
Deadline passes. Order creator cancels on source; any account cancels on destination.
The GET response lands first: refunds the unfilled 50%, sets _filled, correctly leaves 50% reserved for the pending redeem.
The authenticated RefundEscrow lands: no _filled check, so it refunds the same 50% again and zeroes the escrow.
The earlier RedeemEscrowPartial reverts UnknownOrder.
Net: the order creator recovers 100% of the input and keeps 50% of the output; the solver has irrevocably transferred output tokens and receives nothing. The reverse delivery order is already safe — onGetResponse sees _filled and reverts.

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)) {
revert Filled();
}
The guard is deliberately scoped to RefundEscrow. A blanket _filled check on onAccept would incorrectly block valid in-flight RedeemEscrowPartial / RedeemEscrow messages after a source-side cancellation — those are supposed to consume the portion a single cancellation intentionally reserves.

Tests

GET response → RefundEscrow: the second cancellation reverts Filled; escrow reserved for the pending redeem is preserved.
RefundEscrow → GET response: unchanged, already-correct behavior (regression guard).
A delayed RedeemEscrowPartial still successfully consumes the reserved portion after a single cancellation — confirms the guard didn't over-restrict.
The existing testCrossChainCancel_DoubleCancelBlocked only exercises onGetResponse twice and does not cover the cross-path case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants