Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
Comment thread
innolope-dev marked this conversation as resolved.
Comment thread
innolope-dev marked this conversation as resolved.
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) {
Comment thread
innolope-dev marked this conversation as resolved.
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