fix(balance): keep last-known spendable total instead of rendering $0 - #2481
fix(balance): keep last-known spendable total instead of rendering $0#2481innolope-dev wants to merge 27 commits into
Conversation
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
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Code-analysis diffPainscore total: 6233.75 → 6240.88 (+7.13) 🆕 New findings (44)
…and 24 more. ✅ Resolved (44)
…and 24 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
…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.
Follow-up: the other instances of this bug classPushed Correction to the spend-path severityI 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.
And blocking is the correct call: without So no behavioural change was warranted. What was wrong is narrower: the failure reported Changes
Testing6 new cases on Full suite: 148 suites, 2023 passed, 3 skipped. Typecheck and prettier clean. |
|
Superseded by #2482, which carries the identical 3 commits rebased onto Retargeting this PR to |
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.
Changes
lastKnownSpendable.ts(new) — per-user persisted last-known-good total, built on the existing{userId}:user-preferencesstore rather than a new mechanism. A cold start paints the previous number immediately and corrects it in the background.useWallet— introducesisRainReady; display resolves asstableSpendable ?? (isRainReady ? liveSum : cachedValue). Also consumes the API's newbalanceUnavailable: 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 readshasApplication: falseevery time. Card users keep the 30s cadence; theuser_rail_status_changedWebSocket 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:
/rain/cardscall, soisRainReadywas 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).writeLastKnownSpendablewas inside asetStateupdater, 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 thebalanceUnavailablestates, 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.