fix(withdraw): complete the user-facing charge on collateral-routed crypto withdraws#2491
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Code-analysis diffPainscore total: 6247.08 → 6248.43 (+1.35) 🆕 New findings (26)
…and 6 more. ✅ Resolved (26)
…and 6 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
|
Review — code quality / DRY / bug risk / security Correct fix at the right altitude (the withdraw page was the outlier that never passed Two things to flag:
Security: nothing new — server-side idempotency is what makes always- Note: touches |
|
@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
left a comment
There was a problem hiding this comment.
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:
mixedWithoutMinedHashguard 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
recordPaymenthiccup (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):
- DRY: this is now the 4th flow hand-rolling the same
strategy → chargeId / recordPayment / tolerate-failuretree. Extract a sharedcompleteChargeAfterSpend(result, { isCrossChain })— independent drift across these flows is how the withdraw path fell behind to begin with. Worth a tracked ticket. - Residual: the tolerance fully closes the bug only for
collateral-only(server-settled, idempotent). A mixed same-chain spend whoserecordPaymentfails 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).
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).
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
chargeIdintosendMoneynor calledrecordPaymentfor 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:
chargeDetails.uuidaschargeId— oncollateral-onlythe backend uses the charge as the preparation and settles it server-side in/submit(trusted completion; live on prod since08004d52).recordPayment—mixedspends rely on it (the kernel's plainusdc.transfer(recipient)is validator-matchable); forcollateral-onlythe 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 FErecordPaymentas that recovery).useSpendBundle,useSendMoney,services/rain) that instructed callers to SKIPrecordPaymenton collateral-only — the guidance that seeded this bug.Design notes / accepted trade-offs
recordPaymentfailures on collateral-routed same-chain paths are tolerated (Sentry warning + success view without payment details): funds have already moved, and forcollateral-onlythe backend has already settled the charge. A mixed-path double failure leaves the charge PENDING exactly as pre-fix — never worse than today.collateral-onlyis unaffected: its hash is a backend-broadcast EVM tx and its charge settles via the trusted path, receipt or not.origin/dev:recordPaymentalways fired there and still hard-fails on error.chargeIdcloses a pre-fix double-broadcast hazard — retrying after "submit succeeded but FE errored" now gets a 409 (Charge already completed) from/prepareinstead of silently preparing + broadcasting a second collateral withdrawal.Risks / breaking changes
recordPaymentre-entry contract is live on prodmain.smart-onlyand cross-chain paths verified unchanged (independent review sweep of everysendMoney/spendcaller found no other flow with this bug shape).QA
npm test: 148 suites green. New suitecrypto-withdraw-confirm.test.tsx(6 tests): chargeId reachessendMoney; 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-fixorigin/dev.typecheck+prettierclean.