Skip to content

feat(card): return excess collateral to wallet on limit decrease#2452

Merged
jjramirezn merged 2 commits into
devfrom
feat/return-excess-on-limit-decrease
Jul 20, 2026
Merged

feat(card): return excess collateral to wallet on limit decrease#2452
jjramirezn merged 2 commits into
devfrom
feat/return-excess-on-limit-decrease

Conversation

@jjramirezn

@jjramirezn jjramirezn commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Lowering the per-transaction card limit left the card's collateral above its new target forever: the auto-balancer only tops up (rebalance-decision.ts no-ops when collateral ≥ limit) and nothing on the decrease path ever moved funds back. This PR returns the excess (spendingPower − new limit) to the user's smart wallet right after a limit decrease.

UX contract (deliberate): the user knows nothing about collateral and sees exactly one balance. So the flow is: tap Save changes → limit PATCH → (only if there's ≥ $1 excess) the passkey prompt → done. No explanatory copy, no extra modal. The unified displayed balance is identical before and after (both buckets are folded into one), and the withdrawal intent (AUTO_REBALANCEINTERNAL_TRANSFER) is hidden from history.

Ordering matters: the withdrawal runs only after the limit PATCH succeeds. The auto-balancer targets the DB cardLimit; withdrawing first would race it topping the collateral straight back up.

Failure is non-fatal by design: if the user cancels the passkey or Rain's withdrawal-sig cooldown is active, the limit change has already stuck, the visible balance is unchanged either way (funds stay spendable through normal spend routing), and re-saving the limit retries the return. We capture card_limit_excess_return_failed for observability.

How

  • useSignSpendBundle gains forceStrategy?: 'collateral-only' — live-balance routing would pick smart-only (a self-transfer no-op) whenever the smart wallet covers the amount. Affordability is still enforced against rainSpendingPower; the shared collateral preflight (session-key grant) is unchanged.
  • New useReturnExcessCollateral hook: computes the excess (pure helper computeExcessCollateralCents, $1 threshold mirroring the auto-balancer's REBALANCE_THRESHOLD), signs the forced collateral-only withdrawal to the user's own wallet, submits via the existing /rain/cards/withdraw/submit, then invalidates both balance queries.
  • CardLimitEditModal calls it after a successful perAuthorization PATCH.

No backend changes — prepare/submit, session-key relay, and the history-hiding all exist.

Risks / breaking changes

  • None cross-repo. Pure FE; uses existing endpoints.
  • forceStrategy is additive/optional — all existing signSpend callers are untouched (default path identical).
  • Worst case on withdrawal failure = exact status quo before this PR (excess stays in collateral, still spendable).

Design notes / accepted trade-offs

  • $1 minimum excess — mirrors the auto-balancer threshold; avoids charging a passkey tap + Rain's 5-min per-user sig cooldown for cents.
  • Silent failure — deliberate per the one-balance philosophy: from the user's view nothing failed (limit saved, balance unchanged). Tracked via PostHog.
  • In-transit top-ups ignored — a smart→collateral top-up in flight when the limit is lowered can land after and leave excess again; next limit save (or lock/cancel) returns it. Rare and benign; not worth a wait-loop.
  • Cooldown / stale-approval modals can still surface — a 425 on prepare or a stale session-key 409 on submit dispatches the existing global events, so the user may see the standard cooldown countdown or re-enable prompt. Same behavior as every other collateral flow.
  • No session-key grant yet → the standard grant prompt runs first (two passkey taps total), and the grant endpoint re-arms auto-balance — existing runCollateralSpendPreflight behavior, identical to the lock-card flow.
  • Pre-existing latent bug revealed, not fixed here: LockCardModal/CancelCardModal route their "return everything before lock/cancel" withdrawal through strategy resolution and throw 'Unexpected withdrawal strategy' if the smart wallet balance ≥ spendingPower (routing picks smart-only). They should migrate to forceStrategy: 'collateral-only' — follow-up PR.

QA

  • npm test — 2007/2008 pass; the one failure (add-money-states › "loaded EVM deposit shows QR code and address") reproduces on pristine origin/dev (pre-existing, unrelated).
  • New tests: computeExcessCollateralCents (7 cases) + useReturnExcessCollateral contract tests (skip-below-threshold / forced-strategy args / fail-closed / no-submit-on-sign-fail).
  • Typecheck + prettier clean; build passing locally.
  • Manual sandbox path: card with $200 collateral, lower limit to $50 → passkey prompt → collateral drops to $50, smart wallet +$150, displayed balance unchanged, no history entry.

Summary by CodeRabbit

  • New Features

    • Card limit reductions can now automatically return eligible excess collateral to the wallet.
    • Added safeguards to ensure only valid, sufficiently large excess amounts are returned.
    • Wallet balances and card details refresh after collateral is returned.
  • Bug Fixes

    • Failed collateral returns no longer prevent card limit updates from completing.
  • Tests

    • Added coverage for collateral calculations, wallet readiness, signing failures, and withdrawal submission.

Lowering the per-transaction limit left the card holding more than its
new target: the auto-balancer only ever tops up (rebalance-decision
no-ops when collateral >= limit), so the excess sat in the collateral
contract indefinitely. After the limit PATCH succeeds, sign a FORCED
collateral-only withdrawal of the excess back to the user's smart
wallet - the PATCH-first ordering keeps the auto-balancer from racing
the withdrawal by topping the collateral back up.

UX: the user only sees the passkey prompt. The unified displayed
balance is identical before/after (both buckets are folded into one),
and the intent kind AUTO_REBALANCE maps to INTERNAL_TRANSFER, which
history hides. Failures are non-fatal by design: the limit change
already stuck, and re-saving the limit retries the return.

forceStrategy on useSignSpendBundle exists because live-balance routing
would pick smart-only (a self-transfer no-op) whenever the smart wallet
covers the amount - the exact trap the lock-card flow comments around.
@vercel

vercel Bot commented Jul 20, 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 20, 2026 3:13pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 480ff55e-a3bf-4739-aa65-8546c7898fba

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:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Card limit decreases now calculate eligible excess collateral, optionally return it through a forced collateral-only Rain withdrawal, refresh wallet data, and emit success or failure analytics. The flow is covered by hook and utility tests.

Changes

Excess collateral return flow

Layer / File(s) Summary
Excess collateral calculation
src/utils/balance.utils.ts, src/utils/__tests__/balance.utils.test.ts
Adds thresholded excess-collateral calculation with validation, rounding rules, and unit coverage.
Forced collateral-only withdrawal
src/hooks/wallet/useSignSpendBundle.ts, src/hooks/wallet/useReturnExcessCollateral.ts, src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx
Adds forced collateral-only signing, affordability checks, withdrawal submission, query invalidation, and contract tests for success and failure paths.
Limit update integration and telemetry
src/components/Card/CardLimitEditModal.tsx, src/constants/analytics.consts.ts
Runs excess return after per-authorization limit updates and records returned or failed excess-collateral operations without blocking modal completion.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CardLimitEditModal
  participant useReturnExcessCollateral
  participant useSignSpendBundle
  participant rainApi
  CardLimitEditModal->>useReturnExcessCollateral: returnExcess(newLimitCents)
  useReturnExcessCollateral->>useSignSpendBundle: sign collateral-only withdrawal
  useSignSpendBundle-->>useReturnExcessCollateral: signed withdrawal
  useReturnExcessCollateral->>rainApi: submitWithdrawal
  rainApi-->>useReturnExcessCollateral: withdrawal result
  useReturnExcessCollateral-->>CardLimitEditModal: returned excess cents
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: hugo0, innolope-dev

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: returning excess collateral on card limit decreases.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/return-excess-on-limit-decrease

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

@jjramirezn

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 20, 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.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6223 → 6234.78 (+11.78)
Findings: +3 net (+17 new, -14 resolved)

🆕 New findings (17)

  • high hotspot — src/constants/analytics.consts.ts — 34 commits, +320/-7 lines since 6 months ago
  • high complexity — src/hooks/wallet/useSignSpendBundle.ts — CC 11, MI 44.66, SLOC 182
  • high complexity — src/constants/analytics.consts.ts — CC 1, MI 33.03, SLOC 172
  • medium high-mdd — src/hooks/wallet/useSignSpendBundle.ts:104 — useSignSpendBundle: MDD 54.4 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSignSpendBundle.ts:114 — : MDD 46.5 (uses across many lines from declarations)
  • medium complexity — src/utils/balance.utils.ts — CC 24, MI 62.32, SLOC 73
  • medium structural-dup — hooks/wallet/useSignSpendBundle.ts:272 — 22 duplicate lines / 98 tokens with hooks/wallet/useSpendBundle.ts:275
  • medium complexity — src/components/Card/CardLimitEditModal.tsx — CC 14, MI 54.28, SLOC 78
  • medium complexity — src/hooks/wallet/useReturnExcessCollateral.ts — CC 6, MI 57.21, SLOC 46
  • low high-dlt — src/hooks/wallet/useSignSpendBundle.ts:104 — useSignSpendBundle: DLT 24 (calls 24 distinct functions — high context load)
  • low high-mdd — src/components/Card/CardLimitEditModal.tsx:24 — CardLimitEditModal: MDD 18.7 (uses across many lines from declarations)
  • low high-dlt — src/components/Card/CardLimitEditModal.tsx:24 — CardLimitEditModal: DLT 17 (calls 17 distinct functions — high context load)
  • low high-mdd — src/hooks/wallet/useReturnExcessCollateral.ts:27 — useReturnExcessCollateral: MDD 17.3 (uses across many lines from declarations)
  • low high-dlt — src/hooks/wallet/useSignSpendBundle.ts:114 — : DLT 16 (calls 16 distinct functions — high context load)
  • low structural-dup — hooks/wallet/useSignSpendBundle.ts:257 — 12 duplicate lines / 54 tokens with hooks/wallet/useSpendBundle.ts:246
  • low missing-return-type — src/hooks/wallet/useReturnExcessCollateral.ts:27 — useReturnExcessCollateral: exported fn missing return type annotation
  • low missing-return-type — src/hooks/wallet/useSignSpendBundle.ts:104 — useSignSpendBundle: exported fn missing return type annotation

✅ Resolved (14)

  • src/constants/analytics.consts.ts — 33 commits, +316/-7 lines since 6 months ago
  • src/hooks/wallet/useSignSpendBundle.ts — CC 9, MI 46.09, SLOC 168
  • src/constants/analytics.consts.ts — CC 1, MI 33.17, SLOC 170
  • src/hooks/wallet/useSignSpendBundle.ts:93 — useSignSpendBundle: MDD 47.2 (uses across many lines from declarations)
  • src/hooks/wallet/useSignSpendBundle.ts:103 — : MDD 38.9 (uses across many lines from declarations)
  • hooks/wallet/useSignSpendBundle.ts:234 — 22 duplicate lines / 98 tokens with hooks/wallet/useSpendBundle.ts:275
  • src/utils/balance.utils.ts — CC 17, MI 63.77, SLOC 60
  • src/components/Card/CardLimitEditModal.tsx — CC 12, MI 57.22, SLOC 62
  • src/hooks/wallet/useSignSpendBundle.ts:93 — useSignSpendBundle: DLT 23 (calls 23 distinct functions — high context load)
  • src/components/Card/CardLimitEditModal.tsx:23 — CardLimitEditModal: MDD 14.9 (uses across many lines from declarations)
  • src/components/Card/CardLimitEditModal.tsx:23 — CardLimitEditModal: DLT 15 (calls 15 distinct functions — high context load)
  • src/hooks/wallet/useSignSpendBundle.ts:103 — : DLT 15 (calls 15 distinct functions — high context load)
  • hooks/wallet/useSignSpendBundle.ts:219 — 12 duplicate lines / 54 tokens with hooks/wallet/useSpendBundle.ts:246
  • src/hooks/wallet/useSignSpendBundle.ts:93 — useSignSpendBundle: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/wallet/useReturnExcessCollateral.ts 0.0 9.1 +9.1
src/hooks/wallet/useSignSpendBundle.ts 13.5 14.6 +1.0
src/components/Card/CardLimitEditModal.tsx 8.3 9.3 +1.0
src/utils/balance.utils.ts 7.0 7.6 +0.6

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

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

📊 Coverage (unit)

metric %
statements 59.8%
branches 43.3%
functions 48.6%
lines 60.0%
⏱ 10 slowest test cases
time test
3.7s 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.5s 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/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
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 › resetDemoBalance refills and restarts the TTL window
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a stored balance that has no timestamp (legacy install)
📍 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: 2

🤖 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/useSignSpendBundle.ts`:
- Around line 144-164: Update the forced collateral-only branch in
useSignSpendBundle to capture CARD_WITHDRAW_FAILED analytics before invalidating
the overview query and throwing InsufficientSpendableError, matching the
insufficient-funds handling in resolveSpendStrategy. Preserve the existing
collateral balance check and fail-closed behavior.
- Around line 144-164: Add direct tests for the forced collateral-only branch in
useSignSpendBundle: cover successful signing when rainSpendingPower meets
requiredUsdcAmount, and insufficient funds when it does not. Verify the
insufficient path invalidates RAIN_CARD_OVERVIEW_QUERY_KEY and throws
InsufficientSpendableError, while the success path proceeds without resolving
another strategy.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 894571ac-397f-4dfd-bda5-582a636e6479

📥 Commits

Reviewing files that changed from the base of the PR and between 919cd4b and ca79b66.

📒 Files selected for processing (7)
  • src/components/Card/CardLimitEditModal.tsx
  • src/constants/analytics.consts.ts
  • src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx
  • src/hooks/wallet/useReturnExcessCollateral.ts
  • src/hooks/wallet/useSignSpendBundle.ts
  • src/utils/__tests__/balance.utils.test.ts
  • src/utils/balance.utils.ts

Comment thread src/hooks/wallet/useSignSpendBundle.ts
CodeRabbit review: the forced-path insufficient branch skipped the CARD_WITHDRAW_FAILED capture resolveSpendStrategy emits, leaving a gap in the withdraw funnel for the excess-return flow; and the forced branch had no direct tests.
@jjramirezn
jjramirezn marked this pull request as ready for review July 20, 2026 15:15
@jjramirezn
jjramirezn requested a review from Hugo0 July 20, 2026 15:15
@jjramirezn
jjramirezn merged commit 6c20a70 into dev Jul 20, 2026
18 of 20 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.

1 participant