Skip to content

[simplex]: pay a top-up reclaim's relayer fee in the fee token and bound its retry - #1408

Open
seunlanlege wants to merge 4 commits into
mainfrom
fix/simplex-top-up-reclaim-fee-token
Open

seunlanlege wants to merge 4 commits into
mainfrom
fix/simplex-top-up-reclaim-fee-token

Conversation

@seunlanlege

@seunlanlege seunlanlege commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

A cross-chain BRIDGE top-up reclaim could never cancel from a stablecoin-only wallet, and retried forever while the escrow stayed locked.

Why: on every mainnet source the SDK's AWAITING_CANCEL_TRANSACTION carries a non-zero native value (the host swaps it into the relayer fee). BridgeTopUp forwarded it and approved the fee token only when that value was 0. The batch goes out as a sponsored UserOp; with no native balance it reverted, the paymaster still charged gas, no reclaimTxHash was recorded, and drive() re-ran the cancel every 60 s with no limit.

Fix (src/services/BridgeTopUp.ts, src/data/types.ts, ui/src/operator/topUpTimelineModel.ts):

  • Fee in the fee token, always. cancelCalls() decodes relayerFee from the SDK's own cancelOrder calldata, resets the gateway's allowance to 0, approves it for exactly that amount, and calls cancelOrder with value 0. With msg.value == 0, ExtrinsicIntents._cancelFromSource pays through dispatchWithFeeToken, which pulls relayerFee from the caller. Same-chain (BSC) cancels carry no fee and send the cancel alone.
  • Short wallet fails before sending: Not enough USDC on EVM-8453 to pay the cancel's relayer fee: have 0.1, need 0.31. It compares against the fee alone (not the paymaster reserve), so it blocks only a cancel that is certain to revert.
  • Bounded retry. A failed recovery attempt is retried after 1, 2, 4, 8 and 16 min (RECLAIM_RETRY_DELAYS_MS). The sixth consecutive failure (MAX_RECLAIM_ATTEMPTS = 6, about 31 min of backoff) returns the top-up to unfilled with Recovery stopped after 6 failed attempts: <reason>, and Start recovery resumes from the SDK's saved progress. The count restarts on a newly recorded recovery step, on an operator restart, and after an attempt that ran ≥ 10 min (RECLAIM_LONG_ATTEMPT_MS) before failing, so a finality wait that ends in a transient indexer error doesn't stop a healthy recovery. It is in memory, so it also restarts with the process.
  • Stopped-recovery timeline. topUpTimelineModel.ts keeps the steps a stopped recovery reached and marks the one it stopped at as an error: Recovery stopped: <reason> — Start recovery to resume. A recovery that failed before recording any step (for example every destination read timing out) is recognised by its Recovery stopped error and marks its first row instead of showing only Order Expired.
  • No second cancel after Hyperbridge finalizes. The SDK drops its persisted cancellation once HYPERBRIDGE_FINALIZED is yielded, so a retry used to send a second paid cancel. Once that step is recorded, recovery goes straight to the refund claim.
  • Claim rebuild. The SDK reports the claim calldata once, so HYPERBRIDGE_FINALIZED and claimCalldata are saved in one write, and the cancel's GET request is kept as getCommitment (new StoredBridgeTopUp field). The calldata is proven at a fixed Hyperbridge height: a claim that reverts clears claimCalldata, and a missing or cleared one is rebuilt with queryGetRequestWithStatus(getCommitment) (rebuildClaim, 120 s timeout) instead of replaying the old bytes. getCommitment is read back from the cancel's receipt when a record lacks it. When nothing finalized can be rebuilt, the error says either Hyperbridge has not finalized it yet or its proof could not be read, since the SDK swallows proof and RPC errors.
  • Matching the cancel's GET request. A sponsored cancel's receipt is its whole bundle. cancelRequestCommitment accepts only a GetRequestEvent emitted by the source chain's host, from the IntentGateway, whose context starts with the order's commitment (_cancelFromSource encodes it as abi.encode(commitment, user, inputs, totalRequired)). Another op's request, or another order's cancel through the same gateway, is skipped; a receipt with none fails.

Validation

All run from sdk/ or sdk/packages/simplex this round:

  • src/tests/bridge-top-up.test.ts 24 passed, token-sender.test.ts 19, services/UserOpSender.test.ts 6, ui/src/operator 104; 153 passed in total. The new case puts another order's cancel GET first in a bundle and checks ours is picked.
  • pnpm --filter="@hyperbridge/simplex" test:data 146 passed; test:unit 263 passed; test:filler 70 passed, 5 skipped.
  • pnpm --filter="@hyperbridge/simplex" lint: no errors; Biome is clean on BridgeTopUp.ts and bridge-top-up.test.ts.
  • npx tsc --noEmit -p .: the same 14 errors as main (3 of them in the ox dependency), none in changed files.

Not covered: no fork or mainnet run. A source host whose fee token the wallet doesn't hold fails fast with the shortfall message.

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve with minor comments.

