fix(request): show spendable balance incl. card collateral#2266
Conversation
The /request create-link and direct-request views showed smart-account-only `balance`, excluding Rain card collateral, so the "Balance:" affordance read lower than /home and /send for users whose funds are split into card collateral. Switch to spendableBalance (smart + collateral), matching the Send view and useWallet's documented intent (useWallet.ts:257-263).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe PR centralizes balance-gate messages, changes wallet affordability checks to use displayed spendable balance, and updates request, QR pay, withdraw, and send flows to use the new balance and error-message behavior. ChangesSpendability gating and balance messaging
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code-analysis diffPainscore total: 5813.79 → 5746.2 (-67.59) 🆕 New findings (103)
…and 83 more. ✅ Resolved (100)
…and 80 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
…y balance The displayed spendable balance includes in-transit card-collateral top-ups so it doesn't crater during the ~10-45s smart->collateral handoff. Several legacy flows hand-rolled their affordability gate against that DISPLAY number, so during a top-up they could green-light a spend whose funds aren't routable yet - failing at execution instead of being blocked at input. Route Send-link, qr-pay and bank/manteca withdraw gates through the shared useWallet.hasSufficientSpendableBalance() predicate (smart + LANDED collateral), matching the features/payments flows. Withdraw's amount ceiling now derives from a newly-exposed availableSpendableBalance. Displayed balances are unchanged. Outside the top-up window available == display, so behaviour is identical; the change only tightens the rare in-transit window. Revives the qr-pay insufficient-balance test (skipped on mock drift) and adds a request-view regression for the spendable-balance display fix.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/components/Request/__tests__/request-states.test.tsx (1)
415-437: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a mirrored regression test for the direct-request view.
This new guard only covers
CreateRequestLinkView, but the bug/fix scope includes both request entry views. Please add the sameprintableUsdcargument assertion forDirectRequestInitialViewto lock both paths.Proposed test addition pattern
+describe('GROUP 0b: Direct request balance affordance', () => { + test('formats spendableBalance (smart + collateral), not smart-only balance', () => { + mockUseWallet.mockReturnValue({ + address: '0x1234567890abcdef1234567890abcdef12345678', + isConnected: true, + balance: BigInt(100_000_000), + spendableBalance: BigInt(250_000_000), + }) + + renderDirectRequest() + + expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) + expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + }) +})🤖 Prompt for 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. In `@src/components/Request/__tests__/request-states.test.tsx` around lines 415 - 437, Add a mirrored regression test for DirectRequestInitialView within the same test group GROUP 0: Balance affordance. The new test should follow the same pattern as the existing test for CreateRequestLinkView: set up the mockUseWallet with the same balance values (balance: BigInt(100_000_000) and spendableBalance: BigInt(250_000_000)), render DirectRequestInitialView instead of renderCreateRequest, and assert that printableUsdc is called with BigInt(250_000_000) but not with BigInt(100_000_000). This ensures both request entry views are guarded against the regression where balance is used instead of spendableBalance.
🤖 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/app/`(mobile-ui)/qr-pay/page.tsx:
- Line 101: The destructuring assignment in the useWallet() hook call on line
101 includes sendMoney which is not used anywhere in the component, causing a
lint failure. Remove sendMoney from the destructuring statement so only the
actually used properties (spendableBalance aliased as balance and
hasSufficientSpendableBalance) are destructured from useWallet().
---
Nitpick comments:
In `@src/components/Request/__tests__/request-states.test.tsx`:
- Around line 415-437: Add a mirrored regression test for
DirectRequestInitialView within the same test group GROUP 0: Balance affordance.
The new test should follow the same pattern as the existing test for
CreateRequestLinkView: set up the mockUseWallet with the same balance values
(balance: BigInt(100_000_000) and spendableBalance: BigInt(250_000_000)), render
DirectRequestInitialView instead of renderCreateRequest, and assert that
printableUsdc is called with BigInt(250_000_000) but not with
BigInt(100_000_000). This ensures both request entry views are guarded against
the regression where balance is used instead of spendableBalance.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f3c87fa8-74f3-459d-a02b-412ac0ac69be
📒 Files selected for processing (9)
src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsxsrc/app/(mobile-ui)/qr-pay/page.tsxsrc/app/(mobile-ui)/withdraw/[country]/bank/page.tsxsrc/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsxsrc/app/(mobile-ui)/withdraw/manteca/page.tsxsrc/app/(mobile-ui)/withdraw/page.tsxsrc/components/Request/__tests__/request-states.test.tsxsrc/components/Send/link/views/Initial.link.send.view.tsxsrc/hooks/wallet/useWallet.ts
Heads-up: 2nd commit expands the scope of this PRPushed Why the scope grew. The original one-line fix (show What the commit does. Routes those gates through the shared predicate and exposes Tests. Revived the qr-pay insufficient-balance test (was One by-design nuance to be aware of: during a top-up the displayed balance can read higher than the gate allows for ~30s (self-heals once collateral lands) — consistent with |
- qr-pay: drop the now-unused `sendMoney` destructuring (was pre-existing dead binding on the line I touched; flagged as a no-unused-vars lint failure). - request tests: mirror the spendable-balance regression for the direct-request entry view (the report + fix covered both /request views, only one was locked). Renders in the loading state — printableUsdc runs in a useMemo before the early return, so the field-choice assertion fires without the full form harness.
…display formatting
Two DRY follow-ups to the gate fix, both single-sourced in the wallet hook:
1. Messaging — useWallet.spendBlockReason(amount) classifies a blocked spend as
'settling' (covered by the displayed balance but part is still mid-rebalance:
<= display, > available-now) vs 'insufficient' (exceeds the displayed total).
The five legacy gates (send-link, qr-pay, withdraw page/bank/manteca) map that
to one shared SPEND_BLOCK_MESSAGE instead of four bespoke strings. The
'settling' copy is deliberately generic ("Your balance is updating...") so it
never exposes the card-collateral mechanic, and it only shows in the rare
~10-45s in-transit window: in the 99% case display == available so only the
normal "insufficient" path is reachable.
2. Display formatting — send-link, withdraw page/manteca and both request views
render the hook's formattedSpendableBalance instead of re-deriving locally
(printableUsdc vs formatAmount diverged: commas vs none). One formatter,
consistent across screens; orphaned imports removed.
Displayed balance is unchanged: always the full spendable total (smart +
collateral, incl. in-transit). Only the spend gate is strict, and only briefly.
hasSufficientSpendableBalance stays for the features/payments flows; both share a
parseUsdToBaseUnits helper. Tests revived/extended; full unit suite green.
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/app/`(mobile-ui)/withdraw/manteca/page.tsx:
- Line 691: The walletBalance prop assignment on line 691 uses a truthy guard on
the balance variable, which causes the balance text to be hidden when balance is
0n (a valid falsy value). Replace the truthy check with an explicit
null/undefined check so that falsy but valid values like 0n are still passed
through to the walletBalance prop. Change the condition from checking if balance
is truthy to explicitly checking if balance is not null and not undefined.
In `@src/components/Send/link/views/Initial.link.send.view.tsx`:
- Around line 138-147: The spend block validation using spendBlockReason is only
applied in the effect to set error state display, but the handleOnNext function
does not re-check the block before calling createLink, allowing blocked
transactions to be submitted. Add a spend block validation check at the
beginning of the handleOnNext function that calls spendBlockReason with the
current tokenValue, and if a block reason is returned, prevent the function from
proceeding to createLink. This ensures the spend block is enforced at submission
time, not just displayed as a visual error.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 09211471-97e8-4f23-8b04-751699090eb4
📒 Files selected for processing (12)
src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsxsrc/app/(mobile-ui)/qr-pay/page.tsxsrc/app/(mobile-ui)/withdraw/[country]/bank/page.tsxsrc/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsxsrc/app/(mobile-ui)/withdraw/manteca/page.tsxsrc/app/(mobile-ui)/withdraw/page.tsxsrc/components/Request/__tests__/request-states.test.tsxsrc/components/Request/direct-request/views/Initial.direct.request.view.tsxsrc/components/Request/link/views/Create.request.link.view.tsxsrc/components/Send/link/views/Initial.link.send.view.tsxsrc/hooks/wallet/useWallet.tssrc/utils/balance.utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/hooks/wallet/useWallet.ts
CodeRabbit review on the messaging/format commit: - Send-link: the Retry button isn't disabled on a balance error (unlike the other flows, which disable their submit), so handleOnNext could reach createLink with a blocked amount and fail at execution. Re-check spendBlockReason at the top of handleOnNext so the block holds at submit too. - withdraw/manteca: use an explicit `balance !== undefined` guard for the displayed balance so a real $0 balance shows "$0.00" instead of being hidden, matching the other consolidated flows (the old truthy guard dropped 0n).
Course-correction: simplify to fail-late + refetch (dropping the input-gate tightening)After review, the earlier "gate on available-now" approach (blocking in-transit spends at input) was the wrong call, and I'm reverting it. Reasoning:
So the right model is fail-late: gate only on the displayed balance (block when the amount exceeds the full visible balance — a true shortfall), let everything else proceed, and rely on live execution. What this commit does (net simpler than before):
Followed by a full |
…op settling-at-input) Gating money-flows at input on an "available-now" subset was wrong: the FE balance is only ~30s-polled while the spend routing reads the chain live at submit (#2234), so an input-time available-now gate blocks spends that would actually succeed — and the ~15s flow naturally lets the ~10-45s collateral rebalance settle. So gate on the DISPLAYED balance (block only a true shortfall) and let in-transit spends fail late on live execution. - useWallet: hasSufficientSpendableBalance now gates on the displayed spendableBalance. Deletes spendBlockReason / availableSpendableBalance / SPEND_BLOCK_MESSAGE and the whole "settling at input" apparatus (−55 net). - Refetch on failure (TanStack): on InsufficientSpendableError the routing already re-read live smart balance, so the two bundle hooks (useSpendBundle / useSignSpendBundle) invalidate [RAIN_CARD_OVERVIEW_QUERY_KEY] before throwing — the displayed balance + a retry de-stale immediately instead of waiting out the 30s poll. - Informative failure copy: the post-gate in-transit failure now says "Your balance isn't fully available yet — try again in a few seconds" (one shared BALANCE_SETTLING_MESSAGE) across send (ErrorHandler), qr-pay, manteca, useSendMoney — instead of a misleading "add funds". - Keeps the display-field fix + the formattedSpendableBalance formatting consolidation. Net -39 lines.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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/app/`(mobile-ui)/withdraw/page.tsx:
- Around line 84-91: Treat the wallet balance as “unknown” while useWallet() is
still loading instead of defaulting maxDecimalAmount to 0 in WithdrawPage.
Update the max-balance calculation in the withdraw page component so the
insufficient-balance check is skipped until spendableBalance is actually
resolved, and keep the Continue flow from showing the false “Add funds” error
during loading. Use the existing useWallet(), maxDecimalAmount, and the balance
validation branch around the Continue handler to locate the fix.
In `@src/components/Send/link/views/Initial.link.send.view.tsx`:
- Around line 153-160: The balance check in Initial.link.send.view.tsx is
clearing errorState too broadly, which can erase submit-time failures when
loading returns to idle. Update the logic around hasSufficientSpendableBalance
and setErrorState so only the insufficient-balance message is added/removed, and
preserve any existing non-balance errorMessage set during submit handling. Use
the existing error state transitions in the send view to keep late-failure
messaging visible until explicitly cleared by the relevant flow.
In `@src/hooks/wallet/useWallet.ts`:
- Around line 266-278: The hasSufficientSpendableBalance callback in useWallet
should reject non-finite amounts before converting to BigInt, because parseFloat
can produce Infinity/NaN and make Math.floor(...) throw. Update the amount
validation in this function to return false when the parsed value is not finite
(alongside the existing negative check) before computing amountInBaseUnits, so
the spendability guard safely fails instead of crashing.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 57e78e68-1b7e-43e1-8d94-7eb856d81af0
📒 Files selected for processing (13)
src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsxsrc/app/(mobile-ui)/qr-pay/page.tsxsrc/app/(mobile-ui)/withdraw/[country]/bank/page.tsxsrc/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsxsrc/app/(mobile-ui)/withdraw/manteca/page.tsxsrc/app/(mobile-ui)/withdraw/page.tsxsrc/components/Send/link/views/Initial.link.send.view.tsxsrc/hooks/wallet/useSendMoney.tssrc/hooks/wallet/useSignSpendBundle.tssrc/hooks/wallet/useSpendBundle.tssrc/hooks/wallet/useWallet.tssrc/utils/balance.utils.tssrc/utils/friendly-error.utils.tsx
… docs, test) Verified findings from the high-effort review pass: - Send handleOnNext: gate only once balance has loaded (`balance !== undefined`), else a tap before the query resolves false-rejected with "not enough balance" (hasSufficientSpendableBalance returns false on undefined). - useSendMoney.onError: invalidate ['balance', address] after the optimistic rollback — the rollback was discarding the fresh live balance useSpendBundle fetched mid-flight, leaving the smart portion stale until the next 30s poll. - contribute-pot RequestPotActionList: gate the loading-flash on isFetchingSpendableBalance (smart + Rain overview), not isFetchingBalance (smart only) — the latter flashed a false "insufficient" for split-funds users while the overview loaded. - Extracted the gate to a pure, exported `isDisplayBalanceSufficient` and unit- tested the gate-on-display contract (CONTRIBUTING: hooks that gate need a test). - Fixed stale JSDoc that still claimed the gate runs on available-now (formattedSpendableBalance note, computeAvailableSpendable/DisplaySpendable), and scoped the "shared copy" comment to send/pay/withdraw. Not changed (by design / accepted): gate widened to fail-late for the features/payments flows (intended); manteca float-rounding at the boundary fails-safe; the cosmetic toast+inline double-surface on a sendMoney settling failure (follow-up).
…unts BigInt(Math.floor(Infinity * 1e6)) throws a RangeError; a pasted/oversized amount (parseFloat -> Infinity) must fail the gate, not crash the render. isNaN didn't catch Infinity; use Number.isFinite. + tests.
…nd, precision)
Max-effort worst-case review found two real bugs I'd introduced plus robustness gaps:
- [money] Orphan charges: the features/payments flows (direct-send, contribute-pot,
semantic-request) createCharge BEFORE sendMoney, so widening their gate to the
displayed total let an in-transit amount pass, create a backend charge, then fail
late — an unpaid charge per retry. Restore the meaningful split:
hasSufficientSpendableBalance now gates on AVAILABLE-NOW (those charge-first flows),
while the no-pre-charge flows (send-link, qr-pay, withdraw) gate on the DISPLAYED
total via the renamed pure helper isAmountWithinBalance(amount, balance).
- [stuck] qr-pay Pay + manteca Withdraw buttons dead-ended after a settling failure:
the settling message made isBlockingError / disabled true with no way to clear, while
the copy says "try again". Exempt BALANCE_SETTLING_MESSAGE so the retry button stays live.
- [precision/crash] Gate now parses the amount with parseUnits (exactly what the spend
uses) instead of float Math.floor(amount*1e6): kills the boundary divergence AND fails
closed (returns false, never a BigInt(Infinity) RangeError) on adversarial input.
- [load] withdraw page no longer false-blocks ("insufficient") during the balance-load
window (maxDecimalAmount=0); guarded on a loaded balance, re-validates when it lands.
- [robustness] ErrorHandler matches the typed error name, not just the message string.
- [cleanup] removed the orphaned PEANUT_WALLET_TOKEN_DECIMALS import in bank/page.
Tests: pure isAmountWithinBalance (incl. Infinity/overflow), useSendMoney onError refetch.
Full unit suite green.
…ring load CodeRabbit on the adversarial-fix commit: - Send-link (Major): the balance effect cleared errorState on every sufficient- balance pass, wiping a submit-time failure message (e.g. the settling copy) the moment loading returned to idle. Now it only clears OUR balance-gate error (INSUFFICIENT_BALANCE_MESSAGE), never a handleOnNext failure message. - withdraw page (Minor): isContinueDisabled used maxDecimalAmount (=0 while the balance loads), disabling Continue during the load window. Guard it on a loaded balance, matching the validateAmount fix.


