Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d5a7478
feat(sdk): add the CallBundleNotFound error code
chybisov Oct 8, 2026
63fbbc9
fix(ethereum): sign again after the wallet drops a rejected call bundle
chybisov Oct 8, 2026
92c8fac
test(ethereum): cover a call bundle rejected in the wallet end to end
chybisov Oct 8, 2026
f3a703e
test(ethereum): check that the error parser keeps the batched wait codes
chybisov Oct 8, 2026
3cd55d7
chore: add a changeset for the rejected call bundle fix
chybisov Oct 8, 2026
a2ab26a
docs(sdk): describe a dropped call bundle in the resume rules
chybisov Oct 8, 2026
024f2a3
fix(ethereum): also clear the txType of a dropped call bundle
chybisov Oct 8, 2026
a787968
chore: split the changeset of the rejected call bundle fix
chybisov Oct 8, 2026
d9d0a48
feat(sdk): store the call count of a batched transaction
chybisov Oct 9, 2026
e28a2f2
fix(ethereum): treat a lost bundle as dropped only with one call
chybisov Oct 9, 2026
6288b26
test(ethereum): cover a rejected and a deleted multi-call bundle
chybisov Oct 9, 2026
08d2101
test(sdk): check that the late merge copies every cleared field
chybisov Oct 9, 2026
fbede60
docs(sdk): name the stored callCount in the resume rules and route hook
chybisov Oct 9, 2026
e21bf76
docs(ethereum): correct the call count comments of the batched wait
chybisov Oct 9, 2026
a3120a9
refactor(ethereum): pass the batched wait inputs in one options object
chybisov Oct 9, 2026
20976e1
fix(ethereum): fail a call bundle never sent with a final rejection
chybisov Oct 9, 2026
319ff4b
fix(ethereum): map a raw unknown bundle error to CallBundleNotFound
chybisov Oct 9, 2026
e7736b0
docs(sdk): keep provider details out of the resume rules
chybisov Oct 9, 2026
e06674f
test: remove repeated checks of the call bundle fix
chybisov Oct 9, 2026
99efcf4
chore: fix two text drifts of the call bundle fix
chybisov Oct 9, 2026
0916938
docs(ethereum): say that no call reaches the unknown bundle branch
chybisov Oct 9, 2026
707c412
test(ethereum): check that the batched wait gets an unknown call count
chybisov Oct 9, 2026
1df2118
docs(sdk): count a wallet removal in the final option JSDoc
chybisov Oct 9, 2026
832440f
fix(ethereum): give the final rejection of a lost bundle a neutral text
chybisov Oct 9, 2026
d92319f
chore: shorten the sentences of the provider changeset
chybisov Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/call-bundle-not-found-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@lifi/sdk": minor
---

`LiFiErrorCode` has a new code, `CallBundleNotFound` (1028). A step fails with this code when the wallet has no record of an EIP-5792 bundle. The SDK uses it only when it cannot prove that the wallet did not send the bundle. The outcome stays unknown: the action keeps the bundle id, and a resume waits for the bundle again and does not sign.

`ExecutionAction` has a new optional field, `callCount`. It is the number of calls of a batched (EIP-5792) transaction. The Ethereum provider sets it together with `taskId` for a batched transaction.
15 changes: 15 additions & 0 deletions .changeset/sign-again-after-rejected-call-bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@lifi/sdk-provider-ethereum": patch
---

After a user rejected a single-call EIP-5792 bundle in MetaMask, every "Try again" failed with "This bundle id is unknown". The wallet showed no new prompt. MetaMask returns the bundle id before the user approves the bundle. When the user rejects the bundle, MetaMask removes it.

Now the SDK uses a new rule for a bundle with one call. If the wallet reports the bundle and then has no record of it in the same wait, the SDK treats the bundle as never sent. The step fails with a final `SignatureRejected`. The action keeps the bundle id with `txFinal: true`, so the action is closed, and "Try again" signs a new bundle. The SDK uses this rule only for 10 minutes after signing. Some wallets get the bundle status from a remote service, and that service can forget an old bundle.

This rule applies only to a bundle with one call. MetaMask returns the id of a bundle with two or more calls only after it sent the bundle. The action stores the number of calls in `callCount`, so the rule also works after a page reload. A bundle that the SDK stored before this release has no `callCount`, and the rule does not apply to it.

One limit stays for a bundle with one call. If the wallet sends it and then loses its record of it within 10 minutes of signing, "Try again" can sign a second bundle. Examples are "Delete activity and nonce data" or "Reset account" in MetaMask, or a remote status service that forgets the bundle.

When the wallet has no record of the bundle at its first answer, the step fails with the new code `LiFiErrorCode.CallBundleNotFound`. A page reload after the reject is an example. The step also fails with this code when the rule does not apply. An example is a wallet that loses the bundle 10 minutes or more after signing. The SDK cannot know if the wallet sent this bundle. Thus the action keeps the bundle id, and "Try again" waits for the bundle again and does not sign. A route that is stuck after a reject on `@lifi/sdk` 4.11.0 fails with `CallBundleNotFound` on its next "Try again".

