Skip to content

fix: resume a signed transaction instead of signing it again (JUMEMB-79) - #507

Merged
chybisov merged 225 commits into
mainfrom
feature/jumemb-79-tx-is-trying-to-be-resubmitted-if-the-user-opens-a-new-page
Oct 7, 2026
Merged

chybisov merged 225 commits into
mainfrom
feature/jumemb-79-tx-is-trying-to-be-resubmitted-if-the-user-opens-a-new-page

Conversation

@chybisov

@chybisov chybisov commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

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.

Release rule: ships together with the other resume-without-resign changes in one release. Do not merge a Version PR until all of this is on main. All seven packages change (@lifi/sdk minor, the six providers patch; see the changesets).

Why was it implemented this way?

The bug. On resume, prepareRestart dropped every FAILED action, the Solana / Sui / Tron selectors restarted at CheckBalanceTask for any action that had a hash but was not DONE, and BaseStepExecutor marked the last action FAILED on 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 by BaseStepExecutor from a final marker on TransactionError (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.
  • One predicate, hasOpenTransaction(action), drives prepareRestart, every provider selector and the pre-sign guard assertNoOpenTransaction, which every sign task calls at its start and again immediately before the wallet call.
  • prepareRestart keeps an action with an unknown outcome and re-checks it on chain; resumeRoute no longer mutates the route it is given; a restart keeps execution.signedAt while a transaction is open.
  • isKnownToStatusApi is a veto only: it can stop a "dropped" verdict, never cause one.
  • After stopRouteExecution, a task that was still running (for example a wallet prompt the user answers later) still delivers a write that changes txHash / txHex / taskId / txFinal to updateRouteHook — 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.

  • Solana, Tron, Sui: the signed transaction is stored in txHex before 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.
  • Ethereum, Bitcoin, Stellar: final sites are marked; the selectors use the shared predicate. Bitcoin now also stores the signed transaction before a one-round sendrawtransaction, clears it only when every RPC URL refuses it for a reason that proves none of them holds it and getrawtransaction answers -5 on every URL, and resends it on resume within two minutes of signing, in one round (retryCount: 0, like the first send).
  • Sui: a wallet rejection is recognized at the signing call; a node message containing "reject" is no longer SignatureRejected; a failed execution reports the chain's message instead of [object Object].

Alternatives considered. An error-code list for "final" (fragile: TransactionFailed also means "unknown" today); always re-verifying through ExecuteStepRetryError (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"):

  • An unknown outcome past node history stays unknown.
  • A Sui zkLogin signature cannot be verified offline, so such stored bytes are never resent.
  • After a node refuses a Sui transaction on the first run, "Try again" cannot sign a new one until about 17 minutes after signing (no node can prove it absent before then).
  • Two prompts open at once (two tabs, or a stop and a resume while a prompt is open): where the SDK sends (Solana, Sui, Tron, Bitcoin, Stellar and the EVM relayed lane), it checks the action again after the wallet returns, so a second signature is never sent once the first run's transaction has merged in. If the newer prompt is approved first, or in the EVM standard and batched lanes (the wallet sends), both can be sent. A cross-tab lock is a widget follow-up.
  • EVM Safe: the hash is written only after the Safe transaction executes (up to 24 h). A reload during that wait, or the 24 h timeout, leaves no transaction on the action, so "Try again" makes a second Safe proposal. Unchanged from main; storing the Safe transaction hash first is a follow-up.
  • EVM relayed: if the relay request reaches the relayer but its answer is lost, no task id is stored and "Try again" signs a new intent. Unchanged from main.
  • EVM batched: after stopRouteExecution, the stopped run keeps polling wallet_getCallsStatus for up to 24 h (viem's wait takes no signal), and its executeRoute resolves only then.

Integrator-visible changes.

  • updateRouteHook can be called after stopRouteExecution (only for transaction data; an integrator that deleted the route should ignore it). The widget's hook already does.
  • ExecutionAction.txFinal (new, optional) and txHex now also used by Solana, Tron and Sui.
  • Every TransactionError has an own final property (JSON shape change).
  • A resumed execution that receives a late transaction from an earlier run fails with 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 and getStepTransaction are 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 on main first (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 using sendUTXOTransaction's retries. 131 flow tests and pnpm test:unit pass in all seven packages.

Memory and lifetime. A review for leaks found and fixed these on this branch:

  • The kept route and hook of an ended execution (the late-write channel) now live only while a stopped run of that route id is still in flight; a module-wide start number keeps a late write from ever reaching an older route.
  • A stopped route stops polling LI.FI /status and the relayer at once (an abort signal, given only to waits after the broadcast); the step stays PENDING with its transaction, executeRoute resolves with the route, and a resume waits for the same transaction. A live route still polls as before.
  • The relayed wait ends after 24 hours with a non-final error; "Try again" waits for the same relayer task.
  • Sui resume lookups give each node 10 seconds; a timeout counts as unknown, never as "not found".
  • A resumed EVM step waits on the lane its sign task stored (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:

  • A stopped route no longer replays a step. If the step asks for a replay (ExecuteStepRetryError) after stopRouteExecution, it does not run again, so the replay's re-quote, chain reads and integrator callbacks do not run after the stop, and executeRoute resolves with the route.
  • A replay refused because the step has an open transaction now fails with TransactionConflict (1020) instead of InternalError (1000), like the sign-task guard.
  • The EN5 flow spec (background pause) pins the new read order of the prepare task. The reads, the pause point and "nothing signed" are unchanged.

Final review fixes.

  • Where the SDK sends the signed transaction (Solana, Sui, Tron, Bitcoin, Stellar, EVM relayed), the sign task checks the action again right after the wallet returns. If a stopped run's transaction merged in while the prompt was open, the step fails with TransactionConflict and the new bytes are neither stored nor sent.
  • The Stellar absence proof gives each RPC 10 seconds; a node that does not answer gives no information, as a failed request does, so a silent node cannot hold the route.
  • Changeset wording: when Solana and Tron count a transaction as dropped (including the time fallback), and the Sui refusal delay.

Release note: the Bitcoin provider now requires @bigmi/core 0.9.3 (lifinance/bigmi#82). It fixes the waitForTransaction bugs 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

  • I have performed a self-review and testing of my code.
  • This pull request is focused and addresses a single problem.
  • If this PR modifies the SDK API or adds new features that require documentation, I have updated the documentation in the public-docs repository. (Docs follow-up: txFinal, txHex lifetime per chain, and updateRouteHook after stopRouteExecution.)

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.
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
chybisov merged commit 14f4ecc into main Oct 7, 2026
2 checks passed
@chybisov
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
@github-actions github-actions Bot mentioned this pull request Oct 7, 2026
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.
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.

1 participant