Summary
A user reported the "Balance: $XXX" on
/requestreading lower than/home(their example:$2213.38vs$2472.03). Both screens read the sameuseWallet()hook but picked different fields:/home(and/send) showspendableBalance= smart-account USDC + Rain card collateral (landed + in-transit)./requestshowedbalance= smart-account USDC only, silently dropping card collateral.The gap is exactly the funds the user holds as card collateral. This is the case
useWallet.ts:257-263explicitly warns against ("payment-input forms … should show spendableBalance … otherwise a user with funds split across smart and collateral sees a smaller balance than they actually have"). The Send view already follows this; the two Request views were the only outliers.Fix: alias
spendableBalance: balancein both Request views, mirroringInitial.link.send.view.tsx.Risks / breaking changes
AmountInput'swalletBalanceprop is rendered as text only (AmountInput/index.tsx:279-284); it is not wired to any affordability / spend gate. No spend-routing logic changes.spendableBalanceis already returned byuseWallet.QA
Sandbox, user with funds split across smart account and Rain card collateral:
/homebalance,/sendbalance, and/requestbalance (both the create-link view and a direct/<username>request) now show the same number./requestread lower by the collateral amount.Update — 2nd commit (
d2cfdfeda): unify the affordability gate (scope expanded)The display fix above only patched a symptom. The same "which balance field?" bug class lives in the affordability gates of the legacy money-flows, so this commit fixes the root cause across them.
The bug class. The displayed spendable balance includes in-transit card-collateral top-ups (added in #2170 so the balance doesn't crater to $0 during the ~10–45s smart→collateral handoff). Five legacy flows hand-rolled their "insufficient balance" gate by comparing the entered amount against that display number — which can briefly read higher than what's actually routable.
useWalletalready exposeshasSufficientSpendableBalance()(gates on available-now = smart + landed collateral) for exactly this, but only the newerfeatures/payments/flowsused it.Fix. Route the gates through the shared predicate:
Send/link,qr-pay,withdraw/bank,withdraw/manteca→hasSufficientSpendableBalance(amount)withdrawpage ceiling (maxDecimalAmount) → newly-exposedavailableSpendableBalanceRisk. Outside the top-up window
available == display, so behaviour is identical — the change only tightens the rare in-transit window (block at input vs. fail at execution). One known, by-design nuance: during a top-up the display can read higher than the gate allows for ~30s, self-healing once collateral lands.Tests. Revived the qr-pay insufficient-balance test (was
.skipped on mock-shape drift) + added a request-view regression locking the spendable-balance display field. 6 source + 3 test files; typecheck ✅, prettier ✅, affected jest suites ✅.