Repository navigation
[simplex]: pay a top-up reclaim's relayer fee in the fee token and bound its retry - #1408
seunlanlege wants to merge 4 commits into
Conversation
Wizdave97
left a comment
There was a problem hiding this comment.
Verdict: approve with minor comments.
I checked the core fix against the contracts and it holds:
relayerFeeis decoded fromcancelOrderwith 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→dispatchWithFeeTokenpulls exactlyrelayerFeefrom the caller into the gateway proxy, and the proxy is the approved spender. EvmHost.dispatchtakes no extra per-byte fee, so the exact approval can't come in short.- Skipping the cancel once
HYPERBRIDGE_FINALIZEDis recorded is safe, because that step is only emitted for the GET commitment from the cancel's own receipt.
-
Once
HYPERBRIDGE_FINALIZEDis recorded, the claim can never be rebuilt (BridgeTopUp.ts:923, :954, :960).- Stale calldata. For an EVM source, the saved
claimCalldatais aBatchConsensusAndGetResponsebuilt at a fixedprovenHeight. If a relayer moves the source host's Hyperbridge client to a different height during the wait, the consensus part becomes a no-op andhandleGetResponsesreverts withStateCommitmentNotFound. That costs 5 paid, reverted claims, then the top-up goes tounfilled. Start recovery then replays the same calldata. - Lost calldata. The step and
claimCalldataare saved in two separate writes. If the process dies between them,claimableis 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 withqueryGetRequestWithStatus(commitment).
- Stale calldata. For an EVM source, the saved
-
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 duringfetchSourceProoforsubmitAndConfirmReceipt, uses up all 5 attempts at 60 s intervals. - The
unfilledtimeline then shows only "Order Expired … Start recovery". It hides the recorded steps, including a cancel that was already paid. - Funds are safe (
reclaimTxHashis 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
recoveryStepsonunfilled, or add a "recovery stopped" label.
- A short Hyperbridge or RPC outage after
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.
- 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.
- 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.
6cb58cc to
bb21ad4
Compare
Wizdave97
left a comment
There was a problem hiding this comment.
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 ofcontextto equalrecord.commitmentmakes the match exact. - Rebuild error message (BridgeTopUp.ts:1098).
buildFinalizedswallows 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.
…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.
6256a7a to
9a9d879
Compare
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_TRANSACTIONcarries a non-zero nativevalue(the host swaps it into the relayer fee).BridgeTopUpforwarded 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, noreclaimTxHashwas recorded, anddrive()re-ran the cancel every 60 s with no limit.Fix (
src/services/BridgeTopUp.ts,src/data/types.ts,ui/src/operator/topUpTimelineModel.ts):cancelCalls()decodesrelayerFeefrom the SDK's owncancelOrdercalldata, resets the gateway's allowance to 0, approves it for exactly that amount, and callscancelOrderwith value 0. Withmsg.value == 0,ExtrinsicIntents._cancelFromSourcepays throughdispatchWithFeeToken, which pullsrelayerFeefrom the caller. Same-chain (BSC) cancels carry no fee and send the cancel alone.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.RECLAIM_RETRY_DELAYS_MS). The sixth consecutive failure (MAX_RECLAIM_ATTEMPTS = 6, about 31 min of backoff) returns the top-up tounfilledwithRecovery 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.topUpTimelineModel.tskeeps 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 itsRecovery stoppederror and marks its first row instead of showing onlyOrder Expired.HYPERBRIDGE_FINALIZEDis yielded, so a retry used to send a second paid cancel. Once that step is recorded, recovery goes straight to the refund claim.HYPERBRIDGE_FINALIZEDandclaimCalldataare saved in one write, and the cancel's GET request is kept asgetCommitment(newStoredBridgeTopUpfield). The calldata is proven at a fixed Hyperbridge height: a claim that reverts clearsclaimCalldata, and a missing or cleared one is rebuilt withqueryGetRequestWithStatus(getCommitment)(rebuildClaim, 120 s timeout) instead of replaying the old bytes.getCommitmentis 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.cancelRequestCommitmentaccepts only aGetRequestEventemitted by the source chain's host, from the IntentGateway, whosecontextstarts with the order's commitment (_cancelFromSourceencodes it asabi.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/orsdk/packages/simplexthis round:src/tests/bridge-top-up.test.ts24 passed,token-sender.test.ts19,services/UserOpSender.test.ts6,ui/src/operator104; 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:data146 passed;test:unit263 passed;test:filler70 passed, 5 skipped.pnpm --filter="@hyperbridge/simplex" lint: no errors; Biome is clean onBridgeTopUp.tsandbridge-top-up.test.ts.npx tsc --noEmit -p .: the same 14 errors as main (3 of them in theoxdependency), 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.