Skip to content

Commit ed274b4

Browse files
committed
fix(checkout): resume wallet routes by right identifier and re-attach
- Poll relayer/gasless routes by taskId, not as a txHash (distinct keys in the SDK status API), so they no longer hang on NOT_FOUND - Resume an unfinished wallet route (in flight or failed) on the execution page so the SDK re-prompts/retries, instead of the poll-only status page - Re-seed an evicted route from the 24h snapshot before resuming
1 parent 3b67ddc commit ed274b4

15 files changed

Lines changed: 280 additions & 54 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@lifi/widget-checkout": minor
3+
"@lifi/widget": minor
4+
---
5+
6+
Fix wallet-flow resume to poll the correct status identifier and re-attach in-flight routes.
7+
8+
A resumed wallet payment now polls by the right identifier: relayer/gasless routes carry a `taskId`, which is distinct from a `txHash` in the SDK status API and was previously polled as a hash (so it never resolved). A still-executing wallet route is now resumed through the SDK on the transaction page, so it prompts for any remaining user action (a second source-chain signature, a destination-chain claim) instead of sitting on a status page it cannot advance. Routes evicted from the route store are re-seeded from the persisted snapshot before resuming.
9+
10+
`@lifi/widget` exports `isRouteActive`, `isRouteDone`, `isRouteFailed`, and the route-execution store accessors from `@lifi/widget/shared`.

packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { renderHook, waitFor } from '@testing-library/react'
55
import type { ReactNode } from 'react'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
vi.mock('@lifi/widget/shared', () => ({ useSDKClient: () => ({}) }))
8+
vi.mock('@lifi/widget/shared', () => ({
9+
useSDKClient: () => ({}),
10+
useRouteExecutionStore: (selector: (s: { routes: object }) => unknown) =>
11+
selector({ routes: {} }),
12+
isRouteFailed: () => false,
13+
}))
914
vi.mock('@lifi/widget-provider/checkout', () => ({
1015
useCheckoutConfig: () => ({ integrator: 'int' }),
1116
}))

packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
'use client'
22
import { getStatus, type StatusResponse } from '@lifi/sdk'
3-
import { useSDKClient } from '@lifi/widget/shared'
3+
import {
4+
isRouteFailed,
5+
useRouteExecutionStore,
6+
useSDKClient,
7+
} from '@lifi/widget/shared'
48
import { useCheckoutConfig } from '@lifi/widget-provider/checkout'
59
import { useQueries } from '@tanstack/react-query'
610
import { useEffect, useMemo } from 'react'
@@ -14,6 +18,7 @@ import { extractStatusHints } from '../utils/statusHints.js'
1418
import {
1519
computeBackoffInterval,
1620
depositAddressQueryKey,
21+
taskIdQueryKey,
1722
txHashQueryKey,
1823
} from '../utils/statusPolling.js'
1924

@@ -39,6 +44,7 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
3944
const records = usePendingCheckoutStore((s) => s.records)
4045
const clearForKey = usePendingCheckoutStore((s) => s.clearForKey)
4146
const markFailed = usePendingCheckoutStore((s) => s.markFailed)
47+
const storedRoutes = useRouteExecutionStore((s) => s.routes)
4248

