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,55 @@ 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('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
75 changes: 50 additions & 25 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 @@ -343,6 +352,19 @@ export default function WithdrawCryptoPage() {
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
}

clearErrors()
setIsSendingTx(true)

Expand Down Expand Up @@ -530,6 +552,8 @@ export default function WithdrawCryptoPage() {
address,
transactions,
payAmount,
quoteExpiresAt,
quoteRoute,
usdAmount,
sendTransactions,
sendMoney,
Expand Down Expand Up @@ -648,6 +672,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
57 changes: 52 additions & 5 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,18 @@ 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 = toToken || toChain ? quoteGenerationRef.current : (quoteGenerationRef.current += 1)

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,23 +706,30 @@ 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) {
if (!toToken && !toChain && generation === quoteGenerationRef.current) {
Comment thread
abalinda marked this conversation as resolved.
Outdated
setSelectedRoute(route)
Comment thread
abalinda marked this conversation as resolved.
setHasFetchedRoute(true)
}
Expand All @@ -714,7 +751,17 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
setLoadingState('Idle')
}
},
[claimLinkData, isXChain, selectedTokenData, setLoadingState, routes, setHasFetchedRoute, setSelectedRoute]
[
claimLinkData,
isXChain,
selectedTokenData,
setLoadingState,
routes,
setHasFetchedRoute,
setSelectedRoute,
recipient.address,
address,
]
)

useEffect(() => {
Expand Down
12 changes: 7 additions & 5 deletions src/components/Claim/Link/Onchain/Confirm.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Notification } from '@/components/0_Bruddle/Notification'
import Card from '@/components/Global/Card'
import DisplayIcon from '@/components/Global/DisplayIcon'
import NavHeader from '@/components/Global/NavHeader'
import NetworkFeeRow from '@/components/Global/NetworkFeeRow'
import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { loadingStateContext } from '@/context/loadingStates.context'
Expand Down Expand Up @@ -78,9 +79,6 @@ export const ConfirmClaimLinkView = ({
return isStableCoin(resolvedTokenSymbol) ? `$ ${amount}` : `${amount} ${resolvedTokenSymbol}`
}, [selectedRoute, resolvedTokenSymbol])

// Network fee display – always sponsored in this flow
const networkFeeDisplay: string = tCommon('sponsoredByPeanut')

const handleOnClaim = async () => {
if (!recipient) {
return
Expand Down Expand Up @@ -235,8 +233,12 @@ export const ConfirmClaimLinkView = ({
/>
}

{/* Max network fee row */}
<PaymentInfoRow label={t('confirm.maxNetworkFee')} value={networkFeeDisplay} />
{/* Max network fee row — the route preview's quoted fee, verbatim */}
<NetworkFeeRow
label={t('confirm.maxNetworkFee')}
feeUsd={selectedRoute?.feeUsd}
Comment thread
abalinda marked this conversation as resolved.
isCrossChain={!!selectedRoute}
/>

{/* Peanut fee row */}
<PaymentInfoRow label={tCommon('peanutFee')} value={'$ 0.00'} hideBottomBorder />
Expand Down
Loading
Loading