Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,33 @@ describe('crypto withdraw retry — record-only replay (TASK-19581 double-spend)
expect(mockSendMoney).toHaveBeenCalledTimes(2)
})
})

describe('crypto withdraw retry — after a route error (cross-chain cap 429, TASK-22154)', () => {
// The cap answers the SDA provision with 429 while the route is being
// prepared, so the confirm view renders the error with no transactions
// built. Retry must recompute the route, not fail on "not prepared".
it('Retry recomputes the route instead of failing on the transactions the failed route never built', async () => {
const m = mockCrossChainTransfer as unknown as { transactions: unknown; error: unknown; calculate: jest.Mock }
const prev = { transactions: m.transactions, error: m.error }
m.transactions = null
m.error = 'You reached the limit for withdrawals to other networks. Try again in about 50 minutes.'
try {
render(<WithdrawCryptoPage />)
await waitFor(() => expect(m.calculate).toHaveBeenCalled())
const calculateCalls = m.calculate.mock.calls.length
const errorCalls = mockSetPaymentError.mock.calls.length

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

await waitFor(() => expect(m.calculate.mock.calls.length).toBe(calculateCalls + 1))
expect(mockSendMoney).not.toHaveBeenCalled()
expect(mockSendTransactions).not.toHaveBeenCalled()
// Retry only clears errors (null); it never sets "transaction not prepared"
const afterClick = mockSetPaymentError.mock.calls.slice(errorCalls).map((c) => c[0])
expect(afterClick.every((v) => v === null)).toBe(true)
} finally {
m.transactions = prev.transactions
m.error = prev.error
}
})
})
23 changes: 21 additions & 2 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,10 @@ export default function WithdrawCryptoPage() {
}
}, [routeError, recordError, setPaymentError])

