Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
429279b
Merge pull request #2391 from peanutprotocol/dev
jjramirezn Jul 9, 2026
82cffcc
fix: migrate root validator before mixed spends sign the Rain admin sig
Hugo0 Jul 10, 2026
d85278e
fix: same migration gate for the sign-only spend path + overlay the m…
Hugo0 Jul 10, 2026
bfe995e
chore: clear lint deltas — barrel import, orphaned decimals import, c…
Hugo0 Jul 10, 2026
e07d73c
refactor: one shared spend preflight for both engines — drift here is…
Hugo0 Jul 10, 2026
7985712
fix: harden the migration gate per adversarial review — 7 verified fi…
Hugo0 Jul 10, 2026
c8c3da3
chore: clear the lint annotations visible on the PR diff
Hugo0 Jul 10, 2026
f03ffd2
Merge pull request #2392 from peanutprotocol/hotfix/migrate-before-mi…
jjramirezn Jul 10, 2026
2e27f52
fix: remove unauthenticated public Discord relay endpoint
jjramirezn Jul 10, 2026
c4cb821
Merge pull request #2395 from peanutprotocol/fix/remove-unauth-discor…
Hugo0 Jul 10, 2026
14ebb1f
feat: sync Rhino chain support with live catalogs
Hugo0 Jul 10, 2026
bde3487
feat: wire new Rhino chains into the withdraw selector gate
Hugo0 Jul 10, 2026
58aa9d9
fix: search withdraw tokens across the Rhino destination set
Hugo0 Jul 10, 2026
60e8184
feat: add Kaia + Plasma deposit chains
Hugo0 Jul 10, 2026
dc29b19
Merge pull request #2396 from peanutprotocol/hotfix/rhino-chain-catal…
Hugo0 Jul 10, 2026
7de82b9
Merge remote-tracking branch 'origin/main' into merge/main-into-dev-2…
Hugo0 Jul 11, 2026
bbadbf1
feat: Solana + Tron withdrawals, plus chain-expansion polish
Hugo0 Jul 11, 2026
83ed62a
feat: per-chain rollout flags (PostHog) for one-by-one chain launches
Hugo0 Jul 11, 2026
f5e2156
refactor: general useFeatureFlag primitive + useChainRollout as thin …
Hugo0 Jul 11, 2026
ea63b6c
fix: final-review FE corrections — context-level non-EVM merge, react…
Hugo0 Jul 13, 2026
0ed73e9
refactor: CHAIN_REGISTRY — one source of truth for every FE chain fact
Hugo0 Jul 13, 2026
a973865
feat: enable Base withdrawals (verified) + fully consolidate derivati…
Hugo0 Jul 13, 2026
5c7ec1d
fix: withdraw receipts always link the source-chain explorer (CodeRab…
Hugo0 Jul 13, 2026
89e0621
test: leak tripwire — non-EVM synthetic records stay out of non-withd…
Hugo0 Jul 13, 2026
bd5af5f
Merge pull request #2401 from peanutprotocol/refactor/chain-registry
Hugo0 Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 5 additions & 24 deletions src/app/(mobile-ui)/card-recovery/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
'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'
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'
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 5 additions & 4 deletions src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
default: (props: any) => {
// next/image uses 'fill' boolean; strip non-DOM props
const { priority, layout, objectFit, fill, ...rest } = props
return <img {...rest} />

Check warning on line 52 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 52 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
},
}))

Expand Down Expand Up @@ -506,7 +506,7 @@

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

@coderabbitai coderabbitai Bot Jul 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Fix the test setup’s lint errors.

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

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

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

Also applies to: 521-522, 533-533

🧰 Tools
🪛 ESLint

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

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

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

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

Source: Linters/SAST tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

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

Length of output: 3646


🏁 Script executed:

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

Length of output: 690


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

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

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

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


🧬 Code Graph Analysis Results

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

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

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

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

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

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

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

const [loadingState, setLoadingState] = React.useState('Idle')
const isLoading = loadingState !== 'Idle'
return (
Expand All @@ -516,9 +516,10 @@
)
}

// 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) => {},
Expand All @@ -529,7 +530,7 @@
function renderQrPay(params: Record<string, string> = {}) {
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<string>('Idle')
Expand Down
14 changes: 4 additions & 10 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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 { useRainCardOverview } from '@/hooks/useRainCardOverview'
import {
Expand All @@ -28,14 +28,9 @@
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'
Expand All @@ -44,11 +39,10 @@
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'
Expand Down Expand Up @@ -256,7 +250,7 @@
if (sumsubFlow.showWrapper || sumsubFlow.isModalOpen) {
sumsubFlow.completeFlow()
}
}, [kycGateState, sumsubFlow.showWrapper, sumsubFlow.isModalOpen, sumsubFlow.completeFlow])

Check warning on line 253 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

Check warning on line 253 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

const queryClient = useQueryClient()
const [isShaking, setIsShaking] = useState(false)
Expand Down Expand Up @@ -367,7 +361,7 @@
if (isSuccess || !!errorMessage) {
setLoadingState('Idle')
}
}, [isSuccess, errorMessage])

Check warning on line 364 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

Check warning on line 364 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

