Skip to content

fix(card): route spends on live on-chain smart balance (fixes #2230 incident, keeps smart-first)#2234

Merged
Hugo0 merged 5 commits into
mainfrom
hotfix/smart-first-live-balance
Jun 16, 2026
Merged

fix(card): route spends on live on-chain smart balance (fixes #2230 incident, keeps smart-first)#2234
Hugo0 merged 5 commits into
mainfrom
hotfix/smart-first-live-balance

Conversation

@jjramirezn

Copy link
Copy Markdown
Contributor

🚑 Incident fix — keeps smart-first, fixes the on-chain revert

Follow-up to #2230. Smart-first routing is correct, but it trusted the FE's cached smartBalance (useBalance, 30s cache, often read before the smart→collateral sweep). Card funds are swept smart→collateral to back the card, so the smart account is normally ~empty — a stale balance >= amount routed smart-only to an empty account and the USDC transfer reverted on-chain (ERC20: transfer amount exceeds balance).

Fix: both routers — useSpendBundle.spend and useSignSpendBundle.signSpend — now read the live balanceOf of the exact kernel account that will send the UserOp (fetchLiveSmartUsdcBalance), right before computeSpendStrategy, and use it for routing and the mixed shortfall. getClientForChain already asserts the client belongs to the logged-in user, so sender + balance are an authoritative pair (also closes the stale-key/account-switch angle).

Removes the now-redundant smartBalance input from both hooks and every call site (useSendMoney, useWallet.sendTransactions, qr-pay, withdraw/manteca, Lock/CancelCardModal) so a cached value can never drive routing again.

Net: keeps Konrad's smart-first win (smart-account USDC → no Rain cooldown), and collateral-funded users route collateral-only/mixed correctly.

Replaces

Supersedes the revert PR #2233 (we keep smart-first instead of reverting).

Risk

  • One extra on-chain balanceOf read per spend (negligible; useBalance already polls every 30s). If the read throws, the spend errors clearly instead of reverting on-chain.
  • Residual sub-second sweep race (read → sweep → submit) is far narrower than the 30s cache window; a belt-and-suspenders "fall back to collateral on smart-only revert" is a tracked follow-up.

QA

  • npx jest src/hooks/wallet — 21 pass, incl. new fetchLiveSmartUsdcBalance tests (reads balanceOf of the sender; returns chain value).
  • typecheck ✅, next build ✅.
  • Sandbox: (a) collateral-only user (smart on-chain 0) pays → routes collateral-only, no paymaster revert; (b) user with smart-account USDC pays → smart-only, no Rain cooldown on rapid back-to-back.

🤖 Generated with Claude Code

…value

Follow-up to the #2230 incident. Smart-first routing is correct, but it trusted the FE's cached smartBalance (useBalance, 30s cache / read before the smart->collateral sweep). For card users the smart account is swept ~empty into collateral, so a stale balance >= amount routed smart-only to an empty account and the USDC transfer reverted on-chain (ERC20: transfer amount exceeds balance).

Both routers (useSpendBundle.spend + useSignSpendBundle.signSpend) now read the LIVE balanceOf the exact kernel account that will send the UserOp (fetchLiveSmartUsdcBalance) before computeSpendStrategy, and use it for routing and the mixed shortfall. getClientForChain already asserts the client belongs to the logged-in user. Removes the now-redundant smartBalance input from both hooks and all call sites. Keeps smart-first so users with smart-account USDC avoid Rain's withdrawal-signature cooldown.
@vercel

vercel Bot commented Jun 16, 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 Jun 16, 2026 8:26pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4009fd7b-9620-493f-928b-96d30899b354

📥 Commits

Reviewing files that changed from the base of the PR and between 30db9f7 and b34d494.

📒 Files selected for processing (3)
  • src/hooks/wallet/__tests__/useSpendBundle.test.ts
  • src/hooks/wallet/useSignSpendBundle.ts
  • src/hooks/wallet/useSpendBundle.ts

Walkthrough

smartBalance is removed from SpendBundleInput and SignSpendBundleInput. A shared smartUsdcBalanceQueryOptions helper is extracted from useBalance, and a new exported fetchLiveSmartUsdcBalance function uses it with staleTime: 0 to force an on-chain read at routing time. All callers—useWallet, useSendMoney, and four UI flows—drop the field.

Changes

Live USDC Balance Routing

Layer / File(s) Summary
Shared smart USDC query configuration
src/hooks/wallet/useBalance.ts
Extracts smartUsdcBalanceQueryOptions(address) with query key, ERC-20 balanceOf queryFn, and cache/retry settings; useBalance is refactored to spread those options and supply only reactive controls.
fetchLiveSmartUsdcBalance helper and SpendBundleInput contract update
src/hooks/wallet/useSpendBundle.ts
Adds useQueryClient/QueryClient and smartUsdcBalanceQueryOptions imports, removes smartBalance from SpendBundleInput, and exports fetchLiveSmartUsdcBalance which calls queryClient.fetchQuery with staleTime: 0.
useSpendBundle execution refactor with live balance
src/hooks/wallet/useSpendBundle.ts
Obtains queryClient via useQueryClient, builds one kernelClient up front, fetches live smartBalance before computeSpendStrategy, and removes redundant chain-id/kernel-client re-derivations in all routing branches.
useSignSpendBundle contract and live routing update
src/hooks/wallet/useSignSpendBundle.ts
Imports useQueryClient and fetchLiveSmartUsdcBalance, removes smartBalance from SignSpendBundleInput, reworks signSpend to resolve chain ID and kernel account once then fetch live balance, and removes duplicated derivations.
Hook and UI call-site cleanup
src/hooks/wallet/useWallet.ts, src/hooks/wallet/useSendMoney.ts, src/app/(mobile-ui)/qr-pay/page.tsx, src/app/(mobile-ui)/withdraw/manteca/page.tsx, src/components/Card/CancelCardModal.tsx, src/components/Card/LockCardModal.tsx
All callers drop the smartBalance argument (or its destructured alias) from every spend/signSpend invocation.
Unit tests for fetchLiveSmartUsdcBalance
src/hooks/wallet/__tests__/useSpendBundle.test.ts
Adds mockReadContract spy, imports fetchLiveSmartUsdcBalance, and validates balanceOf call shape, returned balance, and forced staleTime: 0 refetch via a minimal QueryClient stand-in.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • peanutprotocol/peanut-ui#2230: Modifies computeSpendStrategy to prioritize smart-only over collateral-only when the smart account can cover the amount — the counterpart to this PR's change that feeds a live on-chain balance into that same strategy function.
  • peanutprotocol/peanut-ui#1996: Modifies SpendBundleInput and the rainApi.prepareWithdrawal payload in useSpendBundle.ts for a different field (chargeId), making it a direct structural sibling of this PR's changes to the same contract.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: fixing live on-chain smart balance routing to fix incident #2230 while maintaining smart-first strategy.
Description check ✅ Passed The description thoroughly explains the incident, root cause, solution, and testing performed, directly relating to the changeset's goal of fixing balance-based routing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 5786.03 → 5719.2 (-66.83)
Findings: +1 net (+51 new, -50 resolved)

🆕 New findings (51)

  • critical complexity — src/app/(mobile-ui)/qr-pay/page.tsx — CC 295, MI 53.27, SLOC 955
  • critical complexity — src/app/(mobile-ui)/withdraw/manteca/page.tsx — CC 150, MI 52, SLOC 520
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 86 commits, +1052/-1014 lines since 6 months ago
  • high hotspot — src/app/(mobile-ui)/withdraw/manteca/page.tsx — 63 commits, +644/-375 lines since 6 months ago
  • high complexity — src/hooks/wallet/useWallet.ts — CC 47, MI 56.61, SLOC 195
  • high method-complexity — src/app/(mobile-ui)/withdraw/manteca/page.tsx:77 — MantecaWithdrawFlow CC 43 SLOC 198
  • high complexity — src/hooks/wallet/useSignSpendBundle.ts — CC 13, MI 38.9, SLOC 177
  • medium react-long-component — src/app/(mobile-ui)/qr-pay/page.tsx:92 — QRPayPage is 1499 lines — split it
  • medium react-long-component — src/app/(mobile-ui)/withdraw/manteca/page.tsx:77 — MantecaWithdrawFlow is 830 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:92 — QRPayPage: MDD 335.8 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/manteca/page.tsx:77 — MantecaWithdrawFlow: MDD 239.1 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:154 — useSpendBundle: MDD 68.8 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:169 — : MDD 59.7 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSignSpendBundle.ts:101 — useSignSpendBundle: MDD 54.6 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSignSpendBundle.ts:109 — : MDD 50.7 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/manteca/page.tsx:318 — handleWithdraw: MDD 47.7 (uses across many lines from declarations)
  • medium structural-dup — src/app/(mobile-ui)/dev/shake-test/page.tsx:27 — 46 duplicate lines / 216 tokens with src/app/(mobile-ui)/qr-pay/page.tsx:857
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:577 — : MDD 35.1 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSendMoney.ts:52 — useSendMoney: MDD 35.3 (uses across many lines from declarations)
  • medium structural-dup — src/app/(mobile-ui)/withdraw/manteca/page.tsx:605 — 29 duplicate lines / 132 tokens with src/components/AddMoney/components/MantecaAddMoney.tsx:239

…and 31 more.

✅ Resolved (50)

  • src/app/(mobile-ui)/qr-pay/page.tsx — CC 295, MI 53.26, SLOC 956
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx — CC 150, MI 51.95, SLOC 522
  • src/app/(mobile-ui)/qr-pay/page.tsx — 85 commits, +1052/-1013 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx — 62 commits, +643/-373 lines since 6 months ago
  • src/hooks/wallet/useWallet.ts — CC 47, MI 56.5, SLOC 197
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:77 — MantecaWithdrawFlow CC 43 SLOC 199
  • src/hooks/wallet/useSignSpendBundle.ts — CC 13, MI 39.09, SLOC 174
  • src/app/(mobile-ui)/qr-pay/page.tsx:92 — QRPayPage is 1500 lines — split it
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:77 — MantecaWithdrawFlow is 831 lines — split it
  • src/app/(mobile-ui)/qr-pay/page.tsx:92 — QRPayPage: MDD 336.1 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:77 — MantecaWithdrawFlow: MDD 239.7 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts:138 — useSpendBundle: MDD 68.0 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts:152 — : MDD 60.5 (uses across many lines from declarations)
  • src/hooks/wallet/useSignSpendBundle.ts:101 — useSignSpendBundle: MDD 51.7 (uses across many lines from declarations)
  • src/hooks/wallet/useSignSpendBundle.ts:108 — : MDD 49.1 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:318 — handleWithdraw: MDD 47.9 (uses across many lines from declarations)
  • src/app/(mobile-ui)/dev/shake-test/page.tsx:27 — 46 duplicate lines / 216 tokens with src/app/(mobile-ui)/qr-pay/page.tsx:858
  • src/app/(mobile-ui)/qr-pay/page.tsx:577 — : MDD 35.6 (uses across many lines from declarations)
  • src/hooks/wallet/useSendMoney.ts:52 — useSendMoney: MDD 35.0 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx:606 — 29 duplicate lines / 132 tokens with src/components/AddMoney/components/MantecaAddMoney.tsx:239

…and 30 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/wallet/useSignSpendBundle.ts 18.5 19.2 +0.7
src/hooks/wallet/useSpendBundle.ts 7.8 7.2 -0.5

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1435 ran, 0 failed, 0 skipped, 19.2s

📊 Coverage (unit)

metric %
statements 52.2%
branches 34.4%
functions 39.3%
lines 52.1%
⏱ 10 slowest test cases
time test
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in updateUserById
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in updateUserById body
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every stamp stays within canvas at any count
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle too long for US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid German IBAN
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle invalid ETH address (invalid characters)
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address with surrounding spaces
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle maximum length (17 digits) US account
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/useSpendBundle.ts`:
- Around line 191-192: In the useSpendBundle hook, replace the non-null
assertion on kernelClient.account!.address with an explicit guard check that
verifies kernelClient.account is initialized before accessing the address
property. Follow the same pattern used in useSignSpendBundle (lines 117-121) by
checking if the account exists and throwing a descriptive error when it is
uninitialized, rather than relying on the non-null assertion which would produce
a cryptic "Cannot read property 'address' of undefined" error if the kernel
context hasn't hydrated yet.
🪄 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: 58ccb6fb-abc5-442e-a53f-51478526a3c9

📥 Commits

Reviewing files that changed from the base of the PR and between 9f371b3 and cb302d3.

📒 Files selected for processing (9)
  • src/app/(mobile-ui)/qr-pay/page.tsx
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx
  • src/components/Card/CancelCardModal.tsx
  • src/components/Card/LockCardModal.tsx
  • src/hooks/wallet/__tests__/useSpendBundle.test.ts
  • src/hooks/wallet/useSendMoney.ts
  • src/hooks/wallet/useSignSpendBundle.ts
  • src/hooks/wallet/useSpendBundle.ts
  • src/hooks/wallet/useWallet.ts
💤 Files with no reviewable changes (4)
  • src/components/Card/CancelCardModal.tsx
  • src/components/Card/LockCardModal.tsx
  • src/app/(mobile-ui)/qr-pay/page.tsx
  • src/hooks/wallet/useWallet.ts

Comment thread src/hooks/wallet/useSpendBundle.ts Outdated
Comment on lines +191 to +192
const kernelClient = getClientForChain(chainIdStr)
const smartBalance = await fetchLiveSmartUsdcBalance(kernelClient.account!.address)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a guard for kernelClient.account before accessing .address.

useSignSpendBundle (lines 117-121) explicitly checks kernelClient.account and throws a descriptive error when uninitialized. This hook uses a non-null assertion that could produce a cryptic Cannot read property 'address' of undefined if the kernel context hasn't hydrated yet.

Suggested fix
             const kernelClient = getClientForChain(chainIdStr)
-            const smartBalance = await fetchLiveSmartUsdcBalance(kernelClient.account!.address)
+            const kernelAccount = kernelClient.account
+            if (!kernelAccount) {
+                throw new Error('useSpendBundle: kernel account not initialized')
+            }
+            const smartBalance = await fetchLiveSmartUsdcBalance(kernelAccount.address)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const kernelClient = getClientForChain(chainIdStr)
const smartBalance = await fetchLiveSmartUsdcBalance(kernelClient.account!.address)
const kernelClient = getClientForChain(chainIdStr)
const kernelAccount = kernelClient.account
if (!kernelAccount) {
throw new Error('useSpendBundle: kernel account not initialized')
}
const smartBalance = await fetchLiveSmartUsdcBalance(kernelAccount.address)
🤖 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/hooks/wallet/useSpendBundle.ts` around lines 191 - 192, In the
useSpendBundle hook, replace the non-null assertion on
kernelClient.account!.address with an explicit guard check that verifies
kernelClient.account is initialized before accessing the address property.
Follow the same pattern used in useSignSpendBundle (lines 117-121) by checking
if the account exists and throwing a descriptive error when it is uninitialized,
rather than relying on the non-null assertion which would produce a cryptic
"Cannot read property 'address' of undefined" error if the kernel context hasn't
hydrated yet.

… races

Two follow-ups to #2234, both on the smart-first spend path.

1. Kill the duplicated balance read. #2234 added a standalone
   fetchLiveSmartUsdcBalance — a second copy of useBalance's
   readContract(balanceOf). Routing now force-refetches the SAME TanStack
   query (smartUsdcBalanceQueryOptions, staleTime:0): one source of truth,
   one readContract, and the displayed balance refreshes in the same call
   instead of lying until the next 30s poll.

2. Recoverable routing. A smart-only spend can still lose the
   read -> sweep -> submit race (card funds get swept smart->collateral
   after we route). A thrown smart-only UserOp never broadcast —
   handleSendUserOpEncoded's receipt-timeout path returns, only a failed
   sendUserOperation throws — so we re-read the live balance and, if it
   dropped below the amount, retry on collateral/mixed. Provably no
   double-spend. Real failures (balance still covers, stale key) rethrow
   untouched.

Tests: pure rerouteAfterSmartOnlySweep cases + a renderHook test proving a
swept smart-only retries on collateral exactly once (and a non-sweep failure
rethrows without touching collateral).
@coderabbitai coderabbitai Bot added the enhancement New feature or request label Jun 16, 2026
`import/first` isn't registered in this repo's eslint config, so the
`// eslint-disable-next-line import/first` directive errors ("rule not
found"). The late import it guarded isn't flagged either (no such rule), so
the comment is pure dead weight. Removing it from the new reroute test (net
0) and the sibling unit test (−1 from the standing eslint count).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/__tests__/useSpendBundle.reroute.test.tsx`:
- Line 78: The ESLint suppression comment on line 78 of the
useSpendBundle.reroute.test.tsx file disables the `import/first` rule, but this
rule is not defined in the active ESLint configuration, causing the suppression
itself to fail linting. Remove the entire eslint-disable-next-line comment line
that references the non-existent import/first rule. If mocks truly need to
register before imports, verify the actual ESLint rule name in your
configuration and update the suppression accordingly, or restructure the code to
comply with active linting rules.
🪄 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: b236469d-245d-4a31-abe2-cdcd01316c2d

📥 Commits

Reviewing files that changed from the base of the PR and between cb302d3 and a2d7359.

📒 Files selected for processing (5)
  • src/hooks/wallet/__tests__/useSpendBundle.reroute.test.tsx
  • src/hooks/wallet/__tests__/useSpendBundle.test.ts
  • src/hooks/wallet/useBalance.ts
  • src/hooks/wallet/useSignSpendBundle.ts
  • src/hooks/wallet/useSpendBundle.ts

Comment thread src/hooks/wallet/__tests__/useSpendBundle.reroute.test.tsx Outdated
Hugo0 added 2 commits June 16, 2026 13:20
Review found the smart-only reroute could double-spend. `client.sendUserOperation`
is a network RPC that can throw AFTER the bundler accepts the UserOp (chronic on
mobile ZeroDev RPC), so "thrown => never broadcast" does not hold. The reroute
would then re-read the already-debited balance, classify it as a sweep, and pay
a second time from collateral — strictly worse than the revert it was healing.

Keep fix #1 only: routing reads the live balance through the SHARED
smartUsdcBalanceQueryOptions query (force-refetch, staleTime:0) instead of a
duplicated readContract — one source of truth, and the displayed balance
refreshes in the same call. This already collapses the 30s stale-cache window
that caused incident #2230.

Residual (accepted): a sweep landing in the sub-second window between the live
read and on-chain execution still reverts the payment (terminal, manual retry —
safe, no double-spend). The general fix is backend-authoritative routing, filed
as a follow-up.
…ed its test)

Completes the previous commit: removes rerouteAfterSmartOnlySweep, the runStrategy
extraction, and the smart-only reroute wrappers from useSpendBundle.spend and
useSignSpendBundle.signSpend, plus the reroute unit tests. Routing keeps fix #1
only (shared smartUsdcBalanceQueryOptions, force-refetch staleTime:0).
@Hugo0 Hugo0 merged commit 0196a02 into main Jun 16, 2026
22 of 24 checks passed
Hugo0 added a commit that referenced this pull request Jun 24, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants