Repository navigation
fix: resume a signed transaction instead of signing it again (JUMEMB-79) - #507
Merged
chybisov merged 225 commits intoOct 7, 2026
Conversation
…ear final ones on re-init
…action is written
A stop during the wallet prompt, then a resume, opens a second prompt for the same step. When the user approves the older prompt first, its late write merges the signed bytes into the newer execution. The newer run then got its own signature, cleared the merged bytes and stored and sent a second transaction. The sign task now checks the action again right after the wallet returns, before the clear. Nothing awaits between the check and either write (the decode and the signature read are synchronous), so one check protects both. The newer run fails with TransactionConflict, its bytes are neither stored nor sent, and "Try again" waits for the merged transaction.
A stop during the wallet prompt, then a resume, opens a second prompt for the same step. When the user approves the older prompt first, its late write merges the signed bytes into the newer execution. The newer run then got its own signature, replaced the merged bytes with its own and executed a second transaction. The sign task now checks the action again right after the wallet returns, outside the signer error handling and before the one write. Nothing awaits between the check and that write. The newer run fails with TransactionConflict, its bytes are neither stored nor executed, and "Try again" waits for the merged transaction.
A stop during the wallet prompt, then a resume, opens a second prompt for the same step. When the user approves the older prompt first, its late write merges the signed transaction into the newer execution. The newer run then got its own signature, replaced the merged transaction with its own, and the wait task broadcast a second transaction. The sign task now checks the action again right after the wallet returns, before the one write. Nothing awaits between the check and that write. The newer run fails with TransactionConflict, its transaction is neither stored nor broadcast, and "Try again" waits for the merged transaction.
A stop during the wallet prompt, then a resume, opens a second prompt for the same step. When the user approves the older prompt first, its late write merges the signed transaction into the newer execution. The newer run then got its own signed PSBT, replaced the merged transaction with its own and sent a second transaction. The sign task now checks the action again right after the wallet returns, before the PSBT is finalized and written. Nothing awaits between the check and that write. The newer run fails with TransactionConflict, its transaction is neither stored nor sent, and "Try again" waits for the merged transaction.
A stop during the wallet prompt, then a resume, opens a second prompt for the same step. When the user approves the older prompt first, its late write merges the signed envelope into the newer execution. The newer run then got its own envelope, replaced the merged one with its own and submitted a second transaction. The sign task now checks the action again right after the wallet returns, before the hash is derived and written. Nothing awaits between the check and that write. The newer run fails with TransactionConflict, its envelope is neither stored nor submitted, and "Try again" waits for the merged transaction.
proveStellarTransactionAbsent asks every configured RPC with Promise.allSettled. stellar-sdk 17.2.0 sets no request timeout, so one node that accepts the request and never answers held the proof, and with it the sign task or the wait task that classifies a rejected submission, forever. Each getTransaction call now races a 10 second deadline (withTimeout; the call takes no abort signal). A node that does not answer in time counts exactly as a failed request counts today: it gives no information and can never help prove absence. The proof rule is unchanged, and no timer is left behind.
The order of the steps in stopRouteExecution matters in one place. The setInteraction loop must run before executionState.delete, because StatusManager.allowUpdates(false) keeps the route and hook from the execution state. Only the abort can run before or after them. The sdk changeset now says what the checks after the wallet change: in the EVM relayed lane and in the five providers where the SDK sends, a prompt that was already open can still be signed, but after a merge its bytes are never sent or stored. The EVM standard and batched lanes keep the limit, and a newer prompt approved before the older run stores its transaction (in the relayed lane, before its relay request returns) still sends both. The replay sentence loses its InternalError remark, a state that never shipped. The assertNoOpenTransaction docs name the third check. The Solana changeset adds the five-minute time fallback of the dropped rule, the Tron changeset the signedAt fallback of a route without txHex, and the Sui changeset the 17 minutes before "Try again" can sign again after a node refusal.
The second check in the EVM standard lane runs before viem's sendTransaction awaits, so say "unless its wallet call has already started" and scope the two-prompt limit to the lanes it applies to. Name the Safe limit in the Ethereum changeset. Give the earliest time a Tron route stored without txHex can be declared dropped, and limit the Stellar deadline sentence to the absence check.
…tted-if-the-user-opens-a-new-page
Since #509 the Tron provider reads TRC-20 balances and allowances through a static ABI. It sends no wallet/getcontract request, and the balance read now starts before the block read, because the contract is built without a request. Pin the new node calls in the TRC-20 swap and background specs, drop the finding that #509 fixed, and remove the fake node's getcontract answer and its ABI, so an unexpected ABI fetch now fails as an unknown request.
Both wait tasks build their errors with confirmationError, so only its own spec still called unwrapConfirmation. Remove the function and rename the module and its spec to confirmationError. The spec keeps every mapping case and calls confirmationError directly. The rule that rpc-unavailable and not-confirmed must stay distinct moves to the confirmationError doc.
SUI_REEXECUTION_RETURNS_EFFECTS was always false, so the wait task never reached the branch that declared a refused re-execution dropped. Only a getter mock in the wait task spec could switch it on. Remove the constant, the branch, the mock and the two tests that existed only for that branch. The task still looks the digest up once more after a refusal and rethrows it as an unknown outcome, because a node refusal does not prove the transaction absent.
A provider author had no place in the repo that lists the rules every resume path follows. Add them to the hasOpenTransaction JSDoc, so they also show in the published types: the first-task selector, the three pre-sign checks, storing the signed bytes before the first send, a resume that never signs, the proof a final error needs, and the stop signal. The summary now also says that the predicate is true for a transaction that landed.
Production comments in the Solana, Stellar, Sui and Tron providers cited sections of a design document that is not in the repo. Remove those citations. Where a comment needs the rule to make sense, it now states the rule itself, or points to the resume rules in the hasOpenTransaction JSDoc in transactionState.ts. No code changes.
Spec and mock comments in core and the six providers cited sections, tasks and findings of design documents that are not in the repo. Remove those citations and state the rule in plain words where the comment needs it. Test titles lose their scenario ID prefixes and keep the readable rest. No assertion changes.
The "signs exactly once after a final failure" spec rejects a receipt as reverted, so the error parser calls fetchTxErrorDetails from the built @lifi/sdk, which sent a real request to api.tenderly.co on every run. Stub fetch for the whole file: the stub answers only the Tenderly URL, with the same body as the network fake, and throws for any other URL. The spec now asserts that the stub got the Tenderly request for the reverted hash.
hasStepOpenTransaction and TRANSACTION_ACTION_TYPES are used only inside core, which imports them from transactionState.ts directly. No provider package imports them, so the package root no longer exports them and they do not become public API with this release. The sdk changeset no longer lists them.
UpdateRouteHook and ExecutionOptions.updateRouteHook had no docs. They now say that the hook gets the same working route object on every call, which the SDK keeps changing, so the integrator copies it before storing. They also say that after stopRouteExecution the hook can still be called with the transaction data of a task that was running at the stop, and that the integrator stores it so a resume waits for that transaction. The sdk changeset keeps every fact but lists them as short bullets. It no longer says that it "adds" helpers it then calls internal: the helpers are exported for provider packages and are not integrator API.
The route's step must keep sharing the executor's execution object. A late write after a stop merges its transaction into the live route in place, and the running sign task checks that same object after the wallet returns. A deep copy would hide the merge from that check. The stop-and-resume race test in the Ethereum reload spec pins this.
The return-type tests of getStepTransaction and getRoutes called the action inside expectTypeOf and never awaited it. The request was still open when the test server closed, so it could reach the network during teardown, and the worker sometimes aborted with a TLSWrap assertion (SIGABRT). Check the declared return type of the function instead, so no request starts. The test server now also answers an unhandled request with an error instead of passing it through to the network.
A write of transaction data after stopRouteExecution must never throw into the task that made it, so deliverLateTransactionData catches every error. That also hid errors thrown by the integrator's updateRouteHook, which the normal update path lets propagate. Keep the catch, but log the error with console.debug in development, as the SDK does for the other errors it swallows.
The resume rules allowed a final error only on a chain verdict. The EVM relayed lane also marks a transaction final when the relayer reports it failed, so the rule now names failed or reverted on chain or at the relayer.
Clearing txHex is a limit, not an order: Stellar keeps the bytes, and Tron and Bitcoin clear bytes no node can still hold. A resumed EVM wait can ask for a chain switch, so drop the claim that a wait needs no user interaction. Tron proves coverage with a separate head read, so say a covering node reports the transaction absent, in the same response where the chain allows it. List the relayer verdict in the txFinal and final docs too, say the hook gets the same route object within one execution, and drop two review labels from test titles.
chybisov
deleted the
feature/jumemb-79-tx-is-trying-to-be-resubmitted-if-the-user-opens-a-new-page
branch
October 7, 2026 10:38
Merged
chybisov
added a commit
that referenced
this pull request
Oct 7, 2026
Set every budget to the size on main plus 20%, the same rule as the widget repos. The Sui provider is now 91.9 kB after #507 added @mysten/sui/verify, so its budget moves from 78 kB to 111 kB.
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which Linear task is linked to this PR?
JUMEMB-79 — a transaction is submitted again (a second wallet signature) when the user reloads or opens a new page during a swap.
Why was it implemented this way?
The bug. On resume,
prepareRestartdropped everyFAILEDaction, the Solana / Sui / Tron selectors restarted atCheckBalanceTaskfor any action that had a hash but was notDONE, andBaseStepExecutormarked the last actionFAILEDon any error. So after a reload (the widget resumes on mount, also in the background), the SDK asked the wallet to sign a second transaction while the first one could still land. Solana, Sui and Tron also kept the signed transaction only in memory, so a reload between signing and broadcast could not resume it at all.The rule. Never sign a second transaction while an earlier one can still land. "Try again" signs anew only after a final outcome.
Where to start reading. The resume rules that every provider follows are in the JSDoc of
hasOpenTransaction(packages/sdk/src/core/transactionState.ts).Core (
@lifi/sdk).ExecutionAction.txFinal: written byBaseStepExecutorfrom afinalmarker onTransactionError(new TransactionError(code, message, cause, { final: true })), only at the sites where the chain gave a definite answer (reverted, cancelled, replaced, dropped with proof). Codes and messages are unchanged.hasOpenTransaction(action), drivesprepareRestart, every provider selector and the pre-sign guardassertNoOpenTransaction, which every sign task calls at its start and again immediately before the wallet call.prepareRestartkeeps an action with an unknown outcome and re-checks it on chain;resumeRouteno longer mutates the route it is given; a restart keepsexecution.signedAtwhile a transaction is open.isKnownToStatusApiis a veto only: it can stop a "dropped" verdict, never cause one.stopRouteExecution, a task that was still running (for example a wallet prompt the user answers later) still delivers a write that changestxHash/txHex/taskId/txFinaltoupdateRouteHook— into the stored route, or merged into a live (or the last ended) execution of the same route, whose guard then refuses a second signature. An old run no longer stops a newer execution of the same route.Providers.
txHexbefore it is sent; a resume looks it up and resends exactly those bytes (Sui and Solana durable nonce only within two minutes of signing). "Dropped" needs proof from a single chain response that itself shows history coverage and head (canary signatures/digests in the same request; Tron: block-time head and a 24 h window), plus the status-API veto. Without proof the outcome stays unknown and the route is not re-signed.sendrawtransaction, clears it only when every RPC URL refuses it for a reason that proves none of them holds it andgetrawtransactionanswers-5on every URL, and resends it on resume within two minutes of signing, in one round (retryCount: 0, like the first send).SignatureRejected; a failed execution reports the chain's message instead of[object Object].Alternatives considered. An error-code list for "final" (fragile:
TransactionFailedalso means "unknown" today); always re-verifying throughExecuteStepRetryError(a branch in every wait task); absence proof from separate coverage requests (getFirstAvailableBlock,minimumLedgerSlot, …), rejected because default RPCs are load-balanced pools, so a coverage answer can come from a different backend than the lookup.Accepted limits (the exit is "delete the route"):
main; storing the Safe transaction hash first is a follow-up.main.stopRouteExecution, the stopped run keeps pollingwallet_getCallsStatusfor up to 24 h (viem's wait takes no signal), and itsexecuteRouteresolves only then.Integrator-visible changes.
updateRouteHookcan be called afterstopRouteExecution(only for transaction data; an integrator that deleted the route should ignore it). The widget's hook already does.ExecutionAction.txFinal(new, optional) andtxHexnow also used by Solana, Tron and Sui.TransactionErrorhas an ownfinalproperty (JSON shape change).TransactionConflict; "Try again" then waits for that transaction.Testing. Unit tests per task (each assertion checked against a mutation), reload tests per chain (route round-tripped through JSON inside
updateRouteHook, a new executor,resumeRoute; the wallet andgetStepTransactionare never called again), the EVM flow harness (including stop-during-prompt), and read-only checks against public mainnet/testnet RPCs for the canary and error shapes.Money-path flow matrix. A matrix of end-to-end flow specs (
packages/*/src/core/flows/*.flow.spec.ts) drives the real SDK for every provider against fake chain networks, a fake wallet that signs with a real throwaway key, and a fake LI.FI API (swap, ERC-20/TRC-20 approval, bridge, user rejection, background run, on-chain failure, plus an EVM chain switch, exchange-rate update and two-step route). It was written and made green onmainfirst (characterization), then merged here; every assertion that changed is explained against the design and marked// #507:(all in Sui: the split sign/execute, the error mapping and message, the digest kept on a failed execution). Resume variants were added for Bitcoin, Stellar, the Solana Jito bundle and the Tron TRC-20 path, and a Sui node-refusal retry spec. The only production change from this work: the Bitcoin resume resend now sends once per RPC URL instead of usingsendUTXOTransaction's retries. 131 flow tests andpnpm test:unitpass in all seven packages.Memory and lifetime. A review for leaks found and fixed these on this branch:
/statusand the relayer at once (an abort signal, given only to waits after the broadcast); the step stays PENDING with its transaction,executeRouteresolves with the route, and a resume waits for the same transaction. A live route still polls as before.txType), so a relayed signature-only step resumes on the relayer and a partial batch is never signed again.After merging main (#503, a step that turns out relayed at prepare is replayed). A review of the two changes together found two gaps, fixed here:
ExecuteStepRetryError) afterstopRouteExecution, it does not run again, so the replay's re-quote, chain reads and integrator callbacks do not run after the stop, andexecuteRouteresolves with the route.TransactionConflict(1020) instead ofInternalError(1000), like the sign-task guard.Final review fixes.
TransactionConflictand the new bytes are neither stored nor sent.Release note: the Bitcoin provider now requires
@bigmi/core0.9.3 (lifinance/bigmi#82). It fixes thewaitForTransactionbugs that a resumed Bitcoin wait depends on: a wait whose block budget ran out could stop the shared block watcher, so later waits on the client never settled.Visual showcase (Screenshots or Videos)
Not applicable (no UI change). The issue's screen recording shows the second signature request this fixes.
Checklist before requesting a review
txFinal,txHexlifetime per chain, andupdateRouteHookafterstopRouteExecution.)