Skip to content

chore: backmerge withdrawal fee safeguards into dev (TASK-22662) - #3229

Merged
jjramirezn merged 6 commits into
devfrom
codex/TASK-22662-backmerge-20260917
Sep 17, 2026
Merged

jjramirezn merged 6 commits into
devfrom
codex/TASK-22662-backmerge-20260917

Conversation

@jjramirezn

@jjramirezn jjramirezn commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Backmerge production PR #3224 into dev. Withdrawal confirmations show the quoted network fee, reject fees that consume the withdrawal, preserve record-only retries, and enforce Solana's $1 floor.

Task

TASK-22662 — continuation of the main-to-dev backmerge work.
Source fix: #3224.

Integration and risk

Main head: c8ea1c49fa8dca71be50178b60fc0dbf7bb996a3. The merge is conflict-free. This preserves dev changes and adds no behavior beyond production. The changed withdrawal gates affect money paths. No backend contract change or migration.

Validation

Full local unit suite: 628 suites and 7,766 tests passed; seven tests skipped. Typecheck and repository formatting passed after syncing dev. The production build passed before the final dev sync; full tests and typecheck were repeated after the sync. CI and current-head automated review must pass before merge.

Visual evidence and documentation

Production screenshots and regression evidence: #3224. Fresh dev screenshots: none; this is an unchanged backmerge of the shipped implementation and its tests. Review the source screenshots alongside the dev integration.
Product facts already reflect the production change. Source PR #3224 records separate customer-content follow-ups for fee statements; this backmerge does not include content changes.

Existing documentation follow-up, verified against mono origin/main (9d76c2da): customer pages under content/help/{fees-pricing,withdraw-crypto}, content/withdraw/{ethereum,tron,solana}, content/pricing, and content/supported-networks still contain blanket free-withdrawal claims. Terms §7.1 (content/legal/terms/en.md) still promises blockchain fee coverage. Product sources already reflect the exceptions. Content and legal-owner edits remain separate from this backmerge.

Accepted follow-ups from current-head review

Chip, Kimi, and Claude reported no blocking findings at ee2547b13351ab40da65fbbb32c93eb80c12e815. Preserve the shipped implementation in this merge-only PR. The inherited English-only fee blocker needs a separate localization fix. Product minimum summaries in product/networks.md and product/send-links.md also need Solana's $1 floor. These follow-ups, plus the existing customer/legal wording debt, are recorded on TASK-22662. They are not fixed by this PR.

abalinda and others added 6 commits September 17, 2026 10:28
Peanut stops sponsoring the withdrawal fee on Ethereum, Tron and Solana, and
Rhino enables the charge on their account. The confirm screen already reads the
fee from Rhino's authenticated quote, so the number appears on its own the
moment Rhino starts charging — and disappears again if a network goes back to
being sponsored. Nothing in the client decides which networks charge.

What this adds is the part the screen was getting wrong either way:

The "i" beside Network fee now says fees differ by network and names one that
is free, so the row is where a user learns that picking another network is an
option. The charged variant also stops claiming the fee is "already included in
the amount you pay" — a withdrawal is quoted pay-mode, so the fee comes out of
what the recipient receives, never on top of what the sender pays.

A quote whose fee takes the whole amount is now blocked. Rhino's route minimum
exists to stop the bridge rejecting a small deposit, not to keep the fee below
the amount, so a small withdrawal to an expensive network could otherwise be
confirmed with nothing left to deliver.
Four holes, all on the same guard.

The gate measured the fee against `?amount=`, which stays editable after
review, while the broadcast spends the amount pinned to the charge. Raising the
URL amount cleared the gate and then moved the original one, delivering
nothing. It reads the pinned amount now, and the spend re-checks the fee
immediately before broadcasting — a render-time value cannot guard a click that
happens later.

Retry sits on its own branch and carried none of the gates, so a failed send
was a way past them. It now refuses for the same reasons the confirm button
does. That hole predates the fee: it already let a retry through the Rhino
route minimum, which strands the deposit at the SDA.

Solana's floor is $1. Rhino still accepts $0.50, but a delivery Peanut no
longer sponsors costs about that much, so anything under a dollar arrives as
dust. product/networks.md said $1 already; nothing enforced it.

