Skip to content

fix(balance): keep last-known spendable total instead of rendering $0 - #2481

Closed
innolope-dev wants to merge 27 commits into
mainfrom
fix/spendable-balance-last-known
Closed

fix(balance): keep last-known spendable total instead of rendering $0#2481
innolope-dev wants to merge 27 commits into
mainfrom
fix/spendable-balance-last-known

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

The bug

The home screen's headline number is smartAccountBalance + rainSpendingPower + rainInTransit. 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, 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.

Changes

  • lastKnownSpendable.ts (new) — per-user persisted last-known-good total, built on the existing {userId}:user-preferences store rather than a new mechanism. A cold start paints the previous number immediately and corrects it in the background.
  • useWallet — introduces isRainReady; display resolves as stableSpendable ?? (isRainReady ? liveSum : cachedValue). Also consumes the API's new balanceUnavailable: a response that arrived but couldn't read Rain is not ready, while 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's fresher than our own cache.
  • 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 reads hasApplication: false every time. Card users keep the 30s cadence; the user_rail_status_changed WebSocket invalidation resumes polling the moment someone applies.
  • home/page.tsx — a cached number renders at 50% opacity, so it reads as refreshing rather than 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 exactly that. The cache is only ever written from a fully-settled read where Rain actually answered, so a bad value can't become sticky.

Two bugs caught in self-review

Both would have shipped:

  1. Demo mode makes no /rain/cards call, so isRainReady was permanently false — the demo home screen would have sat on a loader forever. Verified the regression test genuinely catches it (reverting just that clause fails that test and nothing else).
  2. writeLastKnownSpendable was inside a setState updater, which StrictMode double-invokes. Moved to a ref-guarded write, which also stops a redundant disk write on every 30s poll.

Testing

New useWalletSpendable.test.tsx — 9 tests covering all the balanceUnavailable states, the cache seed/write rules, demo mode, and the gate-stays-live guarantee.

Full suite green: 148 suites, 2018 passed, 3 skipped. Typecheck and prettier clean. The 2 eslint errors on home/page.tsx (underMaintenanceConfig, isPostSignupSession) are pre-existing — confirmed byte-identical on a stashed tree.

kushagrasarathe and others added 26 commits July 17, 2026 14:47
Name mismatch (Bridge BE01) is the top cause of returned SEPA/ACH
deposits, but the beneficiary/account-holder name was shown as a plain
row while amount and reference got prominent warnings. Add a warning so
users send from an account in their own name and enter the recipient
name exactly as shown.
Fold the name-match guidance into the existing 'Double check before
sending' checklist card instead of a separate warning card, so there is
one source of pre-send do-nots (no drift, no signal dilution from a
fourth alarm card). Omit it for MX/SPEI, where paying from a third-party
account is a supported flow and the own-name rule does not apply.
BE01 validates the creditor (recipient) name too, not just the sender
account. Add the recipient-name-exact line alongside the sender rule in
the same non-MX branch.
fix: warn about name mismatch on bank deposit details
Hotfix debt from the 2026-07-16/17 card-issuance outage. main carries the
callback-ref fix that dev lacks; without this back-merge the next dev → main
release would revert prod to the broken wrapper.
…lent again

The Jul-16/17 card outage ran 19h and was found by a customer in Dhaka, not by
us. The reason is structural, not bad luck: a WebSDK that never launches throws
nothing, requests nothing and logs nothing. Sentry cannot see code that does not
run, so the only trace was throughput quietly going to zero. This closes that
class of failure rather than the one instance of it.

