Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ const mockCrossChainTransfer = {
isCalculating: false,
isXChain: false,
isDiffToken: false,
isFeeEstimationError: false,
quoteExpiresAt: null as string | null,
error: null,
calculate: jest.fn(),
reset: jest.fn(),
Expand Down Expand Up @@ -274,7 +276,71 @@ const confirm = async () => {
beforeEach(() => {
jest.clearAllMocks()
mockRecordPayment.mockResolvedValue(PAYMENT_RESULT)
Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false })
Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false, quoteExpiresAt: null })
})

describe('crypto withdraw confirm — expired Rhino quote', () => {
afterEach(() => jest.useRealTimers())

it('re-quotes instead of signing when the quote aged out while the screen sat open', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') })
// Fresh at render: expires in 60s.
Object.assign(mockCrossChainTransfer, {
isXChain: true,
quoteExpiresAt: new Date(Date.now() + 60_000).toISOString(),
})
render(<WithdrawCryptoPage />)

const quotesBeforeTap = mockCrossChainTransfer.calculate.mock.calls.length

// …then the user waits past it. No render happens in between.
jest.setSystemTime(Date.now() + 120_000)
fireEvent.click(screen.getByTestId('confirm-withdraw'))

await waitFor(() => expect(mockCrossChainTransfer.calculate).toHaveBeenCalledTimes(quotesBeforeTap + 1))
expect(mockSendTransactions).not.toHaveBeenCalled()
expect(mockSendMoney).not.toHaveBeenCalled()
expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS')
})

it('a tap with nothing prepared (an expiry refresh that failed) re-quotes instead of dead-ending', async () => {
Object.assign(mockCrossChainTransfer, { isXChain: true, transactions: null, quoteExpiresAt: null })
try {
render(<WithdrawCryptoPage />)
const quotesBeforeTap = mockCrossChainTransfer.calculate.mock.calls.length

fireEvent.click(screen.getByTestId('confirm-withdraw'))

await waitFor(() => expect(mockCrossChainTransfer.calculate).toHaveBeenCalledTimes(quotesBeforeTap + 1))
expect(mockSendTransactions).not.toHaveBeenCalled()
expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true }))
} finally {
Object.assign(mockCrossChainTransfer, { transactions: [{ to: RECIPIENT, value: 0n, data: '0x' }] })
}
})

it('signs while the quote is still fresh', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') })
Object.assign(mockCrossChainTransfer, {
isXChain: true,
quoteExpiresAt: new Date(Date.now() + 120_000).toISOString(),
})
mockSendTransactions.mockResolvedValue({
userOpHash: '0xuserop',
receipt: { transactionHash: '0xmined', status: 'success' },
strategy: 'mixed',
intentId: 'prep-intent-9',
})
render(<WithdrawCryptoPage />)
// Entering the confirm view quotes once; the tap must not quote again.
const quotesBeforeTap = mockCrossChainTransfer.calculate.mock.calls.length

jest.setSystemTime(Date.now() + 10_000)
fireEvent.click(screen.getByTestId('confirm-withdraw'))

await waitFor(() => expect(mockSendTransactions).toHaveBeenCalled())
expect(mockCrossChainTransfer.calculate).toHaveBeenCalledTimes(quotesBeforeTap)
})
})

// ---------- tests ----------
Expand Down
81 changes: 54 additions & 27 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { tokenSelectorContext } from '@/context/tokenSelector.context'
import { useAppHaptic } from '@/hooks/useAppHaptic'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer'
import { isQuoteNearExpiry } from '@/services/rhino-bridge'
import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder'
import { isTxReverted, printableAddress, validateEnsName } from '@/utils/general.utils'
import { appBaseUrl } from '@/utils/url.utils'
Expand Down Expand Up @@ -96,6 +97,8 @@ export default function WithdrawCryptoPage() {
isXChain,
isDiffToken,
error: routeError,
isFeeEstimationError,
quoteExpiresAt,
calculate: calculateRoute,
reset: resetRouteCalculation,
} = useCrossChainTransfer()
Expand Down Expand Up @@ -164,33 +167,39 @@ export default function WithdrawCryptoPage() {
}
}, [routeError, recordError, setPaymentError])

// Quote the route (Rhino preview + SDA / bridge quote, or the same-chain
// tx). Runs on entering the confirm view and again before signing when the
// quote on screen has expired.
const quoteRoute = useCallback(() => {
if (!chargeDetails || !withdrawData || !address) return Promise.resolve()
return calculateRoute({
source: {
address: address as Address,
tokenAddress: PEANUT_WALLET_TOKEN as Address,
chainId: PEANUT_WALLET_CHAIN.id.toString(),
// amountToWithdraw is USD-denominated; source token is USDC (1:1).
// Required for the bridge path's 'pay' mode (cross-chain ETH/etc).
tokenAmount: amountToWithdraw,
},
destination: {
recipientAddress: chargeDetails.requestLink.recipientAddress as Address,
tokenAddress: chargeDetails.tokenAddress as Address,
tokenAmount: chargeDetails.tokenAmount,
tokenDecimals: chargeDetails.tokenDecimals,
tokenType: Number(chargeDetails.tokenType),
chainId: chargeDetails.chainId,
},
context: 'withdraw',
contextId: chargeDetails.uuid,
senderPeanutWalletAddress: address as Address,
skipGasEstimate: true, // peanut wallet handles gas
})
}, [chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw])