A failed quote no longer explains itself. The row shows a dash, and both
tooltip strings are untrue there — one promises free delivery, the other
describes a fee nobody quoted.
The gates I just put on Retry also caught the one state where Retry is not
trying to spend anything. When the on-chain leg lands and only recordPayment
fails, the page keeps executedSpendRef so Retry replays the bookkeeping and
never re-broadcasts — but the wallet has emptied by then, so the original spend
reads as unaffordable and the balance gate disabled the only recovery. A
full-balance withdrawal hits this every time, and the charge is left PENDING
and missing from Activity with no way back.

The gates now step aside once the spend has landed, which is what
handleConfirmWithdrawal already does for the amount and fee checks. Before the
spend they hold exactly as they did.
…-from-quote

feat(withdraw): show the network fee Rhino quotes for the delivery
@notion-workspace

Copy link
Copy Markdown

@vercel

vercel Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 17, 2026 11:19am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 8033.98 → 8035.61 (+1.63)
Findings: +1 net (+22 new, -21 resolved)

🆕 New findings (22)

  • critical complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx — CC 149, MI 48.32, SLOC 628
  • critical complexity — src/features/withdraw/views/ConfirmWithdrawView.tsx — CC 54, MI 55.9, SLOC 72
  • high hotspot — src/app/(mobile-ui)/withdraw/crypto/page.tsx — 79 commits, +1095/-480 lines since 6 months ago
  • high method-complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx:477 — CC 41 SLOC 184
  • high method-complexity — src/features/withdraw/views/ConfirmWithdrawView.tsx:87 — ConfirmWithdrawView CC 41 SLOC 53
  • medium react-long-component — src/app/(mobile-ui)/withdraw/crypto/page.tsx:55 — WithdrawCryptoPage is 975 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:55 — WithdrawCryptoPage: MDD 242.9 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:55 — WithdrawCryptoPage: DLT 92 (calls 92 distinct functions — high context load)
  • medium high-mdd — src/features/withdraw/views/ConfirmWithdrawView.tsx:87 — ConfirmWithdrawView: MDD 91.7 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:477 — : MDD 40.8 (uses across many lines from declarations)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:477 — : DLT 32 (calls 32 distinct functions — high context load)
  • medium high-dlt — src/app/(mobile-ui)/withdraw/crypto/page.tsx:303 — : DLT 30 (calls 30 distinct functions — high context load)
  • medium method-complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx:55 — WithdrawCryptoPage CC 26 SLOC 197
  • medium method-complexity — src/app/(mobile-ui)/withdraw/crypto/page.tsx:303 — CC 26 SLOC 115
  • medium high-mdd — src/app/(mobile-ui)/withdraw/crypto/page.tsx:303 — : MDD 25.3 (uses across many lines from declarations)
  • medium complexity — src/utils/cross-chain-fee.utils.ts — CC 18, MI 65.08, SLOC 32
  • medium react-effect-derives-state — src/app/(mobile-ui)/withdraw/crypto/page.tsx:253 — small useEffect that only sets state from deps
  • low high-dlt — src/features/withdraw/views/ConfirmWithdrawView.tsx:87 — ConfirmWithdrawView: DLT 16 (calls 16 distinct functions — high context load)
  • low missing-return-type — src/app/(mobile-ui)/withdraw/crypto/page.tsx:55 — WithdrawCryptoPage: exported fn missing return type annotation
  • low react-useless-memo — src/app/(mobile-ui)/withdraw/crypto/page.tsx:214 — useMemo over primitive expression: isSendingTx || isRecording

…and 2 more.

