fix(card): force collateral-only routing on lock/cancel - #2571
Conversation
Lock/cancel sign a withdrawal whose purpose is draining Rain collateral back to the wallet, but since cb302d3 removed the smartBalance:0n input, routing fell through to live-balance strategy selection: any user whose wallet USDC covered their spending power routed smart-only, tripped the modals' own strategy check, and could neither lock nor cancel ('Unexpected withdrawal strategy', prod, 3 users affected). Also fail closed when the card overview hasn't loaded: undefined read as zero spending power, silently skipping the withdrawal and getting the action rejected server-side ('Withdrawal signature required'). Port of the dev-based #2570 (closed in favor of this main-based hotfix; that branch holds the i18n-ified variant for the dev back-merge).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedNo new commits to review since the last review. ⚙️ 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:
📝 WalkthroughWalkthroughLock and cancel card flows now fail closed while overview data is unavailable and force collateral-only withdrawal signing. New tests cover successful routing, unloaded overview behavior, zero spending power, and backend payloads. ChangesCard collateral withdrawal routing
Estimated code review effort: 2 (Simple) | ~15 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Code-analysis diffPainscore total: 6310.83 → 6311.34 (+0.51) 🆕 New findings (6)
✅ Resolved (6)
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
What broke (prod)
Locking or cancelling a card fails with "Unexpected withdrawal strategy — please contact support" whenever the user's smart-wallet USDC ≥ their card spending power. The card stays
ACTIVE; the user burns a passkey tap and cannot secure or close their card. No money at risk — the signed UserOp is never broadcast.Reported via Crisp on 2026-07-24 (bug bounty paid). 3 prod users are currently blocked, including one with $25k wallet / $2k collateral; 235 distinct users routed
smart-onlyin the last 6 weeks and were transiently exposed (auto-balance keeps wallets swept near-empty, which is why the standing count is low).Root cause — regression trail
The lock/cancel withdrawal exists to drain Rain collateral back to the wallet, so it must always route
collateral-only:2eb1da396(2026-05-08): forced this by passingsmartBalance: 0ntosignSpend.cb302d35a(2026-06-16): removed thesmartBalanceinput repo-wide and deleted the0nline from both modals without replacement — routing fell through tocomputeSpendStrategy, which returnssmart-onlywhenever the live wallet balance covers the amount. Both modals then reject their own artifact and throw.ca79b66a7: addedforceStrategy: 'collateral-only'for exactly this need, but wired it only intouseReturnExcessCollateral.A stale comment ("smart=0n eliminates the smart-only and mixed branches") kept describing the deleted parameter, which is why later reviews missed it.
The fix (frontend-only, backend unchanged)
LockCardModal/CancelCardModal: passforceStrategy: 'collateral-only'tosignSpend— same pattern asuseReturnExcessCollateral. Replaced the stale comment with one stating the actual constraint. Theartifact.strategy !== 'collateral-only'throw stays: unreachable at runtime now, but load-bearing for TS narrowing to therainWithdrawalvariant.rainCentsToUsdcUnits(undefined)→0n, so an unresolveduseRainCardOverviewsilently skipped the withdrawal and the backend rejected with "Withdrawal signature required to return collateral before this action" (2 users, 07-09 and 07-13). Both modals now throw a friendly retry message instead of signing nothing.Tests
New
src/components/Card/__tests__/LockCardModal.test.tsxcovering both modals (6 tests):signSpendis called withforceStrategy: 'collateral-only'(exact-args assertion — this is the one that catches the nextSignSpendBundleInputrefactor; the regression already happened once).The suite was mutation-tested pre-merge: 6 line-level mutants (drop
forceStrategy×2, drop the overview guard ×2,> 0n→>= 0n×2) all killed by exactly the intended assertion. The existinguseSignSpendBundle.test.tsxforced-routing contract covers the layer this suite mocks.Gate: typecheck clean, full jest suite green, prettier clean.
Known residual (pre-existing, deliberately not touched)
A loaded overview with
balance: nullbut real positive Rain spending power would still read as0nand hit the server-side rejection. Not covered becausebalance: nullnormally means no collateral contract — extending the guard to!overview.balancewould block legitimate no-collateral cancels. Needs a backend-truth check if it ever shows up in prod.Base / back-merge
Main-based hotfix (prod users are blocked now; dev is 88 commits ahead and not ready to promote). Supersedes #2570 (dev-based, closed). Back-merge note: dev has i18n-ified these modals — the closed #2570 branch (
fix/card-lock-force-collateral-only) holds the exact i18n variant (incl. thecard.errors.cardDetailsLoadingkey in 3 locales) to resolve the modal + test conflicts when main back-merges into dev.Verification after deploy
PostHog:
card_withdraw_attemptedwithflow=sign-onlymust never reportstrategy=smart-onlyagain;card_lock_failedwith "Unexpected withdrawal strategy" must stop. Then reply to the affected users' tickets.Screenshots
Summary by CodeRabbit