Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
default: (props: any) => {
// next/image uses 'fill' boolean; strip non-DOM props
const { priority, layout, objectFit, fill, ...rest } = props
return <img {...rest} />

Check warning on line 51 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 51 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
},
}))

Expand Down Expand Up @@ -137,6 +137,9 @@
}))

jest.mock('@/utils/balance.utils', () => ({
// keep the real isAmountWithinBalance / messages so the gate is genuinely
// exercised; only stub the Rain widening helper.
...jest.requireActual('@/utils/balance.utils'),
rainCentsToUsdcUnits: jest.fn(() => 0n),
}))

Expand Down Expand Up @@ -560,6 +563,8 @@

mockUseWallet.mockReturnValue({
balance: parseUnits('100', 6), // $100 USDC
spendableBalance: parseUnits('100', 6),
hasSufficientSpendableBalance: () => true, // affordable by default
sendMoney: jest.fn(),
})

Expand Down Expand Up @@ -780,14 +785,14 @@
expect(screen.getByRole('button', { name: 'Pay' })).toBeInTheDocument()
})

test.skip('Insufficient balance shows pay button disabled + error', async () => {
// SKIP 2026-04-24: feat/card-ui merge surfaced post-merge balance
// path mismatch in qr-pay state tests. Mock signature for useWallet
// drifted vs new spendable-balance shape. FOLLOW-UP: rewrite or delete
// these state tests after the card-ui apply flow stabilises.
// Set balance to $5 but payment needs $18.4
test('Insufficient balance shows pay button disabled + error', async () => {
// Payment needs ~$18.4 but the displayed spendable is only $5, so the gate
// (hasSufficientSpendableBalance) returns false. Revived from skip once the
// gate moved onto the shared hook predicate (the original mock-shape drift).
mockUseWallet.mockReturnValue({
balance: parseUnits('5', 6),
spendableBalance: parseUnits('5', 6),
hasSufficientSpendableBalance: () => false,
sendMoney: jest.fn(),
})

Expand Down
25 changes: 19 additions & 6 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'
import {
rainCentsToUsdcUnits,
INSUFFICIENT_BALANCE_MESSAGE,
BALANCE_SETTLING_MESSAGE,
isAmountWithinBalance,
} from '@/utils/balance.utils'
import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils'
import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils'
import {
Expand Down Expand Up @@ -98,7 +103,7 @@
const qrCode = decodeURIComponent(searchParams.get('qrCode') || '')
const timestamp = searchParams.get('t')
const qrType = searchParams.get('type')
const { spendableBalance: balance, sendMoney } = useWallet()
const { spendableBalance: balance } = useWallet()
const { signSpend } = useSignSpendBundle()
const handleStaleSession = useStaleSessionGuard()
const { overview: rainCardOverview } = useRainCardOverview()
Expand Down Expand Up @@ -241,7 +246,7 @@
if (sumsubFlow.showWrapper || sumsubFlow.isModalOpen) {
sumsubFlow.completeFlow()
}
}, [kycGateState, sumsubFlow.showWrapper, sumsubFlow.isModalOpen, sumsubFlow.completeFlow])

Check warning on line 249 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

Check warning on line 249 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

const queryClient = useQueryClient()
const [isShaking, setIsShaking] = useState(false)
Expand Down Expand Up @@ -354,7 +359,7 @@
if (isSuccess || !!errorMessage) {
setLoadingState('Idle')
}
}, [isSuccess, errorMessage])

Check warning on line 362 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

Check warning on line 362 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

// First fetch for qrcode info — only after KYC gating allows proceeding
useEffect(() => {
Expand All @@ -366,7 +371,7 @@
}

setIsFirstLoad(false)
}, [timestamp, paymentProcessor, qrCode])

Check warning on line 374 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

Check warning on line 374 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

// Get amount from payment lock (Manteca)
useEffect(() => {
Expand All @@ -381,7 +386,7 @@
setAmount(paymentLock.paymentAgainstAmount)
setCurrencyAmount(paymentLock.paymentAssetAmount)
}
}, [paymentLock?.code, paymentProcessor])

Check warning on line 389 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 389 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Get currency object from payment lock (Manteca)
useEffect(() => {
Expand All @@ -403,10 +408,16 @@
}
}
getCurrencyObject().then(setCurrency)
}, [paymentLock?.code, paymentProcessor])

Check warning on line 411 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 411 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

const isBlockingError = useMemo(() => {
return !!errorMessage && errorMessage !== 'Please confirm the transaction.'
// The settling failure says "try again in a few seconds" — keep the Pay
// button enabled so the user can retry, don't dead-end it like a hard error.
return (
!!errorMessage &&
errorMessage !== 'Please confirm the transaction.' &&
errorMessage !== BALANCE_SETTLING_MESSAGE
)
}, [errorMessage])

const usdAmount = useMemo(() => {
Expand All @@ -419,7 +430,7 @@
// For dynamic QR codes, backend provides the USD amount
return paymentLock.paymentAgainstAmount
}
}, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])

Check warning on line 433 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 433 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Live card-vs-local-rail markup, driven by Manteca's rate + (for ARS)
// BCRA's official rate. Used by both the confirm-screen "Save vs card"
Expand Down Expand Up @@ -628,7 +639,7 @@
} catch (error) {
const rainMsg = rainCollateralErrorMessage(error)
if (error instanceof InsufficientSpendableError) {
setErrorMessage('Not enough USDC in your wallet or card to cover this payment.')
setErrorMessage(BALANCE_SETTLING_MESSAGE)
} else if (error instanceof SessionKeyGrantRequiredError) {
setErrorMessage("One-time card authorization needed. You'll be asked to confirm once.")
} else if (rainMsg) {
Expand Down Expand Up @@ -937,8 +948,10 @@
setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`)
} else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) {
setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`)
} else if (paymentAmount > balance) {
setBalanceErrorMessage('Not enough balance to complete payment. Add funds!')
} else if (!isAmountWithinBalance(usdAmount, balance)) {
// gate on the displayed total; an in-transit shortfall passes here and
// fails late with the settling message at execution.
setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE)
} else {
setBalanceErrorMessage(null)
}
Expand Down
17 changes: 5 additions & 12 deletions src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import InfoCard from '@/components/Global/InfoCard'
import NavHeader from '@/components/Global/NavHeader'
import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import {
PEANUT_WALLET_CHAIN,
PEANUT_WALLET_TOKEN_SYMBOL,
PEANUT_WALLET_TOKEN_DECIMALS,
} from '@/constants/zerodev.consts'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
Expand All @@ -24,6 +20,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { TRANSACTIONS } from '@/constants/query.consts'
import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView'
import { ErrorHandler } from '@/utils/friendly-error.utils'
import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/balance.utils'
import { getBridgeChainName } from '@/utils/bridge-accounts.utils'
import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils'
import { createOfframp, confirmOfframp } from '@/app/actions/offramp'
Expand All @@ -41,7 +38,6 @@ import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/count
import { isBridgeSupportedCountry, getRegionIntent } from '@/utils/regions.utils'
import { PointsAction } from '@/services/services.types'
import { usePointsCalculation } from '@/hooks/usePointsCalculation'
import { parseUnits } from 'viem'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { withdrawCountryUrl } from '@/utils/native-routes'
Expand Down Expand Up @@ -351,12 +347,9 @@ export default function WithdrawBankPage() {
return
}

const withdrawAmount = parseUnits(amountToWithdraw, PEANUT_WALLET_TOKEN_DECIMALS)
if (withdrawAmount > balance) {
setBalanceErrorMessage('Not enough balance to complete withdrawal.')
} else {
setBalanceErrorMessage(null)
}
// gate on the displayed total; an in-transit shortfall passes here and
// fails late with the settling message at execution.
setBalanceErrorMessage(isAmountWithinBalance(amountToWithdraw, balance) ? null : INSUFFICIENT_BALANCE_MESSAGE)
}, [amountToWithdraw, balance, hasPendingTransactions, isLoading])

