diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index cdcfc5213f..83e367ace6 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -137,6 +137,9 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ })) 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), })) @@ -560,6 +563,8 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC + spendableBalance: parseUnits('100', 6), + hasSufficientSpendableBalance: () => true, // affordable by default sendMoney: jest.fn(), }) @@ -780,14 +785,14 @@ describe('GROUP 2: Payment Form States', () => { 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(), }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index c2012710f0..0759ae0139 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,12 @@ import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' 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 { @@ -98,7 +103,7 @@ export default function QRPayPage() { 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() @@ -406,7 +411,13 @@ export default function QRPayPage() { }, [paymentLock?.code, paymentProcessor]) 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(() => { @@ -628,7 +639,7 @@ export default function QRPayPage() { } 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) { @@ -937,8 +948,10 @@ export default function QRPayPage() { 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) } diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 4f91076d9a..04797e7a5f 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -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' @@ -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' @@ -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' @@ -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) { diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index a6602a68c4..bb51cd3c80 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -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({ @@ -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', () => { @@ -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' @@ -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' diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 121e8fc994..4dd2b4abf7 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -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' @@ -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' @@ -105,7 +110,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(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() @@ -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 @@ -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) } @@ -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 */} @@ -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'} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index fb84273e03..6eb1a7f91c 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -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' @@ -81,15 +81,20 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(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]) + // 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(() => { @@ -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 } @@ -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( @@ -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') diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 3cadcf7dc1..00e5c5c97d 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -138,7 +138,7 @@ jest.mock('@/components/0_Bruddle/Toast', () => ({ jest.mock('@/components/Global/AmountInput', () => ({ __esModule: true, default: (props: any) => ( -
+
{ return { loadingStateContext } }) +// DirectRequestInitialView deps — only this view uses them (PayRequestLink does +// not), so stubbing them globally is safe. Defaults resolve to a logged-in user +// viewing a valid recipient, so the main form (incl. AmountInput) renders. +const mockUseUserStore = jest.fn(() => ({ user: { user: { userId: 'user-1', username: 'me' } } })) +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => mockUseUserStore(), +})) + +const mockUseUserByUsername = jest.fn(() => ({ + user: { userId: 'recip-1', username: 'test-user', fullName: 'Test User', isVerified: false }, + isLoading: false, + error: undefined, +})) +jest.mock('@/hooks/useUserByUsername', () => ({ + useUserByUsername: () => mockUseUserByUsername(), +})) + +const mockUseUserInteractions = jest.fn(() => ({ interactions: {} })) +jest.mock('@/hooks/useUserInteractions', () => ({ + useUserInteractions: () => mockUseUserInteractions(), +})) + +jest.mock('@/components/Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/User/UserCard', () => ({ + __esModule: true, + default: () =>
, +})) + // ---------- import components under test AFTER all mocks ---------- import { CreateRequestLinkView } from '../link/views/Create.request.link.view' import { PayRequestLink } from '../Pay/Pay' +import DirectRequestInitialView from '../direct-request/views/Initial.direct.request.view' // ---------- helpers ---------- @@ -323,6 +356,16 @@ function renderPayRequest(params: Record = {}) { ) } +function renderDirectRequest() { + const queryClient = createQueryClient() + + return render( + + + + ) +} + // ---------- default mock values ---------- function applyDefaults() { @@ -411,6 +454,41 @@ beforeEach(() => { applyDefaults() }) +// ============================================================ +// GROUP 0: Balance affordance — spendable (smart + card collateral) +// ============================================================ +describe('GROUP 0: Balance affordance', () => { + // Regression for the report where /request read lower than /home: both entry + // views must show the spendable total (smart + card collateral), sourced from + // the hook's `formattedSpendableBalance` — NOT the smart-only `formattedBalance`. + // Distinct sentinels prove which field reaches the AmountInput's walletBalance. + const SPENDABLE = '250.00 (spendable)' + const SMART_ONLY = '100.00 (smart-only)' + const walletWithSplit = { + address: '0x1234567890abcdef1234567890abcdef12345678', + isConnected: true, + spendableBalance: BigInt(250_000_000), // defined → not the loading branch + formattedSpendableBalance: SPENDABLE, + formattedBalance: SMART_ONLY, + } + + test('create-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) + + renderCreateRequest() + + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) + }) + + test('direct-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) + + renderDirectRequest() + + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) + }) +}) + // ============================================================ // GROUP 1: CreateRequestLinkView — Initial Form States // ============================================================ diff --git a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx index dfad01ffc1..33b8b2b366 100644 --- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx +++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx @@ -15,7 +15,6 @@ import { useUserStore } from '@/redux/hooks' import { type IAttachmentOptions } from '@/interfaces/attachment' import { usersApi } from '@/services/users' import { formatAmount } from '@/utils/general.utils' -import { printableUsdc } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useUserInteractions } from '@/hooks/useUserInteractions' @@ -29,7 +28,7 @@ interface DirectRequestInitialViewProps { const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) => { const onBack = useSafeBack('/home') const { user: authUser } = useUserStore() - const { balance, address } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, address } = useWallet() const [attachmentOptions, setAttachmentOptions] = useState({ message: undefined, fileUrl: undefined, @@ -66,9 +65,11 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = }) } + // Displayed total spendable, single-sourced + formatted by the hook; empty + // while loading so we don't flash "$0.00". const peanutWalletBalance = useMemo(() => { - return balance !== undefined ? printableUsdc(balance) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) const handleTokenValueChange = (value: string | undefined) => { setCurrentInputValue(value || '') diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index b43724bc75..09d7308373 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -22,7 +22,6 @@ import { type IToken } from '@/interfaces' import { type IAttachmentOptions } from '@/interfaces/attachment' import { requestsApi } from '@/services/requests' import { fetchTokenSymbol, formatTokenAmount, getRequestLink, isNativeCurrency } from '@/utils/general.utils' -import { printableUsdc } from '@/utils/balance.utils' import * as Sentry from '@sentry/nextjs' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useQueryClient } from '@tanstack/react-query' @@ -34,7 +33,7 @@ import { useSafeBack } from '@/hooks/useSafeBack' export const CreateRequestLinkView = () => { const toast = useToast() const onBack = useSafeBack('/home') - const { address, isConnected, balance } = useWallet() + const { address, isConnected, spendableBalance: balance, formattedSpendableBalance } = useWallet() const { user } = useAuth() const { selectedChainID, setSelectedChainID, selectedTokenAddress, setSelectedTokenAddress, selectedTokenData } = useContext(tokenSelectorContext) @@ -79,8 +78,12 @@ export const CreateRequestLinkView = () => { // Refs for cleanup const createLinkAbortRef = useRef(null) - // Derived state - const peanutWalletBalance = useMemo(() => (balance !== undefined ? printableUsdc(balance) : ''), [balance]) + // Derived state — displayed total spendable, single-sourced + formatted by the + // hook; empty while loading so we don't flash "$0.00". + const peanutWalletBalance = useMemo( + () => (balance === undefined ? '' : formattedSpendableBalance), + [balance, formattedSpendableBalance] + ) const usdValue = useMemo(() => { if (!selectedTokenData?.price || !tokenValue) return '' diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 570c8fbf8d..2e9bf40d19 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -10,7 +10,7 @@ import { useLinkSendFlow } from '@/context/LinkSendFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { sendLinksApi } from '@/services/sendLinks' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { printableUsdc } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,18 +38,30 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance } = useWallet() + const { fetchBalance, spendableBalance: balance, formattedSpendableBalance } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() + // 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 ? printableUsdc(balance) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) const handleOnNext = useCallback(async () => { try { if (isLoading || !tokenValue) return + // Re-check affordability at submit too: the Retry button isn't disabled + // on a balance error (unlike the other flows), so without this a blocked + // amount could reach createLink. Only when the balance has loaded — else + // a tap before the query resolves would false-reject. Gates on the + // displayed total; an in-transit shortfall fails late with the settling copy. + if (balance !== undefined && !isAmountWithinBalance(tokenValue, balance)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) + return + } + setLoadingState('Loading') // clear any previous errors @@ -118,6 +130,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, + balance, ]) useEffect(() => { @@ -133,15 +146,25 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - if ( - parseUnits(peanutWalletBalance, PEANUT_WALLET_TOKEN_DECIMALS) < - parseUnits(tokenValue, PEANUT_WALLET_TOKEN_DECIMALS) - ) { - setErrorState({ showError: true, errorMessage: 'Insufficient balance' }) - } else { + // Gate on the displayed total: block only a true shortfall. An in-transit + // amount passes and fails late (settling message + refetch) — the FE balance + // is ~30s-polled, so blocking it here would over-reject routable funds. + if (!isAmountWithinBalance(tokenValue, balance)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) + } else if (errorState?.errorMessage === INSUFFICIENT_BALANCE_MESSAGE) { + // only clear OUR balance-gate error — never wipe a submit-time failure + // message (e.g. the settling copy) that handleOnNext set on a late failure. setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) + }, [ + peanutWalletBalance, + balance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + errorState?.errorMessage, + ]) return (
diff --git a/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx b/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx index 29517405d9..20f4e9b869 100644 --- a/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx +++ b/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx @@ -58,7 +58,7 @@ export function RequestPotActionList({ const router = useRouter() const dispatch = useAppDispatch() const { user } = useAuth() - const { hasSufficientSpendableBalance: hasSufficientBalance, isFetchingBalance } = useWallet() + const { hasSufficientSpendableBalance: hasSufficientBalance, isFetchingSpendableBalance } = useWallet() // MIGRATION-REVIEW: mercadopago/pix are QR `pay` methods over Manteca. Old gate was // `isUserMantecaKycApproved`; mapped to canDo('pay', { provider: 'manteca' }) (operation-specific). const isMantecaPayEnabled = useCapabilities().canDo('pay', { provider: 'manteca' }) @@ -80,9 +80,12 @@ export function RequestPotActionList({ // only show insufficient balance after balance has loaded to avoid flash const userHasSufficientPeanutBalance = useMemo(() => { if (!user || !usdAmount) return false - if (isFetchingBalance) return true // assume sufficient while loading to avoid flash + // wait on BOTH smart + Rain overview (spendable) — using the smart-only + // flag would gate on a partial balance and flash a false "insufficient" + // for split-funds users during the Rain-overview load window. + if (isFetchingSpendableBalance) return true // assume sufficient while loading to avoid flash return hasSufficientBalance(usdAmount) - }, [user, usdAmount, hasSufficientBalance, isFetchingBalance]) + }, [user, usdAmount, hasSufficientBalance, isFetchingSpendableBalance]) // filter and sort payment methods const { filteredMethods: sortedMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({ diff --git a/src/hooks/wallet/__tests__/useSendMoney.test.tsx b/src/hooks/wallet/__tests__/useSendMoney.test.tsx index ebd421c384..501bd5d566 100644 --- a/src/hooks/wallet/__tests__/useSendMoney.test.tsx +++ b/src/hooks/wallet/__tests__/useSendMoney.test.tsx @@ -179,6 +179,32 @@ describe('useSendMoney', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [TRANSACTIONS] }) }) }) + + it('should refetch balance on failure so the rolled-back stale value cannot linger', async () => { + // The rollback restores the pre-tap snapshot, which can discard a fresh + // live balance the spend read mid-flight — onError must invalidate to refetch. + const initialBalance = parseUnits('100', PEANUT_WALLET_TOKEN_DECIMALS) + queryClient.setQueryData(['balance', mockAddress], initialBalance) + mockSmartBalance = initialBalance + mockSpend.mockRejectedValue(new Error('Transaction failed')) + + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useSendMoney({ address: mockAddress }), { wrapper }) + + try { + await result.current.mutateAsync({ + toAddress: '0x9999999999999999999999999999999999999999' as `0x${string}`, + amountInUsd: '10', + }) + } catch { + // expected + } + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['balance', mockAddress] }) + }) + }) }) describe('Edge Cases', () => { diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index 21e7c52884..b5f82f3d4d 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -6,7 +6,7 @@ import { TRANSACTIONS, BALANCE_DECREASE, SEND_MONEY } from '@/constants/query.co import { useToast } from '@/components/0_Bruddle/Toast' import { useBalance } from './useBalance' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import type { RainCollateralKind } from '@/services/rain' import { InsufficientSpendableError, @@ -117,10 +117,18 @@ export const useSendMoney = ({ address }: UseSendMoneyOptions) => { queryClient.setQueryData(['balance', address], context.previousBalance) } + // The spend may have read a fresh live balance mid-flight (useSpendBundle's + // fetchLiveSmartUsdcBalance) that the rollback above just discarded — + // refetch so the displayed balance settles on on-chain truth, not the + // pre-tap cached value. + queryClient.invalidateQueries({ queryKey: ['balance', address] }) + console.error('[useSendMoney] Transaction failed, rolled back balance:', error) if (error instanceof InsufficientSpendableError) { - toast.error('Insufficient balance for this transfer.') + // Passed the display gate but couldn't route yet — useSpendBundle has + // already refetched the Rain overview; nudge a retry. + toast.error(BALANCE_SETTLING_MESSAGE) return } if (error instanceof SessionKeyGrantRequiredError) { diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index ba8ea1a440..a6206642a1 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -15,7 +15,7 @@ import { rainWithdrawEip712Types, } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' -import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { useSignUserOp, type SignedUserOpData } from './useSignUserOp' import { @@ -142,6 +142,10 @@ export const useSignSpendBundle = () => { error_kind: 'insufficient', flow: 'sign-only', }) + // Passed the FE display gate but the live balance can't cover it yet + // (in-transit collateral not landed / ~30s-stale FE). Refresh the Rain + // overview so the displayed balance + a retry reflect reality. + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) throw new InsufficientSpendableError() } diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 2f42c122a9..ef12a6d5c0 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -18,7 +18,7 @@ import { } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' -import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { usdcUnitsToRainCents } from '@/utils/balance.utils' import { useModalsContextOptional } from '@/context/ModalsContext' @@ -202,6 +202,10 @@ export const useSpendBundle = () => { strategy: 'insufficient', error_kind: 'insufficient', }) + // Passed the FE display gate but the live balance can't cover it yet + // (in-transit collateral not landed / ~30s-stale FE). Refresh the Rain + // overview so the displayed balance + a retry reflect reality. + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) throw new InsufficientSpendableError() } diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 7c3db5f7bb..8dd9cb92df 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,7 +14,12 @@ import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { computeAvailableSpendable, computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { + computeAvailableSpendable, + computeDisplaySpendable, + rainCentsToUsdcUnits, + isAmountWithinBalance, +} from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -200,19 +205,14 @@ export const useWallet = () => { // consider balance as fetching until: address is validated and query has resolved const isBalanceLoading = !isAddressReady || isFetchingBalance - // Two flavours of "spendable", both summing the smart-account balance with - // Rain collateral. See docs §4.5 and §6 in peanut-api-ts/docs/rain-card-test-summary.md - // and the card design spec. `rainOverview` is declared above so `sendTransactions` - // can consult it too. - // • availableSpendableBalance — smart + LANDED collateral. What can actually - // be spent right now; backs the affordability gate + spend routing. - // • rawSpendableBalance (display) — also adds collateral top-ups still in - // transit, so the unified balance doesn't crater to 0 during the ~10–45s - // smart→collateral auto-balance handoff. - const availableSpendableBalance = useMemo(() => { - if (balance === undefined) return undefined - return computeAvailableSpendable(balance, rainOverview?.balance?.spendingPower) - }, [balance, rainOverview?.balance?.spendingPower]) + // Total spendable balance: smart-account balance + Rain collateral (landed + + // in-transit). Display AND the affordability gate both run on THIS number. + // DISPLAY spendable (smart + landed + in-transit collateral). What we show, + // and what the fail-late flows (send-link, qr-pay, withdraw) gate on directly + // via isAmountWithinBalance: the FE balance is only ~30s-polled while the live + // spend routing reads the chain at submit, so blocking an in-transit amount at + // input would reject funds that would actually succeed — it fails late with a + // "settling, try again" message + a refetch instead. const rawSpendableBalance = useMemo(() => { if (balance === undefined) return undefined return computeDisplaySpendable( @@ -222,6 +222,16 @@ export const useWallet = () => { ) }, [balance, rainOverview?.balance?.spendingPower, rainOverview?.balance?.inTransitToCollateralCents]) + // AVAILABLE-NOW spendable (smart + LANDED collateral, NO in-transit). What + // useSpendBundle can route this instant, and what hasSufficientSpendableBalance + // gates on — for flows that take an irreversible step BEFORE the spend (the + // features/payments flows createCharge first), so an in-transit amount is + // blocked at input rather than leaving an orphan charge when it fails late. + const availableSpendableBalance = useMemo(() => { + if (balance === undefined) return undefined + return computeAvailableSpendable(balance, rainOverview?.balance?.spendingPower) + }, [balance, rainOverview?.balance?.spendingPower]) + // The two inputs (smart-account + rain overview) refresh independently. // When both change at once (e.g. auto-balancer deposit: smart goes down, // collateral goes up by the same amount), the queries settle at slightly @@ -252,35 +262,27 @@ export const useWallet = () => { return formatCurrency(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [balance]) - // Total spendable (smart + Rain collateral) formatted for display. - // Payment-input forms (request-pay, direct-send, contribute-pot) should - // show THIS rather than the smart-only number — otherwise a user with - // funds split across smart and collateral sees a smaller balance than - // they actually have and the "insufficient balance" gate trips even - // though useSpendBundle would route through collateral just fine - // (2026-05-08 jotest097 report TASK-19573). - // NOTE: derived from `spendableBalance`, which includes in-transit collateral - // top-ups, so during the ~10–45s smart→collateral handoff this can read - // higher than `hasSufficientSpendableBalance` allows (gate is on available-now, - // by design — those funds aren't routable until they land). Self-heals in seconds. + // Total spendable (smart + Rain collateral) formatted for display. All + // payment-input forms show THIS rather than the smart-only number — otherwise + // a user with funds split across smart and collateral sees a smaller balance + // than they actually have (2026-05-08 jotest097 report TASK-19573). Note the + // gate may be stricter than the display: the features/payments flows gate on + // available-now (see hasSufficientSpendableBalance) while still showing this + // full total, so during the brief in-transit window display can exceed what + // they can spend — by design, it reconciles in seconds. const formattedSpendableBalance = useMemo(() => { if (spendableBalance === undefined) return '0.00' return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) - // Check if the user has enough spendable to cover a USD amount. Gates on - // available-now (smart + LANDED collateral) — NOT the displayed total, which - // includes in-transit top-ups that can't be routed until they land. Using the - // real figure avoids green-lighting a send that would fail at execution during - // the brief top-up window. + // STRICT affordability gate on AVAILABLE-NOW (excludes in-transit). Used by + // the features/payments flows, which createCharge before spending — an + // in-transit amount must be blocked here, not green-lit into an orphan charge. + // Fail-late flows (send-link, qr-pay, withdraw) instead gate on the displayed + // `spendableBalance` directly via isAmountWithinBalance. Logic is the pure, + // unit-tested isAmountWithinBalance. const hasSufficientSpendableBalance = useCallback( - (amountUsd: string | number): boolean => { - if (availableSpendableBalance === undefined) return false - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return false - const amountInBaseUnits = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) - return availableSpendableBalance >= amountInBaseUnits - }, + (amountUsd: string | number): boolean => isAmountWithinBalance(amountUsd, availableSpendableBalance), [availableSpendableBalance] ) diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 06eaf22690..1ac08289b0 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,6 +1,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, + isAmountWithinBalance, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -105,4 +106,54 @@ describe('balance utils', () => { } ) }) + + describe('isAmountWithinBalance (input affordability gate)', () => { + const balance = 100_000_000n // $100 displayed spendable (6dp) + + it.each([ + ['0', true], + ['50', true], + ['99.99', true], + ['100', true], // exact balance is affordable + ['100.00', true], + ['100.01', false], // a cent over + ['250', false], + ])('gates amount %s against $100 → %s', (amount, expected) => { + expect(isAmountWithinBalance(amount, balance)).toBe(expected) + }) + + it('accepts a numeric amount as well as a string', () => { + expect(isAmountWithinBalance(100, balance)).toBe(true) + expect(isAmountWithinBalance(100.01, balance)).toBe(false) + }) + + it('returns false while the balance is still loading (undefined) — never a false-positive', () => { + expect(isAmountWithinBalance('1', undefined)).toBe(false) + }) + + it.each([['abc'], ['-5'], [Number.NaN], [-1]])('returns false for invalid/negative amount (%s)', (amount) => { + expect(isAmountWithinBalance(amount, balance)).toBe(false) + }) + + it.each([[Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY], ['1e999'], ['Infinity']])( + 'never throws on non-finite / overflowing amount (%s) — returns false, not a RangeError', + (amount) => { + expect(() => isAmountWithinBalance(amount, balance)).not.toThrow() + expect(isAmountWithinBalance(amount, balance)).toBe(false) + } + ) + + it('a zero balance covers only a zero amount', () => { + expect(isAmountWithinBalance('0', 0n)).toBe(true) + expect(isAmountWithinBalance('0.01', 0n)).toBe(false) + }) + + it('gates on the DISPLAYED total incl. in-transit (the contract this PR locks in)', () => { + // smart 0, no landed collateral, $500 in transit → display $500 + const display = computeDisplaySpendable(0n, 0, 50_000) + expect(isAmountWithinBalance('500', display)).toBe(true) + // available-now is $0 here, but the gate must NOT block — it fails late instead + expect(computeAvailableSpendable(0n, 0)).toBe(0n) + }) + }) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index da3d4b280c..7aa9be3edc 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -1,5 +1,23 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' -import { formatUnits } from 'viem' +import { formatUnits, parseUnits } from 'viem' + +/** + * Parse a USD amount (string or number) to token base units PRECISELY — the same + * `parseUnits` the spend itself uses, so the gate verifies exactly what execution + * will require (no float `Math.floor` divergence at the boundary). Returns null + * for anything invalid — empty, NaN, negative, locale comma, >decimals fraction, + * scientific/overflow/Infinity — so the gate fails closed and NEVER throws. + */ +export const parseUsdAmountToUnits = (amountUsd: string | number): bigint | null => { + try { + const s = (typeof amountUsd === 'number' ? amountUsd.toString() : amountUsd).trim() + if (!s) return null + const units = parseUnits(s, PEANUT_WALLET_TOKEN_DECIMALS) + return units < 0n ? null : units + } catch { + return null + } +} export const printableUsdc = (balance: bigint): string => { // For 6 decimals, we want 2 decimal places in output @@ -10,6 +28,45 @@ export const printableUsdc = (balance: bigint): string => { return Number(formatted).toFixed(2) } +/** + * Shared balance copy for the send-link, qr-pay and withdraw flows. (The + * features/payments flows — direct-send/semantic-request/contribute-pot — keep + * their own in-context wording.) + * + * Two distinct moments: + * - INSUFFICIENT — input-time gate, when the entered amount exceeds the full + * displayed balance (a real shortfall). Gates run on the DISPLAYED balance so + * we never block funds the live spend could actually route — see `useWallet`. + * - SETTLING — failure-time, when a spend that passed the gate can't be routed + * yet (the smart→collateral rebalance hasn't landed, or the ~30s-polled FE + * balance was momentarily ahead of chain). Deliberately generic — it must NOT + * expose the card-collateral mechanic — and it nudges a retry, since the FE + * balance is refetched on this failure and the spend usually succeeds shortly. + */ +export const INSUFFICIENT_BALANCE_MESSAGE = 'Not enough balance. Add funds to continue.' +export const BALANCE_SETTLING_MESSAGE = "Your balance isn't fully available yet. Please try again in a few seconds." + +/** + * Pure affordability check: does `balanceUnits` cover `amountUsd`? Parses the + * amount the same way the spend does (parseUnits — precise, no float drift) and + * fails closed on invalid/loading input (returns false, never throws). + * + * The CALLER chooses which balance to pass, and that choice is the gate policy: + * - DISPLAYED total (smart + landed + in-transit) for fail-late flows that take + * no irreversible step before spending (send-link, qr-pay, withdraw) — an + * in-transit amount passes and, if not yet routable, fails late. + * - AVAILABLE-NOW (smart + landed) for flows that do something irreversible + * BEFORE the spend — the features/payments flows `createCharge` first, so an + * in-transit amount must be blocked at input or it leaves an orphan charge. + * Exported so the gate contract is unit-tested independent of `useWallet`. + */ +export const isAmountWithinBalance = (amountUsd: string | number, balanceUnits: bigint | undefined): boolean => { + if (balanceUnits === undefined) return false + const units = parseUsdAmountToUnits(amountUsd) + if (units === null) return false + return balanceUnits >= units +} + /** * Widen a Rain balance figure from integer cents (2 decimals) to a USDC * bigint (matching PEANUT_WALLET_TOKEN_DECIMALS, typically 6) so it can be @@ -30,9 +87,10 @@ export const rainCentsToUsdcUnits = (spendingPowerCents: number | null | undefin /** * Available-now spendable balance, as a USDC base-unit bigint (6dp) — the * smart-account balance plus landed Rain collateral `spendingPower`. This is - * what the user can actually spend right now (`useSpendBundle` routes through - * the smart account and landed collateral), so it backs the affordability gate - * and spend routing. + * what `useSpendBundle` can actually route through right now. It is the base of + * `computeDisplaySpendable` (which adds in-transit on top); it does NOT back the + * input affordability gate — that gates on the displayed total via + * `isAmountWithinBalance` (see `useWallet`). */ export const computeAvailableSpendable = ( smartBalance: bigint, @@ -49,10 +107,11 @@ export const computeAvailableSpendable = ( * (from the backend's `inTransitToCollateralCents`) keeps the unified balance * steady through the handoff. * - * In-transit funds aren't spendable until they land, so they are deliberately - * EXCLUDED from `computeAvailableSpendable` (gate + routing). During the window - * the displayed total therefore exceeds spendable-now by the in-flight amount — - * by design — and reconciles within seconds once collateral lands. + * In-transit funds aren't routable until they land, so they are excluded from + * `computeAvailableSpendable` (what spend routing can actually use). The input + * gate, by contrast, runs on THIS displayed total — a spend that can't be + * routed yet fails late rather than being blocked at input. The displayed total + * reconciles within seconds once collateral lands. */ export const computeDisplaySpendable = ( smartBalance: bigint, diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 54914bfb63..6f586f29ad 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -1,16 +1,19 @@ +import { BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' + /** Safely extract a string-form of an unknown error + its `.message` if any. * Lets the matchers below use `string` methods without unsafe property access * while still accepting whatever shape callers throw (Error, string, object). */ -function extractErrorParts(error: unknown): { text: string; message: string | undefined } { - if (typeof error === 'string') return { text: error, message: error } +function extractErrorParts(error: unknown): { text: string; message: string | undefined; name: string | undefined } { + if (typeof error === 'string') return { text: error, message: error, name: undefined } if (error && typeof error === 'object') { - const obj = error as { toString?: () => unknown; message?: unknown } + const obj = error as { toString?: () => unknown; message?: unknown; name?: unknown } const rawText = typeof obj.toString === 'function' ? obj.toString() : '' const text = typeof rawText === 'string' ? rawText : '' const message = typeof obj.message === 'string' ? obj.message : undefined - return { text, message } + const name = typeof obj.name === 'string' ? obj.name : undefined + return { text, message, name } } - return { text: '', message: undefined } + return { text: '', message: undefined, name: undefined } } /** @@ -39,12 +42,17 @@ export const rainCollateralErrorMessage = (error: unknown): string | null => { /** UI-friendly error message extractor. Matches substrings on common * wallet / viem / Peanut API error messages and returns user-facing copy. */ export const ErrorHandler = (error: unknown): string => { - const { text, message } = extractErrorParts(error) + const { text, message, name } = extractErrorParts(error) // Rain card-collateral errors — surface the backend's already user- // friendly copy verbatim (includes the "Try again in about M min." hint // on the cooldown case). Covers every spend path that touches Rain. const rainMsg = rainCollateralErrorMessage(error) if (rainMsg) return rainMsg + // Spend passed the displayed-balance gate but couldn't be routed yet + // (in-transit collateral not landed) — nudge a retry rather than "add funds". + // Match the typed error's name first (stable) and fall back to the message. + if (name === 'InsufficientSpendableError' || text.includes('Insufficient spendable balance')) + return BALANCE_SETTLING_MESSAGE if (text.includes('insufficient funds')) return "You don't have enough funds." if (text.includes('user rejected transaction')) return 'Please confirm the transaction in your wallet.' if (text.includes('not deployed on chain')) return 'Bulk is not able on this chain, please try another chain.'