Watchdog: if the modal is open and the SDK has not launched within 20s, show the
error UI instead of the infinite spinner. Deliberately keyed on `visible` ALONE —
every silent path (null container, token that never arrives, script that never
loads) ends with the init effect simply not running, so anything keyed on those
deps would also never run. Watching the one fact the user experiences ("I opened
it and nothing happened") catches the whole class, including causes we have not
thought of yet.

Telemetry: kyc_sdk_launched / kyc_sdk_launch_timeout / kyc_sdk_init_failed. The
timeout event carries hadAccessToken / sdkScriptLoaded / hadContainer, which are
the three facts that distinguish the silent stalls from each other. Makes
"opened but never launched" alertable instead of archaeology.

Also corrects the mechanism comments shipped in #2441: the one-commit delay comes
from headlessui's <Transition> promoting tree state inside an effect, NOT the
Portal (isolation-tested: dialog-only mounts the container synchronously;
transition-only does not). The fix was right, the explanation was not, and a
wrong comment misleads the next reader.

Tests: the watchdog fires and surfaces the error UI when the SDK never launches,
and does not fire once it has.
feat(kyc): watchdog + telemetry so a stuck Sumsub SDK can never be silent again
The in-app card is composited layer-by-layer in code, so it drifted from
the finalised Rain art (what Apple Wallet shows): it rendered a mascot with
no PEANUT wordmark and an inverted-white Visa with no tier. Swap the top row
to the PEANUT lockup (mascot + black wordmark) and a black VISA with a
Platinum tier line. Closes TASK-20469.
The composited hand used -rotate-15 + h-full + a negative top inset, which
shoved it up so the fingertips crowded the Visa and the gesture tilted off
from the finalised Rain art. Anchor it upright at the bottom (h-90%) so the
fingertips clear the top logos and the arm sweeps to the bottom-right corner.
Two corrections after seeing the real card:
- The PEANUT wordmark was rendering white (PEANUT_LOGO is the dark-bg
  variant). Use PEANUTMAN + PEANUT_LOGO_BLACK for the black lockup the
  finalised art shows.
- The bottom-anchored hand covered the eye toggle and the revealed
  PAN/CVV. Slide it diagonally off the bottom-right corner while details
  are shown or being fetched (loading skeletons included), and move the
  masked/error eye inline with the number so it stays in a hand-free zone.
  The hand slides back when the card is re-masked.
Lowering the per-transaction limit left the card holding more than its
new target: the auto-balancer only ever tops up (rebalance-decision
no-ops when collateral >= limit), so the excess sat in the collateral
contract indefinitely. After the limit PATCH succeeds, sign a FORCED
collateral-only withdrawal of the excess back to the user's smart
wallet - the PATCH-first ordering keeps the auto-balancer from racing
the withdrawal by topping the collateral back up.

UX: the user only sees the passkey prompt. The unified displayed
balance is identical before/after (both buckets are folded into one),
and the intent kind AUTO_REBALANCE maps to INTERNAL_TRANSFER, which
history hides. Failures are non-fatal by design: the limit change
already stuck, and re-saving the limit retries the return.

forceStrategy on useSignSpendBundle exists because live-balance routing
would pick smart-only (a self-transfer no-op) whenever the smart wallet
covers the amount - the exact trap the lock-card flow comments around.
CodeRabbit review: the forced-path insufficient branch skipped the CARD_WITHDRAW_FAILED capture resolveSpendStrategy emits, leaving a gap in the withdraw funnel for the excess-return flow; and the forced branch had no direct tests.
…mit-decrease

feat(card): return excess collateral to wallet on limit decrease
Backend now marks every Bridge rail blocked for UK residents with reason
code 'uk_resident_blocked'. Surface it honestly instead of the generic
'we couldn't unlock this / contact support' (ID-failure) framing:

- InitiateKycModal gains a 'region-unavailable' variant — 'Not available
  in the UK', regulatory copy, single 'Got it' dismiss (no verify, no
  contact-support)
- add-money + withdraw bank pages pick that variant when the gate's reason
  is uk_resident_blocked

Card block rides on the existing eligibility machinery (BE denylist).
- title 'Not available for UK residents' (conveys residence, not the
  destination country — a UK resident is blocked even withdrawing to SEPA)
- drop the card mention from the bank-flow copy
- CTA 'Withdraw funds' -> /withdraw so users can exit via crypto
- wire the region-unavailable variant into AddWithdrawCountriesList (the
  country-selection modal), not just the add-money/withdraw bank pages
fix(card): match in-app card art to finalised Visa Platinum lockup
…ailable check (Hugo review)

The 'reason.code === uk_resident_blocked' -> region-unavailable ternary was
copy-pasted across the add-money/withdraw bank pages and the country-list.
Centralize in one helper alongside getKycModalVariant.
The bare-badge /invite screen labeled its primary CTA "Claim your badge"
while actually routing to signup, and framed login as an alternative to
claiming rather than the other path to it. Only logged-out visitors ever
see this variant (logged-in users are auto-claimed), so the buttons now
say what they do: Sign up / Log in, with the description explaining that
either one claims the badge.
Keeps all per-variant strings (title, description, ctaLabel, loginLabel)
in one place instead of burying one ternary in the JSX.
feat(kyc): honest UK-resident block on Bridge bank flows (TASK-20729)
…copy