// prepare transaction when entering confirm view
useEffect(() => {
// prepare transaction when entering confirm view — and again on Retry
// after a route error (e.g. the cross-chain cap's 429), so the user is not
// stuck with a Retry that fails on "no transactions prepared"
const calculateCurrentRoute = useCallback(() => {
if (currentView === 'CONFIRM' && chargeDetails && withdrawData && address) {
calculateRoute({
source: {
Expand All @@ -191,6 +193,10 @@ export default function WithdrawCryptoPage() {
}
}, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw])

useEffect(() => {
calculateCurrentRoute()
}, [calculateCurrentRoute])

const handleSetupReview = useCallback(
async (data: Omit<WithdrawData, 'amount'>) => {
if (!amountToWithdraw) {
Expand Down Expand Up @@ -337,6 +343,16 @@ export default function WithdrawCryptoPage() {
}

if (!transactions || transactions.length === 0) {
if (routeError) {
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
// Retry after a failed route (cap 429, quote failure): recompute
// instead of failing on the transactions the failure never built.
// One recalculation at a time: a double-tap must not provision
// twice (each provision holds a cap slot) or race the route state.
if (isCalculating) return
Comment thread
abalinda marked this conversation as resolved.
clearErrors()
calculateCurrentRoute()
return
}
console.error('No transactions prepared for withdrawal')
setError(t('errors.txNotPrepared'))
return
Expand Down Expand Up @@ -538,6 +554,9 @@ export default function WithdrawCryptoPage() {
setTransactionHash,
setPaymentDetails,
clearErrors,
routeError,
isCalculating,
calculateCurrentRoute,
setError,
triggerHaptic,
t,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* useCrossChainTransfer — the bridge path (destination token outside USDC/USDT)
* must name its charge on POST /rhino/bridge/quote: that is what makes the API
* reserve a cap slot and bind the charge to its destination (peanut-api-ts
* #1497 gates the quote only when contextId is present). claim-xchain has no
* charge and must NOT send one, or the gate refuses it with 403.
*/
import { renderHook, act } from '@testing-library/react'

const mockGetBridgeQuote = jest.fn()
const mockCommitBridgeQuote = jest.fn()
jest.mock('@/services/rhino-bridge', () => ({
getBridgeQuote: (...args: unknown[]) => mockGetBridgeQuote(...args),
commitBridgeQuote: (...args: unknown[]) => mockCommitBridgeQuote(...args),
getBridgeStatus: jest.fn(),
isQuoteNearExpiry: () => false,
}))
jest.mock('@/services/rhino-sda', () => ({ previewSdaTransfer: jest.fn(), provisionSdaTransfer: jest.fn() }))
jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() }))
jest.mock('@/hooks/useFriendlyError', () => ({
useFriendlyError: () => (e: unknown) => (e instanceof Error ? e.message : String(e)),
}))
jest.mock('@/app/actions/tokens', () => ({ estimateTransactionCostUsd: jest.fn().mockResolvedValue(0) }))
jest.mock('@/utils/peanut-claim.utils', () => ({ prepareRequestLinkFulfillmentTransaction: jest.fn() }))
jest.mock('@/interfaces/peanut-sdk-types', () => ({}))
jest.mock('@/constants/rhino.consts', () => ({
chainIdToRhinoName: (id: string) => ({ '42161': 'ARBITRUM', '8453': 'BASE' })[id],
}))
jest.mock('@/constants/chainRegistry.consts', () => ({ NON_EVM_WITHDRAW_CHAINS: {} }))
jest.mock('@/utils/general.utils', () => ({
areEvmAddressesEqual: (a: string, b: string) => a.toLowerCase() === b.toLowerCase(),
getTokenSymbol: () => 'ETH',
}))

import { useCrossChainTransfer } from '../useCrossChainTransfer'

const SOURCE = {
address: '0x1111111111111111111111111111111111111111' as `0x${string}`,
tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as `0x${string}`,
chainId: '42161',
tokenAmount: '5',
}
const ETH_ON_BASE = {
recipientAddress: '0x000000000000000000000000000000000000dEaD',
tokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE',
tokenAmount: '0.002',
tokenDecimals: 18,
tokenType: 0,
chainId: '8453',
}

beforeEach(() => {
mockGetBridgeQuote.mockReset().mockResolvedValue({
quoteId: 'quote-1',
isSwap: false,
amountIn: '5',
amountOut: '0.002',
feeUsd: 0.5,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
})
mockCommitBridgeQuote.mockReset().mockResolvedValue({
kind: 'deposit-with-id',
contractAddress: '0x2222222222222222222222222222222222222222',
commitmentId: 'ab',
})
})

describe('useCrossChainTransfer — bridge path names its charge', () => {
it('withdraw: the bridge quote carries context + contextId so the API can cap it', async () => {
const { result } = renderHook(() => useCrossChainTransfer())

await act(async () => {
await result.current.calculate({
source: SOURCE,
destination: ETH_ON_BASE,
context: 'withdraw',
contextId: 'charge-1',
skipGasEstimate: true,
})
})

expect(mockGetBridgeQuote).toHaveBeenCalledTimes(1)
expect(mockGetBridgeQuote).toHaveBeenCalledWith(
expect.objectContaining({ chainOut: 'BASE', tokenOut: 'ETH', context: 'withdraw', contextId: 'charge-1' })
)
expect(result.current.error).toBeNull()
expect(result.current.transactions).toHaveLength(2)
})

it('claim-xchain: no charge exists, so no context is sent (the gate would refuse it)', async () => {
const { result } = renderHook(() => useCrossChainTransfer())

await act(async () => {
await result.current.calculate({
source: SOURCE,
destination: ETH_ON_BASE,
context: 'claim-xchain',
contextId: 'pubkey-1',
skipGasEstimate: true,
})
})

const body = mockGetBridgeQuote.mock.calls[0][0] as Record<string, unknown>
expect(body).not.toHaveProperty('context')
expect(body).not.toHaveProperty('contextId')
})
})
19 changes: 16 additions & 3 deletions src/features/payments/shared/hooks/useCrossChainTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { encodeFunctionData, erc20Abi, parseUnits, type Address, type Hex } from
import * as peanutInterfaces from '@/interfaces/peanut-sdk-types'
import { prepareRequestLinkFulfillmentTransaction } from '@/utils/peanut-claim.utils'
import { estimateTransactionCostUsd } from '@/app/actions/tokens'
import { useFriendlyError } from '@/hooks/useFriendlyError'
import {
provisionSdaTransfer,
previewSdaTransfer,
Expand Down Expand Up @@ -170,6 +171,7 @@ function inferTokenSymbol(chainId: string, tokenAddress: string): RhinoSupported
}

export function useCrossChainTransfer(): UseCrossChainTransferReturn {
const toFriendlyError = useFriendlyError()
const [transactions, setTransactions] = useState<PreparedTransaction[] | null>(null)
const [sdaAddress, setSdaAddress] = useState<Address | null>(null)
const [receiveAmount, setReceiveAmount] = useState<string | null>(null)
Expand Down Expand Up @@ -302,6 +304,8 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
sourceRhinoChain,
destRhinoChain,
tokenSymbol,
context,
contextId,
setTransactions,
setReceiveAmount,
setPayAmount,
Expand Down Expand Up @@ -356,15 +360,17 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
})
setPath('sda')
} catch (err) {
const message = err instanceof Error ? err.message : 'failed to calculate cross-chain transfer'
setError(message)
// A payer cannot switch to Arbitrum — the request fixed the destination.
setError(
toFriendlyError(err, { crossChainSurface: context === 'pay-request' ? 'payment' : 'withdraw' })
)
setIsFeeEstimationError(true)
captureException(err)
} finally {
setIsCalculating(false)
}
},
[]
[toFriendlyError]
)

return {
Expand Down Expand Up @@ -397,6 +403,8 @@ interface BridgePathParams {
sourceRhinoChain: string
destRhinoChain: string
tokenSymbol: string
context: RhinoTransferContext
contextId: string
setTransactions: (tx: PreparedTransaction[] | null) => void
setReceiveAmount: (v: string | null) => void
setPayAmount: (v: string | null) => void
Expand All @@ -418,6 +426,8 @@ async function runBridgePath({
sourceRhinoChain,
destRhinoChain,
tokenSymbol,
context,
contextId,
setTransactions,
setReceiveAmount,
setPayAmount,
Expand Down Expand Up @@ -453,6 +463,9 @@ async function runBridgePath({
recipient: destination.recipientAddress,
depositor: source.address,
mode,
// Names the charge so the API counts this bridge against the caller's
// cross-chain cap, same as the SDA path. claim-xchain has no charge.
...(context !== 'claim-xchain' ? { context, contextId } : {}),
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
})

const commit: BridgeCommitResponse = await commitBridgeQuote(quote.quoteId, quote.isSwap, isSameChainSwap)
Expand Down
25 changes: 15 additions & 10 deletions src/hooks/useFriendlyError.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { friendlyError } from '@/utils/friendly-error.utils'
import { friendlyError, type FriendlyErrorOptions } from '@/utils/friendly-error.utils'

/**
* Maps a caught error to user-facing copy. `friendlyError` classifies the error
Expand All @@ -10,21 +10,26 @@ import { friendlyError } from '@/utils/friendly-error.utils'
export function useFriendlyError() {
const t = useTranslations('errors')
return useCallback(
(error: unknown): string => {
const result = friendlyError(error)
(error: unknown, opts?: FriendlyErrorOptions): string => {
const result = friendlyError(error, opts)
switch (result.kind) {
case 'text':
return result.text
case 'code':
return t(result.code)
case 'params':
// `result.code` narrows to a single literal here, so next-intl
// resolves exactly this message's ICU args. If a SECOND
// parameterized code is ever added, switch on `result.code`
// inside this branch — otherwise next-intl collapses `values`
// to the intersection of both messages' args and neither one
// typechecks.
return t(result.code, result.values)
// Switch on `result.code` so each branch narrows to one
// literal and next-intl resolves exactly that message's ICU
// args; otherwise `values` collapses to the intersection of
// both messages' args and neither one typechecks.
switch (result.code) {
case 'rainCooldownRetry':
return t(result.code, result.values)
case 'xchainWithdrawLimitRetry':
return t(result.code, result.values)
case 'xchainPaymentLimitRetry':
return t(result.code, result.values)
}
}
},
[t]
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/app/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,10 @@
"genericSupport": "There was an issue with your request. Please contact support.",
"rainCooldownRetry": "A previous card withdrawal is still active. Try again in about {minutes, plural, one {# minute} other {# minutes}}.",
"rainCooldownRetryShortly": "A previous card withdrawal is still active. Please try again shortly.",
"xchainWithdrawLimit": "You reached the limit for withdrawals to other networks. Withdrawals on Arbitrum have no limit, or contact support to raise yours.",
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
"xchainWithdrawLimitRetry": "You reached the limit for withdrawals to other networks. Try again in about {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minute} other {# minutes}}} one {# hour} other {# hours}}} one {# day} other {# days}}. Withdrawals on Arbitrum have no limit, or contact support to raise yours.",
"xchainPaymentLimit": "You made too many transfers to other networks recently. Contact support to raise your limit.",
"xchainPaymentLimitRetry": "You made too many transfers to other networks recently. Try again in about {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minute} other {# minutes}}} one {# hour} other {# hours}}} one {# day} other {# days}}, or contact support to raise your limit.",
"rainInsufficientCollateral": "Your card doesn’t have enough collateral for this withdrawal.",
"staleCardApproval": "Your card needs to be re-enabled before you can withdraw. It only takes one passkey tap.",
"cardRateLimited": "Too many card requests. Please wait a moment and try again.",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/app/messages/en.marketing.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@
"genericSupport": "There was an issue with your request. Please contact support.",
"rainCooldownRetry": "A previous card withdrawal is still active. Try again in about {minutes, plural, one {# minute} other {# minutes}}.",
"rainCooldownRetryShortly": "A previous card withdrawal is still active. Please try again shortly.",
"xchainWithdrawLimit": "You reached the limit for withdrawals to other networks. Withdrawals on Arbitrum have no limit, or contact support to raise yours.",
"xchainWithdrawLimitRetry": "You reached the limit for withdrawals to other networks. Try again in about {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minute} other {# minutes}}} one {# hour} other {# hours}}} one {# day} other {# days}}. Withdrawals on Arbitrum have no limit, or contact support to raise yours.",
"xchainPaymentLimit": "You made too many transfers to other networks recently. Contact support to raise your limit.",
"xchainPaymentLimitRetry": "You made too many transfers to other networks recently. Try again in about {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minute} other {# minutes}}} one {# hour} other {# hours}}} one {# day} other {# days}}, or contact support to raise your limit.",
"rainInsufficientCollateral": "Your card doesn’t have enough collateral for this withdrawal.",
"staleCardApproval": "Your card needs to be re-enabled before you can withdraw. It only takes one passkey tap.",
"cardRateLimited": "Too many card requests. Please wait a moment and try again.",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/app/messages/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,10 @@
"genericSupport": "Hubo un problema con tu solicitud. Contacta con soporte.",
"rainCooldownRetry": "Un retiro anterior con la tarjeta sigue activo. Vuelve a intentarlo en unos {minutes, plural, one {# minuto} other {# minutos}}.",
"rainCooldownRetryShortly": "Un retiro anterior con la tarjeta sigue activo. Vuelve a intentarlo en breve.",
"xchainWithdrawLimit": "Alcanzaste el límite de retiros a otras redes. Los retiros en Arbitrum no tienen límite, o contacta a soporte para ampliar el tuyo.",
"xchainWithdrawLimitRetry": "Alcanzaste el límite de retiros a otras redes. Vuelve a intentarlo en aproximadamente {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minuto} other {# minutos}}} one {# hora} other {# horas}}} one {# día} other {# días}}. Los retiros en Arbitrum no tienen límite, o contacta a soporte para ampliar el tuyo.",
"xchainPaymentLimit": "Hiciste demasiadas transferencias a otras redes recientemente. Contacta a soporte para ampliar tu límite.",
"xchainPaymentLimitRetry": "Hiciste demasiadas transferencias a otras redes recientemente. Vuelve a intentarlo en aproximadamente {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minuto} other {# minutos}}} one {# hora} other {# horas}}} one {# día} other {# días}}, o contacta a soporte para ampliar tu límite.",
"rainInsufficientCollateral": "Tu tarjeta no tiene suficiente colateral para este retiro.",
"staleCardApproval": "Tu tarjeta debe reactivarse antes de que puedas retirar. Solo toma un toque con tu passkey.",
"cardRateLimited": "Demasiadas solicitudes con la tarjeta. Espera un momento e inténtalo de nuevo.",
Expand Down
Loading
Loading