Skip to content

fix: block crypto withdrawals below Rhino route minimums#2458

Open
abalinda wants to merge 6 commits into
mainfrom
fix/rhino-min-withdrawal-guard
Open

fix: block crypto withdrawals below Rhino route minimums#2458
abalinda wants to merge 6 commits into
mainfrom
fix/rhino-min-withdrawal-guard

Conversation

@abalinda

@abalinda abalinda commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Rhino rejects bridge-SDA deposits below the route minimum (UNDER_MIN webhook) and parks the funds — no auto-refund (2026-07-15 incident: $2.50 → Ethereum against a $5 min, stranded at the SDA while the intent completed off the source leg; a second case from 05-21 sat unnoticed for two months). Prod's only net is a confirm-time CTA check that fires after the request/charge already exist.

This PR adds the missing early guard (FE), scoped to where Rhino is actually involved — same-chain Arbitrum USDC withdrawals are direct transfers with no minimum (parity with send-via-link and P2P sends), and the amount step is untouched (no floor, no warning):

  • Chain-aware hard block in handleSetupReview — at the network/coin selection step, before any request/charge is created, for every destination except same-chain Arbitrum USDC: Ethereum $5, Tron $10 (keyed by the picker's real 'tron' slug), $0.50 default. Map in cross-chain-fee.utils.ts, verified against Rhino's getSupportedTokens API 2026-07-21.
  • Error recovery: chain-scoped errors clear when the destination chain changes (previously the block's own "pick a different network" advice dead-ended the Review button).
  • USD-pinned input compared directly (CodeRabbit): the amount page's min/balance gates no longer scale by the app-wide token price — a stale non-USD price could loosen or false-trip the minimums (fixes the same latent bug for bank minimums).

Design notes / accepted trade-offs

  • Static mins + the existing live-quote confirm check back each other up (static = earliest possible + pre-charge, live = per-route truth). Rhino min drift-monitoring extension: follow-up.
  • Same-chain non-USDC withdrawals (if/when offered) are treated as Rhino-routed and get the $0.50 floor — conservative by mechanism.
  • No amount-step warning card (product call) — users learn the minimum at network selection, where it's actionable.
  • minDisplay formatting appears twice (page + review guard) — left inline, 1 expression × 2 sites.

Out of scope (follow-ups)

  • BE guard in peanut-api-ts /rhino/sda-transfer (payAmount vs minDepositLimitUsd).
  • DEPOSIT_ADDRESS_BRIDGE_REJECTED → intent flag + alert (currently updates an in-memory store only).
  • pay-request / claim-xchain cross-chain flows fund SDAs unguarded (amounts fixed by requester/link, not the payer).

Risks

  • FE-only; no API/contract changes. Hotfix to mainback-merge debt main→dev (dev's crypto-min line will conflict trivially — keep this branch's version).
  • Same-chain sub-$0.50 withdrawals remain allowed (deliberate — direct transfer, no Rhino).

QA

  • Unit: cross-chain-fee.utils.test.ts (mins map incl. real-registry anti-drift assertions), withdraw-states.test.tsx (no amount-step floor at $0.4, bank minimums untouched).
  • Visual (Vercel preview): $2 → Ethereum → Review blocked pre-charge with the per-network message; switch to Base → error clears, proceeds; $0.4 → same-chain Arbitrum USDC goes through.
  • Full suite: 145/145 suites, 2001 tests green.

🤖 Generated with Claude Code

abalinda added 3 commits July 21, 2026 13:37
Rhino rejects bridge deposits under the route minimum (UNDER_MIN) and
parks the funds at the SDA with no auto-refund — a $2.50 withdrawal to
Ethereum (min $5) sat stranded since 07-15 with the intent COMPLETED.
Floor every crypto withdrawal at $0.50 on the amount step (red, blocking),
warn $0.50–$5 amounts that Ethereum needs $5, and hard-block chain-aware
minimums (ETH $5 / Tron $10) at review time before any charge exists.
The withdraw chain picker's NON_EVM_WITHDRAW_CHAINS entry carries
chainId 'tron', so the '728126428' map key was unreachable and Tron
silently degraded to the $0.50 default. Caught by adversarial replay
review before push.
…t tests

Adversarial review findings: (1) the review-time minimum error said 'pick
a different network' but nothing cleared it on chain change, so Review
stayed dead after following the instruction; (2) the Tron test asserted a
hand-typed id no picker record carries — now asserts the REAL registry
records; (3) the Ethereum notice read the app-wide token price on a
USD-pinned input; (4) stale 'no minimum' comment contradicted the new
floor; (5) notice copy works under the Send header too.
@vercel

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

Request Review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Crypto withdrawals now use USD-pinned amount validation, chain-specific minimums, minimum guidance, pre-setup enforcement, stale-error clearing on chain changes, and expanded regression coverage.

Changes

Crypto withdrawal minimums

Layer / File(s) Summary
Chain minimum contract and coverage
src/utils/cross-chain-fee.utils.ts, src/utils/cross-chain-fee.utils.test.ts
Defines default, Ethereum, and Tron minimums, supports numeric and slug chain identifiers, and tests chain-specific and fallback behavior.
Withdrawal amount guidance
src/app/(mobile-ui)/withdraw/page.tsx, src/components/Withdraw/views/Initial.withdraw.view.tsx, src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Treats entered amounts as USD, displays minimum notices without disabling Continue, clears errors when the chain changes, and covers these states in tests.
Crypto setup minimum enforcement
src/app/(mobile-ui)/withdraw/crypto/page.tsx
Validates the selected chain’s minimum before creating a withdrawal request or charge, except for same-chain USDC transfers.

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

Sequence Diagram(s)

sequenceDiagram
  participant WithdrawPage
  participant WithdrawCryptoPage
  participant MinimumLookup
  participant WithdrawalSetup
  WithdrawPage->>WithdrawPage: Parse entered amount as USD
  WithdrawPage-->>WithdrawCryptoPage: Continue with USD amount
  WithdrawCryptoPage->>MinimumLookup: Get minimum for destination chain
  MinimumLookup-->>WithdrawCryptoPage: Return chain minimum
  WithdrawCryptoPage->>WithdrawalSetup: Create request and charge after validation
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: kushagrasarathe

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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: blocking crypto withdrawals below Rhino route minimums.
✨ 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 fix/rhino-min-withdrawal-guard

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

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6223.44 → 6226.18 (+2.74)
Findings: +2 net (+25 new, -23 resolved)

🆕 New findings (25)

  • critical complexity — src/app/(mobile-ui)/withdraw/page.tsx — CC 124, MI 54.12, SLOC 334
  • critical complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx — CC 83, MI 52.34, SLOC 351
  • high complexity — src/components/Withdraw/views/Initial.withdraw.view.tsx — CC 30, MI 58.4, SLOC 98
  • medium react-long-component — src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage is 548 lines — split it
  • medium react-long-component — src/app/(mobile-ui)/withdraw/page.tsx:27 — WithdrawPage is 453 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/withdraw/page.tsx:27 — WithdrawPage: MDD 108.4 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: MDD 91.1 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: DLT 54 (calls 54 distinct functions — high context load)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/page.tsx:27 — WithdrawPage: DLT 44 (calls 44 distinct functions — high context load)
  • medium method-complexity — src/app/(mobile-ui)/withdraw/page.tsx:27 — WithdrawPage CC 28 SLOC 108
  • medium hotspot — src/app/(mobile-ui)/withdraw/crypto/page.tsx — 27 commits, +288/-173 lines since 6 months ago
  • medium high-mdd — src/components/Withdraw/views/Initial.withdraw.view.tsx:26 — InitialWithdrawView: MDD 27.1 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — : MDD 23.6 (uses across many lines from declarations)
  • medium method-complexity — src/app/(mobile-ui)/withdraw/page.tsx:278 — CC 17 SLOC 55
  • medium method-complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx:167 — CC 16 SLOC 85
  • medium method-complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx:298 — CC 16 SLOC 82
  • medium react-effect-derives-state — src/app/(mobile-ui)/withdraw/page.tsx:181 — useEffect with empty deps + setState — derived state anti-pattern
  • medium react-effect-derives-state — src/components/Withdraw/views/Initial.withdraw.view.tsx:66 — small useEffect that only sets state from deps
  • medium react-effect-derives-state — src/components/Withdraw/views/Initial.withdraw.view.tsx:116 — useEffect with empty deps + setState — derived state anti-pattern
  • low high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:167 — : DLT 18 (calls 18 distinct functions — high context load)

…and 5 more.

✅ Resolved (23)

  • src/app/(mobile-ui)/withdraw/page.tsx — CC 125, MI 53.75, SLOC 339
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx — CC 78, MI 53.1, SLOC 335
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage is 528 lines — split it
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage is 449 lines — split it
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage: MDD 104.5 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: MDD 92.3 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:43 — WithdrawCryptoPage: DLT 52 (calls 52 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage: DLT 45 (calls 45 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/page.tsx:28 — WithdrawPage CC 28 SLOC 111
  • src/components/Withdraw/views/Initial.withdraw.view.tsx — CC 27, MI 58.44, SLOC 88
  • src/components/Withdraw/views/Initial.withdraw.view.tsx:26 — InitialWithdrawView: MDD 27.0 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:278 — : MDD 23.6 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx — 24 commits, +253/-158 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/page.tsx:274 — CC 17 SLOC 55
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:278 — CC 16 SLOC 82
  • src/app/(mobile-ui)/withdraw/page.tsx:178 — useEffect with empty deps + setState — derived state anti-pattern
  • src/components/Withdraw/views/Initial.withdraw.view.tsx:103 — useEffect with empty deps + setState — derived state anti-pattern
  • src/app/(mobile-ui)/withdraw/page.tsx:274 — handleAmountContinue: MDD 17.3 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:167 — : MDD 16.4 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:167 — : DLT 16 (calls 16 distinct functions — high context load)

…and 3 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/app/(mobile-ui)/withdraw/crypto/page.tsx 17.1 17.9 +0.7
src/utils/cross-chain-fee.utils.ts 4.2 4.9 +0.7
src/components/Withdraw/views/Initial.withdraw.view.tsx 10.2 10.8 +0.7
src/app/(mobile-ui)/withdraw/page.tsx 15.2 15.8 +0.6

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2004 ran, 0 failed, 0 skipped, 32.5s

📊 Coverage (unit)

metric %
statements 59.5%
branches 43.3%
functions 48.4%
lines 59.8%
⏱ 10 slowest test cases
time test
3.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.3s 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/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
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.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/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/utils/__tests__/demo-balance.test.ts › keeps a spent-down balance across a cold start within the TTL
0.1s 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`.

@abalinda

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

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

@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/app/`(mobile-ui)/withdraw/page.tsx:
- Around line 452-460: Update the description in the showEthereumMinNotice
InfoCard to avoid claiming the amount works on every other network. Replace that
statement with wording indicating that other networks may have different
withdrawal minimums, while preserving the Ethereum minimum amount interpolation.
- Around line 134-137: Update the withdrawal amount validation around
isCryptoWithdraw so the minimum check compares the raw USD value from
AmountInput directly against MIN_CRYPTO_WITHDRAW_USD. Apply this USD comparison
before any multiplication by selectedTokenData.price, ensuring Continue remains
disabled for values below $0.50 regardless of stale token pricing.
🪄 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: 951e4334-9792-446d-8bfa-1846d3c1f6e4

📥 Commits

Reviewing files that changed from the base of the PR and between 4d23ef9 and da303f6.

📒 Files selected for processing (6)
  • src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx
  • src/app/(mobile-ui)/withdraw/page.tsx
  • src/components/Withdraw/views/Initial.withdraw.view.tsx
  • src/utils/cross-chain-fee.utils.test.ts
  • src/utils/cross-chain-fee.utils.ts

Comment thread src/app/(mobile-ui)/withdraw/page.tsx Outdated
Comment thread src/app/(mobile-ui)/withdraw/page.tsx Outdated
abalinda added 2 commits July 21, 2026 14:12
CodeRabbit major: the amount page's AmountInput is hard-pinned to USD,
but the min/balance gates multiplied by the app-wide selectedTokenData
price — a stale non-USD price could let $0.01 past the $0.50 floor (or
false-block valid amounts). The typed value IS the USD value; compare it
directly. Also: the Ethereum notice no longer overclaims that every
other network accepts the amount (Tron is $10).
Product call (Aleks): same-chain Arbitrum USDC withdrawals are direct
transfers — no Rhino, no minimum — and must keep parity with
send-via-link and P2P sends. Drop the blanket $0.50 amount-step floor;
warn there instead (destination unknown), and enforce the per-network
Rhino minimums at review time only for bridged/swapped routes.
@abalinda

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 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 marked this pull request as ready for review July 21, 2026 12:27
…lection

Product call (Aleks): no yellow warning on the amount page; the
per-network Rhino minimum blocks at review, where the destination is
actually chosen.
@abalinda

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

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

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