If an "unknown bundle id" error (5730) reaches the error parser from another call, the step also fails with `CallBundleNotFound`, not with `SignatureRejected`.
Original file line number Diff line number Diff line change
@@ -1,22 +1,106 @@
import { LiFiErrorCode, TransactionError } from '@lifi/sdk'
import type { Client, Hash } from 'viem'
import { type Client, type Hash, UnknownBundleIdError } from 'viem'
import { type GetCallsStatusReturnType, waitForCallsStatus } from 'viem/actions'
import { getAction } from 'viem/utils'
import type { WalletCallReceipt } from '../types.js'

// Some wallets answer from a remote service that can forget an old bundle.
const DROPPED_BUNDLE_MAX_AGE_MS = 10 * 60_000

const isUnknownBundleIdError = (error: any): boolean =>
error?.name === 'UnknownBundleIdError' ||
error?.cause?.name === 'UnknownBundleIdError' ||
error?.code === UnknownBundleIdError.code

/**
* Unknown signing time is never in the window. Nor is a signing time in the
* future, as in `isResendAllowed`.
*/
const isWithinDropWindow = (signedAt: number | undefined): boolean => {
const now = Date.now()
return (
signedAt !== undefined &&
now >= signedAt &&
now - signedAt < DROPPED_BUNDLE_MAX_AGE_MS
)
}

/**
* Waits until the wallet reports the EIP-5792 call bundle as done or failed.
*
* MetaMask returns the id of a single-call bundle before the user approves
* it, and removes the bundle on a reject: `wallet_getCallsStatus` then fails
* with 5730 (`UnknownBundleIdError`). It returns the id of a bundle of two or
* more calls only after it sent the bundle. Thus the SDK treats a bundle as
* never sent only when all of these are true: its stored `callCount` is 1,
* the wallet answered for it in this wait and then has no record of it, and
* this happens within `DROPPED_BUNDLE_MAX_AGE_MS` of signing.
*
* @param client - The wallet client that sent the bundle.
* @param batchHash - The bundle id that `wallet_sendCalls` returned.
* @param options.onFailed - Gets the wallet's answer for a failed bundle, before the error is thrown.
* @param options.signedAt - The signing time of the step (`execution.signedAt`).
* @param options.callCount - The stored number of calls of the bundle (`action.callCount`).
* @returns The receipt of the last call of the bundle.
* @throws {TransactionError} If the bundle failed, was canceled, or the wallet has no record of it. A bundle that the SDK treats as never sent is a final `SignatureRejected`. Any other bundle without a record is `CallBundleNotFound`, which is not final.
* @throws Any other error of the wallet, unchanged.
*/
export const waitForBatchTransactionReceipt = async (
client: Client,
batchHash: Hash,
onFailed?: (result: GetCallsStatusReturnType) => void
{
onFailed,
signedAt,
callCount,
}: {
onFailed?: (result: GetCallsStatusReturnType) => void
signedAt?: number
callCount?: number
} = {}
): Promise<WalletCallReceipt> => {
const result = await getAction(
client,
waitForCallsStatus,
'waitForCallsStatus'
)({
id: batchHash,
timeout: 3_600_000 * 24,
})
let known = false
let result: GetCallsStatusReturnType
try {
result = await getAction(
client,
waitForCallsStatus,
'waitForCallsStatus'
)({
id: batchHash,
timeout: 3_600_000 * 24,
// viem's default, and a record that the wallet answered for this id.
// viem shares one poll between waits for the same client and id, and
// calls only the predicate of the wait that started it.
status: ({ statusCode }) => {
known = true
return statusCode === 200 || statusCode >= 300
},
})
} catch (error) {
if (!isUnknownBundleIdError(error)) {
throw error
}
// MetaMask removes a single-call bundle in this way only before it sends
// it. The SDK treats the bundle as never sent: a final rejection, so
// "Try again" signs anew.
if (known && callCount === 1 && isWithinDropWindow(signedAt)) {
throw new TransactionError(
LiFiErrorCode.SignatureRejected,
'The wallet has no record of the call bundle. The SDK treats it as never sent.',
error as Error,
{ final: true }
)
}
// Unknown from the first answer (a reload after the reject, another
// wallet), too long after signing, or no stored count of one call (two
// or more calls, or a route stored without the count): the bundle may
// still land.
throw new TransactionError(
LiFiErrorCode.CallBundleNotFound,
'The wallet has no record of the call bundle.',
error as Error
)
}

if (result.status === 'success') {
if (result.receipts?.some((receipt) => receipt.status === 'reverted')) {
Expand Down
Loading
Loading