Skip to content
11 changes: 11 additions & 0 deletions .changeset/checkout-deposit-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@lifi/widget-checkout": patch
---

Harden the checkout deposit and status flows against errors and transient states.

- Latch the last real deposit-address status so a regressive `NOT_FOUND` (e.g. the simulation-failure to refund path) no longer collapses a refund or executing screen back to "watching".
- A resumed deposit with a deposit address keeps watching on `NOT_FOUND` instead of being dropped and bounced to amount entry: a just-started deposit that isn't indexed yet stays recoverable.
- Surface failed session, step-transaction, and status-poll calls as a retryable error screen (or a retry CTA) instead of stranding on a disabled button or an endless spinner.
- Cancelling an on-ramp provider pops the status entry instead of stacking a duplicate amount screen, so the first Back press is no longer a no-op.
- Deposit transfers always hand off to the status page by deposit address, never by a tx hash, so a refund can't land on a misleading tx-hash status that 404-loops.
10 changes: 10 additions & 0 deletions .changeset/checkout-evm-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@lifi/widget-checkout": minor
"@lifi/widget": minor
---

Restrict checkout to EVM chains and tokens, and hide the native gas token in deposit flows.

Checkout now forces `chains.types` to EVM-only, so chain lists, token lists, route quotes, and wallet/recipient selection surface only EVM chains and their native + ERC20 tokens. The native gas token is hidden from source-token selection in the transfer/exchange/cash (Intent Factory) flows, which cannot accept it; the wallet flow keeps full token support.

`@lifi/widget`'s wallet menu now honors the `chains.types` allow/deny config, so a restricted ecosystem set only offers wallets for the allowed chain types.
10 changes: 10 additions & 0 deletions .changeset/checkout-resume-identifier-and-execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@lifi/widget-checkout": minor
"@lifi/widget": minor
---

Fix wallet-flow resume to poll the correct status identifier and re-attach in-flight routes.

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.

`@lifi/widget` exports `isRouteActive`, `isRouteDone`, `isRouteFailed`, and the route-execution store accessors from `@lifi/widget/shared`.
7 changes: 7 additions & 0 deletions .changeset/checkout-wallet-any-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@lifi/widget-checkout": minor
---

Let the wallet checkout flow use any available route instead of only the Intent Factory route. Wallet payments now quote across all integrator-allowed exchanges, with activity tracking and resume falling back to tx-hash status polling when a route has no deposit address. The deposit-based flows (transfer, exchange, cash) keep their Intent Factory restriction.

In the wallet flow, the destination now defaults to the connected wallet (matching the destination ecosystem) when the integrator leaves the recipient user-settable, so users no longer have to fill in the "where to send it" field manually. The field stays editable, and a cross-ecosystem destination still falls back to manual entry.
2 changes: 2 additions & 0 deletions packages/widget-checkout/src/CheckoutLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { CheckoutToastHost } from './components/CheckoutToastHost.js'
import { Container, ExpandedContainer } from './components/Container.js'
import { Header } from './components/Header.js'
import { OnRampHostedModals } from './components/OnRampHostedModals.js'
import { useDefaultWalletRecipient } from './hooks/useDefaultWalletRecipient.js'
import { useSyncCheckoutRecipientToForm } from './hooks/useSyncCheckoutRecipientToForm.js'

