Skip to content

fix(withdraw): complete the user-facing charge on collateral-routed crypto withdraws#2491

Merged
abalinda merged 5 commits into
mainfrom
fix/withdraw-collateral-charge-completion-v2
Jul 23, 2026
Merged

fix(withdraw): complete the user-facing charge on collateral-routed crypto withdraws#2491
abalinda merged 5 commits into
mainfrom
fix/withdraw-collateral-charge-completion-v2

Conversation

@abalinda

Copy link
Copy Markdown
Contributor

Summary

Crypto withdraws funded from Rain card collateral (wallet at $0 after auto-sweep — the norm for card users) succeed on-chain but stay stuck PENDING and never appear in Activity, while the balance visibly drops. Users read this as lost funds — 98 stuck charges / 55 users in the last 14 days, all CRYPTO_WITHDRAW (Notion P2 task).

Root cause: the withdraw page was the only charge-backed spend flow that neither passed chargeId into sendMoney nor called recordPayment for collateral-routed same-chain spends. The user-facing charge intent therefore never completed. History's read side was already built expecting the charge to be the canonical row: it hides never-paid charges as "abandoned drafts" and hides the rain-prepare intent as a "phantom" — so a successful withdrawal was completely invisible.

The fix mirrors the proven direct-send / contribute-pot / semantic-request contract:

  • Pass chargeDetails.uuid as chargeId — on collateral-only the backend uses the charge as the preparation and settles it server-side in /submit (trusted completion; live on prod since 08004d52).
  • Always call recordPaymentmixed spends rely on it (the kernel's plain usdc.transfer(recipient) is validator-matchable); for collateral-only the backend routes it through the same trusted-completion path idempotently — the designed recovery net when /submit's post-mining bookkeeping fails (the backend comment explicitly names FE recordPayment as that recovery).
  • Refresh 3 stale comments (useSpendBundle, useSendMoney, services/rain) that instructed callers to SKIP recordPayment on collateral-only — the guidance that seeded this bug.

Design notes / accepted trade-offs

  • recordPayment failures on collateral-routed same-chain paths are tolerated (Sentry warning + success view without payment details): funds have already moved, and for collateral-only the backend has already settled the charge. A mixed-path double failure leaves the charge PENDING exactly as pre-fix — never worse than today.
  • Mixed same-chain spends only record with a mined receipt hash. If the receipt wait timed out, the kernel path returns only the userOp hash — the validator can never match that, and retry-exhaustion would flip a successful withdrawal to a false FAILED. We skip the record (pre-fix behavior, PENDING) with a Sentry breadcrumb instead. collateral-only is unaffected: its hash is a backend-broadcast EVM tx and its charge settles via the trusted path, receipt or not.
  • Cross-chain behavior is byte-identical to origin/dev: recordPayment always fired there and still hard-fails on error.
  • Bonus safety: passing chargeId closes a pre-fix double-broadcast hazard — retrying after "submit succeeded but FE errored" now gets a 409 (Charge already completed) from /prepare instead of silently preparing + broadcasting a second collateral withdrawal.
  • Backfill of the 98 historical stuck charges is a separate ops task, not this PR.

Risks / breaking changes

  • FE-only. No BE change required — the charge-backed prepare + trusted completion + idempotent recordPayment re-entry contract is live on prod main.
  • Blast radius: crypto-withdraw confirm flow only. smart-only and cross-chain paths verified unchanged (independent review sweep of every sendMoney/spend caller found no other flow with this bug shape).

QA

  • npm test: 148 suites green. New suite crypto-withdraw-confirm.test.tsx (6 tests): chargeId reaches sendMoney; mixed same-chain records the mined hash; collateral soft-fail still lands on the success view; mixed-without-receipt skips the record; cross-chain rethrow preserved; smart-only hard-fail preserved. The bug-pinning tests verified to fail against pre-fix origin/dev.
  • typecheck + prettier clean.
  • To verify manually (sandbox): with wallet USDC at $0 and funded card collateral, withdraw same-chain USDC to an external address → the withdrawal must appear as COMPLETED in Activity and the charge must not stay PENDING.

Supersedes #2484 — same 5 commits, cherry-picked onto main so the PR carries only the fix (the old branch was cut from dev and dragged ~37 unrelated dev files into the diff). Diff verified byte-identical to the fix-only range; new test suite re-run green on the main base.

abalinda added 5 commits July 23, 2026 12:34
…ithdraws

Same-chain crypto withdraws funded from Rain card collateral succeeded
on-chain but never completed their charge intent — the page skipped
recordPayment and never passed chargeId, so the charge rotted PENDING
until the reaper failed it. History hides both rows (phantom +
abandoned-draft filters), so users watched their balance drop with no
Activity entry (98 charges / 55 users in 14 days).

Mirror the direct-send contract: pass chargeDetails.uuid into sendMoney
(the backend uses the charge as the prep and settles it in /submit's
trusted completion) and always recordPayment — mixed spends rely on it,
collateral-only re-enters trusted completion idempotently as the
recovery net. recordPayment failures after a collateral-routed
same-chain spend degrade to the success view instead of a false
'failed': the funds have already moved.

Also refresh the three stale hook/service comments that instructed
callers to SKIP recordPayment on collateral-only — the guidance that
seeded this bug.
Adversarial review find: on a receipt-wait timeout the kernel path
returns only the userOp hash. Feeding that to the validator for an
untagged mixed charge can never match a tx — retry exhaustion would
flip the successful withdrawal to FAILED, worse than the pre-fix stuck
PENDING. Skip the record with a Sentry breadcrumb in that case
(collateral-only stays safe: backend-broadcast tx hash + trusted-path
settlement). Also pin cross-chain rethrow + this skip in tests.
…ce + isDiffToken routing

Structured review pass: the bug-seeding 'skip recordPayment' guidance
survived in two more comments (useSpendBundle prepare call-site,
useWallet sendMoney return doc) — the exact rot this PR exists to purge.
Also close the two coverage gaps a scoping regression could slip
through: mixed same-chain tolerance and token-boundary-only routing.
@vercel

vercel Bot commented Jul 23, 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 23, 2026 9:41am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a843e561-659d-433b-9dc3-64af1707b154

📥 Commits

Reviewing files that changed from the base of the PR and between 5fd5969 and 2b967a5.

📒 Files selected for processing (6)
  • src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx
  • src/hooks/wallet/useSendMoney.ts
  • src/hooks/wallet/useSpendBundle.ts
  • src/hooks/wallet/useWallet.ts
  • src/services/rain.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/withdraw-collateral-charge-completion-v2

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

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6247.08 → 6248.43 (+1.35)
Findings: 0 net (+26 new, -26 resolved)

🆕 New findings (26)

  • critical complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx — CC 86, MI 51.66, SLOC 371
  • medium react-long-component — src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage is 589 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: MDD 103.4 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:108 — useSpendBundle: MDD 59.3 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: DLT 55 (calls 55 distinct functions — high context load)
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:123 — : MDD 46.5 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSendMoney.ts:50 — useSendMoney: MDD 39.0 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useWallet.ts:53 — useWallet: MDD 38.5 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — : MDD 38.2 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/wallet/useSpendBundle.ts:108 — useSpendBundle: DLT 32 (calls 32 distinct functions — high context load)
  • medium hotspot — src/app/(mobile-ui)/withdraw/crypto/page.tsx — 29 commits, +368/-212 lines since 6 months ago
  • medium method-complexity — src/services/rain.ts:335 — rainRequest CC 29 SLOC 62
  • medium hotspot — src/hooks/wallet/useSpendBundle.ts — 26 commits, +963/-603 lines since 6 months ago
  • medium hotspot — src/services/rain.ts — 26 commits, +859/-117 lines since 6 months ago
  • medium high-mdd — src/services/rain.ts:335 — rainRequest: MDD 22.7 (uses across many lines from declarations)
  • medium structural-dup — hooks/wallet/useSignSpendBundle.ts:234 — 22 duplicate lines / 98 tokens with hooks/wallet/useSpendBundle.ts:282
  • medium method-complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — CC 19 SLOC 102
  • low high-dlt — src/hooks/wallet/useSpendBundle.ts:123 — : DLT 24 (calls 24 distinct functions — high context load)
  • low high-mdd — src/hooks/wallet/useSendMoney.ts:113 — onError: MDD 18.5 (uses across many lines from declarations)
  • low high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — : DLT 17 (calls 17 distinct functions — high context load)

…and 6 more.

✅ Resolved (26)

  • src/app/(mobile-ui)/withdraw/crypto/page.tsx — CC 83, MI 52.34, SLOC 351
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage is 548 lines — split it
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: MDD 91.1 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts:103 — useSpendBundle: MDD 58.3 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: DLT 54 (calls 54 distinct functions — high context load)
  • src/hooks/wallet/useSpendBundle.ts:118 — : MDD 45.5 (uses across many lines from declarations)
  • src/hooks/wallet/useSendMoney.ts:49 — useSendMoney: MDD 39.0 (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/useSpendBundle.ts:103 — useSpendBundle: DLT 32 (calls 32 distinct functions — high context load)
  • src/services/rain.ts:334 — rainRequest CC 29 SLOC 62
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx — 27 commits, +288/-173 lines since 6 months ago
  • src/services/rain.ts — 25 commits, +856/-115 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — : MDD 23.6 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts — 24 commits, +949/-596 lines since 6 months ago
  • src/services/rain.ts:334 — rainRequest: MDD 22.7 (uses across many lines from declarations)
  • hooks/wallet/useSignSpendBundle.ts:234 — 22 duplicate lines / 98 tokens with hooks/wallet/useSpendBundle.ts:275
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — CC 16 SLOC 82
  • src/hooks/wallet/useSpendBundle.ts:118 — : DLT 24 (calls 24 distinct functions — high context load)
  • src/hooks/wallet/useSendMoney.ts:112 — onError: MDD 18.5 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — : DLT 16 (calls 16 distinct functions — high context load)

…and 6 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/app/(mobile-ui)/withdraw/crypto/page.tsx 17.8 18.5 +0.6

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2070 ran, 0 failed, 0 skipped, 33.7s

📊 Coverage (unit)

metric %
statements 60.2%
branches 44.1%
functions 49.2%
lines 60.6%
⏱ 10 slowest test cases
time test
4.0s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s 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.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.3s 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.1s src/utils/__tests__/demo-balance.test.ts › resetDemoBalance refills and restarts the TTL window
0.1s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@kushagrasarathe

Copy link
Copy Markdown
Contributor

Review — code quality / DRY / bug risk / security

Correct fix at the right altitude (the withdraw page was the outlier that never passed chargeId / called recordPayment). The mixedWithoutMinedHash guard is a sharp catch — feeding a userOp hash to the on-chain validator would flip a successful withdraw to FAILED, worse than the stuck-PENDING it replaces. Test coverage across every strategy×outcome cell is the right rigor for a money path.

Two things to flag:

  1. DRY — biggest smell here. This is now the 4th flow (direct-send, contribute-pot, semantic-request, withdraw) hand-rolling the same strategy → chargeId / recordPayment / tolerate-failure decision tree. The comments literally say "mirrors the proven contract" — that's copy-paste of branching logic, not reuse. The strategy-dependent completion belongs in a shared helper (e.g. completeChargeAfterSpend(result, { isCrossChain })). Not blocking — fixing the bug first is right — but worth a tracked follow-up, since independent drift across these flows is exactly how the withdraw path fell behind to begin with.

  2. Residual bug for the mixed path. The failure tolerance fully closes the bug only for collateral-only (settled server-side, idempotent re-entry). A mixed same-chain spend whose recordPayment fails still stays PENDING with only a Sentry warning — "exactly like pre-fix," as the comment says. Fine and documented; just don't read this as "PENDING is now impossible" — it's impossible for the reported collateral-only cohort, narrowed for mixed.

Security: nothing new — server-side idempotency is what makes always-recordPayment safe.

Note: touches withdraw/crypto/page.tsx alongside #2490 (different regions); merge order may require a rebase.

@abalinda

abalinda commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@kushagrasarathe agreed. The DRY extraction is now tracked in mono TODO.md (shared completeChargeAfterSpend helper) but outside of the scope of this PR (that one is pretty big, needs a proper PR)

@kushagrasarathe kushagrasarathe 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.

APPROVE — with two non-blocking follow-ups called out below.

Correct fix at the right altitude: the withdraw page was the only charge-backed spend flow not passing chargeId / calling recordPayment, which is exactly why collateral-routed withdraws rotted PENDING and vanished from Activity (98 charges / 55 users). The fix mirrors the proven direct-send / contribute-pot / semantic-request contract.

What makes this approvable despite being a large money path:

  • mixedWithoutMinedHash guard is a sharp catch — feeding a userOp hash to the on-chain validator would flip a successful withdraw to FAILED (worse than stuck-PENDING). Correct.
  • The try/catch degradation is right: collateral-routed same-chain tolerates a recordPayment hiccup (funds already moved; collateral-only already settled server-side), other paths keep rethrowing.
  • 426-line test file covers every strategy × outcome cell, including the smart-only strict-rethrow and cross-chain-keeps-recording branches. This is the rigor a money path needs.

Two follow-ups (neither blocks this bug fix):

  1. DRY: this is now the 4th flow hand-rolling the same strategy → chargeId / recordPayment / tolerate-failure tree. Extract a shared completeChargeAfterSpend(result, { isCrossChain }) — independent drift across these flows is how the withdraw path fell behind to begin with. Worth a tracked ticket.
  2. Residual: the tolerance fully closes the bug only for collateral-only (server-settled, idempotent). A mixed same-chain spend whose recordPayment fails still stays PENDING with only a Sentry warning — documented as "exactly like pre-fix," so it's narrowed, not eliminated, for that path. Accepted.

CI green apart from the pre-existing repo-wide no-explicit-any eslint failure (new code is eslint-clean).

@abalinda
abalinda merged commit ed3021a into main Jul 23, 2026
23 of 25 checks passed
innolope-dev added a commit that referenced this pull request Jul 24, 2026
Brings in the merged PRs missing from the release: #2490 (token-selector
non-EVM label), #2491 (withdraw collateral-charge completion), #2499 (footer
legal-entity line).

Conflict in withdraw/crypto/page.tsx resolved to keep mobile-release's
localization while adopting #2491's fix: pass chargeId into sendMoney so
collateral-routed withdraws settle the charge server-side. Re-added the
@sentry/nextjs captureMessage import the localization pass had dropped, and
adapted crypto-withdraw-confirm.test.tsx to the localized page (context path,
next-intl + useFriendlyError mocks). Dropped the dead intentId var (unused on
main too).
innolope-dev added a commit that referenced this pull request Jul 24, 2026
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.

2 participants