From 677ec585429c735b646d1644f759f3c14dc2d8d8 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 20:34:19 +0100 Subject: [PATCH 1/5] fix(balance): keep last-known spendable total instead of rendering $0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home screen's headline number is smart-account balance + Rain spendingPower + Rain inTransit. Both Rain terms come from a single /rain/cards call, and rainCentsToUsdcUnits(undefined) folds them to 0n — so a failed fetch was indistinguishable from 'this user has no collateral'. For a card user whose funds the auto-balancer swept into collateral, the smart account is legitimately 0, and the total cratered to a confident, wrong $0 with no error and no retry affordance. /rain/cards times out at 10s for 621 users (PEANUT-UI-QD5), on web and native alike; native compounds it because the Android WebView GET path is edge-blocked on some devices (PEANUT-UI-R5F). React Query retains the last good data across a failed refetch, so this only bit on a session's first load. Persist the last fully-settled total per user and seed the display from it, so a cold start paints the previous number immediately and corrects it in the background. The cached value is display-only and dimmed while stale — every affordability gate still runs on the live availableSpendableBalance, so a stale number can never green-light a spend into an orphan charge. --- src/app/(mobile-ui)/home/page.tsx | 12 +- .../__tests__/useWalletSpendable.test.tsx | 160 ++++++++++++++++++ src/hooks/wallet/lastKnownSpendable.ts | 37 ++++ src/hooks/wallet/useWallet.ts | 49 ++++-- src/utils/general.utils.ts | 4 + 5 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 src/hooks/wallet/__tests__/useWalletSpendable.test.tsx create mode 100644 src/hooks/wallet/lastKnownSpendable.ts diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 9174375ae6..3811bba167 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -54,7 +54,8 @@ const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_ export default function Home() { 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() @@ -193,6 +194,7 @@ export default function Home() { isBalanceHidden={isBalanceHidden} onToggleBalanceVisibility={handleToggleBalanceVisibility} isFetchingBalance={isFetchingSpendableBalance} + isBalanceStale={isSpendableBalanceStale} /> @@ -312,11 +314,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) { @@ -337,10 +341,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/hooks/wallet/__tests__/useWalletSpendable.test.tsx b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx new file mode 100644 index 0000000000..4c56128a40 --- /dev/null +++ b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx @@ -0,0 +1,160 @@ +/** + * 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 } | 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 })) + +// eslint-disable-next-line import/first -- must come after jest.mock calls +import { useWallet } from '../useWallet' +// eslint-disable-next-line import/first +import store from '@/redux/store' +// eslint-disable-next-line import/first +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()) + }) + + // 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/useWallet.ts b/src/hooks/wallet/useWallet.ts index 122db2eb10..818411bc4c 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -25,6 +25,7 @@ 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 +105,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 +243,15 @@ 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". + const isRainReady = demoMode || rainOverview !== undefined + // 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 +261,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 +335,6 @@ export const useWallet = () => { fetchBalance, isFetchingBalance: isBalanceLoading, isFetchingSpendableBalance: isSpendableBalanceLoading, + isSpendableBalanceStale, } } diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 8b9e69bbd8..d6288afcb4 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -498,6 +498,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 = ( From 4f8258163e3687e81ec0a558a3920b184d1271fd Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 21:36:02 +0100 Subject: [PATCH 2/5] fix(balance): consume balanceUnavailable, stop polling /rain/cards for non-card users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to the last-known-spendable cache, pairing with the API's new `balanceUnavailable` flag. - treat a response that arrived but couldn't read Rain (flag set, no balance to fall back on) as not-ready, so the displayed total holds the last-known value instead of summing an absent spendingPower as 0. A null balance WITHOUT the flag stays a real zero — a user with no card must still see their plain smart-account total. - a served-but-stale balance is used as-is: it is fresher than the FE's own cache. - stop the 30s /rain/cards poll for users with no card application. It ran for every logged-in user and made this the most-called endpoint, and with it the top source of client-side 10s timeouts, for a payload that reads `hasApplication: false` every time. Card users keep the 30s cadence, and the user_rail_status_changed WebSocket invalidation resumes polling the moment someone applies. - guard on `!!rainOverview` rather than `!== undefined`: a null JSON body would pass the stricter check and then throw on property access. --- src/hooks/useRainCardOverview.ts | 9 +++- .../__tests__/useWalletSpendable.test.tsx | 45 ++++++++++++++++++- src/hooks/wallet/useWallet.ts | 10 ++++- src/services/rain.ts | 10 +++++ 4 files changed, 71 insertions(+), 3 deletions(-) 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 index 4c56128a40..5998dbdb06 100644 --- a/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx +++ b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx @@ -27,7 +27,12 @@ const WALLET_ADDRESS = '0x1111111111111111111111111111111111111111' const USER_ID = 'user-under-test' let mockSmartBalance: bigint | undefined -let mockRainOverview: { balance: { spendingPower: number; inTransitToCollateralCents?: number } | null } | undefined +let mockRainOverview: + | { + balance: { spendingPower: number; inTransitToCollateralCents?: number } | null + balanceUnavailable?: boolean + } + | undefined let mockAnyFetching: boolean jest.mock('../useBalance', () => ({ @@ -130,6 +135,44 @@ describe('useWallet spendable balance', () => { 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 () => { diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 818411bc4c..11f82c3d6b 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -250,7 +250,15 @@ export const useWallet = () => { // 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". - const isRainReady = demoMode || rainOverview !== undefined + // + // 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. + // `!!` not `!== undefined`: the query type says object-or-undefined, but a + // null JSON body deserializes to null, which would pass the stricter check + // and then throw on property access. + const isRainReady = + demoMode || (!!rainOverview && !(rainOverview.balanceUnavailable && rainOverview.balance == null)) // The two inputs (smart-account + rain overview) refresh independently. // When both change at once (e.g. auto-balancer deposit: smart goes down, 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[] } From 2853a2d2c5a2c3dc3ba863a30cef75a2787fa645 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 21:44:43 +0100 Subject: [PATCH 3/5] fix(balance): share the rain-known predicate, stop rendering unknown limits as $0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more instances of the same unknown-coerced-to-zero shape. - extract isRainBalanceKnown into balance.utils so the display path (useWallet) and the spend path (useSpendBundle) answer 'can this Rain figure be trusted' identically. They were never allowed to disagree. - spendPreflight: when the overview is missing, rainSpendingPower arrives as 0n and a Rain outage lands in the insufficient branch. Blocking is still the right call — we can't size a collateral withdrawal we can't see, and the user already gets BALANCE_SETTLING_MESSAGE ('try again in a few seconds'), not a false 'not enough funds'. But reporting it as error_kind 'insufficient' hid the outage inside the card-withdraw failure rate; it now reports 'rain-balance-unavailable'. - limits: 'You can add up to $0' when remainingLimit is merely unknown. Omit the sentence rather than invent a figure. --- src/features/limits/utils.ts | 11 ++++++--- src/hooks/wallet/spendPreflight.ts | 23 +++++++++++++++++-- src/hooks/wallet/useSpendBundle.ts | 3 ++- src/hooks/wallet/useWallet.ts | 7 ++---- src/utils/__tests__/balance.utils.test.ts | 27 +++++++++++++++++++++++ src/utils/balance.utils.ts | 16 ++++++++++++++ 6 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/features/limits/utils.ts b/src/features/limits/utils.ts index 2a79e040f6..f6eeaf4447 100644 --- a/src/features/limits/utils.ts +++ b/src/features/limits/utils.ts @@ -185,11 +185,16 @@ 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) + // `?? 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) // build the limit message based on flow type let limitMessage = '' - if (flowType === 'onramp') { + if (formattedLimit === null) { + limitMessage = '' + } else if (flowType === 'onramp') { limitMessage = `You can add up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit}${validation.daysUntilReset ? '' : ' per transaction'}` } else if (flowType === 'offramp') { limitMessage = `You can withdraw up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit}${validation.daysUntilReset ? '' : ' per transaction'}` @@ -198,7 +203,7 @@ export function getLimitsWarningCardProps({ limitMessage = `You can pay up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit} per transaction` } - items.push({ text: limitMessage }) + if (limitMessage) items.push({ text: limitMessage }) // add days until reset if applicable if (validation.daysUntilReset) { 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 a7a2dbcd7d..89acb5a0dc 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 { smartUsdcBalanceQueryOptions } from './useBalance' import { isDemoMode } from '@/utils/demo' @@ -153,6 +153,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 11f82c3d6b..a2b2a4cdc4 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -19,6 +19,7 @@ import { computeDisplaySpendable, rainCentsToUsdcUnits, isAmountWithinBalance, + isRainBalanceKnown, } from '@/utils/balance.utils' import { useSpendBundle } from './useSpendBundle' import type { SpendStrategy } from './spendPreflight' @@ -254,11 +255,7 @@ export const useWallet = () => { // 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. - // `!!` not `!== undefined`: the query type says object-or-undefined, but a - // null JSON body deserializes to null, which would pass the stricter check - // and then throw on property access. - const isRainReady = - demoMode || (!!rainOverview && !(rainOverview.balanceUnavailable && rainOverview.balance == null)) + 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, diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 1ac08289b0..194089f182 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -2,6 +2,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, isAmountWithinBalance, + isRainBalanceKnown, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -156,4 +157,30 @@ describe('balance utils', () => { expect(computeAvailableSpendable(0n, 0)).toBe(0n) }) }) + // 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 7aa9be3edc..477b10033a 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -97,6 +97,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. From f726aa46d3da8287afbd849d1e05f319b6976e67 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 23 Jul 2026 14:57:12 +0100 Subject: [PATCH 4/5] fix(balance): scope in-memory spendable holds to the active user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stableSpendable and persistedSpendableRef survived an account switch, so user A's settled total could paint for user B (and an equal total suppressed B's cache write). Tag both holds with their owner and ignore mismatches. Also stop refreshing the last-known-good cache from a served-stale (balanceUnavailable) Rain value — display may use it, the cache is reserved for live reads. Tests pin all three behaviors. --- .../__tests__/useWalletSpendable.test.tsx | 36 +++++++++++++++++- src/hooks/wallet/useWallet.ts | 37 ++++++++++++++----- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx index 5998dbdb06..6bd244dd24 100644 --- a/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx +++ b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx @@ -52,10 +52,11 @@ jest.mock('../../useZeroDev', () => ({ useZeroDev: () => ({ address: WALLET_ADDRESS, isKernelClientReady: true, handleSendUserOpEncoded: jest.fn() }), })) +let mockUserId: string = USER_ID jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: { - user: { userId: USER_ID }, + user: { userId: mockUserId }, accounts: [{ type: 'peanut-wallet', identifier: WALLET_ADDRESS }], }, }), @@ -81,6 +82,7 @@ const usd = (amount: number) => BigInt(Math.round(amount * 1e6)) beforeEach(() => { localStorage.clear() + mockUserId = USER_ID mockSmartBalance = undefined mockRainOverview = undefined mockAnyFetching = false @@ -159,6 +161,38 @@ describe('useWallet spendable balance', () => { await waitFor(() => expect(result.current.spendableBalance).toBe(usd(35))) expect(result.current.isSpendableBalanceStale).toBe(false) + // ...but a served-stale value must never refresh the last-known-good + // cache — that is reserved for authoritative live reads. + expect(readLastKnownSpendable(USER_ID)).toBe(usd(80)) + }) + + it('does not paint the previous user’s settled total after an account switch', async () => { + mockSmartBalance = usd(100) + mockRainOverview = { balance: { spendingPower: 0 } } + + const { result, rerender } = renderHook(() => useWallet(), { wrapper }) + await waitFor(() => expect(result.current.spendableBalance).toBe(usd(100))) + + // Switch account: Rain hasn't answered for user B and B has no cache — + // the UI must show a loader, not user A's number. + mockUserId = 'user-b' + mockRainOverview = undefined + rerender() + + await waitFor(() => expect(result.current.spendableBalance).toBeUndefined()) + }) + + it('writes the new user’s cache after a switch even when the totals are equal', async () => { + mockSmartBalance = usd(100) + mockRainOverview = { balance: { spendingPower: 0 } } + + const { rerender } = renderHook(() => useWallet(), { wrapper }) + await waitFor(() => expect(readLastKnownSpendable(USER_ID)).toBe(usd(100))) + + mockUserId = 'user-b' + rerender() + + await waitFor(() => expect(readLastKnownSpendable('user-b')).toBe(usd(100))) }) // A user with no card legitimately has no Rain balance — that must stay a diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index a2b2a4cdc4..09563ec4d5 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -28,6 +28,8 @@ import { isDemoMode } from '@/utils/demo' import { useDemoBalanceUnits } from '@/utils/demo-balance' import { readLastKnownSpendable, writeLastKnownSpendable } from './lastKnownSpendable' +type OwnedSpendable = { owner: string | undefined; value: bigint } + type SendTransactionsOptions = { chainId?: string /** @@ -265,35 +267,50 @@ export const useWallet = () => { const isSmartFetchingActive = useIsFetching({ queryKey: ['balance'] }) > 0 const isRainFetchingActive = useIsFetching({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) > 0 const anyFetching = isSmartFetchingActive || isRainFetchingActive - const [stableSpendable, setStableSpendable] = useState(undefined) + // Every in-memory hold is tagged with the user it belongs to: on an account + // switch the previous user's number must neither paint for the next user nor + // (via an equal total) suppress their cache write. + const [stableSpendable, setStableSpendable] = useState(undefined) const userId = user?.user?.userId - const [lastKnownSpendable, setLastKnownSpendable] = useState(undefined) + const stableForUser = stableSpendable?.owner === userId ? stableSpendable?.value : undefined + const [lastKnownSpendable, setLastKnownSpendable] = useState(undefined) useEffect(() => { - setLastKnownSpendable(readLastKnownSpendable(userId)) + const value = readLastKnownSpendable(userId) + setLastKnownSpendable(value === undefined ? undefined : { owner: userId, value }) }, [userId]) + const lastKnownForUser = lastKnownSpendable?.owner === userId ? lastKnownSpendable?.value : undefined // 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) + const persistedSpendableRef = useRef(undefined) + const rainBalanceUnavailable = !!rainOverview?.balanceUnavailable useEffect(() => { if (anyFetching || !isRainReady || rawSpendableBalance === undefined || demoMode) return - setStableSpendable((prev) => (prev === rawSpendableBalance ? prev : rawSpendableBalance)) - if (persistedSpendableRef.current === rawSpendableBalance) return - persistedSpendableRef.current = rawSpendableBalance + setStableSpendable((prev) => + prev !== undefined && prev.owner === userId && prev.value === rawSpendableBalance + ? prev + : { owner: userId, value: rawSpendableBalance } + ) + // A served-stale Rain value (balanceUnavailable) may paint, but must not + // seed the last-known-good cache — that is reserved for live reads. + if (rainBalanceUnavailable) return + const persisted = persistedSpendableRef.current + if (persisted !== undefined && persisted.owner === userId && persisted.value === rawSpendableBalance) return + persistedSpendableRef.current = { owner: userId, value: rawSpendableBalance } writeLastKnownSpendable(userId, rawSpendableBalance) - }, [anyFetching, isRainReady, rawSpendableBalance, userId, demoMode]) + }, [anyFetching, isRainReady, rawSpendableBalance, userId, demoMode, rainBalanceUnavailable]) // 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 spendableBalance = stableForUser ?? (isRainReady ? rawSpendableBalance : lastKnownForUser) 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 + const isSpendableBalanceStale = !demoMode && stableForUser === undefined && !isRainReady && !!lastKnownForUser // formatted balance for display (e.g. "1,234.56"). Smart-account only — // use `formattedSpendableBalance` below for user-facing widgets that From fcf58542597f51d36332e0490eabdff3095da65c Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 24 Jul 2026 10:07:41 +0100 Subject: [PATCH 5/5] fix(balance): time-bound the last-known spendable cache The persisted last-known total wrote an `at` timestamp that readers never consulted, so a stale value could be painted indefinitely if /rain/cards reads kept failing for a user. Expire it after LAST_KNOWN_MAX_AGE_MS (7d); the value stays display-only and stale-flagged, and the affordability gate is unaffected (it runs on live data). Entries written before the timestamp existed are treated as fresh. --- .../wallet/__tests__/useWalletSpendable.test.tsx | 15 ++++++++++++++- src/hooks/wallet/lastKnownSpendable.ts | 10 ++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx index 6bd244dd24..af6b9090e0 100644 --- a/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx +++ b/src/hooks/wallet/__tests__/useWalletSpendable.test.tsx @@ -74,7 +74,7 @@ import { useWallet } from '../useWallet' // eslint-disable-next-line import/first import store from '@/redux/store' // eslint-disable-next-line import/first -import { readLastKnownSpendable, writeLastKnownSpendable } from '../lastKnownSpendable' +import { LAST_KNOWN_MAX_AGE_MS, readLastKnownSpendable, writeLastKnownSpendable } from '../lastKnownSpendable' const wrapper = ({ children }: { children: ReactNode }) => {children} @@ -234,4 +234,17 @@ describe('useWallet spendable balance', () => { await waitFor(() => expect(result.current.spendableBalance).toBe(usd(500))) expect(result.current.hasSufficientSpendableBalance('100')).toBe(false) }) + + it('drops a last-known total once it is older than the max age', () => { + const base = Date.now() + writeLastKnownSpendable(USER_ID, usd(75)) + expect(readLastKnownSpendable(USER_ID)).toBe(usd(75)) + + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(base + LAST_KNOWN_MAX_AGE_MS + 1000) + try { + expect(readLastKnownSpendable(USER_ID)).toBeUndefined() + } finally { + nowSpy.mockRestore() + } + }) }) diff --git a/src/hooks/wallet/lastKnownSpendable.ts b/src/hooks/wallet/lastKnownSpendable.ts index 192401836e..30d617d2ca 100644 --- a/src/hooks/wallet/lastKnownSpendable.ts +++ b/src/hooks/wallet/lastKnownSpendable.ts @@ -20,9 +20,19 @@ import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils * green-light a spend that leaves an orphan charge. */ +/** + * How long a persisted total may still be painted on cold start. The value is + * display-only and always shown stale-flagged, so this bound is not a + * correctness guard — it just stops an ancient number from being shown + * indefinitely when /rain/cards reads keep failing for a user. + */ +export const LAST_KNOWN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + export const readLastKnownSpendable = (userId: string | undefined): bigint | undefined => { const stored = getUserPreferences(userId)?.lastKnownSpendable if (!stored?.units) return undefined + // Expire on `at` (older entries predate the timestamp — treat as fresh). + if (typeof stored.at === 'number' && Date.now() - stored.at > LAST_KNOWN_MAX_AGE_MS) return undefined try { const units = BigInt(stored.units) return units < 0n ? undefined : units