From bc900921391d271e6fdf759bc35fdd7541b88d0b Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 23:40:22 +0100 Subject: [PATCH 1/8] fix(withdraw): show the cross-chain cap message and let Retry recompute the route (TASK-22154) peanut-api-ts #1497 answers POST /rhino/sda-transfer with 429 { error, code: XCHAIN_WITHDRAW_LIMIT_REACHED, retryAfterSec } once a user is over the per-user cross-chain withdrawal cap. Today postRhino throws "Failed to provision SDA transfer: 429 {json}" and the confirm view shows that string; its Retry then calls onConfirm with no prepared transactions and dead-ends on "transaction not prepared". - postRhino throws an ApiError (message = backend text, plus code and retryAfterSec), and apiErrorFromResponse now carries retryAfterSec. - friendlyError maps XCHAIN_WITHDRAW_LIMIT_REACHED to localized copy that states the wait in the coarsest unit (minutes / hours / days) and points at Arbitrum (no limit) and support; en, es-419, es-AR, pt-BR. - useCrossChainTransfer surfaces errors through useFriendlyError instead of echoing err.message. - Retry after a route error recomputes the route instead of failing on the transactions the failed route never built. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 17 +++++++- src/constants/legal-versions.generated.ts | 4 +- .../shared/hooks/useCrossChainTransfer.ts | 7 ++-- src/hooks/useFriendlyError.ts | 17 ++++---- src/i18n/app/messages/en.json | 2 + src/i18n/app/messages/en.marketing.json | 2 + src/i18n/app/messages/es-419.json | 2 + src/i18n/app/messages/es-419.marketing.json | 2 + src/i18n/app/messages/es-AR.json | 2 + src/i18n/app/messages/es-AR.marketing.json | 2 + src/i18n/app/messages/pt-BR.json | 2 + src/i18n/app/messages/pt-BR.marketing.json | 2 + src/services/api-error.ts | 23 +++++++++-- src/services/rhino-sda.ts | 9 +++-- .../__tests__/friendly-error.utils.test.tsx | 39 +++++++++++++++++++ src/utils/friendly-error.utils.tsx | 12 ++++++ 16 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index a01dfe46ed..ad3c00ea60 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,13 @@ 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. + clearErrors() + calculateCurrentRoute() + return + } console.error('No transactions prepared for withdrawal') setError(t('errors.txNotPrepared')) return diff --git a/src/constants/legal-versions.generated.ts b/src/constants/legal-versions.generated.ts index c5973fe12c..0e8f847fc7 100644 --- a/src/constants/legal-versions.generated.ts +++ b/src/constants/legal-versions.generated.ts @@ -29,8 +29,8 @@ export const LEGAL_DOCUMENT_VERSIONS = { hash: '431def76a1838075b8110dff955da06a3d561d61229117c14127deef9f09e1ca', }, privacy: { - version: '2026-07-15', - hash: '921c1da00646a4ab8f6c9b663d9ba130acbc294f1645f3e3d05ad264744b66c8', + version: '2026-08-27', + hash: 'da1d914e134c2ac6b75ca6d83211ae443880c709a288a1e285efead347f70181', }, terms: { version: '2026-07-15', diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts index e142eefc07..56fae4433b 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) @@ -356,15 +358,14 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn { }) setPath('sda') } catch (err) { - const message = err instanceof Error ? err.message : 'failed to calculate cross-chain transfer' - setError(message) + setError(toFriendlyError(err)) setIsFeeEstimationError(true) captureException(err) } finally { setIsCalculating(false) } }, - [] + [toFriendlyError] ) return { diff --git a/src/hooks/useFriendlyError.ts b/src/hooks/useFriendlyError.ts index 099fa300af..0e12483a2f 100644 --- a/src/hooks/useFriendlyError.ts +++ b/src/hooks/useFriendlyError.ts @@ -18,13 +18,16 @@ export function useFriendlyError() { 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) + } } }, [t] diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index d522579449..f713bffc7a 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -3182,6 +3182,8 @@ "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.", "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..6562ea777b 100644 --- a/src/i18n/app/messages/en.marketing.json +++ b/src/i18n/app/messages/en.marketing.json @@ -95,6 +95,8 @@ "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.", "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..dd922474e0 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -3182,6 +3182,8 @@ "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.", "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..b6fb531d26 100644 --- a/src/i18n/app/messages/es-419.marketing.json +++ b/src/i18n/app/messages/es-419.marketing.json @@ -95,6 +95,8 @@ "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.", "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..1e7bafc030 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -1253,6 +1253,8 @@ "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.", "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..f144b43588 100644 --- a/src/i18n/app/messages/es-AR.marketing.json +++ b/src/i18n/app/messages/es-AR.marketing.json @@ -32,6 +32,8 @@ "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.", "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..c9140cb322 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -3182,6 +3182,8 @@ "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.", "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..1b7bae39e9 100644 --- a/src/i18n/app/messages/pt-BR.marketing.json +++ b/src/i18n/app/messages/pt-BR.marketing.json @@ -95,6 +95,8 @@ "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.", "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-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..f25ef218a2 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,7 @@ describe('friendly error copy catalog', () => { 'rainInsufficientCollateral', 'rainCooldownRetryShortly', 'cardRateLimited', + 'xchainWithdrawLimit', 'linkTransactionHashFetch', ] @@ -399,3 +401,40 @@ 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 non-zero unit', () => { + 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 }, + }) + expect(friendlyError(at(2 * 86400 + 60))).toEqual({ + kind: 'params', + code: 'xchainWithdrawLimitRetry', + values: { days: 2, hours: 48, 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('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..803a2fd4f5 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -89,6 +89,7 @@ export type FriendlyErrorCode = | 'rainInsufficientCollateral' | 'rainCooldownRetryShortly' | 'cardRateLimited' + | 'xchainWithdrawLimit' | 'linkTransactionHashFetch' /** @@ -107,6 +108,7 @@ 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: 'text'; text: string } const code = (c: FriendlyErrorCode): FriendlyError => ({ kind: 'code', code: c }) @@ -209,6 +211,16 @@ 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 minutes = cooldownMinutes(error) + if (minutes === null) return code('xchainWithdrawLimit') + const days = Math.floor(minutes / (24 * 60)) + const hours = Math.floor(minutes / 60) + return { kind: 'params', code: 'xchainWithdrawLimitRetry', values: { days, hours, minutes } } + } if (wire) { const mapped = WIRE_CODE_MAP[wire as ApiErrorCode] if (mapped) return code(mapped) From 4edd885dbce53708a9bcf4bca7bfade04b399381 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 09:32:29 +0100 Subject: [PATCH 2/8] fix(withdraw): name the charge on bridge quotes so the cap covers non-stablecoin withdrawals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peanut-api-ts #1497 gates POST /rhino/bridge/quote with the same per-user cross-chain cap as the SDA route when the quote names its charge. Send context + contextId from the bridge path (ETH/WETH/… destinations), which never touched /rhino/sda-transfer and so was uncapped. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- .../payments/shared/hooks/useCrossChainTransfer.ts | 9 +++++++++ src/services/rhino-bridge.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts index 56fae4433b..ac138cafe9 100644 --- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts +++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts @@ -304,6 +304,8 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn { sourceRhinoChain, destRhinoChain, tokenSymbol, + context, + contextId, setTransactions, setReceiveAmount, setPayAmount, @@ -398,6 +400,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 @@ -419,6 +423,8 @@ async function runBridgePath({ sourceRhinoChain, destRhinoChain, tokenSymbol, + context, + contextId, setTransactions, setReceiveAmount, setPayAmount, @@ -454,6 +460,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 } : {}), }) const commit: BridgeCommitResponse = await commitBridgeQuote(quote.quoteId, quote.isSwap, isSameChainSwap) diff --git a/src/services/rhino-bridge.ts b/src/services/rhino-bridge.ts index 7b3292beb5..5e077dd5ea 100644 --- a/src/services/rhino-bridge.ts +++ b/src/services/rhino-bridge.ts @@ -25,6 +25,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 { From 8d0b5b28861f6750ecd0b28676d238d20407bbc8 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 09:48:44 +0100 Subject: [PATCH 3/8] fix(withdraw): Retry sees the current route error; shown wait never under-promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip review (bc900921): - handleConfirmWithdrawal branched on routeError but its dependency list omitted routeError and calculateCurrentRoute, so a 429 that arrived after the callback was memoized still fell through to "transaction not prepared". Both are dependencies now; regression test added to crypto-withdraw-confirm.test.tsx. - The cap wait rounded the shown unit down (119 min → "1 hour", 47 h → "1 day"). It now rounds up, and a unit is used only once the wait reaches it. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- .../crypto-withdraw-confirm.test.tsx | 30 +++++++++++++++++++ src/app/(mobile-ui)/withdraw/crypto/page.tsx | 2 ++ .../__tests__/friendly-error.utils.test.tsx | 8 +++-- src/utils/friendly-error.utils.tsx | 6 ++-- 4 files changed, 42 insertions(+), 4 deletions(-) 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..5e74552b19 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,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() + 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 + } + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index ad3c00ea60..952bc28098 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -551,6 +551,8 @@ export default function WithdrawCryptoPage() { setTransactionHash, setPaymentDetails, clearErrors, + routeError, + calculateCurrentRoute, setError, triggerHaptic, t, diff --git a/src/utils/__tests__/friendly-error.utils.test.tsx b/src/utils/__tests__/friendly-error.utils.test.tsx index f25ef218a2..476d44026d 100644 --- a/src/utils/__tests__/friendly-error.utils.test.tsx +++ b/src/utils/__tests__/friendly-error.utils.test.tsx @@ -410,7 +410,7 @@ describe('cross-chain withdraw cap (XCHAIN_WITHDRAW_LIMIT_REACHED)', () => { retryAfterSec, }) - it('renders the wait in the coarsest non-zero unit', () => { + 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', @@ -421,10 +421,14 @@ describe('cross-chain withdraw cap (XCHAIN_WITHDRAW_LIMIT_REACHED)', () => { 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: 2, hours: 48, minutes: 2881 }, + values: { days: 3, hours: 49, minutes: 2881 }, }) }) diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 803a2fd4f5..9289a8709b 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -217,8 +217,10 @@ const classifyError = (error: unknown): FriendlyError => { // the coarsest non-zero unit. const minutes = cooldownMinutes(error) if (minutes === null) return code('xchainWithdrawLimit') - const days = Math.floor(minutes / (24 * 60)) - const hours = Math.floor(minutes / 60) + // 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 return { kind: 'params', code: 'xchainWithdrawLimitRetry', values: { days, hours, minutes } } } if (wire) { From 42de3c5b9fe8356399ff22738452fce6f44c5850 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 10:06:25 +0100 Subject: [PATCH 4/8] fix(withdraw): bridge-path errors carry the API code too Chip on peanut-api-ts#1497 (60991481): rhino-bridge.ts postJson/getJson still threw a plain Error, so a cap 429 on POST /rhino/bridge/quote would have rendered the generic 'contact support' copy. Same apiErrorFromResponse as rhino-sda.ts now. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- src/services/rhino-bridge.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/services/rhino-bridge.ts b/src/services/rhino-bridge.ts index 5e077dd5ea..1cd12c5aaf 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' @@ -79,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 } @@ -92,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 } From f2e511f7c1a10642fd127daaf7d8aaffa0f8267c Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 10:27:36 +0100 Subject: [PATCH 5/8] fix(withdraw): guard Retry against double taps, test the bridge charge binding, drop stray legal-versions bump Chip review (42de3c5b9): - Retry recomputes only when no calculation is in flight, so a double tap cannot provision twice (each provision holds a cap slot) or race the route state. - useCrossChainTransfer test: the bridge quote carries context + contextId for a withdraw and omits both for claim-xchain. - src/constants/legal-versions.generated.ts was regenerated by the dev server's predev hook against a newer content submodule and committed by mistake; restored to main. The privacy bump ships with its backend half. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 4 + src/constants/legal-versions.generated.ts | 4 +- .../__tests__/useCrossChainTransfer.test.ts | 107 ++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 952bc28098..d132876c30 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -346,6 +346,9 @@ export default function WithdrawCryptoPage() { 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 @@ -552,6 +555,7 @@ export default function WithdrawCryptoPage() { setPaymentDetails, clearErrors, routeError, + isCalculating, calculateCurrentRoute, setError, triggerHaptic, diff --git a/src/constants/legal-versions.generated.ts b/src/constants/legal-versions.generated.ts index 0e8f847fc7..c5973fe12c 100644 --- a/src/constants/legal-versions.generated.ts +++ b/src/constants/legal-versions.generated.ts @@ -29,8 +29,8 @@ export const LEGAL_DOCUMENT_VERSIONS = { hash: '431def76a1838075b8110dff955da06a3d561d61229117c14127deef9f09e1ca', }, privacy: { - version: '2026-08-27', - hash: 'da1d914e134c2ac6b75ca6d83211ae443880c709a288a1e285efead347f70181', + version: '2026-07-15', + hash: '921c1da00646a4ab8f6c9b663d9ba130acbc294f1645f3e3d05ad264744b66c8', }, terms: { version: '2026-07-15', 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..1811b89c82 --- /dev/null +++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts @@ -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 + expect(body).not.toHaveProperty('context') + expect(body).not.toHaveProperty('contextId') + }) +}) From 384403617793a16d87b9c8f57e50e4fbbee3b487 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 10:53:13 +0100 Subject: [PATCH 6/8] fix(withdraw): payment-framed cap message for request payers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip (f2e511f7c): the cap counts request payments too, and a payer blocked on the semantic-request flow saw "withdrawals to other networks … withdraw on Arbitrum instead" — advice they cannot act on, the request fixed the destination. friendlyError takes a crossChainSurface hint; the cross-chain hook passes 'payment' for pay-request, which selects xchainPaymentLimit* copy (no Arbitrum advice) in en, es-419, es-AR, pt-BR. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- .../shared/hooks/useCrossChainTransfer.ts | 5 +++- src/hooks/useFriendlyError.ts | 8 +++--- src/i18n/app/messages/en.json | 2 ++ src/i18n/app/messages/en.marketing.json | 2 ++ src/i18n/app/messages/es-419.json | 2 ++ src/i18n/app/messages/es-419.marketing.json | 2 ++ src/i18n/app/messages/es-AR.json | 2 ++ src/i18n/app/messages/es-AR.marketing.json | 2 ++ src/i18n/app/messages/pt-BR.json | 2 ++ src/i18n/app/messages/pt-BR.marketing.json | 2 ++ .../__tests__/friendly-error.utils.test.tsx | 14 ++++++++++ src/utils/friendly-error.utils.tsx | 27 ++++++++++++++----- 12 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts index ac138cafe9..cd191f0d56 100644 --- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts +++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts @@ -360,7 +360,10 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn { }) setPath('sda') } catch (err) { - setError(toFriendlyError(err)) + // 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 { diff --git a/src/hooks/useFriendlyError.ts b/src/hooks/useFriendlyError.ts index 0e12483a2f..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,8 +10,8 @@ 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 @@ -27,6 +27,8 @@ export function useFriendlyError() { return t(result.code, result.values) case 'xchainWithdrawLimitRetry': return t(result.code, result.values) + case 'xchainPaymentLimitRetry': + return t(result.code, result.values) } } }, diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index f713bffc7a..4563b71a0f 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -3184,6 +3184,8 @@ "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 6562ea777b..c1bd7309d8 100644 --- a/src/i18n/app/messages/en.marketing.json +++ b/src/i18n/app/messages/en.marketing.json @@ -97,6 +97,8 @@ "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 dd922474e0..1819dc3f40 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -3184,6 +3184,8 @@ "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 b6fb531d26..ec6b12d809 100644 --- a/src/i18n/app/messages/es-419.marketing.json +++ b/src/i18n/app/messages/es-419.marketing.json @@ -97,6 +97,8 @@ "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 1e7bafc030..d47b20260f 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -1255,6 +1255,8 @@ "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 f144b43588..b1dbc33851 100644 --- a/src/i18n/app/messages/es-AR.marketing.json +++ b/src/i18n/app/messages/es-AR.marketing.json @@ -34,6 +34,8 @@ "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 c9140cb322..7b583b0dac 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -3184,6 +3184,8 @@ "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 1b7bae39e9..0a45d5717d 100644 --- a/src/i18n/app/messages/pt-BR.marketing.json +++ b/src/i18n/app/messages/pt-BR.marketing.json @@ -97,6 +97,8 @@ "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/utils/__tests__/friendly-error.utils.test.tsx b/src/utils/__tests__/friendly-error.utils.test.tsx index 476d44026d..8480804f7a 100644 --- a/src/utils/__tests__/friendly-error.utils.test.tsx +++ b/src/utils/__tests__/friendly-error.utils.test.tsx @@ -150,6 +150,7 @@ describe('friendly error copy catalog', () => { 'rainCooldownRetryShortly', 'cardRateLimited', 'xchainWithdrawLimit', + 'xchainPaymentLimit', 'linkTransactionHashFetch', ] @@ -436,6 +437,19 @@ describe('cross-chain withdraw cap (XCHAIN_WITHDRAW_LIMIT_REACHED)', () => { 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`) diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 9289a8709b..6b3d8f2a7d 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -90,6 +90,7 @@ export type FriendlyErrorCode = | 'rainCooldownRetryShortly' | 'cardRateLimited' | 'xchainWithdrawLimit' + | 'xchainPaymentLimit' | 'linkTransactionHashFetch' /** @@ -109,6 +110,7 @@ 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 }) @@ -182,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 } @@ -197,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 @@ -215,13 +226,17 @@ const classifyError = (error: unknown): FriendlyError => { // 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('xchainWithdrawLimit') + 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 - return { kind: 'params', code: 'xchainWithdrawLimitRetry', values: { days, hours, minutes } } + 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] From 11f59bf1441403b2b5d12a76f7d8d2d6eba9361c Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 11:16:32 +0100 Subject: [PATCH 7/8] test(withdraw): pin the Retry double-tap guard Chip (384403617): the isCalculating guard on the route-error Retry branch had no test. A second tap while a recalculation is in flight must not call calculate again or set 'not prepared'. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- .../crypto-withdraw-confirm.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 5e74552b19..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 @@ -515,4 +515,36 @@ describe('crypto withdraw retry — after a route error (cross-chain cap 429, TA 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 + } + }) }) From d81184d44cf2d6b307a36f23334eed508aa68022 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 18:10:52 +0100 Subject: [PATCH 8/8] fix(withdraw): the bridge commit names its charge too Chip on peanut-api-ts#1497 (6e815a66): only the quote sent context/contextId; with the cap on, POST /rhino/bridge/commit refuses commits without them, so a non-stablecoin withdrawal would quote and then fail at commit. The commit now carries the same charge (none for claim-xchain). Hook test asserts both. Claude-Session: https://claude.ai/code/session_017idXbJRcFgbugvA8Xxr5YC --- .../hooks/__tests__/useCrossChainTransfer.test.ts | 6 ++++++ .../payments/shared/hooks/useCrossChainTransfer.ts | 7 ++++++- src/services/rhino-bridge.ts | 10 ++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts index 1811b89c82..5ca4e357d0 100644 --- a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts +++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts @@ -83,6 +83,11 @@ describe('useCrossChainTransfer — bridge path names its charge', () => { 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) }) @@ -103,5 +108,6 @@ describe('useCrossChainTransfer — bridge path names its charge', () => { 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 cd191f0d56..9e6d256e42 100644 --- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts +++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts @@ -468,7 +468,12 @@ async function runBridgePath({ ...(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/services/rhino-bridge.ts b/src/services/rhino-bridge.ts index 1cd12c5aaf..caeeb2dceb 100644 --- a/src/services/rhino-bridge.ts +++ b/src/services/rhino-bridge.ts @@ -107,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 {