Skip to content

feat: Solana + Tron withdrawals + chain-expansion polish#2398

Merged
Hugo0 merged 25 commits into
devfrom
feat/solana-tron-withdrawals
Jul 13, 2026
Merged

feat: Solana + Tron withdrawals + chain-expansion polish#2398
Hugo0 merged 25 commits into
devfrom
feat/solana-tron-withdrawals

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Enables Solana (USDC+USDT) and Tron (USDT) as withdraw destinations — both verified against Rhino prod with live quotes + real outflow-SDA creates — plus the polish items from the #2396 rollout review.

How it works: the user-side transaction is unchanged (ERC20 transfer to a Rhino SDA on Arbitrum; Rhino delivers on the destination chain). The work is FE plumbing that was keyed on EVM chainIds:

  • Synthetic non-EVM chain records (nonEvmWithdraw.consts.ts) merged into the token selector in withdraw mode only — send/pay/claim surfaces and URL parsing keep their EVM+wagmi assumptions and never see them
  • Per-family recipient validation: Solana base58 / Tron base58check, family derived from the selected chain, never string-sniffed (every Tron address is also valid base58 in Solana's length range); switching family resets the entered address
  • chainIdToRhinoName handles slugs + EVM numeric ids on the withdraw path

Polish:

  • Linea logo fixed — chain-details ships an ipfs-hosted SVG that next/image refuses (rendered as 'LI' initials); CoinGecko raster via the existing CHAIN_ICON_OVERRIDES mechanism
  • Deposit fee note (~0.1%) — the crypto deposit flow previously showed no fee messaging at all
  • Kaia/Plasma deposit chips annotated 'USDT only' (+ Tempo/Celo/Gnosis annotations) — a USDC deposit on a USDT-only chain has no webhook; this properly retires the accepted-risk from feat: sync Rhino chain support with live catalogs (Avalanche + 8 more withdraw destinations, Tempo deposits) #2396
  • Cross-chain withdraw receipts fall back to the source-chain (Arbitrum) explorer when the destination has no explorer entry — Tempo receipts were linkless (the recorded hash is the Arbitrum tx anyway)
  • Withdraw view shell switched to the mandated flex/gap layout (space-y clipped the CTA on short viewports — Hugo's screenshot)

Risks / breaking changes

  • ⚠️ Deploy order: requires peanut-api-ts#1166 live first — the old destination-RPC delivery probe cannot verify non-EVM deliveries; the Rhino-native verification in fix: close kyc modal btn #1166 can.
  • Non-EVM entries are scoped to the withdraw selector via the existing restrictToRhino flag — verified that claim/send/pay filter by wagmi network ids and can't surface them.
  • CrossChainDestinationInfo.recipientAddress/tokenAddress widened from viem Address to string — the same-chain path (EVM-only by construction) narrows back with a cast; cross-chain tx construction never uses the recipient.

QA

  • Jest: 1,820 passing incl. new addressFamily suite (cross-family rejection, base58 alphabet edge cases)
  • typecheck 0 · prettier ✅ · next build
  • POC proof: quote + SDA create ARBITRUM→SOLANA and ARBITRUM→TRON against Rhino prod (2026-07-11)
  • Pending before ready-for-review: sandbox visual pass (selector entries, logos incl. Linea fix, drawer clipping on 375px, deposit annotations) + staging e2e $1 withdrawal to Solana after BE deploys

jjramirezn and others added 17 commits July 9, 2026 12:33
Prod release SP-150 — dev → main
Mixed spends from unmigrated pre-2025-09-18 accounts always reverted with
'Delegatecall failed': the SDK wraps their userOp in migrateWithCall, which
swaps the root validator v0.0.2->v0.0.3 BEFORE withdrawAsset verifies the
pre-signed (v0.0.2-routed) admin EIP-712 signature via ERC-1271. Proven by
on-chain simulation at the failing block: migration alone succeeds, the
withdrawal alone succeeds, combined they revert. 8 users / ~$1k currently
blocked, and since their wallets are empty they can never organically migrate.

Fix: before a mixed spend on an unmigrated account, fire the migration as a
standalone no-op userOp, then rebuild the kernel client so the admin sig is
signed AND verified under v0.0.3. The client cache is now ref-backed so the
rebuilt client reaches closures captured before the rebuild (grant flow), and
the admin EIP-712 payload is built in exactly one place for both spend paths.
…igration beat

useSignSpendBundle (qr-pay, Manteca withdraw, card cancel/lock refunds) has the
identical trap: it pre-signs the admin EIP-712 and a migration-wrapped userOp
the backend submits later, so unmigrated accounts revert the same way. Gate it
with the same ensureRootValidatorMigrated pre-flight, and swap its two verbatim
EIP-712 blocks for the shared builder (4 copies -> 1 across both hooks).

UX: the migration tap now runs under the existing security-verification overlay
(the same intentional 'Verifying security…' beat the mixed flow already uses),
gated on wrapper accounts only so the common path never flickers.
…learClients effect dep

Base had 2 eslint errors in useSpendBundle (restricted @/interfaces barrel +
unused PEANUT_WALLET_TOKEN_DECIMALS); both are one-liners inside this PR's
diff, so boy-scout them rather than ship a red-on-arrival file. Also adds the
new clearClients callback to its effect deps (stable identity, no behavior
change) so this PR introduces zero new lint findings.
… how bugs ship twice

The migration-ordering bug existed on BOTH spend engines (useSpendBundle and
useSignSpendBundle) precisely because their preflight sequences were parallel
copies — the original fix would have covered one surface and silently missed
qr-pay, Manteca withdrawals, and card cancel/lock refunds. Extract the entire
drift-prone sequence (live-balance routing -> insufficient rejection ->
migration gate -> grant gate) into spendPreflight.ts so the next gate is
physically impossible to add to only one engine. The engines keep only their
legitimately-different execution branches (broadcast vs sign-and-return).

Routing primitives + shared errors move with it; all importers updated, no
re-export shims. 8 new tests lock the orchestration order and gating.
…ndings

The big one (CONFIRMED): ERC-4337 semantics — a REVERTED userOp still yields
receipt.status='success' on the bundle tx, so the gate could declare a failed
migration successful and walk straight back into the 'Delegatecall failed'
revert. Migration is now verified against ground truth (the on-chain root
validator, short poll for propagation), never the receipt; a null receipt
(timeout) heals if the op actually landed. A reverted bundle throws a
deterministic KernelMigrationFailedError (no 'please retry' framing), and the
rebuilt client is asserted to be off the wrapper (lagging public RPC would
otherwise hand back a v0.0.2-signing wrapper) with one grace retry.

Cache races (CONFIRMED): builds now carry a per-chain monotonic sequence — a
stale build that resolves after logout or after a rebuild superseded it can no
longer clobber the cache (previous user's client post-logout / pre-migration
wrapper post-rebuild), and .finally only clears the dedupe slot it owns.

Also: fail-closed overview check hoisted ABOVE the migration tap (never charge
a passkey tap for a doomed flow), loadingState reset on the receipt-timeout
path, failure-capture parity for the sign-only engine (card_withdraw_failed
now emitted), and the card-recovery page's fourth copy of the Rain EIP-712
payload migrated to the shared builder.

Declined finding: 'permanent per-spend rebuild tax' — createKernelMigrationAccount
returns a plain (non-wrapper) v0.0.3 account once the chain shows the account
migrated, so the gate becomes a no-op after the first successful rebuild.
card-recovery's orphaned Address import was OURS (the typed-data builder swap
made it unused). The rest are pre-existing mechanical fixes in files this PR
already touches: qr-pay's four dead imports + the '@/context' barrel (test
mock repointed to the specific file), useWallet's '@/interfaces' barrel.
Deliberately NOT touched: the two 'any's and the unused transactionUsd —
those need type judgment, not cleanup, and belong to the backlog.
…xed-spend

