From 1fb6bad9b5c8c000abb6eceb6b231527001d52c2 Mon Sep 17 00:00:00 2001 From: tomide Date: Tue, 16 Jun 2026 16:48:37 +0200 Subject: [PATCH 1/8] feat(checkout): use any route for wallet payments - Wallet flow drops the Intent-Factory-only exchange override; the deposit flows (transfer/exchange/cash) keep it. - Records with no deposit address poll status by tx hash, deriving cross-chain bridge hints from the frozen route; resume opens the status page by hash. - Wallet records now always persist the frozen quote so those hints are available to the activity list and resume. --- .changeset/checkout-wallet-any-route.md | 7 +++ .../hooks/useCheckoutPendingRecords.test.tsx | 34 ++++++++++- .../src/hooks/useCheckoutPendingRecords.ts | 57 ++++++++++++++----- .../src/hooks/useCheckoutTransactionStatus.ts | 20 +++++-- .../hooks/usePendingCheckoutWriter.test.tsx | 27 +++++++-- .../src/hooks/usePendingCheckoutWriter.ts | 4 +- .../CheckoutTransactionStatusPage.tsx | 2 + .../SelectSourcePage/SelectSourcePage.tsx | 11 +--- .../src/providers/CheckoutAppProvider.tsx | 6 +- .../src/utils/buildResumeNavigation.test.ts | 17 +++++- .../src/utils/buildResumeNavigation.ts | 18 +++++- .../widget-checkout/src/utils/statusHints.ts | 30 ++++++++++ 12 files changed, 194 insertions(+), 39 deletions(-) create mode 100644 .changeset/checkout-wallet-any-route.md create mode 100644 packages/widget-checkout/src/utils/statusHints.ts diff --git a/.changeset/checkout-wallet-any-route.md b/.changeset/checkout-wallet-any-route.md new file mode 100644 index 000000000..122505b82 --- /dev/null +++ b/.changeset/checkout-wallet-any-route.md @@ -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. diff --git a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx index fddbbb2d3..67c9afd72 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx +++ b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx @@ -16,6 +16,11 @@ vi.mock('../utils/depositAddressStatus.js', () => ({ getDepositAddressStatus(...args), })) +const getStatus = vi.fn() +vi.mock('@lifi/sdk', () => ({ + getStatus: (...args: unknown[]) => getStatus(...args), +})) + import { buildPendingRecord, buildResumeKey, @@ -50,6 +55,8 @@ describe('useCheckoutPendingRecords', () => { beforeEach(() => { getDepositAddressStatus.mockReset() getDepositAddressStatus.mockResolvedValue({ status: 'PENDING' }) + getStatus.mockReset() + getStatus.mockResolvedValue({ status: 'PENDING' }) usePendingCheckoutStore.getState().clearAll() }) @@ -129,7 +136,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({ @@ -140,7 +148,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() }) }) diff --git a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts index 8f83f2e1e..03ef5e282 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts @@ -1,5 +1,5 @@ 'use client' -import type { StatusResponse } from '@lifi/sdk' +import { getStatus, type StatusResponse } from '@lifi/sdk' import { useSDKClient } from '@lifi/widget/shared' import { useCheckoutConfig } from '@lifi/widget-provider/checkout' import { useQueries } from '@tanstack/react-query' @@ -10,9 +10,11 @@ import { usePendingCheckoutStore, } from '../stores/usePendingCheckoutStore.js' import { getDepositAddressStatus } from '../utils/depositAddressStatus.js' +import { extractStatusHints } from '../utils/statusHints.js' import { computeBackoffInterval, depositAddressQueryKey, + txHashQueryKey, } from '../utils/statusPolling.js' export type PendingActivityState = 'deposit' | 'refund' | 'failed' @@ -26,8 +28,11 @@ 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() @@ -50,26 +55,50 @@ 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' + let queryKey: readonly unknown[] + if (canPollByDeposit) { + queryKey = depositAddressQueryKey( + record.depositAddress, + record.fromChain + ) + } else if (canPollByHash) { + queryKey = txHashQueryKey(record.transactionHash) + } 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 => - getDepositAddressStatus({ + }): Promise => { + if (canPollByDeposit) { + return getDepositAddressStatus({ + sdkClient, + depositAddress: record.depositAddress as string, + fromChain: record.fromChain as number, + 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, refetchInterval: () => computeBackoffInterval(record.createdAt), } }), diff --git a/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts b/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts index 1310e9c5d..b326e47a6 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts @@ -4,6 +4,7 @@ import { useSDKClient } from '@lifi/widget/shared' import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useEffect, useRef } from 'react' import { getDepositAddressStatus } from '../utils/depositAddressStatus.js' +import type { HashStatusHints } from '../utils/statusHints.js' import { computeBackoffInterval, depositAddressQueryKey, @@ -29,6 +30,12 @@ export interface UseCheckoutTransactionStatusArgs { * paused — a hash means the deposit already happened. */ pauseDepositPoll?: boolean + /** + * Cross-chain hints (bridge/toChain) forwarded to the hash poll. The SDK needs + * `bridge` to resolve a cross-chain transfer by tx hash; ignored on the + * deposit-address path. + */ + statusHints?: HashStatusHints } export const useCheckoutTransactionStatus = ({ @@ -36,11 +43,12 @@ export const useCheckoutTransactionStatus = ({ depositAddress, fromChain, pauseDepositPoll, + statusHints, }: UseCheckoutTransactionStatusArgs): CheckoutTransactionStatus => { const sdkClient = useSDKClient() - // Status is ALWAYS polled by deposit address — the tx hash is a - // display/details supplement, never a status query. The hash path exists - // solely for the transaction details page, which has no deposit address. + // Deposit-funded flows (and IF wallet routes) poll by deposit address; the tx + // hash is a display/details supplement there. A wallet payment on a non-IF + // route has no deposit address, so it polls status by hash instead. const canPollByDeposit = !!depositAddress && !!fromChain && !pauseDepositPoll const canPollByHash = !!transactionHash && !canPollByDeposit const enabled = canPollByHash || canPollByDeposit @@ -65,7 +73,11 @@ export const useCheckoutTransactionStatus = ({ queryKey, queryFn: async ({ signal }) => { if (canPollByHash) { - return getStatus(sdkClient, { txHash: transactionHash! }, { signal }) + return getStatus( + sdkClient, + { txHash: transactionHash!, ...statusHints }, + { signal } + ) } if (canPollByDeposit) { return getDepositAddressStatus({ diff --git a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx index 766ac438d..fe1651183 100644 --- a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx +++ b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx @@ -53,7 +53,11 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { wrapper: wrap(undefined), }) act(() => { - result.current.writeWallet({ transactionHash: '0xhash', fromChain: 1 }) + result.current.writeWallet({ + transactionHash: '0xhash', + fromChain: 1, + frozenQuote: frozenQuote('route-1'), + }) }) const key = buildResumeKey('int', '0xhash') expect(usePendingCheckoutStore.getState().records[key]).toBeDefined() @@ -122,7 +126,11 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { wrapper: wrap(false), }) act(() => { - result.current.writeWallet({ transactionHash: '0xhash', fromChain: 1 }) + result.current.writeWallet({ + transactionHash: '0xhash', + fromChain: 1, + frozenQuote: frozenQuote('route-1'), + }) result.current.writeTransfer({ depositAddress: '0xdep', fromChain: 137, @@ -144,7 +152,11 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { wrapper: wrap(true), }) act(() => { - result.current.writeWallet({ transactionHash: '0xhash', fromChain: 1 }) + result.current.writeWallet({ + transactionHash: '0xhash', + fromChain: 1, + frozenQuote: frozenQuote('route-1'), + }) }) expect(recordKeys().length).toBe(1) const { result: r2 } = renderHook(() => usePendingCheckoutWriter(), { @@ -168,11 +180,16 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => { act(() => { // First write has only the tx hash; a later write of the same deposit // also carries the deposit address. Both must land on one record. - result.current.writeWallet({ transactionHash: '0xhash', fromChain: 1 }) + result.current.writeWallet({ + transactionHash: '0xhash', + fromChain: 1, + frozenQuote: frozenQuote('route-1'), + }) result.current.writeWallet({ transactionHash: '0xhash', fromChain: 1, depositAddress: '0xdep', + frozenQuote: frozenQuote('route-1'), }) }) expect(recordKeys()).toEqual([buildResumeKey('int', '0xhash')]) @@ -191,6 +208,7 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => { flowA.result.current.writeWallet({ transactionHash: '0xAAA', fromChain: 1, + frozenQuote: frozenQuote('route-a'), }) }) const flowB = renderHook(() => usePendingCheckoutWriter(), { @@ -200,6 +218,7 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => { flowB.result.current.writeWallet({ transactionHash: '0xBBB', fromChain: 1, + frozenQuote: frozenQuote('route-b'), }) }) expect(recordKeys().sort()).toEqual( diff --git a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts index e5c288f67..3f41a1be7 100644 --- a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts +++ b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts @@ -14,7 +14,9 @@ interface WalletWriteArgs { transactionHash: string fromChain: number depositAddress?: string - frozenQuote?: PersistedFrozenQuote + // Required: the activity list and resume derive cross-chain status hints + // (bridge/toChain) from this route, so a wallet record must always carry it. + frozenQuote: PersistedFrozenQuote } interface TransferWriteArgs { diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx index 7632731d6..32b29a8ea 100644 --- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx +++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx @@ -30,6 +30,7 @@ import { getReceivingTxLink, } from '../../utils/depositAddressStatus.js' import { checkoutNavigationRoutes } from '../../utils/navigationRoutes.js' +import { extractStatusHints } from '../../utils/statusHints.js' import { StatusCompleted } from './StatusCompleted.js' import { StatusExecuting } from './StatusExecuting.js' import { StatusWatching } from './StatusWatching.js' @@ -84,6 +85,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { depositAddress, fromChain, pauseDepositPoll: isOnRampActive, + statusHints: extractStatusHints(frozenRoute), }) const isRefundInProgress = status?.substatus === 'REFUND_IN_PROGRESS' diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx index e4489ce72..98aba9770 100644 --- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx +++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx @@ -151,20 +151,15 @@ export const SelectSourcePage: React.FC = () => { }, [hasWalletConnected, goToToken]) const handlePayFromWallet = useCallback(() => { - overrideExchanges([...INTENT_FACTORY_ONLY]) + // The wallet flow pays directly from the connected wallet, so it keeps the + // integrator's full route set — no IF-only override (unlike the deposit flows). setFundingSource('wallet') if (hasWalletConnected) { goToToken() return } openWalletMenu() - }, [ - hasWalletConnected, - goToToken, - openWalletMenu, - overrideExchanges, - setFundingSource, - ]) + }, [hasWalletConnected, goToToken, openWalletMenu, setFundingSource]) const handleTransferCrypto = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) diff --git a/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx b/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx index b7dd32ca2..a444591ce 100644 --- a/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx +++ b/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx @@ -42,9 +42,11 @@ const CheckoutAppShell: React.FC = ({ }) => { const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) - // All checkout flows force the IF-only exchange allow-list so the IF tool surfaces a route. + // The deposit-based flows (transfer/exchange/cash) force the IF-only exchange + // allow-list so the IF tool surfaces a deposit-address route. The wallet flow + // pays directly from the connected wallet, so it uses any integrator-allowed route. const effectiveWidgetConfig = useMemo(() => { - if (!fundingSource) { + if (!fundingSource || fundingSource === 'wallet') { return widgetConfig } return { diff --git a/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts b/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts index c1c01ace1..aa47d295c 100644 --- a/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts +++ b/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts @@ -38,7 +38,7 @@ describe('buildResumeNavigation', () => { }) }) - it('wallet record without depositAddress → "/"', () => { + it('wallet record without depositAddress → status page by tx hash', () => { const nav = buildResumeNavigation( fakeRecord({ fundingSource: 'wallet', @@ -46,6 +46,21 @@ describe('buildResumeNavigation', () => { fromChain: 1, }) ) + expect(nav.to).toBe(STATUS_PATH) + expect(nav.search).toEqual({ + transactionHash: '0xabc', + fromChain: 1, + resumed: '1', + }) + }) + + it('wallet record with hash but no fromChain → "/"', () => { + const nav = buildResumeNavigation( + fakeRecord({ + fundingSource: 'wallet', + transactionHash: '0xabc', + }) + ) expect(nav.to).toBe('/') }) diff --git a/packages/widget-checkout/src/utils/buildResumeNavigation.ts b/packages/widget-checkout/src/utils/buildResumeNavigation.ts index d34816339..a8738eb6a 100644 --- a/packages/widget-checkout/src/utils/buildResumeNavigation.ts +++ b/packages/widget-checkout/src/utils/buildResumeNavigation.ts @@ -35,9 +35,9 @@ export function buildResumeNavigation( ) { return { to: TRANSFER_DEPOSIT, search: { resumed: '1' } } } - // Always resume via the deposit address — intent-factory fulfillment is - // tracked by the deposit address, not the source tx hash. The hash, when - // known, rides along for display/details only. + // Deposit-funded flows (and IF wallet routes) resume via the deposit address — + // fulfillment is tracked by the deposit address, not the source tx hash. The + // hash, when known, rides along for display/details only. if (record.depositAddress && record.fromChain !== undefined) { return { to: `/${TRANSACTION_EXECUTION}/${TRANSACTION_STATUS}`, @@ -51,5 +51,17 @@ export function buildResumeNavigation( }, } } + // A wallet payment that took a non-IF route has no deposit address — resume the + // status page by tx hash, which polls getStatus for the in-flight transfer. + if (record.transactionHash && record.fromChain !== undefined) { + return { + to: `/${TRANSACTION_EXECUTION}/${TRANSACTION_STATUS}`, + search: { + transactionHash: record.transactionHash, + fromChain: record.fromChain, + resumed: '1', + }, + } + } return { to: HOME, search: {} } } diff --git a/packages/widget-checkout/src/utils/statusHints.ts b/packages/widget-checkout/src/utils/statusHints.ts new file mode 100644 index 000000000..5f6206988 --- /dev/null +++ b/packages/widget-checkout/src/utils/statusHints.ts @@ -0,0 +1,30 @@ +import type { Route } from '@lifi/sdk' + +export interface HashStatusHints { + bridge?: string + fromChain?: number + toChain?: number +} + +// `getStatus({ txHash })` needs `bridge` (and the chains) to resolve a +// cross-chain transfer by hash; without them the API can stay NOT_FOUND. Derive +// them from the frozen route. Same-chain swaps have no `cross` step, so `bridge` +// is omitted and the hash poll resolves on its own. +export function extractStatusHints( + route: Route | undefined | null +): HashStatusHints { + if (!route) { + return {} + } + const bridge = (route.steps ?? []) + .flatMap((step) => step.includedSteps ?? []) + .find((included) => included.type === 'cross')?.toolDetails?.key + const hints: HashStatusHints = { + fromChain: route.fromChainId, + toChain: route.toChainId, + } + if (bridge) { + hints.bridge = bridge + } + return hints +} From 418cee23e1d218391a37485cf8403fa6922c1053 Mon Sep 17 00:00:00 2001 From: tomide Date: Tue, 16 Jun 2026 16:49:24 +0200 Subject: [PATCH 2/8] feat(checkout): default wallet-flow destination to connected wallet - Pre-fills the recipient with the connected account matching the destination ecosystem when the integrator leaves it user-settable; the field stays editable. - Falls back to the manual "where to send it" prompt when no connected account matches the destination chain. --- .../widget-checkout/src/CheckoutLayout.tsx | 2 + .../hooks/useDefaultWalletRecipient.test.tsx | 78 +++++++++++++++++++ .../src/hooks/useDefaultWalletRecipient.ts | 52 +++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 packages/widget-checkout/src/hooks/useDefaultWalletRecipient.test.tsx create mode 100644 packages/widget-checkout/src/hooks/useDefaultWalletRecipient.ts diff --git a/packages/widget-checkout/src/CheckoutLayout.tsx b/packages/widget-checkout/src/CheckoutLayout.tsx index a8af31225..a2ec30e8f 100644 --- a/packages/widget-checkout/src/CheckoutLayout.tsx +++ b/packages/widget-checkout/src/CheckoutLayout.tsx @@ -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 ( diff --git a/packages/widget-checkout/src/hooks/useDefaultWalletRecipient.test.tsx b/packages/widget-checkout/src/hooks/useDefaultWalletRecipient.test.tsx new file mode 100644 index 000000000..137f9abe4 --- /dev/null +++ b/packages/widget-checkout/src/hooks/useDefaultWalletRecipient.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface MockAccount { + isConnected: boolean + address?: string + chainType: string +} + +let mockAccounts: MockAccount[] = [] +let mockFundingSource: string | null = 'wallet' +let mockResolved: { + isUserSettable: boolean + isUserSet: boolean + setUserRecipient: typeof setUserRecipient +} + +const setUserRecipient = vi.fn() + +vi.mock('@lifi/wallet-management', () => ({ + useAccount: () => ({ accounts: mockAccounts }), +})) +vi.mock('@lifi/widget/shared', () => ({ + useWidgetConfig: () => ({ toChain: 1 }), + useChain: () => ({ chain: { chainType: 'EVM' } }), +})) +vi.mock('../stores/useCheckoutFlowStore.js', () => ({ + useCheckoutFlowStore: ( + selector: (s: { fundingSource: string | null }) => unknown + ) => selector({ fundingSource: mockFundingSource }), +})) +vi.mock('./useResolvedCheckoutRecipient.js', () => ({ + useResolvedCheckoutRecipient: () => mockResolved, +})) + +import { useDefaultWalletRecipient } from './useDefaultWalletRecipient.js' + +describe('useDefaultWalletRecipient', () => { + beforeEach(() => { + setUserRecipient.mockReset() + mockAccounts = [{ isConnected: true, address: '0xabc', chainType: 'EVM' }] + mockFundingSource = 'wallet' + mockResolved = { isUserSettable: true, isUserSet: false, setUserRecipient } + }) + + it('seeds the connected wallet as recipient in the wallet flow', () => { + renderHook(() => useDefaultWalletRecipient()) + expect(setUserRecipient).toHaveBeenCalledWith({ + address: '0xabc', + chainType: 'EVM', + }) + }) + + it('does not seed in a deposit flow', () => { + mockFundingSource = 'transfer' + renderHook(() => useDefaultWalletRecipient()) + expect(setUserRecipient).not.toHaveBeenCalled() + }) + + it('does not seed when the integrator fixed the recipient', () => { + mockResolved = { isUserSettable: false, isUserSet: false, setUserRecipient } + renderHook(() => useDefaultWalletRecipient()) + expect(setUserRecipient).not.toHaveBeenCalled() + }) + + it('does not overwrite a recipient the user already set', () => { + mockResolved = { isUserSettable: true, isUserSet: true, setUserRecipient } + renderHook(() => useDefaultWalletRecipient()) + expect(setUserRecipient).not.toHaveBeenCalled() + }) + + it('falls back to the manual field when no account matches the destination ecosystem', () => { + mockAccounts = [{ isConnected: true, address: 'Sol111', chainType: 'SVM' }] + renderHook(() => useDefaultWalletRecipient()) + expect(setUserRecipient).not.toHaveBeenCalled() + }) +}) diff --git a/packages/widget-checkout/src/hooks/useDefaultWalletRecipient.ts b/packages/widget-checkout/src/hooks/useDefaultWalletRecipient.ts new file mode 100644 index 000000000..aa72952ea --- /dev/null +++ b/packages/widget-checkout/src/hooks/useDefaultWalletRecipient.ts @@ -0,0 +1,52 @@ +'use client' +import { useAccount } from '@lifi/wallet-management' +import { useChain, useWidgetConfig } from '@lifi/widget/shared' +import { useEffect, useRef } from 'react' +import { useCheckoutFlowStore } from '../stores/useCheckoutFlowStore.js' +import { useResolvedCheckoutRecipient } from './useResolvedCheckoutRecipient.js' + +// In the wallet flow the user pays from (and receives into) their own wallet, so +// default the destination to the connected account that matches the destination +// ecosystem. Seeds once and only when the integrator left the recipient +// user-settable and the user hasn't set one — the card stays editable, and a +// cross-ecosystem destination (no matching account) falls back to the manual +// "where to send it" prompt. +export function useDefaultWalletRecipient(): void { + const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) + const { toChain } = useWidgetConfig() + const { chain: destinationChain } = useChain(toChain) + const { isUserSettable, isUserSet, setUserRecipient } = + useResolvedCheckoutRecipient() + const { accounts } = useAccount() + const seededRef = useRef(false) + + useEffect(() => { + if ( + seededRef.current || + fundingSource !== 'wallet' || + !isUserSettable || + isUserSet || + !destinationChain + ) { + return + } + const match = accounts.find( + (a) => + a.isConnected && a.address && a.chainType === destinationChain.chainType + ) + if (match?.address) { + seededRef.current = true + setUserRecipient({ + address: match.address, + chainType: destinationChain.chainType, + }) + } + }, [ + fundingSource, + isUserSettable, + isUserSet, + destinationChain, + accounts, + setUserRecipient, + ]) +} From 7415f7c1149d4ec37fd7506956d09002c9655fad Mon Sep 17 00:00:00 2001 From: tomide Date: Tue, 16 Jun 2026 16:49:49 +0200 Subject: [PATCH 3/8] chore(checkout): rename intent factory exchange key to smartDeposits --- .../widget-checkout/src/hooks/useCheckoutExchangesOverride.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widget-checkout/src/hooks/useCheckoutExchangesOverride.ts b/packages/widget-checkout/src/hooks/useCheckoutExchangesOverride.ts index 512e487c2..e6ad07987 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutExchangesOverride.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutExchangesOverride.ts @@ -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'] From 0cb000c5de2a531d26a35fcfc1080401aff166e9 Mon Sep 17 00:00:00 2001 From: tomide Date: Tue, 16 Jun 2026 23:45:27 +0200 Subject: [PATCH 4/8] feat(checkout): defer token-list highlight until user selects Stops the seeded default (e.g. USDC) from rendering pre-highlighted; a per-flow tokenSelected flag gates it until a genuine tap. --- .../pages/SelectTokenPage/SelectTokenList.tsx | 18 +++++++++++++++--- .../src/stores/useCheckoutFlowStore.tsx | 6 ++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx index 2de63151f..f3ff1d772 100644 --- a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx +++ b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx @@ -15,7 +15,8 @@ import { } from '@lifi/widget/shared' import { Box } from '@mui/material' import type { RefObject } from 'react' -import { type FC, memo, useEffect, useMemo, useRef } from 'react' +import { type FC, memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { useCheckoutFlowStore } from '../../stores/useCheckoutFlowStore.js' export interface SelectTokenListProps { formType: FormType @@ -60,7 +61,18 @@ const TokenListView: FC = ({ const listParentRef = useRef(null) const { listHeight } = useListHeight({ listParentRef, headerRef }) const emitter = useWidgetEvents() - const handleTokenClick = useTokenSelect(formType, afterTokenSelect) + const selectToken = useTokenSelect(formType, afterTokenSelect) + const tokenSelected = useCheckoutFlowStore((s) => s.tokenSelected) + const setTokenSelected = useCheckoutFlowStore((s) => s.setTokenSelected) + + // Don't highlight the seeded default (e.g. USDC) until the user taps a token. + const handleTokenClick = useCallback( + (tokenAddress: string, chainId?: number) => { + setTokenSelected(true) + selectToken(tokenAddress, chainId) + }, + [selectToken, setTokenSelected] + ) const filteredTokens = useMemo(() => { if (!allowedSymbols || allowedSymbols.size === 0) { @@ -102,7 +114,7 @@ const TokenListView: FC = ({ showCategories={showCategories} showPinnedTokens={showPinnedTokens} onClick={handleTokenClick} - selectedTokenAddress={selectedTokenAddress} + selectedTokenAddress={tokenSelected ? selectedTokenAddress : undefined} isAllNetworks={isAllNetworks} /> diff --git a/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx b/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx index bddabf82d..34e83a371 100644 --- a/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx +++ b/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx @@ -18,10 +18,13 @@ export interface CheckoutFlowState { frozenDepositId: string | null /** Exchange account chosen for one-tap reconnect; in-memory, passed to the provider's `open()`. */ selectedExchangeAccount: OnRampAccessToken | null + /** True once the user taps a token in this flow; gates the token-list highlight so the seeded default isn't pre-highlighted. */ + tokenSelected: boolean setFundingSource: (source: CheckoutFundingSource | null) => void setFrozenRouteId: (routeId: string | null) => void setFrozenDepositId: (depositId: string | null) => void setSelectedExchangeAccount: (account: OnRampAccessToken | null) => void + setTokenSelected: (tokenSelected: boolean) => void reset: () => void } @@ -33,17 +36,20 @@ export function createCheckoutFlowStore(): CheckoutFlowStore { frozenRouteId: null, frozenDepositId: null, selectedExchangeAccount: null, + tokenSelected: false, setFundingSource: (fundingSource) => set({ fundingSource }), setFrozenRouteId: (frozenRouteId) => set({ frozenRouteId }), setFrozenDepositId: (frozenDepositId) => set({ frozenDepositId }), setSelectedExchangeAccount: (selectedExchangeAccount) => set({ selectedExchangeAccount }), + setTokenSelected: (tokenSelected) => set({ tokenSelected }), reset: () => set({ fundingSource: null, frozenRouteId: null, frozenDepositId: null, selectedExchangeAccount: null, + tokenSelected: false, }), })) } From 3b67ddc49ee541f22338734074d5ebf445a78717 Mon Sep 17 00:00:00 2001 From: tomide Date: Tue, 16 Jun 2026 23:46:05 +0200 Subject: [PATCH 5/8] feat(checkout): offer connected wallets as destination recipients Fixes already-connected wallets being unselectable: the old commit only fired on a new connection event, never for an existing wallet. --- .../SetDestinationAddressPage.tsx | 69 ++++++++++++++++++- packages/widget/src/i18n/en.json | 1 + 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx b/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx index a5532d3ef..d4203f53f 100644 --- a/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx +++ b/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx @@ -1,6 +1,7 @@ import { useAccount, useWalletMenu } from '@lifi/wallet-management' import { PageContainer, + shortenAddress, useAddressValidation, useChain, useFieldActions, @@ -9,6 +10,7 @@ import { } from '@lifi/widget/shared' import WalletOutlinedIcon from '@mui/icons-material/AccountBalanceWalletOutlined' import { + Avatar, Box, Button, Divider, @@ -18,7 +20,14 @@ import { TextField, Typography, } from '@mui/material' -import { type JSX, useCallback, useEffect, useRef, useState } from 'react' +import { + type JSX, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { useCheckoutNavigate } from '../../hooks/useCheckoutNavigate.js' import { useResolvedCheckoutRecipient } from '../../hooks/useResolvedCheckoutRecipient.js' @@ -31,7 +40,7 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => { const { toChain } = useWidgetConfig() const { chain: destinationChain } = useChain(toChain) const { validateAddress, isValidating } = useAddressValidation() - const { setUserRecipient } = useResolvedCheckoutRecipient() + const { recipient, setUserRecipient } = useResolvedCheckoutRecipient() const { setFieldValue } = useFieldActions() const { openWalletMenu } = useWalletMenu() const { accounts } = useAccount() @@ -96,6 +105,21 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => { } }, [accounts, destinationChain, commitRecipient]) + // Connected wallets in the destination ecosystem, offered as one-tap recipients. + const connectedAccounts = useMemo(() => { + const byAddress = new Map() + for (const account of accounts) { + if ( + account.isConnected && + account.address && + account.chainType === destinationChain?.chainType + ) { + byAddress.set(account.address.toLowerCase(), account) + } + } + return [...byAddress.values()] + }, [accounts, destinationChain]) + return ( @@ -130,6 +154,41 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => { + {connectedAccounts.map((account) => { + const name = + account.connector?.displayName ?? + account.connector?.name ?? + account.name + const shortAddress = shortenAddress(account.address) + const isSelected = + recipient?.address?.toLowerCase() === account.address?.toLowerCase() + return ( + { + if (account.address) { + commitRecipient(account.address, account.chainType) + } + }} + sx={{ borderRadius: 3, height: 56 }} + > + + + + + + + + ) + })} { diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json index f40b9b77d..a3950946b 100644 --- a/packages/widget/src/i18n/en.json +++ b/packages/widget/src/i18n/en.json @@ -511,6 +511,7 @@ "depositWithCash": "Deposit with Cash", "depositWithCashSubtitle": "Debit or credit card", "connectWallet": "Connect wallet", + "useAnotherWallet": "Use another wallet", "moreWallets": "More", "progress": { "title": "Purchase in progress", From ed274b426a23a7c731aa44cc5c14434b034906b1 Mon Sep 17 00:00:00 2001 From: tomide Date: Wed, 17 Jun 2026 01:10:33 +0200 Subject: [PATCH 6/8] 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 --- ...heckout-resume-identifier-and-execution.md | 10 +++++ .../hooks/useCheckoutPendingRecords.test.tsx | 7 ++- .../src/hooks/useCheckoutPendingRecords.ts | 36 +++++++++++++-- .../src/hooks/useCheckoutTransactionStatus.ts | 36 ++++++++++----- .../hooks/usePendingCheckoutWriter.test.tsx | 38 ++++++++++++---- .../src/hooks/usePendingCheckoutWriter.ts | 17 ++++--- .../src/hooks/useResumeCheckout.ts | 18 +++++++- .../src/pages/CheckoutTransactionPage.tsx | 37 +++++++++------- .../CheckoutTransactionStatusPage.tsx | 3 ++ .../src/stores/usePendingCheckoutStore.ts | 5 ++- .../src/utils/buildResumeNavigation.test.ts | 42 ++++++++++++++++++ .../src/utils/buildResumeNavigation.ts | 44 ++++++++++++++++--- .../src/utils/getSourceTxIdentifier.ts | 24 ++++++++++ .../src/utils/statusPolling.ts | 6 +++ packages/widget/src/shared.ts | 11 ++++- 15 files changed, 280 insertions(+), 54 deletions(-) create mode 100644 .changeset/checkout-resume-identifier-and-execution.md create mode 100644 packages/widget-checkout/src/utils/getSourceTxIdentifier.ts diff --git a/.changeset/checkout-resume-identifier-and-execution.md b/.changeset/checkout-resume-identifier-and-execution.md new file mode 100644 index 000000000..0c26f5494 --- /dev/null +++ b/.changeset/checkout-resume-identifier-and-execution.md @@ -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`. diff --git a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx index 67c9afd72..8d16ffcc4 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx +++ b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.test.tsx @@ -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' }), })) diff --git a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts index 03ef5e282..05efb6e41 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutPendingRecords.ts @@ -1,6 +1,10 @@ 'use client' import { getStatus, type StatusResponse } from '@lifi/sdk' -import { useSDKClient } from '@lifi/widget/shared' +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' @@ -14,6 +18,7 @@ import { extractStatusHints } from '../utils/statusHints.js' import { computeBackoffInterval, depositAddressQueryKey, + taskIdQueryKey, txHashQueryKey, } from '../utils/statusPolling.js' @@ -39,6 +44,7 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] { 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() @@ -63,6 +69,11 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] { !canPollByDeposit && !!record.transactionHash && record.status !== 'failed' + const canPollByTaskId = + !canPollByDeposit && + !canPollByHash && + !!record.taskId && + record.status !== 'failed' let queryKey: readonly unknown[] if (canPollByDeposit) { queryKey = depositAddressQueryKey( @@ -71,6 +82,8 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] { ) } else if (canPollByHash) { queryKey = txHashQueryKey(record.transactionHash) + } else if (canPollByTaskId) { + queryKey = taskIdQueryKey(record.taskId) } else { queryKey = ['checkout-activity-idle', key] } @@ -89,6 +102,16 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] { signal, }) } + if (canPollByTaskId) { + return getStatus( + sdkClient, + { + taskId: record.taskId as string, + ...extractStatusHints(record.frozenQuote?.route), + }, + { signal } + ) + } return getStatus( sdkClient, { @@ -98,7 +121,7 @@ export function useCheckoutPendingRecords(): PendingActivityItem[] { { signal } ) }, - enabled: canPollByDeposit || canPollByHash, + enabled: canPollByDeposit || canPollByHash || canPollByTaskId, refetchInterval: () => computeBackoffInterval(record.createdAt), } }), @@ -138,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 { diff --git a/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts b/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts index b326e47a6..a4b796da9 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutTransactionStatus.ts @@ -8,6 +8,7 @@ import type { HashStatusHints } from '../utils/statusHints.js' import { computeBackoffInterval, depositAddressQueryKey, + taskIdQueryKey, txHashQueryKey, } from '../utils/statusPolling.js' @@ -22,6 +23,8 @@ export interface CheckoutTransactionStatus { export interface UseCheckoutTransactionStatusArgs { transactionHash?: string | null + /** Relayer/gasless task id; distinct from transactionHash in the status API. */ + taskId?: string | null depositAddress?: string | null fromChain?: number | null /** @@ -40,24 +43,28 @@ export interface UseCheckoutTransactionStatusArgs { export const useCheckoutTransactionStatus = ({ transactionHash, + taskId, depositAddress, fromChain, pauseDepositPoll, statusHints, }: UseCheckoutTransactionStatusArgs): CheckoutTransactionStatus => { const sdkClient = useSDKClient() - // Deposit-funded flows (and IF wallet routes) poll by deposit address; the tx - // hash is a display/details supplement there. A wallet payment on a non-IF - // route has no deposit address, so it polls status by hash instead. + // Deposit-funded flows poll by deposit address (hash/taskId are display-only + // there). A non-IF wallet payment has no deposit address, so it polls by hash, + // or by taskId for a relayer route — distinct keys in the SDK status API. const canPollByDeposit = !!depositAddress && !!fromChain && !pauseDepositPoll const canPollByHash = !!transactionHash && !canPollByDeposit - const enabled = canPollByHash || canPollByDeposit + const canPollByTaskId = !!taskId && !canPollByDeposit && !canPollByHash + const enabled = canPollByDeposit || canPollByHash || canPollByTaskId // Same key as the QR-page poll when we're polling by deposit address — // react-query shares the cache entry so the handoff is instant. const queryKey = canPollByDeposit ? depositAddressQueryKey(depositAddress, fromChain) - : txHashQueryKey(transactionHash) + : canPollByHash + ? txHashQueryKey(transactionHash) + : taskIdQueryKey(taskId) // Lazy so the fast-poll backoff window starts when polling actually begins, // not when the page mounts (polling may be paused at mount). @@ -72,6 +79,14 @@ export const useCheckoutTransactionStatus = ({ const { data, isLoading } = useQuery({ queryKey, queryFn: async ({ signal }) => { + if (canPollByDeposit) { + return getDepositAddressStatus({ + sdkClient, + depositAddress: depositAddress!, + fromChain: fromChain!, + signal, + }) + } if (canPollByHash) { return getStatus( sdkClient, @@ -79,13 +94,12 @@ export const useCheckoutTransactionStatus = ({ { signal } ) } - if (canPollByDeposit) { - return getDepositAddressStatus({ + if (canPollByTaskId) { + return getStatus( sdkClient, - depositAddress: depositAddress!, - fromChain: fromChain!, - signal, - }) + { taskId: taskId!, ...statusHints }, + { signal } + ) } return undefined }, diff --git a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx index fe1651183..2d554c4e9 100644 --- a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx +++ b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.test.tsx @@ -54,7 +54,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { }) act(() => { result.current.writeWallet({ - transactionHash: '0xhash', + identifier: { value: '0xhash', kind: 'txHash' }, fromChain: 1, frozenQuote: frozenQuote('route-1'), }) @@ -69,7 +69,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { }) act(() => { result.current.writeWallet({ - transactionHash: '0xhash', + identifier: { value: '0xhash', kind: 'txHash' }, fromChain: 1, depositAddress: '0xdep', frozenQuote: frozenQuote('route-3'), @@ -80,12 +80,34 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { expect(record.fundingSource).toBe('wallet') expect(record.depositAddress).toBe('0xdep') expect(record.depositId).toBe('0xdep') + expect(record.transactionHash).toBe('0xhash') + expect(record.taskId).toBeUndefined() + // Carries the route id so resume can re-attach to the in-flight route. + expect(record.frozenRouteId).toBe('route-3') expect(record.frozenQuote?.id).toBe('route-3') expect(record.fromAmount).toBe('100000000') expect(record.tokenSymbol).toBe('USDC') expect(record.tokenDecimals).toBe(6) }) + it('writeWallet stores a relayer route under taskId, not transactionHash', () => { + const { result } = renderHook(() => usePendingCheckoutWriter(), { + wrapper: wrap(true), + }) + act(() => { + result.current.writeWallet({ + identifier: { value: 'task-123', kind: 'taskId' }, + fromChain: 1, + frozenQuote: frozenQuote('route-task'), + }) + }) + const key = buildResumeKey('int', 'task-123') + const record = usePendingCheckoutStore.getState().records[key] + expect(record.taskId).toBe('task-123') + expect(record.transactionHash).toBeUndefined() + expect(record.frozenRouteId).toBe('route-task') + }) + it('writes when resumePending is explicitly true', () => { const { result } = renderHook(() => usePendingCheckoutWriter(), { wrapper: wrap(true), @@ -127,7 +149,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { }) act(() => { result.current.writeWallet({ - transactionHash: '0xhash', + identifier: { value: '0xhash', kind: 'txHash' }, fromChain: 1, frozenQuote: frozenQuote('route-1'), }) @@ -153,7 +175,7 @@ describe('usePendingCheckoutWriter — resumePending gate', () => { }) act(() => { result.current.writeWallet({ - transactionHash: '0xhash', + identifier: { value: '0xhash', kind: 'txHash' }, fromChain: 1, frozenQuote: frozenQuote('route-1'), }) @@ -181,12 +203,12 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => { // First write has only the tx hash; a later write of the same deposit // also carries the deposit address. Both must land on one record. result.current.writeWallet({ - transactionHash: '0xhash', + identifier: { value: '0xhash', kind: 'txHash' }, fromChain: 1, frozenQuote: frozenQuote('route-1'), }) result.current.writeWallet({ - transactionHash: '0xhash', + identifier: { value: '0xhash', kind: 'txHash' }, fromChain: 1, depositAddress: '0xdep', frozenQuote: frozenQuote('route-1'), @@ -206,7 +228,7 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => { }) act(() => { flowA.result.current.writeWallet({ - transactionHash: '0xAAA', + identifier: { value: '0xAAA', kind: 'txHash' }, fromChain: 1, frozenQuote: frozenQuote('route-a'), }) @@ -216,7 +238,7 @@ describe('usePendingCheckoutWriter — frozen deposit key', () => { }) act(() => { flowB.result.current.writeWallet({ - transactionHash: '0xBBB', + identifier: { value: '0xBBB', kind: 'txHash' }, fromChain: 1, frozenQuote: frozenQuote('route-b'), }) diff --git a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts index 3f41a1be7..9a91120f8 100644 --- a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts +++ b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts @@ -9,13 +9,14 @@ import { type PersistedFrozenQuote, usePendingCheckoutStore, } from '../stores/usePendingCheckoutStore.js' +import type { SourceTxIdentifier } from '../utils/getSourceTxIdentifier.js' interface WalletWriteArgs { - transactionHash: string + identifier: SourceTxIdentifier fromChain: number depositAddress?: string - // Required: the activity list and resume derive cross-chain status hints - // (bridge/toChain) from this route, so a wallet record must always carry it. + // Resume reads cross-chain status hints from this route and uses it to + // re-attach the in-flight route, so a wallet record must always carry it. frozenQuote: PersistedFrozenQuote } @@ -84,7 +85,7 @@ export function usePendingCheckoutWriter(): PendingCheckoutWriter { const writeWallet = useCallback( ({ - transactionHash, + identifier, fromChain, depositAddress, frozenQuote, @@ -92,15 +93,21 @@ export function usePendingCheckoutWriter(): PendingCheckoutWriter { if (!enabled) { return } - const depositId = resolveDepositId(depositAddress ?? transactionHash) + const transactionHash = + identifier.kind === 'txHash' ? identifier.value : undefined + const taskId = identifier.kind === 'taskId' ? identifier.value : undefined + const depositId = resolveDepositId(depositAddress ?? identifier.value) write( buildResumeKey(integrator, depositId), buildPendingRecord({ depositId, fundingSource: 'wallet', transactionHash, + taskId, fromChain, depositAddress, + // Route id resume uses to re-attach the in-flight route. + frozenRouteId: frozenQuote.id, frozenQuote, ...displayFields(frozenQuote), status: 'pending', diff --git a/packages/widget-checkout/src/hooks/useResumeCheckout.ts b/packages/widget-checkout/src/hooks/useResumeCheckout.ts index c86b01f78..6c9b001f3 100644 --- a/packages/widget-checkout/src/hooks/useResumeCheckout.ts +++ b/packages/widget-checkout/src/hooks/useResumeCheckout.ts @@ -1,4 +1,5 @@ 'use client' +import { isRouteDone, useRouteExecutionStoreContext } from '@lifi/widget/shared' import { useNavigate } from '@tanstack/react-router' import { useCallback, useContext } from 'react' import { CheckoutFlowStoreContext } from '../stores/useCheckoutFlowStore.js' @@ -13,6 +14,7 @@ export function useResumeCheckout(): ( const navigate = useNavigate() const flowStore = useContext(CheckoutFlowStoreContext) const seedFrozenQuote = useSeedFrozenQuote() + const routeStore = useRouteExecutionStoreContext() return useCallback( (record: PendingRecord, depositDetected?: boolean) => { @@ -30,12 +32,26 @@ export function useResumeCheckout(): ( expiresAt: record.frozenQuote.expiresAt, }) } + // Any unfinished wallet route (in flight, or failed and awaiting retry) + // resumes on the execution page. Re-seed from the 24h snapshot if it was + // evicted from the route store so resumeRoute can re-attach. + let routeResumable = false + if (record.fundingSource === 'wallet' && record.frozenRouteId) { + const state = routeStore.getState() + let stored = state.routes[record.frozenRouteId]?.route + if (!stored && record.frozenQuote?.route) { + state.setExecutableRoute(record.frozenQuote.route) + stored = routeStore.getState().routes[record.frozenRouteId]?.route + } + routeResumable = !!stored && !isRouteDone(stored) + } const nav = buildResumeNavigation(record, { frozenQuoteFresh, depositDetected, + routeResumable, }) navigate({ to: nav.to, search: nav.search }) }, - [navigate, flowStore, seedFrozenQuote] + [navigate, flowStore, seedFrozenQuote, routeStore] ) } diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionPage.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionPage.tsx index 222105df6..97da6b35b 100644 --- a/packages/widget-checkout/src/pages/CheckoutTransactionPage.tsx +++ b/packages/widget-checkout/src/pages/CheckoutTransactionPage.tsx @@ -42,6 +42,7 @@ import { CheckoutExecutionProgress } from '../components/CheckoutExecutionProgre import { FROZEN_QUOTE_TTL_MS, useFrozenQuote } from '../hooks/useFrozenQuote.js' import { usePendingCheckoutWriter } from '../hooks/usePendingCheckoutWriter.js' import { extractDepositAddress } from '../utils/extractDepositAddress.js' +import { getSourceTxIdentifier } from '../utils/getSourceTxIdentifier.js' import { checkoutNavigationRoutes } from '../utils/navigationRoutes.js' const statusPath = `/${checkoutNavigationRoutes.transactionExecution}/${checkoutNavigationRoutes.transactionStatus}` @@ -58,36 +59,40 @@ function PendingCheckoutWalletHandoff({ const navigate = useNavigate() const { writeWallet } = usePendingCheckoutWriter() const { freeze } = useFrozenQuote() - // A hash already present when a route is first observed belongs to a - // previous execution (persisted route state is reused for identical - // quotes) — only a hash appearing live triggers the handoff. + // Only an identifier appearing live triggers handoff; one already present on + // first observe belongs to a prior execution (route state is reused across + // identical quotes). const observedRef = useRef<{ routeId: string - skipHash: string | null - handledHash: string | null + skipValue: string | null + handledValue: string | null } | null>(null) useEffect(() => { if (!route) { observedRef.current = null return } - const hash = getSourceTxHash(route) ?? null + const identifier = getSourceTxIdentifier(route) ?? null const observed = observedRef.current if (!observed || observed.routeId !== route.id) { observedRef.current = { routeId: route.id, - skipHash: hash, - handledHash: null, + skipValue: identifier?.value ?? null, + handledValue: null, } return } - if (!hash || hash === observed.skipHash || hash === observed.handledHash) { + if ( + !identifier || + identifier.value === observed.skipValue || + identifier.value === observed.handledValue + ) { return } - observed.handledHash = hash + observed.handledValue = identifier.value const depositAddress = extractDepositAddress(route) ?? undefined writeWallet({ - transactionHash: hash, + identifier, fromChain: route.fromChainId, depositAddress, frozenQuote: { @@ -99,19 +104,19 @@ function PendingCheckoutWalletHandoff({ if (!handoffEnabled || !depositAddress) { return } - // The status page's tx-hash poll is the single status source from here — - // stop the SDK's background execution (it runs its own status poll) and - // drop the persisted route record so a re-quote of the same route can't - // resurface this execution. + // IF routes are relayer-fulfilled and tracked by deposit address from here: + // stop background execution and drop the route so a re-quote can't resurface it. freeze(route) stopRouteExecution(route) onHandoff() navigate({ to: statusPath, search: { - transactionHash: hash, depositAddress, fromChain: route.fromChainId, + ...(identifier.kind === 'txHash' + ? { transactionHash: identifier.value } + : { taskId: identifier.value }), }, replace: true, }) diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx index 32b29a8ea..60fd9e347 100644 --- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx +++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx @@ -38,6 +38,7 @@ import { resolveStatusVariant } from './statusVariants.js' interface StatusSearch { transactionHash?: string + taskId?: string depositAddress?: string fromChain?: number walletDisconnected?: boolean @@ -60,6 +61,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { const navigate = useNavigate() const { search } = useLocation() as { search: StatusSearch } const transactionHash = search.transactionHash ?? null + const taskId = search.taskId ?? null const depositAddress = search.depositAddress ?? null const fromChain = typeof search.fromChain === 'number' ? search.fromChain : null @@ -82,6 +84,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { const { status, phase, isLoading, notFound } = useCheckoutTransactionStatus({ transactionHash, + taskId, depositAddress, fromChain, pauseDepositPoll: isOnRampActive, diff --git a/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts b/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts index 17fe35d8e..e263ea46c 100644 --- a/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts +++ b/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts @@ -3,7 +3,7 @@ import type { Route } from '@lifi/sdk' import { create, type StoreApi, type UseBoundStore } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' -export const PENDING_RECORD_VERSION = 3 +export const PENDING_RECORD_VERSION = 4 export const PENDING_STORAGE_KEY = 'lifi-checkout-pending' export const PENDING_TTL_MS: number = 24 * 60 * 60 * 1000 @@ -23,6 +23,8 @@ export interface PendingRecord { depositId?: string fundingSource: PendingFundingSource transactionHash?: string + /** Relayer/gasless task id; mutually exclusive with transactionHash. */ + taskId?: string depositAddress?: string fromChain?: number provider?: PendingProvider @@ -135,6 +137,7 @@ export function buildPendingRecord( depositId: partial.depositId, fundingSource: partial.fundingSource, transactionHash: partial.transactionHash, + taskId: partial.taskId, depositAddress: partial.depositAddress, fromChain: partial.fromChain, provider: partial.provider, diff --git a/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts b/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts index aa47d295c..686e0f073 100644 --- a/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts +++ b/packages/widget-checkout/src/utils/buildResumeNavigation.test.ts @@ -132,6 +132,48 @@ describe('buildResumeNavigation', () => { expect(nav.search.depositAddress).toBe('0xdep') }) + it('wallet route still resumable → execution page with routeId (not status)', () => { + const nav = buildResumeNavigation( + fakeRecord({ + fundingSource: 'wallet', + transactionHash: '0xabc', + fromChain: 1, + frozenRouteId: 'route-9', + }), + { routeResumable: true } + ) + expect(nav.to).toBe('/transaction-execution') + expect(nav.search).toEqual({ routeId: 'route-9', resumed: '1' }) + }) + + it('routeResumable is ignored when the record carries no routeId', () => { + const nav = buildResumeNavigation( + fakeRecord({ + fundingSource: 'wallet', + transactionHash: '0xabc', + fromChain: 1, + }), + { routeResumable: true } + ) + expect(nav.to).toBe(STATUS_PATH) + }) + + it('wallet record with taskId (no hash) → status page by taskId', () => { + const nav = buildResumeNavigation( + fakeRecord({ + fundingSource: 'wallet', + taskId: 'task-1', + fromChain: 1, + }) + ) + expect(nav.to).toBe(STATUS_PATH) + expect(nav.search).toEqual({ + taskId: 'task-1', + fromChain: 1, + resumed: '1', + }) + }) + it('cash + frozenQuoteFresh is ignored (cash never uses transfer-deposit)', () => { const nav = buildResumeNavigation( fakeRecord({ diff --git a/packages/widget-checkout/src/utils/buildResumeNavigation.ts b/packages/widget-checkout/src/utils/buildResumeNavigation.ts index a8738eb6a..d140bef98 100644 --- a/packages/widget-checkout/src/utils/buildResumeNavigation.ts +++ b/packages/widget-checkout/src/utils/buildResumeNavigation.ts @@ -8,6 +8,17 @@ const TRANSACTION_STATUS = 'transaction-status' const TRANSFER_DEPOSIT = '/transfer-deposit' const HOME = '/' +function statusSearch(record: PendingRecord): Record { + // Identifier rides along for the details link; deposit polling drives status. + if (record.transactionHash) { + return { transactionHash: record.transactionHash } + } + if (record.taskId) { + return { taskId: record.taskId } + } + return {} +} + export interface ResumeNavigation { to: string search: Record @@ -18,12 +29,29 @@ export interface BuildResumeNavigationOptions { frozenQuoteFresh?: boolean /** True once the deposit has been detected on-chain (live status !== NOT_FOUND). */ depositDetected?: boolean + /** + * Wallet route present and not finished (in flight or failed). Resume re-attaches + * SDK execution on the transaction page so the user can continue or retry. + */ + routeResumable?: boolean } export function buildResumeNavigation( record: PendingRecord, options: BuildResumeNavigationOptions = {} ): ResumeNavigation { + // An unfinished wallet route (in flight or failed) resumes on the execution + // page so the SDK can re-attach to prompt/retry; a status page can't advance it. + if ( + options.routeResumable && + record.fundingSource === 'wallet' && + record.frozenRouteId + ) { + return { + to: `/${TRANSACTION_EXECUTION}`, + search: { routeId: record.frozenRouteId, resumed: '1' }, + } + } // Reopen the QR/deposit-address page only while the window is still open AND // no deposit has landed yet — so the user can finish sending. Once a deposit // is detected, fall through to the status page to track it. @@ -44,20 +72,22 @@ export function buildResumeNavigation( search: { depositAddress: record.depositAddress, fromChain: record.fromChain, - ...(record.transactionHash - ? { transactionHash: record.transactionHash } - : {}), + ...statusSearch(record), resumed: '1', }, } } - // A wallet payment that took a non-IF route has no deposit address — resume the - // status page by tx hash, which polls getStatus for the in-flight transfer. - if (record.transactionHash && record.fromChain !== undefined) { + // Non-IF wallet payment, no deposit address and no longer resumable (done on + // the source side or evicted): poll status by its identifier (taskId/txHash + // are distinct keys). + if ( + (record.transactionHash || record.taskId) && + record.fromChain !== undefined + ) { return { to: `/${TRANSACTION_EXECUTION}/${TRANSACTION_STATUS}`, search: { - transactionHash: record.transactionHash, + ...statusSearch(record), fromChain: record.fromChain, resumed: '1', }, diff --git a/packages/widget-checkout/src/utils/getSourceTxIdentifier.ts b/packages/widget-checkout/src/utils/getSourceTxIdentifier.ts new file mode 100644 index 000000000..f1fa8d349 --- /dev/null +++ b/packages/widget-checkout/src/utils/getSourceTxIdentifier.ts @@ -0,0 +1,24 @@ +import type { RouteExtended } from '@lifi/sdk' + +export interface SourceTxIdentifier { + value: string + kind: 'txHash' | 'taskId' +} + +// Preserve the kind: the SDK status API resolves taskId and txHash differently, +// so a relayed route's taskId must never be polled as a txHash. +export const getSourceTxIdentifier = ( + route?: RouteExtended +): SourceTxIdentifier | undefined => { + const sourceAction = route?.steps[0].execution?.actions + ?.filter( + (action) => !['RESET_ALLOWANCE', 'SET_ALLOWANCE'].includes(action.type) + ) + .find((action) => action.txHash || action.taskId) + if (!sourceAction) { + return undefined + } + return sourceAction.txHash + ? { value: sourceAction.txHash, kind: 'txHash' } + : { value: sourceAction.taskId!, kind: 'taskId' } +} diff --git a/packages/widget-checkout/src/utils/statusPolling.ts b/packages/widget-checkout/src/utils/statusPolling.ts index 19f09d587..cb5aac449 100644 --- a/packages/widget-checkout/src/utils/statusPolling.ts +++ b/packages/widget-checkout/src/utils/statusPolling.ts @@ -13,6 +13,12 @@ export function txHashQueryKey( return [ROOT, 'hash', transactionHash ?? null] } +export function taskIdQueryKey( + taskId: string | null | undefined +): readonly unknown[] { + return [ROOT, 'task', taskId ?? null] +} + export function simulateQueryKey( simulate: string | null | undefined, substatus?: string | null diff --git a/packages/widget/src/shared.ts b/packages/widget/src/shared.ts index 301cd820b..b114099d8 100644 --- a/packages/widget/src/shared.ts +++ b/packages/widget/src/shared.ts @@ -141,8 +141,17 @@ export { useSetHeaderHeight, } from './stores/header/useHeaderStore.js' export { useInputModeStore } from './stores/inputMode/useInputModeStore.js' +export { + useRouteExecutionStore, + useRouteExecutionStoreContext, +} from './stores/routes/RouteExecutionStore.js' export { RouteExecutionStatus } from './stores/routes/types.js' -export { getSourceTxHash } from './stores/routes/utils.js' +export { + getSourceTxHash, + isRouteActive, + isRouteDone, + isRouteFailed, +} from './stores/routes/utils.js' export { StoreProvider } from './stores/StoreProvider.js' export { SettingsStoreProvider, From a622864efff97c93a4ab9ac19e0592407b900dcc Mon Sep 17 00:00:00 2001 From: tomide Date: Wed, 17 Jun 2026 01:33:37 +0200 Subject: [PATCH 7/8] feat(checkout): restrict to EVM chains and hide native gas token - Force chains.types to EVM-only; cascades to chains, tokens, routes, wallets - Hide native gas token in transfer/exchange/cash flows (IF can't accept it) - Wallet menu now honors chains.types ecosystem allow/deny config --- .changeset/checkout-evm-only.md | 10 +++++++ .../SelectSourcePage/SelectSourcePage.tsx | 18 ++++++++++++- .../pages/SelectTokenPage/SelectTokenList.tsx | 14 +++++++--- .../pages/SelectTokenPage/SelectTokenPage.tsx | 6 ++--- .../src/utils/checkoutToWidgetConfig.test.ts | 23 ++++++++++++++++ .../src/utils/checkoutToWidgetConfig.ts | 6 +++++ .../src/utils/nativeToken.test.ts | 27 +++++++++++++++++++ .../widget-checkout/src/utils/nativeToken.ts | 4 +++ .../WalletProvider/WalletProvider.tsx | 18 ++++++++++--- 9 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 .changeset/checkout-evm-only.md create mode 100644 packages/widget-checkout/src/utils/nativeToken.test.ts create mode 100644 packages/widget-checkout/src/utils/nativeToken.ts diff --git a/.changeset/checkout-evm-only.md b/.changeset/checkout-evm-only.md new file mode 100644 index 000000000..eb508e22d --- /dev/null +++ b/.changeset/checkout-evm-only.md @@ -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. diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx index 98aba9770..e5e6d045f 100644 --- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx +++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx @@ -38,6 +38,7 @@ import { DEFAULT_FROM_CHAIN_ID, DEFAULT_FROM_TOKEN_ADDRESS, } from '../../utils/checkoutDefaults.js' +import { isNativeToken } from '../../utils/nativeToken.js' import { checkoutNavigationRoutes } from '../../utils/navigationRoutes.js' import { CheckoutActivitySection } from './CheckoutActivitySection.js' import { SelectSourceFundingOptions } from './SelectSourceFundingOptions.js' @@ -164,8 +165,23 @@ export const SelectSourcePage: React.FC = () => { const handleTransferCrypto = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) setFundingSource('transfer') + // IF deposits can't accept the native gas token; reset a carried-over pick. + if (isNativeToken(prevTokenAddress)) { + setFieldValue(FormKeyHelper.getChainKey('from'), DEFAULT_FROM_CHAIN_ID) + setFieldValue( + FormKeyHelper.getTokenKey('from'), + DEFAULT_FROM_TOKEN_ADDRESS + ) + setFieldValue(FormKeyHelper.getAmountKey('from'), '') + } goToToken() - }, [goToToken, overrideExchanges, setFundingSource]) + }, [ + goToToken, + overrideExchanges, + prevTokenAddress, + setFieldValue, + setFundingSource, + ]) const pinExchangeSource = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) diff --git a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx index f3ff1d772..c39faa1d5 100644 --- a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx +++ b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx @@ -17,6 +17,7 @@ import { Box } from '@mui/material' import type { RefObject } from 'react' import { type FC, memo, useCallback, useEffect, useMemo, useRef } from 'react' import { useCheckoutFlowStore } from '../../stores/useCheckoutFlowStore.js' +import { isNativeToken } from '../../utils/nativeToken.js' export interface SelectTokenListProps { formType: FormType @@ -38,6 +39,8 @@ type SharedListProps = Omit & { selectedTokenAddress?: string isAllNetworks: boolean tokenSearchFilter?: string + // IF deposit flows can't accept the native gas token; only the wallet flow keeps it. + excludeNative: boolean } type TokenListResult = ReturnType @@ -47,6 +50,7 @@ const TokenListView: FC = ({ headerRef, afterTokenSelect, allowedSymbols, + excludeNative, selectedChainId, selectedTokenAddress, isAllNetworks, @@ -75,15 +79,18 @@ const TokenListView: FC = ({ ) const filteredTokens = useMemo(() => { + const withoutNative = excludeNative + ? tokens.filter((token) => !isNativeToken(token.address)) + : tokens if (!allowedSymbols || allowedSymbols.size === 0) { - return tokens + return withoutNative } // Strip on-wallet amounts — the curated list funds from an exchange, // not the connected wallet — regardless of which hook fed the list. - return tokens + return withoutNative .filter((token) => allowedSymbols.has(token.symbol.toUpperCase())) .map((token) => ({ ...token, amount: undefined })) - }, [tokens, allowedSymbols]) + }, [tokens, allowedSymbols, excludeNative]) const showCategories = !allowedSymbols && withCategories && !tokenSearchFilter && !isAllNetworks @@ -170,6 +177,7 @@ export const SelectTokenList: FC = memo( headerRef, afterTokenSelect, allowedSymbols, + excludeNative: !isWalletFunded, selectedChainId, selectedTokenAddress, isAllNetworks, diff --git a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx index 87b5f67ab..90534e9dd 100644 --- a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx +++ b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx @@ -34,10 +34,8 @@ export const SelectTokenPage: React.FC = () => { const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) const isWalletFunded = useIsWalletFundedFlow() const isExchangeFlow = fundingSource === 'exchange' - const exchangeAllowedSymbols = useMemo( - () => new Set(['USDC', 'USDT', 'ETH']), - [] - ) + // IF deposits can't accept the native gas token, so the curated set is ERC20-only. + const exchangeAllowedSymbols = useMemo(() => new Set(['USDC', 'USDT']), []) const hideChainSelect = hiddenUI?.chainSelect || isExchangeFlow const isMobile = useMediaQuery((theme: Theme) => diff --git a/packages/widget-checkout/src/utils/checkoutToWidgetConfig.test.ts b/packages/widget-checkout/src/utils/checkoutToWidgetConfig.test.ts index c8586b56b..ace1b8510 100644 --- a/packages/widget-checkout/src/utils/checkoutToWidgetConfig.test.ts +++ b/packages/widget-checkout/src/utils/checkoutToWidgetConfig.test.ts @@ -1,3 +1,4 @@ +import { ChainType } from '@lifi/sdk' import { describe, expect, it } from 'vitest' import type { CheckoutConfig } from '../types/config.js' import { checkoutConfigToWidgetConfig } from './checkoutToWidgetConfig.js' @@ -57,4 +58,26 @@ describe('checkoutConfigToWidgetConfig', () => { const result = checkoutConfigToWidgetConfig(minimalCheckout) expect(result.integrator).toBe('test-integrator') }) + + it('forces EVM-only chain types', () => { + const result = checkoutConfigToWidgetConfig(minimalCheckout) + expect(result.chains?.types?.allow).toEqual([ChainType.EVM]) + }) + + it('preserves integrator chain-id allow alongside the forced EVM type lock', () => { + const result = checkoutConfigToWidgetConfig({ + integrator: 'x', + config: { chains: { allow: [1, 137] } }, + }) + expect(result.chains?.allow).toEqual([1, 137]) + expect(result.chains?.types?.allow).toEqual([ChainType.EVM]) + }) + + it('overrides an integrator-supplied non-EVM chain type', () => { + const result = checkoutConfigToWidgetConfig({ + integrator: 'x', + config: { chains: { types: { allow: [ChainType.SVM] } } }, + }) + expect(result.chains?.types?.allow).toEqual([ChainType.EVM]) + }) }) diff --git a/packages/widget-checkout/src/utils/checkoutToWidgetConfig.ts b/packages/widget-checkout/src/utils/checkoutToWidgetConfig.ts index 79aeeafb8..2a1130e00 100644 --- a/packages/widget-checkout/src/utils/checkoutToWidgetConfig.ts +++ b/packages/widget-checkout/src/utils/checkoutToWidgetConfig.ts @@ -1,3 +1,4 @@ +import { ChainType } from '@lifi/sdk' import type { WidgetConfig } from '@lifi/widget/shared' import type { CheckoutConfig } from '../types/config.js' @@ -15,6 +16,11 @@ export function checkoutConfigToWidgetConfig( // toChain/toToken/toAddress are required config; CheckoutConfigGuard blocks when missing. return { ...merged, + // EVM-only for now; cascades to chains, tokens, routes, and the wallet menu. + chains: { + ...merged.chains, + types: { allow: [ChainType.EVM] }, + }, hiddenUI: { ...merged.hiddenUI, toToken: true, diff --git a/packages/widget-checkout/src/utils/nativeToken.test.ts b/packages/widget-checkout/src/utils/nativeToken.test.ts new file mode 100644 index 000000000..9ac2cd1a5 --- /dev/null +++ b/packages/widget-checkout/src/utils/nativeToken.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { isNativeToken, NATIVE_TOKEN_ADDRESS } from './nativeToken.js' + +describe('isNativeToken', () => { + it('matches the zero sentinel address', () => { + expect(isNativeToken(NATIVE_TOKEN_ADDRESS)).toBe(true) + }) + + it('is case-insensitive', () => { + expect(isNativeToken('0x0000000000000000000000000000000000000000')).toBe( + true + ) + expect(isNativeToken('0X0000000000000000000000000000000000000000')).toBe( + true + ) + }) + + it('returns false for ERC20 addresses', () => { + expect(isNativeToken('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe( + false + ) + }) + + it('returns false for undefined', () => { + expect(isNativeToken(undefined)).toBe(false) + }) +}) diff --git a/packages/widget-checkout/src/utils/nativeToken.ts b/packages/widget-checkout/src/utils/nativeToken.ts new file mode 100644 index 000000000..18710f1f4 --- /dev/null +++ b/packages/widget-checkout/src/utils/nativeToken.ts @@ -0,0 +1,4 @@ +export const NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000' + +export const isNativeToken = (address?: string): boolean => + address?.toLowerCase() === NATIVE_TOKEN_ADDRESS diff --git a/packages/widget/src/providers/WalletProvider/WalletProvider.tsx b/packages/widget/src/providers/WalletProvider/WalletProvider.tsx index ac912ca47..9965c99b7 100644 --- a/packages/widget/src/providers/WalletProvider/WalletProvider.tsx +++ b/packages/widget/src/providers/WalletProvider/WalletProvider.tsx @@ -10,6 +10,7 @@ import { import { useTranslation } from 'react-i18next' import { useAvailableChains } from '../../hooks/useAvailableChains.js' import { useInitializeSDKProviders } from '../../hooks/useInitializeSDKProviders.js' +import { getConfigItemSets, isItemAllowedForSets } from '../../utils/item.js' import { useWidgetConfig } from '../WidgetProvider/WidgetProvider.js' import { useExternalWalletProvider } from './useExternalWalletProvider.js' @@ -21,12 +22,23 @@ export const WalletProvider = ({ children, providers, }: PropsWithChildren): JSX.Element => { - const { walletConfig } = useWidgetConfig() + const { walletConfig, chains: chainsConfig } = useWidgetConfig() const { chains } = useAvailableChains() const { i18n } = useTranslation() const { useExternalWalletProvidersOnly, internalChainTypes } = useExternalWalletProvider() + // Restrict offered wallets to the ecosystems allowed by chains.types config. + const enabledChainTypes = useMemo(() => { + const chainTypeSets = getConfigItemSets( + chainsConfig?.types, + (items) => new Set(items) + ) + return internalChainTypes.filter((chainType) => + isItemAllowedForSets(chainType, chainTypeSets) + ) + }, [internalChainTypes, chainsConfig?.types]) + if ( !providers.length && !useExternalWalletProvidersOnly && @@ -40,12 +52,12 @@ export const WalletProvider = ({ const walletManagementConfig = useMemo( () => ({ locale: i18n.resolvedLanguage as never, - enabledChainTypes: internalChainTypes, + enabledChainTypes, walletEcosystemsOrder: walletConfig?.walletEcosystemsOrder, }), [ i18n.resolvedLanguage, - internalChainTypes, + enabledChainTypes, walletConfig?.walletEcosystemsOrder, ] ) From 06113ff9b4374bc3caf5bcf74d18ac36e1e10b81 Mon Sep 17 00:00:00 2001 From: tomide Date: Tue, 23 Jun 2026 14:35:56 +0100 Subject: [PATCH 8/8] fix(checkout): harden deposit and status flows against errors --- .changeset/checkout-deposit-resilience.md | 11 +++ .../src/components/CheckoutFlowCtaButton.tsx | 25 ++++- .../src/hooks/useCheckoutFlowQuote.ts | 14 ++- .../src/hooks/useCheckoutTransactionStatus.ts | 40 ++++++-- .../CheckoutTransactionStatusPage.tsx | 94 ++++++++++++++++--- .../useTransferStatusPoll.ts | 17 ++-- .../OnRampProvider/OnRampProvider.tsx | 5 + .../PendingCheckoutPersistenceBridge.tsx | 3 +- .../widget-provider-mesh/src/MeshHost.tsx | 1 + .../src/TransakHost.tsx | 19 +++- packages/widget-provider/src/checkout/api.ts | 2 +- .../contexts/OnRampSessionsContext.test.ts | 1 + .../widget-provider/src/checkout/types.ts | 7 ++ 13 files changed, 201 insertions(+), 38 deletions(-) create mode 100644 .changeset/checkout-deposit-resilience.md diff --git a/.changeset/checkout-deposit-resilience.md b/.changeset/checkout-deposit-resilience.md new file mode 100644 index 000000000..2858120c2 --- /dev/null +++ b/.changeset/checkout-deposit-resilience.md @@ -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. diff --git a/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx b/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx index 7132f6f34..41d6b2fa8 100644 --- a/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx +++ b/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx @@ -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) @@ -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 ( + + ) + } + return (