Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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,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) {
Comment thread
innolope-dev marked this conversation as resolved.
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