I checked the core fix against the contracts and it holds:

  • relayerFee is decoded from cancelOrder with the SDK's own ABI and fails closed: an unknown selector or a missing argument throws before anything is sent.
  • With msg.value == 0, _cancelFromSource → dispatchWithFeeToken pulls exactly relayerFee from the caller into the gateway proxy, and the proxy is the approved spender.
  • EvmHost.dispatch takes no extra per-byte fee, so the exact approval can't come in short.
  • Skipping the cancel once HYPERBRIDGE_FINALIZED is recorded is safe, because that step is only emitted for the GET commitment from the cancel's own receipt.
  1. Once HYPERBRIDGE_FINALIZED is recorded, the claim can never be rebuilt (BridgeTopUp.ts:923, :954, :960).

    • Stale calldata. For an EVM source, the saved claimCalldata is a BatchConsensusAndGetResponse built at a fixed provenHeight. If a relayer moves the source host's Hyperbridge client to a different height during the wait, the consensus part becomes a no-op and handleGetResponses reverts with StateCommitmentNotFound. That costs 5 paid, reverted claims, then the top-up goes to unfilled. Start recovery then replays the same calldata.
    • Lost calldata. The step and claimCalldata are saved in two separate writes. If the process dies between them, claimable is never true after a restart, and the loop polls forever with no error and no cap.
    • Neither is a regression (the old path re-sent a cancel that reverted for this wallet), but this path is now the only way out.
    • Suggestion: save the step and the calldata in one update, and keep the GET commitment from the cancel receipt. When a claim reverts or the calldata is missing, rebuild it with queryGetRequestWithStatus(commitment).
  2. The 5-attempt cap only tolerates about 5 minutes of fast failures, and when it stops a recovery the dashboard shows a fresh expiry (BridgeTopUp.ts:1105, topUpTimelineModel.ts:390).

    • A short Hyperbridge or RPC outage after ORDER_CANCELED, for example during fetchSourceProof or submitAndConfirmReceipt, uses up all 5 attempts at 60 s intervals.
    • The unfilled timeline then shows only "Order Expired … Start recovery". It hides the recorded steps, including a cancel that was already paid.
    • Funds are safe (reclaimTxHash is handed back, so there is no double send), but recovery now waits on the operator.
    • Suggestion: back off between attempts (1/2/4/8/16 min) so the cap covers a real outage. When steps exist, show recoverySteps on unfilled, or add a "recovery stopped" label.

Nit: the mainnet fee tokens don't need it today, but reusing the fill path's approve(0)-first pattern here would make this approval safe for USDT-style tokens.

Cross-PR: a sponsored (UserOp) cancel that times out records no reclaimTxHash, so a retry sends and pays for a second cancel. #1412 lists this as not covered. The cap here limits how often it can happen but doesn't prevent it.

seunlanlege added a commit that referenced this pull request Oct 19, 2026
- Save the HYPERBRIDGE_FINALIZED step and claimCalldata in one update, keep the
  cancel's GET request commitment, and rebuild the refund claim with
  queryGetRequestWithStatus when a claim reverts or its calldata is missing,
  instead of replaying stale bytes or polling forever.
- Back off recovery retries 1, 2, 4, 8 and 16 min (six attempts, about 31 min),
  and show a stopped recovery's steps in the timeline with the reason and how
  to resume.
- Reset the fee-token allowance to 0 before approving the relayer fee, for
  USDT-style tokens.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- Save the HYPERBRIDGE_FINALIZED step and claimCalldata in one update, keep the
  cancel's GET request commitment, and rebuild the refund claim with
  queryGetRequestWithStatus when a claim reverts or its calldata is missing,
  instead of replaying stale bytes or polling forever.
- Back off recovery retries 1, 2, 4, 8 and 16 min (six attempts, about 31 min),
  and show a stopped recovery's steps in the timeline with the reason and how
  to resume.
- Reset the fee-token allowance to 0 before approving the relayer fee, for
  USDT-style tokens.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-top-up-reclaim-fee-token branch 2 times, most recently from 6cb58cc to bb21ad4 Compare October 19, 2026 15:08

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. The round-1 findings are fixed:

  • The recovery step and the claim calldata are saved in one write.
  • A missing or stale claim is rebuilt through queryGetRequestWithStatus.
  • Retries back off over about 31 minutes.
  • A stopped recovery keeps its timeline.

Non-blocking follow-ups:

  • PR description. It still says MAX_RECLAIM_ATTEMPTS (5). It doesn't mention the claim rebuild, the backoff, or the stopped-recovery timeline.
  • Bundle filter for the cancel's GetRequestEvent (BridgeTopUp.ts:1427-1431). It matches on the gateway only, so another sender's cancel through the same gateway, earlier in the same bundle, would be picked instead. Also requiring the first 32 bytes of context to equal record.commitment makes the match exact.
  • Rebuild error message (BridgeTopUp.ts:1098). buildFinalized swallows proof and RPC errors. A Hyperbridge or indexer outage during the rebuild is therefore reported as "Hyperbridge has not finalized a refund response … yet". Wording that covers both cases would avoid misleading the operator.

seunlanlege added a commit that referenced this pull request Oct 20, 2026
…und its retry

A cross-chain reclaim forwarded the SDK's native quote in a sponsored op; a
stablecoin-only wallet reverted, the paymaster charged gas, and drive()
retried every 60 s forever. The cancel now approves the gateway for the
relayerFee decoded from the SDK's calldata and sends no value, fails before
sending when the wallet is short of the fee token, and stops after five
consecutive failures, back at unfilled. A retry after Hyperbridge finalized
the cancellation goes straight to the refund claim instead of cancelling
again.
- Save the HYPERBRIDGE_FINALIZED step and claimCalldata in one update, keep the
  cancel's GET request commitment, and rebuild the refund claim with
  queryGetRequestWithStatus when a claim reverts or its calldata is missing,
  instead of replaying stale bytes or polling forever.
- Back off recovery retries 1, 2, 4, 8 and 16 min (six attempts, about 31 min),
  and show a stopped recovery's steps in the timeline with the reason and how
  to resume.
- Reset the fee-token allowance to 0 before approving the relayer fee, for
  USDT-style tokens.
@seunlanlege
seunlanlege force-pushed the fix/simplex-top-up-reclaim-fee-token branch from 6256a7a to 9a9d879 Compare October 20, 2026 12:35

This branch has not been deployed

No deployments
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.

2 participants