// First fetch for qrcode info — only after KYC gating allows proceeding
useEffect(() => {
Expand All @@ -386,7 +380,7 @@
}

setIsFirstLoad(false)
}, [timestamp, paymentProcessor, qrCode])

Check warning on line 383 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

Check warning on line 383 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

// Get amount from payment lock (Manteca)
useEffect(() => {
Expand All @@ -401,7 +395,7 @@
setAmount(paymentLock.paymentAgainstAmount)
setCurrencyAmount(paymentLock.paymentAssetAmount)
}
}, [paymentLock?.code, paymentProcessor])

Check warning on line 398 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 398 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Get currency object from payment lock (Manteca)
useEffect(() => {
Expand All @@ -423,7 +417,7 @@
}
}
getCurrencyObject().then(setCurrency)
}, [paymentLock?.code, paymentProcessor])

Check warning on line 420 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 420 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

const isBlockingError = useMemo(() => {
// The settling failure says "try again in a few seconds" — keep the Pay
Expand All @@ -445,7 +439,7 @@
// For dynamic QR codes, backend provides the USD amount
return paymentLock.paymentAgainstAmount
}
}, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])

Check warning on line 442 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 442 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Live card-vs-local-rail markup, driven by Manteca's rate + (for ARS)
// BCRA's official rate. Used by both the confirm-screen "Save vs card"
Expand Down
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/app/actions/supported-chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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<Record<string, ChainWithTokens>> {
Expand Down
33 changes: 0 additions & 33 deletions src/app/api/send-discord-notification/route.ts

This file was deleted.

13 changes: 8 additions & 5 deletions src/components/AddMoney/components/ChooseNetworkDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 (
<Drawer open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DrawerContent className="pt-4">
Expand All @@ -27,7 +32,7 @@ const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerPro
<div className="overflow-hidden rounded-t-sm border border-black bg-white ">
<ActionListCard
title="EVM"
description={`${SUPPORTED_EVM_CHAINS.length} Networks - 1 Address`}
description={`${evmChainCount} Networks - 1 Address`}
position="single"
className="border-0"
leftIcon={
Expand All @@ -44,9 +49,7 @@ const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerPro
{/* expanded chain list */}
<div onClick={() => onSelect('EVM')} className="mx-4 border-t border-dashed border-black py-3">
<div className="flex flex-wrap gap-2">
{SUPPORTED_EVM_CHAINS.map((chain) => (
<ChainChip key={chain} chainName={chain} chainSymbol={CHAIN_LOGOS[chain]} />
))}
<EvmChainChips />
</div>
</div>
</div>
Expand Down
24 changes: 24 additions & 0 deletions src/components/AddMoney/components/EvmChainChips.tsx
Original file line number Diff line number Diff line change
@@ -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 <ChainChip key={chain} chainName={label} chainSymbol={CHAIN_LOGOS[chain]} />
})}
</>
)
}

export default EvmChainChips
7 changes: 2 additions & 5 deletions src/components/AddMoney/components/SupportedNetworksModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,9 +24,7 @@ const SupportedNetworksModal = ({ visible, onClose }: SupportedNetworksModalProp
</p>

<div className="flex flex-wrap gap-2">
{SUPPORTED_EVM_CHAINS.map((chain) => (
<ChainChip key={chain} chainName={chain} chainSymbol={CHAIN_LOGOS[chain]} />
))}
<EvmChainChips />
</div>

<InfoCard
Expand Down
6 changes: 6 additions & 0 deletions src/components/AddMoney/views/CryptoDeposit.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,12 @@ const CryptoDepositView = ({
{depositAddressData.maxDepositLimitUsd.toLocaleString()} USD
</p>
</div>
{!isOfframp && (
<p className="pt-1 text-sm text-grey-1">
A small bridging fee (~0.1%) applies — you&apos;ll receive slightly less than you
send.
</p>
)}
{isOfframp && (
<p className="pt-1 text-sm text-grey-1">
Moving more than the max? Send it in multiple transfers.
Expand Down
2 changes: 1 addition & 1 deletion src/components/Card/CancelCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/components/Card/LockCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
21 changes: 20 additions & 1 deletion src/components/Global/GeneralRecipientInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -35,6 +40,7 @@ const GeneralRecipientInput = ({
infoText,
showInfoText = true,
isWithdrawal = false,
addressFamily = 'evm',
}: GeneralRecipientInputProps) => {
const recipientType = useRef<RecipientType>('address')
const errorMessage = useRef('')
Expand All @@ -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)
Expand Down Expand Up @@ -82,7 +101,7 @@ const GeneralRecipientInput = ({
return false
}
},
[isWithdrawal]
[isWithdrawal, addressFamily]
)

const onInputUpdate = useCallback(
Expand Down
23 changes: 13 additions & 10 deletions src/components/Global/TokenSelector/TokenSelector.consts.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand Down Expand Up @@ -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
Expand All @@ -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<string, readonly string[]> = {
'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<string, readonly string[]> = Object.fromEntries(
CHAIN_REGISTRY.filter((c) => c.withdraw).map((c) => [c.id, c.withdraw!.tokens])
)
Loading
Loading