fix: migrate root validator before mixed spends sign the Rain admin sig
/api/send-discord-notification forwarded arbitrary POST body.message
to DISCORD_WEBHOOK_URL with no auth, rate limit, or mention filtering —
actively abused to post @everyone into our Discord. Zero callers exist;
all real Discord alerting is server-side in peanut-api-ts.
…d-relay

fix: remove unauthenticated public Discord relay endpoint
Rhino's live bridge config supports 9 EVM withdraw destinations our
chainId→name mapping never listed, so users picking them got
'Unsupported Rhino chain mapping' (Avalanche was user-visible today:
it's in chain-details.json but had no mapping entry). Each addition
was verified against Rhino prod (getBridgeConfig status=enabled +
real ARBITRUM→X quote + outflow SDA create): AVALANCHE, HYPEREVM,
INK, KATANA, LINEA, MANTLE, PLASMA, STABLE, TEMPO. SCROLL removed —
Rhino disabled it and quotes now 400. KAIA/OPBNB excluded: SDA create
rejects their tokenOut (DepositAddressTokenOutNotSupported).

Deposit side: TEMPO added to SUPPORTED_EVM_CHAINS (SDA catalog lists
USDC+USDT, meeting the REQUIRED_TOKENS bar). KAIA/PLASMA stay off the
deposit list — USDT-only, and the EVM-family token list advertises
USDC, which Rhino would silently swallow on those chains.

chain-details/token-details entries use Rhino's authoritative token
addresses (getBridgeConfig) and chainid.network registry metadata;
USDT-only routes (PLASMA, STABLE) expose only USDT so the token
selector can't offer an unbridgeable pair.
The withdraw destination list is gated by
RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN ∩ the wagmi source-network
list — so the chainId→name mapping alone never surfaced the new
chains. Extend the gate with the 9 verified destinations (USDT-only
for Plasma/Stable), and derive the withdraw list from the gate's own
keys instead of the wagmi list: destinations need no wallet
connection or balance reads, and several deliverable chains
(Avalanche, Linea, Ink…) are intentionally not source chains.

Also exclude Scroll from the source selector: Rhino disabled it and
it isn't an SDA deposit chain, so every cross-chain route from it
dead-ends (completes the mapping removal in the previous commit).
CodeRabbit catch: with the withdraw list now derived from the Rhino
gate, the search path still scanned the wagmi source list — so
searching 'USDT' before picking a network omitted destination-only
chains (Linea, Avalanche, …).
Both are in Rhino's live SDA catalog (USDT-only) and the backend
already provisions inflow SDAs accepting them — only the FE list hid
them. The deposit UI advertises tokens per EVM family (incl. USDC),
so a USDC deposit on these chains relies on Rhino support to return
it — accepted risk (Hugo, 2026-07-10); per-chain token gating is the
proper fix, tracked as a follow-up.
…og-sync

feat: sync Rhino chain support with live catalogs (Avalanche + 8 more withdraw destinations, Tempo deposits)
Rhino delivers to Solana (USDC+USDT) and Tron (USDT) — verified with
live quotes and outflow-SDA creates. The user-side tx is unchanged (an
ERC20 transfer to the SDA on Arbitrum); what was missing was FE
plumbing keyed on EVM chainIds:
- synthetic non-EVM chain records merged into the withdraw selector
  ONLY (send/pay/claim keep their EVM+wagmi assumptions)
- per-family recipient validation (base58 / base58check), family from
  the SELECTED chain — never string-sniffed (every Tron address is
  also valid base58 in Solana's length range)
- chainIdToRhinoName handles slugs + EVM ids for the withdraw path

Polish from the #2396 rollout review:
- Linea logo: chain-details ships an ipfs SVG that next/image refuses
  → CHAIN_ICON_OVERRIDES raster (the Arbitrum mechanism)
- deposit fee note (~0.1%) — the crypto deposit flow showed no fee
  messaging at all
- Kaia/Plasma deposit chips annotated 'USDT only' (a USDC deposit
  there is Rhino-unrecoverable-by-webhook; removes the accepted-risk)
- cross-chain withdraw receipts: fall back to the source-chain
  explorer when the destination has none (Tempo receipts were
  linkless; the recorded hash is the Arbitrum tx anyway)
- withdraw view outer shell to flex/gap per the page-layout rules
  (space-y clipped the CTA on short viewports)

Depends on peanut-api-ts#1166 (Rhino-native delivery verification)
deploying first — the old destination-RPC probe can't verify non-EVM
deliveries.
@vercel

vercel Bot commented Jul 11, 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 13, 2026 6:53am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f1c2e0bb-1bbd-4636-82d4-119e4dc9fb0c

📥 Commits

Reviewing files that changed from the base of the PR and between ea63b6c and bd5af5f.

📒 Files selected for processing (12)
  • src/components/Global/TokenSelector/TokenSelector.consts.ts
  • src/components/TransactionDetails/transactionTransformer.ts
  • src/constants/__tests__/chainRegistry.test.ts
  • src/constants/__tests__/nonEvmLeak.test.ts
  • src/constants/chainRegistry.consts.ts
  • src/constants/rhino.consts.ts
  • src/context/tokenSelector.context.tsx
  • src/features/payments/shared/hooks/useCrossChainTransfer.ts
  • src/hooks/__tests__/useChainRollout.test.tsx
  • src/hooks/useChainRollout.ts
  • src/lib/validation/addressFamily.ts
  • src/utils/featureFlag.utils.ts

Walkthrough

The PR centralizes spend preflight, migration-aware kernel client rebuilding, and Rain typed-data signing. It adds registry-driven chain and token support, rollout-gated EVM networks, Solana/Tron recipient handling, related UI updates, and supporting payment and transaction-flow fixes.

Changes

Spend preflight and migration

Layer / File(s) Summary
Typed-data signing and kernel migration
src/utils/*, src/context/kernelClient.context.tsx, src/app/(mobile-ui)/card-recovery/page.tsx
Rain withdrawal payload construction and root-validator migration handling are centralized, tested, and integrated with rebuilt kernel clients.
Shared spend routing and collateral preflight
src/hooks/wallet/spendPreflight.ts, src/hooks/wallet/__tests__/spendPreflight.test.ts
Spend strategy resolution, affordability checks, migration gating, and session-key grant sequencing are centralized.
Spend and sign-only hook integration
src/hooks/wallet/useSpendBundle.ts, src/hooks/wallet/useSignSpendBundle.ts, src/hooks/wallet/useSendMoney.ts
Spend hooks use shared preflight results, rebuilt clients, and shared error types for signing and broadcast flows.

Non-EVM withdrawals and chain rollout

Layer / File(s) Summary
Chain and token catalogs
src/constants/chainRegistry.consts.ts, src/constants/rhino.consts.ts, src/constants/*-details.json, src/components/Global/TokenSelector/TokenSelector.consts.ts
Chain metadata, token metadata, Rhino mappings, non-EVM records, and derived support lists are expanded or made registry-driven.
Feature-flag rollout and network selection
src/hooks/useFeatureFlag.ts, src/hooks/useChainRollout.ts, src/components/AddMoney/..., src/components/Global/TokenSelector/*
Network chips, counts, token searches, and selectable destinations are filtered by rollout status.
Non-EVM address and transfer handling
src/lib/validation/*, src/components/Global/GeneralRecipientInput/*, src/components/Withdraw/..., src/features/payments/shared/hooks/useCrossChainTransfer.ts, src/services/rhino-sda.ts
Recipient validation and transfer data support Solana and Tron address and token formats.

Supporting integrations

Layer / File(s) Summary
Payment, card, transaction, and loading integrations
src/app/(mobile-ui)/qr-pay/*, src/hooks/useZeroDev.ts, src/components/Card/*, src/components/TransactionDetails/*
Context mocks and imports are aligned, failed UserOps reset loading state, shared spend errors are rewired, and explorer links use the wallet chain.
Deposit notices, icons, and payment configuration
src/components/AddMoney/views/CryptoDeposit.view.tsx, src/app/actions/supported-chains.ts, src/app/api/send-discord-notification/route.ts
A bridging-fee notice and Linea icon override are added, while the Discord notification route implementation is removed.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately highlights the main change: Solana/Tron withdrawals plus related chain-expansion polish.
Description check ✅ Passed The description matches the PR scope, covering non-EVM withdrawals, validation, rollout gating, and the listed polish items.
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.

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

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6129.56 → 6129.56 (0)
Findings: 0 net (+0 new, -0 resolved)

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1837 ran, 0 failed, 0 skipped, 29.7s

📊 Coverage (unit)

metric %
statements 57.9%
branches 42.0%
functions 47.1%
lines 58.0%
⏱ 10 slowest test cases
time test
4.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in updateUserById
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in updateUserById 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.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address
0.1s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Perk claim in progress shows disabled button + progress
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address in lowercase
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address with surrounding spaces
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@Hugo0

Hugo0 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

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

Marketing wants to launch the new chains one at a time with a fuss
(Konrad). One PostHog flag per chain — toggling in the PostHog UI
enables/disables a chain on prod instantly, no deploy. Staging/preview/
local bypass the flags entirely (QA tests before launch). Fail-closed
on prod: if PostHog is unavailable a gated chain stays hidden — a
rollout gate must never fail into 'launched'. Legacy chains are
unflagged and always on.
…domain wrapper

The team-facing concept is the generic primitive (reactive PostHog
flag read with explicit per-feature failure semantics); chain rollout
keeps a named wrapper because isChainRolledOut('solana') reads better
at call sites than a raw flag string. New features use useFeatureFlag
directly.

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/AddMoney/components/ChooseNetworkDrawer.tsx (1)

33-58: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

EVM network count no longer matches the filtered chip list.

Line 37's ${SUPPORTED_EVM_CHAINS.length} Networks - 1 Address still counts the full unfiltered array, while the chips below are now .filter(isChainRolledOut). Once a chain is gated off in prod, the header count will overstate what's actually shown.

💡 Proposed fix
+                            const evmChains = SUPPORTED_EVM_CHAINS.filter(isChainRolledOut)
                             <ActionListCard
                                 title="EVM"
-                                description={`${SUPPORTED_EVM_CHAINS.length} Networks - 1 Address`}
+                                description={`${evmChains.length} Networks - 1 Address`}

And reuse evmChains in the .map() below instead of recomputing the filter inline.

🤖 Prompt for 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.

In `@src/components/AddMoney/components/ChooseNetworkDrawer.tsx` around lines 33 -
58, Define a reusable evmChains collection from SUPPORTED_EVM_CHAINS filtered
with isChainRolledOut, use evmChains.length for the ActionListCard network
count, and map over evmChains for the expanded chip list instead of filtering
inline.
🧹 Nitpick comments (4)
src/features/payments/shared/hooks/useCrossChainTransfer.ts (1)

157-163: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Case-insensitive comparison is wrong for base58 (Solana/Tron) addresses.

Base58 is case-sensitive (unlike EVM hex, where lowercasing is standard practice for checksum-agnostic comparison). Lowercasing both sides before comparing t.address against tokenAddress could cause a false match if a future entry differs from another only by letter case. Harmless today with the current fixed 3-token list, but worth using exact string equality here to avoid a latent bug as more non-EVM tokens are added.

♻️ Suggested fix
     const nonEvm = NON_EVM_WITHDRAW_CHAINS[String(chainId).toLowerCase()]
     if (nonEvm) {
-        return nonEvm.tokens.find((t) => t.address.toLowerCase() === tokenAddress.toLowerCase())?.symbol.toUpperCase()
+        return nonEvm.tokens.find((t) => t.address === tokenAddress)?.symbol.toUpperCase()
     }
🤖 Prompt for 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.

In `@src/features/payments/shared/hooks/useCrossChainTransfer.ts` around lines 157
- 163, Update inferTokenSymbol’s non-EVM token lookup to compare t.address and
tokenAddress with exact, case-sensitive string equality; preserve the existing
symbol normalization and undefined behavior when no token matches.
src/lib/validation/addressFamily.ts (1)

12-15: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use real address validation for Solana/Tron withdrawals
src/lib/validation/addressFamily.ts: these regexes only enforce shape, so they still accept malformed Solana/Tron strings that fail decoded-byte-length or Base58Check rules. Swap this for a real decode/checksum check before accepting a withdrawal destination.

🤖 Prompt for 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.

In `@src/lib/validation/addressFamily.ts` around lines 12 - 15, Replace the
shape-only SOLANA_ADDRESS_REGEX and TRON_ADDRESS_REGEX checks with real
validation in the address-family validation flow: Base58-decode Solana
destinations and require the expected decoded byte length, and
Base58Check-decode Tron destinations while verifying the checksum and network
format. Ensure withdrawals reject malformed destinations before acceptance,
reusing existing decoding utilities if available.
src/lib/validation/recipient.ts (1)

11-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Non-EVM branch may be unused by production code — see companion comment in GeneralRecipientInput/index.tsx.

GeneralRecipientInput.checkAddress reimplements this exact isValidAddressForFamily branching independently instead of delegating to this function, so this addition appears reachable only from tests unless another caller (e.g. useCrossChainTransfer.ts) invokes it with a non-'evm' family. Also note this branch doesn't trim recipient before validating, unlike the UI's trimmedInput — a latent inconsistency if this path is ever exercised directly. See the follow-up comment in GeneralRecipientInput/index.tsx for the suggested consolidation.

🤖 Prompt for 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.

In `@src/lib/validation/recipient.ts` around lines 11 - 27, The non-EVM validation
in validateAndResolveRecipient is duplicated and may not be used by production
code; consolidate GeneralRecipientInput.checkAddress to delegate to this
function for non-EVM addresses. Ensure recipient is trimmed before validation
and resolution so direct and UI-driven paths apply identical normalization and
address-family checks.
src/components/Global/GeneralRecipientInput/index.tsx (1)

59-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Delegate to validateAndResolveRecipient instead of reimplementing the non-EVM check.

validateAndResolveRecipient in recipient.ts was extended in this same PR with an addressFamily parameter that performs this exact check (validate via isValidAddressForFamily, throw RecipientValidationError on failure, otherwise return an ADDRESS-typed result). This branch reimplements that logic locally instead of calling it, so the two copies can drift (e.g. differing trim behavior, differing error message).

♻️ Proposed consolidation
                 // Non-EVM destination: base58 address or nothing — never IBAN,
                 // US-routing, ENS, or username.
                 if (addressFamily !== 'evm') {
-                    const familyValid = isValidAddressForFamily(trimmedInput, addressFamily)
-                    if (familyValid) {
-                        resolvedAddress.current = trimmedInput
-                    } else {
-                        errorMessage.current = `Invalid ${addressFamily === 'solana' ? 'Solana' : 'Tron'} address`
-                    }
-                    recipientType.current = 'address'
-                    return familyValid
+                    recipientType.current = 'address'
+                    try {
+                        const validation = await validateAndResolveRecipient(trimmedInput, isWithdrawal, addressFamily)
+                        resolvedAddress.current = validation.resolvedAddress
+                        return true
+                    } catch (error: unknown) {
+                        errorMessage.current = (error as Error).message
+                        return false
+                    }
                 }
🤖 Prompt for 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.

In `@src/components/Global/GeneralRecipientInput/index.tsx` around lines 59 - 71,
Replace the local non-EVM validation branch in the recipient input flow with a
call to validateAndResolveRecipient, passing the trimmed input and
addressFamily. Reuse its returned ADDRESS result to update the recipient state,
and handle RecipientValidationError through the existing error-state path
instead of calling isValidAddressForFamily or constructing a separate error
message locally.
🤖 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)/qr-pay/__tests__/qr-pay-states.test.tsx:
- Line 509: Fix the test setup around loadingStateContext by replacing both
local require() bindings with a static import, removing the unused
mockSetLoadingState declaration, and passing that mock to the provider’s
setLoadingState prop if the provider requires it; otherwise remove the mock
entirely.

In `@src/components/TransactionDetails/transactionTransformer.ts`:
- Around line 253-261: The explorer URL selection around baseUrl must use
PEANUT_WALLET_CHAIN for every CRYPTO_WITHDRAW, not only when the destination URL
is missing. Update the logic using intentKindOf(entry) so withdrawals directly
select the source-chain explorer, while preserving destination-based explorer
selection for other transaction types.

In `@src/context/kernelClient.context.tsx`:
- Line 501: Update the client build flow around storeClient so primary
initialization uses the same sequencing/arbitration as migration rebuilds. When
a build is superseded, do not return its stale kernelClient; instead await the
latest build or reject. Ensure superseded builds cannot overwrite the current
cache entry or remove the newer in-flight slot, preventing exposure of an
outdated signing client.

In `@src/hooks/useChainRollout.ts`:
- Around line 42-46: Update useChainRollout so its returned checker reflects the
useFeatureFlags subscription: return a render-scoped wrapper around
isChainRolledOut whose identity changes when flags update, ensuring memoized
consumers such as TokenSelector recompute allowedChainIds after PostHog flags
load.

In `@src/hooks/useFeatureFlag.ts`:
- Around line 38-45: Update useFeatureFlags so the returned checker callback is
recreated when the flag reload state changes, rather than always returning the
stable isFeatureFlagEnabled reference. Apply the same changing-identity behavior
to useChainRollout so consumers such as TokenSelector recompute after PostHog
flags load, while preserving their existing flag-checking logic.

---

Outside diff comments:
In `@src/components/AddMoney/components/ChooseNetworkDrawer.tsx`:
- Around line 33-58: Define a reusable evmChains collection from
SUPPORTED_EVM_CHAINS filtered with isChainRolledOut, use evmChains.length for
the ActionListCard network count, and map over evmChains for the expanded chip
list instead of filtering inline.

---

Nitpick comments:
In `@src/components/Global/GeneralRecipientInput/index.tsx`:
- Around line 59-71: Replace the local non-EVM validation branch in the
recipient input flow with a call to validateAndResolveRecipient, passing the
trimmed input and addressFamily. Reuse its returned ADDRESS result to update the
recipient state, and handle RecipientValidationError through the existing
error-state path instead of calling isValidAddressForFamily or constructing a
separate error message locally.

In `@src/features/payments/shared/hooks/useCrossChainTransfer.ts`:
- Around line 157-163: Update inferTokenSymbol’s non-EVM token lookup to compare
t.address and tokenAddress with exact, case-sensitive string equality; preserve
the existing symbol normalization and undefined behavior when no token matches.

In `@src/lib/validation/addressFamily.ts`:
- Around line 12-15: Replace the shape-only SOLANA_ADDRESS_REGEX and
TRON_ADDRESS_REGEX checks with real validation in the address-family validation
flow: Base58-decode Solana destinations and require the expected decoded byte
length, and Base58Check-decode Tron destinations while verifying the checksum
and network format. Ensure withdrawals reject malformed destinations before
acceptance, reusing existing decoding utilities if available.

In `@src/lib/validation/recipient.ts`:
- Around line 11-27: The non-EVM validation in validateAndResolveRecipient is
duplicated and may not be used by production code; consolidate
GeneralRecipientInput.checkAddress to delegate to this function for non-EVM
addresses. Ensure recipient is trimmed before validation and resolution so
direct and UI-driven paths apply identical normalization and address-family
checks.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 332c5a0a-25cd-44bc-a091-2b0865b70e81

📥 Commits

Reviewing files that changed from the base of the PR and between 558b1cf and f5e2156.

📒 Files selected for processing (40)
  • src/app/(mobile-ui)/card-recovery/page.tsx
  • src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
  • src/app/(mobile-ui)/qr-pay/page.tsx
  • src/app/(mobile-ui)/withdraw/manteca/page.tsx
  • src/app/actions/supported-chains.ts
  • src/app/api/send-discord-notification/route.ts
  • src/components/AddMoney/components/ChooseNetworkDrawer.tsx
  • src/components/AddMoney/components/SupportedNetworksModal.tsx
  • src/components/AddMoney/views/CryptoDeposit.view.tsx
  • src/components/Card/CancelCardModal.tsx
  • src/components/Card/LockCardModal.tsx
  • src/components/Global/GeneralRecipientInput/index.tsx
  • src/components/Global/TokenSelector/TokenSelector.consts.ts
  • src/components/Global/TokenSelector/TokenSelector.tsx
  • src/components/TransactionDetails/transactionTransformer.ts
  • src/components/Withdraw/views/Initial.withdraw.view.tsx
  • src/constants/analytics.consts.ts
  • src/constants/chain-details.json
  • src/constants/nonEvmWithdraw.consts.ts
  • src/constants/rhino.consts.ts
  • src/constants/token-details.json
  • src/context/kernelClient.context.tsx
  • src/features/payments/shared/hooks/useCrossChainTransfer.ts
  • src/hooks/useChainRollout.ts
  • src/hooks/useFeatureFlag.ts
  • src/hooks/useZeroDev.ts
  • src/hooks/wallet/__tests__/spendPreflight.test.ts
  • src/hooks/wallet/spendPreflight.ts
  • src/hooks/wallet/useSendMoney.ts
  • src/hooks/wallet/useSignSpendBundle.ts
  • src/hooks/wallet/useSpendBundle.ts
  • src/hooks/wallet/useWallet.ts
  • src/lib/validation/__tests__/addressFamily.test.ts
  • src/lib/validation/addressFamily.ts
  • src/lib/validation/recipient.ts
  • src/services/rhino-sda.ts
  • src/utils/__tests__/kernelMigration.utils.test.ts
  • src/utils/__tests__/rainWithdraw.utils.test.ts
  • src/utils/kernelMigration.utils.ts
  • src/utils/rainWithdraw.utils.ts
💤 Files with no reviewable changes (1)
  • src/app/api/send-discord-notification/route.ts

// Loading state context provider
const LoadingStateProvider = ({ children }: { children: React.ReactNode }) => {
const loadingStateContext = require('@/context').loadingStateContext
const loadingStateContext = require('@/context/loadingStates.context').loadingStateContext

@coderabbitai coderabbitai Bot Jul 11, 2026

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Fix the test setup’s lint errors.

require() at Lines 509 and 533 violates @typescript-eslint/no-require-imports, while mockSetLoadingState at Line 521 is unused. Import loadingStateContext statically, remove the local require() bindings, and either remove the mock or pass it as the provider’s setLoadingState value.

Proposed cleanup
+import { loadingStateContext } from '`@/context/loadingStates.context`'

-    const loadingStateContext = require('`@/context/loadingStates.context`').loadingStateContext
-
-const mockSetLoadingState = jest.fn()
-
-    const { loadingStateContext } = require('`@/context/loadingStates.context`')

Also applies to: 521-522, 533-533

🧰 Tools
🪛 ESLint

[error] 509-509: A require() style import is forbidden.

(@typescript-eslint/no-require-imports)

🤖 Prompt for 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.

In `@src/app/`(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx at line 509, Fix
the test setup around loadingStateContext by replacing both local require()
bindings with a static import, removing the unused mockSetLoadingState
declaration, and passing that mock to the provider’s setLoadingState prop if the
provider requires it; otherwise remove the mock entirely.

Source: Linters/SAST tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR's scope — qr-pay-states.test.tsx isn't touched by this branch (this review ran before back-merge #2397 landed on dev; the current diff vs dev has zero hits on this file).

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh pr diff 2398 --repo peanutprotocol/peanut-ui -- src/app/\(mobile-ui\)/qr-pay/__tests__/qr-pay-states.test.tsx | head -100

Length of output: 3646


🏁 Script executed:

#!/bin/bash
gh pr view 2398 --repo peanutprotocol/peanut-ui --json files --jq '.files[].path' | rg -i 'qr-pay-states'
echo "---"
gh pr diff 2398 --repo peanutprotocol/peanut-ui | rg -n 'qr-pay-states' | head -20

Length of output: 690


@Hugo0, thanks for the context. Just to double check, I re-ran a check against the current state of the PR and the file still shows up in the diff:

gh pr view 2398 --json files --jq '.files[].path' | rg -i 'qr-pay-states'
→ src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

gh pr diff 2398 | rg -n 'qr-pay-states'
→ diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

This may just mean the branch was updated after your comment (e.g., a rebase/merge bringing dev's version of the file back into the diff), so the require() lines may now be back in scope. If that's the case and you'd still like to defer the lint cleanup to a separate PR, that's totally fine — just let me know and I'll leave it alone. Otherwise, happy to help with the fix here if it's now in scope.


🧬 Code Graph Analysis Results

Relevant snippet — src/types/capabilities.ts (lines 63-90)

export interface RailCapability {
    id: RailId
    provider: ProviderCode
    method: string // 'ACH_US'
    /** User-facing channel: bank / card / qr-only. Derived BE-side from `method`. */
    channel: RailChannel
    country: string // jurisdiction, not strict ISO-2: 'US' | 'EU' | 'GLOBAL' | …
    currency: string
    status: RailCapabilityStatus
    /**
     * Per-operation refinement of `status`. ABSENT → `status` applies to every
     * operation (Bridge, Rain — no pay/withdraw split). PRESENT → read the
     * specific op, falling back to `status`: `operations?.[op] ?? status`.
     * Only operations the method actually supports are listed.
     */
    operations?: Partial<Record<RailOperation, RailCapabilityStatus>>
    /** keys into NextAction.key — actions that unlock currently-unavailable operations on this rail. */
    blockingActions?: string[]
    /**
     * Non-blocking hints — actions the user CAN take on a rail that's otherwise
     * working (the rail stays usable): the Bridge advisory pre-empt (a future-dated
     * requirement whose NextAction carries `effectiveDate`) and the Manteca
     * cap-nudge. Distinct from `blockingActions` so the FE never gates on them.
     */
    hintActions?: string[]
    /** present for requires-info / blocked — normalized reason for uniform FE rendering. */
    reason?: CapabilityReason
}

Relevant snippet — src/app/(mobile-ui)/qr-pay/page.tsx (lines 101-260)

export default function QRPayPage() {
    // ...reads query params, wallet/auth hooks, and loadingStateContext...

    /**
     * Computes QR gate state from `useCapabilities()`:
     * - Inputs (via hooks):
     *   - useSearchParams(): qrCode, type, timestamp
     *   - useCapabilities(): { canDo, railsForProvider, isKycApproved, isLoading }
     *   - useAuth(): user, fetchUser
     *   - loading state: loadingStateContext (via useContext)
     * - Returns/side effects:
     *   - Updates local React state during render based on `kycGateState`
     *   - Sets `shouldBlockPay` boolean: kycGateState !== PROCEED_TO_PAY
     * - Key mapping logic:
     *   1) If isLoadingCapabilities OR (!user && !userFetchSettled) => QrKycState.LOADING
     *   2) If canDo('pay', { provider: 'manteca' }) => QrKycState.PROCEED_TO_PAY
     *   3) Else inspect `railsForProvider('manteca')`:
     *      - top-level rail.status === 'blocked' =>
     *          - if blockedRail.reason?.code === 'country_not_supported' =>
     *              PROVIDER_RESTART_IDENTITY (uses blockedRail.reason.userMessage)
     *          - otherwise => PROVIDER_REJECTION_BLOCKED
     *      - rail.status === 'requires-info' => PROVIDER_REJECTION_FIXABLE
     *      - rail.status === 'pending' => IDENTITY_VERIFICATION_IN_PROGRESS
     *      - fallback => REQUIRES_IDENTITY_VERIFICATION
     * - Important note from inline comment:
     *   - US-nationality restriction refinement is codified server-side so
     *     canDo('pay', { provider: 'manteca' }) covers PROCEED_TO_PAY.
     */

    // shouldBlockPay is derived directly from kycGateState
    const shouldBlockPay = kycGateState !== QrKycState.PROCEED_TO_PAY

    // ...starts/controls sumsub flow (useMultiPhaseKycFlow) ...
}

Comment thread src/components/TransactionDetails/transactionTransformer.ts Outdated
// registering→not→registering UI flicker between retries.
if (isMounted) {
setClientsByChain((prev) => ({ ...prev, [primaryChainId]: kernelClient }))
storeClient(primaryChainId, kernelClient)

@coderabbitai coderabbitai Bot Jul 11, 2026

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Fail closed for every superseded client build.

The sequence guard only prevents stale cache writes: the superseded promise still returns its old client to its caller. Additionally, the primary initializer commits outside this sequencing, so it can overwrite a newer migration rebuild and delete its in-flight slot. Either race can expose a v0.0.2-signing client after migration.

Route primary initialization through the same build arbitration, and make superseded builds await the latest build or reject rather than return their stale client.

Also applies to: 628-634

🤖 Prompt for 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.

In `@src/context/kernelClient.context.tsx` at line 501, Update the client build
flow around storeClient so primary initialization uses the same
sequencing/arbitration as migration rebuilds. When a build is superseded, do not
return its stale kernelClient; instead await the latest build or reject. Ensure
superseded builds cannot overwrite the current cache entry or remove the newer
in-flight slot, preventing exposure of an outdated signing client.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR's scope — qr-pay-states.test.tsx isn't touched by this branch (the early review ran before back-merge #2397 landed on dev; the current diff has zero hits on this file).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR's scope — kernelClient.context.tsx isn't touched by this branch (pre-back-merge review artifact; the current diff vs dev has zero hits on this file). The kernel migration hotfix owns that file.

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.

@Hugo0, this reply looks like it may have landed on the wrong thread — this comment chain is about the build-sequencing race in src/context/kernelClient.context.tsx (superseded client builds returning stale clients around Line 501), not about qr-pay-states.test.tsx.

If you intended to reply to a different review comment (the one about qr-pay-states.test.tsx not being touched by this branch), could you point me to that thread so I can follow up there?

For this thread specifically — the concern is still open: primary initialization in the initializeClients effect writes via storeClient outside the buildSeqRef/latestBuildSeqRef arbitration used by startClientBuild, and superseded builds in startClientBuild still resolve their caller with the stale kernelClient instead of the latest one. Let me know if you'd like me to look into a fix for that, or if you believe it's not applicable given how the primary-init effect and rebuildClientForChain interact in practice.

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh pr diff 2398 --repo peanutprotocol/peanut-ui --name-only | grep -i kernelClient || echo "NOT FOUND in current diff"
echo "---"
gh pr view 2398 --repo peanutprotocol/peanut-ui --json baseRefName,headRefName,commits --jq '.baseRefName, .headRefName'

Length of output: 7735


@Hugo0, I checked this directly — gh pr diff 2398 --name-only (base dev, head feat/solana-tron-withdrawals) still lists src/context/kernelClient.context.tsx as a changed file in the current diff:

src/context/kernelClient.context.tsx

So the file does appear to be touched by this branch as of now, even if the changes originated from the kernel migration hotfix and arrived here via the back-merge from dev. Since GitHub still shows it in this PR's diff, the review comment is technically in-scope for this PR (assuming CodeRabbit is diffing against dev).

That said, if the kernel migration hotfix is a separate PR that already merged (or will merge) directly into dev/main independently of this PR, and this PR is just inheriting those changes via merge rather than introducing them, I understand not wanting to address it here — the fix belongs with that hotfix's owner/PR instead. Let me know if that's the case and I'll leave it be; otherwise, happy to keep the concern open since it's visible in this diff.


🧬 Code Graph Analysis Results

Relevant snippets for src/context/kernelClient.context.tsx

src/utils/capacitor.ts

At lines 60-62

export function isAndroidNative(): boolean {
    return getPlatform() === 'android-native'
}

At lines 87-89

export function getNativeRpId(): string {
    return process.env.NEXT_PUBLIC_NATIVE_RP_ID || 'peanut.me'
}

src/app/actions/clients.ts

At lines 43-67

export const PUBLIC_CLIENTS_BY_CHAIN: Record<
    string,
    {
        client: PublicClient
        chain: Chain
        bundlerUrl: string
        paymasterUrl: string
    }
> = {
    // Primary wallet chain - always included (configurable via NEXT_PUBLIC_PEANUT_WALLET_CHAIN_ID)
    [PEANUT_WALLET_CHAIN.id]: {
        // FOLLOW-UP: PEANUT_WALLET_CHAIN is `Chain | <extractChain return>`
        // (env-driven). The extractChain return is broader than Chain at the
        // type level even though it's structurally identical at runtime. Cast
        // here so the map literal infers cleanly.
        client: createPublicClient({
            transport: getTransportWithFallback(PEANUT_WALLET_CHAIN.id as ChainId),
            chain: PEANUT_WALLET_CHAIN as Chain,
            pollingInterval: 500,
        }),
        chain: PEANUT_WALLET_CHAIN as Chain,
        bundlerUrl: BUNDLER_URL,
        paymasterUrl: PAYMASTER_URL,
    },
}

src/context/authContext.tsx

At lines 303-309

export const useAuth = (): AuthContextType => {
    const context = useContext(AuthContext)
    if (context === undefined) {
        throw new Error('useAuth must be used within an AuthProvider')
    }
    return context
}

src/utils/native-webauthn.ts

At lines 137-211

export function createNativeSignMessageCallback(rpId: string) {
    return async (
        message: SignableMessage,
        _rpId: string,
        chainId: number,
        allowCredentials?: AllowCredentialDescriptor[]
    ): Promise<Hex> => {
        // convert message to hex string
        let messageContent: string
        if (typeof message === 'string') {
            messageContent = message
        } else if ('raw' in message && typeof message.raw === 'string') {
            messageContent = message.raw
        } else if ('raw' in message && message.raw instanceof Uint8Array) {
            messageContent = uint8ArrayToHexString(message.raw)
        } else {
            throw new Error('unsupported message format for native signing')
        }

        // convert hex to base64url challenge
        const formattedMessage = messageContent.startsWith('0x') ? messageContent.slice(2) : messageContent
        const messageBytes = new Uint8Array(formattedMessage.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)))
        const challenge = bufferToBase64URL(messageBytes.buffer)

        const assertionOptions = {
            challenge: messageBytes as BufferSource,
            rpId,
            allowCredentials: allowCredentials?.map((cred) => ({
                id: base64URLToBytes(cred.id) as BufferSource,
                type: 'public-key' as const,
            })),
            userVerification: 'required' as const,
            timeout: 60000,
        }

        // uses navigator.credentials.get() which is shimmed by `@capgo/capacitor-passkey`
        const cred = (await navigator.credentials.get({
            publicKey: assertionOptions,
        })) as PublicKeyCredential

        if (!cred || !cred.response) {
            throw new Error('native signing failed — no credential returned')
        }

        const response = cred.response as AuthenticatorAssertionResponse

        // parse response in zerodev's expected format
        const authenticatorData = new Uint8Array(response.authenticatorData)
        const authenticatorDataHex = uint8ArrayToHexString(authenticatorData)

        const clientDataJSON = new TextDecoder().decode(new Uint8Array(response.clientDataJSON))

        // zerodev looks for '"type":"webauthn.get"' in clientDataJSON
        const beforeType = BigInt(clientDataJSON.lastIndexOf('"type":"webauthn.get"'))

        // parse and normalize signature
        const signatureBytes = new Uint8Array(response.signature)
        const signatureHex = uint8ArrayToHexString(signatureBytes)
        const { r, s } = parseAndNormalizeSig(signatureHex)

        const isRIP7212Supported = RIP7212_CHAIN_IDS.includes(chainId)

        return encodeAbiParameters(
            [
                { name: 'authenticatorData', type: 'bytes' },
                { name: 'clientDataJSON', type: 'string' },
                { name: 'responseTypeLocation', type: 'uint256' },
                { name: 'r', type: 'uint256' },
                { name: 's', type: 'uint256' },
                { name: 'usePrecompiled', type: 'bool' },
            ],
            [authenticatorDataHex, clientDataJSON, beforeType, r, s, isRIP7212Supported]
        )
    }
}

src/redux/hooks.ts

At line 5

export const useAppDispatch = () => useDispatch<AppDispatch>()

src/hooks/query/user.ts

At lines 29-71

fetchUser = async (): Promise<IUserProfile | null> => {
        const userResponse = await apiFetch('/users/me', { method: 'GET' })
        if (userResponse.ok) {
            const payload: (IUserProfile & { token?: string }) | null = await userResponse.json()

            // Sliding refresh: backend re-mints when the JWT crosses half its
            // lifetime and ships the new one alongside the user payload. Swap
            // it in client-side so active users never hit the 30d hard logout.
            // Strip `token` unconditionally so auth state never leaks into the
            // user store, even if the backend ever sends a falsy value.
            if (payload && 'token' in payload) {
                if (payload.token) setAuthToken(payload.token)
                delete payload.token
            }

            if (payload) {
                // Was: hitUserMetric(userData.user.userId, 'login', ...) → POST /users/:id/metrics/login.
                // DB `user_metrics` table deprecated 2026-04-24; analytics is PostHog's job.
                posthog.capture(ANALYTICS_EVENTS.LOGIN, { isPwa, deviceType })
                dispatch(userActions.setUser(payload))
            }
            return payload
        }

        // 5xx = backend error, throw so tanstack retries
        if (userResponse.status >= 500) {
            console.error('Backend error fetching user:', userResponse.status)
            throw new BackendError('Backend error fetching user', userResponse.status)
        }

        // 401 (expired/invalid JWT) and 404 (user no longer exists — e.g. local
        // DB re-seeded out from under a stale cookie) both mean the JWT is
        // irrecoverable. Wipe the token so the next render escapes to /setup
        // instead of looping on the same dead JWT.
        if (userResponse.status === 401 || userResponse.status === 404) {
            clearAuthToken()
        }

        // 4xx = auth failure, clear stale redux so layout redirects to /setup
        console.warn('Failed to fetch user, status:', userResponse.status)
        dispatch(userActions.setUser(null))
        return null
    }

Comment thread src/hooks/useChainRollout.ts Outdated
Comment thread src/hooks/useFeatureFlag.ts
…ive rollout gate, CLAUDE.md structure

1. The synthetic Solana/Tron records now merge ONCE in
   tokenSelector.context — the record the price hook reads — so
   selectedTokenData resolves (stablecoin $1 branch) and the Review
   button actually enables; kills the selector-local merge AND the
   withdraw view's duplicate fallback (review findings: feature was
   dead-on-arrival + DRY).
