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.
|
WalkthroughThis PR centralizes spend preflight and kernel migration handling, adds Rain typed-data utilities, expands Rhino and non-EVM withdrawal support with rollout gating, updates recipient validation and transfer typing, and adjusts related UI, transaction, loading-state, and configuration integrations. ChangesSpend preflight and migration
Non-EVM withdrawals and chain rollout
Supporting UI and integration fixes
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
Code-analysis diffPainscore total: 6099.08 → 6124.38 (+25.3) 🆕 New findings (44)
…and 24 more. ✅ Resolved (41)
…and 21 more. 📈 Painscore deltas (top movers)
|
🧪 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
| let baseUrl = getExplorerUrl(explorerUrlChainID) | ||
| // Cross-chain withdrawals record the ARBITRUM source tx hash while | ||
| // entry.chainId is the destination — and several destinations (Tempo, | ||
| // Solana, Tron, …) have no chain-details explorer entry at all, which | ||
| // left the receipt linkless. Fall back to the source-chain explorer so | ||
| // the receipt always links the tx that actually carries the hash. | ||
| if (!baseUrl && intentKindOf(entry) === 'CRYPTO_WITHDRAW') { | ||
| baseUrl = getExplorerUrl(PEANUT_WALLET_CHAIN.id.toString()) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use the source-chain explorer for every crypto withdrawal.
The fallback only runs when the destination has no explorer URL. When a destination explorer exists, the Arbitrum/source-chain txHash is incorrectly linked under the destination chain. Select PEANUT_WALLET_CHAIN for CRYPTO_WITHDRAW directly, not only as a fallback.
Proposed fix
- const explorerUrlChainID =
- intentKindOf(entry) === 'CRYPTO_DEPOSIT' ? PEANUT_WALLET_CHAIN.id.toString() : entry.chainId
- let baseUrl = getExplorerUrl(explorerUrlChainID)
+ const kind = intentKindOf(entry)
+ const explorerUrlChainID =
+ kind === 'CRYPTO_DEPOSIT' || kind === 'CRYPTO_WITHDRAW'
+ ? PEANUT_WALLET_CHAIN.id.toString()
+ : entry.chainId
+ const baseUrl = getExplorerUrl(explorerUrlChainID)
- if (!baseUrl && intentKindOf(entry) === 'CRYPTO_WITHDRAW') {
- baseUrl = getExplorerUrl(PEANUT_WALLET_CHAIN.id.toString())
- }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let baseUrl = getExplorerUrl(explorerUrlChainID) | |
| // Cross-chain withdrawals record the ARBITRUM source tx hash while | |
| // entry.chainId is the destination — and several destinations (Tempo, | |
| // Solana, Tron, …) have no chain-details explorer entry at all, which | |
| // left the receipt linkless. Fall back to the source-chain explorer so | |
| // the receipt always links the tx that actually carries the hash. | |
| if (!baseUrl && intentKindOf(entry) === 'CRYPTO_WITHDRAW') { | |
| baseUrl = getExplorerUrl(PEANUT_WALLET_CHAIN.id.toString()) | |
| } | |
| // Cross-chain withdrawals record the ARBITRUM source tx hash while | |
| // entry.chainId is the destination — and several destinations (Tempo, | |
| // Solana, Tron, …) have no chain-details explorer entry at all, which | |
| // left the receipt linkless. Fall back to the source-chain explorer so | |
| // the receipt always links the tx that actually carries the hash. | |
| const kind = intentKindOf(entry) | |
| const explorerUrlChainID = | |
| kind === 'CRYPTO_DEPOSIT' || kind === 'CRYPTO_WITHDRAW' | |
| ? PEANUT_WALLET_CHAIN.id.toString() | |
| : entry.chainId | |
| const baseUrl = getExplorerUrl(explorerUrlChainID) |
🤖 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/TransactionDetails/transactionTransformer.ts` around lines 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.
| // 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.
| /** Reactive variant — re-renders when PostHog's flags load. */ | ||
| export function useChainRollout(): (chainKey: string) => boolean { | ||
| useFeatureFlags() | ||
| return isChainRolledOut | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm no other memoization wraps isChainRolledOut/useChainRollout results, and check other consumers.
rg -n "useChainRollout" -B2 -A6 --type=ts --type=tsx src 2>/dev/null || rg -n "useChainRollout" -B2 -A6 srcRepository: peanutprotocol/peanut-ui
Length of output: 6067
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== useFeatureFlag.ts outline ==\n'
ast-grep outline src/hooks/useFeatureFlag.ts --view expanded || true
printf '\n== useChainRollout.ts outline ==\n'
ast-grep outline src/hooks/useChainRollout.ts --view expanded || true
printf '\n== TokenSelector.tsx outline ==\n'
ast-grep outline src/components/Global/TokenSelector/TokenSelector.tsx --view expanded || true
printf '\n== useFeatureFlag.ts relevant lines ==\n'
nl -ba src/hooks/useFeatureFlag.ts | sed -n '1,220p'
printf '\n== useChainRollout.ts relevant lines ==\n'
nl -ba src/hooks/useChainRollout.ts | sed -n '1,200p'
printf '\n== TokenSelector.tsx relevant lines ==\n'
nl -ba src/components/Global/TokenSelector/TokenSelector.tsx | sed -n '180,360p'Repository: peanutprotocol/peanut-ui
Length of output: 1442
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== useFeatureFlag.ts ==\n'
sed -n '1,220p' src/hooks/useFeatureFlag.ts
printf '\n== useChainRollout.ts ==\n'
sed -n '1,220p' src/hooks/useChainRollout.ts
printf '\n== TokenSelector.tsx (relevant section) ==\n'
sed -n '186,345p' src/components/Global/TokenSelector/TokenSelector.tsxRepository: peanutprotocol/peanut-ui
Length of output: 11716
Make useChainRollout return a reactive checker
useFeatureFlags() re-renders this hook, but the returned callback is still the module-level isChainRolledOut, so memoized consumers like TokenSelector’s allowedChainIds won’t recompute when PostHog flags load. Return a wrapper whose identity changes with the flag subscription, or key the memo off a flag-version/state value instead.
🤖 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/hooks/useChainRollout.ts` around lines 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.
| export function useFeatureFlags(): (flagKey: string, options?: FeatureFlagOptions) => boolean { | ||
| const [, bump] = useReducer((n: number) => n + 1, 0) | ||
| useEffect(() => { | ||
| // returns an unsubscribe function | ||
| return posthog.onFeatureFlags(() => bump()) | ||
| }, []) | ||
| return isFeatureFlagEnabled | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
git ls-files 'src/hooks/useFeatureFlag.ts' 'src/hooks/useChainRollout.ts' 'src/components/Global/TokenSelector/TokenSelector.tsx'Repository: peanutprotocol/peanut-ui
Length of output: 273
🏁 Script executed:
ast-grep outline src/hooks/useFeatureFlag.ts --view expanded
ast-grep outline src/hooks/useChainRollout.ts --view expanded
ast-grep outline src/components/Global/TokenSelector/TokenSelector.tsx --view expandedRepository: peanutprotocol/peanut-ui
Length of output: 1261
🏁 Script executed:
sed -n '1,140p' src/hooks/useFeatureFlag.ts
printf '\n--- useChainRollout ---\n'
sed -n '1,140p' src/hooks/useChainRollout.ts
printf '\n--- TokenSelector ---\n'
sed -n '180,230p' src/components/Global/TokenSelector/TokenSelector.tsxRepository: peanutprotocol/peanut-ui
Length of output: 6614
useFeatureFlags needs a changing function identity when flags load
bump() re-renders the hook, but it always returns the same isFeatureFlagEnabled reference. Any consumer that memoizes on the checker — including useChainRollout and TokenSelector’s allowedChainIds — will never recompute after PostHog flags arrive. Return a callback whose identity changes with the reload state, and update useChainRollout to do the same.
🤖 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/hooks/useFeatureFlag.ts` around lines 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.
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)