Repository navigation
fix(ethereum): sign again after a wallet drops a rejected call bundle (JUMEMB-142, JUMEMB-79) - #514
Merged
Merged
Conversation
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 detectedLatest commit: d92319f The changes in this PR will be included in the next version bump. This PR includes changesets to release 8 packages
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 |
Contributor
📦 Bundle size✅ No significant changes. Every check is within budget. @lifi/sdk
Providers
Full integration
How to read this
|
2 tasks done
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."
Merged
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-142: this PR fixes this bug. It blocks JUMEMB-79. QA found the bug as a regression of #507 on Jumper develop (
@lifi/sdk4.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.
processSingleTransactioninMetaMask/core,eip-5792-middleware).wallet_sendCallsfail with 4001, and the SDK stores no id. So the bug does not occur for such a batch.wallet_getCallsStatusthen fails with 5730 "No matching bundle found".The fix.
SignatureRejected(1012), marked as final. The action keeps the bundle id withtxFinal: true, so the bundle is no longer an open transaction, and "Try again" signs a new bundle.ExecutionAction.callCount. So the rule also works after a page reload. A route stored before this release has nocallCount, and the rule does not apply to it. TheUpdateRouteHookJSDoc now namescallCountamong the fields that an integrator must store.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 withoutcallCount, 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".@lifi/sdk4.11.0 fails withCallBundleNotFoundon its next "Try again".CallBundleNotFoundand not toSignatureRejected. The outcome is then unknown, and the SDK does not sign again.waitForCallsStatus, with the same status rule and the same 24-hour timeout.Alternatives that we did not use.
Accepted limits.
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 SDK cannot check this case, because a bundle id has no transaction hash that the SDK can look up.
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.viem uses one status poll for all waits on the same wallet client and bundle id. This occurs only after
stopRouteExecutionand a resume on the same page. If the wallet then has no record of the bundle, the resumed wait fails withCallBundleNotFound, 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.
waitForCallsStatuson 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.batchedReject.unit.spec.ts, uses a fake MetaMask with real 5730 answers. It covers these cases:wallet_sendCallsfails with 4001, and "Try again" signs once);knowncheck, the call-count check, the storedcallCount, the late merge ofcallCount, the time limit, the parser mapping and the status rule each make tests fail.src/coresuites,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
CallBundleNotFoundand new optional fieldExecutionAction.callCount: public-docs needs an entry for each.)