✅ Resolved (21)

  • src/app/(mobile-ui)/withdraw/crypto/page.tsx — CC 145, MI 48.65, SLOC 612
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx — 76 commits, +1046/-466 lines since 6 months ago
  • src/features/withdraw/views/ConfirmWithdrawView.tsx — CC 49, MI 56.08, SLOC 72
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:473 — CC 39 SLOC 178
  • src/features/withdraw/views/ConfirmWithdrawView.tsx:80 — ConfirmWithdrawView CC 36 SLOC 53
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:51 — WithdrawCryptoPage is 944 lines — split it
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:51 — WithdrawCryptoPage: MDD 233.9 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:51 — WithdrawCryptoPage: DLT 91 (calls 91 distinct functions — high context load)
  • src/features/withdraw/views/ConfirmWithdrawView.tsx:80 — ConfirmWithdrawView: MDD 87.4 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:473 — : MDD 40.8 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:299 — : DLT 30 (calls 30 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:473 — : DLT 30 (calls 30 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:299 — CC 26 SLOC 115
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:51 — WithdrawCryptoPage CC 25 SLOC 196
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:299 — : MDD 25.3 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:249 — small useEffect that only sets state from deps
  • src/features/withdraw/views/ConfirmWithdrawView.tsx:80 — ConfirmWithdrawView: DLT 16 (calls 16 distinct functions — high context load)
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:51 — WithdrawCryptoPage: exported fn missing return type annotation
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:210 — useMemo over primitive expression: isSendingTx || isRecording
  • src/app/(mobile-ui)/withdraw/crypto/page.tsx:23 — import * as — prefer named imports

…and 1 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/app/(mobile-ui)/withdraw/crypto/page.tsx 24.2 24.8 +0.6
src/features/withdraw/views/ConfirmWithdrawView.tsx 8.6 9.1 +0.5
src/utils/cross-chain-fee.utils.ts 4.4 4.9 +0.5

@jjramirezn

Copy link
Copy Markdown
Contributor Author

/chip review

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • ✅ unit: 7773 ran, 0 failed, 0 skipped, 3.1m

📊 Coverage (unit)

metric %
statements 79.3%
branches 66.9%
functions 73.8%
lines 80.4%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
3.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@chip-peanut-bot chip-peanut-bot 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.

Chip review — no blocking findings — this is not an approval

The fee safeguards match the quote contract and product schedule, but the new proactive fee blocker bypasses localization.

Findings

  • MINOR · src/app/(mobile-ui)/withdraw/crypto/page.tsx:885 · Localize the new fee-blocking message
    With the app in es-419 or pt-BR, a cross-chain quote whose fee consumes the withdrawal enters this branch and shows this hardcoded English blocker, even though the same fee failure already has localized strings. Move this message behind t(...) (adding placeholders for network and fee if those details should remain) so supported locales do not fall back to English.

  • MINOR · src/utils/cross-chain-fee.utils.ts:71 · [claude-opus] Solana's $1 floor is missing from the product-truth summary lines that support reads
    This PR makes the app enforce a $1 floor on Solana (SOLANA_MIN_WITHDRAW_USD, keyed as solana in CHAIN_MIN_WITHDRAW_USD; enforced at charge creation in src/app/(mobile-ui)/withdraw/crypto/page.tsx:345 and for Rhino-bridged link claims via src/utils/claim-min-guard.ts:22).

The code is right — /home/chip/mono/product/networks.md:34 already says solana minimum_withdrawal: "$1.00", and the fee entry ($0.50 + 0.07%, unsponsored since 2026-09-16) is exactly why the floor exists. But three places in product truth still enumerate the enforced minimums as $0.50/$5/$10 with no Solana entry, and those are the lines a support agent or a generated help page reads:

  • product/networks.md:7 — "Crypto withdrawal minimums are per network, as the app enforces them ($0.50 default; Ethereum $5; Tron $10)"
  • product/networks.md:363 — limits table row "Crypto send (any other network) | Per network ($0.50 default; Ethereum $5; Tron $10)"
  • product/send-links.md:25 — "Floor is the destination network's: $0.50 default, $5 Ethereum, $10 Tron (see networks.md)" — and claim-min-guard.ts uses the same table, so Solana link claims now floor at $1 too.

Effect: a user is told $0.50 is enough for a Solana withdrawal or link claim and the app rejects it. Fix in mono (update-content skill, not this PR): add Solana $1 to all three enumerations so they agree with networks.md:34.

Checked clean

  • Exact head, base, trusted author, and detached worktree identity
  • Quoted fee, pay amount, receive amount, expiry, and no-broadcast guards against the backend Rhino quote contract
  • Solana, Ethereum, and Tron fee/minimum behavior against canonical product network and pricing sources
  • Record-only retry behavior after funds move and CTA gate coverage
  • Relevant unit, typecheck, lint, format, screen-test, and authorship checks were green; visual/deploy checks were still running

Security review by moonshotai/kimi-k3: 0 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.

Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: ee2547b13351 · Context: repo, product, api-sibling · Took 10m

Comment thread src/app/(mobile-ui)/withdraw/crypto/page.tsx
@jjramirezn
jjramirezn marked this pull request as ready for review September 17, 2026 11:27
@jjramirezn
jjramirezn merged commit 0b76361 into dev Sep 17, 2026
33 of 35 checks passed

This branch was successfully deployed

1 active deployment
Preview — ee2547b1 Deployed Sep 17, 2026 by vercel[bot]
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