2. Rollout gate un-frozen: useFeatureFlags returns a NEW checker
   identity per PostHog flag-load event, so memoized chain lists
   recompute (a stable identity kept every flagged chain hidden on
   prod regardless of toggle state). Regression-tested.
3. CLAUDE.md structure: flag map → constants/chainRollout.consts,
   pure checks → utils/featureFlag.utils, hooks now hook-only; chip
   annotation block deduped into EvmChainChips (shared by drawer +
   modal, count now matches visible chips); duplicate react imports
   merged.

@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: 1

🤖 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/__tests__/useChainRollout.test.tsx`:
- Around line 1-22: Rename the outer-scope helpers used by the posthog-js mock
factory to Jest-compatible mock-prefixed names: change isFeatureEnabledMock to
mockIsFeatureEnabled and flagsCallback to mockFlagsCallback, updating all
references in the test. Preserve the existing mock behavior and callback
cleanup.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0cee8f69-47f5-4953-b2bb-2a366269f67a

📥 Commits

Reviewing files that changed from the base of the PR and between f5e2156 and ea63b6c.

📒 Files selected for processing (11)
  • src/components/AddMoney/components/ChooseNetworkDrawer.tsx
  • src/components/AddMoney/components/EvmChainChips.tsx
  • src/components/AddMoney/components/SupportedNetworksModal.tsx
  • src/components/Global/TokenSelector/TokenSelector.tsx
  • src/components/Withdraw/views/Initial.withdraw.view.tsx
  • src/constants/chainRollout.consts.ts
  • src/context/tokenSelector.context.tsx
  • src/hooks/__tests__/useChainRollout.test.tsx
  • src/hooks/useChainRollout.ts
  • src/hooks/useFeatureFlag.ts
  • src/utils/featureFlag.utils.ts

Comment thread src/hooks/__tests__/useChainRollout.test.tsx
One chain's facts were spread across seven hand-maintained maps; the
drift between them caused the SCROLL rot and the frozen-SDA incident.
Every map still exports from its old path, but is now DERIVED from a
single registry entry per chain — adding or launching a chain is one
edit in one file.

Behavior-proven, not just claimed: __tests__/chainRegistry.test.ts
asserts each derived map equals the literal it replaced. Two deliberate
deltas, both documented in the test: Kaia gains a chainId→Rhino-name
mapping (it's a deposit chain; harmless superset), and rollout-flag
keying gains display-name aliases (inert superset). One discovery
preserved as-is: BASE has never been in the curated withdraw gate —
looks like a June-2026 curation oversight, flagged for a product
decision rather than smuggled in via refactor.

Registry invariants are tested too: no duplicate ids, routable chains
must have a Rhino name, deposit chains must be displayable, non-EVM
withdraw destinations must carry their synthetic selector record.
Hugo0 added 2 commits July 13, 2026 06:33
…ons into the registry

Base: missing from the curated withdraw gate since June — an
oversight, not a decision (Hugo). Verified live before enabling:
ARB→BASE quotes OK for ETH/USDC/USDT + outflow SDA create OK
(2026-07-13). Rollout-flagged (chain-rollout-base, created ON).

Full clean per review: chainRollout.consts and nonEvmWithdraw.consts
existed only to hold single derived constants — their exports now
live in chainRegistry.consts itself and the files are gone. No
re-export shims remain; rhino.consts/TokenSelector.consts keep their
derived chain maps because they co-locate with family-level constants
and a dozen consumers, but every value traces to one registry entry.
…bit) + hoisting-safe mock names

The recorded hash lives on Arbitrum; linking entry.chainId (the
destination) mislinked receipts on destinations WITH an explorer and
left the rest linkless — one rule now: deposits and withdrawals link
the Peanut wallet chain.
Hugo0 and others added 2 commits July 13, 2026 07:29
…raw gates

Turns the 'gated only by discipline' caveat into an enforced invariant:
NON_EVM_WITHDRAW_CHAINS (solana/tron) must be disjoint from
TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS (send/claim/pay gate) and from
supportedPeanutChains (URL parse/validation source). Fails at test time
if a future change lets a base58-address chain into an EVM-only flow.
refactor: CHAIN_REGISTRY — one source of truth for every FE chain fact
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants