diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 78d48a144d..c9a4e238da 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -56,7 +56,8 @@ export default function Home() { const t = useTranslations('home') const tNav = useTranslations('navigation') const { showPermissionModal } = useNotifications() - const { balance, isFetchingBalance, spendableBalance, isFetchingSpendableBalance } = useWallet() + const { balance, isFetchingBalance, spendableBalance, isFetchingSpendableBalance, isSpendableBalanceStale } = + useWallet() const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() const { resetWithdrawFlow } = useWithdrawFlow() const { user } = useUserStore() @@ -202,6 +203,7 @@ export default function Home() { isBalanceHidden={isBalanceHidden} onToggleBalanceVisibility={handleToggleBalanceVisibility} isFetchingBalance={isFetchingSpendableBalance} + isBalanceStale={isSpendableBalanceStale} /> @@ -327,11 +329,13 @@ function WalletBalance({ isBalanceHidden, onToggleBalanceVisibility, isFetchingBalance, + isBalanceStale, }: { balance: bigint | undefined isBalanceHidden: boolean onToggleBalanceVisibility: (e: React.MouseEvent) => void isFetchingBalance?: boolean + isBalanceStale?: boolean }) { const balanceDisplay = useMemo(() => { if (isBalanceHidden) { @@ -352,10 +356,12 @@ function WalletBalance({ ) : ( - <> + // stale = painted from the persisted last-known-good while the live + // sum is still pending; dim it rather than asserting it as current + $ {balanceDisplay} - + )} diff --git a/src/features/limits/utils.ts b/src/features/limits/utils.ts index 54f1da520b..c7bc7a99bf 100644 --- a/src/features/limits/utils.ts +++ b/src/features/limits/utils.ts @@ -193,15 +193,25 @@ export function getLimitsWarningCardProps({ // use limitCurrency from validation (correct currency for the limit) or fallback to transaction currency const limitCurrency = validation.limitCurrency ?? currency ?? 'USD' const currencySymbol = getCurrencySymbol(limitCurrency) - const formattedLimit = formatExtendedNumber(validation.remainingLimit ?? 0) - const amountDisplay = currencySymbol === '$' ? `$${formattedLimit}` : `${currencySymbol} ${formattedLimit}` + // `?? 0` here would state "you can add up to $0" when the remaining limit is + // merely UNKNOWN — the same unknown-rendered-as-zero shape as the $0 balance + // bug. Omit the sentence instead of inventing a figure. + const formattedLimit = validation.remainingLimit == null ? null : formatExtendedNumber(validation.remainingLimit) + const amountDisplay = + formattedLimit === null + ? null + : currencySymbol === '$' + ? `$${formattedLimit}` + : `${currencySymbol} ${formattedLimit}` // qr payments are always capped per transaction; onramp/offramp only when // there's no period limit to reset const perTransaction = flowType === 'qr-payment' || !validation.daysUntilReset const perTransactionSuffix = perTransaction ? ' per transaction' : '' let limitMessage = '' - if (flowType === 'onramp') { + if (amountDisplay === null) { + limitMessage = '' + } else if (flowType === 'onramp') { limitMessage = `You can add up to ${amountDisplay}${perTransactionSuffix}` } else if (flowType === 'offramp') { limitMessage = `You can withdraw up to ${amountDisplay}${perTransactionSuffix}` @@ -209,7 +219,10 @@ export function getLimitsWarningCardProps({ limitMessage = `You can pay up to ${amountDisplay}${perTransactionSuffix}` } - items.push({ text: limitMessage, kind: 'limit-amount', amount: amountDisplay, flowType, perTransaction }) + // Skip the amount item entirely when the limit is unknown — no "$0" line. + if (amountDisplay !== null) { + items.push({ text: limitMessage, kind: 'limit-amount', amount: amountDisplay, flowType, perTransaction }) + } // add days until reset if applicable if (validation.daysUntilReset) { diff --git a/src/hooks/useRainCardOverview.ts b/src/hooks/useRainCardOverview.ts index a0543d7d25..380a58474b 100644 --- a/src/hooks/useRainCardOverview.ts +++ b/src/hooks/useRainCardOverview.ts @@ -29,7 +29,14 @@ export const useRainCardOverview = () => { queryFn: () => rainApi.getOverview(), enabled: !!userId, staleTime: 30_000, - refetchInterval: 30_000, + // Only users who actually have a card application have anything here + // that moves on its own. Polling every logged-in user every 30s made + // this the single most-called endpoint and, with it, the top source of + // client-side 10s timeouts (PEANUT-UI-QD5) — for a payload that reads + // `hasApplication: false` every time. Card users keep the 30s cadence; + // everyone else refetches on focus, and the `user_rail_status_changed` + // WebSocket invalidation below resumes polling the moment they apply. + refetchInterval: (query) => (query.state.data?.status.hasApplication === false ? false : 30_000), refetchOnWindowFocus: true, retry: 1, }) diff --git a/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx new file mode 100644 index 0000000000..dd84800d52 --- /dev/null +++ b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx @@ -0,0 +1,201 @@ +/** + * Regression tests for the displayed spendable balance (PEANUT-UI-QD5). + * + * `/rain/cards` supplies BOTH Rain terms of `smart + spendingPower + inTransit`. + * When it hasn't answered, those terms fold to 0n — indistinguishable from "this + * user has no collateral" — so a card user whose funds the auto-balancer swept + * into collateral was shown a confident, wrong $0. + * + * The contract these lock down: + * 1. Rain unavailable + no cache → undefined (UI shows a loader, never $0) + * 2. Rain unavailable + cached value → cached value, flagged stale + * 3. Rain answers → live sum, not stale, cache written + * 4. The affordability gate NEVER reads the cache + */ + +import { renderHook, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import type { ReactNode } from 'react' + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_TOKEN: '0x1234567890123456789012345678901234567890', + PEANUT_WALLET_TOKEN_DECIMALS: 6, + PEANUT_WALLET_CHAIN: { id: 42161 }, +})) + +const WALLET_ADDRESS = '0x1111111111111111111111111111111111111111' +const USER_ID = 'user-under-test' + +let mockSmartBalance: bigint | undefined +let mockRainOverview: + | { + balance: { spendingPower: number; inTransitToCollateralCents?: number } | null + balanceUnavailable?: boolean + } + | undefined +let mockAnyFetching: boolean + +jest.mock('../useBalance', () => ({ + useBalance: () => ({ data: mockSmartBalance, isLoading: false, refetch: jest.fn() }), +})) + +jest.mock('../../useRainCardOverview', () => ({ + useRainCardOverview: () => ({ overview: mockRainOverview, isLoading: false }), + RAIN_CARD_OVERVIEW_QUERY_KEY: 'rain-card-overview', +})) + +jest.mock('@tanstack/react-query', () => ({ + useIsFetching: () => (mockAnyFetching ? 1 : 0), +})) + +jest.mock('../../useZeroDev', () => ({ + useZeroDev: () => ({ address: WALLET_ADDRESS, isKernelClientReady: true, handleSendUserOpEncoded: jest.fn() }), +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ + user: { + user: { userId: USER_ID }, + accounts: [{ type: 'peanut-wallet', identifier: WALLET_ADDRESS }], + }, + }), +})) + +jest.mock('../useSendMoney', () => ({ useSendMoney: () => ({ mutateAsync: jest.fn() }) })) +jest.mock('../useSpendBundle', () => ({ useSpendBundle: () => ({ spend: jest.fn() }) })) +let mockDemoMode = false +let mockDemoBalanceUnits: bigint | undefined +jest.mock('@/utils/demo', () => ({ isDemoMode: () => mockDemoMode })) +jest.mock('@/utils/demo-balance', () => ({ useDemoBalanceUnits: () => mockDemoBalanceUnits })) + +// imports must come after the jest.mock calls above +import { useWallet } from '../useWallet' +import store from '@/redux/store' +import { readLastKnownSpendable, writeLastKnownSpendable } from '../lastKnownSpendable' + +const wrapper = ({ children }: { children: ReactNode }) => {children} + +const usd = (amount: number) => BigInt(Math.round(amount * 1e6)) + +beforeEach(() => { + localStorage.clear() + mockSmartBalance = undefined + mockRainOverview = undefined + mockAnyFetching = false + mockDemoMode = false + mockDemoBalanceUnits = undefined +}) + +describe('useWallet spendable balance', () => { + it('does not report $0 when /rain/cards is unavailable and nothing is cached', async () => { + // The exact production shape: funds swept into collateral, so the smart + // account really is 0 — the total is only wrong because Rain is missing. + mockSmartBalance = 0n + mockRainOverview = undefined + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.isFetchingSpendableBalance).toBe(true)) + expect(result.current.spendableBalance).toBeUndefined() + }) + + it('paints the cached last-known total when /rain/cards is unavailable', async () => { + writeLastKnownSpendable(USER_ID, usd(120.5)) + mockSmartBalance = 0n + mockRainOverview = undefined + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(120.5))) + expect(result.current.isSpendableBalanceStale).toBe(true) + expect(result.current.isFetchingSpendableBalance).toBe(false) + expect(result.current.formattedSpendableBalance).toBe('120.5') + }) + + it('replaces the cached value with the live sum once Rain answers, and re-caches it', async () => { + writeLastKnownSpendable(USER_ID, usd(120.5)) + mockSmartBalance = usd(10) + mockRainOverview = { balance: { spendingPower: 9000, inTransitToCollateralCents: 500 } } // $90 + $5 + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(105))) + expect(result.current.isSpendableBalanceStale).toBe(false) + await waitFor(() => expect(readLastKnownSpendable(USER_ID)).toBe(usd(105))) + }) + + it('never caches a total computed without Rain', async () => { + mockSmartBalance = usd(7) + mockRainOverview = undefined + + renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(readLastKnownSpendable(USER_ID)).toBeUndefined()) + }) + + // The backend answered, but couldn't reach Rain. Summing its absent + // spendingPower as 0 is the same $0 bug via a response that "succeeded". + it('treats balanceUnavailable with no balance as not-ready and holds the cache', async () => { + writeLastKnownSpendable(USER_ID, usd(80)) + mockSmartBalance = 0n + mockRainOverview = { balance: null, balanceUnavailable: true } + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(80))) + expect(result.current.isSpendableBalanceStale).toBe(true) + expect(readLastKnownSpendable(USER_ID)).toBe(usd(80)) // not overwritten with 0 + }) + + // A stale-but-present balance is still better than the FE's own older cache. + it('uses a stale served balance when the backend flags it unavailable', async () => { + writeLastKnownSpendable(USER_ID, usd(80)) + mockSmartBalance = usd(5) + mockRainOverview = { balance: { spendingPower: 3_000 }, balanceUnavailable: true } + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(35))) + expect(result.current.isSpendableBalanceStale).toBe(false) + }) + + // A user with no card legitimately has no Rain balance — that must stay a + // real answer, or they'd never see their plain smart-account total. + it('treats a null balance without the flag as a real zero', async () => { + mockSmartBalance = usd(12) + mockRainOverview = { balance: null, balanceUnavailable: false } + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(12))) + expect(result.current.isSpendableBalanceStale).toBe(false) + }) + + // Demo makes no /rain/cards call, so gating the display on a Rain response + // would strand the demo home screen on a loader forever. + it('shows the synthesized demo balance without waiting on Rain', async () => { + mockDemoMode = true + mockDemoBalanceUnits = usd(42) + mockRainOverview = undefined + + const { result } = renderHook(() => useWallet(), { wrapper }) + + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(42))) + expect(result.current.isFetchingSpendableBalance).toBe(false) + expect(result.current.isSpendableBalanceStale).toBe(false) + // demo must never pollute the real user's cached total + expect(readLastKnownSpendable(USER_ID)).toBeUndefined() + }) + + it('keeps the affordability gate on live data, never the cache', async () => { + writeLastKnownSpendable(USER_ID, usd(500)) + mockSmartBalance = 0n + mockRainOverview = undefined + + const { result } = renderHook(() => useWallet(), { wrapper }) + + // The cached $500 is on screen, but nothing is actually spendable yet. + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(500))) + expect(result.current.hasSufficientSpendableBalance('100')).toBe(false) + }) +}) diff --git a/src/hooks/wallet/lastKnownSpendable.ts b/src/hooks/wallet/lastKnownSpendable.ts new file mode 100644 index 0000000000..192401836e --- /dev/null +++ b/src/hooks/wallet/lastKnownSpendable.ts @@ -0,0 +1,37 @@ +import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' + +/** + * Per-user persisted cache of the last fully-settled spendable total, so a cold + * start can paint the previous number immediately and correct it in the + * background. + * + * Why this exists: the displayed total is `smart + rainSpendingPower + + * rainInTransit`, and BOTH Rain terms come from a single `/rain/cards` call. + * Until that call has succeeded once, `rainCentsToUsdcUnits(undefined)` returns + * 0n — which is indistinguishable from "this user has no collateral". For a card + * user whose funds the auto-balancer swept out of the smart account, the sum then + * craters to a confident, wrong `$0` (PEANUT-UI-QD5: /rain/cards times out at 10s + * for hundreds of users). React Query retains the last successful data across a + * failed REFETCH, so this only bites on the first load of a session — which is + * exactly what this cache covers. + * + * ⚠️ DISPLAY ONLY. Never seed an affordability gate from this: the gates run on + * `availableSpendableBalance`, which stays live precisely so a stale number can't + * green-light a spend that leaves an orphan charge. + */ + +export const readLastKnownSpendable = (userId: string | undefined): bigint | undefined => { + const stored = getUserPreferences(userId)?.lastKnownSpendable + if (!stored?.units) return undefined + try { + const units = BigInt(stored.units) + return units < 0n ? undefined : units + } catch { + return undefined + } +} + +export const writeLastKnownSpendable = (userId: string | undefined, units: bigint): void => { + if (!userId || units < 0n) return + updateUserPreferences(userId, { lastKnownSpendable: { units: units.toString(), at: Date.now() } }) +} diff --git a/src/hooks/wallet/spendPreflight.ts b/src/hooks/wallet/spendPreflight.ts index 6c60bae723..5483aca852 100644 --- a/src/hooks/wallet/spendPreflight.ts +++ b/src/hooks/wallet/spendPreflight.ts @@ -90,6 +90,17 @@ export interface ResolveSpendStrategyArgs { requiredUsdcAmount: bigint rainSpendingPower: bigint collateralOnlyAllowed: boolean + /** + * Whether `rainSpendingPower` is a real reading. When the overview is missing + * it arrives as 0n, which is indistinguishable from "no collateral" — so a + * Rain outage lands in the `insufficient` branch below. Blocking is still the + * right CALL (we can't size a collateral withdrawal we can't see, and the + * user-facing copy is already "try again in a few seconds"), but it must not + * be REPORTED as an insufficiency or a Rain outage is invisible in analytics, + * hidden inside the card-withdraw failure rate. Defaults to true so existing + * callers keep their current reporting. + */ + rainBalanceKnown?: boolean /** Analytics tag distinguishing the sign-only engine; omit for the broadcasting engine. */ flow?: 'sign-only' } @@ -104,7 +115,15 @@ export interface ResolveSpendStrategyArgs { export async function resolveSpendStrategy( args: ResolveSpendStrategyArgs ): Promise<{ strategy: Exclude; smartBalance: bigint }> { - const { queryClient, accountAddress, requiredUsdcAmount, rainSpendingPower, collateralOnlyAllowed, flow } = args + const { + queryClient, + accountAddress, + requiredUsdcAmount, + rainSpendingPower, + collateralOnlyAllowed, + rainBalanceKnown = true, + flow, + } = args const smartBalance = await fetchLiveSmartUsdcBalance(queryClient, accountAddress) const strategy = computeSpendStrategy({ @@ -116,7 +135,7 @@ export async function resolveSpendStrategy( if (strategy === 'insufficient') { posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, { strategy: 'insufficient', - error_kind: 'insufficient', + error_kind: rainBalanceKnown ? 'insufficient' : 'rain-balance-unavailable', ...(flow ? { flow } : {}), }) queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 2ae7568123..27a2cac8f4 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -16,7 +16,7 @@ import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey } from './useGrantSessionKey' -import { usdcUnitsToRainCents } from '@/utils/balance.utils' +import { usdcUnitsToRainCents, isRainBalanceKnown } from '@/utils/balance.utils' import { useModalsContextOptional } from '@/context/ModalsContext' import { isDemoMode } from '@/utils/demo' import { debitDemoBalance } from '@/utils/demo-balance' @@ -152,6 +152,7 @@ export const useSpendBundle = () => { requiredUsdcAmount, rainSpendingPower, collateralOnlyAllowed, + rainBalanceKnown: isRainBalanceKnown(overview), }) onStrategyDecided?.(strategy) diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 122db2eb10..a2b2a4cdc4 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -19,12 +19,14 @@ import { computeDisplaySpendable, rainCentsToUsdcUnits, isAmountWithinBalance, + isRainBalanceKnown, } from '@/utils/balance.utils' import { useSpendBundle } from './useSpendBundle' import type { SpendStrategy } from './spendPreflight' import type { RainCollateralKind } from '@/services/rain' import { isDemoMode } from '@/utils/demo' import { useDemoBalanceUnits } from '@/utils/demo-balance' +import { readLastKnownSpendable, writeLastKnownSpendable } from './lastKnownSpendable' type SendTransactionsOptions = { chainId?: string @@ -104,7 +106,7 @@ export const useWallet = () => { // Rain collateral overview — loaded here so `sendTransactions` can consult // the current `spendingPower` when callers opt into collateral top-up. - const { overview: rainOverview, isLoading: isRainOverviewLoading } = useRainCardOverview() + const { overview: rainOverview } = useRainCardOverview() // Mutation for sending money with optimistic updates const sendMoneyMutation = useSendMoneyMutation({ address: address as Address | undefined }) @@ -242,6 +244,19 @@ export const useWallet = () => { return computeAvailableSpendable(balance, rainOverview?.balance?.spendingPower) }, [balance, rainOverview?.balance?.spendingPower]) + // `/rain/cards` supplies BOTH Rain terms of the sum, so until it has answered + // once, `rainCentsToUsdcUnits(undefined)` folds them to 0n — and a failed + // fetch is indistinguishable from "no collateral". For a card user whose + // funds were swept into collateral that renders a confident, wrong $0. + // React Query keeps the last good data across a failed refetch, so this gate + // only matters on a session's first load. Demo has no Rain call at all, so + // its synthesized balance is always "ready". + // + // A response that arrived but couldn't read Rain (`balanceUnavailable` with + // no balance to fall back on) is NOT ready either — summing its absent + // spendingPower as 0 is exactly the $0 this guards against. + const isRainReady = demoMode || isRainBalanceKnown(rainOverview) + // 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 @@ -251,18 +266,34 @@ export const useWallet = () => { const isRainFetchingActive = useIsFetching({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) > 0 const anyFetching = isSmartFetchingActive || isRainFetchingActive const [stableSpendable, setStableSpendable] = useState(undefined) + + const userId = user?.user?.userId + const [lastKnownSpendable, setLastKnownSpendable] = useState(undefined) useEffect(() => { - if (!anyFetching && rawSpendableBalance !== undefined) { - setStableSpendable((prev) => (prev === rawSpendableBalance ? prev : rawSpendableBalance)) - } - }, [anyFetching, rawSpendableBalance]) + setLastKnownSpendable(readLastKnownSpendable(userId)) + }, [userId]) + + // localStorage writes are synchronous disk I/O on native — only touch it when + // the settled total actually moved, not on every 30s poll. + const persistedSpendableRef = useRef(undefined) + useEffect(() => { + if (anyFetching || !isRainReady || rawSpendableBalance === undefined || demoMode) return + setStableSpendable((prev) => (prev === rawSpendableBalance ? prev : rawSpendableBalance)) + if (persistedSpendableRef.current === rawSpendableBalance) return + persistedSpendableRef.current = rawSpendableBalance + writeLastKnownSpendable(userId, rawSpendableBalance) + }, [anyFetching, isRainReady, rawSpendableBalance, userId, demoMode]) - // Show the stable value once we have one; fall back to the raw sum on - // first paint (before any query has settled). - const spendableBalance = stableSpendable ?? rawSpendableBalance - // Block on both smart-account and rain queries to avoid a flicker from - // the balance jumping when the rain number arrives. - const isSpendableBalanceLoading = demoMode ? false : isBalanceLoading || isRainOverviewLoading + // Show the stable value once we have one, then the live sum once Rain has + // answered, then the persisted last-known-good — so a cold start paints the + // previous number immediately and corrects it in the background rather than + // flashing $0. Cache is DISPLAY only; the gates below stay on the live + // `availableSpendableBalance`. + const spendableBalance = stableSpendable ?? (isRainReady ? rawSpendableBalance : lastKnownSpendable) + const isSpendableBalanceLoading = demoMode ? false : spendableBalance === undefined + // True while the number on screen came from cache and the live sum is still + // pending — lets the UI mark it as refreshing instead of asserting it. + const isSpendableBalanceStale = !demoMode && stableSpendable === undefined && !isRainReady && !!lastKnownSpendable // formatted balance for display (e.g. "1,234.56"). Smart-account only — // use `formattedSpendableBalance` below for user-facing widgets that @@ -309,5 +340,6 @@ export const useWallet = () => { fetchBalance, isFetchingBalance: isBalanceLoading, isFetchingSpendableBalance: isSpendableBalanceLoading, + isSpendableBalanceStale, } } diff --git a/src/services/rain.ts b/src/services/rain.ts index 2ebd1be8b1..c60c18cf96 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -61,6 +61,16 @@ export interface RainCardSummary { export interface RainCardOverview { status: RainCardApplicationStatus balance: RainCardBalance | null + /** + * `true` when the backend could not read the balance from Rain, so `balance` + * is a stale cached value or `null` — NOT an authoritative zero. A null + * balance with this set must never be summed as 0 into the displayed + * spendable total (that was the $0-balance bug, PEANUT-UI-QD5). + * + * Optional so an older/cached API response without the field still parses; + * absent is treated as "available", matching pre-change behaviour. + */ + balanceUnavailable?: boolean cards: RainCardSummary[] } diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index fc8a7cfb15..8028100ff8 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -4,6 +4,7 @@ import { computeExcessCollateralCents, EXCESS_COLLATERAL_MIN_CENTS, isAmountWithinBalance, + isRainBalanceKnown, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -195,4 +196,31 @@ describe('balance utils', () => { expect(computeExcessCollateralCents(20_000, 0)).toBe(20_000) }) }) + + // The distinction the $0-balance bug collapsed: a user with no card is a real + // zero, an overview that never arrived is unknown. Shared by the display path + // (useWallet) and the spend path (useSpendBundle) so they can't disagree. + describe('isRainBalanceKnown', () => { + it('treats a present balance as known', () => { + expect(isRainBalanceKnown({ balance: { spendingPower: 100 } })).toBe(true) + }) + + it('treats a null balance with no failure flag as known — a user with no card', () => { + expect(isRainBalanceKnown({ balance: null })).toBe(true) + expect(isRainBalanceKnown({ balance: null, balanceUnavailable: false })).toBe(true) + }) + + it('treats a flagged null balance as unknown', () => { + expect(isRainBalanceKnown({ balance: null, balanceUnavailable: true })).toBe(false) + }) + + it('trusts a stale-but-served balance even when flagged', () => { + expect(isRainBalanceKnown({ balance: { spendingPower: 100 }, balanceUnavailable: true })).toBe(true) + }) + + it('treats a missing overview as unknown, without throwing on null', () => { + expect(isRainBalanceKnown(undefined)).toBe(false) + expect(isRainBalanceKnown(null)).toBe(false) + }) + }) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index a0f4289344..ddbe03ea0b 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -94,6 +94,22 @@ export const computeAvailableSpendable = ( spendingPowerCents: number | null | undefined ): bigint => smartBalance + rainCentsToUsdcUnits(spendingPowerCents) +/** + * Is the Rain half of the balance an ANSWER, or merely absent? + * + * `rainCentsToUsdcUnits(undefined)` is 0n, so a missing overview is arithmetically + * identical to "this user has no collateral" — the root of the $0 balance bug. + * A user with no card is a real zero; an overview that never arrived, or one the + * backend flagged `balanceUnavailable` with nothing to fall back on, is unknown. + * + * Shared so the display path (`useWallet`) and the spend path (`useSpendBundle`) + * answer this question identically — they were never allowed to disagree about + * whether a Rain figure can be trusted. + */ +export const isRainBalanceKnown = ( + overview: { balance: unknown; balanceUnavailable?: boolean } | null | undefined +): boolean => !!overview && !(overview.balanceUnavailable && overview.balance == null) + /** * Total spendable balance for DISPLAY, as a USDC base-unit bigint (6dp) — * available-now plus card collateral top-ups still in transit. diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index ef2f4dd4de..71db5265b1 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -493,6 +493,10 @@ export type UserPreferences = { * Read by useHomeCarouselCTAs to apply a per-CTA cooldown before re-showing. * Legacy shape was `string[]` (permanent dismissal); both are accepted on read. */ dismissedCarouselCTAs?: string[] | Record + /** Last fully-settled spendable total (smart + Rain), in USDC base units as a + * string. DISPLAY-only seed so a cold start paints the previous number instead + * of $0 while /rain/cards is in flight or failing — see lastKnownSpendable.ts. */ + lastKnownSpendable?: { units: string; at: number } } export const updateUserPreferences = (