Skip to content

fix(card): force collateral-only routing on lock/cancel - #2571

Merged
jjramirezn merged 1 commit into
mainfrom
hotfix/card-lock-force-collateral-only
Jul 29, 2026
Merged

fix(card): force collateral-only routing on lock/cancel#2571
jjramirezn merged 1 commit into
mainfrom
hotfix/card-lock-force-collateral-only

Conversation

@abalinda

@abalinda abalinda commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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-only in 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 passing smartBalance: 0n to signSpend.
  • cb302d35a (2026-06-16): removed the smartBalance input repo-wide and deleted the 0n line from both modals without replacement — routing fell through to computeSpendStrategy, which returns smart-only whenever the live wallet balance covers the amount. Both modals then reject their own artifact and throw.
  • ca79b66a7: added forceStrategy: 'collateral-only' for exactly this need, but wired it only into useReturnExcessCollateral.

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)

  1. LockCardModal / CancelCardModal: pass forceStrategy: 'collateral-only' to signSpend — same pattern as useReturnExcessCollateral. Replaced the stale comment with one stating the actual constraint. The artifact.strategy !== 'collateral-only' throw stays: unreachable at runtime now, but load-bearing for TS narrowing to the rainWithdrawal variant.
  2. Fail closed on unloaded overview (same block, second known failure): rainCentsToUsdcUnits(undefined)0n, so an unresolved useRainCardOverview silently 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.tsx covering both modals (6 tests):

  • signSpend is called with forceStrategy: 'collateral-only' (exact-args assertion — this is the one that catches the next SignSpendBundleInput refactor; the regression already happened once).
  • Unloaded overview → no signing, no backend call, error phase.
  • Zero spending power → proceeds without signing, in both modals (guards the guard).

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 existing useSignSpendBundle.test.tsx forced-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: null but real positive Rain spending power would still read as 0n and hit the server-side rejection. Not covered because balance: null normally means no collateral contract — extending the guard to !overview.balance would 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. the card.errors.cardDetailsLoading key in 3 locales) to resolve the modal + test conflicts when main back-merges into dev.

Verification after deploy

PostHog: card_withdraw_attempted with flow=sign-only must never report strategy=smart-only again; card_lock_failed with "Unexpected withdrawal strategy" must stop. Then reply to the affected users' tickets.

Screenshots

⚠️ NONE — no visual or layout change; the only new user-visible surface is the transient "Card details are still loading" error copy, which requires an unresolved overview query mid-slide and is not cheaply reproducible in the harness. Reviewer flies blind on that one string by design.

Summary by CodeRabbit

  • Bug Fixes
    • Improved card locking and cancellation reliability when card details are still loading.
    • Prevented incomplete withdrawal requests from being submitted while spending power data is unavailable.
    • Updated withdrawal handling to ensure collateral is returned through the correct route.
    • Preserved expected behavior when spending power is zero, allowing the lock or cancellation action to proceed without an unnecessary withdrawal.

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).
@vercel

vercel Bot commented Jul 29, 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 29, 2026 2:15pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 80a7f219-9043-462b-898d-cded61e9a412

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Lock 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.

Changes

Card collateral withdrawal routing

Layer / File(s) Summary
Modal withdrawal routing
src/components/Card/CancelCardModal.tsx, src/components/Card/LockCardModal.tsx
Both flows reject attempts without loaded overview data and pass forceStrategy: 'collateral-only' to signSpend.
Routing regression tests
src/components/Card/__tests__/LockCardModal.test.tsx
Mocks and tests verify forced routing, fail-closed behavior, zero-spending-power handling, and lock/cancel API payloads.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: hugo0

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed It clearly summarizes the main change: forcing collateral-only routing for card lock/cancel.
Linked Issues check ✅ Passed The code changes match the issue's core requirements: force collateral-only routing, fail closed on unloaded overview, and add regression tests.
Out of Scope Changes check ✅ Passed The modal logic and tests stay focused on the reported card routing regression and unloading guard.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hotfix/card-lock-force-collateral-only

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

@abalinda
abalinda marked this pull request as ready for review July 29, 2026 14:12
@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2241 ran, 0 failed, 0 skipped, 34.8s

📊 Coverage (unit)

metric %
statements 61.8%
branches 45.3%
functions 51.2%
lines 62.2%
⏱ 10 slowest test cases
time test
3.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.1s 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/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.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/demo-balance.test.ts › debits and floors at zero
0.2s src/utils/__tests__/demo-balance.test.ts › keeps a spent-down balance across a cold start within the TTL
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a wallet older than the TTL on cold start
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6310.83 → 6311.34 (+0.51)
Findings: 0 net (+6 new, -6 resolved)

🆕 New findings (6)

  • medium structural-dup — components/Card/CancelCardModal.tsx:59 — 30 duplicate lines / 114 tokens with components/Card/LockCardModal.tsx:65
  • medium high-mdd — src/components/Card/CancelCardModal.tsx:25 — CancelCardModal: MDD 29.8 (uses across many lines from declarations)
  • medium high-mdd — src/components/Card/LockCardModal.tsx:43 — LockCardModal: MDD 29.5 (uses across many lines from declarations)
  • medium complexity — src/components/Card/CancelCardModal.tsx — CC 24, MI 54.31, SLOC 106
  • medium complexity — src/components/Card/LockCardModal.tsx — CC 22, MI 55.07, SLOC 87
  • low high-mdd — src/components/Card/CancelCardModal.tsx:105 — submitFeedback: MDD 10.6 (uses across many lines from declarations)

✅ Resolved (6)

  • src/components/Card/CancelCardModal.tsx:25 — CancelCardModal: MDD 28.9 (uses across many lines from declarations)
  • src/components/Card/LockCardModal.tsx:43 — LockCardModal: MDD 26.6 (uses across many lines from declarations)
  • components/Card/CancelCardModal.tsx:59 — 23 duplicate lines / 97 tokens with components/Card/LockCardModal.tsx:65
  • src/components/Card/CancelCardModal.tsx — CC 23, MI 54.66, SLOC 103
  • src/components/Card/LockCardModal.tsx — CC 21, MI 55.5, SLOC 84
  • src/components/Card/CancelCardModal.tsx:98 — submitFeedback: MDD 10.6 (uses across many lines from declarations)

@abalinda

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@abalinda
abalinda requested review from jjramirezn and removed request for Hugo0 July 29, 2026 14:19
@abalinda

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jjramirezn
jjramirezn merged commit b151e80 into main Jul 29, 2026
26 of 28 checks passed
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