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)