fix: honest signup/login CTAs on vanity badge claim screen
SimpleFi (early Argentina QR provider) is deprecated and its creation
path was removed in peanut-api-ts#1218. Drop the FE-side remnants: the
payment-app logo asset + export, the DEPRECATED_SIMPLEFI avatar branch
in getAvatarUrl, and the SIMPLEFI keys in test mocks.

Historical rows are safe: 247 transaction intents with provider
DEPRECATED_SIMPLEFI still arrive on the wire, so the Provider union
keeps the value (mirrors DEPRECATED_SQUID), their kind=QR_PAY still
dispatches to the qrPay strategy (locked by the vendored
qr_pay-deprecated_simplefi render-snapshot case), and getAvatarUrl now
falls back to undefined -> generic default avatar (new test).
Grafana has been decommissioned at Peanut; support agents now use the
prod DB + PostHog per the updated ops playbook (mono 2d873bc). Drop the
dead links the FE still generated: the Crisp session-data
grafana_dashboard entry (useCrispUserData / crisp.ts / useCrispProxyUrl
/ crisp-proxy page), the GRAFANA_DASHBOARD_BASE_URL constant, and the
dev invites-graph click-to-Grafana handler (click now just selects the
node). The Crisp session-data push itself is unchanged beyond the
deleted entry.
…afana

chore: remove decommissioned SimpleFi + Grafana remnants
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.
@innolope-dev innolope-dev self-assigned this Jul 22, 2026
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 22, 2026 8:50pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0a3bd4fc-8206-492d-b9e9-f9be5ec53426

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/spendable-balance-last-known

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6233.75 → 6240.88 (+7.13)
Findings: 0 net (+44 new, -44 resolved)

🆕 New findings (44)

  • critical complexity — src/utils/general.utils.ts — CC 199, MI 57.5, SLOC 755
  • critical complexity — src/app/(mobile-ui)/home/page.tsx — CC 67, MI 62.92, SLOC 202
  • critical complexity — src/hooks/wallet/useWallet.ts — CC 54, MI 56.77, SLOC 207
  • critical complexity — src/features/limits/utils.ts — CC 50, MI 57.21, SLOC 122
  • high hotspot — src/app/(mobile-ui)/home/page.tsx — 33 commits, +178/-146 lines since 6 months ago
  • high complexity — src/hooks/wallet/useSpendBundle.ts — CC 19, MI 47.32, SLOC 220
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:103 — useSpendBundle: MDD 58.5 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/home/page.tsx:55 — Home: MDD 48.6 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:118 — : MDD 45.7 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useWallet.ts:55 — useWallet: MDD 42.0 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/wallet/useWallet.ts:55 — useWallet: DLT 40 (calls 40 distinct functions — high context load)
  • medium high-dlt — src/hooks/wallet/useSpendBundle.ts:103 — useSpendBundle: DLT 33 (calls 33 distinct functions — high context load)
  • medium method-complexity — src/services/rain.ts:337 — rainRequest CC 28 SLOC 61
  • medium complexity — src/utils/balance.utils.ts — CC 27, MI 62.89, SLOC 76
  • medium hotspot — src/hooks/wallet/useSpendBundle.ts — 25 commits, +951/-597 lines since 6 months ago
  • medium hotspot — src/utils/general.utils.ts — 25 commits, +138/-152 lines since 6 months ago
  • medium high-mdd — src/hooks/useRainCardOverview.ts:22 — useRainCardOverview: MDD 23.7 (uses across many lines from declarations)
  • medium hotspot — src/hooks/wallet/useWallet.ts — 24 commits, +408/-185 lines since 6 months ago
  • medium hotspot — src/services/rain.ts — 24 commits, +837/-94 lines since 6 months ago
  • medium high-mdd — src/app/(mobile-ui)/home/page.tsx:312 — WalletBalance: MDD 23.0 (uses across many lines from declarations)

…and 24 more.

