From 82cffccc5a51c1116e22e13121df5644415b0404 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 9 Jul 2026 20:10:33 -0700
Subject: [PATCH 01/19] fix: migrate root validator before mixed spends sign
the Rain admin sig
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.
---
src/constants/analytics.consts.ts | 4 +
src/context/kernelClient.context.tsx | 79 +++++++++---
src/hooks/wallet/useSpendBundle.ts | 83 ++++++-------
.../__tests__/kernelMigration.utils.test.ts | 115 ++++++++++++++++++
.../__tests__/rainWithdraw.utils.test.ts | 45 +++++++
src/utils/kernelMigration.utils.ts | 91 ++++++++++++++
src/utils/rainWithdraw.utils.ts | 43 +++++++
7 files changed, 401 insertions(+), 59 deletions(-)
create mode 100644 src/utils/__tests__/kernelMigration.utils.test.ts
create mode 100644 src/utils/__tests__/rainWithdraw.utils.test.ts
create mode 100644 src/utils/kernelMigration.utils.ts
create mode 100644 src/utils/rainWithdraw.utils.ts
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
index 8445239956..130b02f305 100644
--- a/src/constants/analytics.consts.ts
+++ b/src/constants/analytics.consts.ts
@@ -227,6 +227,10 @@ export const ANALYTICS_EVENTS = {
CARD_PHYSICAL_WAITLIST_JOINED: 'card_physical_waitlist_joined',
CARD_ADD_TO_WALLET_VIEWED: 'card_add_to_wallet_viewed',
// Spend routing across collateral / smart / mixed buckets. `strategy` is SpendStrategy.
+ // Root-validator migration userOp fired ahead of a mixed spend (pre-2025-09-18
+ // accounts still on the unpatched validator) — see kernelMigration.utils.ts.
+ KERNEL_MIGRATION_ATTEMPTED: 'kernel_migration_attempted',
+ KERNEL_MIGRATION_SUCCEEDED: 'kernel_migration_succeeded',
CARD_WITHDRAW_ATTEMPTED: 'card_withdraw_attempted',
CARD_WITHDRAW_SUCCEEDED: 'card_withdraw_succeeded',
CARD_WITHDRAW_FAILED: 'card_withdraw_failed',
diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx
index 42fda06ab6..7636f69f04 100644
--- a/src/context/kernelClient.context.tsx
+++ b/src/context/kernelClient.context.tsx
@@ -45,6 +45,14 @@ interface KernelClientContextType {
// `resolvePatchedSudoValidator` for why binding to the migration client's
// stale v0.0.2 validator wapk-blocks the backend replay.
getPatchedSudoValidator: (publicClient: PublicClient) => Promise>>
+ // Drops the cached client for `chainId` and builds a fresh one. Needed when
+ // the account's on-chain validator set changes mid-session (root-validator
+ // migration): the cached migration account keeps SIGNING via the v0.0.2
+ // validator it was built with, so its EIP-1271 signatures are rejected once
+ // the on-chain root flips to v0.0.3. The rebuilt client lands in the
+ // ref-backed cache, so every consumer — including closures captured before
+ // the rebuild — sees it immediately.
+ rebuildClientForChain: (chainId: string) => Promise
}
type GenericSmartAccountClient = KernelAccountClient
@@ -290,6 +298,22 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => {
// primary-init effect register itself so a recover-funds page mount that
// races primary login doesn't kick off a duplicate Arb build.
const inFlightRef = useRef
- {SUPPORTED_EVM_CHAINS.map((chain) => (
-
- ))}
+ {SUPPORTED_EVM_CHAINS.map((chain) => {
+ const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain]
+ const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain
+ return
+ })}
+ {!isOfframp && (
+
+ A small bridging fee (~0.1%) applies — you'll receive slightly less than you
+ send.
+
+ )}
{isOfframp && (
Moving more than the max? Send it in multiple transfers.
diff --git a/src/components/Global/GeneralRecipientInput/index.tsx b/src/components/Global/GeneralRecipientInput/index.tsx
index 6e4f7026f7..076675606c 100644
--- a/src/components/Global/GeneralRecipientInput/index.tsx
+++ b/src/components/Global/GeneralRecipientInput/index.tsx
@@ -7,6 +7,7 @@ import * as Senty from '@sentry/nextjs'
import { useCallback, useRef } from 'react'
import { isIBAN } from 'validator'
import { validateAndResolveRecipient } from '@/lib/validation/recipient'
+import { isValidAddressForFamily, type WithdrawAddressFamily } from '@/lib/validation/addressFamily'
import { BASE_URL } from '@/constants/general.consts'
type GeneralRecipientInputProps = {
@@ -17,6 +18,10 @@ type GeneralRecipientInputProps = {
infoText?: string
showInfoText?: boolean
isWithdrawal?: boolean
+ /** Address family of the selected withdraw destination ('evm' default).
+ * Solana/Tron short-circuit the IBAN/US-routing/ENS branches — a base58
+ * address is the only valid input for them. */
+ addressFamily?: WithdrawAddressFamily
}
export type GeneralRecipientUpdate = {
@@ -35,6 +40,7 @@ const GeneralRecipientInput = ({
infoText,
showInfoText = true,
isWithdrawal = false,
+ addressFamily = 'evm',
}: GeneralRecipientInputProps) => {
const recipientType = useRef('address')
const errorMessage = useRef('')
@@ -50,6 +56,19 @@ const GeneralRecipientInput = ({
const trimmedInput = recipient.trim().replace(`${BASE_URL}/`, '')
const sanitizedInput = sanitizeBankAccount(trimmedInput)
+ // 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
+ }
+
if (isIBAN(sanitizedInput)) {
type = 'iban'
isValid = await validateBankAccount(sanitizedInput)
@@ -82,7 +101,7 @@ const GeneralRecipientInput = ({
return false
}
},
- [isWithdrawal]
+ [isWithdrawal, addressFamily]
)
const onInputUpdate = useCallback(
diff --git a/src/components/Global/TokenSelector/TokenSelector.consts.ts b/src/components/Global/TokenSelector/TokenSelector.consts.ts
index a02a5601d0..cee368c2c3 100644
--- a/src/components/Global/TokenSelector/TokenSelector.consts.ts
+++ b/src/components/Global/TokenSelector/TokenSelector.consts.ts
@@ -116,4 +116,8 @@ export const RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN: Record = ({ classNameButton, viewT
// state for image loading errors
const [buttonImageError, setButtonImageError] = useState(false)
const {
- supportedChainsAndTokens,
+ supportedChainsAndTokens: contextChainsAndTokens,
setSelectedTokenAddress,
setSelectedChainID,
selectedTokenAddress,
selectedChainID,
} = useContext(tokenSelectorContext)
+ // Withdraw mode also offers non-EVM destinations (Solana/Tron) that have
+ // no chain-details entry — merge their synthetic records so every internal
+ // lookup (network list, token list, button display) resolves them. Other
+ // modes must NOT see them: sources/claims assume EVM addresses + wagmi.
+ const supportedChainsAndTokens = useMemo(
+ () => (restrictToRhino ? { ...contextChainsAndTokens, ...NON_EVM_WITHDRAW_CHAINS } : contextChainsAndTokens),
+ [contextChainsAndTokens, restrictToRhino]
+ )
+
// drawer utility functions
const openDrawer = useCallback(() => setIsDrawerOpen(true), [])
const closeDrawer = useCallback(() => {
@@ -131,7 +141,13 @@ const TokenSelector: React.FC = ({ classNameButton, viewT
// selected network name memo, being used ui
const selectedNetworkName = useMemo(() => {
if (!selectedChainID) return null
- return getChainName(selectedChainID) || `Chain ${selectedChainID}`
+ // record first — non-EVM slugs ('solana'/'tron') aren't in the
+ // chain-details-backed getChainName lookup
+ return (
+ supportedChainsAndTokens?.[selectedChainID]?.networkName ||
+ getChainName(selectedChainID) ||
+ `Chain ${selectedChainID}`
+ )
}, [selectedChainID, supportedChainsAndTokens])
const peanutWalletTokenDetails = useMemo(() => {
@@ -444,7 +460,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT
setSearchValue={setNetworkSearchValue}
selectedChainID={selectedChainID}
allowedChainIds={allowedChainIds}
- comingSoonNetworks={TOKEN_SELECTOR_COMING_SOON_NETWORKS}
+ comingSoonNetworks={restrictToRhino ? [] : TOKEN_SELECTOR_COMING_SOON_NETWORKS}
/>
) : (
diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts
index 333e8a2742..0417b2c5c7 100644
--- a/src/components/TransactionDetails/transactionTransformer.ts
+++ b/src/components/TransactionDetails/transactionTransformer.ts
@@ -250,7 +250,15 @@ function computeDerivedFields(entry: HistoryEntry): {
// (Arbitrum) — the underlying chainId field is the deposit-source chain.
const explorerUrlChainID =
intentKindOf(entry) === 'CRYPTO_DEPOSIT' ? PEANUT_WALLET_CHAIN.id.toString() : entry.chainId
- const baseUrl = getExplorerUrl(explorerUrlChainID)
+ 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())
+ }
let explorerUrlWithTx: string | undefined
let addressExplorerUrl: string | undefined
diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx
index 822a4dfe6a..489bbd2a92 100644
--- a/src/components/Withdraw/views/Initial.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx
@@ -14,6 +14,9 @@ import { useRouter } from 'next/navigation'
import { useContext, useEffect } from 'react'
import TokenSelector from '@/components/Global/TokenSelector/TokenSelector'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
+import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
+import { addressFamilyForChainId } from '@/lib/validation/addressFamily'
+import { useMemo, useRef } from 'react'
interface InitialWithdrawViewProps {
amount: string
@@ -44,8 +47,22 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
setError,
} = useWithdrawFlow()
+ // Non-EVM destinations (Solana/Tron) drive the recipient input's address
+ // family; changing family invalidates whatever address was typed.
+ const addressFamily = useMemo(() => addressFamilyForChainId(selectedChainID), [selectedChainID])
+ const prevFamilyRef = useRef(addressFamily)
+ useEffect(() => {
+ if (prevFamilyRef.current !== addressFamily) {
+ prevFamilyRef.current = addressFamily
+ setRecipient({ name: undefined, address: '' })
+ setIsValidRecipient(false)
+ }
+ }, [addressFamily, setRecipient, setIsValidRecipient])
+
const handleReview = () => {
- const xchainChainData = supportedChainsAndTokens[selectedChainID]
+ // Solana/Tron have no chain-details entry — resolve from the synthetic
+ // non-EVM records the withdraw selector also uses.
+ const xchainChainData = supportedChainsAndTokens[selectedChainID] ?? NON_EVM_WITHDRAW_CHAINS[selectedChainID]
// supportedChainsAndTokens may not list the Peanut wallet chain on
// testnets / env-configured chains. Synthesize a minimal entry so the
// same-chain (no-bridge) path can proceed.
@@ -98,7 +115,9 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
}, [])
return (
-
+ // flex/gap shell per the page-layout rules — space-y on the outer div
+ // conflicts with centering and clipped the CTA on short viewports
+
@@ -114,7 +133,8 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
{
setRecipient(update.recipient)
diff --git a/src/constants/nonEvmWithdraw.consts.ts b/src/constants/nonEvmWithdraw.consts.ts
new file mode 100644
index 0000000000..b0fc0b09d8
--- /dev/null
+++ b/src/constants/nonEvmWithdraw.consts.ts
@@ -0,0 +1,66 @@
+import type { ChainWithTokens } from '@/interfaces/chain-meta'
+import { CHAIN_LOGOS, TOKEN_LOGOS } from '@/constants/rhino.consts'
+
+/**
+ * Non-EVM withdraw destinations (Rhino delivers; verified 2026-07-11 with
+ * live quotes + outflow-SDA creates: SOLANA USDC+USDT, TRON USDT-only).
+ *
+ * These chains have no EVM chainId and no chain-details.json entry, so the
+ * withdraw selector merges these synthetic entries in withdraw mode ONLY
+ * (`restrictToRhino`) — they must not leak into send/pay/claim surfaces or
+ * URL parsing, which assume EVM addresses and wagmi networks.
+ *
+ * The selector `chainId` is the slug ('solana' | 'tron') — the same
+ * identifier the old coming-soon entries used; `chainIdToRhinoName` maps it
+ * to Rhino's API chain name. Token addresses are the canonical SPL mints /
+ * TRC20 contract (mirrors peanut-api-ts `src/rhino/consts.ts`); Rhino
+ * resolves tokens by SYMBOL, the address here is for selector display and
+ * identity only.
+ */
+export const NON_EVM_WITHDRAW_CHAINS: Record = {
+ solana: {
+ chainId: 'solana',
+ networkName: 'Solana',
+ chainIconURI: CHAIN_LOGOS.SOLANA,
+ tokens: [
+ {
+ chainId: 'solana',
+ address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ logoURI: TOKEN_LOGOS.USDC,
+ usdPrice: 0,
+ },
+ {
+ chainId: 'solana',
+ address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
+ decimals: 6,
+ name: 'Tether USD',
+ symbol: 'USDT',
+ logoURI: TOKEN_LOGOS.USDT,
+ usdPrice: 0,
+ },
+ ],
+ },
+ tron: {
+ chainId: 'tron',
+ networkName: 'Tron',
+ chainIconURI: CHAIN_LOGOS.TRON,
+ tokens: [
+ {
+ chainId: 'tron',
+ address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ decimals: 6,
+ name: 'Tether USD',
+ symbol: 'USDT',
+ logoURI: TOKEN_LOGOS.USDT,
+ usdPrice: 0,
+ },
+ ],
+ },
+}
+
+export function isNonEvmWithdrawChainId(chainId: string | number): boolean {
+ return String(chainId).toLowerCase() in NON_EVM_WITHDRAW_CHAINS
+}
diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts
index d7e9df0e86..377da449d2 100644
--- a/src/constants/rhino.consts.ts
+++ b/src/constants/rhino.consts.ts
@@ -94,6 +94,20 @@ const SUPPORTED_TOKENS_BY_NETWORK: Record = {
TRON: ['USDT'],
}
+/**
+ * EVM deposit chains where Rhino accepts FEWER tokens than the EVM family
+ * list above. A token sent on a chain where Rhino doesn't accept it is
+ * silently lost (no webhook, no intent), so deposit surfaces annotate these.
+ * Source: Rhino's live SDA catalog (getSupportedConfigs, 2026-07-11).
+ */
+export const EVM_DEPOSIT_TOKEN_EXCEPTIONS: Partial> = {
+ KAIA: ['USDT'],
+ PLASMA: ['USDT'],
+ TEMPO: ['USDT', 'USDC'],
+ CELO: ['USDT', 'USDC'],
+ GNOSIS: ['USDT', 'USDC'],
+}
+
/** returns supported tokens (with logos) for a given chain type */
export const getSupportedTokens = (network: RhinoChainType): Array<{ name: TokenName; logoUrl: string }> =>
SUPPORTED_TOKENS_BY_NETWORK[network].map((name) => ({ name, logoUrl: TOKEN_LOGOS[name] }))
@@ -145,3 +159,19 @@ export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = {
export function evmChainIdToRhinoName(chainId: string | number): string | undefined {
return EVM_CHAIN_ID_TO_RHINO_NAME[String(chainId)]
}
+
+/**
+ * Non-EVM withdraw destinations use string slugs as their selector chainId
+ * ('solana' | 'tron' — the identifiers the old coming-soon entries used).
+ * Chain data lives in `nonEvmWithdraw.consts.ts`.
+ */
+export const NON_EVM_CHAIN_ID_TO_RHINO_NAME: Record = {
+ solana: 'SOLANA',
+ tron: 'TRON',
+}
+
+/** chainId (EVM numeric or non-EVM slug) → Rhino API chain name. */
+export function chainIdToRhinoName(chainId: string | number): string | undefined {
+ const key = String(chainId)
+ return EVM_CHAIN_ID_TO_RHINO_NAME[key] ?? NON_EVM_CHAIN_ID_TO_RHINO_NAME[key.toLowerCase()]
+}
diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
index 906c5f2e37..031756ba8b 100644
--- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts
+++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
@@ -44,7 +44,8 @@ import {
type BridgeCommitResponse,
type BridgeStatusResponse,
} from '@/services/rhino-bridge'
-import { evmChainIdToRhinoName } from '@/constants/rhino.consts'
+import { chainIdToRhinoName } from '@/constants/rhino.consts'
+import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
import { areEvmAddressesEqual, getTokenSymbol } from '@/utils/general.utils'
/** Tokens Rhino's SDA primitive accepts as `tokenOut`. Anything else routes
@@ -80,8 +81,13 @@ export interface CrossChainSourceInfo {
}
export interface CrossChainDestinationInfo {
- recipientAddress: Address
- tokenAddress: Address
+ /** 0x for EVM destinations, base58 for Solana/Tron. Only forwarded to the
+ * backend/Rhino — cross-chain tx construction never uses it (the user's
+ * tx is an ERC20 transfer to the SDA on Arbitrum). The same-chain path
+ * narrows it back to an EVM Address (non-EVM can never be same-chain). */
+ recipientAddress: string
+ /** 0x for EVM tokens, base58 mint / TRC20 for non-EVM destinations. */
+ tokenAddress: string
tokenAmount: string
tokenDecimals: number
tokenType: number
@@ -148,13 +154,19 @@ interface CalculateInput {
skipGasEstimate?: boolean
}
-function inferTokenSymbol(chainId: string, tokenAddress: Address): RhinoSupportedToken | undefined {
+function inferTokenSymbol(chainId: string, tokenAddress: string): RhinoSupportedToken | undefined {
+ // Non-EVM destinations resolve from their synthetic chain records —
+ // token-details.json only knows EVM chains.
+ const nonEvm = NON_EVM_WITHDRAW_CHAINS[String(chainId).toLowerCase()]
+ if (nonEvm) {
+ return nonEvm.tokens.find((t) => t.address.toLowerCase() === tokenAddress.toLowerCase())?.symbol.toUpperCase()
+ }
// Whatever the curated FE list calls the token (USDC, USDT, ETH, WETH, …);
// backend forwards it to Rhino, which validates against its own per-route
// supported-tokens map. Native ETH on EVM uses the SAME 'ETH' symbol —
// address differs by chain (proxy 0xeee… or zero), but Rhino keys on the
// symbol.
- return getTokenSymbol(tokenAddress, chainId)?.toUpperCase()
+ return getTokenSymbol(tokenAddress as Address, chainId)?.toUpperCase()
}
export function useCrossChainTransfer(): UseCrossChainTransferReturn {
@@ -261,8 +273,8 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
return
}
- const sourceRhinoChain = evmChainIdToRhinoName(source.chainId)
- const destRhinoChain = evmChainIdToRhinoName(destination.chainId)
+ const sourceRhinoChain = chainIdToRhinoName(source.chainId)
+ const destRhinoChain = chainIdToRhinoName(destination.chainId)
if (!sourceRhinoChain || !destRhinoChain) {
throw new Error(
`Unsupported Rhino chain mapping (src=${source.chainId} dest=${destination.chainId})`
@@ -524,8 +536,10 @@ async function buildSameChainTx({
skipGasEstimate,
}: SameChainParams): Promise {
const tx = prepareRequestLinkFulfillmentTransaction({
- recipientAddress: destination.recipientAddress,
- tokenAddress: destination.tokenAddress,
+ // same-chain is EVM-only by construction (source is the Arbitrum
+ // Peanut wallet; non-EVM destinations are always cross-chain)
+ recipientAddress: destination.recipientAddress as Address,
+ tokenAddress: destination.tokenAddress as Address,
tokenAmount: destination.tokenAmount,
tokenDecimals: destination.tokenDecimals,
tokenType: destination.tokenType as peanutInterfaces.EPeanutLinkType,
diff --git a/src/lib/validation/__tests__/addressFamily.test.ts b/src/lib/validation/__tests__/addressFamily.test.ts
new file mode 100644
index 0000000000..5c7039aba1
--- /dev/null
+++ b/src/lib/validation/__tests__/addressFamily.test.ts
@@ -0,0 +1,52 @@
+import { addressFamilyForChainId, isValidAddressForFamily } from '../addressFamily'
+
+// Real addresses: canonical USDC mint (Solana), canonical USDT contract (Tron)
+const SOLANA_ADDR = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const TRON_ADDR = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'
+// lowercase — viem isAddress is checksum-strict on mixed-case input
+const EVM_ADDR = '0xb44401be236a81fcf8437ea917035e0934fda196'
+
+describe('addressFamilyForChainId', () => {
+ it('maps non-EVM slugs to their family', () => {
+ expect(addressFamilyForChainId('solana')).toBe('solana')
+ expect(addressFamilyForChainId('tron')).toBe('tron')
+ expect(addressFamilyForChainId('SOLANA')).toBe('solana')
+ })
+
+ it('maps EVM numeric ids (and null/undefined) to evm', () => {
+ expect(addressFamilyForChainId('42161')).toBe('evm')
+ expect(addressFamilyForChainId(43114)).toBe('evm')
+ expect(addressFamilyForChainId(null)).toBe('evm')
+ expect(addressFamilyForChainId(undefined)).toBe('evm')
+ })
+})
+
+describe('isValidAddressForFamily', () => {
+ it('validates real addresses in their own family', () => {
+ expect(isValidAddressForFamily(SOLANA_ADDR, 'solana')).toBe(true)
+ expect(isValidAddressForFamily(TRON_ADDR, 'tron')).toBe(true)
+ expect(isValidAddressForFamily(EVM_ADDR, 'evm')).toBe(true)
+ })
+
+ it('rejects cross-family inputs', () => {
+ expect(isValidAddressForFamily(EVM_ADDR, 'solana')).toBe(false)
+ expect(isValidAddressForFamily(EVM_ADDR, 'tron')).toBe(false)
+ expect(isValidAddressForFamily(SOLANA_ADDR, 'evm')).toBe(false)
+ expect(isValidAddressForFamily(SOLANA_ADDR, 'tron')).toBe(false)
+ // NOTE: a Tron address IS valid base58 in Solana's length range — the
+ // family always comes from the selected chain, never string-sniffed.
+ expect(isValidAddressForFamily(TRON_ADDR, 'evm')).toBe(false)
+ })
+
+ it('rejects malformed base58 (0, O, I, l are not in the alphabet)', () => {
+ expect(isValidAddressForFamily('0PjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', 'solana')).toBe(false)
+ expect(isValidAddressForFamily('TOOOOqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', 'tron')).toBe(false)
+ expect(isValidAddressForFamily('', 'solana')).toBe(false)
+ expect(isValidAddressForFamily('short', 'solana')).toBe(false)
+ })
+
+ it('rejects tron addresses without the T prefix / wrong length', () => {
+ expect(isValidAddressForFamily(TRON_ADDR.slice(1), 'tron')).toBe(false)
+ expect(isValidAddressForFamily(TRON_ADDR + 'a', 'tron')).toBe(false)
+ })
+})
diff --git a/src/lib/validation/addressFamily.ts b/src/lib/validation/addressFamily.ts
new file mode 100644
index 0000000000..c64fdf6bd5
--- /dev/null
+++ b/src/lib/validation/addressFamily.ts
@@ -0,0 +1,33 @@
+import { isAddress } from 'viem'
+import { isNonEvmWithdrawChainId } from '@/constants/nonEvmWithdraw.consts'
+
+/**
+ * Address families for withdraw destinations. EVM chains share one 0x
+ * format; Solana and Tron each have their own base58 shapes. The family is
+ * always derived from the SELECTED chain — never inferred from the address
+ * string alone (every Tron address also matches the Solana length range).
+ */
+export type WithdrawAddressFamily = 'evm' | 'solana' | 'tron'
+
+/** Base58 (no 0/O/I/l), 32–44 chars — Solana ed25519 account. */
+export const SOLANA_ADDRESS_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
+/** Base58check mainnet Tron address — 'T' + 33 chars. */
+export const TRON_ADDRESS_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/
+
+export function addressFamilyForChainId(chainId?: string | number | null): WithdrawAddressFamily {
+ if (chainId != null && isNonEvmWithdrawChainId(chainId)) {
+ return String(chainId).toLowerCase() as WithdrawAddressFamily
+ }
+ return 'evm'
+}
+
+export function isValidAddressForFamily(address: string, family: WithdrawAddressFamily): boolean {
+ switch (family) {
+ case 'solana':
+ return SOLANA_ADDRESS_REGEX.test(address)
+ case 'tron':
+ return TRON_ADDRESS_REGEX.test(address)
+ case 'evm':
+ return isAddress(address)
+ }
+}
diff --git a/src/lib/validation/recipient.ts b/src/lib/validation/recipient.ts
index cd933960e6..09835c44aa 100644
--- a/src/lib/validation/recipient.ts
+++ b/src/lib/validation/recipient.ts
@@ -8,11 +8,23 @@ import { serverFetch } from '@/utils/api-fetch'
import * as Sentry from '@sentry/nextjs'
import { RecipientValidationError } from '../url-parser/errors'
import { type RecipientType } from '../url-parser/types/payment'
+import { isValidAddressForFamily, type WithdrawAddressFamily } from './addressFamily'
export async function validateAndResolveRecipient(
recipient: string,
- isWithdrawal: boolean = false
+ isWithdrawal: boolean = false,
+ addressFamily: WithdrawAddressFamily = 'evm'
): Promise<{ identifier: string; recipientType: RecipientType; resolvedAddress: string }> {
+ // Non-EVM withdraw destinations (Solana/Tron): a base58 address is the
+ // only valid input — no ENS, no usernames. The family comes from the
+ // selected destination chain, never inferred from the string.
+ if (addressFamily !== 'evm') {
+ if (!isValidAddressForFamily(recipient, addressFamily)) {
+ throw new RecipientValidationError(`Invalid ${addressFamily === 'solana' ? 'Solana' : 'Tron'} address`)
+ }
+ return { identifier: recipient, recipientType: 'ADDRESS', resolvedAddress: recipient }
+ }
+
const recipientType = getRecipientType(recipient, isWithdrawal)
switch (recipientType) {
diff --git a/src/services/rhino-sda.ts b/src/services/rhino-sda.ts
index 8fa610b289..ec16f486b9 100644
--- a/src/services/rhino-sda.ts
+++ b/src/services/rhino-sda.ts
@@ -30,7 +30,9 @@ export interface SdaTransferRequest {
/** Rhino chain name (e.g. ARBITRUM, BASE). */
depositChain: string
destinationChain: string
- destinationAddress: Address
+ /** 0x for EVM destinations, base58 for Solana/Tron — the BE forwards it
+ * to Rhino, which validates per destination chain. */
+ destinationAddress: string
tokenOut: RhinoSupportedToken
senderPeanutWalletAddress?: Address
/**
From 83ed62acb43f09727f9de808f81d7fe4af3fbbd2 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 11 Jul 2026 10:58:53 -0700
Subject: [PATCH 13/19] feat: per-chain rollout flags (PostHog) for one-by-one
chain launches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../components/ChooseNetworkDrawer.tsx | 4 +-
.../components/SupportedNetworksModal.tsx | 4 +-
.../Global/TokenSelector/TokenSelector.tsx | 8 ++-
src/hooks/useChainRollout.ts | 62 +++++++++++++++++++
4 files changed, 74 insertions(+), 4 deletions(-)
create mode 100644 src/hooks/useChainRollout.ts
diff --git a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
index 30a12ebf5c..f3cfc2a00f 100644
--- a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
+++ b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
@@ -9,6 +9,7 @@ import {
getSupportedTokens,
EVM_DEPOSIT_TOKEN_EXCEPTIONS,
} from '@/constants/rhino.consts'
+import { useChainRollout } from '@/hooks/useChainRollout'
import type { RhinoChainType } from '@/services/services.types'
import Image from 'next/image'
@@ -19,6 +20,7 @@ interface ChooseNetworkDrawerProps {
}
const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerProps) => {
+ const isChainRolledOut = useChainRollout()
return (
!isOpen && onClose()}>
@@ -49,7 +51,7 @@ const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerPro
{/* expanded chain list */}
onSelect('EVM')} className="mx-4 border-t border-dashed border-black py-3">
- {SUPPORTED_EVM_CHAINS.map((chain) => {
+ {SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).map((chain) => {
const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain]
const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain
return
diff --git a/src/components/AddMoney/components/SupportedNetworksModal.tsx b/src/components/AddMoney/components/SupportedNetworksModal.tsx
index 2d1cccf24d..41210cdcc1 100644
--- a/src/components/AddMoney/components/SupportedNetworksModal.tsx
+++ b/src/components/AddMoney/components/SupportedNetworksModal.tsx
@@ -4,6 +4,7 @@ import Modal from '@/components/Global/Modal'
import InfoCard from '@/components/Global/InfoCard'
import ChainChip from './ChainChip'
import { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS, EVM_DEPOSIT_TOKEN_EXCEPTIONS } from '@/constants/rhino.consts'
+import { useChainRollout } from '@/hooks/useChainRollout'
interface SupportedNetworksModalProps {
visible: boolean
@@ -11,6 +12,7 @@ interface SupportedNetworksModalProps {
}
const SupportedNetworksModal = ({ visible, onClose }: SupportedNetworksModalProps) => {
+ const isChainRolledOut = useChainRollout()
return (
- {SUPPORTED_EVM_CHAINS.map((chain) => {
+ {SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).map((chain) => {
const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain]
const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain
return
diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx
index 5d83e8af33..412ce46d91 100644
--- a/src/components/Global/TokenSelector/TokenSelector.tsx
+++ b/src/components/Global/TokenSelector/TokenSelector.tsx
@@ -34,6 +34,7 @@ import {
TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS,
} from './TokenSelector.consts'
import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
+import { useChainRollout } from '@/hooks/useChainRollout'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
import underMaintenanceConfig from '@/config/underMaintenance.config'
@@ -190,14 +191,17 @@ const TokenSelector: React.FC
= ({ classNameButton, viewT
// list — the destination needs no wallet connection or balance reads, and
// several deliverable chains (Avalanche, Linea, Ink, …) are intentionally
// not source chains. Names/icons come from supportedChainsAndTokens.
+ // Per-chain rollout flags (PostHog) gate the newly-added withdraw
+ // destinations on prod so marketing can launch chains one by one.
+ const isChainRolledOut = useChainRollout()
const allowedChainIds = useMemo(
() =>
new Set(
restrictToRhino
- ? Object.keys(RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN)
+ ? Object.keys(RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN).filter(isChainRolledOut)
: TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS
),
- [restrictToRhino]
+ [restrictToRhino, isChainRolledOut]
)
const popularChainsForButtons = useMemo(() => {
diff --git a/src/hooks/useChainRollout.ts b/src/hooks/useChainRollout.ts
new file mode 100644
index 0000000000..267a2939af
--- /dev/null
+++ b/src/hooks/useChainRollout.ts
@@ -0,0 +1,62 @@
+'use client'
+import { useEffect, useReducer } from 'react'
+import posthog from 'posthog-js'
+import { BASE_URL } from '@/constants/general.consts'
+
+/**
+ * Per-chain rollout toggles for the Rhino chain expansion — one PostHog
+ * feature flag per chain so marketing can enable chains one by one with a
+ * click (no deploy). Keyed by every identifier a chain appears under in the
+ * selector/deposit surfaces (EVM numeric chainId, non-EVM slug, deposit
+ * ChainName) so one flag governs all surfaces of the same chain.
+ *
+ * Semantics:
+ * - chains NOT in this map (the legacy set) are always on
+ * - outside the prod domain (staging/preview/local) everything is ON — QA
+ * must be able to test a chain before its public launch
+ * - on prod, a chain shows only when its flag is enabled; if PostHog is
+ * unavailable (adblock, outage) new chains stay hidden (fail-closed —
+ * a rollout gate must never fail into "launched")
+ */
+export const CHAIN_ROLLOUT_FLAGS: Record = {
+ // withdraw destinations (EVM chainId keys)
+ '43114': 'chain-rollout-avalanche',
+ '999': 'chain-rollout-hyperevm',
+ '57073': 'chain-rollout-ink',
+ '747474': 'chain-rollout-katana',
+ '59144': 'chain-rollout-linea',
+ '5000': 'chain-rollout-mantle',
+ '9745': 'chain-rollout-plasma',
+ '988': 'chain-rollout-stable',
+ '4217': 'chain-rollout-tempo',
+ // withdraw destinations (non-EVM slugs)
+ solana: 'chain-rollout-solana',
+ tron: 'chain-rollout-tron',
+ // deposit chains (ChainName keys — same flag as the withdraw side where
+ // the chain supports both, so one toggle launches the whole chain)
+ TEMPO: 'chain-rollout-tempo',
+ KAIA: 'chain-rollout-kaia',
+ PLASMA: 'chain-rollout-plasma',
+}
+
+const IS_PROD_DOMAIN = BASE_URL === 'https://peanut.me'
+
+export function isChainRolledOut(chainKey: string): boolean {
+ const flag = CHAIN_ROLLOUT_FLAGS[chainKey]
+ if (!flag) return true
+ if (!IS_PROD_DOMAIN) return true
+ return posthog.isFeatureEnabled(flag) ?? false
+}
+
+/**
+ * Reactive variant: re-renders once PostHog's flags load (they arrive async
+ * after page load), so gated chains pop in rather than requiring a refresh.
+ */
+export function useChainRollout(): (chainKey: string) => boolean {
+ const [, bump] = useReducer((n: number) => n + 1, 0)
+ useEffect(() => {
+ // returns an unsubscribe function
+ return posthog.onFeatureFlags(() => bump())
+ }, [])
+ return isChainRolledOut
+}
From f5e215665b310c70f69f72b0224c386300fc34bb Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 11 Jul 2026 11:06:20 -0700
Subject: [PATCH 14/19] refactor: general useFeatureFlag primitive +
useChainRollout as thin 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.
---
src/hooks/useChainRollout.ts | 38 +++++++++---------------------
src/hooks/useFeatureFlag.ts | 45 ++++++++++++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 27 deletions(-)
create mode 100644 src/hooks/useFeatureFlag.ts
diff --git a/src/hooks/useChainRollout.ts b/src/hooks/useChainRollout.ts
index 267a2939af..30a2f34f90 100644
--- a/src/hooks/useChainRollout.ts
+++ b/src/hooks/useChainRollout.ts
@@ -1,22 +1,16 @@
'use client'
-import { useEffect, useReducer } from 'react'
-import posthog from 'posthog-js'
-import { BASE_URL } from '@/constants/general.consts'
+import { isFeatureFlagEnabled, useFeatureFlags } from '@/hooks/useFeatureFlag'
/**
* Per-chain rollout toggles for the Rhino chain expansion — one PostHog
- * feature flag per chain so marketing can enable chains one by one with a
- * click (no deploy). Keyed by every identifier a chain appears under in the
- * selector/deposit surfaces (EVM numeric chainId, non-EVM slug, deposit
- * ChainName) so one flag governs all surfaces of the same chain.
+ * feature flag per chain so marketing can launch chains one by one with a
+ * click (no deploy). Thin domain wrapper over `useFeatureFlag`; the map is
+ * keyed by every identifier a chain appears under (EVM numeric chainId,
+ * non-EVM slug, deposit ChainName) so one flag governs all surfaces of the
+ * same chain. Chains NOT in this map (the legacy set) are always on.
*
- * Semantics:
- * - chains NOT in this map (the legacy set) are always on
- * - outside the prod domain (staging/preview/local) everything is ON — QA
- * must be able to test a chain before its public launch
- * - on prod, a chain shows only when its flag is enabled; if PostHog is
- * unavailable (adblock, outage) new chains stay hidden (fail-closed —
- * a rollout gate must never fail into "launched")
+ * Hygiene: once a chain is permanently launched, delete its entry here and
+ * its flag in PostHog — flags are scaffolding, not architecture.
*/
export const CHAIN_ROLLOUT_FLAGS: Record = {
// withdraw destinations (EVM chainId keys)
@@ -39,24 +33,14 @@ export const CHAIN_ROLLOUT_FLAGS: Record = {
PLASMA: 'chain-rollout-plasma',
}
-const IS_PROD_DOMAIN = BASE_URL === 'https://peanut.me'
-
export function isChainRolledOut(chainKey: string): boolean {
const flag = CHAIN_ROLLOUT_FLAGS[chainKey]
if (!flag) return true
- if (!IS_PROD_DOMAIN) return true
- return posthog.isFeatureEnabled(flag) ?? false
+ return isFeatureFlagEnabled(flag, { nonProdBypass: true })
}
-/**
- * Reactive variant: re-renders once PostHog's flags load (they arrive async
- * after page load), so gated chains pop in rather than requiring a refresh.
- */
+/** Reactive variant — re-renders when PostHog's flags load. */
export function useChainRollout(): (chainKey: string) => boolean {
- const [, bump] = useReducer((n: number) => n + 1, 0)
- useEffect(() => {
- // returns an unsubscribe function
- return posthog.onFeatureFlags(() => bump())
- }, [])
+ useFeatureFlags()
return isChainRolledOut
}
diff --git a/src/hooks/useFeatureFlag.ts b/src/hooks/useFeatureFlag.ts
new file mode 100644
index 0000000000..44efd14b6c
--- /dev/null
+++ b/src/hooks/useFeatureFlag.ts
@@ -0,0 +1,45 @@
+'use client'
+import { useEffect, useReducer } from 'react'
+import posthog from 'posthog-js'
+import { BASE_URL } from '@/constants/general.consts'
+
+/**
+ * PostHog feature flags — the runtime-toggle primitive.
+ *
+ * DOCTRINE (see engineering/patterns/feature-gates.md in mono): a PostHog
+ * flag answers "have we LAUNCHED this?" — flipped in the PostHog UI with no
+ * deploy, supports cohort/% targeting. It is NOT a kill-switch: incident
+ * switches stay in code (`underMaintenance.config.ts`) because the emergency
+ * brake must not depend on a third-party SaaS. Flags are scaffolding —
+ * delete them once a launch is permanent.
+ *
+ * Failure semantics are per-feature via options:
+ * - rollout gates want `nonProdBypass` (staging/preview/local always ON so
+ * QA can test pre-launch) and fail CLOSED on prod when PostHog is
+ * unavailable — a rollout gate must never fail into "launched".
+ */
+const IS_PROD_DOMAIN = BASE_URL === 'https://peanut.me'
+
+export interface FeatureFlagOptions {
+ /** Treat the flag as ON outside the prod domain (rollout-gate semantics). */
+ nonProdBypass?: boolean
+}
+
+export function isFeatureFlagEnabled(flagKey: string, options: FeatureFlagOptions = {}): boolean {
+ if (options.nonProdBypass && !IS_PROD_DOMAIN) return true
+ return posthog.isFeatureEnabled(flagKey) ?? false
+}
+
+/**
+ * Reactive read of PostHog feature flags: re-renders once flags load (they
+ * arrive async after page load) so gated UI pops in without a refresh.
+ * Returns a checker so one subscription serves any number of flags.
+ */
+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
+}
From ea63b6cab0407695e5b4cfc6da06360cf8fdb651 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 13 Jul 2026 05:58:11 +0100
Subject: [PATCH 15/19] =?UTF-8?q?fix:=20final-review=20FE=20corrections=20?=
=?UTF-8?q?=E2=80=94=20context-level=20non-EVM=20merge,=20reactive=20rollo?=
=?UTF-8?q?ut=20gate,=20CLAUDE.md=20structure?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../components/ChooseNetworkDrawer.tsx | 20 +++----
.../AddMoney/components/EvmChainChips.tsx | 24 ++++++++
.../components/SupportedNetworksModal.tsx | 11 +---
.../Global/TokenSelector/TokenSelector.tsx | 12 +---
.../Withdraw/views/Initial.withdraw.view.tsx | 10 ++--
src/constants/chainRollout.consts.ts | 30 ++++++++++
src/context/tokenSelector.context.tsx | 17 +++++-
src/hooks/__tests__/useChainRollout.test.tsx | 59 +++++++++++++++++++
src/hooks/useChainRollout.ts | 56 +++++-------------
src/hooks/useFeatureFlag.ts | 48 +++++----------
src/utils/featureFlag.utils.ts | 35 +++++++++++
11 files changed, 207 insertions(+), 115 deletions(-)
create mode 100644 src/components/AddMoney/components/EvmChainChips.tsx
create mode 100644 src/constants/chainRollout.consts.ts
create mode 100644 src/hooks/__tests__/useChainRollout.test.tsx
create mode 100644 src/utils/featureFlag.utils.ts
diff --git a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
index f3cfc2a00f..0f1c3dd951 100644
--- a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
+++ b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
@@ -2,13 +2,8 @@
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/Global/Drawer'
import { ActionListCard } from '@/components/ActionListCard'
-import ChainChip from './ChainChip'
-import {
- CHAIN_LOGOS,
- SUPPORTED_EVM_CHAINS,
- getSupportedTokens,
- EVM_DEPOSIT_TOKEN_EXCEPTIONS,
-} from '@/constants/rhino.consts'
+import EvmChainChips from './EvmChainChips'
+import { CHAIN_LOGOS, SUPPORTED_EVM_CHAINS, getSupportedTokens } from '@/constants/rhino.consts'
import { useChainRollout } from '@/hooks/useChainRollout'
import type { RhinoChainType } from '@/services/services.types'
import Image from 'next/image'
@@ -20,7 +15,10 @@ interface ChooseNetworkDrawerProps {
}
const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerProps) => {
+ // Count only rolled-out chains — the chips below are gated the same way,
+ // and "12 Networks" above 10 visible chips would be a lie.
const isChainRolledOut = useChainRollout()
+ const evmChainCount = SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).length
return (
!isOpen && onClose()}>
@@ -34,7 +32,7 @@ const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerPro
onSelect('EVM')} className="mx-4 border-t border-dashed border-black py-3">
- {SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).map((chain) => {
- const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain]
- const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain
- return
- })}
+
diff --git a/src/components/AddMoney/components/EvmChainChips.tsx b/src/components/AddMoney/components/EvmChainChips.tsx
new file mode 100644
index 0000000000..149fe10d68
--- /dev/null
+++ b/src/components/AddMoney/components/EvmChainChips.tsx
@@ -0,0 +1,24 @@
+import ChainChip from './ChainChip'
+import { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS, EVM_DEPOSIT_TOKEN_EXCEPTIONS } from '@/constants/rhino.consts'
+import { useChainRollout } from '@/hooks/useChainRollout'
+
+/**
+ * The rollout-gated EVM deposit chain chips, annotated with per-chain token
+ * exceptions (USDT-only chains) — a USDC deposit on a chain where Rhino only
+ * accepts USDT has no webhook, so the annotation is a funds-safety surface,
+ * not decoration. Shared by ChooseNetworkDrawer and SupportedNetworksModal.
+ */
+const EvmChainChips = () => {
+ const isChainRolledOut = useChainRollout()
+ return (
+ <>
+ {SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).map((chain) => {
+ const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain]
+ const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain
+ return
+ })}
+ >
+ )
+}
+
+export default EvmChainChips
diff --git a/src/components/AddMoney/components/SupportedNetworksModal.tsx b/src/components/AddMoney/components/SupportedNetworksModal.tsx
index 41210cdcc1..777af51c86 100644
--- a/src/components/AddMoney/components/SupportedNetworksModal.tsx
+++ b/src/components/AddMoney/components/SupportedNetworksModal.tsx
@@ -2,9 +2,7 @@
import Modal from '@/components/Global/Modal'
import InfoCard from '@/components/Global/InfoCard'
-import ChainChip from './ChainChip'
-import { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS, EVM_DEPOSIT_TOKEN_EXCEPTIONS } from '@/constants/rhino.consts'
-import { useChainRollout } from '@/hooks/useChainRollout'
+import EvmChainChips from './EvmChainChips'
interface SupportedNetworksModalProps {
visible: boolean
@@ -12,7 +10,6 @@ interface SupportedNetworksModalProps {
}
const SupportedNetworksModal = ({ visible, onClose }: SupportedNetworksModalProps) => {
- const isChainRolledOut = useChainRollout()
return (
- {SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).map((chain) => {
- const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain]
- const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain
- return
- })}
+
= ({ classNameButton, viewT
// state for image loading errors
const [buttonImageError, setButtonImageError] = useState(false)
const {
- supportedChainsAndTokens: contextChainsAndTokens,
+ supportedChainsAndTokens,
setSelectedTokenAddress,
setSelectedChainID,
selectedTokenAddress,
selectedChainID,
} = useContext(tokenSelectorContext)
- // Withdraw mode also offers non-EVM destinations (Solana/Tron) that have
- // no chain-details entry — merge their synthetic records so every internal
- // lookup (network list, token list, button display) resolves them. Other
- // modes must NOT see them: sources/claims assume EVM addresses + wagmi.
- const supportedChainsAndTokens = useMemo(
- () => (restrictToRhino ? { ...contextChainsAndTokens, ...NON_EVM_WITHDRAW_CHAINS } : contextChainsAndTokens),
- [contextChainsAndTokens, restrictToRhino]
- )
-
// drawer utility functions
const openDrawer = useCallback(() => setIsDrawerOpen(true), [])
const closeDrawer = useCallback(() => {
diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx
index 489bbd2a92..2231f1df46 100644
--- a/src/components/Withdraw/views/Initial.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx
@@ -11,12 +11,10 @@ import { type ITokenPriceData } from '@/interfaces'
import type { ChainWithTokens } from '@/interfaces/chain-meta'
import { formatAmount } from '@/utils/general.utils'
import { useRouter } from 'next/navigation'
-import { useContext, useEffect } from 'react'
+import { useContext, useEffect, useMemo, useRef } from 'react'
import TokenSelector from '@/components/Global/TokenSelector/TokenSelector'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
-import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
import { addressFamilyForChainId } from '@/lib/validation/addressFamily'
-import { useMemo, useRef } from 'react'
interface InitialWithdrawViewProps {
amount: string
@@ -60,9 +58,9 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
}, [addressFamily, setRecipient, setIsValidRecipient])
const handleReview = () => {
- // Solana/Tron have no chain-details entry — resolve from the synthetic
- // non-EVM records the withdraw selector also uses.
- const xchainChainData = supportedChainsAndTokens[selectedChainID] ?? NON_EVM_WITHDRAW_CHAINS[selectedChainID]
+ // Context record already includes the synthetic non-EVM withdraw
+ // destinations (merged once in tokenSelector.context).
+ const xchainChainData = supportedChainsAndTokens[selectedChainID]
// supportedChainsAndTokens may not list the Peanut wallet chain on
// testnets / env-configured chains. Synthesize a minimal entry so the
// same-chain (no-bridge) path can proceed.
diff --git a/src/constants/chainRollout.consts.ts b/src/constants/chainRollout.consts.ts
new file mode 100644
index 0000000000..dd6aa3c0cb
--- /dev/null
+++ b/src/constants/chainRollout.consts.ts
@@ -0,0 +1,30 @@
+/**
+ * Per-chain rollout toggles for the Rhino chain expansion — one PostHog
+ * feature flag per chain so marketing can launch chains one by one with a
+ * click (no deploy). Keyed by every identifier a chain appears under in the
+ * selector/deposit surfaces (EVM numeric chainId, non-EVM slug, deposit
+ * ChainName) so one flag governs all surfaces of the same chain.
+ *
+ * Hygiene: once a chain is permanently launched, delete its entry here and
+ * its flag in PostHog — flags are scaffolding, not architecture.
+ */
+export const CHAIN_ROLLOUT_FLAGS: Record = {
+ // withdraw destinations (EVM chainId keys)
+ '43114': 'chain-rollout-avalanche',
+ '999': 'chain-rollout-hyperevm',
+ '57073': 'chain-rollout-ink',
+ '747474': 'chain-rollout-katana',
+ '59144': 'chain-rollout-linea',
+ '5000': 'chain-rollout-mantle',
+ '9745': 'chain-rollout-plasma',
+ '988': 'chain-rollout-stable',
+ '4217': 'chain-rollout-tempo',
+ // withdraw destinations (non-EVM slugs)
+ solana: 'chain-rollout-solana',
+ tron: 'chain-rollout-tron',
+ // deposit chains (ChainName keys — same flag as the withdraw side where
+ // the chain supports both, so one toggle launches the whole chain)
+ TEMPO: 'chain-rollout-tempo',
+ KAIA: 'chain-rollout-kaia',
+ PLASMA: 'chain-rollout-plasma',
+}
diff --git a/src/context/tokenSelector.context.tsx b/src/context/tokenSelector.context.tsx
index 37760162a8..e3923215ec 100644
--- a/src/context/tokenSelector.context.tsx
+++ b/src/context/tokenSelector.context.tsx
@@ -1,5 +1,5 @@
'use client'
-import React, { createContext, useState, useCallback, useEffect } from 'react'
+import React, { createContext, useState, useCallback, useEffect, useMemo } from 'react'
import {
PEANUT_WALLET_CHAIN,
@@ -11,6 +11,7 @@ import {
} from '@/constants/zerodev.consts'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useSupportedChainsAndTokens } from '@/hooks/useSupportedChainsAndTokens'
+import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
import { useTokenPrice } from '@/hooks/useTokenPrice'
import { type ITokenPriceData } from '@/interfaces'
import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils'
@@ -73,7 +74,19 @@ export const TokenContextProvider = ({ children }: { children: React.ReactNode }
const [devconnectRecipientAddress, setDevconnectRecipientAddress] = useState('')
// Fetch supported chains and tokens (cached for 24 hours - static data)
- const { data: supportedChainsAndTokens = {} } = useSupportedChainsAndTokens()
+ const { data: fetchedChainsAndTokens = {} } = useSupportedChainsAndTokens()
+
+ // Merge the synthetic non-EVM withdraw destinations (Solana/Tron) here —
+ // the ONE record every selector surface AND the price hook read, so
+ // selectedTokenData resolves for them (stablecoin $1 branch) and no
+ // consumer needs its own merge/fallback. They stay invisible outside the
+ // withdraw flow: every network list is gated by allowedChainIds (the
+ // wagmi id set everywhere except withdraw), and URL parsing/validation
+ // read the server action, not this context.
+ const supportedChainsAndTokens = useMemo(
+ () => ({ ...fetchedChainsAndTokens, ...NON_EVM_WITHDRAW_CHAINS }),
+ [fetchedChainsAndTokens]
+ )
// Fetch token price using TanStack Query (replaces manual useEffect + state)
const {
diff --git a/src/hooks/__tests__/useChainRollout.test.tsx b/src/hooks/__tests__/useChainRollout.test.tsx
new file mode 100644
index 0000000000..a341839b6e
--- /dev/null
+++ b/src/hooks/__tests__/useChainRollout.test.tsx
@@ -0,0 +1,59 @@
+import { renderHook, act } from '@testing-library/react'
+
+// posthog-js is mocked so tests control flag values and load events
+let flagsCallback: (() => void) | undefined
+const isFeatureEnabledMock = jest.fn()
+jest.mock('posthog-js', () => ({
+ __esModule: true,
+ default: {
+ isFeatureEnabled: (key: string) => isFeatureEnabledMock(key),
+ onFeatureFlags: (cb: () => void) => {
+ flagsCallback = cb
+ return () => {
+ flagsCallback = undefined
+ }
+ },
+ },
+}))
+// Force prod-domain semantics so the nonProdBypass doesn't short-circuit
+jest.mock('@/constants/general.consts', () => ({
+ ...jest.requireActual('@/constants/general.consts'),
+ BASE_URL: 'https://peanut.me',
+}))
+
+import { useChainRollout } from '../useChainRollout'
+import { useFeatureFlags } from '../useFeatureFlag'
+
+describe('useFeatureFlags', () => {
+ it('returns a NEW checker identity when PostHog flags load (memo-busting)', () => {
+ const { result } = renderHook(() => useFeatureFlags())
+ const before = result.current
+ act(() => flagsCallback?.())
+ expect(result.current).not.toBe(before) // regression: frozen-at-mount gate
+ })
+})
+
+describe('useChainRollout', () => {
+ beforeEach(() => isFeatureEnabledMock.mockReset())
+
+ it('always allows unflagged (legacy) chains', () => {
+ const { result } = renderHook(() => useChainRollout())
+ expect(result.current('42161')).toBe(true)
+ expect(isFeatureEnabledMock).not.toHaveBeenCalled()
+ })
+
+ it('fails CLOSED on prod when PostHog has no answer', () => {
+ isFeatureEnabledMock.mockReturnValue(undefined)
+ const { result } = renderHook(() => useChainRollout())
+ expect(result.current('solana')).toBe(false)
+ })
+
+ it('reflects flag values once loaded, keyed per chain', () => {
+ isFeatureEnabledMock.mockImplementation((key: string) => key === 'chain-rollout-tempo')
+ const { result } = renderHook(() => useChainRollout())
+ act(() => flagsCallback?.())
+ expect(result.current('4217')).toBe(true) // tempo by chainId
+ expect(result.current('TEMPO')).toBe(true) // tempo by deposit ChainName — same flag
+ expect(result.current('solana')).toBe(false)
+ })
+})
diff --git a/src/hooks/useChainRollout.ts b/src/hooks/useChainRollout.ts
index 30a2f34f90..973270f7c2 100644
--- a/src/hooks/useChainRollout.ts
+++ b/src/hooks/useChainRollout.ts
@@ -1,46 +1,22 @@
'use client'
-import { isFeatureFlagEnabled, useFeatureFlags } from '@/hooks/useFeatureFlag'
+import { useMemo } from 'react'
+import { useFeatureFlags } from '@/hooks/useFeatureFlag'
+import { CHAIN_ROLLOUT_FLAGS } from '@/constants/chainRollout.consts'
/**
- * Per-chain rollout toggles for the Rhino chain expansion — one PostHog
- * feature flag per chain so marketing can launch chains one by one with a
- * click (no deploy). Thin domain wrapper over `useFeatureFlag`; the map is
- * keyed by every identifier a chain appears under (EVM numeric chainId,
- * non-EVM slug, deposit ChainName) so one flag governs all surfaces of the
- * same chain. Chains NOT in this map (the legacy set) are always on.
- *
- * Hygiene: once a chain is permanently launched, delete its entry here and
- * its flag in PostHog — flags are scaffolding, not architecture.
+ * Reactive per-chain rollout gate — thin domain wrapper over
+ * `useFeatureFlags` (see `chainRollout.consts.ts` for the chain→flag map and
+ * `featureFlag.utils.ts` for the doctrine). Returns a fresh checker identity
+ * when PostHog's flags load so memoized chain lists recompute.
*/
-export const CHAIN_ROLLOUT_FLAGS: Record = {
- // withdraw destinations (EVM chainId keys)
- '43114': 'chain-rollout-avalanche',
- '999': 'chain-rollout-hyperevm',
- '57073': 'chain-rollout-ink',
- '747474': 'chain-rollout-katana',
- '59144': 'chain-rollout-linea',
- '5000': 'chain-rollout-mantle',
- '9745': 'chain-rollout-plasma',
- '988': 'chain-rollout-stable',
- '4217': 'chain-rollout-tempo',
- // withdraw destinations (non-EVM slugs)
- solana: 'chain-rollout-solana',
- tron: 'chain-rollout-tron',
- // deposit chains (ChainName keys — same flag as the withdraw side where
- // the chain supports both, so one toggle launches the whole chain)
- TEMPO: 'chain-rollout-tempo',
- KAIA: 'chain-rollout-kaia',
- PLASMA: 'chain-rollout-plasma',
-}
-
-export function isChainRolledOut(chainKey: string): boolean {
- const flag = CHAIN_ROLLOUT_FLAGS[chainKey]
- if (!flag) return true
- return isFeatureFlagEnabled(flag, { nonProdBypass: true })
-}
-
-/** Reactive variant — re-renders when PostHog's flags load. */
export function useChainRollout(): (chainKey: string) => boolean {
- useFeatureFlags()
- return isChainRolledOut
+ const isFlagEnabled = useFeatureFlags()
+ return useMemo(
+ () => (chainKey: string) => {
+ const flag = CHAIN_ROLLOUT_FLAGS[chainKey]
+ if (!flag) return true
+ return isFlagEnabled(flag, { nonProdBypass: true })
+ },
+ [isFlagEnabled]
+ )
}
diff --git a/src/hooks/useFeatureFlag.ts b/src/hooks/useFeatureFlag.ts
index 44efd14b6c..2a448ba677 100644
--- a/src/hooks/useFeatureFlag.ts
+++ b/src/hooks/useFeatureFlag.ts
@@ -1,45 +1,25 @@
'use client'
-import { useEffect, useReducer } from 'react'
+import { useEffect, useMemo, useReducer } from 'react'
import posthog from 'posthog-js'
-import { BASE_URL } from '@/constants/general.consts'
+import { isFeatureFlagEnabled, type FeatureFlagOptions } from '@/utils/featureFlag.utils'
/**
- * PostHog feature flags — the runtime-toggle primitive.
- *
- * DOCTRINE (see engineering/patterns/feature-gates.md in mono): a PostHog
- * flag answers "have we LAUNCHED this?" — flipped in the PostHog UI with no
- * deploy, supports cohort/% targeting. It is NOT a kill-switch: incident
- * switches stay in code (`underMaintenance.config.ts`) because the emergency
- * brake must not depend on a third-party SaaS. Flags are scaffolding —
- * delete them once a launch is permanent.
- *
- * Failure semantics are per-feature via options:
- * - rollout gates want `nonProdBypass` (staging/preview/local always ON so
- * QA can test pre-launch) and fail CLOSED on prod when PostHog is
- * unavailable — a rollout gate must never fail into "launched".
- */
-const IS_PROD_DOMAIN = BASE_URL === 'https://peanut.me'
-
-export interface FeatureFlagOptions {
- /** Treat the flag as ON outside the prod domain (rollout-gate semantics). */
- nonProdBypass?: boolean
-}
-
-export function isFeatureFlagEnabled(flagKey: string, options: FeatureFlagOptions = {}): boolean {
- if (options.nonProdBypass && !IS_PROD_DOMAIN) return true
- return posthog.isFeatureEnabled(flagKey) ?? false
-}
-
-/**
- * Reactive read of PostHog feature flags: re-renders once flags load (they
- * arrive async after page load) so gated UI pops in without a refresh.
- * Returns a checker so one subscription serves any number of flags.
+ * Reactive read of PostHog feature flags. PostHog delivers flags async after
+ * page load; this hook returns a NEW checker function identity on every
+ * flag-load event, so downstream useMemo/useCallback that depend on the
+ * checker recompute (a stable identity silently froze gated UI at
+ * mount-time values — the 2026-07 chain-rollout review finding).
*/
export function useFeatureFlags(): (flagKey: string, options?: FeatureFlagOptions) => boolean {
- const [, bump] = useReducer((n: number) => n + 1, 0)
+ const [version, bump] = useReducer((n: number) => n + 1, 0)
useEffect(() => {
// returns an unsubscribe function
return posthog.onFeatureFlags(() => bump())
}, [])
- return isFeatureFlagEnabled
+ return useMemo(
+ () => (flagKey: string, options?: FeatureFlagOptions) => isFeatureFlagEnabled(flagKey, options),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` IS the
+ // reactivity trigger: a new checker identity per flag-load event.
+ [version]
+ )
}
diff --git a/src/utils/featureFlag.utils.ts b/src/utils/featureFlag.utils.ts
new file mode 100644
index 0000000000..9980207d19
--- /dev/null
+++ b/src/utils/featureFlag.utils.ts
@@ -0,0 +1,35 @@
+import posthog from 'posthog-js'
+import { BASE_URL } from '@/constants/general.consts'
+import { CHAIN_ROLLOUT_FLAGS } from '@/constants/chainRollout.consts'
+
+/**
+ * PostHog feature flags — the runtime-toggle primitive (non-reactive reads;
+ * components use the `useFeatureFlags` / `useChainRollout` hooks so they
+ * re-render when flags load).
+ *
+ * DOCTRINE (mono engineering/patterns/feature-gates.md): a PostHog flag
+ * answers "have we LAUNCHED this?" — flipped in the PostHog UI, no deploy,
+ * cohort/% targeting. It is NOT a kill-switch: incident switches stay in
+ * code (`underMaintenance.config.ts`). Flags are scaffolding — delete them
+ * once a launch is permanent.
+ */
+const IS_PROD_DOMAIN = BASE_URL === 'https://peanut.me'
+
+export interface FeatureFlagOptions {
+ /** Treat the flag as ON outside the prod domain (rollout-gate semantics:
+ * staging/preview/local always see the feature so QA can test
+ * pre-launch; prod fails CLOSED when PostHog is unavailable). */
+ nonProdBypass?: boolean
+}
+
+export function isFeatureFlagEnabled(flagKey: string, options: FeatureFlagOptions = {}): boolean {
+ if (options.nonProdBypass && !IS_PROD_DOMAIN) return true
+ return posthog.isFeatureEnabled(flagKey) ?? false
+}
+
+/** Chains without a rollout flag (the legacy set) are always on. */
+export function isChainRolledOut(chainKey: string): boolean {
+ const flag = CHAIN_ROLLOUT_FLAGS[chainKey]
+ if (!flag) return true
+ return isFeatureFlagEnabled(flag, { nonProdBypass: true })
+}
From 0ed73e9bf544dd086dad16878d4e18bbf74fa761 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 13 Jul 2026 06:15:33 +0100
Subject: [PATCH 16/19] =?UTF-8?q?refactor:=20CHAIN=5FREGISTRY=20=E2=80=94?=
=?UTF-8?q?=20one=20source=20of=20truth=20for=20every=20FE=20chain=20fact?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../TokenSelector/TokenSelector.consts.ts | 25 +-
src/constants/__tests__/chainRegistry.test.ts | 166 ++++++++++
src/constants/chainRegistry.consts.ts | 294 ++++++++++++++++++
src/constants/chainRollout.consts.ts | 39 +--
src/constants/nonEvmWithdraw.consts.ts | 84 ++---
src/constants/rhino.consts.ts | 111 ++-----
6 files changed, 535 insertions(+), 184 deletions(-)
create mode 100644 src/constants/__tests__/chainRegistry.test.ts
create mode 100644 src/constants/chainRegistry.consts.ts
diff --git a/src/components/Global/TokenSelector/TokenSelector.consts.ts b/src/components/Global/TokenSelector/TokenSelector.consts.ts
index cee368c2c3..2a5376fd26 100644
--- a/src/components/Global/TokenSelector/TokenSelector.consts.ts
+++ b/src/components/Global/TokenSelector/TokenSelector.consts.ts
@@ -1,5 +1,6 @@
import { SOLANA_ICON, TRON_ICON } from '@/assets'
import { networks } from '@/config'
+import { CHAIN_REGISTRY } from '@/constants/chainRegistry.consts'
import type { IPeanutChainDetails, IToken } from '@/interfaces/interfaces'
import { celo, linea, scroll, worldchain } from 'viem/chains'
@@ -100,24 +101,6 @@ export const TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS = networks
* Rhino. KAIA/opBNB deliberately absent: quotes pass but SDA create rejects
* (DepositAddressTokenOutNotSupported).
*/
-export const RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN: Record = {
- '42161': ['ETH', 'USDC', 'USDT'], // Arbitrum
- '1': ['ETH', 'USDC', 'USDT'], // Ethereum
- '10': ['ETH', 'USDC', 'USDT'], // Optimism
- '137': ['USDC', 'USDT'], // Polygon (native POL not bridged by Rhino)
- '100': ['USDC', 'USDT'], // Gnosis (native xDAI not bridged by Rhino)
- '56': ['BNB', 'USDC', 'USDT'], // BNB Chain
- '43114': ['USDC', 'USDT'], // Avalanche
- '999': ['USDC', 'USDT'], // HyperEVM
- '57073': ['USDC', 'USDT'], // Ink
- '747474': ['USDC', 'USDT'], // Katana (delivered as vbUSDC/vbUSDT)
- '59144': ['USDC', 'USDT'], // Linea
- '5000': ['USDC', 'USDT'], // Mantle (USDT delivered as USDT0)
- '9745': ['USDT'], // Plasma (USDT0-only chain)
- '988': ['USDT'], // Stable (USDT0-only chain)
- '4217': ['USDC', 'USDT'], // Tempo (delivered as USDC.e/USDT0)
- // Non-EVM destinations (slug ids; entries in nonEvmWithdraw.consts.ts).
- // Verified 2026-07-11: live quote + outflow SDA create for both.
- solana: ['USDC', 'USDT'],
- tron: ['USDT'], // no USDC on Tron
-}
+export const RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN: Record = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.withdraw).map((c) => [c.id, c.withdraw!.tokens])
+)
diff --git a/src/constants/__tests__/chainRegistry.test.ts b/src/constants/__tests__/chainRegistry.test.ts
new file mode 100644
index 0000000000..e3f0b46e84
--- /dev/null
+++ b/src/constants/__tests__/chainRegistry.test.ts
@@ -0,0 +1,166 @@
+/**
+ * Behavior-equality proof for the CHAIN_REGISTRY refactor: every derived map
+ * must match the hand-maintained literals it replaced (values captured from
+ * feat/solana-tron-withdrawals @ ea63b6ca). If you're editing these
+ * EXPECTATIONS to make a failure pass, you're changing chain behavior —
+ * verify against Rhino's live catalogs first.
+ */
+// TokenSelector.consts imports the wagmi `networks` config, which cannot
+// construct under jest (appkit env) — only the id list matters here.
+jest.mock('@/config', () => ({ networks: [] }))
+
+import {
+ CHAIN_LOGOS,
+ SUPPORTED_EVM_CHAINS,
+ OTHER_SUPPORTED_CHAINS,
+ EVM_CHAIN_ID_TO_RHINO_NAME,
+ NON_EVM_CHAIN_ID_TO_RHINO_NAME,
+ chainIdToRhinoName,
+ EVM_DEPOSIT_TOKEN_EXCEPTIONS,
+} from '../rhino.consts'
+import { CHAIN_ROLLOUT_FLAGS } from '../chainRollout.consts'
+import { NON_EVM_WITHDRAW_CHAINS } from '../nonEvmWithdraw.consts'
+import { RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN } from '@/components/Global/TokenSelector/TokenSelector.consts'
+import { CHAIN_REGISTRY } from '../chainRegistry.consts'
+
+describe('CHAIN_REGISTRY derivations match the replaced literals', () => {
+ it('EVM_CHAIN_ID_TO_RHINO_NAME', () => {
+ expect(EVM_CHAIN_ID_TO_RHINO_NAME).toEqual({
+ '1': 'ETHEREUM',
+ '10': 'OPTIMISM',
+ '56': 'BINANCE',
+ '100': 'GNOSIS',
+ '137': 'MATIC_POS',
+ '42161': 'ARBITRUM',
+ '421614': 'ARBITRUM',
+ '8453': 'BASE',
+ '42220': 'CELO',
+ '43114': 'AVALANCHE',
+ '999': 'HYPEREVM',
+ '57073': 'INK',
+ '747474': 'KATANA',
+ '59144': 'LINEA',
+ '5000': 'MANTLE',
+ '9745': 'PLASMA',
+ '988': 'STABLE',
+ '4217': 'TEMPO',
+ // NEW vs the literal (deliberate): Kaia now maps — it's a deposit
+ // chain and webhook/receipt surfaces may reference it. It is NOT a
+ // withdraw destination (no entry in the withdraw gate below).
+ '8217': 'KAIA',
+ })
+ // SCROLL must never come back without a live-catalog re-check
+ expect(EVM_CHAIN_ID_TO_RHINO_NAME['534352']).toBeUndefined()
+ })
+
+ it('non-EVM mapping and the combined resolver', () => {
+ expect(NON_EVM_CHAIN_ID_TO_RHINO_NAME).toEqual({ solana: 'SOLANA', tron: 'TRON' })
+ expect(chainIdToRhinoName('SOLANA')).toBe('SOLANA')
+ expect(chainIdToRhinoName(421614)).toBe('ARBITRUM')
+ expect(chainIdToRhinoName('534352')).toBeUndefined()
+ })
+
+ it('RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN', () => {
+ expect(RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN).toEqual({
+ '42161': ['ETH', 'USDC', 'USDT'],
+ '1': ['ETH', 'USDC', 'USDT'],
+ '10': ['ETH', 'USDC', 'USDT'],
+ '137': ['USDC', 'USDT'],
+ '100': ['USDC', 'USDT'],
+ '56': ['BNB', 'USDC', 'USDT'],
+ '43114': ['USDC', 'USDT'],
+ '999': ['USDC', 'USDT'],
+ '57073': ['USDC', 'USDT'],
+ '747474': ['USDC', 'USDT'],
+ '59144': ['USDC', 'USDT'],
+ '5000': ['USDC', 'USDT'],
+ '9745': ['USDT'],
+ '988': ['USDT'],
+ '4217': ['USDC', 'USDT'],
+ solana: ['USDC', 'USDT'],
+ tron: ['USDT'],
+ })
+ // Kaia/opBNB rejected at SDA create; Scroll disabled — must stay out
+ expect(RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN['8217']).toBeUndefined()
+ expect(RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN['534352']).toBeUndefined()
+ })
+
+ it('deposit surfaces: chains, exceptions, logos', () => {
+ expect([...SUPPORTED_EVM_CHAINS].sort()).toEqual(
+ [
+ 'ARBITRUM',
+ 'ETHEREUM',
+ 'BASE',
+ 'OPTIMISM',
+ 'BNB',
+ 'POLYGON',
+ 'KATANA',
+ 'GNOSIS',
+ 'CELO',
+ 'TEMPO',
+ 'KAIA',
+ 'PLASMA',
+ ].sort()
+ )
+ expect([...OTHER_SUPPORTED_CHAINS].sort()).toEqual(['SOLANA', 'TRON'])
+ expect(EVM_DEPOSIT_TOKEN_EXCEPTIONS).toEqual({
+ KAIA: ['USDT'],
+ PLASMA: ['USDT'],
+ TEMPO: ['USDT', 'USDC'],
+ CELO: ['USDT', 'USDC'],
+ GNOSIS: ['USDT', 'USDC'],
+ })
+ // every advertised chain has a logo (broken-logo class of bug)
+ for (const chain of [...SUPPORTED_EVM_CHAINS, ...OTHER_SUPPORTED_CHAINS]) {
+ expect(CHAIN_LOGOS[chain]).toMatch(/^https:\/\//)
+ }
+ expect(CHAIN_LOGOS.SCROLL).toMatch(/^https:\/\//) // legacy display-only
+ })
+
+ it('CHAIN_ROLLOUT_FLAGS — every surface key of a flagged chain maps to ONE flag', () => {
+ expect(CHAIN_ROLLOUT_FLAGS).toEqual({
+ '43114': 'chain-rollout-avalanche',
+ '999': 'chain-rollout-hyperevm',
+ '57073': 'chain-rollout-ink',
+ '747474': 'chain-rollout-katana',
+ KATANA: 'chain-rollout-katana',
+ '59144': 'chain-rollout-linea',
+ '5000': 'chain-rollout-mantle',
+ '9745': 'chain-rollout-plasma',
+ PLASMA: 'chain-rollout-plasma',
+ '988': 'chain-rollout-stable',
+ '4217': 'chain-rollout-tempo',
+ TEMPO: 'chain-rollout-tempo',
+ '8217': 'chain-rollout-kaia',
+ KAIA: 'chain-rollout-kaia',
+ solana: 'chain-rollout-solana',
+ SOLANA: 'chain-rollout-solana',
+ tron: 'chain-rollout-tron',
+ TRON: 'chain-rollout-tron',
+ })
+ })
+
+ it('NON_EVM_WITHDRAW_CHAINS synthetic records', () => {
+ expect(Object.keys(NON_EVM_WITHDRAW_CHAINS).sort()).toEqual(['solana', 'tron'])
+ expect(NON_EVM_WITHDRAW_CHAINS.solana.tokens.map((t) => t.symbol)).toEqual(['USDC', 'USDT'])
+ expect(NON_EVM_WITHDRAW_CHAINS.solana.tokens[0].address).toBe('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')
+ expect(NON_EVM_WITHDRAW_CHAINS.tron.tokens.map((t) => t.symbol)).toEqual(['USDT'])
+ expect(NON_EVM_WITHDRAW_CHAINS.tron.tokens[0].address).toBe('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t')
+ })
+
+ it('registry invariants', () => {
+ const ids = CHAIN_REGISTRY.map((c) => c.id)
+ expect(new Set(ids).size).toBe(ids.length) // no duplicate ids
+ for (const entry of CHAIN_REGISTRY) {
+ // a routable chain must have a Rhino name
+ if (entry.deposit || entry.withdraw) expect(entry.rhinoName).toBeTruthy()
+ // a deposit-advertised chain must be displayable
+ if (entry.deposit) {
+ expect(entry.displayName).toBeTruthy()
+ expect(entry.logoUrl).toBeTruthy()
+ }
+ // non-EVM withdraw destinations need their synthetic record
+ if (entry.withdraw && entry.family !== 'evm') expect(entry.nonEvmRecord).toBeTruthy()
+ }
+ })
+})
diff --git a/src/constants/chainRegistry.consts.ts b/src/constants/chainRegistry.consts.ts
new file mode 100644
index 0000000000..ffb8343e78
--- /dev/null
+++ b/src/constants/chainRegistry.consts.ts
@@ -0,0 +1,294 @@
+/**
+ * THE chain registry — single source of truth for every chain fact the FE
+ * hand-maintains about Rhino-connected chains.
+ *
+ * Before this file, one chain's facts were spread across SEVEN maps
+ * (CHAIN_LOGOS, SUPPORTED_EVM_CHAINS, EVM_CHAIN_ID_TO_RHINO_NAME,
+ * RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN, EVM_DEPOSIT_TOKEN_EXCEPTIONS,
+ * CHAIN_ROLLOUT_FLAGS, NON_EVM_WITHDRAW_CHAINS) — the drift between them
+ * caused the SCROLL rot and the frozen-SDA incident. Those exports still
+ * exist at their old import paths, but every one of them is now DERIVED
+ * from this registry (see the `derive*` helpers below + the equality tests
+ * in __tests__/chainRegistry.test.ts).
+ *
+ * To add/change a chain: edit ONE entry here. Verify against Rhino's live
+ * catalogs first (`getBridgeConfig()` for withdraw, `getSupportedConfigs()`
+ * for deposit) — the monitor's drift check compares this registry to Rhino.
+ *
+ * NOT in scope: chain-details.json / token-details.json (chain metadata for
+ * generic EVM surfaces — explorer URLs, full token lists) and the BE's
+ * CHAINS_CONFIG (already a single map in peanut-api-ts).
+ */
+
+export interface RegistryTokenMeta {
+ symbol: string
+ address: string
+ decimals: number
+ name: string
+ logoURI: string
+}
+
+export interface ChainRegistryEntry {
+ /** Selector identifier: EVM numeric chainId as a string, or a non-EVM slug. */
+ id: string
+ /** Additional selector ids resolving to the same Rhino bucket (e.g. Arb Sepolia → ARBITRUM). */
+ aliasIds?: readonly string[]
+ /** Rhino API chain name. Absent = Rhino has the chain disabled (kept for display only). */
+ rhinoName?: string
+ family: 'evm' | 'solana' | 'tron'
+ /** Display key on deposit surfaces (the legacy `ChainName`). */
+ displayName?: string
+ logoUrl?: string
+ /** Present = advertised DEPOSIT chain. `tokens` only when narrower than
+ * the family default (USDT/USDC/ETH for EVM) — drives the "USDT only"
+ * funds-safety annotations. */
+ deposit?: { tokens?: readonly string[] }
+ /** Present = Rhino WITHDRAW destination; `tokens` = symbols Rhino
+ * delivers there (each verified: live quote + outflow SDA create). */
+ withdraw?: { tokens: readonly string[] }
+ /** Synthetic selector record for non-EVM chains (no chain-details entry). */
+ nonEvmRecord?: { networkName: string; tokens: readonly RegistryTokenMeta[] }
+ /** PostHog rollout gate — see engineering/patterns/feature-gates.md.
+ * Delete when the chain launch is permanent. */
+ rolloutFlag?: string
+}
+
+const TOKEN_LOGO = {
+ USDT: 'https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661',
+ USDC: 'https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png',
+} as const
+
+const CHAIN_REGISTRY_LITERAL = [
+ // ── legacy, always-on chains ────────────────────────────────────────────
+ {
+ id: '42161',
+ aliasIds: ['421614'], // Arb Sepolia — same Rhino bucket for sandbox runs
+ rhinoName: 'ARBITRUM',
+ family: 'evm',
+ displayName: 'ARBITRUM',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/33/standard/AO_logomark.png?1706606717',
+ deposit: {},
+ withdraw: { tokens: ['ETH', 'USDC', 'USDT'] },
+ },
+ {
+ id: '1',
+ rhinoName: 'ETHEREUM',
+ family: 'evm',
+ displayName: 'ETHEREUM',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/279/standard/ethereum.png?1706606803',
+ deposit: {},
+ withdraw: { tokens: ['ETH', 'USDC', 'USDT'] },
+ },
+ {
+ id: '8453',
+ rhinoName: 'BASE',
+ family: 'evm',
+ displayName: 'BASE',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/131/standard/base.png?1759905869',
+ deposit: {},
+ // NOTE: deliberately NO `withdraw` — Base has never been in the
+ // curated withdraw gate (looks like a June-2026 curation oversight;
+ // Rhino fully supports it). Behavior-preserving refactor: enabling it
+ // is a one-line product decision + verification, not a side effect.
+ },
+ {
+ id: '10',
+ rhinoName: 'OPTIMISM',
+ family: 'evm',
+ displayName: 'OPTIMISM',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/41/standard/optimism.png?1706606778',
+ deposit: {},
+ withdraw: { tokens: ['ETH', 'USDC', 'USDT'] },
+ },
+ {
+ id: '100',
+ rhinoName: 'GNOSIS',
+ family: 'evm',
+ displayName: 'GNOSIS',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/11062/standard/Aatar_green_white.png?1706606458',
+ deposit: { tokens: ['USDT', 'USDC'] }, // no ETH on Gnosis at Rhino
+ withdraw: { tokens: ['USDC', 'USDT'] }, // native xDAI not bridged by Rhino
+ },
+ {
+ id: '137',
+ rhinoName: 'MATIC_POS', // Rhino's name for Polygon (display: POLYGON)
+ family: 'evm',
+ displayName: 'POLYGON',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/15/standard/polygon_pos.png?1706606645',
+ deposit: {},
+ withdraw: { tokens: ['USDC', 'USDT'] }, // native POL not bridged by Rhino
+ },
+ {
+ id: '56',
+ rhinoName: 'BINANCE', // Rhino's name for BNB Chain (display: BNB)
+ family: 'evm',
+ displayName: 'BNB',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/1/standard/bnb_smart_chain.png?1706606721',
+ deposit: {},
+ withdraw: { tokens: ['BNB', 'USDC', 'USDT'] },
+ },
+ {
+ id: '42220',
+ rhinoName: 'CELO',
+ family: 'evm',
+ displayName: 'CELO',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/21/standard/celo.jpeg?1711358666',
+ deposit: { tokens: ['USDT', 'USDC'] }, // no ETH on Celo at Rhino
+ // not a withdraw destination in the curated gate (legacy state)
+ },
+ {
+ // SCROLL: display-only legacy — Rhino disabled it 2026-07 ("SCROLL is
+ // disabled" InvalidRequest). No rhinoName = not routable anywhere.
+ id: '534352',
+ family: 'evm',
+ displayName: 'SCROLL',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/153/standard/scroll.jpeg?1706606782',
+ },
+
+ // ── 2026-07 expansion (peanut-ui#2396/#2398) — each verified against ──
+ // ── Rhino prod: live quote + outflow SDA create; rollout-flagged ──────
+ {
+ id: '43114',
+ rhinoName: 'AVALANCHE',
+ family: 'evm',
+ withdraw: { tokens: ['USDC', 'USDT'] },
+ rolloutFlag: 'chain-rollout-avalanche',
+ },
+ {
+ id: '999',
+ rhinoName: 'HYPEREVM',
+ family: 'evm',
+ withdraw: { tokens: ['USDC', 'USDT'] },
+ rolloutFlag: 'chain-rollout-hyperevm',
+ },
+ {
+ id: '57073',
+ rhinoName: 'INK',
+ family: 'evm',
+ withdraw: { tokens: ['USDC', 'USDT'] },
+ rolloutFlag: 'chain-rollout-ink',
+ },
+ {
+ id: '747474',
+ rhinoName: 'KATANA',
+ family: 'evm',
+ displayName: 'KATANA',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/32239/standard/katana.jpg?1751496126',
+ deposit: {},
+ withdraw: { tokens: ['USDC', 'USDT'] }, // delivered as vbUSDC/vbUSDT
+ rolloutFlag: 'chain-rollout-katana',
+ },
+ {
+ id: '59144',
+ rhinoName: 'LINEA',
+ family: 'evm',
+ withdraw: { tokens: ['USDC', 'USDT'] },
+ rolloutFlag: 'chain-rollout-linea',
+ },
+ {
+ id: '5000',
+ rhinoName: 'MANTLE',
+ family: 'evm',
+ withdraw: { tokens: ['USDC', 'USDT'] }, // USDT delivered as USDT0
+ rolloutFlag: 'chain-rollout-mantle',
+ },
+ {
+ id: '9745',
+ rhinoName: 'PLASMA',
+ family: 'evm',
+ displayName: 'PLASMA',
+ logoUrl: 'https://coin-images.coingecko.com/asset_platforms/images/32256/small/plasma.jpg?1758000963',
+ deposit: { tokens: ['USDT'] }, // USDT0-only chain — USDC would be lost
+ withdraw: { tokens: ['USDT'] },
+ rolloutFlag: 'chain-rollout-plasma',
+ },
+ {
+ id: '988',
+ rhinoName: 'STABLE',
+ family: 'evm',
+ withdraw: { tokens: ['USDT'] }, // USDT0-only chain
+ rolloutFlag: 'chain-rollout-stable',
+ },
+ {
+ id: '4217',
+ rhinoName: 'TEMPO',
+ family: 'evm',
+ displayName: 'TEMPO',
+ logoUrl: 'https://icons.llamao.fi/icons/chains/rsz_tempo.jpg',
+ deposit: { tokens: ['USDT', 'USDC'] }, // no ETH asset on Tempo
+ withdraw: { tokens: ['USDC', 'USDT'] }, // delivered as USDC.e/USDT0
+ rolloutFlag: 'chain-rollout-tempo',
+ },
+ {
+ id: '8217',
+ rhinoName: 'KAIA',
+ family: 'evm',
+ displayName: 'KAIA',
+ logoUrl: 'https://coin-images.coingecko.com/asset_platforms/images/9672/small/kaia.png?1734946776',
+ deposit: { tokens: ['USDT'] }, // USDT-only at Rhino — USDC would be lost
+ // NOT a withdraw destination: Rhino SDA create rejects Kaia tokenOut
+ rolloutFlag: 'chain-rollout-kaia',
+ },
+
+ // ── non-EVM ─────────────────────────────────────────────────────────────
+ {
+ id: 'solana',
+ rhinoName: 'SOLANA',
+ family: 'solana',
+ displayName: 'SOLANA',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/5/standard/solana.png?1706606708',
+ deposit: {}, // SOL family default (USDT/USDC)
+ withdraw: { tokens: ['USDC', 'USDT'] },
+ nonEvmRecord: {
+ networkName: 'Solana',
+ tokens: [
+ {
+ symbol: 'USDC',
+ address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
+ decimals: 6,
+ name: 'USD Coin',
+ logoURI: TOKEN_LOGO.USDC,
+ },
+ {
+ symbol: 'USDT',
+ address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
+ decimals: 6,
+ name: 'Tether USD',
+ logoURI: TOKEN_LOGO.USDT,
+ },
+ ],
+ },
+ rolloutFlag: 'chain-rollout-solana',
+ },
+ {
+ id: 'tron',
+ rhinoName: 'TRON',
+ family: 'tron',
+ displayName: 'TRON',
+ logoUrl: 'https://assets.coingecko.com/asset_platforms/images/1094/standard/TRON_LOGO.png?1706606652',
+ deposit: {}, // TRON family default (USDT)
+ withdraw: { tokens: ['USDT'] }, // no USDC on Tron
+ nonEvmRecord: {
+ networkName: 'Tron',
+ tokens: [
+ {
+ symbol: 'USDT',
+ address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ decimals: 6,
+ name: 'Tether USD',
+ logoURI: TOKEN_LOGO.USDT,
+ },
+ ],
+ },
+ rolloutFlag: 'chain-rollout-tron',
+ },
+] as const satisfies readonly ChainRegistryEntry[]
+
+/** Display-name union (the legacy `ChainName`) — literal types preserved
+ * via Extract (indexed access fails on union members lacking the prop). */
+type RegistryEntryLiteral = (typeof CHAIN_REGISTRY_LITERAL)[number]
+export type RegistryChainName = Extract['displayName']
+
+/** The registry, widened for iteration (optional props accessible on every
+ * entry). The literal source above keeps the name union type-safe. */
+export const CHAIN_REGISTRY: readonly ChainRegistryEntry[] = CHAIN_REGISTRY_LITERAL
diff --git a/src/constants/chainRollout.consts.ts b/src/constants/chainRollout.consts.ts
index dd6aa3c0cb..d01b497069 100644
--- a/src/constants/chainRollout.consts.ts
+++ b/src/constants/chainRollout.consts.ts
@@ -1,30 +1,15 @@
+import { CHAIN_REGISTRY } from '@/constants/chainRegistry.consts'
+
/**
- * Per-chain rollout toggles for the Rhino chain expansion — one PostHog
- * feature flag per chain so marketing can launch chains one by one with a
- * click (no deploy). Keyed by every identifier a chain appears under in the
- * selector/deposit surfaces (EVM numeric chainId, non-EVM slug, deposit
- * ChainName) so one flag governs all surfaces of the same chain.
+ * Per-chain PostHog rollout flags — DERIVED from CHAIN_REGISTRY, keyed by
+ * every identifier a chain appears under (selector id, aliases, deposit
+ * display name) so one flag governs all surfaces of the same chain.
*
- * Hygiene: once a chain is permanently launched, delete its entry here and
- * its flag in PostHog — flags are scaffolding, not architecture.
+ * Hygiene: when a chain launch is permanent, delete `rolloutFlag` from its
+ * registry entry and the flag in PostHog — flags are scaffolding.
*/
-export const CHAIN_ROLLOUT_FLAGS: Record = {
- // withdraw destinations (EVM chainId keys)
- '43114': 'chain-rollout-avalanche',
- '999': 'chain-rollout-hyperevm',
- '57073': 'chain-rollout-ink',
- '747474': 'chain-rollout-katana',
- '59144': 'chain-rollout-linea',
- '5000': 'chain-rollout-mantle',
- '9745': 'chain-rollout-plasma',
- '988': 'chain-rollout-stable',
- '4217': 'chain-rollout-tempo',
- // withdraw destinations (non-EVM slugs)
- solana: 'chain-rollout-solana',
- tron: 'chain-rollout-tron',
- // deposit chains (ChainName keys — same flag as the withdraw side where
- // the chain supports both, so one toggle launches the whole chain)
- TEMPO: 'chain-rollout-tempo',
- KAIA: 'chain-rollout-kaia',
- PLASMA: 'chain-rollout-plasma',
-}
+export const CHAIN_ROLLOUT_FLAGS: Record = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.rolloutFlag).flatMap((c) =>
+ [c.id, ...(c.aliasIds ?? []), ...(c.displayName ? [c.displayName] : [])].map((key) => [key, c.rolloutFlag!])
+ )
+)
diff --git a/src/constants/nonEvmWithdraw.consts.ts b/src/constants/nonEvmWithdraw.consts.ts
index b0fc0b09d8..f763521474 100644
--- a/src/constants/nonEvmWithdraw.consts.ts
+++ b/src/constants/nonEvmWithdraw.consts.ts
@@ -1,66 +1,34 @@
import type { ChainWithTokens } from '@/interfaces/chain-meta'
-import { CHAIN_LOGOS, TOKEN_LOGOS } from '@/constants/rhino.consts'
+import { CHAIN_REGISTRY } from '@/constants/chainRegistry.consts'
/**
- * Non-EVM withdraw destinations (Rhino delivers; verified 2026-07-11 with
- * live quotes + outflow-SDA creates: SOLANA USDC+USDT, TRON USDT-only).
- *
- * These chains have no EVM chainId and no chain-details.json entry, so the
- * withdraw selector merges these synthetic entries in withdraw mode ONLY
- * (`restrictToRhino`) — they must not leak into send/pay/claim surfaces or
- * URL parsing, which assume EVM addresses and wagmi networks.
- *
- * The selector `chainId` is the slug ('solana' | 'tron') — the same
- * identifier the old coming-soon entries used; `chainIdToRhinoName` maps it
- * to Rhino's API chain name. Token addresses are the canonical SPL mints /
- * TRC20 contract (mirrors peanut-api-ts `src/rhino/consts.ts`); Rhino
- * resolves tokens by SYMBOL, the address here is for selector display and
- * identity only.
+ * Synthetic selector records for non-EVM withdraw destinations — DERIVED
+ * from CHAIN_REGISTRY (`nonEvmRecord` entries). These chains have no
+ * chain-details.json entry; the token-selector context merges these records
+ * so every selector surface and the price hook resolve them. They stay
+ * invisible outside the withdraw flow: every other network list is gated by
+ * the wagmi id set, and URL parsing/validation read the server action.
*/
-export const NON_EVM_WITHDRAW_CHAINS: Record = {
- solana: {
- chainId: 'solana',
- networkName: 'Solana',
- chainIconURI: CHAIN_LOGOS.SOLANA,
- tokens: [
- {
- chainId: 'solana',
- address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
- decimals: 6,
- name: 'USD Coin',
- symbol: 'USDC',
- logoURI: TOKEN_LOGOS.USDC,
+export const NON_EVM_WITHDRAW_CHAINS: Record = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.nonEvmRecord).map((c) => [
+ c.id,
+ {
+ chainId: c.id,
+ networkName: c.nonEvmRecord!.networkName,
+ chainIconURI: c.logoUrl ?? '',
+ tokens: c.nonEvmRecord!.tokens.map((t) => ({
+ chainId: c.id,
+ address: t.address,
+ decimals: t.decimals,
+ name: t.name,
+ symbol: t.symbol,
+ logoURI: t.logoURI,
usdPrice: 0,
- },
- {
- chainId: 'solana',
- address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
- decimals: 6,
- name: 'Tether USD',
- symbol: 'USDT',
- logoURI: TOKEN_LOGOS.USDT,
- usdPrice: 0,
- },
- ],
- },
- tron: {
- chainId: 'tron',
- networkName: 'Tron',
- chainIconURI: CHAIN_LOGOS.TRON,
- tokens: [
- {
- chainId: 'tron',
- address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
- decimals: 6,
- name: 'Tether USD',
- symbol: 'USDT',
- logoURI: TOKEN_LOGOS.USDT,
- usdPrice: 0,
- },
- ],
- },
-}
+ })),
+ },
+ ])
+)
export function isNonEvmWithdrawChainId(chainId: string | number): boolean {
- return String(chainId).toLowerCase() in NON_EVM_WITHDRAW_CHAINS
+ return Object.prototype.hasOwnProperty.call(NON_EVM_WITHDRAW_CHAINS, String(chainId).toLowerCase())
}
diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts
index 377da449d2..b3a796c204 100644
--- a/src/constants/rhino.consts.ts
+++ b/src/constants/rhino.consts.ts
@@ -1,23 +1,10 @@
import type { RhinoChainType } from '@/services/services.types'
+import { CHAIN_REGISTRY, type RegistryChainName } from '@/constants/chainRegistry.consts'
-/** Chain name to logo URL mapping - reusable across the app */
-export const CHAIN_LOGOS = {
- ARBITRUM: 'https://assets.coingecko.com/asset_platforms/images/33/standard/AO_logomark.png?1706606717',
- ETHEREUM: 'https://assets.coingecko.com/asset_platforms/images/279/standard/ethereum.png?1706606803',
- BASE: 'https://assets.coingecko.com/asset_platforms/images/131/standard/base.png?1759905869',
- OPTIMISM: 'https://assets.coingecko.com/asset_platforms/images/41/standard/optimism.png?1706606778',
- GNOSIS: 'https://assets.coingecko.com/asset_platforms/images/11062/standard/Aatar_green_white.png?1706606458',
- POLYGON: 'https://assets.coingecko.com/asset_platforms/images/15/standard/polygon_pos.png?1706606645',
- BNB: 'https://assets.coingecko.com/asset_platforms/images/1/standard/bnb_smart_chain.png?1706606721',
- KATANA: 'https://assets.coingecko.com/asset_platforms/images/32239/standard/katana.jpg?1751496126',
- SCROLL: 'https://assets.coingecko.com/asset_platforms/images/153/standard/scroll.jpeg?1706606782',
- CELO: 'https://assets.coingecko.com/asset_platforms/images/21/standard/celo.jpeg?1711358666',
- TRON: 'https://assets.coingecko.com/asset_platforms/images/1094/standard/TRON_LOGO.png?1706606652',
- SOLANA: 'https://assets.coingecko.com/asset_platforms/images/5/standard/solana.png?1706606708',
- TEMPO: 'https://icons.llamao.fi/icons/chains/rsz_tempo.jpg',
- KAIA: 'https://coin-images.coingecko.com/asset_platforms/images/9672/small/kaia.png?1734946776',
- PLASMA: 'https://coin-images.coingecko.com/asset_platforms/images/32256/small/plasma.jpg?1758000963',
-} as const
+/** Chain name to logo URL mapping — DERIVED from CHAIN_REGISTRY. */
+export const CHAIN_LOGOS = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.displayName && c.logoUrl).map((c) => [c.displayName, c.logoUrl])
+) as Record
/** Token symbol to logo URL mapping - reusable across the app */
export const TOKEN_LOGOS = {
@@ -29,30 +16,17 @@ export const TOKEN_LOGOS = {
export type ChainName = keyof typeof CHAIN_LOGOS
export type TokenName = keyof typeof TOKEN_LOGOS
-// Mirrors Rhino's live SDA config (`depositAddresses.getSupportedConfigs()`).
-// Scroll was removed 2026-06-11: Rhino's live config no longer returns an SDA
-// entry for it, and a deposit on an unsupported chain is silently lost.
-export const SUPPORTED_EVM_CHAINS = [
- 'ARBITRUM',
- 'ETHEREUM',
- 'BASE',
- 'OPTIMISM',
- 'BNB',
- 'POLYGON',
- 'KATANA',
- 'GNOSIS',
- 'CELO',
- // TEMPO/KAIA/PLASMA added 2026-07-10 from Rhino's live SDA catalog. KAIA and
- // PLASMA are USDT-only on Rhino while the deposit UI advertises tokens per
- // EVM family (incl. USDC) — accepted risk (Hugo, 2026-07-10): a USDC deposit
- // there is recoverable via the Rhino team. Per-chain token gating is the
- // proper fix (follow-up).
- 'TEMPO',
- 'KAIA',
- 'PLASMA',
-] as const
-
-export const OTHER_SUPPORTED_CHAINS = ['SOLANA', 'TRON'] as const
+// DERIVED from CHAIN_REGISTRY: EVM chains with a deposit surface. Mirrors
+// Rhino's live SDA config — a deposit on an unsupported chain is silently
+// lost, so registry entries only get `deposit` after verifying the catalog.
+export const SUPPORTED_EVM_CHAINS = CHAIN_REGISTRY.filter((c) => c.family === 'evm' && c.deposit).map(
+ (c) => c.displayName as RegistryChainName
+)
+
+// DERIVED from CHAIN_REGISTRY: non-EVM deposit chains.
+export const OTHER_SUPPORTED_CHAINS = CHAIN_REGISTRY.filter((c) => c.family !== 'evm' && c.deposit).map(
+ (c) => c.displayName as RegistryChainName
+)
/** Rhino-supported chains with their logos */
export const RHINO_SUPPORTED_CHAINS = (Object.keys(CHAIN_LOGOS) as ChainName[]).map((name) => ({
@@ -100,13 +74,12 @@ const SUPPORTED_TOKENS_BY_NETWORK: Record = {
* silently lost (no webhook, no intent), so deposit surfaces annotate these.
* Source: Rhino's live SDA catalog (getSupportedConfigs, 2026-07-11).
*/
-export const EVM_DEPOSIT_TOKEN_EXCEPTIONS: Partial> = {
- KAIA: ['USDT'],
- PLASMA: ['USDT'],
- TEMPO: ['USDT', 'USDC'],
- CELO: ['USDT', 'USDC'],
- GNOSIS: ['USDT', 'USDC'],
-}
+export const EVM_DEPOSIT_TOKEN_EXCEPTIONS: Partial> = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.family === 'evm' && c.deposit?.tokens).map((c) => [
+ c.displayName,
+ [...(c.deposit!.tokens as readonly TokenName[])],
+ ])
+)
/** returns supported tokens (with logos) for a given chain type */
export const getSupportedTokens = (network: RhinoChainType): Array<{ name: TokenName; logoUrl: string }> =>
@@ -130,31 +103,14 @@ export const RHINO_SUPPORTED_TOKENS = (Object.keys(TOKEN_LOGOS) as TokenName[])
// BNB Chain is `BINANCE` in Rhino's API. Sending the display name (POLYGON/BNB)
// 400s with `Invalid chain`. Keep this in sync with peanut-api-ts
// `src/rhino/consts.ts` CHAINS_CONFIG.
-export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = {
- '1': 'ETHEREUM',
- '10': 'OPTIMISM',
- '56': 'BINANCE', // Rhino's name for BNB Chain (display: BNB)
- '100': 'GNOSIS',
- '137': 'MATIC_POS', // Rhino's name for Polygon (display: POLYGON)
- // SCROLL (534352) removed 2026-07-10: Rhino disabled it ("SCROLL is disabled"
- // InvalidRequest on quote). Re-add only after confirming via getBridgeConfig().
- '42161': 'ARBITRUM',
- '421614': 'ARBITRUM', // Arb Sepolia — same Rhino bucket for sandbox runs
- '8453': 'BASE',
- '42220': 'CELO',
- // Added 2026-07-10 after verifying each against Rhino's live bridge config
- // (status=enabled) AND a real ARBITRUM→X quote + outflow-SDA create.
- // PLASMA/STABLE are USDT-only routes; token gating lives in token-details.json.
- '43114': 'AVALANCHE',
- '999': 'HYPEREVM',
- '57073': 'INK',
- '747474': 'KATANA',
- '59144': 'LINEA',
- '5000': 'MANTLE',
- '9745': 'PLASMA',
- '988': 'STABLE',
- '4217': 'TEMPO',
-}
+// DERIVED from CHAIN_REGISTRY: every EVM entry with a live Rhino name,
+// including aliases (Arb Sepolia → ARBITRUM for the sandbox harness).
+// Rhino-disabled chains (SCROLL) have no rhinoName and drop out naturally.
+export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.family === 'evm' && c.rhinoName).flatMap((c) =>
+ [c.id, ...(c.aliasIds ?? [])].map((id) => [id, c.rhinoName])
+ )
+)
export function evmChainIdToRhinoName(chainId: string | number): string | undefined {
return EVM_CHAIN_ID_TO_RHINO_NAME[String(chainId)]
@@ -165,10 +121,9 @@ export function evmChainIdToRhinoName(chainId: string | number): string | undefi
* ('solana' | 'tron' — the identifiers the old coming-soon entries used).
* Chain data lives in `nonEvmWithdraw.consts.ts`.
*/
-export const NON_EVM_CHAIN_ID_TO_RHINO_NAME: Record = {
- solana: 'SOLANA',
- tron: 'TRON',
-}
+export const NON_EVM_CHAIN_ID_TO_RHINO_NAME: Record = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.family !== 'evm' && c.rhinoName).map((c) => [c.id, c.rhinoName])
+)
/** chainId (EVM numeric or non-EVM slug) → Rhino API chain name. */
export function chainIdToRhinoName(chainId: string | number): string | undefined {
From a97386530ffb256a29140f4fe2ddd1df92f64363 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 13 Jul 2026 06:33:35 +0100
Subject: [PATCH 17/19] feat: enable Base withdrawals (verified) + fully
consolidate derivations into the registry
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/constants/__tests__/chainRegistry.test.ts | 9 ++-
src/constants/chainRegistry.consts.ts | 58 +++++++++++++++++--
src/constants/chainRollout.consts.ts | 15 -----
src/constants/nonEvmWithdraw.consts.ts | 34 -----------
src/constants/rhino.consts.ts | 2 +-
src/context/tokenSelector.context.tsx | 2 +-
.../shared/hooks/useCrossChainTransfer.ts | 2 +-
src/hooks/useChainRollout.ts | 2 +-
src/lib/validation/addressFamily.ts | 2 +-
src/utils/featureFlag.utils.ts | 2 +-
10 files changed, 66 insertions(+), 62 deletions(-)
delete mode 100644 src/constants/chainRollout.consts.ts
delete mode 100644 src/constants/nonEvmWithdraw.consts.ts
diff --git a/src/constants/__tests__/chainRegistry.test.ts b/src/constants/__tests__/chainRegistry.test.ts
index e3f0b46e84..02377c3368 100644
--- a/src/constants/__tests__/chainRegistry.test.ts
+++ b/src/constants/__tests__/chainRegistry.test.ts
@@ -18,10 +18,8 @@ import {
chainIdToRhinoName,
EVM_DEPOSIT_TOKEN_EXCEPTIONS,
} from '../rhino.consts'
-import { CHAIN_ROLLOUT_FLAGS } from '../chainRollout.consts'
-import { NON_EVM_WITHDRAW_CHAINS } from '../nonEvmWithdraw.consts'
import { RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN } from '@/components/Global/TokenSelector/TokenSelector.consts'
-import { CHAIN_REGISTRY } from '../chainRegistry.consts'
+import { CHAIN_REGISTRY, CHAIN_ROLLOUT_FLAGS, NON_EVM_WITHDRAW_CHAINS } from '../chainRegistry.consts'
describe('CHAIN_REGISTRY derivations match the replaced literals', () => {
it('EVM_CHAIN_ID_TO_RHINO_NAME', () => {
@@ -68,6 +66,9 @@ describe('CHAIN_REGISTRY derivations match the replaced literals', () => {
'137': ['USDC', 'USDT'],
'100': ['USDC', 'USDT'],
'56': ['BNB', 'USDC', 'USDT'],
+ // Added 2026-07-13 (Hugo): the June curation oversight, fixed —
+ // verified live (quotes ETH/USDC/USDT + SDA create) same day.
+ '8453': ['ETH', 'USDC', 'USDT'],
'43114': ['USDC', 'USDT'],
'999': ['USDC', 'USDT'],
'57073': ['USDC', 'USDT'],
@@ -119,6 +120,8 @@ describe('CHAIN_REGISTRY derivations match the replaced literals', () => {
it('CHAIN_ROLLOUT_FLAGS — every surface key of a flagged chain maps to ONE flag', () => {
expect(CHAIN_ROLLOUT_FLAGS).toEqual({
+ '8453': 'chain-rollout-base',
+ BASE: 'chain-rollout-base',
'43114': 'chain-rollout-avalanche',
'999': 'chain-rollout-hyperevm',
'57073': 'chain-rollout-ink',
diff --git a/src/constants/chainRegistry.consts.ts b/src/constants/chainRegistry.consts.ts
index ffb8343e78..77bd60b4e8 100644
--- a/src/constants/chainRegistry.consts.ts
+++ b/src/constants/chainRegistry.consts.ts
@@ -86,10 +86,12 @@ const CHAIN_REGISTRY_LITERAL = [
displayName: 'BASE',
logoUrl: 'https://assets.coingecko.com/asset_platforms/images/131/standard/base.png?1759905869',
deposit: {},
- // NOTE: deliberately NO `withdraw` — Base has never been in the
- // curated withdraw gate (looks like a June-2026 curation oversight;
- // Rhino fully supports it). Behavior-preserving refactor: enabling it
- // is a one-line product decision + verification, not a side effect.
+ // Enabled 2026-07-13 (Hugo): Base was missing from the curated
+ // withdraw gate since June — an oversight, not a decision. Verified
+ // same-day: ARB→BASE quotes OK for ETH/USDC/USDT + outflow SDA
+ // create OK. Rollout-flagged like the other 2026-07 additions.
+ withdraw: { tokens: ['ETH', 'USDC', 'USDT'] },
+ rolloutFlag: 'chain-rollout-base',
},
{
id: '10',
@@ -292,3 +294,51 @@ export type RegistryChainName = Extract = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.rolloutFlag).flatMap((c) =>
+ [c.id, ...(c.aliasIds ?? []), ...(c.displayName ? [c.displayName] : [])].map((key) => [key, c.rolloutFlag!])
+ )
+)
+
+/**
+ * Synthetic selector records for non-EVM withdraw destinations (no
+ * chain-details.json entry). The token-selector context merges these so
+ * every selector surface and the price hook resolve them; they stay
+ * invisible outside the withdraw flow (all other network lists are gated by
+ * the wagmi id set, and URL parsing/validation read the server action).
+ */
+export const NON_EVM_WITHDRAW_CHAINS: Record = Object.fromEntries(
+ CHAIN_REGISTRY.filter((c) => c.nonEvmRecord).map((c) => [
+ c.id,
+ {
+ chainId: c.id,
+ networkName: c.nonEvmRecord!.networkName,
+ chainIconURI: c.logoUrl ?? '',
+ tokens: c.nonEvmRecord!.tokens.map((t) => ({
+ chainId: c.id,
+ address: t.address,
+ decimals: t.decimals,
+ name: t.name,
+ symbol: t.symbol,
+ logoURI: t.logoURI,
+ usdPrice: 0,
+ })),
+ },
+ ])
+)
+
+export function isNonEvmWithdrawChainId(chainId: string | number): boolean {
+ return Object.prototype.hasOwnProperty.call(NON_EVM_WITHDRAW_CHAINS, String(chainId).toLowerCase())
+}
diff --git a/src/constants/chainRollout.consts.ts b/src/constants/chainRollout.consts.ts
deleted file mode 100644
index d01b497069..0000000000
--- a/src/constants/chainRollout.consts.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { CHAIN_REGISTRY } from '@/constants/chainRegistry.consts'
-
-/**
- * Per-chain PostHog rollout flags — DERIVED from CHAIN_REGISTRY, keyed by
- * every identifier a chain appears under (selector id, aliases, deposit
- * display name) so one flag governs all surfaces of the same chain.
- *
- * Hygiene: when a chain launch is permanent, delete `rolloutFlag` from its
- * registry entry and the flag in PostHog — flags are scaffolding.
- */
-export const CHAIN_ROLLOUT_FLAGS: Record = Object.fromEntries(
- CHAIN_REGISTRY.filter((c) => c.rolloutFlag).flatMap((c) =>
- [c.id, ...(c.aliasIds ?? []), ...(c.displayName ? [c.displayName] : [])].map((key) => [key, c.rolloutFlag!])
- )
-)
diff --git a/src/constants/nonEvmWithdraw.consts.ts b/src/constants/nonEvmWithdraw.consts.ts
deleted file mode 100644
index f763521474..0000000000
--- a/src/constants/nonEvmWithdraw.consts.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { ChainWithTokens } from '@/interfaces/chain-meta'
-import { CHAIN_REGISTRY } from '@/constants/chainRegistry.consts'
-
-/**
- * Synthetic selector records for non-EVM withdraw destinations — DERIVED
- * from CHAIN_REGISTRY (`nonEvmRecord` entries). These chains have no
- * chain-details.json entry; the token-selector context merges these records
- * so every selector surface and the price hook resolve them. They stay
- * invisible outside the withdraw flow: every other network list is gated by
- * the wagmi id set, and URL parsing/validation read the server action.
- */
-export const NON_EVM_WITHDRAW_CHAINS: Record = Object.fromEntries(
- CHAIN_REGISTRY.filter((c) => c.nonEvmRecord).map((c) => [
- c.id,
- {
- chainId: c.id,
- networkName: c.nonEvmRecord!.networkName,
- chainIconURI: c.logoUrl ?? '',
- tokens: c.nonEvmRecord!.tokens.map((t) => ({
- chainId: c.id,
- address: t.address,
- decimals: t.decimals,
- name: t.name,
- symbol: t.symbol,
- logoURI: t.logoURI,
- usdPrice: 0,
- })),
- },
- ])
-)
-
-export function isNonEvmWithdrawChainId(chainId: string | number): boolean {
- return Object.prototype.hasOwnProperty.call(NON_EVM_WITHDRAW_CHAINS, String(chainId).toLowerCase())
-}
diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts
index b3a796c204..44044211f1 100644
--- a/src/constants/rhino.consts.ts
+++ b/src/constants/rhino.consts.ts
@@ -119,7 +119,7 @@ export function evmChainIdToRhinoName(chainId: string | number): string | undefi
/**
* Non-EVM withdraw destinations use string slugs as their selector chainId
* ('solana' | 'tron' — the identifiers the old coming-soon entries used).
- * Chain data lives in `nonEvmWithdraw.consts.ts`.
+ * Chain data lives in `chainRegistry.consts.ts`.
*/
export const NON_EVM_CHAIN_ID_TO_RHINO_NAME: Record = Object.fromEntries(
CHAIN_REGISTRY.filter((c) => c.family !== 'evm' && c.rhinoName).map((c) => [c.id, c.rhinoName])
diff --git a/src/context/tokenSelector.context.tsx b/src/context/tokenSelector.context.tsx
index e3923215ec..c027f0eaa9 100644
--- a/src/context/tokenSelector.context.tsx
+++ b/src/context/tokenSelector.context.tsx
@@ -11,7 +11,7 @@ import {
} from '@/constants/zerodev.consts'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useSupportedChainsAndTokens } from '@/hooks/useSupportedChainsAndTokens'
-import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
+import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/chainRegistry.consts'
import { useTokenPrice } from '@/hooks/useTokenPrice'
import { type ITokenPriceData } from '@/interfaces'
import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils'
diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
index 031756ba8b..e142eefc07 100644
--- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts
+++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
@@ -45,7 +45,7 @@ import {
type BridgeStatusResponse,
} from '@/services/rhino-bridge'
import { chainIdToRhinoName } from '@/constants/rhino.consts'
-import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/nonEvmWithdraw.consts'
+import { NON_EVM_WITHDRAW_CHAINS } from '@/constants/chainRegistry.consts'
import { areEvmAddressesEqual, getTokenSymbol } from '@/utils/general.utils'
/** Tokens Rhino's SDA primitive accepts as `tokenOut`. Anything else routes
diff --git a/src/hooks/useChainRollout.ts b/src/hooks/useChainRollout.ts
index 973270f7c2..c2add3226b 100644
--- a/src/hooks/useChainRollout.ts
+++ b/src/hooks/useChainRollout.ts
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
import { useFeatureFlags } from '@/hooks/useFeatureFlag'
-import { CHAIN_ROLLOUT_FLAGS } from '@/constants/chainRollout.consts'
+import { CHAIN_ROLLOUT_FLAGS } from '@/constants/chainRegistry.consts'
/**
* Reactive per-chain rollout gate — thin domain wrapper over
diff --git a/src/lib/validation/addressFamily.ts b/src/lib/validation/addressFamily.ts
index c64fdf6bd5..1f4d6a737c 100644
--- a/src/lib/validation/addressFamily.ts
+++ b/src/lib/validation/addressFamily.ts
@@ -1,5 +1,5 @@
import { isAddress } from 'viem'
-import { isNonEvmWithdrawChainId } from '@/constants/nonEvmWithdraw.consts'
+import { isNonEvmWithdrawChainId } from '@/constants/chainRegistry.consts'
/**
* Address families for withdraw destinations. EVM chains share one 0x
diff --git a/src/utils/featureFlag.utils.ts b/src/utils/featureFlag.utils.ts
index 9980207d19..ea03d87070 100644
--- a/src/utils/featureFlag.utils.ts
+++ b/src/utils/featureFlag.utils.ts
@@ -1,6 +1,6 @@
import posthog from 'posthog-js'
import { BASE_URL } from '@/constants/general.consts'
-import { CHAIN_ROLLOUT_FLAGS } from '@/constants/chainRollout.consts'
+import { CHAIN_ROLLOUT_FLAGS } from '@/constants/chainRegistry.consts'
/**
* PostHog feature flags — the runtime-toggle primitive (non-reactive reads;
From 5c7ec1d0f418f327681b91c99753a808b7efffb8 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 13 Jul 2026 06:36:19 +0100
Subject: [PATCH 18/19] fix: withdraw receipts always link the source-chain
explorer (CodeRabbit) + hoisting-safe mock names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../transactionTransformer.ts | 21 +++++++++---------
src/hooks/__tests__/useChainRollout.test.tsx | 22 +++++++++----------
2 files changed, 22 insertions(+), 21 deletions(-)
diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts
index 0417b2c5c7..e9036bf9ef 100644
--- a/src/components/TransactionDetails/transactionTransformer.ts
+++ b/src/components/TransactionDetails/transactionTransformer.ts
@@ -248,17 +248,18 @@ function computeDerivedFields(entry: HistoryEntry): {
} {
// For crypto deposits, force the explorer URL to Peanut's wallet chain
// (Arbitrum) — the underlying chainId field is the deposit-source chain.
+ // CRYPTO_DEPOSIT and CRYPTO_WITHDRAW both record the tx hash on Peanut's
+ // wallet chain (Arbitrum) — for withdrawals entry.chainId is the
+ // DESTINATION, so linking it with the recorded hash mislinked receipts on
+ // destinations that have an explorer (e.g. Avalanche) and left them
+ // linkless on ones that don't (Tempo, Solana, Tron). Always link the
+ // chain the recorded hash actually lives on. (Known residual: a withdraw
+ // completed via the BRIDGE_EXECUTED webhook carries the destination-side
+ // hash — rare; linking source keeps the dominant case correct.)
+ const kind = intentKindOf(entry)
const explorerUrlChainID =
- intentKindOf(entry) === 'CRYPTO_DEPOSIT' ? PEANUT_WALLET_CHAIN.id.toString() : entry.chainId
- 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())
- }
+ kind === 'CRYPTO_DEPOSIT' || kind === 'CRYPTO_WITHDRAW' ? PEANUT_WALLET_CHAIN.id.toString() : entry.chainId
+ const baseUrl = getExplorerUrl(explorerUrlChainID)
let explorerUrlWithTx: string | undefined
let addressExplorerUrl: string | undefined
diff --git a/src/hooks/__tests__/useChainRollout.test.tsx b/src/hooks/__tests__/useChainRollout.test.tsx
index a341839b6e..27dc1d5d77 100644
--- a/src/hooks/__tests__/useChainRollout.test.tsx
+++ b/src/hooks/__tests__/useChainRollout.test.tsx
@@ -1,16 +1,16 @@
import { renderHook, act } from '@testing-library/react'
// posthog-js is mocked so tests control flag values and load events
-let flagsCallback: (() => void) | undefined
-const isFeatureEnabledMock = jest.fn()
+let mockFlagsCallback: (() => void) | undefined
+const mockIsFeatureEnabled = jest.fn()
jest.mock('posthog-js', () => ({
__esModule: true,
default: {
- isFeatureEnabled: (key: string) => isFeatureEnabledMock(key),
+ isFeatureEnabled: (key: string) => mockIsFeatureEnabled(key),
onFeatureFlags: (cb: () => void) => {
- flagsCallback = cb
+ mockFlagsCallback = cb
return () => {
- flagsCallback = undefined
+ mockFlagsCallback = undefined
}
},
},
@@ -28,30 +28,30 @@ describe('useFeatureFlags', () => {
it('returns a NEW checker identity when PostHog flags load (memo-busting)', () => {
const { result } = renderHook(() => useFeatureFlags())
const before = result.current
- act(() => flagsCallback?.())
+ act(() => mockFlagsCallback?.())
expect(result.current).not.toBe(before) // regression: frozen-at-mount gate
})
})
describe('useChainRollout', () => {
- beforeEach(() => isFeatureEnabledMock.mockReset())
+ beforeEach(() => mockIsFeatureEnabled.mockReset())
it('always allows unflagged (legacy) chains', () => {
const { result } = renderHook(() => useChainRollout())
expect(result.current('42161')).toBe(true)
- expect(isFeatureEnabledMock).not.toHaveBeenCalled()
+ expect(mockIsFeatureEnabled).not.toHaveBeenCalled()
})
it('fails CLOSED on prod when PostHog has no answer', () => {
- isFeatureEnabledMock.mockReturnValue(undefined)
+ mockIsFeatureEnabled.mockReturnValue(undefined)
const { result } = renderHook(() => useChainRollout())
expect(result.current('solana')).toBe(false)
})
it('reflects flag values once loaded, keyed per chain', () => {
- isFeatureEnabledMock.mockImplementation((key: string) => key === 'chain-rollout-tempo')
+ mockIsFeatureEnabled.mockImplementation((key: string) => key === 'chain-rollout-tempo')
const { result } = renderHook(() => useChainRollout())
- act(() => flagsCallback?.())
+ act(() => mockFlagsCallback?.())
expect(result.current('4217')).toBe(true) // tempo by chainId
expect(result.current('TEMPO')).toBe(true) // tempo by deposit ChainName — same flag
expect(result.current('solana')).toBe(false)
From 89e0621eccc4bcce82c58771deec9b0d4ce04ea5 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 13 Jul 2026 07:29:16 +0100
Subject: [PATCH 19/19] =?UTF-8?q?test:=20leak=20tripwire=20=E2=80=94=20non?=
=?UTF-8?q?-EVM=20synthetic=20records=20stay=20out=20of=20non-withdraw=20g?=
=?UTF-8?q?ates?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/constants/__tests__/nonEvmLeak.test.ts | 55 ++++++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 src/constants/__tests__/nonEvmLeak.test.ts
diff --git a/src/constants/__tests__/nonEvmLeak.test.ts b/src/constants/__tests__/nonEvmLeak.test.ts
new file mode 100644
index 0000000000..ef7ecf322d
--- /dev/null
+++ b/src/constants/__tests__/nonEvmLeak.test.ts
@@ -0,0 +1,55 @@
+/**
+ * Leak tripwire for the synthetic non-EVM withdraw records.
+ *
+ * NON_EVM_WITHDRAW_CHAINS (Solana/Tron) is merged into the GLOBAL
+ * tokenSelector context so the withdraw selector and the price hook resolve
+ * them. They are kept out of send / claim / pay / URL-parse surfaces only by
+ * discipline: those surfaces gate their network list on the wagmi-derived id
+ * set (TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS), and URL parsing reads the
+ * server action, not the context. This test turns that discipline into an
+ * enforced invariant — if a non-EVM chain ever enters a non-withdraw gate,
+ * it fails here instead of leaking a broken (base58-address) chain into a
+ * send flow.
+ */
+// TokenSelector.consts imports the wagmi `networks` config, which cannot
+// construct under jest — mock it to the real mainnet ids the gate filters on.
+jest.mock('@/config', () => ({
+ networks: [
+ { id: 42161 },
+ { id: 1 },
+ { id: 10 },
+ { id: 137 },
+ { id: 100 },
+ { id: 8453 },
+ { id: 56 },
+ { id: 42220 },
+ { id: 59144 },
+ { id: 534352 },
+ { id: 480 }, // worldchain
+ ],
+}))
+
+import { NON_EVM_WITHDRAW_CHAINS } from '../chainRegistry.consts'
+import { TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS } from '@/components/Global/TokenSelector/TokenSelector.consts'
+import { supportedPeanutChains } from '@/constants/general.consts'
+
+describe('non-EVM synthetic records do not leak into non-withdraw surfaces', () => {
+ const nonEvmIds = Object.keys(NON_EVM_WITHDRAW_CHAINS) // ['solana', 'tron']
+
+ it('has the expected non-EVM ids (guards the test itself)', () => {
+ expect(nonEvmIds.sort()).toEqual(['solana', 'tron'])
+ })
+
+ it('is disjoint from the non-withdraw network gate (TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS)', () => {
+ for (const id of nonEvmIds) {
+ expect(TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS).not.toContain(id)
+ }
+ })
+
+ it('is disjoint from the canonical chain source (supportedPeanutChains — feeds URL parsing/validation)', () => {
+ const peanutChainIds = supportedPeanutChains.map((c) => String(c.chainId).toLowerCase())
+ for (const id of nonEvmIds) {
+ expect(peanutChainIds).not.toContain(id)
+ }
+ })
+})