diff --git a/src/app/(mobile-ui)/card-recovery/page.tsx b/src/app/(mobile-ui)/card-recovery/page.tsx
index a155c8fd61..0676a690e3 100644
--- a/src/app/(mobile-ui)/card-recovery/page.tsx
+++ b/src/app/(mobile-ui)/card-recovery/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
-import type { Address, Hex } from 'viem'
+import type { Hex } from 'viem'
import { Button } from '@/components/0_Bruddle/Button'
import { Card } from '@/components/0_Bruddle/Card'
import ErrorAlert from '@/components/Global/ErrorAlert'
@@ -9,11 +9,7 @@ import NavHeader from '@/components/Global/NavHeader'
import PeanutLoading from '@/components/Global/PeanutLoading'
import { useKernelClient } from '@/context/kernelClient.context'
import { useSafeBack } from '@/hooks/useSafeBack'
-import {
- RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
- RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
- rainWithdrawEip712Types,
-} from '@/constants/rain.consts'
+import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils'
import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts'
import { rainApi, type RecoverFundsPreviewResponse } from '@/services/rain'
import { getExplorerUrl } from '@/utils/general.utils'
@@ -82,24 +78,9 @@ export default function CardRecoveryPage() {
const chainIdNum = Number(prep.chainId)
const kernelClient = getClientForChain(chainIdStr)
- const adminSignature = (await kernelClient.account!.signTypedData({
- domain: {
- name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
- version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
- chainId: chainIdNum,
- verifyingContract: prep.collateralProxy as Address,
- salt: prep.adminSalt as Hex,
- },
- types: rainWithdrawEip712Types,
- primaryType: 'Withdraw',
- message: {
- user: prep.adminAddress as Address,
- asset: prep.tokenAddress as Address,
- amount: BigInt(prep.amount),
- recipient: prep.recipientAddress as Address,
- nonce: BigInt(prep.adminNonce),
- },
- })) as Hex
+ const adminSignature = (await kernelClient.account!.signTypedData(
+ buildRainWithdrawTypedData(prep, chainIdNum)
+ )) as Hex
setStep('submitting')
const { txHash: hash } = await rainApi.submitWithdrawal({
diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
index 2aa00884e7..f305d66aa0 100644
--- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
+++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
@@ -506,7 +506,7 @@ function setCapabilitiesGate(state: GateState, opts: { userMessage?: string | nu
// Loading state context provider
const LoadingStateProvider = ({ children }: { children: React.ReactNode }) => {
- const loadingStateContext = require('@/context').loadingStateContext
+ const loadingStateContext = require('@/context/loadingStates.context').loadingStateContext
const [loadingState, setLoadingState] = React.useState('Idle')
const isLoading = loadingState !== 'Idle'
return (
@@ -516,9 +516,10 @@ const LoadingStateProvider = ({ children }: { children: React.ReactNode }) => {
)
}
-// We need to mock the context module itself since it's imported via { loadingStateContext }
+// We need to mock the context module itself (specific file, not the barrel — the page
+// imports from '@/context/loadingStates.context' per the no-barrel rule)
const mockSetLoadingState = jest.fn()
-jest.mock('@/context', () => ({
+jest.mock('@/context/loadingStates.context', () => ({
loadingStateContext: React.createContext({
loadingState: 'Idle' as string,
setLoadingState: (s: string) => {},
@@ -529,7 +530,7 @@ jest.mock('@/context', () => ({
function renderQrPay(params: Record = {}) {
setSearchParams(params)
const queryClient = createQueryClient()
- const { loadingStateContext } = require('@/context')
+ const { loadingStateContext } = require('@/context/loadingStates.context')
const LoadingProvider = ({ children }: { children: React.ReactNode }) => {
const [loadingState, setLoadingState] = React.useState('Idle')
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 42ebe2d750..a5e11a4b33 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -19,7 +19,7 @@ import AmountInput from '@/components/Global/AmountInput'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard'
-import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
+import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/spendPreflight'
import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import {
@@ -28,14 +28,9 @@ import {
BALANCE_SETTLING_MESSAGE,
isAmountWithinBalance,
} from '@/utils/balance.utils'
-import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils'
+import { formatNumberForDisplay } from '@/utils/general.utils'
import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils'
-import {
- calculateSavingsInCents,
- hasCardMarkupComparison,
- isArgentinaMantecaQrPayment,
- getSavingsMessage,
-} from '@/utils/qr-payment.utils'
+import { calculateSavingsInCents, hasCardMarkupComparison, getSavingsMessage } from '@/utils/qr-payment.utils'
import { useCardMarkupRate } from '@/hooks/useCardMarkupRate'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
@@ -44,11 +39,10 @@ import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts'
import { MIN_MANTECA_QR_PAYMENT_AMOUNT, MIN_PIX_AMOUNT_BRL } from '@/constants/payment.consts'
import { isPixRecurringCode } from '@/utils/withdraw.utils'
import { formatUnits, parseUnits } from 'viem'
-import type { TransactionReceipt, Hash } from 'viem'
import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer'
import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer'
import { EHistoryUserRole } from '@/hooks/useTransactionHistory'
-import { loadingStateContext } from '@/context'
+import { loadingStateContext } from '@/context/loadingStates.context'
import { getCurrencyPrice } from '@/app/actions/currency'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { captureException } from '@sentry/nextjs'
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index b79d827e00..88da6282ee 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -3,7 +3,7 @@
import { useWallet } from '@/hooks/wallet/useWallet'
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard'
-import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
+import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/spendPreflight'
import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils'
import {
rainCentsToUsdcUnits,
diff --git a/src/app/actions/supported-chains.ts b/src/app/actions/supported-chains.ts
index 0b94ecc5b7..56eb628b3c 100644
--- a/src/app/actions/supported-chains.ts
+++ b/src/app/actions/supported-chains.ts
@@ -7,6 +7,10 @@ import ARBITRUM_ICON from '@/assets/chains/arbitrum.svg'
// falls back to initials ("AO"). Prefer a bundled local asset for those.
const CHAIN_ICON_OVERRIDES: Record = {
'42161': ARBITRUM_ICON,
+ // Linea's chain-details icon is an SVG served via ipfs.io — next/image
+ // refuses SVG by default, so it rendered as "LI" initials. CoinGecko
+ // raster instead. (Avalanche/Mantle ipfs icons are PNG and render fine.)
+ '59144': 'https://coin-images.coingecko.com/asset_platforms/images/135/small/linea.jpeg?1706606705',
}
export async function getSupportedChainsAndTokens(): Promise> {
diff --git a/src/app/api/send-discord-notification/route.ts b/src/app/api/send-discord-notification/route.ts
deleted file mode 100644
index 42dc4e2cd4..0000000000
--- a/src/app/api/send-discord-notification/route.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { NextRequest } from 'next/server'
-import { fetchWithSentry } from '@/utils/sentry.utils'
-
-export async function POST(request: NextRequest) {
- try {
- const body = await request.json()
- const webhookUrl = process.env.DISCORD_WEBHOOK_URL ?? ''
-
- if (!webhookUrl) throw new Error('DISCORD_WEBHOOK not found in env')
-
- const response = await fetchWithSentry(webhookUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- content: body.message,
- }),
- })
-
- return new Response(JSON.stringify(response), {
- status: 200,
- headers: {
- 'Content-Type': 'application/json',
- },
- })
- } catch (error) {
- console.error('Error in discord send notif Route Handler:', error)
- return new Response('Internal Server Error', { status: 500 })
- }
-}
-
-// OK
diff --git a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
index 2ba3113ab2..0f1c3dd951 100644
--- a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
+++ b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx
@@ -2,8 +2,9 @@
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/Global/Drawer'
import { ActionListCard } from '@/components/ActionListCard'
-import ChainChip from './ChainChip'
+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'
@@ -14,6 +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()}>
@@ -27,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.map((chain) => (
-
- ))}
+
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 29953b2e9f..777af51c86 100644
--- a/src/components/AddMoney/components/SupportedNetworksModal.tsx
+++ b/src/components/AddMoney/components/SupportedNetworksModal.tsx
@@ -2,8 +2,7 @@
import Modal from '@/components/Global/Modal'
import InfoCard from '@/components/Global/InfoCard'
-import ChainChip from './ChainChip'
-import { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS } from '@/constants/rhino.consts'
+import EvmChainChips from './EvmChainChips'
interface SupportedNetworksModalProps {
visible: boolean
@@ -25,9 +24,7 @@ const SupportedNetworksModal = ({ visible, onClose }: SupportedNetworksModalProp
- {SUPPORTED_EVM_CHAINS.map((chain) => (
-
- ))}
+
+ {!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/Card/CancelCardModal.tsx b/src/components/Card/CancelCardModal.tsx
index 0922d82576..57602ce962 100644
--- a/src/components/Card/CancelCardModal.tsx
+++ b/src/components/Card/CancelCardModal.tsx
@@ -10,7 +10,7 @@ import SlideToAction from '@/components/Card/SlideToAction'
import { rainApi } from '@/services/rain'
import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview'
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
-import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
+import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/spendPreflight'
import { useWallet } from '@/hooks/wallet/useWallet'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'
diff --git a/src/components/Card/LockCardModal.tsx b/src/components/Card/LockCardModal.tsx
index 61d7f7cf29..b7dda32168 100644
--- a/src/components/Card/LockCardModal.tsx
+++ b/src/components/Card/LockCardModal.tsx
@@ -10,7 +10,7 @@ import SlideToAction from '@/components/Card/SlideToAction'
import { rainApi } from '@/services/rain'
import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview'
import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle'
-import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
+import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/spendPreflight'
import { useWallet } from '@/hooks/wallet/useWallet'
import { rainCentsToUsdcUnits } from '@/utils/balance.utils'
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 1201cdfee1..2a5376fd26 100644
--- a/src/components/Global/TokenSelector/TokenSelector.consts.ts
+++ b/src/components/Global/TokenSelector/TokenSelector.consts.ts
@@ -1,7 +1,8 @@
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, worldchain } from 'viem/chains'
+import { celo, linea, scroll, worldchain } from 'viem/chains'
interface CombinedType extends IPeanutChainDetails {
tokens: IToken[]
@@ -69,7 +70,9 @@ export const TOKEN_SELECTOR_POPULAR_NETWORK_IDS = [
},
]
-const networksToExclude: readonly number[] = [celo.id, linea.id, worldchain.id] as const
+// scroll excluded 2026-07-10: Rhino disabled it entirely, and Scroll isn't an
+// SDA deposit chain either, so every cross-chain route from it dead-ends.
+const networksToExclude: readonly number[] = [celo.id, linea.id, scroll.id, worldchain.id] as const
// supported network ids for the network list, getting this from reown appkit config
export const TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS = networks
@@ -92,12 +95,12 @@ export const TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS = networks
* BNB Chain is supported.
* - EVM only (the withdraw flow uses 0x addresses); matches the current
* selectable chain set rather than every Rhino chain.
+ * - 2026-07-10 expansion: each new chain verified against Rhino prod with a real
+ * ARBITRUM→X quote AND an outflow SDA create (see PR #2396). Stablecoins only
+ * for the new chains — that's what was tested. Plasma/Stable are USDT-only on
+ * 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
-}
+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/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx
index 2f7214cf8b..5ccc640914 100644
--- a/src/components/Global/TokenSelector/TokenSelector.tsx
+++ b/src/components/Global/TokenSelector/TokenSelector.tsx
@@ -33,6 +33,7 @@ import {
TOKEN_SELECTOR_POPULAR_NETWORK_IDS,
TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS,
} from './TokenSelector.consts'
+import { useChainRollout } from '@/hooks/useChainRollout'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
import underMaintenanceConfig from '@/config/underMaintenance.config'
@@ -131,7 +132,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(() => {
@@ -169,14 +176,22 @@ const TokenSelector: React.FC = ({ classNameButton, viewT
}
}
+ // Withdraw destinations are gated by what Rhino can DELIVER to
+ // (RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN), not by the wagmi source-chain
+ // 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
- ? TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS.filter((id) => RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN[id])
+ ? Object.keys(RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN).filter(isChainRolledOut)
: TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS
),
- [restrictToRhino]
+ [restrictToRhino, isChainRolledOut]
)
const popularChainsForButtons = useMemo(() => {
@@ -306,8 +321,11 @@ const TokenSelector: React.FC = ({ classNameButton, viewT
}
if (searchValue) {
- // search active: show searched token across ALL supported networks
- return buildTokensForChainArray(TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS, searchValue)
+ // search active: show searched token across all networks selectable
+ // in this mode — the Rhino destination set for withdraw (which
+ // includes destination-only chains like Linea/Avalanche), the wagmi
+ // source list otherwise.
+ return buildTokensForChainArray(Array.from(allowedChainIds), searchValue)
}
if (selectedChainID) {
// specific chain selected: show popular (USDC, USDT, Native) for that chain
@@ -324,6 +342,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT
isCrossChainDisabled,
restrictToRhino,
isRhinoSupported,
+ allowedChainIds,
])
// filter popular tokens by search
@@ -435,7 +454,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..e9036bf9ef 100644
--- a/src/components/TransactionDetails/transactionTransformer.ts
+++ b/src/components/TransactionDetails/transactionTransformer.ts
@@ -248,8 +248,17 @@ 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
+ kind === 'CRYPTO_DEPOSIT' || kind === 'CRYPTO_WITHDRAW' ? PEANUT_WALLET_CHAIN.id.toString() : entry.chainId
const baseUrl = getExplorerUrl(explorerUrlChainID)
let explorerUrlWithTx: string | undefined
diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx
index 822a4dfe6a..2231f1df46 100644
--- a/src/components/Withdraw/views/Initial.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx
@@ -11,9 +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 { addressFamilyForChainId } from '@/lib/validation/addressFamily'
interface InitialWithdrawViewProps {
amount: string
@@ -44,7 +45,21 @@ 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 = () => {
+ // 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
@@ -98,7 +113,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 +131,8 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
{
setRecipient(update.recipient)
diff --git a/src/constants/__tests__/chainRegistry.test.ts b/src/constants/__tests__/chainRegistry.test.ts
new file mode 100644
index 0000000000..02377c3368
--- /dev/null
+++ b/src/constants/__tests__/chainRegistry.test.ts
@@ -0,0 +1,169 @@
+/**
+ * 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 { RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN } from '@/components/Global/TokenSelector/TokenSelector.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', () => {
+ 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'],
+ // 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'],
+ '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({
+ '8453': 'chain-rollout-base',
+ BASE: 'chain-rollout-base',
+ '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/__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)
+ }
+ })
+})
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/constants/chain-details.json b/src/constants/chain-details.json
index becebf85d8..e4ccf0163e 100644
--- a/src/constants/chain-details.json
+++ b/src/constants/chain-details.json
@@ -106,7 +106,6 @@
],
"mainnet": true
},
-
"10": {
"name": "Optimism",
"chain": "ETH",
@@ -835,7 +834,6 @@
"format": "png"
}
},
-
"167009": {
"name": "Taiko Hekla L2",
"chain": "ETH",
@@ -1073,5 +1071,173 @@
"url": "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/color/eth.svg",
"format": "svg"
}
+ },
+ "999": {
+ "name": "HyperEVM",
+ "chain": "HYPE",
+ "icon": {
+ "url": "https://coin-images.coingecko.com/asset_platforms/images/22208/small/hyperliquid.jpg?1740125774",
+ "format": "jpg"
+ },
+ "rpc": ["https://rpc.hyperliquid.xyz/evm"],
+ "features": [],
+ "faucets": [],
+ "nativeCurrency": {
+ "name": "Hype",
+ "symbol": "HYPE",
+ "decimals": 18
+ },
+ "infoURL": "https://hyperliquid.xyz",
+ "shortName": "hyperevm",
+ "chainId": "999",
+ "networkId": 999,
+ "explorers": [
+ {
+ "name": "HyperEVMScan",
+ "url": "https://hyperevmscan.io",
+ "standard": "EIP3091"
+ }
+ ],
+ "mainnet": true
+ },
+ "57073": {
+ "name": "Ink",
+ "chain": "ETH",
+ "icon": {
+ "url": "https://coin-images.coingecko.com/asset_platforms/images/22194/small/ink.jpg?1737600222",
+ "format": "jpg"
+ },
+ "rpc": ["https://rpc-gel.inkonchain.com"],
+ "features": [],
+ "faucets": [],
+ "nativeCurrency": {
+ "name": "Ether",
+ "symbol": "ETH",
+ "decimals": 18
+ },
+ "infoURL": "https://inkonchain.com",
+ "shortName": "ink",
+ "chainId": "57073",
+ "networkId": 57073,
+ "explorers": [
+ {
+ "name": "Ink Explorer",
+ "url": "https://explorer.inkonchain.com",
+ "standard": "EIP3091"
+ }
+ ],
+ "mainnet": true
+ },
+ "747474": {
+ "name": "Katana",
+ "chain": "ETH",
+ "icon": {
+ "url": "https://coin-images.coingecko.com/asset_platforms/images/32239/small/katana.jpg?1751496126",
+ "format": "jpg"
+ },
+ "rpc": ["https://rpc.katana.network"],
+ "features": [],
+ "faucets": [],
+ "nativeCurrency": {
+ "name": "Ether",
+ "symbol": "ETH",
+ "decimals": 18
+ },
+ "infoURL": "https://katana.network",
+ "shortName": "katana",
+ "chainId": "747474",
+ "networkId": 747474,
+ "explorers": [
+ {
+ "name": "Katanascan",
+ "url": "https://katanascan.com",
+ "standard": "EIP3091"
+ }
+ ],
+ "mainnet": true
+ },
+ "9745": {
+ "name": "Plasma",
+ "chain": "Plasma",
+ "icon": {
+ "url": "https://coin-images.coingecko.com/asset_platforms/images/32256/small/plasma.jpg?1758000963",
+ "format": "jpg"
+ },
+ "rpc": ["https://rpc.plasma.to"],
+ "features": [],
+ "faucets": [],
+ "nativeCurrency": {
+ "name": "Plasma",
+ "symbol": "XPL",
+ "decimals": 18
+ },
+ "infoURL": "https://plasma.to",
+ "shortName": "plasma",
+ "chainId": "9745",
+ "networkId": 9745,
+ "explorers": [
+ {
+ "name": "Routescan",
+ "url": "https://plasmascan.to",
+ "standard": "EIP3091"
+ }
+ ],
+ "mainnet": true
+ },
+ "988": {
+ "name": "Stable",
+ "chain": "Stable",
+ "icon": {
+ "url": "https://coin-images.coingecko.com/asset_platforms/images/32271/small/stable.png?1765196531",
+ "format": "png"
+ },
+ "rpc": ["https://rpc.stable.xyz"],
+ "features": [],
+ "faucets": [],
+ "nativeCurrency": {
+ "name": "USDT0",
+ "symbol": "USDT0",
+ "decimals": 18
+ },
+ "infoURL": "https://stable.xyz",
+ "shortName": "stable",
+ "chainId": "988",
+ "networkId": 988,
+ "explorers": [
+ {
+ "name": "Stablescan",
+ "url": "https://stablescan.xyz",
+ "standard": "EIP3091"
+ }
+ ],
+ "mainnet": true
+ },
+ "4217": {
+ "name": "Tempo",
+ "chain": "Tempo",
+ "icon": {
+ "url": "https://icons.llamao.fi/icons/chains/rsz_tempo.jpg",
+ "format": "jpg"
+ },
+ "rpc": ["https://rpc.mainnet.tempo.xyz"],
+ "features": [],
+ "faucets": [],
+ "nativeCurrency": {
+ "name": "USD",
+ "symbol": "USD",
+ "decimals": 18
+ },
+ "infoURL": "https://tempo.xyz",
+ "shortName": "tempo",
+ "chainId": "4217",
+ "networkId": 4217,
+ "explorers": [
+ {
+ "name": "Tempo Explorer",
+ "url": "https://explore.tempo.xyz",
+ "standard": "EIP3091"
+ }
+ ],
+ "mainnet": true
}
}
diff --git a/src/constants/chainRegistry.consts.ts b/src/constants/chainRegistry.consts.ts
new file mode 100644
index 0000000000..77bd60b4e8
--- /dev/null
+++ b/src/constants/chainRegistry.consts.ts
@@ -0,0 +1,344 @@
+/**
+ * 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: {},
+ // 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',
+ 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
+
+// ─── Derived views ─────────────────────────────────────────────────────────
+// Everything below is COMPUTED from the registry — never hand-edit a chain
+// fact here; edit the entry above.
+
+import type { ChainWithTokens } from '@/interfaces/chain-meta'
+
+/**
+ * Per-chain PostHog rollout flags, keyed by every identifier a chain appears
+ * under (selector id, aliases, deposit display name) so one flag governs all
+ * surfaces of the same chain. See engineering/patterns/feature-gates.md.
+ */
+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!])
+ )
+)
+
+/**
+ * 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/rhino.consts.ts b/src/constants/rhino.consts.ts
index 018eadf980..44044211f1 100644
--- a/src/constants/rhino.consts.ts
+++ b/src/constants/rhino.consts.ts
@@ -1,20 +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',
-} 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 = {
@@ -26,22 +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',
-] 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) => ({
@@ -83,6 +68,19 @@ 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> = 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 }> =>
SUPPORTED_TOKENS_BY_NETWORK[network].map((name) => ({ name, logoUrl: TOKEN_LOGOS[name] }))
@@ -105,19 +103,30 @@ 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)
- '534352': 'SCROLL',
- '42161': 'ARBITRUM',
- '421614': 'ARBITRUM', // Arb Sepolia — same Rhino bucket for sandbox runs
- '8453': 'BASE',
- '42220': 'CELO',
-}
+// 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)]
}
+
+/**
+ * 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 `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])
+)
+
+/** 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/constants/token-details.json b/src/constants/token-details.json
index df10788e90..695d0dae3f 100644
--- a/src/constants/token-details.json
+++ b/src/constants/token-details.json
@@ -2514,6 +2514,13 @@
"symbol": "USDC",
"decimals": 6,
"logoURI": "https://market-data-images.s3.us-east-1.amazonaws.com/tokenImages/0x10ca7e698fab4eb287d4d33b3886ae17a6d078fbda455cdd673cfec0ca8ef413.png"
+ },
+ {
+ "address": "0x779ded0c9e1022225f8e0630b35a9b54be713736",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
}
]
},
@@ -2968,5 +2975,125 @@
"logoURI": "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/color/eth.svg"
}
]
+ },
+ {
+ "chainId": "999",
+ "name": "HyperEVM",
+ "tokens": [
+ {
+ "address": "0xb88339cb7199b77e23db6e890353e22632ba630f",
+ "name": "USD Coin",
+ "symbol": "USDC",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png"
+ },
+ {
+ "address": "0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
+ }
+ ]
+ },
+ {
+ "chainId": "57073",
+ "name": "Ink",
+ "tokens": [
+ {
+ "address": "0x0000000000000000000000000000000000000000",
+ "name": "Ether",
+ "symbol": "ETH",
+ "decimals": 18,
+ "logoURI": "https://assets.coingecko.com/coins/images/279/standard/ethereum.png?1696501628"
+ },
+ {
+ "address": "0x2d270e6886d130d724215a266106e6832161eaed",
+ "name": "USD Coin",
+ "symbol": "USDC",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png"
+ },
+ {
+ "address": "0x0200c29006150606b650577bbe7b6248f58470c1",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
+ }
+ ]
+ },
+ {
+ "chainId": "747474",
+ "name": "Katana",
+ "tokens": [
+ {
+ "address": "0x0000000000000000000000000000000000000000",
+ "name": "Ether",
+ "symbol": "ETH",
+ "decimals": 18,
+ "logoURI": "https://assets.coingecko.com/coins/images/279/standard/ethereum.png?1696501628"
+ },
+ {
+ "address": "0x203a662b0bd271a6ed5a60edfbd04bfce608fd36",
+ "name": "USD Coin",
+ "symbol": "USDC",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png"
+ },
+ {
+ "address": "0x2dca96907fde857dd3d816880a0df407eeb2d2f2",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
+ }
+ ]
+ },
+ {
+ "chainId": "9745",
+ "name": "Plasma",
+ "tokens": [
+ {
+ "address": "0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
+ }
+ ]
+ },
+ {
+ "chainId": "988",
+ "name": "Stable",
+ "tokens": [
+ {
+ "address": "0x779ded0c9e1022225f8e0630b35a9b54be713736",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
+ }
+ ]
+ },
+ {
+ "chainId": "4217",
+ "name": "Tempo",
+ "tokens": [
+ {
+ "address": "0x20c000000000000000000000b9537d11c60e8b50",
+ "name": "USD Coin",
+ "symbol": "USDC",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png"
+ },
+ {
+ "address": "0x20c00000000000000000000014f22ca97301eb73",
+ "name": "Tether USD",
+ "symbol": "USDT",
+ "decimals": 6,
+ "logoURI": "https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661"
+ }
+ ]
}
]
diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx
index 42fda06ab6..85ad8dcc06 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,35 @@ 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