export const CheckoutLayout: React.FC = () => {
const { elementId } = useWidgetConfig()
useDefaultWalletRecipient()
useSyncCheckoutRecipientToForm()

return (
Expand Down
25 changes: 23 additions & 2 deletions packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,14 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => {
const emitter = useWidgetEvents()
const { toAddress, requiredToAddress } = useToAddressRequirements()
const { recipient, isUserSettable } = useResolvedCheckoutRecipient()
const { route, routes, depositAddress, setReviewableRoute } =
useCheckoutFlowQuote()
const {
route,
routes,
depositAddress,
isError,
refetch,
setReviewableRoute,
} = useCheckoutFlowQuote()
const { freeze } = useFrozenQuote()
const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) ?? 'wallet'
const setFrozenRouteId = useCheckoutFlowStore((s) => s.setFrozenRouteId)
Expand Down Expand Up @@ -152,6 +158,21 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => {
)
}

// A failed step leaves no deposit address, so the CTA can never enable.
if (isError) {
return (
<Button
variant="contained"
color="primary"
fullWidth
onClick={() => refetch()}
sx={{ flex: 1 }}
>
{t('button.tryAgain')}
</Button>
)
}

return (
<Button
variant="contained"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ export function useCheckoutExchangesOverride(): (allow: string[]) => void {
)
}

export const INTENT_FACTORY_ONLY: readonly string[] = ['intentFactoryDex']
export const INTENT_FACTORY_ONLY: readonly string[] = ['smartDeposits']
14 changes: 12 additions & 2 deletions packages/widget-checkout/src/hooks/useCheckoutFlowQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { LiFiStep, Route } from '@lifi/sdk'
import { getStepTransaction } from '@lifi/sdk'
import { useSDKClient } from '@lifi/widget/shared'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { extractDepositAddress } from '../utils/extractDepositAddress.js'
import { useCheckoutRoutes } from './useCheckoutRoutes.js'

Expand All @@ -13,6 +13,7 @@ export interface CheckoutFlowQuote {
isLoading: boolean
isFetching: boolean
isFetched: boolean
isError: boolean
refetch: () => void
setReviewableRoute: (route: Route) => void
}
Expand All @@ -37,6 +38,8 @@ export function useCheckoutFlowQuote(): CheckoutFlowQuote {
isLoading: stepLoading,
isFetching: stepFetching,
isFetched: stepFetched,
isError: stepError,
refetch: refetchStep,
} = useQuery<LiFiStep | undefined>({
queryKey: [
'checkout-step-transaction',
Expand Down Expand Up @@ -66,14 +69,21 @@ export function useCheckoutFlowQuote(): CheckoutFlowQuote {
}
}, [rawRoute, populatedStep])

// A step-only failure won't re-run from a routes refetch (routes stay cached).
const refetchAll = useCallback(() => {
refetch()
refetchStep()
}, [refetch, refetchStep])

return {
route,
routes,
depositAddress: extractDepositAddress(route),
isLoading: routesLoading || (Boolean(rawRoute) && stepLoading),
isFetching: routesFetching || (Boolean(rawRoute) && stepFetching),
isFetched: routesFetched && (!rawRoute || stepFetched),
refetch,
isError: Boolean(rawRoute) && stepError,
refetch: refetchAll,
setReviewableRoute,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@lifi/widget/shared', () => ({ useSDKClient: () => ({}) }))
vi.mock('@lifi/widget/shared', () => ({
useSDKClient: () => ({}),
useRouteExecutionStore: (selector: (s: { routes: object }) => unknown) =>
selector({ routes: {} }),
isRouteFailed: () => false,
}))
vi.mock('@lifi/widget-provider/checkout', () => ({
useCheckoutConfig: () => ({ integrator: 'int' }),
}))
Expand All @@ -16,6 +21,11 @@ vi.mock('../utils/depositAddressStatus.js', () => ({
getDepositAddressStatus(...args),
}))

const getStatus = vi.fn()
vi.mock('@lifi/sdk', () => ({
getStatus: (...args: unknown[]) => getStatus(...args),
}))

import {
buildPendingRecord,
buildResumeKey,
Expand Down Expand Up @@ -50,6 +60,8 @@ describe('useCheckoutPendingRecords', () => {
beforeEach(() => {
getDepositAddressStatus.mockReset()
getDepositAddressStatus.mockResolvedValue({ status: 'PENDING' })
getStatus.mockReset()
getStatus.mockResolvedValue({ status: 'PENDING' })
usePendingCheckoutStore.getState().clearAll()
})

Expand Down Expand Up @@ -129,7 +141,8 @@ describe('useCheckoutPendingRecords', () => {
expect(getDepositAddressStatus).not.toHaveBeenCalled()
})

it('does not poll a record without a deposit address', async () => {
it('polls a deposit-address-less wallet record by tx hash and clears it on DONE', async () => {
getStatus.mockResolvedValue({ status: 'DONE' })
usePendingCheckoutStore.getState().write(
buildResumeKey('int', 'h1'),
buildPendingRecord({
Expand All @@ -140,7 +153,29 @@ describe('useCheckoutPendingRecords', () => {
})
)
renderHook(() => useCheckoutPendingRecords(), { wrapper: wrap() })
await new Promise((resolve) => setTimeout(resolve, 20))
await waitFor(() => expect(getStatus).toHaveBeenCalled())
expect(getStatus.mock.calls[0]?.[1]).toMatchObject({ txHash: '0xhash' })
expect(getDepositAddressStatus).not.toHaveBeenCalled()
await waitFor(() =>
expect(
usePendingCheckoutStore.getState().records['int:h1']
).toBeUndefined()
)
})

it('polls an IF wallet record (deposit + hash) by deposit address, never by hash', async () => {
usePendingCheckoutStore.getState().write(
buildResumeKey('int', 'd1'),
buildPendingRecord({
fundingSource: 'wallet',
depositAddress: '0xdep',
transactionHash: '0xhash',
fromChain: 1,
status: 'pending',
})
)
renderHook(() => useCheckoutPendingRecords(), { wrapper: wrap() })
await waitFor(() => expect(getDepositAddressStatus).toHaveBeenCalled())
expect(getStatus).not.toHaveBeenCalled()
})
})
91 changes: 75 additions & 16 deletions packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
'use client'
import type { StatusResponse } from '@lifi/sdk'
import { useSDKClient } from '@lifi/widget/shared'
import { getStatus, type StatusResponse } from '@lifi/sdk'
import {
isRouteFailed,
useRouteExecutionStore,
useSDKClient,
} from '@lifi/widget/shared'
import { useCheckoutConfig } from '@lifi/widget-provider/checkout'
import { useQueries } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
Expand All @@ -10,9 +14,12 @@ import {
usePendingCheckoutStore,
} from '../stores/usePendingCheckoutStore.js'
import { getDepositAddressStatus } from '../utils/depositAddressStatus.js'
import { extractStatusHints } from '../utils/statusHints.js'
import {
computeBackoffInterval,
depositAddressQueryKey,
taskIdQueryKey,
txHashQueryKey,
} from '../utils/statusPolling.js'

export type PendingActivityState = 'deposit' | 'refund' | 'failed'
Expand All @@ -26,14 +33,18 @@ export interface PendingActivityItem {
depositDetected: boolean
}

// The deposit-address poll is the single reconciler: done/refunded clears the
// record, failed marks it (kept as a dismissible card). No address → no poll.
// The status poll is the single reconciler: done/refunded clears the record,
// failed marks it (kept as a dismissible card). Deposit-funded records poll by
// deposit address; wallet records that took a non-IF route (no deposit address)
// poll by tx hash instead. Deposit address takes precedence — a record with both
// (IF wallet) always polls by deposit address, never by hash.
export function useCheckoutPendingRecords(): PendingActivityItem[] {
const { integrator } = useCheckoutConfig()
const sdkClient = useSDKClient()
const records = usePendingCheckoutStore((s) => s.records)
const clearForKey = usePendingCheckoutStore((s) => s.clearForKey)
const markFailed = usePendingCheckoutStore((s) => s.markFailed)
const storedRoutes = useRouteExecutionStore((s) => s.routes)

const entries = useMemo(() => {
const now = Date.now()
Expand All @@ -50,26 +61,67 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {

const results = useQueries({
queries: entries.map(([key, record]) => {
const canPoll =
const canPollByDeposit =
!!record.depositAddress &&
record.fromChain !== undefined &&
record.status !== 'failed'
const canPollByHash =
!canPollByDeposit &&
!!record.transactionHash &&
record.status !== 'failed'
const canPollByTaskId =
!canPollByDeposit &&
!canPollByHash &&
!!record.taskId &&
record.status !== 'failed'
let queryKey: readonly unknown[]
if (canPollByDeposit) {
queryKey = depositAddressQueryKey(
record.depositAddress,
record.fromChain
)
} else if (canPollByHash) {
queryKey = txHashQueryKey(record.transactionHash)
} else if (canPollByTaskId) {
queryKey = taskIdQueryKey(record.taskId)
} else {
queryKey = ['checkout-activity-idle', key]
}
return {
queryKey: canPoll
? depositAddressQueryKey(record.depositAddress, record.fromChain)
: ['checkout-activity-idle', key],
queryKey,
queryFn: async ({
signal,
}: {
signal: AbortSignal
}): Promise<StatusResponse | undefined> =>
getDepositAddressStatus({
}): Promise<StatusResponse | undefined> => {
if (canPollByDeposit) {
return getDepositAddressStatus({
sdkClient,
depositAddress: record.depositAddress as string,
fromChain: record.fromChain as number,
signal,
})
}
if (canPollByTaskId) {
return getStatus(
sdkClient,
{
taskId: record.taskId as string,
...extractStatusHints(record.frozenQuote?.route),
},
{ signal }
)
}
return getStatus(
sdkClient,
depositAddress: record.depositAddress as string,
fromChain: record.fromChain as number,
signal,
}),
enabled: canPoll,
{
txHash: record.transactionHash as string,
...extractStatusHints(record.frozenQuote?.route),
},
{ signal }
)
},
enabled: canPollByDeposit || canPollByHash || canPollByTaskId,
refetchInterval: () => computeBackoffInterval(record.createdAt),
}
}),
Expand Down Expand Up @@ -109,13 +161,20 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] {
return entries.map(([key, record], i) => {
const data = results[i]?.data
const depositDetected = Boolean(data && data.status !== 'NOT_FOUND')
// A wallet route can fail locally (e.g. a rejected signature) with no
// pollable status — use the route store's verdict so the card isn't stuck.
const storedRoute =
record.fundingSource === 'wallet' && record.frozenRouteId
? storedRoutes[record.frozenRouteId]?.route
: undefined
let state: PendingActivityState
if (data?.substatus === 'REFUND_IN_PROGRESS') {
state = 'refund'
} else if (
data?.status === 'FAILED' ||
data?.status === 'INVALID' ||
record.status === 'failed'
record.status === 'failed' ||
(storedRoute && isRouteFailed(storedRoute))
) {
state = 'failed'
} else {
Expand Down
Loading