diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
index d1265cd9d2..a85c5e40ab 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
@@ -486,3 +486,65 @@ 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()
+ 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
+ }
+ })
+
+ it('Retry does nothing while a recalculation is already in flight (a double tap must not provision twice)', async () => {
+ const m = mockCrossChainTransfer as unknown as {
+ transactions: unknown
+ error: unknown
+ isCalculating: boolean
+ calculate: jest.Mock
+ }
+ const prev = { transactions: m.transactions, error: m.error, isCalculating: m.isCalculating }
+ m.transactions = null
+ m.error = 'You reached the limit for withdrawals to other networks. Try again in about 50 minutes.'
+ m.isCalculating = true
+ try {
+ render()
+ 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'))
+ fireEvent.click(screen.getByTestId('confirm-withdraw'))
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(m.calculate.mock.calls.length).toBe(calculateCalls)
+ expect(mockSendMoney).not.toHaveBeenCalled()
+ 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
+ m.isCalculating = prev.isCalculating
+ }
+ })
+})
diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
index a01dfe46ed..d132876c30 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
@@ -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: {
@@ -191,6 +193,10 @@ export default function WithdrawCryptoPage() {
}
}, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw])
+ useEffect(() => {
+ calculateCurrentRoute()
+ }, [calculateCurrentRoute])
+
const handleSetupReview = useCallback(
async (data: Omit) => {
if (!amountToWithdraw) {
@@ -337,6 +343,16 @@ export default function WithdrawCryptoPage() {
}
if (!transactions || transactions.length === 0) {
+ if (routeError) {
+ // 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
+ clearErrors()
+ calculateCurrentRoute()
+ return
+ }
console.error('No transactions prepared for withdrawal')
setError(t('errors.txNotPrepared'))
return
@@ -538,6 +554,9 @@ export default function WithdrawCryptoPage() {
setTransactionHash,
setPaymentDetails,
clearErrors,
+ routeError,
+ isCalculating,
+ calculateCurrentRoute,
setError,
triggerHaptic,
t,
diff --git a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
new file mode 100644
index 0000000000..5ca4e357d0
--- /dev/null
+++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
@@ -0,0 +1,113 @@
+/**
+ * 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' })
+ )
+ // the commit names the same charge: the API allows one live commitment per charge
+ expect(mockCommitBridgeQuote).toHaveBeenCalledWith('quote-1', false, false, {
+ 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
+ expect(body).not.toHaveProperty('context')
+ expect(body).not.toHaveProperty('contextId')
+ expect(mockCommitBridgeQuote.mock.calls[0][3]).toBeUndefined()
+ })
+})
diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
index e142eefc07..9e6d256e42 100644
--- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts
+++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
@@ -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,
@@ -170,6 +171,7 @@ function inferTokenSymbol(chainId: string, tokenAddress: string): RhinoSupported
}
export function useCrossChainTransfer(): UseCrossChainTransferReturn {
+ const toFriendlyError = useFriendlyError()
const [transactions, setTransactions] = useState(null)
const [sdaAddress, setSdaAddress] = useState(null)
const [receiveAmount, setReceiveAmount] = useState(null)
@@ -302,6 +304,8 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
sourceRhinoChain,
destRhinoChain,
tokenSymbol,
+ context,
+ contextId,
setTransactions,
setReceiveAmount,
setPayAmount,
@@ -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 {
@@ -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
@@ -418,6 +426,8 @@ async function runBridgePath({
sourceRhinoChain,
destRhinoChain,
tokenSymbol,
+ context,
+ contextId,
setTransactions,
setReceiveAmount,
setPayAmount,
@@ -453,9 +463,17 @@ 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 } : {}),
})
- const commit: BridgeCommitResponse = await commitBridgeQuote(quote.quoteId, quote.isSwap, isSameChainSwap)
+ const commit: BridgeCommitResponse = await commitBridgeQuote(
+ quote.quoteId,
+ quote.isSwap,
+ isSameChainSwap,
+ context !== 'claim-xchain' ? { context, contextId } : undefined
+ )
if (!commit.contractAddress) {
throw new Error('Rhino did not return a bridge contract address — cannot construct tx')
diff --git a/src/hooks/useFriendlyError.ts b/src/hooks/useFriendlyError.ts
index 099fa300af..bedb9ab433 100644
--- a/src/hooks/useFriendlyError.ts
+++ b/src/hooks/useFriendlyError.ts
@@ -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
@@ -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]
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index d522579449..4563b71a0f 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -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.",
+ "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.",
diff --git a/src/i18n/app/messages/en.marketing.json b/src/i18n/app/messages/en.marketing.json
index 83c6e8e4f0..c1bd7309d8 100644
--- a/src/i18n/app/messages/en.marketing.json
+++ b/src/i18n/app/messages/en.marketing.json
@@ -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.",
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 1323c2347f..1819dc3f40 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -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.",
diff --git a/src/i18n/app/messages/es-419.marketing.json b/src/i18n/app/messages/es-419.marketing.json
index 5614680de3..ec6b12d809 100644
--- a/src/i18n/app/messages/es-419.marketing.json
+++ b/src/i18n/app/messages/es-419.marketing.json
@@ -95,6 +95,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.",
diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json
index 7fba686e55..d47b20260f 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -1253,6 +1253,10 @@
"genericSupport": "Hubo un problema con tu solicitud. Contactá con soporte.",
"rainCooldownRetry": "Un retiro anterior con la tarjeta sigue activo. Volvé a intentarlo en unos {minutes, plural, one {# minuto} other {# minutos}}.",
"rainCooldownRetryShortly": "Un retiro anterior con la tarjeta sigue activo. Volvé a intentarlo en breve.",
+ "xchainWithdrawLimit": "Alcanzaste el límite de retiros a otras redes. Los retiros en Arbitrum no tienen límite, o contactá a soporte para ampliar el tuyo.",
+ "xchainWithdrawLimitRetry": "Alcanzaste el límite de retiros a otras redes. Volvé 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 contactá a soporte para ampliar el tuyo.",
+ "xchainPaymentLimit": "Hiciste demasiadas transferencias a otras redes recientemente. Contactá a soporte para ampliar tu límite.",
+ "xchainPaymentLimitRetry": "Hiciste demasiadas transferencias a otras redes recientemente. Volvé 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 contactá a soporte para ampliar tu límite.",
"staleCardApproval": "Necesitás volver a habilitar tu tarjeta antes de retirar. Solo toma un toque con tu passkey.",
"cardRateLimited": "Demasiadas solicitudes con la tarjeta. Esperá un momento e intentalo de nuevo.",
"linkTransactionHashFetch": "No pudimos cargar la transacción de este enlace. Actualizá la página e intentalo de nuevo.",
diff --git a/src/i18n/app/messages/es-AR.marketing.json b/src/i18n/app/messages/es-AR.marketing.json
index 33e110684e..b1dbc33851 100644
--- a/src/i18n/app/messages/es-AR.marketing.json
+++ b/src/i18n/app/messages/es-AR.marketing.json
@@ -32,6 +32,10 @@
"genericSupport": "Hubo un problema con tu solicitud. Contactá con soporte.",
"rainCooldownRetry": "Un retiro anterior con la tarjeta sigue activo. Volvé a intentarlo en unos {minutes, plural, one {# minuto} other {# minutos}}.",
"rainCooldownRetryShortly": "Un retiro anterior con la tarjeta sigue activo. Volvé a intentarlo en breve.",
+ "xchainWithdrawLimit": "Alcanzaste el límite de retiros a otras redes. Los retiros en Arbitrum no tienen límite, o contactá a soporte para ampliar el tuyo.",
+ "xchainWithdrawLimitRetry": "Alcanzaste el límite de retiros a otras redes. Volvé 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 contactá a soporte para ampliar el tuyo.",
+ "xchainPaymentLimit": "Hiciste demasiadas transferencias a otras redes recientemente. Contactá a soporte para ampliar tu límite.",
+ "xchainPaymentLimitRetry": "Hiciste demasiadas transferencias a otras redes recientemente. Volvé 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 contactá a soporte para ampliar tu límite.",
"staleCardApproval": "Necesitás volver a habilitar tu tarjeta antes de retirar. Solo toma un toque con tu passkey.",
"cardRateLimited": "Demasiadas solicitudes con la tarjeta. Esperá un momento e intentalo de nuevo.",
"linkTransactionHashFetch": "No pudimos cargar la transacción de este enlace. Actualizá la página e intentalo de nuevo.",
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index 37ae1dff45..7b583b0dac 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -3182,6 +3182,10 @@
"genericSupport": "Ocorreu um problema com sua solicitação. Entre em contato com o suporte.",
"rainCooldownRetry": "Um saque anterior com o cartão ainda está ativo. Tente novamente em cerca de {minutes, plural, one {# minuto} other {# minutos}}.",
"rainCooldownRetryShortly": "Um saque anterior com o cartão ainda está ativo. Tente novamente em instantes.",
+ "xchainWithdrawLimit": "Você atingiu o limite de saques para outras redes. Saques na Arbitrum não têm limite, ou fale com o suporte para ampliar o seu.",
+ "xchainWithdrawLimitRetry": "Você atingiu o limite de saques para outras redes. Tente novamente em cerca de {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minuto} other {# minutos}}} one {# hora} other {# horas}}} one {# dia} other {# dias}}. Saques na Arbitrum não têm limite, ou fale com o suporte para ampliar o seu.",
+ "xchainPaymentLimit": "Você fez muitas transferências para outras redes recentemente. Fale com o suporte para ampliar o seu limite.",
+ "xchainPaymentLimitRetry": "Você fez muitas transferências para outras redes recentemente. Tente novamente em cerca de {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minuto} other {# minutos}}} one {# hora} other {# horas}}} one {# dia} other {# dias}}, ou fale com o suporte para ampliar o seu limite.",
"rainInsufficientCollateral": "Seu cartão não tem colateral suficiente para este saque.",
"staleCardApproval": "Seu cartão precisa ser reativado antes de você sacar. Leva só um toque com a passkey.",
"cardRateLimited": "Muitas solicitações com o cartão. Aguarde um momento e tente novamente.",
diff --git a/src/i18n/app/messages/pt-BR.marketing.json b/src/i18n/app/messages/pt-BR.marketing.json
index 90e112f421..0a45d5717d 100644
--- a/src/i18n/app/messages/pt-BR.marketing.json
+++ b/src/i18n/app/messages/pt-BR.marketing.json
@@ -95,6 +95,10 @@
"genericSupport": "Ocorreu um problema com sua solicitação. Entre em contato com o suporte.",
"rainCooldownRetry": "Um saque anterior com o cartão ainda está ativo. Tente novamente em cerca de {minutes, plural, one {# minuto} other {# minutos}}.",
"rainCooldownRetryShortly": "Um saque anterior com o cartão ainda está ativo. Tente novamente em instantes.",
+ "xchainWithdrawLimit": "Você atingiu o limite de saques para outras redes. Saques na Arbitrum não têm limite, ou fale com o suporte para ampliar o seu.",
+ "xchainWithdrawLimitRetry": "Você atingiu o limite de saques para outras redes. Tente novamente em cerca de {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minuto} other {# minutos}}} one {# hora} other {# horas}}} one {# dia} other {# dias}}. Saques na Arbitrum não têm limite, ou fale com o suporte para ampliar o seu.",
+ "xchainPaymentLimit": "Você fez muitas transferências para outras redes recentemente. Fale com o suporte para ampliar o seu limite.",
+ "xchainPaymentLimitRetry": "Você fez muitas transferências para outras redes recentemente. Tente novamente em cerca de {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minuto} other {# minutos}}} one {# hora} other {# horas}}} one {# dia} other {# dias}}, ou fale com o suporte para ampliar o seu limite.",
"rainInsufficientCollateral": "Seu cartão não tem colateral suficiente para este saque.",
"staleCardApproval": "Seu cartão precisa ser reativado antes de você sacar. Leva só um toque com a passkey.",
"cardRateLimited": "Muitas solicitações com o cartão. Aguarde um momento e tente novamente.",
diff --git a/src/services/api-error.ts b/src/services/api-error.ts
index 8d51468e90..1dc762637d 100644
--- a/src/services/api-error.ts
+++ b/src/services/api-error.ts
@@ -25,6 +25,7 @@ export const API_ERROR_CODES = {
MANTECA_KYC_REQUIRED: 'MANTECA_KYC_REQUIRED',
TRANSFER_ALREADY_CONFIRMED: 'TRANSFER_ALREADY_CONFIRMED',
CHAIN_INFRA_UNAVAILABLE: 'CHAIN_INFRA_UNAVAILABLE',
+ XCHAIN_WITHDRAW_LIMIT_REACHED: 'XCHAIN_WITHDRAW_LIMIT_REACHED',
} as const
export type ApiErrorCode = (typeof API_ERROR_CODES)[keyof typeof API_ERROR_CODES]
@@ -42,12 +43,15 @@ export type ApiErrorCode = (typeof API_ERROR_CODES)[keyof typeof API_ERROR_CODES
export class ApiError extends Error {
readonly status: number
readonly code: string | undefined
+ /** Seconds until a rate-limited or cooled-down action can be retried, when the backend sent one. */
+ readonly retryAfterSec: number | undefined
- constructor(message: string, opts: { status: number; code?: string; cause?: unknown }) {
+ constructor(message: string, opts: { status: number; code?: string; retryAfterSec?: number; cause?: unknown }) {
super(message, { cause: opts.cause })
this.name = 'ApiError'
this.status = opts.status
this.code = opts.code
+ this.retryAfterSec = opts.retryAfterSec
}
}
@@ -90,14 +94,27 @@ export function apiErrorStatus(error: unknown): number | undefined {
export async function apiErrorFromResponse(response: Response, fallbackMessage: string): Promise {
let message = fallbackMessage
let code: string | undefined
+ let retryAfterSec: number | undefined
try {
const body = await response.text()
- const parsed = JSON.parse(body) as { message?: unknown; error?: unknown; code?: unknown }
+ const parsed = JSON.parse(body) as {
+ message?: unknown
+ error?: unknown
+ code?: unknown
+ retryAfterSec?: unknown
+ }
if (typeof parsed.message === 'string' && parsed.message) message = parsed.message
else if (typeof parsed.error === 'string' && parsed.error) message = parsed.error
if (typeof parsed.code === 'string' && parsed.code) code = parsed.code
+ if (
+ typeof parsed.retryAfterSec === 'number' &&
+ Number.isFinite(parsed.retryAfterSec) &&
+ parsed.retryAfterSec > 0
+ ) {
+ retryAfterSec = parsed.retryAfterSec
+ }
} catch {
// unreadable or non-JSON body — keep the fallback message
}
- return new ApiError(message, { status: response.status, code })
+ return new ApiError(message, { status: response.status, code, retryAfterSec })
}
diff --git a/src/services/rhino-bridge.ts b/src/services/rhino-bridge.ts
index 7b3292beb5..caeeb2dceb 100644
--- a/src/services/rhino-bridge.ts
+++ b/src/services/rhino-bridge.ts
@@ -11,6 +11,7 @@
* Pairs with peanut-api-ts /rhino/bridge/* routes.
*/
+import { apiErrorFromResponse } from '@/services/api-error'
import { PEANUT_API_URL } from '@/constants/general.consts'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { getAuthHeaders, authReady } from '@/utils/auth-token'
@@ -25,6 +26,9 @@ export interface BridgeQuoteParams {
recipient: string
depositor: string
mode: 'pay' | 'receive'
+ /** The charge this quote is for; lets the API apply the per-user cross-chain cap to the bridge path too. */
+ context?: 'withdraw' | 'pay-request'
+ contextId?: string
}
export interface BridgeQuoteResponse {
@@ -76,10 +80,10 @@ async function postJson(path: string, body: TReq, errorLabel: string
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(body),
})
- if (!response.ok) {
- const text = await response.text().catch(() => '')
- throw new Error(`${errorLabel}: ${response.status} ${text}`)
- }
+ // ApiError keeps the backend's `error` text as the message and carries its
+ // `code` / `retryAfterSec`, so the cap's 429 on the bridge path renders the
+ // same localized copy as the SDA path instead of "contact support".
+ if (!response.ok) throw await apiErrorFromResponse(response, errorLabel)
return (await response.json()) as TRes
}
@@ -89,10 +93,10 @@ async function getJson(path: string, errorLabel: string): Promise {
method: 'GET',
headers: getAuthHeaders(),
})
- if (!response.ok) {
- const text = await response.text().catch(() => '')
- throw new Error(`${errorLabel}: ${response.status} ${text}`)
- }
+ // ApiError keeps the backend's `error` text as the message and carries its
+ // `code` / `retryAfterSec`, so the cap's 429 on the bridge path renders the
+ // same localized copy as the SDA path instead of "contact support".
+ if (!response.ok) throw await apiErrorFromResponse(response, errorLabel)
return (await response.json()) as TRes
}
@@ -103,9 +107,15 @@ export function getBridgeQuote(params: BridgeQuoteParams): Promise {
- return postJson('/rhino/bridge/commit', { quoteId, isSwap, isSameChainSwap }, 'Failed to commit bridge quote')
+ return postJson(
+ '/rhino/bridge/commit',
+ { quoteId, isSwap, isSameChainSwap, ...(charge ?? {}) },
+ 'Failed to commit bridge quote'
+ )
}
export function getBridgeStatus(bridgeId: string): Promise {
diff --git a/src/services/rhino-sda.ts b/src/services/rhino-sda.ts
index d540a3e27e..a21766977f 100644
--- a/src/services/rhino-sda.ts
+++ b/src/services/rhino-sda.ts
@@ -10,6 +10,7 @@
* Three consumers: withdraw, pay-request-x-chain, claim-link-x-chain.
*/
+import { apiErrorFromResponse } from '@/services/api-error'
import { PEANUT_API_URL } from '@/constants/general.consts'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { getAuthHeaders, authReady } from '@/utils/auth-token'
@@ -82,10 +83,10 @@ async function postRhino(path: string, body: TReq, errorLabel: strin
},
body: JSON.stringify(body),
})
- if (!response.ok) {
- const text = await response.text().catch(() => '')
- throw new Error(`${errorLabel}: ${response.status} ${text}`)
- }
+ // ApiError keeps the backend's `error` text as the message and carries its
+ // `code` / `retryAfterSec`, so friendlyError can localize a 429 instead of
+ // the UI echoing a "Failed to …: 429 {json}" string.
+ if (!response.ok) throw await apiErrorFromResponse(response, errorLabel)
return (await response.json()) as TRes
}
diff --git a/src/utils/__tests__/friendly-error.utils.test.tsx b/src/utils/__tests__/friendly-error.utils.test.tsx
index 9170598bcf..8480804f7a 100644
--- a/src/utils/__tests__/friendly-error.utils.test.tsx
+++ b/src/utils/__tests__/friendly-error.utils.test.tsx
@@ -1,4 +1,5 @@
import { friendlyError, rainCollateralErrorMessage, type FriendlyErrorCode } from '../friendly-error.utils'
+import { ApiError } from '@/services/api-error'
import en from '@/i18n/app/messages/en.json'
describe('friendlyError', () => {
@@ -148,6 +149,8 @@ describe('friendly error copy catalog', () => {
'rainInsufficientCollateral',
'rainCooldownRetryShortly',
'cardRateLimited',
+ 'xchainWithdrawLimit',
+ 'xchainPaymentLimit',
'linkTransactionHashFetch',
]
@@ -399,3 +402,57 @@ describe('browser-native fetch rejection (TASK-21956)', () => {
})
})
})
+
+describe('cross-chain withdraw cap (XCHAIN_WITHDRAW_LIMIT_REACHED)', () => {
+ const at = (retryAfterSec: number | undefined) =>
+ new ApiError('You reached the limit of 10 cross-chain withdrawals per hour.', {
+ status: 429,
+ code: 'XCHAIN_WITHDRAW_LIMIT_REACHED',
+ retryAfterSec,
+ })
+
+ it('renders the wait in the coarsest reached unit, rounded up so it never under-promises', () => {
+ expect(friendlyError(at(90))).toEqual({
+ kind: 'params',
+ code: 'xchainWithdrawLimitRetry',
+ values: { days: 0, hours: 0, minutes: 2 },
+ })
+ expect(friendlyError(at(3 * 3600))).toEqual({
+ kind: 'params',
+ code: 'xchainWithdrawLimitRetry',
+ values: { days: 0, hours: 3, minutes: 180 },
+ })
+ // 119 min is shown as 2 hours, not 1
+ expect(friendlyError(at(119 * 60))).toMatchObject({ values: { days: 0, hours: 2 } })
+ // 47 h is shown as 2 days, not 1
+ expect(friendlyError(at(47 * 3600))).toMatchObject({ values: { days: 2, hours: 47 } })
+ expect(friendlyError(at(2 * 86400 + 60))).toEqual({
+ kind: 'params',
+ code: 'xchainWithdrawLimitRetry',
+ values: { days: 3, hours: 49, minutes: 2881 },
+ })
+ })
+
+ it('falls back to the copy without a countdown when the wait is missing', () => {
+ expect(friendlyError(at(undefined))).toEqual({ kind: 'code', code: 'xchainWithdrawLimit' })
+ })
+
+ it('on the payment surface uses the payment copy (no Arbitrum advice — the request fixed the destination)', () => {
+ expect(friendlyError(at(90), { crossChainSurface: 'payment' })).toEqual({
+ kind: 'params',
+ code: 'xchainPaymentLimitRetry',
+ values: { days: 0, hours: 0, minutes: 2 },
+ })
+ expect(friendlyError(at(undefined), { crossChainSurface: 'payment' })).toEqual({
+ kind: 'code',
+ code: 'xchainPaymentLimit',
+ })
+ expect(en.errors.xchainPaymentLimitRetry).not.toContain('Arbitrum')
+ })
+
+ it('has ICU copy that resolves for every unit', () => {
+ const msg: string = en.errors.xchainWithdrawLimitRetry
+ for (const unit of ['minutes', 'hours', 'days']) expect(msg).toContain(`{${unit}, plural`)
+ expect(msg).toContain('Arbitrum')
+ })
+})
diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx
index 4221e3f352..6b3d8f2a7d 100644
--- a/src/utils/friendly-error.utils.tsx
+++ b/src/utils/friendly-error.utils.tsx
@@ -89,6 +89,8 @@ export type FriendlyErrorCode =
| 'rainInsufficientCollateral'
| 'rainCooldownRetryShortly'
| 'cardRateLimited'
+ | 'xchainWithdrawLimit'
+ | 'xchainPaymentLimit'
| 'linkTransactionHashFetch'
/**
@@ -107,6 +109,8 @@ export type FriendlyErrorCode =
export type FriendlyError =
| { kind: 'code'; code: FriendlyErrorCode }
| { kind: 'params'; code: 'rainCooldownRetry'; values: { minutes: number } }
+ | { kind: 'params'; code: 'xchainWithdrawLimitRetry'; values: { days: number; hours: number; minutes: number } }
+ | { kind: 'params'; code: 'xchainPaymentLimitRetry'; values: { days: number; hours: number; minutes: number } }
| { kind: 'text'; text: string }
const code = (c: FriendlyErrorCode): FriendlyError => ({ kind: 'code', code: c })
@@ -180,13 +184,22 @@ const isGenericSupport = (result: FriendlyError): boolean => result.kind === 'co
* matchers on ONE level of `.cause` (fetch wrappers rethrow with the real
* failure attached there), then surfaces a displayable backend-authored
* ApiError message verbatim rather than discarding the actual reason. */
-export const friendlyError = (error: unknown): FriendlyError => {
- const classified = classifyError(error)
+/**
+ * Where the error surfaced, for the few messages whose advice depends on it.
+ * The cross-chain cap counts withdrawals and request payments alike; a payer
+ * cannot "withdraw on Arbitrum instead", the request fixed the destination.
+ */
+export interface FriendlyErrorOptions {
+ crossChainSurface?: 'withdraw' | 'payment'
+}
+
+export const friendlyError = (error: unknown, opts?: FriendlyErrorOptions): FriendlyError => {
+ const classified = classifyError(error, opts)
if (!isGenericSupport(classified)) return classified
const cause = error && typeof error === 'object' ? (error as { cause?: unknown }).cause : undefined
if (cause !== undefined && cause !== null) {
- const fromCause = classifyError(cause)
+ const fromCause = classifyError(cause, opts)
if (!isGenericSupport(fromCause)) return fromCause
}
@@ -195,7 +208,7 @@ export const friendlyError = (error: unknown): FriendlyError => {
return code('genericSupport')
}
-const classifyError = (error: unknown): FriendlyError => {
+const classifyError = (error: unknown, opts?: FriendlyErrorOptions): FriendlyError => {
const { text, message, name } = extractErrorParts(error)
// Wire code first: it's locale-independent and immune to backend copy
@@ -209,6 +222,22 @@ const classifyError = (error: unknown): FriendlyError => {
? code('rainCooldownRetryShortly')
: { kind: 'params', code: 'rainCooldownRetry', values: { minutes } }
}
+ if (wire === API_ERROR_CODES.XCHAIN_WITHDRAW_LIMIT_REACHED) {
+ // Per-user cross-chain withdraw cap. The wait can be minutes (hour
+ // rung), hours (day rung) or days (30-day rung); the ICU message picks
+ // the coarsest non-zero unit.
+ const payment = opts?.crossChainSurface === 'payment'
+ const minutes = cooldownMinutes(error)
+ if (minutes === null) return code(payment ? 'xchainPaymentLimit' : 'xchainWithdrawLimit')
+ // Round the shown unit UP so the copy never promises a retry before the
+ // cap lifts; a unit is used only once the wait reaches it.
+ const hours = minutes >= 60 ? Math.ceil(minutes / 60) : 0
+ const days = minutes >= 24 * 60 ? Math.ceil(minutes / (24 * 60)) : 0
+ const values = { days, hours, minutes }
+ return payment
+ ? { kind: 'params', code: 'xchainPaymentLimitRetry', values }
+ : { kind: 'params', code: 'xchainWithdrawLimitRetry', values }
+ }
if (wire) {
const mapped = WIRE_CODE_MAP[wire as ApiErrorCode]
if (mapped) return code(mapped)