From 7ea2b35ece91c8dad7a89c201cb1ecd56066ab09 Mon Sep 17 00:00:00 2001 From: peanut Date: Sun, 21 Jun 2026 17:57:02 +0200 Subject: [PATCH 01/10] fix(request): show spendable balance incl. card collateral The /request create-link and direct-request views showed smart-account-only `balance`, excluding Rain card collateral, so the "Balance:" affordance read lower than /home and /send for users whose funds are split into card collateral. Switch to spendableBalance (smart + collateral), matching the Send view and useWallet's documented intent (useWallet.ts:257-263). --- .../direct-request/views/Initial.direct.request.view.tsx | 2 +- src/components/Request/link/views/Create.request.link.view.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..8766a46040 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 @@ -29,7 +29,7 @@ interface DirectRequestInitialViewProps { const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) => { const onBack = useSafeBack('/home') const { user: authUser } = useUserStore() - const { balance, address } = useWallet() + const { spendableBalance: balance, address } = useWallet() const [attachmentOptions, setAttachmentOptions] = useState({ message: undefined, fileUrl: undefined, 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..75e723c9a3 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -34,7 +34,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 } = useWallet() const { user } = useAuth() const { selectedChainID, setSelectedChainID, selectedTokenAddress, setSelectedTokenAddress, selectedTokenData } = useContext(tokenSelectorContext) From d2cfdfedafd26d4cc888b18b5193518ce4a970fb Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 14:09:39 -0700 Subject: [PATCH 02/10] fix(balance): gate money-flows on available-now spendable, not display balance The displayed spendable balance includes in-transit card-collateral top-ups so it doesn't crater during the ~10-45s smart->collateral handoff. Several legacy flows hand-rolled their affordability gate against that DISPLAY number, so during a top-up they could green-light a spend whose funds aren't routable yet - failing at execution instead of being blocked at input. Route Send-link, qr-pay and bank/manteca withdraw gates through the shared useWallet.hasSufficientSpendableBalance() predicate (smart + LANDED collateral), matching the features/payments flows. Withdraw's amount ceiling now derives from a newly-exposed availableSpendableBalance. Displayed balances are unchanged. Outside the top-up window available == display, so behaviour is identical; the change only tightens the rare in-transit window. Revives the qr-pay insufficient-balance test (skipped on mock drift) and adds a request-view regression for the spendable-balance display fix. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 15 +++++++----- src/app/(mobile-ui)/qr-pay/page.tsx | 8 ++++--- .../withdraw/[country]/bank/page.tsx | 10 ++++---- .../__tests__/withdraw-states.test.tsx | 14 ++++++++--- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 8 ++++--- src/app/(mobile-ui)/withdraw/page.tsx | 11 ++++++--- .../Request/__tests__/request-states.test.tsx | 24 +++++++++++++++++++ .../link/views/Initial.link.send.view.tsx | 20 +++++++++++----- src/hooks/wallet/useWallet.ts | 5 ++++ 9 files changed, 86 insertions(+), 29 deletions(-) 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..b9db875468 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 @@ -560,6 +560,8 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC + spendableBalance: parseUnits('100', 6), + hasSufficientSpendableBalance: () => true, sendMoney: jest.fn(), }) @@ -780,14 +782,15 @@ 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 available-now spendable is only $5, so the + // affordability gate (hasSufficientSpendableBalance) blocks it. Revived from + // skip once the gate moved off the raw display balance onto the shared hook + // predicate (the original mock-shape drift this test hit). 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..9bbce34799 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -98,7 +98,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, hasSufficientSpendableBalance, sendMoney } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -937,12 +937,14 @@ 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) { + } else if (!hasSufficientSpendableBalance(usdAmount)) { + // available-now gate (excludes in-transit collateral) — the displayed + // `balance` can briefly read higher during a card top-up. setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 4f91076d9a..ee531426b7 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -41,7 +41,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' @@ -68,7 +67,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance } = useWallet() + const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -351,13 +350,14 @@ export default function WithdrawBankPage() { return } - const withdrawAmount = parseUnits(amountToWithdraw, PEANUT_WALLET_TOKEN_DECIMALS) - if (withdrawAmount > balance) { + // available-now gate (excludes in-transit collateral); `balance` above is + // the displayed total and can read higher mid-top-up. + if (!hasSufficientSpendableBalance(amountToWithdraw)) { setBalanceErrorMessage('Not enough balance to complete withdrawal.') } else { setBalanceErrorMessage(null) } - }, [amountToWithdraw, balance, hasPendingTransactions, isLoading]) + }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null 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..6193f91e5c 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,10 @@ function applyDefaults() { mockWithdrawFlow.selectedBankAccount = null mockUseWallet.mockReturnValue({ - // component destructures `spendableBalance` (not `balance`) — CodeRabbit nit + // component destructures `spendableBalance` (display) + `availableSpendableBalance` + // (the spend ceiling that backs maxDecimalAmount). Same value here: no in-transit. spendableBalance: parseUnits('100', 6), + availableSpendableBalance: parseUnits('100', 6), }) mockUseGetExchangeRate.mockReturnValue({ @@ -492,7 +494,10 @@ 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), + availableSpendableBalance: parseUnits('100', 6), + }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } mockWithdrawFlow.amountToWithdraw = '50' @@ -513,7 +518,10 @@ 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), + availableSpendableBalance: parseUnits('100', 6), + }) 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..077a3e37d6 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -105,7 +105,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -496,12 +496,14 @@ 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) { + } else if (!hasSufficientSpendableBalance(usdAmount)) { + // available-now gate (excludes in-transit collateral); displayed `balance` + // can read higher during a card top-up. setBalanceErrorMessage('Not enough balance to complete withdrawal.') } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index fb84273e03..c8344300d3 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -81,11 +81,16 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { spendableBalance: balance } = useWallet() + const { spendableBalance: balance, availableSpendableBalance } = useWallet() + // Spend CEILING — gates the entered amount. Use available-now (smart + LANDED + // collateral); the displayed balance below includes in-transit top-ups that + // can't be withdrawn until they land. const maxDecimalAmount = useMemo(() => { - return balance !== undefined ? Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : 0 - }, [balance]) + return availableSpendableBalance !== undefined + ? Number(formatUnits(availableSpendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) + : 0 + }, [availableSpendableBalance]) const peanutWalletBalance = useMemo(() => { return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : '' diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 3cadcf7dc1..ece0c17fd4 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -285,6 +285,7 @@ jest.mock('@/context/loadingStates.context', () => { // ---------- import components under test AFTER all mocks ---------- import { CreateRequestLinkView } from '../link/views/Create.request.link.view' import { PayRequestLink } from '../Pay/Pay' +import { printableUsdc } from '@/utils/balance.utils' // the jest.fn mock above // ---------- helpers ---------- @@ -411,6 +412,29 @@ beforeEach(() => { applyDefaults() }) +// ============================================================ +// GROUP 0: Balance affordance — spendable (smart + card collateral) +// ============================================================ +describe('GROUP 0: Balance affordance', () => { + test('formats spendableBalance (smart + collateral), not smart-only balance', () => { + // Regression for the report where /request read lower than /home: the view + // must format `spendableBalance` (smart + card collateral), not the smart-only + // `balance`. printableUsdc is mocked to a constant, so we assert the VALUE it + // was handed (the field choice) rather than the rendered text. + mockUseWallet.mockReturnValue({ + address: '0x1234567890abcdef1234567890abcdef12345678', + isConnected: true, + balance: BigInt(100_000_000), // smart-only: $100 + spendableBalance: BigInt(250_000_000), // smart + collateral: $250 + }) + + renderCreateRequest() + + expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) + expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + }) +}) + // ============================================================ // GROUP 1: CreateRequestLinkView — Initial Form States // ============================================================ 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..e25358724f 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -38,7 +38,7 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance } = useWallet() + const { fetchBalance, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() @@ -133,15 +133,23 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - if ( - parseUnits(peanutWalletBalance, PEANUT_WALLET_TOKEN_DECIMALS) < - parseUnits(tokenValue, PEANUT_WALLET_TOKEN_DECIMALS) - ) { + // Gate on available-now (smart + LANDED collateral), NOT the displayed + // `peanutWalletBalance` which includes in-transit top-ups that can't be + // routed yet — otherwise the ~10–45s post-top-up window green-lights a + // create-link that fails at execution. Matches the features/payments flows. + if (!hasSufficientSpendableBalance(tokenValue)) { setErrorState({ showError: true, errorMessage: 'Insufficient balance' }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) + }, [ + peanutWalletBalance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + hasSufficientSpendableBalance, + ]) return (
diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 7c3db5f7bb..24ec10bb20 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -288,6 +288,11 @@ export const useWallet = () => { address: isAddressReady ? address : undefined, // populate address only if it is validated and matches the user's wallet address balance, spendableBalance, + // available-now spendable (smart + LANDED collateral), excluding in-transit + // top-ups. This is the spend CEILING — what `hasSufficientSpendableBalance` + // gates on. Use it (not `spendableBalance`) anywhere you cap or validate an + // amount the user is about to move, so the in-transit window can't over-permit. + availableSpendableBalance, formattedBalance, formattedSpendableBalance, hasSufficientSpendableBalance, From 54605960ef2aa17b76e58140e5477d2bfeb55f73 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 14:51:27 -0700 Subject: [PATCH 03/10] fix(balance): address CodeRabbit review on the gate commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qr-pay: drop the now-unused `sendMoney` destructuring (was pre-existing dead binding on the line I touched; flagged as a no-unused-vars lint failure). - request tests: mirror the spendable-balance regression for the direct-request entry view (the report + fix covered both /request views, only one was locked). Renders in the loading state — printableUsdc runs in a useMemo before the early return, so the field-choice assertion fires without the full form harness. --- src/app/(mobile-ui)/qr-pay/page.tsx | 2 +- .../Request/__tests__/request-states.test.tsx | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 9bbce34799..151fdd80c8 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, hasSufficientSpendableBalance, sendMoney } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index ece0c17fd4..ee95f8eeb5 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -282,9 +282,34 @@ jest.mock('@/context/loadingStates.context', () => { return { loadingStateContext } }) +// DirectRequestInitialView deps — only this view uses them (PayRequestLink does +// not), so stubbing them globally is safe. We render the view in its loading +// state below; printableUsdc runs in a useMemo BEFORE the early return, so the +// balance-field assertion still fires without rendering the full form. +const mockUseUserStore = jest.fn(() => ({ user: undefined })) +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => mockUseUserStore(), +})) + +const mockUseUserByUsername = jest.fn(() => ({ user: undefined, isLoading: true, 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: () =>
, +})) + // ---------- 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' import { printableUsdc } from '@/utils/balance.utils' // the jest.fn mock above // ---------- helpers ---------- @@ -324,6 +349,16 @@ function renderPayRequest(params: Record = {}) { ) } +function renderDirectRequest() { + const queryClient = createQueryClient() + + return render( + + + + ) +} + // ---------- default mock values ---------- function applyDefaults() { @@ -433,6 +468,22 @@ describe('GROUP 0: Balance affordance', () => { expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) }) + + test('direct-request formats spendableBalance (smart + collateral), not smart-only balance', () => { + // Mirror of the above for the second entry view both the report and the fix + // covered. Renders in the loading state (default mocks) — printableUsdc runs + // in a useMemo before the early return, so the field choice is still asserted. + mockUseWallet.mockReturnValue({ + address: '0x1234567890abcdef1234567890abcdef12345678', + balance: BigInt(100_000_000), // smart-only: $100 + spendableBalance: BigInt(250_000_000), // smart + collateral: $250 + }) + + renderDirectRequest() + + expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) + expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + }) }) // ============================================================ From 85413404b16fb64c4e54f304a2fd1fcf5185d873 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 15:27:28 -0700 Subject: [PATCH 04/10] refactor(balance): unify gate messaging (settling vs insufficient) + display formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two DRY follow-ups to the gate fix, both single-sourced in the wallet hook: 1. Messaging — useWallet.spendBlockReason(amount) classifies a blocked spend as 'settling' (covered by the displayed balance but part is still mid-rebalance: <= display, > available-now) vs 'insufficient' (exceeds the displayed total). The five legacy gates (send-link, qr-pay, withdraw page/bank/manteca) map that to one shared SPEND_BLOCK_MESSAGE instead of four bespoke strings. The 'settling' copy is deliberately generic ("Your balance is updating...") so it never exposes the card-collateral mechanic, and it only shows in the rare ~10-45s in-transit window: in the 99% case display == available so only the normal "insufficient" path is reachable. 2. Display formatting — send-link, withdraw page/manteca and both request views render the hook's formattedSpendableBalance instead of re-deriving locally (printableUsdc vs formatAmount diverged: commas vs none). One formatter, consistent across screens; orphaned imports removed. Displayed balance is unchanged: always the full spendable total (smart + collateral, incl. in-transit). Only the spend gate is strict, and only briefly. hasSufficientSpendableBalance stays for the features/payments flows; both share a parseUsdToBaseUnits helper. Tests revived/extended; full unit suite green. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 14 ++-- src/app/(mobile-ui)/qr-pay/page.tsx | 15 ++--- .../withdraw/[country]/bank/page.tsx | 16 ++--- .../__tests__/withdraw-states.test.tsx | 11 +++- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 23 +++---- src/app/(mobile-ui)/withdraw/page.tsx | 25 ++++--- .../Request/__tests__/request-states.test.tsx | 65 ++++++++++--------- .../views/Initial.direct.request.view.tsx | 9 +-- .../link/views/Create.request.link.view.tsx | 11 ++-- .../link/views/Initial.link.send.view.tsx | 31 ++++----- src/hooks/wallet/useWallet.ts | 42 ++++++++++-- src/utils/balance.utils.ts | 23 +++++++ 12 files changed, 178 insertions(+), 107 deletions(-) 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 b9db875468..2e5c06d3f5 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 @@ -138,6 +138,10 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ jest.mock('@/utils/balance.utils', () => ({ rainCentsToUsdcUnits: jest.fn(() => 0n), + SPEND_BLOCK_MESSAGE: { + settling: 'Your balance is updating. Try again in a few seconds.', + insufficient: 'Not enough balance. Add funds to continue.', + }, })) const mockUseTransactionDetailsDrawer = jest.fn() @@ -561,7 +565,7 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC spendableBalance: parseUnits('100', 6), - hasSufficientSpendableBalance: () => true, + spendBlockReason: () => null, // affordable by default sendMoney: jest.fn(), }) @@ -784,13 +788,13 @@ describe('GROUP 2: Payment Form States', () => { test('Insufficient balance shows pay button disabled + error', async () => { // Payment needs ~$18.4 but the available-now spendable is only $5, so the - // affordability gate (hasSufficientSpendableBalance) blocks it. Revived from - // skip once the gate moved off the raw display balance onto the shared hook - // predicate (the original mock-shape drift this test hit). + // gate (spendBlockReason) returns 'insufficient'. Revived from skip once the + // gate moved off the raw display balance onto the shared hook classifier + // (the original mock-shape drift this test hit). mockUseWallet.mockReturnValue({ balance: parseUnits('5', 6), spendableBalance: parseUnits('5', 6), - hasSufficientSpendableBalance: () => false, + spendBlockReason: () => 'insufficient', sendMoney: jest.fn(), }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 151fdd80c8..1e472d296c 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,7 @@ 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, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance, spendBlockReason } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -937,14 +937,13 @@ 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 (!hasSufficientSpendableBalance(usdAmount)) { - // available-now gate (excludes in-transit collateral) — the displayed - // `balance` can briefly read higher during a card top-up. - setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') } else { - setBalanceErrorMessage(null) + // available-now gate; 'settling' covers the brief card top-up window + // where the displayed balance reads higher than what's routable. + const block = spendBlockReason(usdAmount) + setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) } - }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, spendBlockReason, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index ee531426b7..c685b74461 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -24,6 +24,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 { SPEND_BLOCK_MESSAGE } 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' @@ -67,7 +68,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { address, sendMoney, spendableBalance: balance, spendBlockReason } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -350,14 +351,11 @@ export default function WithdrawBankPage() { return } - // available-now gate (excludes in-transit collateral); `balance` above is - // the displayed total and can read higher mid-top-up. - if (!hasSufficientSpendableBalance(amountToWithdraw)) { - setBalanceErrorMessage('Not enough balance to complete withdrawal.') - } else { - setBalanceErrorMessage(null) - } - }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + // available-now gate; 'settling' covers the brief card top-up window where + // the displayed balance reads higher than what's routable. + const block = spendBlockReason(amountToWithdraw) + setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) + }, [amountToWithdraw, balance, spendBlockReason, hasPendingTransactions, isLoading]) if (!bankAccount) { return null 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 6193f91e5c..0867712916 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -252,6 +252,9 @@ function applyDefaults() { // (the spend ceiling that backs maxDecimalAmount). Same value here: no in-transit. spendableBalance: parseUnits('100', 6), availableSpendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + // amount-aware so over-$100 entries classify as a real shortfall + spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), }) mockUseGetExchangeRate.mockReturnValue({ @@ -366,10 +369,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', () => { @@ -497,6 +500,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), availableSpendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } @@ -521,6 +526,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), availableSpendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 077a3e37d6..a83ee3fa23 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -5,7 +5,7 @@ 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, SPEND_BLOCK_MESSAGE } 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 +22,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 +105,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -496,14 +496,13 @@ 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 (!hasSufficientSpendableBalance(usdAmount)) { - // available-now gate (excludes in-transit collateral); displayed `balance` - // can read higher during a card top-up. - setBalanceErrorMessage('Not enough balance to complete withdrawal.') } else { - setBalanceErrorMessage(null) + // available-now gate; 'settling' covers the brief card top-up window + // where the displayed balance reads higher than what's routable. + const block = spendBlockReason(usdAmount) + setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) } - }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, spendBlockReason, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows @@ -689,9 +688,7 @@ function MantecaBankWithdrawFlow() { price: 1, decimals: 2, }} - walletBalance={ - balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined - } + walletBalance={balance ? formattedSpendableBalance : undefined} /> {/* limits warning/error card - uses centralized helper for props */} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index c8344300d3..631b2a2f75 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 { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' import useGetExchangeRate from '@/hooks/useGetExchangeRate' import { AccountType } from '@/interfaces' @@ -81,7 +81,12 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { spendableBalance: balance, availableSpendableBalance } = useWallet() + const { + spendableBalance: balance, + availableSpendableBalance, + formattedSpendableBalance, + spendBlockReason, + } = useWallet() // Spend CEILING — gates the entered amount. Use available-now (smart + LANDED // collateral); the displayed balance below includes in-transit top-ups that @@ -92,9 +97,11 @@ export default function WithdrawPage() { : 0 }, [availableSpendableBalance]) + // 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(() => { @@ -203,15 +210,17 @@ 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 { - message = 'Please enter a valid amount.' + // amount > available-now: distinguish "settling" (funds briefly + // mid-rebalance) from a true shortfall; generic prompt is a + // defensive fallback that shouldn't normally be reached. + const block = spendBlockReason(usdEquivalent) + message = block ? SPEND_BLOCK_MESSAGE[block] : 'Please enter a valid amount.' } setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [maxDecimalAmount, spendBlockReason, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index ee95f8eeb5..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) => ( -
+
{ }) // DirectRequestInitialView deps — only this view uses them (PayRequestLink does -// not), so stubbing them globally is safe. We render the view in its loading -// state below; printableUsdc runs in a useMemo BEFORE the early return, so the -// balance-field assertion still fires without rendering the full form. -const mockUseUserStore = jest.fn(() => ({ user: undefined })) +// 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: undefined, isLoading: true, error: undefined })) +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(), })) @@ -306,11 +309,15 @@ jest.mock('@/components/Global/PeanutLoading', () => ({ 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' -import { printableUsdc } from '@/utils/balance.utils' // the jest.fn mock above // ---------- helpers ---------- @@ -451,38 +458,34 @@ beforeEach(() => { // GROUP 0: Balance affordance — spendable (smart + card collateral) // ============================================================ describe('GROUP 0: Balance affordance', () => { - test('formats spendableBalance (smart + collateral), not smart-only balance', () => { - // Regression for the report where /request read lower than /home: the view - // must format `spendableBalance` (smart + card collateral), not the smart-only - // `balance`. printableUsdc is mocked to a constant, so we assert the VALUE it - // was handed (the field choice) rather than the rendered text. - mockUseWallet.mockReturnValue({ - address: '0x1234567890abcdef1234567890abcdef12345678', - isConnected: true, - balance: BigInt(100_000_000), // smart-only: $100 - spendableBalance: BigInt(250_000_000), // smart + collateral: $250 - }) + // 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(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) - expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) }) - test('direct-request formats spendableBalance (smart + collateral), not smart-only balance', () => { - // Mirror of the above for the second entry view both the report and the fix - // covered. Renders in the loading state (default mocks) — printableUsdc runs - // in a useMemo before the early return, so the field choice is still asserted. - mockUseWallet.mockReturnValue({ - address: '0x1234567890abcdef1234567890abcdef12345678', - balance: BigInt(100_000_000), // smart-only: $100 - spendableBalance: BigInt(250_000_000), // smart + collateral: $250 - }) + test('direct-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) renderDirectRequest() - expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) - expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) }) }) 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 8766a46040..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 { spendableBalance: 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 75e723c9a3..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, spendableBalance: 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 e25358724f..61d6d70dee 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 { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,13 +38,15 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { fetchBalance, spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = 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 { @@ -133,23 +135,16 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - // Gate on available-now (smart + LANDED collateral), NOT the displayed - // `peanutWalletBalance` which includes in-transit top-ups that can't be - // routed yet — otherwise the ~10–45s post-top-up window green-lights a - // create-link that fails at execution. Matches the features/payments flows. - if (!hasSufficientSpendableBalance(tokenValue)) { - setErrorState({ showError: true, errorMessage: 'Insufficient balance' }) + // Classify against available-now vs the displayed total: the ~10–45s + // post-top-up window shows the generic "updating" copy instead of a misleading + // "insufficient" that would contradict the balance on screen. + const block = spendBlockReason(tokenValue) + if (block) { + setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [ - peanutWalletBalance, - tokenValue, - setErrorState, - hasPendingTransactions, - isLoading, - hasSufficientSpendableBalance, - ]) + }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading, spendBlockReason]) return (
diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 24ec10bb20..cdf6c63c93 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, + type SpendBlockReason, +} from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -268,6 +273,14 @@ export const useWallet = () => { return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) + // Parse a USD amount to token base units; null for invalid/negative input. + // Shared by the gate + shortfall classifier so they parse identically. + const parseUsdToBaseUnits = useCallback((amountUsd: string | number): bigint | null => { + const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd + if (isNaN(amount) || amount < 0) return null + return BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) + }, []) + // 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 @@ -276,12 +289,30 @@ export const useWallet = () => { 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)) + const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) + if (amountInBaseUnits === null) return false return availableSpendableBalance >= amountInBaseUnits }, - [availableSpendableBalance] + [availableSpendableBalance, parseUsdToBaseUnits] + ) + + // Classify why a would-be spend of `amountUsd` can't go through, so callers + // can show the right message instead of a blanket "insufficient": + // • null — fully spendable now (≤ available-now) + // • 'settling' — covered by the displayed total but part is in-transit + // collateral not yet landed (≤ display, > available-now) + // • 'insufficient'— exceeds the displayed total too + // `null` while either figure is still loading (callers also guard on that). + const spendBlockReason = useCallback( + (amountUsd: string | number): SpendBlockReason | null => { + if (availableSpendableBalance === undefined || spendableBalance === undefined) return null + const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) + if (amountInBaseUnits === null) return null + if (amountInBaseUnits <= availableSpendableBalance) return null + if (amountInBaseUnits <= spendableBalance) return 'settling' + return 'insufficient' + }, + [availableSpendableBalance, spendableBalance, parseUsdToBaseUnits] ) return { @@ -296,6 +327,7 @@ export const useWallet = () => { formattedBalance, formattedSpendableBalance, hasSufficientSpendableBalance, + spendBlockReason, isConnected: isKernelClientReady, sendTransactions, sendMoney, diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index da3d4b280c..87b318f069 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -10,6 +10,29 @@ export const printableUsdc = (balance: bigint): string => { return Number(formatted).toFixed(2) } +/** + * Why a would-be spend can't go through right now: + * • 'settling' — covered by the displayed balance, but part is in-transit + * card collateral that hasn't landed yet (≤ display, > available-now). + * • 'insufficient' — exceeds the displayed balance too (a real shortfall). + * Returned by `useWallet().spendBlockReason(amount)`; `null` means it's fine. + */ +export type SpendBlockReason = 'settling' | 'insufficient' + +/** + * Single source of truth for affordability-gate copy across every money-flow + * (send, pay, withdraw). The 'settling' string explains the ~10–45s window after + * a card top-up where the displayed balance briefly exceeds what's routable — + * so the user isn't told "insufficient" while the screen shows enough. + */ +export const SPEND_BLOCK_MESSAGE: Record = { + // Deliberately generic — it must NOT expose the card-collateral mechanic. + // Only seen in the rare ~10-45s window where funds are mid-rebalance; the + // full balance is already on screen, this just says "give it a second". + settling: 'Your balance is updating. Try again in a few seconds.', + insufficient: 'Not enough balance. Add funds to continue.', +} + /** * 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 From d466312396241cb16c69a91be458796d9e40816a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 18:54:07 -0700 Subject: [PATCH 05/10] fix(balance): enforce send gate at submit + show $0 balance on manteca CodeRabbit review on the messaging/format commit: - Send-link: the Retry button isn't disabled on a balance error (unlike the other flows, which disable their submit), so handleOnNext could reach createLink with a blocked amount and fail at execution. Re-check spendBlockReason at the top of handleOnNext so the block holds at submit too. - withdraw/manteca: use an explicit `balance !== undefined` guard for the displayed balance so a real $0 balance shows "$0.00" instead of being hidden, matching the other consolidated flows (the old truthy guard dropped 0n). --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 2 +- .../Send/link/views/Initial.link.send.view.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index a83ee3fa23..53bea03436 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -688,7 +688,7 @@ function MantecaBankWithdrawFlow() { price: 1, decimals: 2, }} - walletBalance={balance ? formattedSpendableBalance : undefined} + walletBalance={balance !== undefined ? formattedSpendableBalance : undefined} /> {/* limits warning/error card - uses centralized helper for props */} 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 61d6d70dee..a66fb8bc0a 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -52,6 +52,15 @@ const LinkSendInitialView = () => { 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 and fail at execution instead of here. + const block = spendBlockReason(tokenValue) + if (block) { + setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) + return + } + setLoadingState('Loading') // clear any previous errors @@ -120,6 +129,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, + spendBlockReason, ]) useEffect(() => { From ec95c9d1ca8123d42392d050aef7d1053e73ac85 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 23:02:01 -0700 Subject: [PATCH 06/10] refactor(balance): fail-late on display gate + refetch on failure (drop settling-at-input) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating money-flows at input on an "available-now" subset was wrong: the FE balance is only ~30s-polled while the spend routing reads the chain live at submit (#2234), so an input-time available-now gate blocks spends that would actually succeed — and the ~15s flow naturally lets the ~10-45s collateral rebalance settle. So gate on the DISPLAYED balance (block only a true shortfall) and let in-transit spends fail late on live execution. - useWallet: hasSufficientSpendableBalance now gates on the displayed spendableBalance. Deletes spendBlockReason / availableSpendableBalance / SPEND_BLOCK_MESSAGE and the whole "settling at input" apparatus (−55 net). - Refetch on failure (TanStack): on InsufficientSpendableError the routing already re-read live smart balance, so the two bundle hooks (useSpendBundle / useSignSpendBundle) invalidate [RAIN_CARD_OVERVIEW_QUERY_KEY] before throwing — the displayed balance + a retry de-stale immediately instead of waiting out the 30s poll. - Informative failure copy: the post-gate in-transit failure now says "Your balance isn't fully available yet — try again in a few seconds" (one shared BALANCE_SETTLING_MESSAGE) across send (ErrorHandler), qr-pay, manteca, useSendMoney — instead of a misleading "add funds". - Keeps the display-field fix + the formattedSpendableBalance formatting consolidation. Net -39 lines. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 17 ++-- src/app/(mobile-ui)/qr-pay/page.tsx | 17 ++-- .../withdraw/[country]/bank/page.tsx | 13 ++- .../__tests__/withdraw-states.test.tsx | 14 ++-- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 17 ++-- src/app/(mobile-ui)/withdraw/page.tsx | 33 +++----- .../link/views/Initial.link.send.view.tsx | 39 +++++---- src/hooks/wallet/useSendMoney.ts | 6 +- src/hooks/wallet/useSignSpendBundle.ts | 6 +- src/hooks/wallet/useSpendBundle.ts | 6 +- src/hooks/wallet/useWallet.ts | 81 +++++-------------- src/utils/balance.utils.ts | 33 +++----- src/utils/friendly-error.utils.tsx | 5 ++ 13 files changed, 124 insertions(+), 163 deletions(-) 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 2e5c06d3f5..7217b4a8ec 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 @@ -138,10 +138,8 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ jest.mock('@/utils/balance.utils', () => ({ rainCentsToUsdcUnits: jest.fn(() => 0n), - SPEND_BLOCK_MESSAGE: { - settling: 'Your balance is updating. Try again in a few seconds.', - insufficient: 'Not enough balance. Add funds to continue.', - }, + INSUFFICIENT_BALANCE_MESSAGE: 'Not enough balance. Add funds to continue.', + BALANCE_SETTLING_MESSAGE: "Your balance isn't fully available yet. Please try again in a few seconds.", })) const mockUseTransactionDetailsDrawer = jest.fn() @@ -565,7 +563,7 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC spendableBalance: parseUnits('100', 6), - spendBlockReason: () => null, // affordable by default + hasSufficientSpendableBalance: () => true, // affordable by default sendMoney: jest.fn(), }) @@ -787,14 +785,13 @@ describe('GROUP 2: Payment Form States', () => { }) test('Insufficient balance shows pay button disabled + error', async () => { - // Payment needs ~$18.4 but the available-now spendable is only $5, so the - // gate (spendBlockReason) returns 'insufficient'. Revived from skip once the - // gate moved off the raw display balance onto the shared hook classifier - // (the original mock-shape drift this test hit). + // 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), - spendBlockReason: () => 'insufficient', + 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 1e472d296c..bdfedab86a 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,7 @@ 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, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, spendBlockReason } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -628,7 +628,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,13 +937,14 @@ 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 (!hasSufficientSpendableBalance(usdAmount)) { + // 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 { - // available-now gate; 'settling' covers the brief card top-up window - // where the displayed balance reads higher than what's routable. - const block = spendBlockReason(usdAmount) - setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) + setBalanceErrorMessage(null) } - }, [usdAmount, balance, spendBlockReason, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index c685b74461..fa13d05b8f 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -24,7 +24,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 { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } 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' @@ -68,7 +68,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance, spendBlockReason } = useWallet() + const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -351,11 +351,10 @@ export default function WithdrawBankPage() { return } - // available-now gate; 'settling' covers the brief card top-up window where - // the displayed balance reads higher than what's routable. - const block = spendBlockReason(amountToWithdraw) - setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) - }, [amountToWithdraw, balance, spendBlockReason, hasPendingTransactions, isLoading]) + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(hasSufficientSpendableBalance(amountToWithdraw) ? null : INSUFFICIENT_BALANCE_MESSAGE) + }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null 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 0867712916..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,13 +248,11 @@ function applyDefaults() { mockWithdrawFlow.selectedBankAccount = null mockUseWallet.mockReturnValue({ - // component destructures `spendableBalance` (display) + `availableSpendableBalance` - // (the spend ceiling that backs maxDecimalAmount). Same value here: no in-transit. + // component gates on the displayed `spendableBalance` (= maxDecimalAmount). spendableBalance: parseUnits('100', 6), - availableSpendableBalance: parseUnits('100', 6), formattedSpendableBalance: '100.00', - // amount-aware so over-$100 entries classify as a real shortfall - spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), + // amount-aware: over-$100 entries are a true shortfall + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockUseGetExchangeRate.mockReturnValue({ @@ -499,9 +497,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), - availableSpendableBalance: parseUnits('100', 6), formattedSpendableBalance: '100.00', - spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } @@ -525,9 +522,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { // /withdraw/manteca rather than the Bridge bank page (or the throw). mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), - availableSpendableBalance: parseUnits('100', 6), formattedSpendableBalance: '100.00', - spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 53bea03436..2c6a704d9d 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -5,7 +5,7 @@ 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, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' @@ -105,7 +105,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -358,7 +358,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,13 +496,14 @@ 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 (!hasSufficientSpendableBalance(usdAmount)) { + // 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 { - // available-now gate; 'settling' covers the brief card top-up window - // where the displayed balance reads higher than what's routable. - const block = spendBlockReason(usdAmount) - setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) + setBalanceErrorMessage(null) } - }, [usdAmount, balance, spendBlockReason, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 631b2a2f75..67f2c358b0 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 { SPEND_BLOCK_MESSAGE } from '@/utils/balance.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,21 +81,14 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { - spendableBalance: balance, - availableSpendableBalance, - formattedSpendableBalance, - spendBlockReason, - } = useWallet() - - // Spend CEILING — gates the entered amount. Use available-now (smart + LANDED - // collateral); the displayed balance below includes in-transit top-ups that - // can't be withdrawn until they land. + 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 availableSpendableBalance !== undefined - ? Number(formatUnits(availableSpendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) - : 0 - }, [availableSpendableBalance]) + 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". @@ -210,17 +203,15 @@ export default function WithdrawPage() { message = isFromSendFlow ? `Minimum send amount is ${minDisplay}.` : `Minimum withdrawal is ${minDisplay}.` + } else if (amount > maxDecimalAmount) { + message = INSUFFICIENT_BALANCE_MESSAGE } else { - // amount > available-now: distinguish "settling" (funds briefly - // mid-rebalance) from a true shortfall; generic prompt is a - // defensive fallback that shouldn't normally be reached. - const block = spendBlockReason(usdEquivalent) - message = block ? SPEND_BLOCK_MESSAGE[block] : 'Please enter a valid amount.' + message = 'Please enter a valid amount.' } setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, spendBlockReason, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( 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 a66fb8bc0a..da8d16b83f 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 { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,7 +38,12 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() + const { + fetchBalance, + spendableBalance: balance, + formattedSpendableBalance, + hasSufficientSpendableBalance, + } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() @@ -54,10 +59,10 @@ const LinkSendInitialView = () => { // 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 and fail at execution instead of here. - const block = spendBlockReason(tokenValue) - if (block) { - setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) + // amount could reach createLink. Gates on the displayed total — an + // in-transit shortfall passes here and fails late with the settling copy. + if (!hasSufficientSpendableBalance(tokenValue)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) return } @@ -129,7 +134,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, - spendBlockReason, + hasSufficientSpendableBalance, ]) useEffect(() => { @@ -145,16 +150,22 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - // Classify against available-now vs the displayed total: the ~10–45s - // post-top-up window shows the generic "updating" copy instead of a misleading - // "insufficient" that would contradict the balance on screen. - const block = spendBlockReason(tokenValue) - if (block) { - setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) + // 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 (!hasSufficientSpendableBalance(tokenValue)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading, spendBlockReason]) + }, [ + peanutWalletBalance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + hasSufficientSpendableBalance, + ]) return (
diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index 21e7c52884..aa358ce029 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, @@ -120,7 +120,9 @@ export const useSendMoney = ({ address }: UseSendMoneyOptions) => { 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 cdf6c63c93..a56c504d2b 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,12 +14,7 @@ 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, - type SpendBlockReason, -} from '@/utils/balance.utils' +import { computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -205,19 +200,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. + // Gating on the displayed total (rather than an "available-now" subset) is + // deliberate: the FE balance is only ~30s-polled while the live spend routing + // reads the chain at submit, so an input-time available-now gate would block + // spends that would actually succeed. A spend that can't be routed yet fails + // late with a "settling, try again" message + a balance refetch instead. + // See docs §4.5/§6 in peanut-api-ts/docs/rain-card-test-summary.md. const rawSpendableBalance = useMemo(() => { if (balance === undefined) return undefined return computeDisplaySpendable( @@ -273,61 +263,28 @@ export const useWallet = () => { return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) - // Parse a USD amount to token base units; null for invalid/negative input. - // Shared by the gate + shortfall classifier so they parse identically. - const parseUsdToBaseUnits = useCallback((amountUsd: string | number): bigint | null => { - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return null - return BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) - }, []) - - // 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. + // Whether the user can cover a USD amount. Gates on the DISPLAYED spendable + // total (smart + all collateral, incl. in-transit) — see the rawSpendableBalance + // note above for why we gate on display, not available-now. A spend that passes + // here but can't be routed yet fails late with the settling message + a refetch. const hasSufficientSpendableBalance = useCallback( (amountUsd: string | number): boolean => { - if (availableSpendableBalance === undefined) return false - const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) - if (amountInBaseUnits === null) return false - return availableSpendableBalance >= amountInBaseUnits - }, - [availableSpendableBalance, parseUsdToBaseUnits] - ) - - // Classify why a would-be spend of `amountUsd` can't go through, so callers - // can show the right message instead of a blanket "insufficient": - // • null — fully spendable now (≤ available-now) - // • 'settling' — covered by the displayed total but part is in-transit - // collateral not yet landed (≤ display, > available-now) - // • 'insufficient'— exceeds the displayed total too - // `null` while either figure is still loading (callers also guard on that). - const spendBlockReason = useCallback( - (amountUsd: string | number): SpendBlockReason | null => { - if (availableSpendableBalance === undefined || spendableBalance === undefined) return null - const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) - if (amountInBaseUnits === null) return null - if (amountInBaseUnits <= availableSpendableBalance) return null - if (amountInBaseUnits <= spendableBalance) return 'settling' - return 'insufficient' + if (spendableBalance === 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 spendableBalance >= amountInBaseUnits }, - [availableSpendableBalance, spendableBalance, parseUsdToBaseUnits] + [spendableBalance] ) return { address: isAddressReady ? address : undefined, // populate address only if it is validated and matches the user's wallet address balance, spendableBalance, - // available-now spendable (smart + LANDED collateral), excluding in-transit - // top-ups. This is the spend CEILING — what `hasSufficientSpendableBalance` - // gates on. Use it (not `spendableBalance`) anywhere you cap or validate an - // amount the user is about to move, so the in-transit window can't over-permit. - availableSpendableBalance, formattedBalance, formattedSpendableBalance, hasSufficientSpendableBalance, - spendBlockReason, isConnected: isKernelClientReady, sendTransactions, sendMoney, diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 87b318f069..06161c4e5b 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -11,27 +11,20 @@ export const printableUsdc = (balance: bigint): string => { } /** - * Why a would-be spend can't go through right now: - * • 'settling' — covered by the displayed balance, but part is in-transit - * card collateral that hasn't landed yet (≤ display, > available-now). - * • 'insufficient' — exceeds the displayed balance too (a real shortfall). - * Returned by `useWallet().spendBlockReason(amount)`; `null` means it's fine. - */ -export type SpendBlockReason = 'settling' | 'insufficient' - -/** - * Single source of truth for affordability-gate copy across every money-flow - * (send, pay, withdraw). The 'settling' string explains the ~10–45s window after - * a card top-up where the displayed balance briefly exceeds what's routable — - * so the user isn't told "insufficient" while the screen shows enough. + * Single source of truth for money-flow balance copy across send / pay / withdraw. + * + * 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 SPEND_BLOCK_MESSAGE: Record = { - // Deliberately generic — it must NOT expose the card-collateral mechanic. - // Only seen in the rare ~10-45s window where funds are mid-rebalance; the - // full balance is already on screen, this just says "give it a second". - settling: 'Your balance is updating. Try again in a few seconds.', - insufficient: 'Not enough balance. Add funds to continue.', -} +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." /** * Widen a Rain balance figure from integer cents (2 decimals) to a USDC diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 54914bfb63..955f4e4236 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -1,3 +1,5 @@ +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). */ @@ -45,6 +47,9 @@ export const ErrorHandler = (error: unknown): string => { // 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". + if (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.' From 9e6e63e36250321463ab3edbb977ab45420903cb Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 23:20:18 -0700 Subject: [PATCH 07/10] fix(balance): address /code-review findings (loading guards, refetch, docs, test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified findings from the high-effort review pass: - Send handleOnNext: gate only once balance has loaded (`balance !== undefined`), else a tap before the query resolves false-rejected with "not enough balance" (hasSufficientSpendableBalance returns false on undefined). - useSendMoney.onError: invalidate ['balance', address] after the optimistic rollback — the rollback was discarding the fresh live balance useSpendBundle fetched mid-flight, leaving the smart portion stale until the next 30s poll. - contribute-pot RequestPotActionList: gate the loading-flash on isFetchingSpendableBalance (smart + Rain overview), not isFetchingBalance (smart only) — the latter flashed a false "insufficient" for split-funds users while the overview loaded. - Extracted the gate to a pure, exported `isDisplayBalanceSufficient` and unit- tested the gate-on-display contract (CONTRIBUTING: hooks that gate need a test). - Fixed stale JSDoc that still claimed the gate runs on available-now (formattedSpendableBalance note, computeAvailableSpendable/DisplaySpendable), and scoped the "shared copy" comment to send/pay/withdraw. Not changed (by design / accepted): gate widened to fail-late for the features/payments flows (intended); manteca float-rounding at the boundary fails-safe; the cosmetic toast+inline double-surface on a sendMoney settling failure (follow-up). --- .../link/views/Initial.link.send.view.tsx | 8 ++-- .../components/RequestPotActionList.tsx | 9 ++-- src/hooks/wallet/useSendMoney.ts | 6 +++ src/hooks/wallet/useWallet.ts | 19 +++----- src/utils/__tests__/balance.utils.test.ts | 43 +++++++++++++++++++ src/utils/balance.utils.ts | 39 +++++++++++++---- 6 files changed, 97 insertions(+), 27 deletions(-) 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 da8d16b83f..f0f3e3260c 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -59,9 +59,10 @@ const LinkSendInitialView = () => { // 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. Gates on the displayed total — an - // in-transit shortfall passes here and fails late with the settling copy. - if (!hasSufficientSpendableBalance(tokenValue)) { + // 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 && !hasSufficientSpendableBalance(tokenValue)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) return } @@ -134,6 +135,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, + balance, hasSufficientSpendableBalance, ]) 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/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index aa358ce029..b5f82f3d4d 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -117,6 +117,12 @@ 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) { diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index a56c504d2b..c61887907d 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,7 +14,7 @@ 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 { computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { computeDisplaySpendable, rainCentsToUsdcUnits, isDisplayBalanceSufficient } from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -253,11 +253,9 @@ export const useWallet = () => { // 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. + // (2026-05-08 jotest097 report TASK-19573). The input gate runs on this same + // displayed total, so display and gate agree; a spend that isn't routable yet + // fails late rather than being blocked at input. const formattedSpendableBalance = useMemo(() => { if (spendableBalance === undefined) return '0.00' return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) @@ -267,14 +265,9 @@ export const useWallet = () => { // total (smart + all collateral, incl. in-transit) — see the rawSpendableBalance // note above for why we gate on display, not available-now. A spend that passes // here but can't be routed yet fails late with the settling message + a refetch. + // Logic lives in the pure, unit-tested `isDisplayBalanceSufficient`. const hasSufficientSpendableBalance = useCallback( - (amountUsd: string | number): boolean => { - if (spendableBalance === 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 spendableBalance >= amountInBaseUnits - }, + (amountUsd: string | number): boolean => isDisplayBalanceSufficient(amountUsd, spendableBalance), [spendableBalance] ) diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 06eaf22690..8ab31a8a33 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,6 +1,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, + isDisplayBalanceSufficient, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -105,4 +106,46 @@ describe('balance utils', () => { } ) }) + + describe('isDisplayBalanceSufficient (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(isDisplayBalanceSufficient(amount, balance)).toBe(expected) + }) + + it('accepts a numeric amount as well as a string', () => { + expect(isDisplayBalanceSufficient(100, balance)).toBe(true) + expect(isDisplayBalanceSufficient(100.01, balance)).toBe(false) + }) + + it('returns false while the balance is still loading (undefined) — never a false-positive', () => { + expect(isDisplayBalanceSufficient('1', undefined)).toBe(false) + }) + + it.each([['abc'], ['-5'], [Number.NaN], [-1]])('returns false for invalid/negative amount (%s)', (amount) => { + expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + }) + + it('a zero balance covers only a zero amount', () => { + expect(isDisplayBalanceSufficient('0', 0n)).toBe(true) + expect(isDisplayBalanceSufficient('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(isDisplayBalanceSufficient('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 06161c4e5b..322179c3d8 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -11,7 +11,9 @@ export const printableUsdc = (balance: bigint): string => { } /** - * Single source of truth for money-flow balance copy across send / pay / withdraw. + * 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 @@ -26,6 +28,25 @@ export const printableUsdc = (balance: bigint): string => { 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." +/** + * Affordability gate for money-flows: can `amountUsd` be spent against the + * DISPLAYED spendable balance (smart + all collateral, incl. in-transit)? + * Gating on the displayed total — not an available-now subset — is deliberate: + * the live spend routing reads the chain at submit, so a too-strict input gate + * would block routable funds; a pass that can't be routed yet fails late. + * Returns false while the balance is still loading (undefined). Pure + exported + * so the gate contract is unit-tested independent of the `useWallet` hook. + */ +export const isDisplayBalanceSufficient = ( + amountUsd: string | number, + spendableBalance: bigint | undefined +): boolean => { + if (spendableBalance === undefined) return false + const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd + if (isNaN(amount) || amount < 0) return false + return spendableBalance >= BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) +} + /** * 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 @@ -46,9 +67,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 + * `isDisplayBalanceSufficient` (see `useWallet`). */ export const computeAvailableSpendable = ( smartBalance: bigint, @@ -65,10 +87,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, From 10a7398ecc17f95c50a7eab36f96202b52c558e8 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 23:24:28 -0700 Subject: [PATCH 08/10] fix(balance): guard isDisplayBalanceSufficient against non-finite amounts BigInt(Math.floor(Infinity * 1e6)) throws a RangeError; a pasted/oversized amount (parseFloat -> Infinity) must fail the gate, not crash the render. isNaN didn't catch Infinity; use Number.isFinite. + tests. --- src/utils/__tests__/balance.utils.test.ts | 8 ++++++++ src/utils/balance.utils.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 8ab31a8a33..b43d528dfd 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -135,6 +135,14 @@ describe('balance utils', () => { expect(isDisplayBalanceSufficient(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(() => isDisplayBalanceSufficient(amount, balance)).not.toThrow() + expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + } + ) + it('a zero balance covers only a zero amount', () => { expect(isDisplayBalanceSufficient('0', 0n)).toBe(true) expect(isDisplayBalanceSufficient('0.01', 0n)).toBe(false) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 322179c3d8..d204133569 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -43,7 +43,10 @@ export const isDisplayBalanceSufficient = ( ): boolean => { if (spendableBalance === undefined) return false const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return false + // `Number.isFinite` rejects NaN, Infinity and -Infinity — the last is critical: + // `BigInt(Math.floor(Infinity * 1e6))` is `BigInt(Infinity)`, which THROWS a + // RangeError. A pasted/oversized amount must fail the gate, never crash it. + if (!Number.isFinite(amount) || amount < 0) return false return spendableBalance >= BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) } From 71da9f5e735d31dc62d5fc7b0460c32ac9a5fafd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 24 Jun 2026 00:05:19 -0700 Subject: [PATCH 09/10] fix(balance): adversarial-review fixes (orphan charges, button dead-end, precision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max-effort worst-case review found two real bugs I'd introduced plus robustness gaps: - [money] Orphan charges: the features/payments flows (direct-send, contribute-pot, semantic-request) createCharge BEFORE sendMoney, so widening their gate to the displayed total let an in-transit amount pass, create a backend charge, then fail late — an unpaid charge per retry. Restore the meaningful split: hasSufficientSpendableBalance now gates on AVAILABLE-NOW (those charge-first flows), while the no-pre-charge flows (send-link, qr-pay, withdraw) gate on the DISPLAYED total via the renamed pure helper isAmountWithinBalance(amount, balance). - [stuck] qr-pay Pay + manteca Withdraw buttons dead-ended after a settling failure: the settling message made isBlockingError / disabled true with no way to clear, while the copy says "try again". Exempt BALANCE_SETTLING_MESSAGE so the retry button stays live. - [precision/crash] Gate now parses the amount with parseUnits (exactly what the spend uses) instead of float Math.floor(amount*1e6): kills the boundary divergence AND fails closed (returns false, never a BigInt(Infinity) RangeError) on adversarial input. - [load] withdraw page no longer false-blocks ("insufficient") during the balance-load window (maxDecimalAmount=0); guarded on a loaded balance, re-validates when it lands. - [robustness] ErrorHandler matches the typed error name, not just the message string. - [cleanup] removed the orphaned PEANUT_WALLET_TOKEN_DECIMALS import in bank/page. Tests: pure isAmountWithinBalance (incl. Infinity/overflow), useSendMoney onError refetch. Full unit suite green. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 5 +- src/app/(mobile-ui)/qr-pay/page.tsx | 21 +++++-- .../withdraw/[country]/bank/page.tsx | 14 ++--- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 16 +++-- src/app/(mobile-ui)/withdraw/page.tsx | 10 ++- .../link/views/Initial.link.send.view.tsx | 23 ++----- .../wallet/__tests__/useSendMoney.test.tsx | 26 ++++++++ src/hooks/wallet/useWallet.ts | 61 ++++++++++++------- src/utils/__tests__/balance.utils.test.ts | 24 ++++---- src/utils/balance.utils.ts | 57 +++++++++++------ src/utils/friendly-error.utils.tsx | 17 +++--- 11 files changed, 170 insertions(+), 104 deletions(-) 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 7217b4a8ec..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,9 +137,10 @@ 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), - INSUFFICIENT_BALANCE_MESSAGE: 'Not enough balance. Add funds to continue.', - BALANCE_SETTLING_MESSAGE: "Your balance isn't fully available yet. Please try again in a few seconds.", })) const mockUseTransactionDetailsDrawer = jest.fn() diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index bdfedab86a..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, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } 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, hasSufficientSpendableBalance } = 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(() => { @@ -937,14 +948,14 @@ 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 (!hasSufficientSpendableBalance(usdAmount)) { + } 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) } - }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index fa13d05b8f..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,7 +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 } from '@/utils/balance.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' @@ -68,7 +64,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { address, sendMoney, spendableBalance: balance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -353,8 +349,8 @@ export default function WithdrawBankPage() { // gate on the displayed total; an in-transit shortfall passes here and // fails late with the settling message at execution. - setBalanceErrorMessage(hasSufficientSpendableBalance(amountToWithdraw) ? null : INSUFFICIENT_BALANCE_MESSAGE) - }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + setBalanceErrorMessage(isAmountWithinBalance(amountToWithdraw, balance) ? null : INSUFFICIENT_BALANCE_MESSAGE) + }, [amountToWithdraw, balance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 2c6a704d9d..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, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } 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' @@ -105,7 +110,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance, formattedSpendableBalance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -496,14 +501,14 @@ 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 (!hasSufficientSpendableBalance(usdAmount)) { + } 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) } - }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows @@ -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 67f2c358b0..55ecdd4458 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -191,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 } @@ -203,7 +207,7 @@ export default function WithdrawPage() { message = isFromSendFlow ? `Minimum send amount is ${minDisplay}.` : `Minimum withdrawal is ${minDisplay}.` - } else if (amount > maxDecimalAmount) { + } else if (balanceLoaded && amount > maxDecimalAmount) { message = INSUFFICIENT_BALANCE_MESSAGE } else { message = 'Please enter a valid amount.' @@ -211,7 +215,7 @@ export default function WithdrawPage() { setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( 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 f0f3e3260c..94d95baf33 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 { INSUFFICIENT_BALANCE_MESSAGE } 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,12 +38,7 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { - fetchBalance, - spendableBalance: balance, - formattedSpendableBalance, - hasSufficientSpendableBalance, - } = useWallet() + const { fetchBalance, spendableBalance: balance, formattedSpendableBalance } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() @@ -62,7 +57,7 @@ const LinkSendInitialView = () => { // 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 && !hasSufficientSpendableBalance(tokenValue)) { + if (balance !== undefined && !isAmountWithinBalance(tokenValue, balance)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) return } @@ -136,7 +131,6 @@ const LinkSendInitialView = () => { setView, setErrorState, balance, - hasSufficientSpendableBalance, ]) useEffect(() => { @@ -155,19 +149,12 @@ const LinkSendInitialView = () => { // 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 (!hasSufficientSpendableBalance(tokenValue)) { + if (!isAmountWithinBalance(tokenValue, balance)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [ - peanutWalletBalance, - tokenValue, - setErrorState, - hasPendingTransactions, - isLoading, - hasSufficientSpendableBalance, - ]) + }, [peanutWalletBalance, balance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) return (
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/useWallet.ts b/src/hooks/wallet/useWallet.ts index c61887907d..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 { computeDisplaySpendable, rainCentsToUsdcUnits, isDisplayBalanceSufficient } 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' @@ -202,12 +207,12 @@ export const useWallet = () => { // Total spendable balance: smart-account balance + Rain collateral (landed + // in-transit). Display AND the affordability gate both run on THIS number. - // Gating on the displayed total (rather than an "available-now" subset) is - // deliberate: the FE balance is only ~30s-polled while the live spend routing - // reads the chain at submit, so an input-time available-now gate would block - // spends that would actually succeed. A spend that can't be routed yet fails - // late with a "settling, try again" message + a balance refetch instead. - // See docs §4.5/§6 in peanut-api-ts/docs/rain-card-test-summary.md. + // 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( @@ -217,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 @@ -247,28 +262,28 @@ 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). The input gate runs on this same - // displayed total, so display and gate agree; a spend that isn't routable yet - // fails late rather than being blocked at input. + // 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]) - // Whether the user can cover a USD amount. Gates on the DISPLAYED spendable - // total (smart + all collateral, incl. in-transit) — see the rawSpendableBalance - // note above for why we gate on display, not available-now. A spend that passes - // here but can't be routed yet fails late with the settling message + a refetch. - // Logic lives in the pure, unit-tested `isDisplayBalanceSufficient`. + // 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 => isDisplayBalanceSufficient(amountUsd, spendableBalance), - [spendableBalance] + (amountUsd: string | number): boolean => isAmountWithinBalance(amountUsd, availableSpendableBalance), + [availableSpendableBalance] ) return { diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index b43d528dfd..1ac08289b0 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,7 +1,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, - isDisplayBalanceSufficient, + isAmountWithinBalance, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -107,7 +107,7 @@ describe('balance utils', () => { ) }) - describe('isDisplayBalanceSufficient (input affordability gate)', () => { + describe('isAmountWithinBalance (input affordability gate)', () => { const balance = 100_000_000n // $100 displayed spendable (6dp) it.each([ @@ -119,39 +119,39 @@ describe('balance utils', () => { ['100.01', false], // a cent over ['250', false], ])('gates amount %s against $100 → %s', (amount, expected) => { - expect(isDisplayBalanceSufficient(amount, balance)).toBe(expected) + expect(isAmountWithinBalance(amount, balance)).toBe(expected) }) it('accepts a numeric amount as well as a string', () => { - expect(isDisplayBalanceSufficient(100, balance)).toBe(true) - expect(isDisplayBalanceSufficient(100.01, balance)).toBe(false) + 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(isDisplayBalanceSufficient('1', undefined)).toBe(false) + expect(isAmountWithinBalance('1', undefined)).toBe(false) }) it.each([['abc'], ['-5'], [Number.NaN], [-1]])('returns false for invalid/negative amount (%s)', (amount) => { - expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + 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(() => isDisplayBalanceSufficient(amount, balance)).not.toThrow() - expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + expect(() => isAmountWithinBalance(amount, balance)).not.toThrow() + expect(isAmountWithinBalance(amount, balance)).toBe(false) } ) it('a zero balance covers only a zero amount', () => { - expect(isDisplayBalanceSufficient('0', 0n)).toBe(true) - expect(isDisplayBalanceSufficient('0.01', 0n)).toBe(false) + 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(isDisplayBalanceSufficient('500', display)).toBe(true) + 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 d204133569..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 @@ -29,25 +47,24 @@ export const INSUFFICIENT_BALANCE_MESSAGE = 'Not enough balance. Add funds to co export const BALANCE_SETTLING_MESSAGE = "Your balance isn't fully available yet. Please try again in a few seconds." /** - * Affordability gate for money-flows: can `amountUsd` be spent against the - * DISPLAYED spendable balance (smart + all collateral, incl. in-transit)? - * Gating on the displayed total — not an available-now subset — is deliberate: - * the live spend routing reads the chain at submit, so a too-strict input gate - * would block routable funds; a pass that can't be routed yet fails late. - * Returns false while the balance is still loading (undefined). Pure + exported - * so the gate contract is unit-tested independent of the `useWallet` hook. + * 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 isDisplayBalanceSufficient = ( - amountUsd: string | number, - spendableBalance: bigint | undefined -): boolean => { - if (spendableBalance === undefined) return false - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - // `Number.isFinite` rejects NaN, Infinity and -Infinity — the last is critical: - // `BigInt(Math.floor(Infinity * 1e6))` is `BigInt(Infinity)`, which THROWS a - // RangeError. A pasted/oversized amount must fail the gate, never crash it. - if (!Number.isFinite(amount) || amount < 0) return false - return spendableBalance >= BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) +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 } /** @@ -73,7 +90,7 @@ export const rainCentsToUsdcUnits = (spendingPowerCents: number | null | undefin * 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 - * `isDisplayBalanceSufficient` (see `useWallet`). + * `isAmountWithinBalance` (see `useWallet`). */ export const computeAvailableSpendable = ( smartBalance: bigint, diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 955f4e4236..6f586f29ad 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -3,16 +3,17 @@ 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 } } /** @@ -41,7 +42,7 @@ 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. @@ -49,7 +50,9 @@ export const ErrorHandler = (error: unknown): string => { 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". - if (text.includes('Insufficient spendable balance')) return BALANCE_SETTLING_MESSAGE + // 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.' From 4661a2b3845c7c9c9c07a5d934e0d0dac1774169 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 24 Jun 2026 07:28:27 -0700 Subject: [PATCH 10/10] fix(balance): preserve submit-time errors + don't disable Continue during load CodeRabbit on the adversarial-fix commit: - Send-link (Major): the balance effect cleared errorState on every sufficient- balance pass, wiping a submit-time failure message (e.g. the settling copy) the moment loading returned to idle. Now it only clears OUR balance-gate error (INSUFFICIENT_BALANCE_MESSAGE), never a handleOnNext failure message. - withdraw page (Minor): isContinueDisabled used maxDecimalAmount (=0 while the balance loads), disabling Continue during the load window. Guard it on a loaded balance, matching the validateAmount fix. --- src/app/(mobile-ui)/withdraw/page.tsx | 6 ++++-- .../Send/link/views/Initial.link.send.view.tsx | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 55ecdd4458..6eb1a7f91c 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -347,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/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 94d95baf33..2e9bf40d19 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -151,10 +151,20 @@ const LinkSendInitialView = () => { // is ~30s-polled, so blocking it here would over-reject routable funds. if (!isAmountWithinBalance(tokenValue, balance)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) - } else { + } 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, balance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) + }, [ + peanutWalletBalance, + balance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + errorState?.errorMessage, + ]) return (