4349
const entries = useMemo(() => {
4450
const now = Date.now()
@@ -63,6 +69,11 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
6369
!canPollByDeposit &&
6470
!!record.transactionHash &&
6571
record.status !== 'failed'
72+
const canPollByTaskId =
73+
!canPollByDeposit &&
74+
!canPollByHash &&
75+
!!record.taskId &&
76+
record.status !== 'failed'
6677
let queryKey: readonly unknown[]
6778
if (canPollByDeposit) {
6879
queryKey = depositAddressQueryKey(
@@ -71,6 +82,8 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
7182
)
7283
} else if (canPollByHash) {
7384
queryKey = txHashQueryKey(record.transactionHash)
85+
} else if (canPollByTaskId) {
86+
queryKey = taskIdQueryKey(record.taskId)
7487
} else {
7588
queryKey = ['checkout-activity-idle', key]
7689
}
@@ -89,6 +102,16 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
89102
signal,
90103
})
91104
}
105+
if (canPollByTaskId) {
106+
return getStatus(
107+
sdkClient,
108+
{
109+
taskId: record.taskId as string,
110+
...extractStatusHints(record.frozenQuote?.route),
111+
},
112+
{ signal }
113+
)
114+
}
92115
return getStatus(
93116
sdkClient,
94117
{
@@ -98,7 +121,7 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
98121
{ signal }
99122
)
100123
},
101-
enabled: canPollByDeposit || canPollByHash,
124+
enabled: canPollByDeposit || canPollByHash || canPollByTaskId,
102125
refetchInterval: () => computeBackoffInterval(record.createdAt),
103126
}
104127
}),
@@ -138,13 +161,20 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
138161
return entries.map(([key, record], i) => {
139162
const data = results[i]?.data
140163
const depositDetected = Boolean(data && data.status !== 'NOT_FOUND')
164+
// A wallet route can fail locally (e.g. a rejected signature) with no
165+
// pollable status — use the route store's verdict so the card isn't stuck.
166+
const storedRoute =
167+
record.fundingSource === 'wallet' && record.frozenRouteId
168+
? storedRoutes[record.frozenRouteId]?.route
169+
: undefined
141170
let state: PendingActivityState
142171
if (data?.substatus === 'REFUND_IN_PROGRESS') {
143172
state = 'refund'
144173
} else if (
145174
data?.status === 'FAILED' ||
146175
data?.status === 'INVALID' ||
147-
record.status === 'failed'
176+
record.status === 'failed' ||
177+
(storedRoute && isRouteFailed(storedRoute))
148178
) {
149179
state = 'failed'
150180
} else {

packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { HashStatusHints } from '../utils/statusHints.js'
88
import {
99
computeBackoffInterval,
1010
depositAddressQueryKey,
11+
taskIdQueryKey,
1112
txHashQueryKey,
1213
} from '../utils/statusPolling.js'
1314

@@ -22,6 +23,8 @@ export interface CheckoutTransactionStatus {
2223

2324
export interface UseCheckoutTransactionStatusArgs {
2425
transactionHash?: string | null
26+
/** Relayer/gasless task id; distinct from transactionHash in the status API. */
27+
taskId?: string | null
2528
depositAddress?: string | null
2629
fromChain?: number | null
2730
/**
@@ -40,24 +43,28 @@ export interface UseCheckoutTransactionStatusArgs {
4043

4144
export const useCheckoutTransactionStatus = ({
4245
transactionHash,
46+
taskId,
4347
depositAddress,
4448
fromChain,
4549
pauseDepositPoll,
4650
statusHints,
4751
}: UseCheckoutTransactionStatusArgs): CheckoutTransactionStatus => {
4852
const sdkClient = useSDKClient()
49-
// Deposit-funded flows (and IF wallet routes) poll by deposit address; the tx
50-
// hash is a display/details supplement there. A wallet payment on a non-IF
51-
// route has no deposit address, so it polls status by hash instead.
53+
// Deposit-funded flows poll by deposit address (hash/taskId are display-only
54+
// there). A non-IF wallet payment has no deposit address, so it polls by hash,
55+
// or by taskId for a relayer route — distinct keys in the SDK status API.
5256
const canPollByDeposit = !!depositAddress && !!fromChain && !pauseDepositPoll
5357
const canPollByHash = !!transactionHash && !canPollByDeposit
54-
const enabled = canPollByHash || canPollByDeposit
58+
const canPollByTaskId = !!taskId && !canPollByDeposit && !canPollByHash
59+
const enabled = canPollByDeposit || canPollByHash || canPollByTaskId
5560

5661
// Same key as the QR-page poll when we're polling by deposit address —
5762
// react-query shares the cache entry so the handoff is instant.
5863
const queryKey = canPollByDeposit
5964
? depositAddressQueryKey(depositAddress, fromChain)
60-
: txHashQueryKey(transactionHash)
65+
: canPollByHash
66+
? txHashQueryKey(transactionHash)
67+
: taskIdQueryKey(taskId)
6168

6269
// Lazy so the fast-poll backoff window starts when polling actually begins,
6370
// not when the page mounts (polling may be paused at mount).
@@ -72,20 +79,27 @@ export const useCheckoutTransactionStatus = ({
7279
const { data, isLoading } = useQuery({
7380
queryKey,
7481
queryFn: async ({ signal }) => {
82+
if (canPollByDeposit) {
83+
return getDepositAddressStatus({
84+
sdkClient,
85+
depositAddress: depositAddress!,
86+
fromChain: fromChain!,
87+
signal,
88+
})
89+
}
7590
if (canPollByHash) {
7691
return getStatus(
7792
sdkClient,
7893
{ txHash: transactionHash!, ...statusHints },
7994
{ signal }
8095
)
8196
}
82-
if (canPollByDeposit) {
83-
return getDepositAddressStatus({
97+
if (canPollByTaskId) {
98+
return getStatus(
8499
sdkClient,
85-
depositAddress: depositAddress!,
86-
fromChain: fromChain!,
87-
signal,
88-
})
100+
{ taskId: taskId!, ...statusHints },
101+
{ signal }
102+
)
89103
}
90104
return undefined
91105
},

packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => {
5454
})
5555
act(() => {
5656
result.current.writeWallet({
57-
transactionHash: '0xhash',
57+
identifier: { value: '0xhash', kind: 'txHash' },
5858
fromChain: 1,
5959
frozenQuote: frozenQuote('route-1'),
6060
})
@@ -69,7 +69,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => {
6969
})
7070
act(() => {
7171
result.current.writeWallet({
72-
transactionHash: '0xhash',
72+
identifier: { value: '0xhash', kind: 'txHash' },
7373
fromChain: 1,
7474
depositAddress: '0xdep',
7575
frozenQuote: frozenQuote('route-3'),
@@ -80,12 +80,34 @@ describe('usePendingCheckoutWriter — resumePending gate', () => {
8080
expect(record.fundingSource).toBe('wallet')
8181
expect(record.depositAddress).toBe('0xdep')
8282
expect(record.depositId).toBe('0xdep')
83+
expect(record.transactionHash).toBe('0xhash')
84+
expect(record.taskId).toBeUndefined()
85+
// Carries the route id so resume can re-attach to the in-flight route.
86+
expect(record.frozenRouteId).toBe('route-3')
8387
expect(record.frozenQuote?.id).toBe('route-3')
8488
expect(record.fromAmount).toBe('100000000')
8589
expect(record.tokenSymbol).toBe('USDC')
8690
expect(record.tokenDecimals).toBe(6)
8791
})
8892

93+
it('writeWallet stores a relayer route under taskId, not transactionHash', () => {
94+
const { result } = renderHook(() => usePendingCheckoutWriter(), {
95+
wrapper: wrap(true),
96+
})
97+
act(() => {
98+
result.current.writeWallet({
99+
identifier: { value: 'task-123', kind: 'taskId' },
100+
fromChain: 1,
101+
frozenQuote: frozenQuote('route-task'),
102+
})
103+
})
104+
const key = buildResumeKey('int', 'task-123')
105+
const record = usePendingCheckoutStore.getState().records[key]
106+
expect(record.taskId).toBe('task-123')
107+
expect(record.transactionHash).toBeUndefined()
108+
expect(record.frozenRouteId).toBe('route-task')
109+
})
110+
89111
it('writes when resumePending is explicitly true', () => {
90112
const { result } = renderHook(() => usePendingCheckoutWriter(), {
91113
wrapper: wrap(true),
@@ -127,7 +149,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => {
127149
})
128150
act(() => {
129151
result.current.writeWallet({
130-
transactionHash: '0xhash',
152+
identifier: { value: '0xhash', kind: 'txHash' },
131153
fromChain: 1,
132154
frozenQuote: frozenQuote('route-1'),
133155
})
@@ -153,7 +175,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => {
153175
})
154176
act(() => {
155177
result.current.writeWallet({
156-
transactionHash: '0xhash',
178+
identifier: { value: '0xhash', kind: 'txHash' },
157179
fromChain: 1,
158180
frozenQuote: frozenQuote('route-1'),
159181
})
@@ -181,12 +203,12 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => {
181203
// First write has only the tx hash; a later write of the same deposit
182204
// also carries the deposit address. Both must land on one record.
183205
result.current.writeWallet({
184-
transactionHash: '0xhash',
206+
identifier: { value: '0xhash', kind: 'txHash' },
185207
fromChain: 1,
186208
frozenQuote: frozenQuote('route-1'),
187209
})
188210
result.current.writeWallet({
189-
transactionHash: '0xhash',
211+
identifier: { value: '0xhash', kind: 'txHash' },
190212
fromChain: 1,
191213
depositAddress: '0xdep',
192214
frozenQuote: frozenQuote('route-1'),
@@ -206,7 +228,7 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => {
206228
})
207229
act(() => {
208230
flowA.result.current.writeWallet({
209-
transactionHash: '0xAAA',
231+
identifier: { value: '0xAAA', kind: 'txHash' },
210232
fromChain: 1,
211233
frozenQuote: frozenQuote('route-a'),
212234
})
@@ -216,7 +238,7 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => {
216238
})
217239
act(() => {
218240
flowB.result.current.writeWallet({
219-
transactionHash: '0xBBB',
241+
identifier: { value: '0xBBB', kind: 'txHash' },
220242
fromChain: 1,
221243
frozenQuote: frozenQuote('route-b'),
222244
})

packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import {
99
type PersistedFrozenQuote,
1010
usePendingCheckoutStore,
1111
} from '../stores/usePendingCheckoutStore.js'
12+
import type { SourceTxIdentifier } from '../utils/getSourceTxIdentifier.js'
1213

1314
interface WalletWriteArgs {
14-
transactionHash: string
15+
identifier: SourceTxIdentifier
1516
fromChain: number
1617
depositAddress?: string
17-
// Required: the activity list and resume derive cross-chain status hints
18-
// (bridge/toChain) from this route, so a wallet record must always carry it.
18+
// Resume reads cross-chain status hints from this route and uses it to
19+
// re-attach the in-flight route, so a wallet record must always carry it.
1920
frozenQuote: PersistedFrozenQuote
2021
}
2122

@@ -84,23 +85,29 @@ export function usePendingCheckoutWriter(): PendingCheckoutWriter {
8485

8586
const writeWallet = useCallback(
8687
({
87-
transactionHash,
88+
identifier,
8889
fromChain,
8990
depositAddress,
9091
frozenQuote,
9192
}: WalletWriteArgs) => {
9293
if (!enabled) {
9394
return
9495
}
95-
const depositId = resolveDepositId(depositAddress ?? transactionHash)
96+
const transactionHash =
97+
identifier.kind === 'txHash' ? identifier.value : undefined
98+
const taskId = identifier.kind === 'taskId' ? identifier.value : undefined
99+
const depositId = resolveDepositId(depositAddress ?? identifier.value)
96100
write(
97101
buildResumeKey(integrator, depositId),
98102
buildPendingRecord({
99103
depositId,
100104
fundingSource: 'wallet',
101105
transactionHash,
106+
taskId,
102107
fromChain,
103108
depositAddress,
109+
// Route id resume uses to re-attach the in-flight route.
110+
frozenRouteId: frozenQuote.id,
104111
frozenQuote,
105112
...displayFields(frozenQuote),
106113
status: 'pending',

0 commit comments

Comments
 (0)