✅ Resolved (44)

  • src/utils/general.utils.ts — CC 199, MI 57.49, SLOC 755
  • src/app/(mobile-ui)/home/page.tsx — CC 66, MI 63.05, SLOC 200
  • src/features/limits/utils.ts — CC 47, MI 57.73, SLOC 118
  • src/hooks/wallet/useWallet.ts — CC 46, MI 57.28, SLOC 190
  • src/app/(mobile-ui)/home/page.tsx — 32 commits, +169/-143 lines since 6 months ago
  • src/hooks/wallet/useSpendBundle.ts — CC 19, MI 47.41, SLOC 218
  • src/hooks/wallet/useSpendBundle.ts:103 — useSpendBundle: MDD 58.3 (uses across many lines from declarations)
  • src/app/(mobile-ui)/home/page.tsx:55 — Home: MDD 48.5 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts:118 — : MDD 45.5 (uses across many lines from declarations)
  • src/hooks/wallet/useWallet.ts:53 — useWallet: MDD 38.0 (uses across many lines from declarations)
  • src/hooks/wallet/useWallet.ts:53 — useWallet: DLT 36 (calls 36 distinct functions — high context load)
  • src/hooks/wallet/useSpendBundle.ts:103 — useSpendBundle: DLT 32 (calls 32 distinct functions — high context load)
  • src/services/rain.ts:327 — rainRequest CC 28 SLOC 61
  • src/components/Card/share-asset/ShareAssetD3.tsx — 24 commits, +1525/-1137 lines since 6 months ago
  • src/components/Kyc/KycStatusDrawer.tsx — 24 commits, +274/-308 lines since 6 months ago
  • src/hooks/wallet/useSpendBundle.ts — 24 commits, +949/-596 lines since 6 months ago
  • src/utils/balance.utils.ts — CC 24, MI 62.32, SLOC 73
  • src/utils/general.utils.ts — 24 commits, +134/-152 lines since 6 months ago
  • src/services/rain.ts:327 — rainRequest: MDD 23.2 (uses across many lines from declarations)
  • hooks/wallet/useSignSpendBundle.ts:272 — 22 duplicate lines / 98 tokens with hooks/wallet/useSpendBundle.ts:275

…and 24 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/wallet/lastKnownSpendable.ts 0.0 5.1 +5.1
src/hooks/wallet/useWallet.ts 11.5 12.3 +0.8

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2026 ran, 0 failed, 0 skipped, 27.1s

📊 Coverage (unit)

metric %
statements 60.3%
branches 44.0%
functions 49.5%
lines 60.6%
⏱ 10 slowest test cases
time test
2.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.0s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.2s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.2s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a stored balance that has no timestamp (legacy install)
0.1s src/utils/__tests__/demo-balance.test.ts › resetDemoBalance refills and restarts the TTL window
0.1s src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx › shows the mailto fallback + retry when the proxy reports CRISP_FAILED
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

…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.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Follow-up: the other instances of this bug class

Pushed 869c04230, covering the remaining places where an unknown value is coerced to zero and then treated as fact.

Correction to the spend-path severity

I earlier described this as users being "told 'not enough balance' and blocked from sending money they have." That was wrong, and one grep would have caught it.

InsufficientSpendableError already maps to BALANCE_SETTLING_MESSAGE"Your balance isn't fully available yet. Please try again in a few seconds." — via friendly-error.utils.tsx:59, and every handler I checked (qr-pay, withdraw/manteca, LockCardModal, CancelCardModal, useSendMoney) uses that copy. Nobody is accused of having insufficient funds.

And blocking is the correct call: without spendingPower we can't size a collateral withdrawal, so the spend genuinely can't be routed. useSpendBundle already invalidates the Rain overview so a retry self-heals.

So no behavioural change was warranted. What was wrong is narrower: the failure reported error_kind: 'insufficient', hiding a Rain outage inside the card-withdraw failure rate. It now reports rain-balance-unavailable.

Changes

  • isRainBalanceKnown extracted into balance.utils — the display path and the spend path now answer "can this Rain figure be trusted?" through one predicate instead of two copies that could drift.
  • spendPreflight takes rainBalanceKnown (defaulting to true, so existing callers keep current reporting) and uses it for the analytics tag only.
  • features/limits/utils.tsformatExtendedNumber(remainingLimit ?? 0) rendered "You can add up to $0" when the limit was merely unknown. Now omits the sentence rather than inventing a figure. Note this needed guarding at message construction, not just at the items.push — otherwise the template interpolates the literal "null".

Testing

6 new cases on isRainBalanceKnown pinning the distinction that started all this: a user with no card is a real zero, a missing overview is unknown, and a stale-but-served balance is still usable.

Full suite: 148 suites, 2023 passed, 3 skipped. Typecheck and prettier clean.

@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Superseded by #2482, which carries the identical 3 commits rebased onto main (this branch was cut from dev, and force-push is blocked by repo rules so the history had to move to a new branch).

Retargeting this PR to main in place would have dragged in 24 unrelated dev-only commits — SimpleFi removal, UK-resident block, card art, Sumsub watchdog, SEPA name-match — since main and dev have diverged (main 53 ahead, dev 26 ahead).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants