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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/app/(mobile-ui)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -193,6 +194,7 @@ export default function Home() {
isBalanceHidden={isBalanceHidden}
onToggleBalanceVisibility={handleToggleBalanceVisibility}
isFetchingBalance={isFetchingSpendableBalance}
isBalanceStale={isSpendableBalanceStale}
/>

<ActionButtonGroup>
Expand Down Expand Up @@ -312,11 +314,13 @@ function WalletBalance({
isBalanceHidden,
onToggleBalanceVisibility,
isFetchingBalance,
isBalanceStale,
}: {
balance: bigint | undefined
isBalanceHidden: boolean
onToggleBalanceVisibility: (e: React.MouseEvent<HTMLButtonElement>) => void
isFetchingBalance?: boolean
isBalanceStale?: boolean
}) {
const balanceDisplay = useMemo(() => {
if (isBalanceHidden) {
Expand All @@ -337,10 +341,12 @@ function WalletBalance({
<Loading />
</span>
) : (
<>
// stale = painted from the persisted last-known-good while the live
// sum is still pending; dim it rather than asserting it as current
<span className={twMerge('flex items-end', isBalanceStale && 'opacity-50')}>
<span className="text-[32px] md:text-[40px]">$ </span>
{balanceDisplay}
</>
</span>
)}
</div>

Expand Down
11 changes: 8 additions & 3 deletions src/features/limits/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`
Expand All @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/useRainCardOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
250 changes: 250 additions & 0 deletions src/hooks/wallet/__tests__/useWalletSpendable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* 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() }),
}))

let mockUserId: string = USER_ID
jest.mock('@/context/authContext', () => ({
useAuth: () => ({
user: {
user: { userId: mockUserId },
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 { LAST_KNOWN_MAX_AGE_MS, readLastKnownSpendable, writeLastKnownSpendable } from '../lastKnownSpendable'

const wrapper = ({ children }: { children: ReactNode }) => <Provider store={store}>{children}</Provider>

const usd = (amount: number) => BigInt(Math.round(amount * 1e6))

beforeEach(() => {
localStorage.clear()
mockUserId = USER_ID
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)
// ...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
// 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)
})

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()
}
})
})
47 changes: 47 additions & 0 deletions src/hooks/wallet/lastKnownSpendable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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.
*/

/**
* 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
} 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() } })
}
Loading
Loading