if (!bankAccount) {
Expand Down
21 changes: 16 additions & 5 deletions src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,11 @@ function applyDefaults() {
mockWithdrawFlow.selectedBankAccount = null

mockUseWallet.mockReturnValue({
// component destructures `spendableBalance` (not `balance`) — CodeRabbit nit
// component gates on the displayed `spendableBalance` (= maxDecimalAmount).
spendableBalance: parseUnits('100', 6),
formattedSpendableBalance: '100.00',
// amount-aware: over-$100 entries are a true shortfall
hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100,
})

mockUseGetExchangeRate.mockReturnValue({
Expand Down Expand Up @@ -364,10 +367,10 @@ describe('GROUP 3: Amount Validation', () => {

test('Error state shows ErrorAlert', () => {
mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' }
mockWithdrawFlow.error = { showError: true, errorMessage: 'Amount exceeds your wallet balance.' }
mockWithdrawFlow.error = { showError: true, errorMessage: 'Not enough balance. Add funds to continue.' }
renderWithdraw()

expect(screen.getByTestId('error-alert')).toHaveTextContent('Amount exceeds your wallet balance.')
expect(screen.getByTestId('error-alert')).toHaveTextContent('Not enough balance. Add funds to continue.')
})

test('Error hidden when limits blocking card is displayed', () => {
Expand Down Expand Up @@ -492,7 +495,11 @@ describe('GROUP 6: Continue never dead-buttons', () => {
// feedback (Sentry: incomplete-app-router-transaction, 6 users/14d).
mockGetCountryFromAccount.mockReturnValue(undefined)

mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) })
mockUseWallet.mockReturnValue({
spendableBalance: parseUnits('100', 6),
formattedSpendableBalance: '100.00',
hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100,
})
mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' }
mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } }
mockWithdrawFlow.amountToWithdraw = '50'
Expand All @@ -513,7 +520,11 @@ describe('GROUP 6: Continue never dead-buttons', () => {
// Manteca (AR/BR) accounts set selectedBankAccount too; the manteca
// method check must win over the generic bank branch so they reach
// /withdraw/manteca rather than the Bridge bank page (or the throw).
mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) })
mockUseWallet.mockReturnValue({
spendableBalance: parseUnits('100', 6),
formattedSpendableBalance: '100.00',
hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100,
})
mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' }
mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } }
mockWithdrawFlow.amountToWithdraw = '50'
Expand Down
28 changes: 17 additions & 11 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard'
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'
import {
rainCentsToUsdcUnits,
INSUFFICIENT_BALANCE_MESSAGE,
BALANCE_SETTLING_MESSAGE,
isAmountWithinBalance,
} from '@/utils/balance.utils'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
Expand All @@ -22,11 +27,11 @@ import { loadingStateContext } from '@/context/loadingStates.context'
import { countryData } from '@/components/AddMoney/consts'
import { getFlagUrl } from '@/constants/countryCurrencyMapping'
import Image from 'next/image'
import { formatAmount, formatNumberForDisplay } from '@/utils/general.utils'
import { formatNumberForDisplay } from '@/utils/general.utils'
import { validateCbuCvuAlias, validatePixKey, normalizePixInput, isPixEmvcoQr } from '@/utils/withdraw.utils'
import ValidatedInput from '@/components/Global/ValidatedInput'
import AmountInput from '@/components/Global/AmountInput'
import { formatUnits, parseUnits } from 'viem'
import { parseUnits } from 'viem'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { useModalsContext } from '@/context/ModalsContext'
import Select from '@/components/Global/Select'
Expand Down Expand Up @@ -105,7 +110,7 @@ function MantecaBankWithdrawFlow() {
const [priceLock, setPriceLock] = useState<WithdrawPriceLock | null>(null)
const [isLockingPrice, setIsLockingPrice] = useState(false)
const router = useRouter()
const { spendableBalance: balance } = useWallet()
const { spendableBalance: balance, formattedSpendableBalance } = useWallet()
const { signSpend } = useSignSpendBundle()
const handleStaleSession = useStaleSessionGuard()
const { overview: rainCardOverview } = useRainCardOverview()
Expand Down Expand Up @@ -358,7 +363,7 @@ function MantecaBankWithdrawFlow() {
} catch (error) {
const rainMsg = rainCollateralErrorMessage(error)
if (error instanceof InsufficientSpendableError) {
setErrorMessage('Not enough USDC in your wallet or card to cover this withdrawal.')
setErrorMessage(BALANCE_SETTLING_MESSAGE)
} else if (error instanceof SessionKeyGrantRequiredError) {
// Grant prompt was attempted inside signSpend and failed.
// Telling the user "you'll be asked" is misleading — they
Expand Down Expand Up @@ -496,8 +501,10 @@ function MantecaBankWithdrawFlow() {
// only check min amount and balance here - max amount is handled by limits validation
if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) {
setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`)
} else if (paymentAmount > balance) {
setBalanceErrorMessage('Not enough balance to complete withdrawal.')
} else if (!isAmountWithinBalance(usdAmount, balance)) {
// gate on the displayed total; an in-transit shortfall passes here and
// fails late with the settling message at execution.
setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE)
} else {
setBalanceErrorMessage(null)
}
Expand Down Expand Up @@ -687,9 +694,7 @@ function MantecaBankWithdrawFlow() {
price: 1,
decimals: 2,
}}
walletBalance={
balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined
}
walletBalance={balance !== undefined ? formattedSpendableBalance : undefined}
/>

{/* limits warning/error card - uses centralized helper for props */}
Expand Down Expand Up @@ -895,7 +900,8 @@ function MantecaBankWithdrawFlow() {
icon="arrow-up"
onClick={handleWithdraw}
loading={isLoading}
disabled={!!errorMessage || isLoading}
// settling failure is retryable — don't dead-end the button on it
disabled={(!!errorMessage && errorMessage !== BALANCE_SETTLING_MESSAGE) || isLoading}
shadowSize="4"
>
{isLoading ? loadingState : 'Withdraw'}
Expand Down
31 changes: 21 additions & 10 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { tokenSelectorContext } from '@/context/tokenSelector.context'
import { formatAmount } from '@/utils/general.utils'
import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils'
import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils'
import useGetExchangeRate from '@/hooks/useGetExchangeRate'
import { AccountType } from '@/interfaces'
Expand Down Expand Up @@ -81,15 +81,20 @@ export default function WithdrawPage() {
// raw amount currently typed in the input
const [rawTokenAmount, setRawTokenAmount] = useState<string>(amountFromContext || '')

const { spendableBalance: balance } = useWallet()
const { spendableBalance: balance, formattedSpendableBalance } = useWallet()

// Spend ceiling = the displayed total spendable. We gate on display (not an
// available-now subset) so we never block funds the live withdraw could route;
// an in-transit shortfall fails late with a settling message. See useWallet.
const maxDecimalAmount = useMemo(() => {
return balance !== undefined ? Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : 0
}, [balance])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Displayed total spendable (smart + collateral), single-sourced + formatted
// by the hook. Empty while loading so we don't flash "$0.00".
const peanutWalletBalance = useMemo(() => {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
}, [balance])
return balance === undefined ? '' : formattedSpendableBalance
}, [balance, formattedSpendableBalance])

// derive country and account type for minimum amount validation
const { countryIso2, rateAccountType } = useMemo(() => {
Expand Down Expand Up @@ -186,7 +191,11 @@ export default function WithdrawPage() {
const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below
const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD

if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) {
// While the balance is still loading, maxDecimalAmount is 0 — skip the
// balance check so a pre-filled amount isn't false-blocked; the effect
// re-validates once it lands (validateAmount is in its deps).
const balanceLoaded = balance !== undefined
if (usdEquivalent >= minUsdAmount && (!balanceLoaded || amount <= maxDecimalAmount)) {
setError({ showError: false, errorMessage: '' })
return true
}
Expand All @@ -198,15 +207,15 @@ export default function WithdrawPage() {
message = isFromSendFlow
? `Minimum send amount is ${minDisplay}.`
: `Minimum withdrawal is ${minDisplay}.`
} else if (amount > maxDecimalAmount) {
message = 'Amount exceeds your wallet balance.'
} else if (balanceLoaded && amount > maxDecimalAmount) {
message = INSUFFICIENT_BALANCE_MESSAGE
} else {
message = 'Please enter a valid amount.'
}
setError({ showError: true, errorMessage: message })
return false
},
[maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount]
[balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount]
)

const handleTokenAmountChange = useCallback(
Expand Down Expand Up @@ -338,8 +347,10 @@ export default function WithdrawPage() {
const usdEq = (selectedTokenData?.price ?? 1) * numericAmount
if (usdEq < minUsdAmount) return true // below country-specific minimum

return numericAmount > maxDecimalAmount || error.showError
}, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount])
// only apply the balance ceiling once it has loaded (maxDecimalAmount is 0
// while spendableBalance is undefined) — else Continue is disabled during load
return (balance !== undefined && numericAmount > maxDecimalAmount) || error.showError
}, [rawTokenAmount, balance, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount])

// native app: render country-specific views when ?country= is present
const viewFromQuery = searchParams.get('view')
Expand Down
Loading
Loading