Skip to content
17 changes: 15 additions & 2 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,10 @@
}
}, [routeError, recordError, setPaymentError])

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

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

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

if (!transactions || transactions.length === 0) {
if (routeError) {
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
// Retry after a failed route (cap 429, quote failure): recompute
// instead of failing on the transactions the failure never built.
clearErrors()
calculateCurrentRoute()
return
}
console.error('No transactions prepared for withdrawal')
setError(t('errors.txNotPrepared'))
return
Expand Down Expand Up @@ -522,7 +535,7 @@
} finally {
setIsSendingTx(false)
}
}, [

Check warning on line 538 in src/app/(mobile-ui)/withdraw/crypto/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useCallback has missing dependencies: 'calculateCurrentRoute' and 'routeError'. Either include them or remove the dependency array
chargeDetails,
withdrawData,
amountToWithdraw,
Expand Down
4 changes: 2 additions & 2 deletions src/constants/legal-versions.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export const LEGAL_DOCUMENT_VERSIONS = {
hash: '431def76a1838075b8110dff955da06a3d561d61229117c14127deef9f09e1ca',
},
privacy: {
version: '2026-07-15',
hash: '921c1da00646a4ab8f6c9b663d9ba130acbc294f1645f3e3d05ad264744b66c8',
version: '2026-08-27',
Comment thread
abalinda marked this conversation as resolved.
Outdated
hash: 'da1d914e134c2ac6b75ca6d83211ae443880c709a288a1e285efead347f70181',
},
terms: {
version: '2026-07-15',
Expand Down
7 changes: 4 additions & 3 deletions src/features/payments/shared/hooks/useCrossChainTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { encodeFunctionData, erc20Abi, parseUnits, type Address, type Hex } from
import * as peanutInterfaces from '@/interfaces/peanut-sdk-types'
import { prepareRequestLinkFulfillmentTransaction } from '@/utils/peanut-claim.utils'
import { estimateTransactionCostUsd } from '@/app/actions/tokens'
import { useFriendlyError } from '@/hooks/useFriendlyError'
import {
provisionSdaTransfer,
previewSdaTransfer,
Expand Down Expand Up @@ -170,6 +171,7 @@ function inferTokenSymbol(chainId: string, tokenAddress: string): RhinoSupported
}

export function useCrossChainTransfer(): UseCrossChainTransferReturn {
const toFriendlyError = useFriendlyError()
const [transactions, setTransactions] = useState<PreparedTransaction[] | null>(null)
const [sdaAddress, setSdaAddress] = useState<Address | null>(null)
const [receiveAmount, setReceiveAmount] = useState<string | null>(null)
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 10 additions & 7 deletions src/hooks/useFriendlyError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.
"xchainWithdrawLimitRetry": "You reached the limit for withdrawals to other networks. Try again in about {days, plural, =0 {{hours, plural, =0 {{minutes, plural, one {# minute} other {# minutes}}} one {# hour} other {# hours}}} one {# day} other {# days}}. Withdrawals on Arbitrum have no limit, or contact support to raise yours.",
"rainInsufficientCollateral": "Your card doesn’t have enough collateral for this withdrawal.",
"staleCardApproval": "Your card needs to be re-enabled before you can withdraw. It only takes one passkey tap.",
"cardRateLimited": "Too many card requests. Please wait a moment and try again.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/en.marketing.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/es-419.marketing.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/es-AR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/es-AR.marketing.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/app/messages/pt-BR.marketing.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
23 changes: 20 additions & 3 deletions src/services/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
}
}

Expand Down Expand Up @@ -90,14 +94,27 @@ export function apiErrorStatus(error: unknown): number | undefined {
export async function apiErrorFromResponse(response: Response, fallbackMessage: string): Promise<ApiError> {
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 })
}
9 changes: 5 additions & 4 deletions src/services/rhino-sda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -82,10 +83,10 @@ async function postRhino<TReq, TRes>(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
}

Expand Down
Loading
Loading