// prepare transaction when entering confirm view
useEffect(() => {
if (currentView === 'CONFIRM' && chargeDetails && withdrawData && address) {
calculateRoute({
source: {
address: address as Address,
tokenAddress: PEANUT_WALLET_TOKEN as Address,
chainId: PEANUT_WALLET_CHAIN.id.toString(),
// amountToWithdraw is USD-denominated; source token is USDC (1:1).
// Required for the bridge path's 'pay' mode (cross-chain ETH/etc).
tokenAmount: amountToWithdraw,
},
destination: {
recipientAddress: chargeDetails.requestLink.recipientAddress as Address,
tokenAddress: chargeDetails.tokenAddress as Address,
tokenAmount: chargeDetails.tokenAmount,
tokenDecimals: chargeDetails.tokenDecimals,
tokenType: Number(chargeDetails.tokenType),
chainId: chargeDetails.chainId,
},
context: 'withdraw',
contextId: chargeDetails.uuid,
senderPeanutWalletAddress: address as Address,
skipGasEstimate: true, // peanut wallet handles gas
})
}
}, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw])
if (currentView === 'CONFIRM') void quoteRoute()
}, [currentView, quoteRoute])

const handleSetupReview = useCallback(
async (data: Omit<WithdrawData, 'amount'>) => {
Expand Down Expand Up @@ -338,8 +347,23 @@ export default function WithdrawCryptoPage() {
}

if (!transactions || transactions.length === 0) {
console.error('No transactions prepared for withdrawal')
setError(t('errors.txNotPrepared'))
// Nothing prepared — the route never resolved, or an expiry refresh
// just failed. Quote again instead of dead-ending on "not prepared";
// a persistent failure keeps surfacing through routeError.
await quoteRoute()
return
}

// The numbers on screen are Rhino's quote only until it expires. Decide
// that NOW, at the tap — a render-time flag goes stale on a screen left
// open — with the signing lead time the bridge path uses. Past expiry,
// refresh and let the user confirm the fresh numbers instead of signing
// a stale pay amount — unless funds already moved for this charge (the
// record-only retry below must never re-quote).
const alreadySpent = executedSpendRef.current?.chargeId === chargeDetails.uuid
const quoteExpired = quoteExpiresAt ? isQuoteNearExpiry(quoteExpiresAt) : false
if (quoteExpired && !alreadySpent) {
await quoteRoute()
Comment thread
abalinda marked this conversation as resolved.
return
}

Expand Down Expand Up @@ -530,6 +554,8 @@ export default function WithdrawCryptoPage() {
address,
transactions,
payAmount,
quoteExpiresAt,
quoteRoute,
usdAmount,
sendTransactions,
sendMoney,
Expand Down Expand Up @@ -648,6 +674,7 @@ export default function WithdrawCryptoPage() {
networkFee={networkFee}
isCrossChain={isCrossChainWithdrawal}
isCalculating={isCalculating}
quoteFailed={isFeeEstimationError}
receiveAmount={receiveAmount}
payAmount={payAmount}
showHighFeeWarning={showHighFeeWarning}
Expand Down
6 changes: 6 additions & 0 deletions src/components/Claim/Claim.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export interface ClaimXChainPreview {
receiveAmount: string
/** Rhino fee in USD. */
feeUsd: number
/** The address the account-bound quote was priced for — a cached route
* is only valid for that recipient (see findClaimRoute). */
quotedFor: string
/** ISO expiry of the Rhino quote behind receiveAmount/feeUsd; an expired
* route is a cache miss (see findClaimRoute). */
expiresAt: string
}
export type ClaimType = 'claim' | 'claimxchain'

Expand Down
79 changes: 69 additions & 10 deletions src/components/Claim/Link/Initial.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import ActionModal from '@/components/Global/ActionModal'
import { BankFlowManager } from './views/BankFlowManager.view'
import { type ClaimXChainPreview } from '../Claim.consts'
import { previewSdaTransfer } from '@/services/rhino-sda'
import { findClaimRoute, resolveClaimQuoteRecipient } from '@/utils/claim-route.utils'
import { evmChainIdToRhinoName } from '@/constants/rhino.consts'
import { getTokenSymbol } from '@/utils/general.utils'
import { Button } from '@/components/0_Bruddle/Button'
Expand Down Expand Up @@ -211,6 +212,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
}, [user, resetClaimBankFlow])

const hasTrackedClaimView = useRef(false)
// Each route quote gets a generation; a result whose generation is no
// longer current (the recipient changed, or a newer quote started) is
// cached but never selected — a slow quote for A must not land on a
// confirm screen for B.
const quoteGenerationRef = useRef(0)
useEffect(() => {
if (claimLinkData && !hasTrackedClaimView.current) {
hasTrackedClaimView.current = true
Expand Down Expand Up @@ -608,6 +614,23 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
setIsValidRecipient(!!recipient.address)
}, [recipient.address])

// A route is priced for one recipient (account-bound quote). Switching the
// external address drops the stale selection and re-quotes for the new one;
// an unchanged effective recipient (bank claims, the Peanut wallet) keeps it.
useEffect(() => {
if (!selectedRoute) return
const quotedFor = resolveClaimQuoteRecipient({
recipientAddress: recipient.address,
walletAddress: address,
senderAddress: claimLinkData.senderAddress,
})
if (selectedRoute.quotedFor.toLowerCase() === quotedFor.toLowerCase()) return
quoteGenerationRef.current += 1
setSelectedRoute(undefined)
setHasFetchedRoute(false)
setRefetchXchainRoute(true)
}, [recipient.address, address, claimLinkData.senderAddress, selectedRoute, setSelectedRoute, setHasFetchedRoute])

useEffect(() => {
if (!selectedTokenData) return
if (
Expand Down Expand Up @@ -644,11 +667,19 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
}
const chainId = toChain ?? selectedTokenData!.chainId
const tokenAddress = toToken ?? selectedTokenData!.address
// The quote is account- and address-bound, so a cached route is only
// valid for the recipient it was priced for.
const quotedFor = resolveClaimQuoteRecipient({
recipientAddress: recipient.address,
walletAddress: address,
senderAddress: claimLinkData.senderAddress,
})

Comment thread
abalinda marked this conversation as resolved.
const generation = ++quoteGenerationRef.current
const isCurrent = () => generation === quoteGenerationRef.current

try {
const existingRoute = routes.find(
(route) => route.chainId === chainId && areEvmAddressesEqual(route.tokenAddress, tokenAddress)
)
const existingRoute = findClaimRoute(routes, { chainId, tokenAddress, quotedFor })
Comment thread
abalinda marked this conversation as resolved.

if (existingRoute) {
setSelectedRoute(existingRoute)
Expand Down Expand Up @@ -676,29 +707,42 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
// Rhino preview expects a decimal string, so format down.
const decimals = selectedTokenData?.decimals ?? 6
const previewAmount = formatUnits(claimLinkData.amount, decimals)
// The SDA deposit itself comes from the Peanut claim relayer, so
// the link sender's address (always an EVM address on the link's
// chain) stands in as depositor for pricing.
const preview = await previewSdaTransfer({
chainIn: sourceRhinoChain,
chainOut: destRhinoChain,
token: tokenSymbol,
amount: previewAmount,
mode: 'pay',
depositor: claimLinkData.senderAddress,
Comment thread
abalinda marked this conversation as resolved.
recipient: quotedFor,
})

const route: ClaimXChainPreview = {
chainId,
tokenAddress: tokenAddress as Address,
receiveAmount: preview.receiveAmount,
feeUsd: preview.feeUsd,
quotedFor,
expiresAt: preview.expiresAt,
}

setRoutes([...routes, route])
if (!toToken && !toChain) {
// Functional update: concurrent quotes must not overwrite each
// other's cache entry (each miss costs a flow credit).
setRoutes((prev) => [...prev, route])
if (!toToken && !toChain && isCurrent()) {
Comment thread
abalinda marked this conversation as resolved.
setSelectedRoute(route)
Comment thread
abalinda marked this conversation as resolved.
setHasFetchedRoute(true)
}
return route
} catch (error) {
console.error('Error fetching route:', error)
Sentry.captureException(error)
// A superseded quote's failure must not clear a newer route or
// install its error over a newer success.
if (!isCurrent()) return undefined
if (!toToken && !toChain) {
setSelectedRoute(undefined)
setHasFetchedRoute(true)
Expand All @@ -707,14 +751,25 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
showError: true,
errorMessage: ROUTE_NOT_FOUND_ERROR,
})
Sentry.captureException(error)
return undefined
} finally {
setIsXchainLoading(false)
setLoadingState('Idle')
if (isCurrent()) {
setIsXchainLoading(false)
setLoadingState('Idle')
}
}
},
[claimLinkData, isXChain, selectedTokenData, setLoadingState, routes, setHasFetchedRoute, setSelectedRoute]
[
claimLinkData,
isXChain,
selectedTokenData,
setLoadingState,
routes,
setHasFetchedRoute,
setSelectedRoute,
recipient.address,
address,
]
)

useEffect(() => {
Expand All @@ -728,7 +783,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
useEffect(() => {
if (!selectedChainID || !selectedTokenAddress) return

// Clear the old route when selection changes
// Clear the old route when selection changes — and retire any quote
// still in flight for the old chain/token, synchronously, so it can
// never resolve as current and select a route for a destination the
// user has left.
quoteGenerationRef.current += 1
setSelectedRoute(undefined)
setHasFetchedRoute(false)

Expand Down
Loading
Loading