feat: Solana + Tron withdrawals + chain-expansion polish#2398
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 37 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (12)
WalkthroughThe 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. ChangesSpend preflight and migration
Non-EVM withdrawals and chain rollout
Supporting integrations
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
Code-analysis diffPainscore total: 6129.56 → 6129.56 (0) |
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
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.
There was a problem hiding this comment.
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 winEVM network count no longer matches the filtered chip list.
Line 37's
${SUPPORTED_EVM_CHAINS.length} Networks - 1 Addressstill 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
evmChainsin 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 winCase-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.addressagainsttokenAddresscould 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 winUse 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 winNon-EVM branch may be unused by production code — see companion comment in
GeneralRecipientInput/index.tsx.
GeneralRecipientInput.checkAddressreimplements this exactisValidAddressForFamilybranching 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 trimrecipientbefore validating, unlike the UI'strimmedInput— a latent inconsistency if this path is ever exercised directly. See the follow-up comment inGeneralRecipientInput/index.tsxfor 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 winDelegate to
validateAndResolveRecipientinstead of reimplementing the non-EVM check.
validateAndResolveRecipientinrecipient.tswas extended in this same PR with anaddressFamilyparameter that performs this exact check (validate viaisValidAddressForFamily, throwRecipientValidationErroron failure, otherwise return anADDRESS-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
📒 Files selected for processing (40)
src/app/(mobile-ui)/card-recovery/page.tsxsrc/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsxsrc/app/(mobile-ui)/qr-pay/page.tsxsrc/app/(mobile-ui)/withdraw/manteca/page.tsxsrc/app/actions/supported-chains.tssrc/app/api/send-discord-notification/route.tssrc/components/AddMoney/components/ChooseNetworkDrawer.tsxsrc/components/AddMoney/components/SupportedNetworksModal.tsxsrc/components/AddMoney/views/CryptoDeposit.view.tsxsrc/components/Card/CancelCardModal.tsxsrc/components/Card/LockCardModal.tsxsrc/components/Global/GeneralRecipientInput/index.tsxsrc/components/Global/TokenSelector/TokenSelector.consts.tssrc/components/Global/TokenSelector/TokenSelector.tsxsrc/components/TransactionDetails/transactionTransformer.tssrc/components/Withdraw/views/Initial.withdraw.view.tsxsrc/constants/analytics.consts.tssrc/constants/chain-details.jsonsrc/constants/nonEvmWithdraw.consts.tssrc/constants/rhino.consts.tssrc/constants/token-details.jsonsrc/context/kernelClient.context.tsxsrc/features/payments/shared/hooks/useCrossChainTransfer.tssrc/hooks/useChainRollout.tssrc/hooks/useFeatureFlag.tssrc/hooks/useZeroDev.tssrc/hooks/wallet/__tests__/spendPreflight.test.tssrc/hooks/wallet/spendPreflight.tssrc/hooks/wallet/useSendMoney.tssrc/hooks/wallet/useSignSpendBundle.tssrc/hooks/wallet/useSpendBundle.tssrc/hooks/wallet/useWallet.tssrc/lib/validation/__tests__/addressFamily.test.tssrc/lib/validation/addressFamily.tssrc/lib/validation/recipient.tssrc/services/rhino-sda.tssrc/utils/__tests__/kernelMigration.utils.test.tssrc/utils/__tests__/rainWithdraw.utils.test.tssrc/utils/kernelMigration.utils.tssrc/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 |
There was a problem hiding this comment.
📐 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
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
🧩 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 -100Length 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 -20Length 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) ...
}| // registering→not→registering UI flicker between retries. | ||
| if (isMounted) { | ||
| setClientsByChain((prev) => ({ ...prev, [primaryChainId]: kernelClient })) | ||
| storeClient(primaryChainId, kernelClient) |
There was a problem hiding this comment.
🩺 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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
🧩 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
}…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.
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
src/components/AddMoney/components/ChooseNetworkDrawer.tsxsrc/components/AddMoney/components/EvmChainChips.tsxsrc/components/AddMoney/components/SupportedNetworksModal.tsxsrc/components/Global/TokenSelector/TokenSelector.tsxsrc/components/Withdraw/views/Initial.withdraw.view.tsxsrc/constants/chainRollout.consts.tssrc/context/tokenSelector.context.tsxsrc/hooks/__tests__/useChainRollout.test.tsxsrc/hooks/useChainRollout.tssrc/hooks/useFeatureFlag.tssrc/utils/featureFlag.utils.ts
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.
…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.
…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
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:
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 themchainIdToRhinoNamehandles slugs + EVM numeric ids on the withdraw pathPolish:
CHAIN_ICON_OVERRIDESmechanismRisks / breaking changes
restrictToRhinoflag — verified that claim/send/pay filter by wagmi network ids and can't surface them.CrossChainDestinationInfo.recipientAddress/tokenAddresswidened from viemAddresstostring— the same-chain path (EVM-only by construction) narrows back with a cast; cross-chain tx construction never uses the recipient.QA
addressFamilysuite (cross-family rejection, base58 alphabet edge cases)next build✅ARBITRUM→SOLANAandARBITRUM→TRONagainst Rhino prod (2026-07-11)