Skip to content

fix(ethereum): sign again after a wallet drops a rejected call bundle (JUMEMB-142, JUMEMB-79) - #514

Merged
chybisov merged 25 commits into
mainfrom
fix/jumemb-79-batched-reject-resign
Oct 9, 2026
Merged

chybisov merged 25 commits into
mainfrom
fix/jumemb-79-batched-reject-resign

Conversation

@chybisov

@chybisov chybisov commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Which Linear task is linked to this PR?

JUMEMB-142: this PR fixes this bug. It blocks JUMEMB-79. QA found the bug as a regression of #507 on Jumper develop (@lifi/sdk 4.11.0).

Why was it implemented this way?

The bug. A user rejects a batched swap (EIP-5792) in MetaMask, for example ETH to USDC on Base. The step fails with 1012 "This bundle id is unknown". Each "Try again" fails in the same way, and the wallet shows no new prompt. Only Delete clears the route. SDK 4.10.0 signs again.

The cause.

  • For a batch with one call, MetaMask returns the bundle id before the user approves the batch (processSingleTransaction in MetaMask/core, eip-5792-middleware).
  • For a batch with two or more calls, MetaMask returns the id only after it signed and sent the batch. A reject makes wallet_sendCalls fail with 4001, and the SDK stores no id. So the bug does not occur for such a batch.
  • After a reject, MetaMask removes the transaction. wallet_getCallsStatus then fails with 5730 "No matching bundle found".
  • The SDK kept the bundle id on the failed action. Since fix: resume a signed transaction instead of signing it again (JUMEMB-79) #507, an action with a bundle id is an open transaction. So each "Try again" waited for the rejected bundle again.

The fix.

  • In one wait, the wallet first reports a bundle with one call and then has no record of it. MetaMask removes a single-call bundle in this way only before it sends it, so the SDK treats the bundle as never sent. The step fails with SignatureRejected (1012), marked as final. The action keeps the bundle id with txFinal: true, so the bundle is no longer an open transaction, and "Try again" signs a new bundle.
  • The rule applies only to a bundle with one call. For a bundle with two or more calls, MetaMask has the bundle only after it sent it. So "no record" means that MetaMask deleted a bundle that it sent, for example after "Delete activity and nonce data".
  • The action stores the number of calls in a new optional field, ExecutionAction.callCount. So the rule also works after a page reload. A route stored before this release has no callCount, and the rule does not apply to it. The UpdateRouteHook JSDoc now names callCount among the fields that an integrator must store.
  • The SDK accepts this proof only for 10 minutes after signing. MetaMask has no timer that deletes a bundle. But a user deletes the activity data most likely when a transaction is stuck, some minutes after signing. Also, we do not know how other wallets keep the bundle status.
  • In all other cases, the step fails with the new code LiFiErrorCode.CallBundleNotFound (1028), and the action keeps the bundle id. Examples: a reload after the reject, a bundle with two or more calls, a route stored without callCount, or an age of 10 minutes or more. The SDK cannot know if the wallet sent this bundle, so "Try again" waits again and does not sign. For this code, the widget PRs show [Delete] [Start a new swap] and no "Try again".
  • A route that is stuck on @lifi/sdk 4.11.0 fails with CallBundleNotFound on its next "Try again".
  • If an "unknown bundle" error reaches the error parser from any other path, the parser now maps it to CallBundleNotFound and not to SignatureRejected. The outcome is then unknown, and the SDK does not sign again.
  • Other lanes (standard, relayed, Safe transaction service) and other providers do not change. The batched wait keeps viem's waitForCallsStatus, with the same status rule and the same 24-hour timeout.

Alternatives that we did not use.

  • Treat every "unknown bundle" as a reject. This frees every stuck route. But a resume with another wallet, browser or device also gets "unknown bundle" for a bundle that it sent. The SDK would then sign a second swap.
  • Prove "not sent" with the account nonce. Smart accounts (EIP-7702) can execute a bundle without a change of the account nonce, so this proof is not safe.

Accepted limits.

  1. The SDK treats one sequence as proof that the wallet did not send a bundle: in one wait, the wallet first reports a bundle with one call and then has no record of it. This rule applies to all wallets. If a wallet sends a bundle with one call and loses its record less than 10 minutes after signing, "Try again" can sign a second bundle, and both bundles can execute. Examples:

    • The user clicks "Delete activity and nonce data" (MetaMask extension, Settings > Developer Tools) or "Reset account" (MetaMask Mobile) after the approval and before the confirmation.
    • MetaMask Mobile deletes the transactions of an address and a chain when a hardware-wallet swap retry fails.
    • A wallet gets the bundle status from a remote service, and the service reports the bundle as unknown for more than approximately 3 seconds.

    The SDK cannot check this case, because a bundle id has no transaction hash that the SDK can look up.

  2. The 10-minute period starts when the wallet returns the bundle id. For one call, MetaMask returns the id when it opens the prompt. If the user rejects 10 minutes or more after that, the step fails with CallBundleNotFound, and the user must start a new swap.

  3. viem uses one status poll for all waits on the same wallet client and bundle id. This occurs only after stopRouteExecution and a resume on the same page. If the wallet then has no record of the bundle, the resumed wait fails with CallBundleNotFound, and a later wait on the same page can wait until the 24-hour timeout. A page reload removes the problem. This behaviour existed before this change. A follow-up replaces the viem poll with a poll that the SDK owns.

Testing.

  • Unit tests for the batched wait run viem's real waitForCallsStatus on fake timers. They cover the status rule, "known, then unknown", "unknown" on the first answer, the 10-minute limit and its boundaries, a 5730 inside viem's retry burst, other errors, and two waits on the same bundle.
  • A new flow spec, batchedReject.unit.spec.ts, uses a fake MetaMask with real 5730 answers. It covers these cases:
    • a reject in the same page;
    • a reload while the prompt is open;
    • a reload after the reject;
    • an approved bundle;
    • a stop and then a reject;
    • a stop and a resume on the same page;
    • a reject of a bundle with two or more calls (wallet_sendCalls fails with 4001, and "Try again" signs once);
    • a bundle with two or more calls that MetaMask deletes after it sent it (1028, the bundle id stays, and "Try again" sends nothing).
  • Each new test failed before its fix. Mutations of the known check, the call-count check, the stored callCount, the late merge of callCount, the time limit, the parser mapping and the status rule each make tests fail.
  • All six providers' src/core suites, pnpm test:unit, the type checks, lint, circular-deps and knip pass.

Visual showcase (Screenshots or Videos)

Not applicable (no UI change). The widget PRs show the new UI.

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. (New error code CallBundleNotFound and new optional field ExecutionAction.callCount: public-docs needs an entry for each.)

A wallet can answer that it has no record of an EIP-5792 call bundle.
Unless the wallet reported the bundle earlier in the same wait, that
answer does not prove that the bundle never left the wallet, so the
outcome stays unknown. The new code 1028 lets an integrator tell this
case apart from a rejected signature.
MetaMask returns the id of a single-call EIP-5792 bundle before the
user approves it, and removes the bundle when the user rejects it.
wallet_getCallsStatus then fails with 5730 (UnknownBundleIdError). The
error parser mapped that to a non-final SignatureRejected, so the
action kept its bundle id, and every "Try again" waited on the same
removed bundle and failed again without a new signature.

The batched wait now passes viem the default status predicate, which
also records that the wallet answered for the bundle. When the wallet
answered for it in this wait and then has no record of it, within ten
minutes of signing, the bundle never left the wallet. The wait reports
this as CallBundleDroppedError, and the task clears the transaction
fields of the action and fails with a non-final SignatureRejected, as
after a rejection before sending. "Try again" then signs anew.

A bundle the wallet does not know from the first answer (a reload
after the reject, another wallet), or too long after signing, may
still land. The wait fails with a non-final CallBundleNotFound, and
the action keeps the bundle id, so "Try again" waits for it again.
A wait that joined the poll of another wait on the same client and id
never sees an answer, so it also ends with CallBundleNotFound.
The specs drive the real step executor and the real batched wait,
down to viem's waitForCallsStatus and its RPC error mapping, against a
fake wallet that answers wallet_getCallsStatus as MetaMask does for a
single call: 100 while the prompt is open, 200 after the approval and
5730 after a reject.

They pin that a reject in the same run, and a reject after a reload
while the prompt is open, fail with SignatureRejected and let "Try
again" sign exactly one new bundle. A reload after the reject fails
with CallBundleNotFound, keeps the bundle id and signs nothing on
"Try again". An approved bundle completes as before. After
stopRouteExecution, the stopped run's cleared action reaches storage,
and a newer execution of the same route, whose wait joined the poll,
keeps its bundle id and fails with CallBundleNotFound.
The parser still maps a raw UnknownBundleIdError to SignatureRejected.
The errors of the batched wait carry that viem error as a cause, so
the specs pin that a SignatureRejected for a dropped bundle and a
CallBundleNotFound pass the parser with their codes unchanged.
A minor bump of @lifi/sdk for the new CallBundleNotFound code, and a
patch bump of @lifi/sdk-provider-ethereum for the batched wait.
A wallet that reported an EIP-5792 call bundle in the same wait and
then has no record of it, within 10 minutes of signing, never sent it.
The rules now say that a provider clears the action with
CLEARED_TRANSACTION_FIELDS in that case and throws without the final
marker, as the EVM batched wait does.
A bundle that the wallet dropped before it sent it now leaves no
txType either, so the stored action equals a rejection before sending.
The task spec compares the written fields strictly: with
toHaveBeenCalledWith, a missing key matched an undefined one, so a
write that cleared only some fields passed.
@lifi/sdk gets its own short changeset for the new CallBundleNotFound
code. The provider changeset keeps the EVM text, uses "bundle id" as
the code does, and names the remaining limit: a wallet that sends a
bundle and then loses its record of it within 10 minutes of signing.
@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d92319f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@lifi/sdk Minor
@lifi/sdk-provider-ethereum Patch
@lifi/sdk-provider-bitcoin Patch
@lifi/sdk-provider-solana Patch
@lifi/sdk-provider-stellar Patch
@lifi/sdk-provider-sui Patch
@lifi/sdk-provider-tron Patch
@lifi/sdk-provider-zcash Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📦 Bundle size

✅ No significant changes. Every check is within budget.

@lifi/sdk

Import Base PR Change Budget used
⚪ createClient + getQuote 4.29 kB 4.29 kB — ▰▰▰▰▰▰▰▱▱▱ 72%
⚪ all exports 16.0 kB 16.0 kB 🔺 +0.03 kB (+0.2%) ▰▰▰▰▰▰▰▰▱▱ 80%
⚪ CJS (no tree-shaking) 19.3 kB 19.4 kB 🔺 +0.09 kB (+0.5%) ▰▰▰▰▰▰▰▰▱▱ 81%

Providers

Import Base PR Change Budget used
⚪ ethereum (+ viem) 95.3 kB 95.5 kB 🔺 +0.13 kB (+0.1%) ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ solana 28.1 kB 28.1 kB — ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ bitcoin 68.2 kB 68.2 kB — ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ sui 91.9 kB 91.9 kB — ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ tron 192.5 kB 192.5 kB — ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ stellar 122.9 kB 122.9 kB — ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ zcash 6.45 kB 6.45 kB — ▰▰▰▰▰▰▰▰▱▱ 81%

Full integration

Import Base PR Change Budget used
⚪ createClient + all providers 560.5 kB 560.8 kB 🔺 +0.24 kB (+0.0%) ▰▰▰▰▰▰▰▰▱▱ 83%
How to read this
  • Sizes are minified + brotli, measured with size-limit the way an app bundles the import: tree-shaken, with all dependencies included except the ones in the check's ignore list (packages the app already has, such as peers).
  • Code behind a dynamic import() is counted too, unless the check's name says otherwise.
  • 🟠 / 🟢 mark a change of at least 512 B and at least 1% or 5.00 kB. 🔴 means the check is over its budget.
  • Budgets live in .size-limit.json. The base commit is measured with the same checks as the PR.

d92319f merged into db6f1b2 · workflow run

ExecutionAction has a new optional callCount, the number of calls of a
batched (EIP-5792) transaction. A provider sets it with taskId. It is
data of the transaction, so CLEARED_TRANSACTION_FIELDS clears it, and
the late merge after stopRouteExecution copies it with the other
fields. A stale count of a final-failed bundle must not describe a
later bundle.

The resume rules now limit the dropped bundle case to a single-call
bundle. MetaMask returns the id of two or more calls only after it sent
the bundle, so a wallet that then has no record of it removed a sent
bundle, which can still land.
MetaMask returns the id of a single-call bundle before the user
approves it, so a bundle that the wallet reported and then no longer
knows was rejected. A bundle of two or more calls (EIP-7702) gets its
id only after MetaMask signed and sent it, and a reject fails
wallet_sendCalls with 4001. A 5730 for such an id means that the
wallet removed a sent bundle, e.g. with "Delete activity and nonce
data". Clearing the action then let "Try again" sign a second swap
while the first one could still land.

The batched sign task now stores callCount, the length of the array it
passes to sendCalls, with the bundle id. The batched wait passes the
stored count to waitForBatchTransactionReceipt, which reports a drop
only for a count of 1. A count of 2 or more, or no count (a route
stored before this change), gives CallBundleNotFound and keeps the
bundle id. The stored count keeps the rule working after a page reload.
The fake MetaMask of the batched reject flows now answers
wallet_sendCalls as MetaMask does. It returns the id of one call at
once, and the id of two or more calls only after the approval; a
reject of those fails with 4001. The page sends through viem's real
sendCalls, so viem maps the 4001 to UserRejectedRequestError.

New flows for an approval and a swap in one bundle: a reject fails
with SignatureRejected, stores no bundle id, and "Try again" signs
once. A sent bundle that the wallet deletes fails with
CallBundleNotFound, keeps the bundle id and callCount 2, and "Try
again" signs nothing. The reload flow now shows the stored callCount
that the new page reads.
The merge into a live action lists the transaction fields by hand. The
new test writes a value for each key of CLEARED_TRANSACTION_FIELDS and
checks that the live action gets each one. A field that is added to
the constant later and not to the merge now fails this test, so a
stale value of it cannot stay in the live action.
Rule 3 of the resume rules now says that the count is the stored
callCount, not the calls of the context, which a reload removes. The
UpdateRouteHook JSDoc names callCount with the other transaction data
that an integrator stores after a stop. The changeset says that the
Ethereum provider sets callCount only for a batched transaction.
The comment for CallBundleNotFound is now also true for a route stored
without the count. The comment at the start names the bundle whose id
MetaMask returns only after it sent the bundle.
waitForBatchTransactionReceipt took five positional parameters, with
signedAt and callCount next to each other. A swapped call compiled and
failed silently. The optional inputs are now one options object, and
the function has a JSDoc with its parameters and errors, as the
relayed wait has. The behaviour does not change.
waitForBatchTransactionReceipt now throws a final SignatureRejected
when the SDK treats a single-call bundle as never sent. The rule does
not change: a stored callCount of 1, an answer for the bundle in the
same wait, then 5730 within DROPPED_BUNDLE_MAX_AGE_MS of signing. The
step executor stores FAILED with txFinal, so the action is closed and
"Try again" signs one new bundle. The action keeps the bundle id.

CallBundleDroppedError and the clearing branch of the batched wait task
are gone. After stopRouteExecution, the stopped run now stores the
final rejection through the hook it kept; before, its stored action
stayed PENDING with no error. isDropProvable is now
isWithinDropWindow, because it only checks a time window, and its
unused now parameter is gone. The comments say that the removal is
MetaMask behaviour, and that the SDK treats the bundle as never sent.
The error parser mapped a raw UnknownBundleIdError (5730) to a
non-final SignatureRejected with the bundle id kept. That is the stuck
state of the original bug. The batched wait handles 5730 first, so no
call reaches this branch today, but the safe result for an unhandled
5730 is the unknown outcome, CallBundleNotFound. The branch matches
only the error itself, so the final SignatureRejected of the batched
wait, which carries the error as a cause, keeps its code. The link now
points to getCallsStatus in MetaMask/core.
Rule 3 no longer names the call bundle case. Its stored callCount and
its 10 minutes belong to the EIP-5792 lane of the Ethereum provider,
and the JSDoc of the batched wait keeps them. Rule 5 and the txFinal
JSDoc now count a wallet that removed a transaction before it sent it
as a cancel, which is a final outcome. The callCount JSDoc says why
the field exists, and the TRANSACTION_KEYS comment names callCount
with the other fields that never trigger a delivery.
The late merge test for callCount alone is a subset of the test that
loops over every key of CLEARED_TRANSACTION_FIELDS. The
CallBundleNotFound spec keeps only the 1028 value pin, because the
TransactionError spec already checks the default final value. The
batched wait task spec checks the pass-through of the options with one
case. The openPage JSDoc of the batched reject flows says that the
module mock of the wait follows the newest page.
The Solana sign task comment refers to CLEARED_TRANSACTION_FIELDS and
no longer lists its keys, so a new field needs no Solana edit. The
provider changeset names "@lifi/sdk 4.11.0", because the provider has
its own version.
The parser comment said that the branch stays for any other call that
throws UnknownBundleIdError. No such call exists today: the batched
wait handles 5730 first. The comment now says that, and that a later
call that reaches the branch gets the unknown outcome.
A route stored without callCount must give the batched wait no count,
so the wait never treats a legacy bundle as one call. The pass-through
test of the wait task covers this case again, next to a count of 2. A
default such as action.callCount ?? 1 in the task now fails it.
The JSDoc of TransactionErrorOptions.final lists the final verdicts,
as rule 5 of the resume rules and the txFinal JSDoc do. It now has the
same phrase for a wallet that removed a transaction before it sent it,
so the three lists agree.
The message "The wallet removed the call bundle before it sent it."
stated the MetaMask inference as a fact, and an integrator can show it
to the user. It is wrong when the wallet sent the bundle and then lost
its record, for example after "Reset account". The message now says
what the SDK knows and what it does: "The wallet has no record of the
call bundle. The SDK treats it as never sent." The code stays 1012.
Three sentences of the provider changeset had more than 25 words. Each
one is now two sentences. The text no longer calls the time limit a
proof. It now says "The SDK uses this rule only for 10 minutes after
signing."
@chybisov chybisov changed the title fix(ethereum): sign again after a wallet drops a rejected call bundle (JUMEMB-79) fix(ethereum): sign again after a wallet drops a rejected call bundle (JUMEMB-142, JUMEMB-79) Oct 9, 2026
@chybisov
chybisov merged commit 5dc9cc5 into main Oct 9, 2026
6 checks passed
@chybisov
chybisov deleted the fix/jumemb-79-batched-reject-resign branch October 9, 2026 13:36
@github-actions github-actions Bot mentioned this pull request Oct 9, 2026
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