fix(balance): keep last-known spendable total instead of rendering $0 - #2482
fix(balance): keep last-known spendable total instead of rendering $0#2482innolope-dev wants to merge 6 commits into
Conversation
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.
…r non-card users 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.
…limits as $0
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthrough
ChangesWallet spendable balance
Rain overview polling
Limit warning rendering
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant WalletUI
participant useWallet
participant RainOverview
participant Preferences
WalletUI->>useWallet: request spendable balance
useWallet->>RainOverview: read current overview
useWallet->>Preferences: read last settled total
Preferences-->>useWallet: return cached total
useWallet-->>WalletUI: return balance and stale state
useWallet->>Preferences: write settled live total
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: dependency version conflict. Check your lock file or package.json. Comment |
Code-analysis diffPainscore total: 6270.06 → 6278.41 (+8.35) 🆕 New findings (44)
…and 24 more. ✅ Resolved (43)
…and 23 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/hooks/wallet/useWallet.ts`:
- Around line 280-285: Update the spendable-balance persistence flow around
isRainReady and writeLastKnownSpendable so balanceUnavailable responses may
remain usable for display but never update persistedSpendableRef or
lastKnownSpendable; require an authoritative Rain response before persisting.
Extend the stale-served-balance test to assert that the existing cache value
remains unchanged.
- Around line 270-285: Scope stableSpendable and persistedSpendableRef to the
active user by storing each value together with its owning userId. Update the
effects and the render logic around stableSpendable (including the priority path
near line 292) to ignore or reset values whose owner differs from the current
user, ensuring account switches cannot display or suppress writes using the
previous user’s balance. Add a regression test covering an account switch
between users.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2a266338-3bd7-494d-a817-4f6b18d2783e
📒 Files selected for processing (12)
src/app/(mobile-ui)/home/page.tsxsrc/features/limits/utils.tssrc/hooks/useRainCardOverview.tssrc/hooks/wallet/__tests__/useWalletSpendable.test.tsxsrc/hooks/wallet/lastKnownSpendable.tssrc/hooks/wallet/spendPreflight.tssrc/hooks/wallet/useSpendBundle.tssrc/hooks/wallet/useWallet.tssrc/services/rain.tssrc/utils/__tests__/balance.utils.test.tssrc/utils/balance.utils.tssrc/utils/general.utils.ts
… fix
Rolls up for the next TestFlight + Play internal build:
- main → mobile-release sync (push routing, app lock, step-up, CSP,
Manteca per-rail hotfix, Rhino minimums, Sentry noise) — #2483
- en / es-419 / pt-BR localization + 0 ESLint errors — #2447
- $0-balance fix: last-known spendable, balanceUnavailable, no
non-card /rain/cards poll — cherry-picked from #2482
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.
kushagrasarathe
left a comment
There was a problem hiding this comment.
Reviewed — SAFE (money-safe). Approving with one nit to tighten.
Correctly separates a real $0 balance from a failed read: the last-known spendable total is shown (fixing the $0 flash) while a bad read no longer masquerades as zero. Verified no stale number reaches an actual spend — the live on-chain gate in resolveSpendStrategy is intact; this cache is display-only.
Nit (non-blocking): the last-known cache isn't time-bounded — the at timestamp is written but never read, so a stale value can be shown indefinitely if reads keep failing. Either bound the display on at (e.g. show stale-flagged only within N minutes) or drop the unused field. Fine to ship as-is and follow up.
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.
|
Pushed Time-bounding nit (your review): Cross-user scoping (CodeRabbit's Major finding on Spendable suite green (12 passing), typecheck clean on the touched files. |
|
Re-reviewed
No remaining concerns — good to merge. |
|
@innolope-dev merge conflicts |
|
Resolved the merge conflict with |
Rebased onto
mainfrom #2481 (which was branched offdev; force-push is blocked by repo rules, hence a new branch). Supersedes #2481 — identical 3 commits, no dev-only work dragged along.Targets
mainbecause this needs to ship in the next mobile build, andmain/devhave diverged (main is 53 ahead of dev, dev 26 ahead of main).The bug
The home screen's headline number is
smartAccountBalance + rainSpendingPower + rainInTransit. Both Rain terms come from a single/rain/cardscall, andrainCentsToUsdcUnits(undefined)folds them to0n— 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, so the total cratered to a confident, wrong $0 — no error, no retry affordance. Reported as mobile apps showing zero balance while activity loaded correctly (activity is a different endpoint and server-computed).
Pairs with peanutprotocol/peanut-api-ts#1226, but ships independently:
balanceUnavailableis an optional field and absent is treated as "available".Changes
lastKnownSpendable.ts(new) — per-user persisted last-known-good total on the existing{userId}:user-preferencesstore. A cold start paints the previous number immediately and corrects it in the background.useWallet—isRainReadygate; display resolves asstableSpendable ?? (isRainReady ? liveSum : cachedValue).isRainBalanceKnowninbalance.utils— shared by the display path and the spend path so they can't drift on "can this Rain figure be trusted".useRainCardOverview— stop the 30s poll for users with no card application. It ran for every logged-in user, making this the most-called endpoint and the top source of client 10s timeouts, for a payload that readshasApplication: falseevery time.spendPreflight— a Rain outage was reported aserror_kind: 'insufficient', hiding it inside the card-withdraw failure rate. Nowrain-balance-unavailable. Analytics only; behaviour unchanged (blocking is correct — you can't size a collateral withdrawal you can't see — and users already get "try again in a few seconds", not "not enough funds").features/limits/utils.ts— "You can add up to $0" when the remaining limit was merely unknown. Omits the sentence instead.home/page.tsx— a cached number renders at 50% opacity so it reads as refreshing, not asserted.Safety
The cache is display-only. Every affordability gate still runs on the live
availableSpendableBalance, so a stale number can't green-light a spend into an orphan charge — there's a test pinning that. The cache is only written from a fully-settled read where Rain actually answered.Two bugs caught in self-review
/rain/cardscall, soisRainReadywas permanently false — the demo home screen would have sat on a loader forever. Verified the regression test catches it (reverting just that clause fails that test and nothing else).writeLastKnownSpendablewas inside asetStateupdater, which StrictMode double-invokes. Moved to a ref-guarded write.Testing
Full suite on the
mainbase: 155 suites, 2073 passed, 3 skipped. Typecheck and prettier clean. 15 new tests acrossuseWalletSpendable.test.tsxandbalance.utils.test.ts.Summary by CodeRabbit