Skip to content
Closed
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 @@ -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()
Expand Down Expand Up @@ -202,6 +203,7 @@ export default function Home() {
isBalanceHidden={isBalanceHidden}
onToggleBalanceVisibility={handleToggleBalanceVisibility}
isFetchingBalance={isFetchingSpendableBalance}
isBalanceStale={isSpendableBalanceStale}
/>

<ActionButtonGroup>
Expand Down Expand Up @@ -327,11 +329,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 @@ -352,10 +356,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
21 changes: 17 additions & 4 deletions src/features/limits/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,23 +193,36 @@ 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}`
} else {
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) {
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
201 changes: 201 additions & 0 deletions src/hooks/wallet/__tests__/useWalletSpendable.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <Provider store={store}>{children}</Provider>

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)
})
})
37 changes: 37 additions & 0 deletions src/hooks/wallet/lastKnownSpendable.ts
Original file line number Diff line number Diff line change
@@ -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() } })
}
23 changes: 21 additions & 2 deletions src/hooks/wallet/spendPreflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand All @@ -104,7 +115,15 @@ export interface ResolveSpendStrategyArgs {
export async function resolveSpendStrategy(
args: ResolveSpendStrategyArgs
): Promise<{ strategy: Exclude<SpendStrategy, 'insufficient'>; 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({
Expand All @@ -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] })
Expand Down
Loading
Loading