From 82cffccc5a51c1116e22e13121df5644415b0404 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 9 Jul 2026 20:10:33 -0700 Subject: [PATCH 01/19] fix: migrate root validator before mixed spends sign the Rain admin sig Mixed spends from unmigrated pre-2025-09-18 accounts always reverted with 'Delegatecall failed': the SDK wraps their userOp in migrateWithCall, which swaps the root validator v0.0.2->v0.0.3 BEFORE withdrawAsset verifies the pre-signed (v0.0.2-routed) admin EIP-712 signature via ERC-1271. Proven by on-chain simulation at the failing block: migration alone succeeds, the withdrawal alone succeeds, combined they revert. 8 users / ~$1k currently blocked, and since their wallets are empty they can never organically migrate. Fix: before a mixed spend on an unmigrated account, fire the migration as a standalone no-op userOp, then rebuild the kernel client so the admin sig is signed AND verified under v0.0.3. The client cache is now ref-backed so the rebuilt client reaches closures captured before the rebuild (grant flow), and the admin EIP-712 payload is built in exactly one place for both spend paths. --- src/constants/analytics.consts.ts | 4 + src/context/kernelClient.context.tsx | 79 +++++++++--- src/hooks/wallet/useSpendBundle.ts | 83 ++++++------- .../__tests__/kernelMigration.utils.test.ts | 115 ++++++++++++++++++ .../__tests__/rainWithdraw.utils.test.ts | 45 +++++++ src/utils/kernelMigration.utils.ts | 91 ++++++++++++++ src/utils/rainWithdraw.utils.ts | 43 +++++++ 7 files changed, 401 insertions(+), 59 deletions(-) create mode 100644 src/utils/__tests__/kernelMigration.utils.test.ts create mode 100644 src/utils/__tests__/rainWithdraw.utils.test.ts create mode 100644 src/utils/kernelMigration.utils.ts create mode 100644 src/utils/rainWithdraw.utils.ts diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 8445239956..130b02f305 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -227,6 +227,10 @@ export const ANALYTICS_EVENTS = { CARD_PHYSICAL_WAITLIST_JOINED: 'card_physical_waitlist_joined', CARD_ADD_TO_WALLET_VIEWED: 'card_add_to_wallet_viewed', // Spend routing across collateral / smart / mixed buckets. `strategy` is SpendStrategy. + // Root-validator migration userOp fired ahead of a mixed spend (pre-2025-09-18 + // accounts still on the unpatched validator) — see kernelMigration.utils.ts. + KERNEL_MIGRATION_ATTEMPTED: 'kernel_migration_attempted', + KERNEL_MIGRATION_SUCCEEDED: 'kernel_migration_succeeded', CARD_WITHDRAW_ATTEMPTED: 'card_withdraw_attempted', CARD_WITHDRAW_SUCCEEDED: 'card_withdraw_succeeded', CARD_WITHDRAW_FAILED: 'card_withdraw_failed', diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index 42fda06ab6..7636f69f04 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -45,6 +45,14 @@ interface KernelClientContextType { // `resolvePatchedSudoValidator` for why binding to the migration client's // stale v0.0.2 validator wapk-blocks the backend replay. getPatchedSudoValidator: (publicClient: PublicClient) => Promise>> + // Drops the cached client for `chainId` and builds a fresh one. Needed when + // the account's on-chain validator set changes mid-session (root-validator + // migration): the cached migration account keeps SIGNING via the v0.0.2 + // validator it was built with, so its EIP-1271 signatures are rejected once + // the on-chain root flips to v0.0.3. The rebuilt client lands in the + // ref-backed cache, so every consumer — including closures captured before + // the rebuild — sees it immediately. + rebuildClientForChain: (chainId: string) => Promise } type GenericSmartAccountClient = KernelAccountClient @@ -290,6 +298,22 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { // primary-init effect register itself so a recover-funds page mount that // races primary login doesn't kick off a duplicate Arb build. const inFlightRef = useRef>>(new Map()) + // Ref mirror of `clientsByChain`. Reads go through the ref so that closures + // captured before a cache update (e.g. a spend flow that rebuilds the client + // mid-flight after a root-validator migration) resolve to the CURRENT client + // instead of a stale one. State stays the source of re-renders; the ref is + // the source of truth for lookups. Always write both via storeClient/clearClients. + const clientsRef = useRef>({}) + + const storeClient = useCallback((chainId: string, client: GenericSmartAccountClient) => { + clientsRef.current = { ...clientsRef.current, [chainId]: client } + setClientsByChain((prev) => ({ ...prev, [chainId]: client })) + }, []) + + const clearClients = useCallback(() => { + clientsRef.current = {} + setClientsByChain({}) + }, []) const isAfterZeroDevMigration = useMemo(() => { if (!user?.user?.createdAt) { @@ -304,7 +328,7 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { // clear webauthn key and clients when user logs out console.log('[KernelClient] No user found, clearing webAuthnKey, clients, and address') setWebAuthnKey(undefined) - setClientsByChain({}) + clearClients() // Drop any in-flight lazy builds — their results would be useless // (and re-applying them would write into a fresh post-logout state). inFlightRef.current.clear() @@ -383,7 +407,8 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { console.error('[harness] primary ECDSA kernel client failed') return } - setClientsByChain(clients) + clientsRef.current = { ...clientsRef.current, ...clients } + setClientsByChain((prev) => ({ ...prev, ...clients })) dispatch(zerodevActions.setIsKernelClientReady(true)) dispatch(zerodevActions.setIsRegistering(false)) dispatch(zerodevActions.setIsLoggingIn(false)) @@ -460,7 +485,7 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { // Only update state after primary succeeds — avoids // registering→not→registering UI flicker between retries. if (isMounted) { - setClientsByChain((prev) => ({ ...prev, [primaryChainId]: kernelClient })) + storeClient(primaryChainId, kernelClient) fetchUser() dispatch(zerodevActions.setIsKernelClientReady(true)) dispatch(zerodevActions.setIsRegistering(false)) @@ -539,7 +564,9 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { const getClientForChain = useCallback( (chainId: string) => { - const client = clientsByChain[chainId] + // Read through the ref so closures captured before a mid-session + // rebuild (root-validator migration) still resolve the fresh client. + const client = clientsRef.current[chainId] ?? clientsByChain[chainId] if (!client) { const availableChains = Object.keys(clientsByChain).join(', ') console.error( @@ -554,14 +581,12 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { [clientsByChain, assertClientOwnedByUser] ) - const ensureClientForChain = useCallback( - async (chainId: string): Promise => { - const cached = clientsByChain[chainId] - if (cached) return assertClientOwnedByUser(cached) - - const inFlight = inFlightRef.current.get(chainId) - if (inFlight) return inFlight.then(assertClientOwnedByUser) - + // Kicks off a fresh client build for `chainId`, stores the result in the + // ref-backed cache, and registers itself in inFlightRef for dedupe. Shared + // by ensureClientForChain (cache-first) and rebuildClientForChain (cache- + // busting). + const startClientBuild = useCallback( + (chainId: string): Promise => { if (!webAuthnKey) { throw new Error(`Cannot build kernel client for chain ${chainId}: not authenticated`) } @@ -585,7 +610,7 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { { bundlerUrl: entry.bundlerUrl, paymasterUrl: entry.paymasterUrl } ) .then((kernelClient) => { - setClientsByChain((prev) => ({ ...prev, [chainId]: kernelClient })) + storeClient(chainId, kernelClient) return assertClientOwnedByUser(kernelClient) }) .catch((error) => { @@ -604,7 +629,32 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { inFlightRef.current.set(chainId, promise) return promise }, - [clientsByChain, webAuthnKey, isAfterZeroDevMigration, user, assertClientOwnedByUser, logoutUser] + [webAuthnKey, isAfterZeroDevMigration, user, assertClientOwnedByUser, logoutUser, storeClient] + ) + + const ensureClientForChain = useCallback( + async (chainId: string): Promise => { + const cached = clientsRef.current[chainId] ?? clientsByChain[chainId] + if (cached) return assertClientOwnedByUser(cached) + + const inFlight = inFlightRef.current.get(chainId) + if (inFlight) return inFlight.then(assertClientOwnedByUser) + + return startClientBuild(chainId) + }, + [clientsByChain, assertClientOwnedByUser, startClientBuild] + ) + + const rebuildClientForChain = useCallback( + async (chainId: string): Promise => { + // Deliberately ignores cache AND any in-flight build: those were + // constructed against the pre-migration on-chain state. storeClient + // in startClientBuild overwrites the stale cache entry for every + // future getClientForChain reader (including stale closures — they + // read through clientsRef). + return startClientBuild(chainId) + }, + [startClientBuild] ) return ( @@ -614,6 +664,7 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { getClientForChain, ensureClientForChain, getPatchedSudoValidator, + rebuildClientForChain, }} > {children} diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 998ab179ce..6920453e30 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -10,12 +10,9 @@ import { useKernelClient } from '@/context/kernelClient.context' import { useAuth } from '@/context/authContext' import { AccountType } from '@/interfaces' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' -import { - RAIN_WITHDRAW_EIP712_DOMAIN_NAME, - RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, - rainCoordinatorAbi, - rainWithdrawEip712Types, -} from '@/constants/rain.consts' +import { rainCoordinatorAbi } from '@/constants/rain.consts' +import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' +import { ensureRootValidatorMigrated } from '@/utils/kernelMigration.utils' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' import { findActiveCard } from '@/components/Card/cardState.utils' @@ -153,7 +150,7 @@ export function computeSpendStrategy(input: { * See plan file for the full rationale. */ export const useSpendBundle = () => { - const { getClientForChain } = useKernelClient() + const { getClientForChain, rebuildClientForChain } = useKernelClient() const { handleSendUserOpEncoded } = useZeroDev() const { user } = useAuth() const { overview } = useRainCardOverview() @@ -214,7 +211,33 @@ export const useSpendBundle = () => { posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_ATTEMPTED, { strategy, kind }) try { - // Pre-flight: any strategy that touches Rain collateral requires + // Pre-flight #1 — root-validator migration (pre-2025-09-18 accounts + // only). The mixed path pre-signs the Rain admin EIP-712 sig, but + // an unmigrated account's userOp is auto-wrapped in a migration + // that swaps the root validator BEFORE `withdrawAsset` verifies + // that sig via ERC-1271 — so the op always reverts ("Delegatecall + // failed"). Migrate first as a standalone userOp, then rebuild the + // client so the sig is signed AND verified under v0.0.3. + // collateral-only is exempt on purpose: it broadcasts server-side + // with the sig checked against the CURRENT (pre-migration) state, + // which is valid — no reason to add a passkey tap there. + let activeClient = kernelClient + if (strategy === 'mixed') { + activeClient = await ensureRootValidatorMigrated({ + client: kernelClient, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), + rebuildClient: () => rebuildClientForChain(chainIdStr), + onEvent: (event) => + posthog.capture( + event === 'attempted' + ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED + : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, + { trigger: 'mixed-spend', kind } + ), + }) + } + + // Pre-flight #2: any strategy that touches Rain collateral requires // the one-time session-key grant. If missing, run the inline grant // flow now (one extra passkey tap the FIRST time, zero after). const touchesCollateral = strategy === 'collateral-only' || strategy === 'mixed' @@ -241,24 +264,9 @@ export const useSpendBundle = () => { chargeId, }) - 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 activeClient.account!.signTypedData( + buildRainWithdrawTypedData(prep, chainIdNum) + )) as Hex const { txHash } = await rainApi.submitWithdrawal({ preparationId: prep.preparationId, @@ -324,24 +332,9 @@ export const useSpendBundle = () => { totalAmountCents: usdcUnitsToRainCents(requiredUsdcAmount).toString(), }) - 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 activeClient.account!.signTypedData( + buildRainWithdrawTypedData(prep, chainIdNum) + )) as Hex // Mixed = two passkey taps. The admin EIP-712 sig (tap #1) just // resolved; the kernel now prepares the follow-up UserOp which @@ -424,7 +417,7 @@ export const useSpendBundle = () => { throw e } }, - [getClientForChain, handleSendUserOpEncoded, user, overview, grant, modals, queryClient] + [getClientForChain, rebuildClientForChain, handleSendUserOpEncoded, user, overview, grant, modals, queryClient] ) return { spend } diff --git a/src/utils/__tests__/kernelMigration.utils.test.ts b/src/utils/__tests__/kernelMigration.utils.test.ts new file mode 100644 index 0000000000..33477a31dc --- /dev/null +++ b/src/utils/__tests__/kernelMigration.utils.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for the pre-spend root-validator migration gate. + * + * Regression context: mixed spends from unmigrated pre-2025-09-18 accounts + * deterministically reverted with "Delegatecall failed" because the migration + * wrapped into the SAME userOp invalidated the pre-signed Rain admin EIP-712 + * signature (routed to the old validator) before `withdrawAsset` verified it + * via ERC-1271. The gate must: migrate first as its own userOp, then hand the + * caller a REBUILT client (the old one keeps signing via the old validator). + */ +import type { TransactionReceipt } from 'viem' +import { + buildMigrationNoopCall, + ensureRootValidatorMigrated, + isMigrationWrapperAccount, + KernelMigrationPendingError, +} from '../kernelMigration.utils' +import { PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' + +const ACCOUNT_ADDRESS = '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483' as const + +const successReceipt = { status: 'success' } as TransactionReceipt +const revertedReceipt = { status: 'reverted' } as TransactionReceipt + +type FakeClient = { account?: unknown; rebuilt?: boolean } + +const makeDeps = (opts: { account: unknown; receipt?: TransactionReceipt | null }) => { + const rebuiltClient: FakeClient = { account: { address: ACCOUNT_ADDRESS }, rebuilt: true } + const sendNoopUserOp = jest.fn(async () => ({ + receipt: opts.receipt === undefined ? successReceipt : opts.receipt, + })) + const rebuildClient = jest.fn(async () => rebuiltClient) + const events: string[] = [] + return { + deps: { + client: { account: opts.account } as FakeClient, + sendNoopUserOp, + rebuildClient, + onEvent: (e: 'attempted' | 'succeeded') => events.push(e), + }, + sendNoopUserOp, + rebuildClient, + rebuiltClient, + events, + } +} + +const wrapperAccount = (migrated: boolean) => ({ + address: ACCOUNT_ADDRESS, + getRootValidatorMigrationStatus: jest.fn(async () => migrated), +}) + +describe('isMigrationWrapperAccount', () => { + it('detects wrapper accounts by the SDK-added status method', () => { + expect(isMigrationWrapperAccount(wrapperAccount(false))).toBe(true) + expect(isMigrationWrapperAccount({ address: ACCOUNT_ADDRESS })).toBe(false) + expect(isMigrationWrapperAccount(undefined)).toBe(false) + }) +}) + +describe('buildMigrationNoopCall', () => { + it('is a zero-value USDC self-transfer (proven-harmless migration payload)', () => { + const call = buildMigrationNoopCall(ACCOUNT_ADDRESS) + expect(call.to.toLowerCase()).toBe(PEANUT_WALLET_TOKEN.toLowerCase()) + expect(call.value).toBe(0n) + // transfer(address,uint256) selector + recipient + amount 0 + expect(call.data.startsWith('0xa9059cbb')).toBe(true) + expect(call.data.toLowerCase()).toContain(ACCOUNT_ADDRESS.slice(2).toLowerCase()) + expect(call.data.endsWith('0'.repeat(64))).toBe(true) + }) +}) + +describe('ensureRootValidatorMigrated', () => { + it('returns the client untouched for plain (already-patched) accounts', async () => { + const { deps, sendNoopUserOp, rebuildClient } = makeDeps({ account: { address: ACCOUNT_ADDRESS } }) + const result = await ensureRootValidatorMigrated(deps) + expect(result).toBe(deps.client) + expect(sendNoopUserOp).not.toHaveBeenCalled() + expect(rebuildClient).not.toHaveBeenCalled() + }) + + it('rebuilds WITHOUT migrating when the wrapper account is already migrated on-chain', async () => { + // A wrapper that migrated mid-session still SIGNS via the old validator — + // returning it unrebuilt would produce rejected EIP-1271 signatures. + const { deps, sendNoopUserOp, rebuildClient, rebuiltClient } = makeDeps({ account: wrapperAccount(true) }) + const result = await ensureRootValidatorMigrated(deps) + expect(result).toBe(rebuiltClient) + expect(sendNoopUserOp).not.toHaveBeenCalled() + expect(rebuildClient).toHaveBeenCalledTimes(1) + }) + + it('migrates via the no-op userOp, then rebuilds, for unmigrated accounts', async () => { + const { deps, sendNoopUserOp, rebuildClient, rebuiltClient, events } = makeDeps({ + account: wrapperAccount(false), + }) + const result = await ensureRootValidatorMigrated(deps) + expect(sendNoopUserOp).toHaveBeenCalledWith(buildMigrationNoopCall(ACCOUNT_ADDRESS)) + expect(rebuildClient).toHaveBeenCalledTimes(1) + expect(result).toBe(rebuiltClient) + expect(events).toEqual(['attempted', 'succeeded']) + }) + + it('throws (and does NOT rebuild) when the migration receipt never confirms', async () => { + const { deps, rebuildClient, events } = makeDeps({ account: wrapperAccount(false), receipt: null }) + await expect(ensureRootValidatorMigrated(deps)).rejects.toThrow(KernelMigrationPendingError) + expect(rebuildClient).not.toHaveBeenCalled() + expect(events).toEqual(['attempted']) + }) + + it('throws when the migration userOp reverts on-chain', async () => { + const { deps, rebuildClient } = makeDeps({ account: wrapperAccount(false), receipt: revertedReceipt }) + await expect(ensureRootValidatorMigrated(deps)).rejects.toThrow(KernelMigrationPendingError) + expect(rebuildClient).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/__tests__/rainWithdraw.utils.test.ts b/src/utils/__tests__/rainWithdraw.utils.test.ts new file mode 100644 index 0000000000..a2dadec8a6 --- /dev/null +++ b/src/utils/__tests__/rainWithdraw.utils.test.ts @@ -0,0 +1,45 @@ +/** + * Guards the Rain withdraw admin EIP-712 payload against drift. The coordinator + * verifies EXACTLY this structure via ERC-1271 — any silent change to the + * domain or message shape bricks every collateral withdrawal. + */ +import { buildRainWithdrawTypedData } from '../rainWithdraw.utils' +import { + RAIN_WITHDRAW_EIP712_DOMAIN_NAME, + RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, + rainWithdrawEip712Types, +} from '@/constants/rain.consts' + +const PREP = { + collateralProxy: '0x4c0b6e210726550c1842c445bc2caf2708c74587', + adminSalt: '0x' + '11'.repeat(32), // synthetic 32-byte salt (public calldata in prod) + adminAddress: '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483', + tokenAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + amount: '30040000', + recipientAddress: '0xb45104ef75c214990b23dcf7354e5fcb8ec4342a', + adminNonce: '7', +} + +describe('buildRainWithdrawTypedData', () => { + it('builds the exact domain + message the coordinator verifies', () => { + const typed = buildRainWithdrawTypedData(PREP, 42161) + expect(typed).toEqual({ + domain: { + name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME, + version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, + chainId: 42161, + verifyingContract: PREP.collateralProxy, + salt: PREP.adminSalt, + }, + types: rainWithdrawEip712Types, + primaryType: 'Withdraw', + message: { + user: PREP.adminAddress, + asset: PREP.tokenAddress, + amount: 30040000n, + recipient: PREP.recipientAddress, + nonce: 7n, + }, + }) + }) +}) diff --git a/src/utils/kernelMigration.utils.ts b/src/utils/kernelMigration.utils.ts new file mode 100644 index 0000000000..ed9f57e609 --- /dev/null +++ b/src/utils/kernelMigration.utils.ts @@ -0,0 +1,91 @@ +import type { Address, Hex, TransactionReceipt } from 'viem' +import { encodeFunctionData, erc20Abi } from 'viem' +import { PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' + +/** + * Pre-2025-09-18 accounts run on the unpatched v0.0.2 passkey validator until + * their first root-validated userOp, which the SDK auto-wraps in a + * `migrateWithCall` that swaps the root validator to v0.0.3 BEFORE executing + * the wrapped calls. Any EIP-1271 signature produced before that userOp (the + * Rain admin EIP-712 in a `mixed` spend) is routed to v0.0.2 and becomes + * invalid the instant the migration inside the same userOp runs — the whole + * op reverts with "Delegatecall failed", deterministically. + * + * Fix: when the account is still unmigrated and the flow carries a pre-signed + * EIP-1271 signature, run the migration as its own no-op userOp first, then + * rebuild the kernel client so all subsequent signatures route to v0.0.3. + * See ops/notify/zerodev-v002-stuck-card-withdrawals-2026-07-06.md (mono). + */ + +/** Shape added by `createKernelMigrationAccount` — absent on plain accounts. */ +export interface MigrationCapableAccount { + address: Address + getRootValidatorMigrationStatus?: () => Promise +} + +export const isMigrationWrapperAccount = (account: unknown): account is Required => + typeof (account as MigrationCapableAccount)?.getRootValidatorMigrationStatus === 'function' + +/** + * The payload for the standalone migration userOp: a zero-value USDC + * self-transfer. The SDK wraps it in `migrateWithCall`; the migration is the + * point, the transfer is a proven-harmless no-op (verified by on-chain + * simulation of the affected account — see the RCA above). + */ +export const buildMigrationNoopCall = (accountAddress: Address): { to: Hex; value: bigint; data: Hex } => ({ + to: PEANUT_WALLET_TOKEN as Hex, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [accountAddress, 0n], + }), +}) + +export class KernelMigrationPendingError extends Error { + constructor() { + super('Account security upgrade did not confirm in time — please retry in a moment') + this.name = 'KernelMigrationPendingError' + } +} + +export interface EnsureMigratedDeps { + /** The client currently held by the caller (possibly a migration wrapper). */ + client: TClient + /** Sends the no-op userOp through the wrapper account (which adds the migration). */ + sendNoopUserOp: (call: ReturnType) => Promise<{ receipt: TransactionReceipt | null }> + /** Rebuilds the kernel client from scratch so signing routes to the new validator. */ + rebuildClient: () => Promise + /** Optional analytics hook, called with the step outcome. */ + onEvent?: (event: 'attempted' | 'succeeded') => void +} + +/** + * Guarantees the account behind `client` is on the patched root validator + * before the caller produces any EIP-1271 signature that will be verified + * inside its own (otherwise migration-wrapped) userOp. + * + * - Plain (already-patched / post-cutoff) account → returns `client` untouched. + * - Wrapper account, already migrated on-chain (e.g. migrated earlier this + * session) → rebuilds only: the wrapper still SIGNS via the old validator + * even after migration, so its signatures would be rejected. + * - Wrapper account, unmigrated → sends the migration userOp, waits for the + * receipt, then rebuilds. + */ +export async function ensureRootValidatorMigrated( + deps: EnsureMigratedDeps +): Promise { + const account = deps.client.account + if (!isMigrationWrapperAccount(account)) return deps.client + + const migrated = await account.getRootValidatorMigrationStatus() + if (!migrated) { + deps.onEvent?.('attempted') + const { receipt } = await deps.sendNoopUserOp(buildMigrationNoopCall(account.address)) + if (!receipt || receipt.status !== 'success') { + throw new KernelMigrationPendingError() + } + deps.onEvent?.('succeeded') + } + return deps.rebuildClient() +} diff --git a/src/utils/rainWithdraw.utils.ts b/src/utils/rainWithdraw.utils.ts new file mode 100644 index 0000000000..9aefe7bf3c --- /dev/null +++ b/src/utils/rainWithdraw.utils.ts @@ -0,0 +1,43 @@ +import type { Address, Hex } from 'viem' +import { + RAIN_WITHDRAW_EIP712_DOMAIN_NAME, + RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, + rainWithdrawEip712Types, +} from '@/constants/rain.consts' + +/** The fields of a `/rain/withdrawals/prepare` response the admin signature covers. */ +export interface RainWithdrawPrep { + collateralProxy: string + adminSalt: string + adminAddress: string + tokenAddress: string + amount: string + recipientAddress: string + adminNonce: string +} + +/** + * Single source of truth for the Rain withdraw admin EIP-712 payload. + * Both the collateral-only and mixed spend paths sign EXACTLY this object; + * any drift between what is signed and what the coordinator verifies via + * ERC-1271 bricks the withdrawal, so it is built in one place only. + */ +export const buildRainWithdrawTypedData = (prep: RainWithdrawPrep, chainId: number) => + ({ + domain: { + name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME, + version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, + chainId, + 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 const From d85278e39ac747a56622acbacab8d622135c2a10 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 9 Jul 2026 20:17:49 -0700 Subject: [PATCH 02/19] fix: same migration gate for the sign-only spend path + overlay the migration beat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSignSpendBundle (qr-pay, Manteca withdraw, card cancel/lock refunds) has the identical trap: it pre-signs the admin EIP-712 and a migration-wrapped userOp the backend submits later, so unmigrated accounts revert the same way. Gate it with the same ensureRootValidatorMigrated pre-flight, and swap its two verbatim EIP-712 blocks for the shared builder (4 copies -> 1 across both hooks). UX: the migration tap now runs under the existing security-verification overlay (the same intentional 'Verifying security…' beat the mixed flow already uses), gated on wrapper accounts only so the common path never flickers. --- src/hooks/wallet/useSignSpendBundle.ts | 106 ++++++++++++++----------- src/hooks/wallet/useSpendBundle.ts | 37 +++++---- 2 files changed, 84 insertions(+), 59 deletions(-) diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index 77cd1cf0db..ac2b3accec 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -8,12 +8,11 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useKernelClient } from '@/context/kernelClient.context' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' -import { - RAIN_WITHDRAW_EIP712_DOMAIN_NAME, - RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, - rainCoordinatorAbi, - rainWithdrawEip712Types, -} from '@/constants/rain.consts' +import { rainCoordinatorAbi } from '@/constants/rain.consts' +import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' +import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' +import { useZeroDev } from '@/hooks/useZeroDev' +import { useModalsContextOptional } from '@/context/ModalsContext' import { rainApi, type RainCollateralKind } from '@/services/rain' import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' @@ -100,7 +99,9 @@ export interface SignSpendBundleInput { */ export const useSignSpendBundle = () => { - const { getClientForChain } = useKernelClient() + const { getClientForChain, rebuildClientForChain } = useKernelClient() + const { handleSendUserOpEncoded } = useZeroDev() + const modals = useModalsContextOptional() const { signCallsUserOp } = useSignUserOp() const { overview } = useRainCardOverview() const { grant } = useGrantSessionKey() @@ -153,7 +154,43 @@ export const useSignSpendBundle = () => { onStrategyDecided?.(strategy) posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_ATTEMPTED, { strategy, kind, flow: 'sign-only' }) - // Pre-flight: any strategy that touches Rain collateral requires + // Pre-flight #1 — root-validator migration (pre-2025-09-18 accounts + // only). The mixed path pre-signs the admin EIP-712 AND a userOp the + // backend submits later; on an unmigrated account that userOp is + // migration-wrapped, and the migration swaps the root validator + // before `withdrawAsset` verifies the (old-validator-routed) admin + // sig via ERC-1271 — the op always reverts ("Delegatecall failed"). + // Migrate first as its own userOp, then re-resolve the account so + // every signature below routes to v0.0.3. Overlay = same intentional + // beat as useSpendBundle's mixed path. collateral-only is exempt: + // its session-key submission never re-wraps a migration around the + // admin sig. + let activeAccount = kernelAccount + if (strategy === 'mixed' && isMigrationWrapperAccount(kernelAccount)) { + modals?.setIsSecurityVerificationOpen?.(true) + try { + const activeClient = await ensureRootValidatorMigrated({ + client: kernelClient, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), + rebuildClient: () => rebuildClientForChain(chainIdStr), + onEvent: (event) => + posthog.capture( + event === 'attempted' + ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED + : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, + { trigger: 'sign-spend', kind } + ), + }) + if (!activeClient.account) { + throw new Error('useSignSpendBundle: rebuilt kernel account not initialized') + } + activeAccount = activeClient.account + } finally { + modals?.setIsSecurityVerificationOpen?.(false) + } + } + + // Pre-flight #2: any strategy that touches Rain collateral requires // the one-time session-key grant. If missing, run the inline grant // flow now. Reject when the overview hasn't loaded yet — we can't // tell whether the grant was already given, and signing @@ -198,24 +235,9 @@ export const useSignSpendBundle = () => { kind, }) - const adminSignature = (await kernelAccount.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 activeAccount.signTypedData( + buildRainWithdrawTypedData(prep, chainIdNum) + )) as Hex return { strategy, @@ -253,24 +275,9 @@ export const useSignSpendBundle = () => { totalAmountCents: usdcUnitsToRainCents(requiredUsdcAmount).toString(), }) - const adminSignature = (await kernelAccount.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 activeAccount.signTypedData( + buildRainWithdrawTypedData(prep, chainIdNum) + )) as Hex const withdrawCall = { to: prep.coordinatorAddress as Hex, @@ -306,7 +313,16 @@ export const useSignSpendBundle = () => { const signedUserOp = await signCallsUserOp([withdrawCall, transferCall], chainIdStr) return { strategy, signedUserOp, rainPreparationId: prep.preparationId } }, - [getClientForChain, signCallsUserOp, overview, grant, queryClient] + [ + getClientForChain, + rebuildClientForChain, + handleSendUserOpEncoded, + modals, + signCallsUserOp, + overview, + grant, + queryClient, + ] ) return { signSpend } diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 6920453e30..fb3336061e 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -12,7 +12,7 @@ import { AccountType } from '@/interfaces' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { rainCoordinatorAbi } from '@/constants/rain.consts' import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' -import { ensureRootValidatorMigrated } from '@/utils/kernelMigration.utils' +import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' import { findActiveCard } from '@/components/Card/cardState.utils' @@ -222,19 +222,28 @@ export const useSpendBundle = () => { // with the sig checked against the CURRENT (pre-migration) state, // which is valid — no reason to add a passkey tap there. let activeClient = kernelClient - if (strategy === 'mixed') { - activeClient = await ensureRootValidatorMigrated({ - client: kernelClient, - sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), - rebuildClient: () => rebuildClientForChain(chainIdStr), - onEvent: (event) => - posthog.capture( - event === 'attempted' - ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED - : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, - { trigger: 'mixed-spend', kind } - ), - }) + if (strategy === 'mixed' && isMigrationWrapperAccount(kernelClient.account)) { + // The migration adds one passkey tap + a few seconds of + // confirmation wait. Show the security-verification overlay + // for the whole beat (same pattern as the admin-sig → userOp + // gap below) so the extra prompt reads as intentional. + modals?.setIsSecurityVerificationOpen?.(true) + try { + activeClient = await ensureRootValidatorMigrated({ + client: kernelClient, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), + rebuildClient: () => rebuildClientForChain(chainIdStr), + onEvent: (event) => + posthog.capture( + event === 'attempted' + ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED + : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, + { trigger: 'mixed-spend', kind } + ), + }) + } finally { + modals?.setIsSecurityVerificationOpen?.(false) + } } // Pre-flight #2: any strategy that touches Rain collateral requires From bfe995e1527c45d96ac412174e00024636aafb91 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 9 Jul 2026 20:22:36 -0700 Subject: [PATCH 03/19] =?UTF-8?q?chore:=20clear=20lint=20deltas=20?= =?UTF-8?q?=E2=80=94=20barrel=20import,=20orphaned=20decimals=20import,=20?= =?UTF-8?q?clearClients=20effect=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base had 2 eslint errors in useSpendBundle (restricted @/interfaces barrel + unused PEANUT_WALLET_TOKEN_DECIMALS); both are one-liners inside this PR's diff, so boy-scout them rather than ship a red-on-arrival file. Also adds the new clearClients callback to its effect deps (stable identity, no behavior change) so this PR introduces zero new lint findings. --- src/context/kernelClient.context.tsx | 2 +- src/hooks/wallet/useSpendBundle.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index 7636f69f04..9789c80959 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -366,7 +366,7 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { // avoid mixed state logoutUser() } - }, [user?.user.userId, logoutUser]) + }, [user?.user.userId, logoutUser, clearClients]) useEffect(() => { if (user?.user.userId && !!webAuthnKey) { diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index fb3336061e..0e710f23c8 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -8,8 +8,8 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useKernelClient } from '@/context/kernelClient.context' import { useAuth } from '@/context/authContext' -import { AccountType } from '@/interfaces' -import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' +import { AccountType } from '@/interfaces/interfaces' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { rainCoordinatorAbi } from '@/constants/rain.consts' import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' From e07d73cec46cb7f777ec04f2f2aba23514ce3ae3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 9 Jul 2026 21:26:59 -0700 Subject: [PATCH 04/19] =?UTF-8?q?refactor:=20one=20shared=20spend=20prefli?= =?UTF-8?q?ght=20for=20both=20engines=20=E2=80=94=20drift=20here=20is=20ho?= =?UTF-8?q?w=20bugs=20ship=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration-ordering bug existed on BOTH spend engines (useSpendBundle and useSignSpendBundle) precisely because their preflight sequences were parallel copies — the original fix would have covered one surface and silently missed qr-pay, Manteca withdrawals, and card cancel/lock refunds. Extract the entire drift-prone sequence (live-balance routing -> insufficient rejection -> migration gate -> grant gate) into spendPreflight.ts so the next gate is physically impossible to add to only one engine. The engines keep only their legitimately-different execution branches (broadcast vs sign-and-return). Routing primitives + shared errors move with it; all importers updated, no re-export shims. 8 new tests lock the orchestration order and gating. --- src/app/(mobile-ui)/qr-pay/page.tsx | 2 +- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 2 +- src/components/Card/CancelCardModal.tsx | 2 +- src/components/Card/LockCardModal.tsx | 2 +- ...dBundle.test.ts => spendPreflight.test.ts} | 126 +++++++++- src/hooks/wallet/spendPreflight.ts | 229 ++++++++++++++++++ src/hooks/wallet/useSendMoney.ts | 8 +- src/hooks/wallet/useSignSpendBundle.ts | 114 +++------ src/hooks/wallet/useSpendBundle.ts | 164 +++---------- src/hooks/wallet/useWallet.ts | 3 +- 10 files changed, 425 insertions(+), 227 deletions(-) rename src/hooks/wallet/__tests__/{useSpendBundle.test.ts => spendPreflight.test.ts} (53%) create mode 100644 src/hooks/wallet/spendPreflight.ts diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 42ebe2d750..67298bbc25 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 { 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/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/hooks/wallet/__tests__/useSpendBundle.test.ts b/src/hooks/wallet/__tests__/spendPreflight.test.ts similarity index 53% rename from src/hooks/wallet/__tests__/useSpendBundle.test.ts rename to src/hooks/wallet/__tests__/spendPreflight.test.ts index bce0e4ab6b..72fa34d440 100644 --- a/src/hooks/wallet/__tests__/useSpendBundle.test.ts +++ b/src/hooks/wallet/__tests__/spendPreflight.test.ts @@ -1,7 +1,8 @@ /** - * Pure-function tests for the spend-routing primitives in `useSpendBundle`. + * Pure-function tests for the spend-routing primitives in `spendPreflight` + * (shared by useSpendBundle and useSignSpendBundle). * - * The hook itself orchestrates kernel clients, Rain API calls, and the + * The hooks orchestrate kernel clients, Rain API calls, and the * session-key grant flow — those paths are covered by integration + manual * testing on sandbox. These tests lock down the deterministic pieces: * - `computeSpendStrategy` routing (smart → collateral → mixed → insufficient) @@ -40,7 +41,7 @@ jest.mock('@/constants/rain.consts', () => ({ RAIN_WITHDRAW_EIP712_DOMAIN_VERSION: '2', })) -import { computeSpendStrategy, fetchLiveSmartUsdcBalance } from '../useSpendBundle' +import { computeSpendStrategy, fetchLiveSmartUsdcBalance } from '../spendPreflight' describe('computeSpendStrategy', () => { const amount = 1000n @@ -147,3 +148,122 @@ describe('fetchLiveSmartUsdcBalance', () => { ) }) }) + +// ── shared collateral pre-flight orchestration ────────────────────────────── +// The one ordered sequence both spend engines run before signing anything: +// migration gate → grant gate. Drift between the engines here is exactly how +// the migration-ordering bug shipped twice. + +import { runCollateralSpendPreflight, SessionKeyGrantRequiredError } from '../spendPreflight' + +const CARD_OVERVIEW = (hasWithdrawApproval: boolean) => + ({ cards: [{ status: 'ACTIVE', hasWithdrawApproval }] }) as never + +const preflightHarness = (opts: { account: unknown; overview?: unknown; grantOk?: boolean; migrated?: boolean }) => { + const rebuilt = { account: { address: '0xrebuilt' } } + const sendNoopUserOp = jest.fn(async () => ({ receipt: { status: 'success' } as never })) + const rebuildClient = jest.fn(async () => rebuilt) + const grant = jest.fn(async () => + opts.grantOk === false + ? { ok: false as const, error: { kind: 'user-cancelled' as const } } + : { ok: true as const } + ) + const overlayStates: boolean[] = [] + return { + args: { + kind: 'CRYPTO_WITHDRAW', + kernelClient: { account: opts.account }, + overview: (opts.overview ?? CARD_OVERVIEW(true)) as never, + requireOverview: false, + grant, + sendNoopUserOp, + rebuildClient, + setSecurityOverlay: (open: boolean) => overlayStates.push(open), + migrationTrigger: 'mixed-spend' as const, + }, + sendNoopUserOp, + rebuildClient, + rebuilt, + grant, + overlayStates, + } +} + +const unmigratedWrapper = () => ({ + address: '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483', + getRootValidatorMigrationStatus: jest.fn(async () => false), +}) + +describe('runCollateralSpendPreflight', () => { + it('smart-only: no migration, no grant, same client back', async () => { + const h = preflightHarness({ account: unmigratedWrapper(), overview: CARD_OVERVIEW(false) }) + const result = await runCollateralSpendPreflight({ ...h.args, strategy: 'smart-only' }) + expect(result).toBe(h.args.kernelClient) + expect(h.sendNoopUserOp).not.toHaveBeenCalled() + expect(h.grant).not.toHaveBeenCalled() + }) + + it('mixed + unmigrated wrapper: migrates under the overlay, returns rebuilt client, then grant-checks', async () => { + const h = preflightHarness({ account: unmigratedWrapper(), overview: CARD_OVERVIEW(false) }) + const result = await runCollateralSpendPreflight({ ...h.args, strategy: 'mixed' }) + expect(h.sendNoopUserOp).toHaveBeenCalledTimes(1) + expect(h.rebuildClient).toHaveBeenCalledTimes(1) + expect(result).toBe(h.rebuilt) + expect(h.overlayStates).toEqual([true, false]) // overlay opened then always closed + expect(h.grant).toHaveBeenCalledTimes(1) // approval missing → inline grant + }) + + it('mixed + plain (patched) account: zero migration behavior', async () => { + const h = preflightHarness({ account: { address: '0xplain' } }) + const result = await runCollateralSpendPreflight({ ...h.args, strategy: 'mixed' }) + expect(result).toBe(h.args.kernelClient) + expect(h.sendNoopUserOp).not.toHaveBeenCalled() + expect(h.rebuildClient).not.toHaveBeenCalled() + expect(h.overlayStates).toEqual([]) + }) + + it('collateral-only: never migrates (pre-migration state still verifies the sig)', async () => { + const h = preflightHarness({ account: unmigratedWrapper() }) + const result = await runCollateralSpendPreflight({ ...h.args, strategy: 'collateral-only' }) + expect(result).toBe(h.args.kernelClient) + expect(h.sendNoopUserOp).not.toHaveBeenCalled() + }) + + it('skips the grant when the approval already exists', async () => { + const h = preflightHarness({ account: { address: '0xplain' }, overview: CARD_OVERVIEW(true) }) + await runCollateralSpendPreflight({ ...h.args, strategy: 'collateral-only' }) + expect(h.grant).not.toHaveBeenCalled() + }) + + it('throws SessionKeyGrantRequiredError when the inline grant fails', async () => { + const h = preflightHarness({ account: { address: '0xplain' }, overview: CARD_OVERVIEW(false), grantOk: false }) + await expect(runCollateralSpendPreflight({ ...h.args, strategy: 'collateral-only' })).rejects.toThrow( + SessionKeyGrantRequiredError + ) + }) + + it('requireOverview: fails closed when the overview has not loaded (sign-only engine)', async () => { + const h = preflightHarness({ account: { address: '0xplain' }, overview: undefined }) + await expect( + runCollateralSpendPreflight({ + ...h.args, + overview: undefined as never, + requireOverview: true, + strategy: 'collateral-only', + }) + ).rejects.toThrow(SessionKeyGrantRequiredError) + expect(h.grant).not.toHaveBeenCalled() + }) + + it('broadcasting engine proceeds without overview (no card visible → nothing to grant)', async () => { + const h = preflightHarness({ account: { address: '0xplain' }, overview: undefined }) + const result = await runCollateralSpendPreflight({ + ...h.args, + overview: undefined as never, + requireOverview: false, + strategy: 'collateral-only', + }) + expect(result).toBe(h.args.kernelClient) + expect(h.grant).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/wallet/spendPreflight.ts b/src/hooks/wallet/spendPreflight.ts new file mode 100644 index 0000000000..94d09eeb7d --- /dev/null +++ b/src/hooks/wallet/spendPreflight.ts @@ -0,0 +1,229 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { Address, TransactionReceipt } from 'viem' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' +import { findActiveCard } from '@/components/Card/cardState.utils' +import type { GrantSessionKeyError } from './useGrantSessionKey' +import { smartUsdcBalanceQueryOptions } from './useBalance' +import { + buildMigrationNoopCall, + ensureRootValidatorMigrated, + isMigrationWrapperAccount, +} from '@/utils/kernelMigration.utils' + +/** + * Shared spend-preflight for BOTH spend engines — `useSpendBundle` (sign + + * broadcast) and `useSignSpendBundle` (sign only, backend broadcasts). + * + * Everything that must never drift between the two engines lives here, in one + * ordered sequence: live-balance routing → insufficient rejection → root- + * validator migration gate → session-key grant gate. The engines differ only + * in how they EXECUTE the chosen strategy; they must not differ in how they + * decide and prepare it. (This module exists because the migration-ordering + * bug had to be fixed twice — once per engine.) + */ + +export type SpendStrategy = 'collateral-only' | 'smart-only' | 'mixed' | 'insufficient' + +export class InsufficientSpendableError extends Error { + constructor() { + super('Insufficient spendable balance') + this.name = 'InsufficientSpendableError' + } +} + +/** + * Thrown when the user is about to spend from Rain collateral but hasn't + * granted the session-key permission yet, and the inline grant either failed + * or the user cancelled the passkey. Caller can surface a friendlier UI. + */ +export class SessionKeyGrantRequiredError extends Error { + constructor(public readonly cause: GrantSessionKeyError) { + super(`Session-key grant required: ${cause.kind}`) + this.name = 'SessionKeyGrantRequiredError' + } +} + +/** + * Live smart-account USDC balance for ROUTING, read through the SAME TanStack + * query that backs the displayed balance (`smartUsdcBalanceQueryOptions`) — one + * source of truth, one `readContract`. `staleTime: 0` forces a fresh on-chain + * read AND writes the result into `['balance', address]`, so the displayed + * balance refreshes in the same call. + * + * Routing MUST use this rather than the 30s-cached `useBalance` value: card + * funds are swept smart→collateral, so a stale (pre-sweep) balance routes + * `smart-only` to an account that's already empty and the transfer reverts + * on-chain ("ERC20: transfer amount exceeds balance" — incident #2230). + */ +export async function fetchLiveSmartUsdcBalance(queryClient: QueryClient, address: Address): Promise { + return queryClient.fetchQuery({ ...smartUsdcBalanceQueryOptions(address), staleTime: 0 }) +} + +/** + * Pure routing helper — decides which bucket(s) a spend will pull from. + * Priority: smart → collateral → mixed. The smart account is spent first + * whenever it can cover the whole amount, so a payment never touches the + * Rain collateral — and Rain's per-account withdrawal-signature cooldown — + * if the user's smart-account USDC already covers it. Collateral is the + * fallback (single recipient AND no subsequent kernel calls, since Rain's + * coordinator transfers tokens directly with nothing following), and `mixed` + * tops up the shortfall from collateral when smart alone can't cover it. + */ +export function computeSpendStrategy(input: { + smart: bigint + rain: bigint + amount: bigint + collateralOnlyAllowed: boolean +}): SpendStrategy { + if (input.smart >= input.amount) return 'smart-only' + if (input.collateralOnlyAllowed && input.rain >= input.amount) return 'collateral-only' + if (input.smart + input.rain >= input.amount) return 'mixed' + return 'insufficient' +} + +export interface ResolveSpendStrategyArgs { + queryClient: QueryClient + /** The exact account that will send the UserOp — routing reads ITS live balance. */ + accountAddress: Address + requiredUsdcAmount: bigint + rainSpendingPower: bigint + collateralOnlyAllowed: boolean + /** Analytics tag distinguishing the sign-only engine; omit for the broadcasting engine. */ + flow?: 'sign-only' +} + +/** + * Routes the spend on the LIVE on-chain balance and rejects unaffordable + * spends. On `insufficient` (passed the FE display gate but the live balance + * can't cover it yet — in-transit collateral not landed / ~30s-stale FE): + * captures the failure, refreshes the Rain overview so the displayed balance + * + a retry reflect reality, and throws `InsufficientSpendableError`. + */ +export async function resolveSpendStrategy( + args: ResolveSpendStrategyArgs +): Promise<{ strategy: Exclude; smartBalance: bigint }> { + const { queryClient, accountAddress, requiredUsdcAmount, rainSpendingPower, collateralOnlyAllowed, flow } = args + + const smartBalance = await fetchLiveSmartUsdcBalance(queryClient, accountAddress) + const strategy = computeSpendStrategy({ + smart: smartBalance, + rain: rainSpendingPower, + amount: requiredUsdcAmount, + collateralOnlyAllowed, + }) + if (strategy === 'insufficient') { + posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, { + strategy: 'insufficient', + error_kind: 'insufficient', + ...(flow ? { flow } : {}), + }) + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) + throw new InsufficientSpendableError() + } + return { strategy, smartBalance } +} + +export interface CollateralSpendPreflightArgs { + strategy: Exclude + kind: string + kernelClient: TClient + overview: Parameters[0] + /** Sign-only engine must fail closed when the overview hasn't loaded — it + * can't tell whether the grant was already given, and signing + * optimistically would crash on the backend submission. The broadcasting + * engine proceeds (no card visible → nothing to grant). */ + requireOverview: boolean + grant: () => Promise<{ ok: true } | { ok: false; error: GrantSessionKeyError }> + /** Fires right before the one-time session-key grant prompt appears. */ + onGrantRequired?: () => void + /** Sends the migration no-op userOp through the CURRENT (wrapper) client. */ + sendNoopUserOp: (call: ReturnType) => Promise<{ receipt: TransactionReceipt | null }> + /** Rebuilds the kernel client so post-migration signatures route to v0.0.3. */ + rebuildClient: () => Promise + /** Security-verification overlay toggle (optional — UI polish, not correctness). */ + setSecurityOverlay?: (open: boolean) => void + migrationTrigger: 'mixed-spend' | 'sign-spend' +} + +/** + * The collateral pre-flights, in the ONE correct order: + * + * 1. Root-validator migration (pre-2025-09-18 accounts only). The mixed path + * pre-signs the Rain admin EIP-712, but an unmigrated account's userOp is + * auto-wrapped in a migration that swaps the root validator BEFORE + * `withdrawAsset` verifies that sig via ERC-1271 — so the op always + * reverts ("Delegatecall failed"). Migrate first as a standalone userOp, + * then rebuild the client so the sig is signed AND verified under v0.0.3. + * `collateral-only` is exempt on purpose: its withdrawal is submitted + * against the CURRENT (pre-migration) on-chain state, where the old + * validator still verifies — no reason to add a passkey tap there. + * 2. One-time session-key grant. If missing, run the inline grant flow now + * (one extra passkey tap the FIRST time, zero after). + * + * Returns the client every subsequent signature MUST come from (rebuilt when + * a migration ran; the caller's original client otherwise). + */ +export async function runCollateralSpendPreflight( + args: CollateralSpendPreflightArgs +): Promise { + const { + strategy, + kind, + kernelClient, + overview, + requireOverview, + grant, + onGrantRequired, + sendNoopUserOp, + rebuildClient, + setSecurityOverlay, + migrationTrigger, + } = args + + let activeClient = kernelClient + if (strategy === 'mixed' && isMigrationWrapperAccount(kernelClient.account)) { + // The migration adds one passkey tap + a few seconds of confirmation + // wait. Show the security-verification overlay for the whole beat + // (same pattern as the mixed flow's admin-sig → userOp gap) so the + // extra prompt reads as intentional. Gated on wrapper accounts so the + // common path never flickers. + setSecurityOverlay?.(true) + try { + activeClient = await ensureRootValidatorMigrated({ + client: kernelClient, + sendNoopUserOp, + rebuildClient, + onEvent: (event) => + posthog.capture( + event === 'attempted' + ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED + : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, + { trigger: migrationTrigger, kind } + ), + }) + } finally { + setSecurityOverlay?.(false) + } + } + + const touchesCollateral = strategy === 'collateral-only' || strategy === 'mixed' + if (touchesCollateral) { + if (requireOverview && !overview) { + throw new SessionKeyGrantRequiredError({ kind: 'unexpected' } as GrantSessionKeyError) + } + const card = findActiveCard(overview) + if (card && !card.hasWithdrawApproval) { + onGrantRequired?.() + const grantResult = await grant() + if (!grantResult.ok) { + throw new SessionKeyGrantRequiredError(grantResult.error) + } + // `grant()` refetches the overview; by the time we continue the + // flag is flipped and the backend will accept the submit call. + } + } + + return activeClient +} diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index b5f82f3d4d..6800b334a9 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -8,12 +8,8 @@ import { useBalance } from './useBalance' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' import { rainCentsToUsdcUnits, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import type { RainCollateralKind } from '@/services/rain' -import { - InsufficientSpendableError, - SessionKeyGrantRequiredError, - type SpendStrategy, - useSpendBundle, -} from './useSpendBundle' +import { useSpendBundle } from './useSpendBundle' +import { InsufficientSpendableError, SessionKeyGrantRequiredError, type SpendStrategy } from './spendPreflight' type SendMoneyParams = { toAddress: Address diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index ac2b3accec..22e97934dc 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -10,21 +10,13 @@ import { useKernelClient } from '@/context/kernelClient.context' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { rainCoordinatorAbi } from '@/constants/rain.consts' import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' -import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' import { useZeroDev } from '@/hooks/useZeroDev' import { useModalsContextOptional } from '@/context/ModalsContext' import { rainApi, type RainCollateralKind } from '@/services/rain' -import { findActiveCard } from '@/components/Card/cardState.utils' -import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' -import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useGrantSessionKey } from './useGrantSessionKey' import { useSignUserOp, type SignedUserOpData } from './useSignUserOp' -import { - computeSpendStrategy, - fetchLiveSmartUsdcBalance, - InsufficientSpendableError, - SessionKeyGrantRequiredError, - type SpendStrategy, -} from './useSpendBundle' +import { resolveSpendStrategy, runCollateralSpendPreflight, type SpendStrategy } from './spendPreflight' import { usdcUnitsToRainCents } from '@/utils/balance.utils' /** @@ -128,86 +120,42 @@ export const useSignSpendBundle = () => { // send the UserOp — never a cached value (see fetchLiveSmartUsdcBalance). // A stale, pre-sweep balance routes `smart-only` to an empty account // and reverts on-chain (incident #2230). - const smartBalance = await fetchLiveSmartUsdcBalance(queryClient, kernelAccount.address) - // Manteca-style flows always have a single recipient and no // subsequent kernel calls — collateral-only is always eligible. - const strategy = computeSpendStrategy({ - smart: smartBalance, - rain: rainSpendingPower, - amount: requiredUsdcAmount, + const { strategy, smartBalance } = await resolveSpendStrategy({ + queryClient, + accountAddress: kernelAccount.address, + requiredUsdcAmount, + rainSpendingPower, collateralOnlyAllowed: true, + flow: 'sign-only', }) - if (strategy === 'insufficient') { - posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, { - strategy: 'insufficient', - error_kind: 'insufficient', - flow: 'sign-only', - }) - // Passed the FE display gate but the live balance can't cover it yet - // (in-transit collateral not landed / ~30s-stale FE). Refresh the Rain - // overview so the displayed balance + a retry reflect reality. - queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) - throw new InsufficientSpendableError() - } onStrategyDecided?.(strategy) posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_ATTEMPTED, { strategy, kind, flow: 'sign-only' }) - // Pre-flight #1 — root-validator migration (pre-2025-09-18 accounts - // only). The mixed path pre-signs the admin EIP-712 AND a userOp the - // backend submits later; on an unmigrated account that userOp is - // migration-wrapped, and the migration swaps the root validator - // before `withdrawAsset` verifies the (old-validator-routed) admin - // sig via ERC-1271 — the op always reverts ("Delegatecall failed"). - // Migrate first as its own userOp, then re-resolve the account so - // every signature below routes to v0.0.3. Overlay = same intentional - // beat as useSpendBundle's mixed path. collateral-only is exempt: - // its session-key submission never re-wraps a migration around the - // admin sig. - let activeAccount = kernelAccount - if (strategy === 'mixed' && isMigrationWrapperAccount(kernelAccount)) { - modals?.setIsSecurityVerificationOpen?.(true) - try { - const activeClient = await ensureRootValidatorMigrated({ - client: kernelClient, - sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), - rebuildClient: () => rebuildClientForChain(chainIdStr), - onEvent: (event) => - posthog.capture( - event === 'attempted' - ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED - : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, - { trigger: 'sign-spend', kind } - ), - }) - if (!activeClient.account) { - throw new Error('useSignSpendBundle: rebuilt kernel account not initialized') - } - activeAccount = activeClient.account - } finally { - modals?.setIsSecurityVerificationOpen?.(false) - } - } - - // Pre-flight #2: any strategy that touches Rain collateral requires - // the one-time session-key grant. If missing, run the inline grant - // flow now. Reject when the overview hasn't loaded yet — we can't - // tell whether the grant was already given, and signing - // optimistically would crash on the backend submission. - const touchesCollateral = strategy === 'collateral-only' || strategy === 'mixed' - if (touchesCollateral) { - if (!overview) { - throw new SessionKeyGrantRequiredError({ kind: 'unexpected' } as GrantSessionKeyError) - } - const card = findActiveCard(overview) - if (card && !card.hasWithdrawApproval) { - onGrantRequired?.() - const grantResult = await grant() - if (!grantResult.ok) { - throw new SessionKeyGrantRequiredError(grantResult.error as GrantSessionKeyError) - } - } + // Shared collateral pre-flights (root-validator migration gate + + // session-key grant) — ONE ordered sequence for both spend engines; + // see runCollateralSpendPreflight. Every signature below MUST come + // from the account it returns. requireOverview: this engine can't + // tell whether the grant exists while the overview is loading, and + // signing optimistically would crash on the backend submission. + const activeClient = await runCollateralSpendPreflight({ + strategy, + kind, + kernelClient, + overview, + requireOverview: true, + grant, + onGrantRequired, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), + rebuildClient: () => rebuildClientForChain(chainIdStr), + setSecurityOverlay: modals?.setIsSecurityVerificationOpen, + migrationTrigger: 'sign-spend', + }) + const activeAccount = activeClient.account + if (!activeAccount) { + throw new Error('useSignSpendBundle: kernel account not initialized after preflight') } // ─── smart-only ───────────────────────────────────────────────── diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 0e710f23c8..dcc839a9cd 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback } from 'react' -import { useQueryClient, type QueryClient } from '@tanstack/react-query' +import { useQueryClient } from '@tanstack/react-query' import type { Address, Hash, Hex, TransactionReceipt } from 'viem' import { encodeFunctionData, erc20Abi } from 'viem' import posthog from 'posthog-js' @@ -12,17 +12,18 @@ import { AccountType } from '@/interfaces/interfaces' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { rainCoordinatorAbi } from '@/constants/rain.consts' import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' -import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' -import { findActiveCard } from '@/components/Card/cardState.utils' -import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' -import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useGrantSessionKey } from './useGrantSessionKey' import { usdcUnitsToRainCents } from '@/utils/balance.utils' import { useModalsContextOptional } from '@/context/ModalsContext' -import { smartUsdcBalanceQueryOptions } from './useBalance' - -export type SpendStrategy = 'collateral-only' | 'smart-only' | 'mixed' | 'insufficient' +import { + resolveSpendStrategy, + runCollateralSpendPreflight, + SessionKeyGrantRequiredError, + type SpendStrategy, +} from './spendPreflight' type UserOpEncodedParams = { to: Hex; value: bigint; data: Hex } @@ -74,24 +75,9 @@ export interface SpendBundleResult { intentId?: string } -export class InsufficientSpendableError extends Error { - constructor() { - super('Insufficient spendable balance') - this.name = 'InsufficientSpendableError' - } -} - -/** - * Thrown when the user is about to spend from Rain collateral but hasn't - * granted the session-key permission yet, and the inline grant either failed - * or the user cancelled the passkey. Caller can surface a friendlier UI. - */ -export class SessionKeyGrantRequiredError extends Error { - constructor(public readonly cause: GrantSessionKeyError) { - super(`Session-key grant required: ${cause.kind}`) - this.name = 'SessionKeyGrantRequiredError' - } -} +// Routing primitives + shared errors (InsufficientSpendableError, +// SessionKeyGrantRequiredError, computeSpendStrategy, fetchLiveSmartUsdcBalance) +// live in ./spendPreflight — shared verbatim with useSignSpendBundle. // `usdcUnitsToRainCents` lives in @/utils/balance.utils alongside its sibling // `rainCentsToUsdcUnits`. Rain's wire convention is asymmetric: cents (2dp) @@ -100,44 +86,6 @@ export class SessionKeyGrantRequiredError extends Error { // `usdcUnitsToRainCents` is for the input side only — never call it on amounts // returned from Rain. -/** - * Live smart-account USDC balance for ROUTING, read through the SAME TanStack - * query that backs the displayed balance (`smartUsdcBalanceQueryOptions`) — one - * source of truth, one `readContract`. `staleTime: 0` forces a fresh on-chain - * read AND writes the result into `['balance', address]`, so the displayed - * balance refreshes in the same call. - * - * Routing MUST use this rather than the 30s-cached `useBalance` value: card - * funds are swept smart→collateral, so a stale (pre-sweep) balance routes - * `smart-only` to an account that's already empty and the transfer reverts - * on-chain ("ERC20: transfer amount exceeds balance" — incident #2230). - */ -export async function fetchLiveSmartUsdcBalance(queryClient: QueryClient, address: Address): Promise { - return queryClient.fetchQuery({ ...smartUsdcBalanceQueryOptions(address), staleTime: 0 }) -} - -/** - * Pure routing helper — decides which bucket(s) a spend will pull from. - * Priority: smart → collateral → mixed. The smart account is spent first - * whenever it can cover the whole amount, so a payment never touches the - * Rain collateral — and Rain's per-account withdrawal-signature cooldown — - * if the user's smart-account USDC already covers it. Collateral is the - * fallback (single recipient AND no subsequent kernel calls, since Rain's - * coordinator transfers tokens directly with nothing following), and `mixed` - * tops up the shortfall from collateral when smart alone can't cover it. - */ -export function computeSpendStrategy(input: { - smart: bigint - rain: bigint - amount: bigint - collateralOnlyAllowed: boolean -}): SpendStrategy { - if (input.smart >= input.amount) return 'smart-only' - if (input.collateralOnlyAllowed && input.rain >= input.amount) return 'collateral-only' - if (input.smart + input.rain >= input.amount) return 'mixed' - return 'insufficient' -} - /** * Orchestrates a USDC outflow across the user's two buckets: * - collateral-only: backend broadcasts `coordinator.withdrawAsset(directTransfer=true)` @@ -187,79 +135,35 @@ export const useSpendBundle = () => { // getClientForChain also asserts the client belongs to the logged-in // user, so this is the authoritative sender + balance pair. const kernelClient = getClientForChain(chainIdStr) - const smartBalance = await fetchLiveSmartUsdcBalance(queryClient, kernelClient.account!.address) - - const strategy = computeSpendStrategy({ - smart: smartBalance, - rain: rainSpendingPower, - amount: requiredUsdcAmount, + const { strategy, smartBalance } = await resolveSpendStrategy({ + queryClient, + accountAddress: kernelClient.account!.address, + requiredUsdcAmount, + rainSpendingPower, collateralOnlyAllowed, }) - if (strategy === 'insufficient') { - posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, { - strategy: 'insufficient', - error_kind: 'insufficient', - }) - // Passed the FE display gate but the live balance can't cover it yet - // (in-transit collateral not landed / ~30s-stale FE). Refresh the Rain - // overview so the displayed balance + a retry reflect reality. - queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) - throw new InsufficientSpendableError() - } onStrategyDecided?.(strategy) posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_ATTEMPTED, { strategy, kind }) try { - // Pre-flight #1 — root-validator migration (pre-2025-09-18 accounts - // only). The mixed path pre-signs the Rain admin EIP-712 sig, but - // an unmigrated account's userOp is auto-wrapped in a migration - // that swaps the root validator BEFORE `withdrawAsset` verifies - // that sig via ERC-1271 — so the op always reverts ("Delegatecall - // failed"). Migrate first as a standalone userOp, then rebuild the - // client so the sig is signed AND verified under v0.0.3. - // collateral-only is exempt on purpose: it broadcasts server-side - // with the sig checked against the CURRENT (pre-migration) state, - // which is valid — no reason to add a passkey tap there. - let activeClient = kernelClient - if (strategy === 'mixed' && isMigrationWrapperAccount(kernelClient.account)) { - // The migration adds one passkey tap + a few seconds of - // confirmation wait. Show the security-verification overlay - // for the whole beat (same pattern as the admin-sig → userOp - // gap below) so the extra prompt reads as intentional. - modals?.setIsSecurityVerificationOpen?.(true) - try { - activeClient = await ensureRootValidatorMigrated({ - client: kernelClient, - sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), - rebuildClient: () => rebuildClientForChain(chainIdStr), - onEvent: (event) => - posthog.capture( - event === 'attempted' - ? ANALYTICS_EVENTS.KERNEL_MIGRATION_ATTEMPTED - : ANALYTICS_EVENTS.KERNEL_MIGRATION_SUCCEEDED, - { trigger: 'mixed-spend', kind } - ), - }) - } finally { - modals?.setIsSecurityVerificationOpen?.(false) - } - } - - // Pre-flight #2: any strategy that touches Rain collateral requires - // the one-time session-key grant. If missing, run the inline grant - // flow now (one extra passkey tap the FIRST time, zero after). - const touchesCollateral = strategy === 'collateral-only' || strategy === 'mixed' - const card = findActiveCard(overview) - if (touchesCollateral && card && !card.hasWithdrawApproval) { - onGrantRequired?.() - const grantResult = await grant() - if (!grantResult.ok) { - throw new SessionKeyGrantRequiredError(grantResult.error) - } - // `grant()` refetches the overview; by the time we continue the - // flag is flipped and the backend will accept the submit call. - } + // Shared collateral pre-flights (root-validator migration gate + + // session-key grant) — ONE ordered sequence for both spend + // engines; see runCollateralSpendPreflight. Every signature + // below MUST come from the client it returns. + const activeClient = await runCollateralSpendPreflight({ + strategy, + kind, + kernelClient, + overview, + requireOverview: false, + grant, + onGrantRequired, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), + rebuildClient: () => rebuildClientForChain(chainIdStr), + setSecurityOverlay: modals?.setIsSecurityVerificationOpen, + migrationTrigger: 'mixed-spend', + }) // ─── collateral-only ────────────────────────────────────────────── if (strategy === 'collateral-only') { diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 8dd9cb92df..f7de4d3c96 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -20,7 +20,8 @@ import { rainCentsToUsdcUnits, isAmountWithinBalance, } from '@/utils/balance.utils' -import { useSpendBundle, type SpendStrategy } from './useSpendBundle' +import { useSpendBundle } from './useSpendBundle' +import type { SpendStrategy } from './spendPreflight' import type { RainCollateralKind } from '@/services/rain' type SendTransactionsOptions = { From 798571211f420babca13e863e2f765b4a18cf1d0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 9 Jul 2026 21:39:15 -0700 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20harden=20the=20migration=20gate=20?= =?UTF-8?q?per=20adversarial=20review=20=E2=80=94=207=20verified=20finding?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The big one (CONFIRMED): ERC-4337 semantics — a REVERTED userOp still yields receipt.status='success' on the bundle tx, so the gate could declare a failed migration successful and walk straight back into the 'Delegatecall failed' revert. Migration is now verified against ground truth (the on-chain root validator, short poll for propagation), never the receipt; a null receipt (timeout) heals if the op actually landed. A reverted bundle throws a deterministic KernelMigrationFailedError (no 'please retry' framing), and the rebuilt client is asserted to be off the wrapper (lagging public RPC would otherwise hand back a v0.0.2-signing wrapper) with one grace retry. Cache races (CONFIRMED): builds now carry a per-chain monotonic sequence — a stale build that resolves after logout or after a rebuild superseded it can no longer clobber the cache (previous user's client post-logout / pre-migration wrapper post-rebuild), and .finally only clears the dedupe slot it owns. Also: fail-closed overview check hoisted ABOVE the migration tap (never charge a passkey tap for a doomed flow), loadingState reset on the receipt-timeout path, failure-capture parity for the sign-only engine (card_withdraw_failed now emitted), and the card-recovery page's fourth copy of the Rain EIP-712 payload migrated to the shared builder. Declined finding: 'permanent per-spend rebuild tax' — createKernelMigrationAccount returns a plain (non-wrapper) v0.0.3 account once the chain shows the account migrated, so the gate becomes a no-op after the first successful rebuild. --- src/app/(mobile-ui)/card-recovery/page.tsx | 27 +- src/context/kernelClient.context.tsx | 29 ++- src/hooks/useZeroDev.ts | 4 + .../wallet/__tests__/spendPreflight.test.ts | 12 +- src/hooks/wallet/spendPreflight.ts | 12 +- src/hooks/wallet/useSignSpendBundle.ts | 238 +++++++++--------- .../__tests__/kernelMigration.utils.test.ts | 61 ++++- src/utils/kernelMigration.utils.ts | 54 +++- 8 files changed, 278 insertions(+), 159 deletions(-) diff --git a/src/app/(mobile-ui)/card-recovery/page.tsx b/src/app/(mobile-ui)/card-recovery/page.tsx index a155c8fd61..943f03a01f 100644 --- a/src/app/(mobile-ui)/card-recovery/page.tsx +++ b/src/app/(mobile-ui)/card-recovery/page.tsx @@ -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/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index 9789c80959..85ad8dcc06 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -310,9 +310,22 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { setClientsByChain((prev) => ({ ...prev, [chainId]: client })) }, []) + // Monotonic build sequence per chain. A build may only store its result if + // it is still the LATEST build for that chain — otherwise a slow, stale + // build (started pre-logout, or superseded by rebuildClientForChain after + // a root-validator migration) would clobber the cache with a client built + // against old state: the previous user's account after logout, or a + // pre-migration wrapper that signs with the wrong validator. + const buildSeqRef = useRef(0) + const latestBuildSeqRef = useRef>(new Map()) + const clearClients = useCallback(() => { clientsRef.current = {} setClientsByChain({}) + // Orphan every in-flight build: none is "latest" anymore, so their + // .then(storeClient) becomes a no-op instead of resurrecting the + // logged-out user's client into the freshly cleared cache. + latestBuildSeqRef.current.clear() }, []) const isAfterZeroDevMigration = useMemo(() => { @@ -597,6 +610,8 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { ) } + const seq = ++buildSeqRef.current + latestBuildSeqRef.current.set(chainId, seq) const promise = createKernelClientForChain( entry.client, entry.chain, @@ -610,7 +625,12 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { { bundlerUrl: entry.bundlerUrl, paymasterUrl: entry.paymasterUrl } ) .then((kernelClient) => { - storeClient(chainId, kernelClient) + // Superseded (logout cleared the map, or a newer build / + // rebuild started): return the client to OUR caller but do + // not store it — the cache belongs to the latest build. + if (latestBuildSeqRef.current.get(chainId) === seq) { + storeClient(chainId, kernelClient) + } return assertClientOwnedByUser(kernelClient) }) .catch((error) => { @@ -623,7 +643,12 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { throw error }) .finally(() => { - inFlightRef.current.delete(chainId) + // Only clear the dedupe slot if it is still OURS — a newer + // build may have replaced it, and deleting that entry would + // silently break dedupe for its concurrent awaiters. + if (inFlightRef.current.get(chainId) === promise) { + inFlightRef.current.delete(chainId) + } }) inFlightRef.current.set(chainId, promise) diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 54ab3ca606..1789e834fd 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -231,6 +231,10 @@ export const useZeroDev = () => { } catch (error) { console.error('Error waiting for UserOp receipt:', error) captureException(error) + // Reset the loading banner too — callers that treat a null + // receipt as a failure (migration gate) would otherwise leave + // the UI stuck on 'Executing transaction'. + setLoadingState('Idle') dispatch(zerodevActions.setIsSendingUserOp(false)) return { userOpHash, diff --git a/src/hooks/wallet/__tests__/spendPreflight.test.ts b/src/hooks/wallet/__tests__/spendPreflight.test.ts index 72fa34d440..15076cd281 100644 --- a/src/hooks/wallet/__tests__/spendPreflight.test.ts +++ b/src/hooks/wallet/__tests__/spendPreflight.test.ts @@ -189,10 +189,14 @@ const preflightHarness = (opts: { account: unknown; overview?: unknown; grantOk? } } -const unmigratedWrapper = () => ({ - address: '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483', - getRootValidatorMigrationStatus: jest.fn(async () => false), -}) +const unmigratedWrapper = () => { + // flips to migrated once the (mocked) migration op lands — mirrors chain state + let calls = 0 + return { + address: '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483', + getRootValidatorMigrationStatus: jest.fn(async () => ++calls > 1), + } +} describe('runCollateralSpendPreflight', () => { it('smart-only: no migration, no grant, same client back', async () => { diff --git a/src/hooks/wallet/spendPreflight.ts b/src/hooks/wallet/spendPreflight.ts index 94d09eeb7d..6c60bae723 100644 --- a/src/hooks/wallet/spendPreflight.ts +++ b/src/hooks/wallet/spendPreflight.ts @@ -182,6 +182,14 @@ export async function runCollateralSpendPreflight { onStrategyDecided?.(strategy) posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_ATTEMPTED, { strategy, kind, flow: 'sign-only' }) - // Shared collateral pre-flights (root-validator migration gate + - // session-key grant) — ONE ordered sequence for both spend engines; - // see runCollateralSpendPreflight. Every signature below MUST come - // from the account it returns. requireOverview: this engine can't - // tell whether the grant exists while the overview is loading, and - // signing optimistically would crash on the backend submission. - const activeClient = await runCollateralSpendPreflight({ - strategy, - kind, - kernelClient, - overview, - requireOverview: true, - grant, - onGrantRequired, - sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), - rebuildClient: () => rebuildClientForChain(chainIdStr), - setSecurityOverlay: modals?.setIsSecurityVerificationOpen, - migrationTrigger: 'sign-spend', - }) - const activeAccount = activeClient.account - if (!activeAccount) { - throw new Error('useSignSpendBundle: kernel account not initialized after preflight') - } - - // ─── smart-only ───────────────────────────────────────────────── - if (strategy === 'smart-only') { - const transferData = encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [recipient, requiredUsdcAmount], + // Failure-capture parity with useSpendBundle's catch: without this, + // a failed migration/grant/signing in the sign-then-broadcast flow + // emits `attempted` with no terminal event and the funnel lies. + try { + // Shared collateral pre-flights (root-validator migration gate + + // session-key grant) — ONE ordered sequence for both spend engines; + // see runCollateralSpendPreflight. Every signature below MUST come + // from the account it returns. requireOverview: this engine can't + // tell whether the grant exists while the overview is loading, and + // signing optimistically would crash on the backend submission. + const activeClient = await runCollateralSpendPreflight({ + strategy, + kind, + kernelClient, + overview, + requireOverview: true, + grant, + onGrantRequired, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainIdStr), + rebuildClient: () => rebuildClientForChain(chainIdStr), + setSecurityOverlay: modals?.setIsSecurityVerificationOpen, + migrationTrigger: 'sign-spend', }) - const signedUserOp = await signCallsUserOp( - [{ to: PEANUT_WALLET_TOKEN as Hex, value: 0n, data: transferData }], - chainIdStr - ) - return { strategy, signedUserOp } - } + const activeAccount = activeClient.account + if (!activeAccount) { + throw new Error('useSignSpendBundle: kernel account not initialized after preflight') + } + + // ─── smart-only ───────────────────────────────────────────────── + if (strategy === 'smart-only') { + const transferData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, requiredUsdcAmount], + }) + const signedUserOp = await signCallsUserOp( + [{ to: PEANUT_WALLET_TOKEN as Hex, value: 0n, data: transferData }], + chainIdStr + ) + return { strategy, signedUserOp } + } + + // ─── collateral-only ──────────────────────────────────────────── + // Only sign the admin EIP-712 — backend submits the withdrawal via + // the user's session-key UserOp (1 tap total). + if (strategy === 'collateral-only') { + const prep = await rainApi.prepareWithdrawal({ + amount: usdcUnitsToRainCents(requiredUsdcAmount).toString(), + recipientAddress: recipient, + directTransfer: true, + kind, + }) + + const adminSignature = (await activeAccount.signTypedData( + buildRainWithdrawTypedData(prep, chainIdNum) + )) as Hex + + return { + strategy, + rainWithdrawal: { + preparationId: prep.preparationId, + amount: prep.amount, + recipientAddress: prep.recipientAddress as Address, + directTransfer: prep.directTransfer, + adminSalt: prep.adminSalt as Hex, + adminNonce: prep.adminNonce, + adminSignature, + executorSignature: prep.executorSignature, + executorSalt: prep.executorSalt, + expiresAt: prep.expiresAt, + }, + } + } - // ─── collateral-only ──────────────────────────────────────────── - // Only sign the admin EIP-712 — backend submits the withdrawal via - // the user's session-key UserOp (1 tap total). - if (strategy === 'collateral-only') { + // ─── mixed ────────────────────────────────────────────────────── + // Pull the shortfall from collateral into the smart account, then + // forward the full amount to the recipient — one atomic UserOp, + // signed without broadcasting. Two passkey taps (admin sig + UserOp). + // Use the kernel account's own address as the admin recipient (the + // address we sign FROM) instead of re-deriving from useAuth. + const adminAddress = kernelAccount.address as Address + + const shortfall = requiredUsdcAmount - smartBalance const prep = await rainApi.prepareWithdrawal({ - amount: usdcUnitsToRainCents(requiredUsdcAmount).toString(), - recipientAddress: recipient, - directTransfer: true, + amount: usdcUnitsToRainCents(shortfall).toString(), + // directTransfer=false sends tokens to the admin (kernel). Same + // semantics as broadcasting useSpendBundle.spend's mixed path. + recipientAddress: adminAddress, + directTransfer: false, kind, + totalAmountCents: usdcUnitsToRainCents(requiredUsdcAmount).toString(), }) const adminSignature = (await activeAccount.signTypedData( buildRainWithdrawTypedData(prep, chainIdNum) )) as Hex - return { - strategy, - rainWithdrawal: { - preparationId: prep.preparationId, - amount: prep.amount, - recipientAddress: prep.recipientAddress as Address, - directTransfer: prep.directTransfer, - adminSalt: prep.adminSalt as Hex, - adminNonce: prep.adminNonce, - adminSignature, - executorSignature: prep.executorSignature, - executorSalt: prep.executorSalt, - expiresAt: prep.expiresAt, - }, + const withdrawCall = { + to: prep.coordinatorAddress as Hex, + value: 0n, + data: encodeFunctionData({ + abi: rainCoordinatorAbi, + functionName: 'withdrawAsset', + args: [ + prep.collateralProxy as Address, + prep.tokenAddress as Address, + BigInt(prep.amount), + prep.recipientAddress as Address, + BigInt(prep.expiresAt), + prep.executorSalt as Hex, + prep.executorSignature as Hex, + [prep.adminSalt as Hex], + [adminSignature], + prep.directTransfer, + ], + }), } - } - - // ─── mixed ────────────────────────────────────────────────────── - // Pull the shortfall from collateral into the smart account, then - // forward the full amount to the recipient — one atomic UserOp, - // signed without broadcasting. Two passkey taps (admin sig + UserOp). - // Use the kernel account's own address as the admin recipient (the - // address we sign FROM) instead of re-deriving from useAuth. - const adminAddress = kernelAccount.address as Address - - const shortfall = requiredUsdcAmount - smartBalance - const prep = await rainApi.prepareWithdrawal({ - amount: usdcUnitsToRainCents(shortfall).toString(), - // directTransfer=false sends tokens to the admin (kernel). Same - // semantics as broadcasting useSpendBundle.spend's mixed path. - recipientAddress: adminAddress, - directTransfer: false, - kind, - totalAmountCents: usdcUnitsToRainCents(requiredUsdcAmount).toString(), - }) - - const adminSignature = (await activeAccount.signTypedData( - buildRainWithdrawTypedData(prep, chainIdNum) - )) as Hex - const withdrawCall = { - to: prep.coordinatorAddress as Hex, - value: 0n, - data: encodeFunctionData({ - abi: rainCoordinatorAbi, - functionName: 'withdrawAsset', - args: [ - prep.collateralProxy as Address, - prep.tokenAddress as Address, - BigInt(prep.amount), - prep.recipientAddress as Address, - BigInt(prep.expiresAt), - prep.executorSalt as Hex, - prep.executorSignature as Hex, - [prep.adminSalt as Hex], - [adminSignature], - prep.directTransfer, - ], - }), - } + const transferCall = { + to: PEANUT_WALLET_TOKEN as Hex, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, requiredUsdcAmount], + }), + } - const transferCall = { - to: PEANUT_WALLET_TOKEN as Hex, - value: 0n, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [recipient, requiredUsdcAmount], - }), + const signedUserOp = await signCallsUserOp([withdrawCall, transferCall], chainIdStr) + return { strategy, signedUserOp, rainPreparationId: prep.preparationId } + } catch (e) { + posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, { + strategy, + kind, + flow: 'sign-only', + error_kind: (e as Error)?.name ?? 'unknown', + error_message: (e as Error)?.message, + }) + throw e } - - const signedUserOp = await signCallsUserOp([withdrawCall, transferCall], chainIdStr) - return { strategy, signedUserOp, rainPreparationId: prep.preparationId } }, [ getClientForChain, diff --git a/src/utils/__tests__/kernelMigration.utils.test.ts b/src/utils/__tests__/kernelMigration.utils.test.ts index 33477a31dc..e90ed642ea 100644 --- a/src/utils/__tests__/kernelMigration.utils.test.ts +++ b/src/utils/__tests__/kernelMigration.utils.test.ts @@ -13,6 +13,7 @@ import { buildMigrationNoopCall, ensureRootValidatorMigrated, isMigrationWrapperAccount, + KernelMigrationFailedError, KernelMigrationPendingError, } from '../kernelMigration.utils' import { PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' @@ -24,8 +25,11 @@ const revertedReceipt = { status: 'reverted' } as TransactionReceipt type FakeClient = { account?: unknown; rebuilt?: boolean } -const makeDeps = (opts: { account: unknown; receipt?: TransactionReceipt | null }) => { - const rebuiltClient: FakeClient = { account: { address: ACCOUNT_ADDRESS }, rebuilt: true } +const makeDeps = (opts: { account: unknown; receipt?: TransactionReceipt | null; rebuiltAccount?: unknown }) => { + const rebuiltClient: FakeClient = { + account: opts.rebuiltAccount ?? { address: ACCOUNT_ADDRESS }, + rebuilt: true, + } const sendNoopUserOp = jest.fn(async () => ({ receipt: opts.receipt === undefined ? successReceipt : opts.receipt, })) @@ -37,6 +41,9 @@ const makeDeps = (opts: { account: unknown; receipt?: TransactionReceipt | null sendNoopUserOp, rebuildClient, onEvent: (e: 'attempted' | 'succeeded') => events.push(e), + // keep polling instant in tests + statusRetries: 3, + statusIntervalMs: 1, }, sendNoopUserOp, rebuildClient, @@ -45,9 +52,21 @@ const makeDeps = (opts: { account: unknown; receipt?: TransactionReceipt | null } } -const wrapperAccount = (migrated: boolean) => ({ +/** Wrapper whose on-chain root flips to migrated after the migration op lands + * (first status read gates, later reads verify). */ +const wrapperAccount = (migrated: boolean) => { + let calls = 0 + return { + address: ACCOUNT_ADDRESS, + getRootValidatorMigrationStatus: jest.fn(async () => (migrated ? true : ++calls > 1)), + } +} + +/** Wrapper whose migration NEVER lands on-chain (e.g. the userOp itself + * reverted even though the 4337 bundle receipt reported success). */ +const stuckWrapperAccount = () => ({ address: ACCOUNT_ADDRESS, - getRootValidatorMigrationStatus: jest.fn(async () => migrated), + getRootValidatorMigrationStatus: jest.fn(async () => false), }) describe('isMigrationWrapperAccount', () => { @@ -89,7 +108,7 @@ describe('ensureRootValidatorMigrated', () => { expect(rebuildClient).toHaveBeenCalledTimes(1) }) - it('migrates via the no-op userOp, then rebuilds, for unmigrated accounts', async () => { + it('migrates via the no-op userOp, verifies on-chain, then rebuilds', async () => { const { deps, sendNoopUserOp, rebuildClient, rebuiltClient, events } = makeDeps({ account: wrapperAccount(false), }) @@ -100,16 +119,40 @@ describe('ensureRootValidatorMigrated', () => { expect(events).toEqual(['attempted', 'succeeded']) }) - it('throws (and does NOT rebuild) when the migration receipt never confirms', async () => { - const { deps, rebuildClient, events } = makeDeps({ account: wrapperAccount(false), receipt: null }) + it('4337 trap: bundle receipt success but userOp reverted (root never flips) → pending, no rebuild', async () => { + // In ERC-4337 a reverted userOp still yields receipt.status === 'success' + // on the bundle tx — the on-chain root validator is the only truth. + const { deps, rebuildClient, events } = makeDeps({ account: stuckWrapperAccount(), receipt: successReceipt }) await expect(ensureRootValidatorMigrated(deps)).rejects.toThrow(KernelMigrationPendingError) expect(rebuildClient).not.toHaveBeenCalled() expect(events).toEqual(['attempted']) }) - it('throws when the migration userOp reverts on-chain', async () => { - const { deps, rebuildClient } = makeDeps({ account: wrapperAccount(false), receipt: revertedReceipt }) + it('null receipt (timeout) still succeeds when the on-chain root flipped', async () => { + const { deps, rebuildClient, rebuiltClient } = makeDeps({ account: wrapperAccount(false), receipt: null }) + const result = await ensureRootValidatorMigrated(deps) + expect(result).toBe(rebuiltClient) + expect(rebuildClient).toHaveBeenCalledTimes(1) + }) + + it('null receipt AND root never flips → pending', async () => { + const { deps, rebuildClient } = makeDeps({ account: stuckWrapperAccount(), receipt: null }) await expect(ensureRootValidatorMigrated(deps)).rejects.toThrow(KernelMigrationPendingError) expect(rebuildClient).not.toHaveBeenCalled() }) + + it('REVERTED bundle receipt → deterministic KernelMigrationFailedError (no retry framing)', async () => { + const { deps, rebuildClient } = makeDeps({ account: wrapperAccount(false), receipt: revertedReceipt }) + await expect(ensureRootValidatorMigrated(deps)).rejects.toThrow(KernelMigrationFailedError) + expect(rebuildClient).not.toHaveBeenCalled() + }) + + it('fails closed when the rebuilt client is STILL a wrapper (lagging public RPC)', async () => { + const { deps, rebuildClient } = makeDeps({ + account: wrapperAccount(true), + rebuiltAccount: stuckWrapperAccount(), // rebuild keeps returning a wrapper + }) + await expect(ensureRootValidatorMigrated(deps)).rejects.toThrow(KernelMigrationPendingError) + expect(rebuildClient).toHaveBeenCalledTimes(2) // one grace retry, then fail closed + }) }) diff --git a/src/utils/kernelMigration.utils.ts b/src/utils/kernelMigration.utils.ts index ed9f57e609..a52fb1a4ab 100644 --- a/src/utils/kernelMigration.utils.ts +++ b/src/utils/kernelMigration.utils.ts @@ -42,6 +42,7 @@ export const buildMigrationNoopCall = (accountAddress: Address): { to: Hex; valu }), }) +/** Transient: the migration hasn't been observed on-chain yet — retrying is correct. */ export class KernelMigrationPendingError extends Error { constructor() { super('Account security upgrade did not confirm in time — please retry in a moment') @@ -49,6 +50,16 @@ export class KernelMigrationPendingError extends Error { } } +/** Deterministic: the migration userOp reverted on-chain — retrying cannot succeed. */ +export class KernelMigrationFailedError extends Error { + constructor() { + super('Account security upgrade failed — please contact support') + this.name = 'KernelMigrationFailedError' + } +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + export interface EnsureMigratedDeps { /** The client currently held by the caller (possibly a migration wrapper). */ client: TClient @@ -58,6 +69,9 @@ export interface EnsureMigratedDeps { rebuildClient: () => Promise /** Optional analytics hook, called with the step outcome. */ onEvent?: (event: 'attempted' | 'succeeded') => void + /** On-chain status verification attempts / spacing (overridable for tests). */ + statusRetries?: number + statusIntervalMs?: number } /** @@ -69,23 +83,53 @@ export interface EnsureMigratedDeps { * - Wrapper account, already migrated on-chain (e.g. migrated earlier this * session) → rebuilds only: the wrapper still SIGNS via the old validator * even after migration, so its signatures would be rejected. - * - Wrapper account, unmigrated → sends the migration userOp, waits for the - * receipt, then rebuilds. + * - Wrapper account, unmigrated → sends the migration userOp, verifies the + * migration against the ON-CHAIN root validator (never the bundle receipt — + * 4337 reverts still produce successful bundles), then rebuilds and asserts + * the wrapper is gone. */ export async function ensureRootValidatorMigrated( deps: EnsureMigratedDeps ): Promise { const account = deps.client.account if (!isMigrationWrapperAccount(account)) return deps.client + const retries = deps.statusRetries ?? 5 + const intervalMs = deps.statusIntervalMs ?? 1500 const migrated = await account.getRootValidatorMigrationStatus() if (!migrated) { deps.onEvent?.('attempted') const { receipt } = await deps.sendNoopUserOp(buildMigrationNoopCall(account.address)) - if (!receipt || receipt.status !== 'success') { - throw new KernelMigrationPendingError() + if (receipt?.status === 'reverted') { + // The bundle itself reverted — deterministic; retrying cannot help. + throw new KernelMigrationFailedError() + } + // ERC-4337: a REVERTED userOp still yields a SUCCESSFUL bundle receipt + // (the EntryPoint's handleOps tx succeeds; only the userOp-level + // `success` flag is false, and handleSendUserOpEncoded drops it). The + // receipt is therefore NOT proof of migration — verify against ground + // truth by re-reading the on-chain root validator, with a short poll + // to ride out RPC propagation. A null receipt (timeout) takes the same + // path: if the op actually landed, the status flips and we proceed. + let confirmed = false + for (let attempt = 0; attempt < retries && !confirmed; attempt++) { + confirmed = await account.getRootValidatorMigrationStatus() + if (!confirmed && attempt < retries - 1) await delay(intervalMs) } + if (!confirmed) throw new KernelMigrationPendingError() deps.onEvent?.('succeeded') } - return deps.rebuildClient() + + // Rebuild — and verify the wrapper is actually gone. The rebuild re-reads + // the root validator through the public RPC, which can lag the bundler + // that confirmed the migration; a lagging node hands back another + // v0.0.2-signing wrapper whose signatures would revert exactly like the + // bug this gate exists to fix. Fail closed rather than sign wrong. + let rebuilt = await deps.rebuildClient() + if (isMigrationWrapperAccount(rebuilt.account)) { + await delay(intervalMs) + rebuilt = await deps.rebuildClient() + if (isMigrationWrapperAccount(rebuilt.account)) throw new KernelMigrationPendingError() + } + return rebuilt } From c8c3da30da79c370d274c2c2a1b04e38c80e0e14 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 9 Jul 2026 22:41:34 -0700 Subject: [PATCH 06/19] chore: clear the lint annotations visible on the PR diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit card-recovery's orphaned Address import was OURS (the typed-data builder swap made it unused). The rest are pre-existing mechanical fixes in files this PR already touches: qr-pay's four dead imports + the '@/context' barrel (test mock repointed to the specific file), useWallet's '@/interfaces' barrel. Deliberately NOT touched: the two 'any's and the unused transactionUsd — those need type judgment, not cleanup, and belong to the backlog. --- src/app/(mobile-ui)/card-recovery/page.tsx | 2 +- .../qr-pay/__tests__/qr-pay-states.test.tsx | 9 +++++---- src/app/(mobile-ui)/qr-pay/page.tsx | 12 +++--------- src/hooks/wallet/useWallet.ts | 2 +- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/app/(mobile-ui)/card-recovery/page.tsx b/src/app/(mobile-ui)/card-recovery/page.tsx index 943f03a01f..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' 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 67298bbc25..a5e11a4b33 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -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/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index f7de4d3c96..5f4cfc9fd4 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -9,7 +9,7 @@ import { useIsFetching } from '@tanstack/react-query' import { formatUnits, type Hex, type Address } from 'viem' import { useZeroDev } from '../useZeroDev' import { useAuth } from '@/context/authContext' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' From 2e27f52b35abf2d2de05d6d25c0941b23ca8d4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 10 Jul 2026 19:17:06 -0300 Subject: [PATCH 07/19] fix: remove unauthenticated public Discord relay endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/send-discord-notification forwarded arbitrary POST body.message to DISCORD_WEBHOOK_URL with no auth, rate limit, or mention filtering — actively abused to post @everyone into our Discord. Zero callers exist; all real Discord alerting is server-side in peanut-api-ts. --- .../api/send-discord-notification/route.ts | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 src/app/api/send-discord-notification/route.ts 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 From 14ebb1f3526663ca551f9d9cc7a5220e24c51582 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 10 Jul 2026 16:04:33 -0700 Subject: [PATCH 08/19] feat: sync Rhino chain support with live catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rhino's live bridge config supports 9 EVM withdraw destinations our chainId→name mapping never listed, so users picking them got 'Unsupported Rhino chain mapping' (Avalanche was user-visible today: it's in chain-details.json but had no mapping entry). Each addition was verified against Rhino prod (getBridgeConfig status=enabled + real ARBITRUM→X quote + outflow SDA create): AVALANCHE, HYPEREVM, INK, KATANA, LINEA, MANTLE, PLASMA, STABLE, TEMPO. SCROLL removed — Rhino disabled it and quotes now 400. KAIA/OPBNB excluded: SDA create rejects their tokenOut (DepositAddressTokenOutNotSupported). Deposit side: TEMPO added to SUPPORTED_EVM_CHAINS (SDA catalog lists USDC+USDT, meeting the REQUIRED_TOKENS bar). KAIA/PLASMA stay off the deposit list — USDT-only, and the EVM-family token list advertises USDC, which Rhino would silently swallow on those chains. chain-details/token-details entries use Rhino's authoritative token addresses (getBridgeConfig) and chainid.network registry metadata; USDT-only routes (PLASMA, STABLE) expose only USDT so the token selector can't offer an unbridgeable pair. --- src/constants/chain-details.json | 170 ++++++++++++++++++++++++++++++- src/constants/rhino.consts.ts | 21 +++- src/constants/token-details.json | 127 +++++++++++++++++++++++ 3 files changed, 315 insertions(+), 3 deletions(-) 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/rhino.consts.ts b/src/constants/rhino.consts.ts index 018eadf980..0a3999150b 100644 --- a/src/constants/rhino.consts.ts +++ b/src/constants/rhino.consts.ts @@ -14,6 +14,7 @@ export const CHAIN_LOGOS = { CELO: 'https://assets.coingecko.com/asset_platforms/images/21/standard/celo.jpeg?1711358666', TRON: 'https://assets.coingecko.com/asset_platforms/images/1094/standard/TRON_LOGO.png?1706606652', SOLANA: 'https://assets.coingecko.com/asset_platforms/images/5/standard/solana.png?1706606708', + TEMPO: 'https://icons.llamao.fi/icons/chains/rsz_tempo.jpg', } as const /** Token symbol to logo URL mapping - reusable across the app */ @@ -39,6 +40,11 @@ export const SUPPORTED_EVM_CHAINS = [ 'KATANA', 'GNOSIS', 'CELO', + // TEMPO added 2026-07-10: live SDA catalog lists it with USDC+USDT (the + // REQUIRED_TOKENS bar). KAIA/PLASMA stay excluded — USDT-only on Rhino, and + // the deposit UI advertises USDC per EVM family, so a USDC deposit there + // would be silently lost. + 'TEMPO', ] as const export const OTHER_SUPPORTED_CHAINS = ['SOLANA', 'TRON'] as const @@ -111,11 +117,24 @@ export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = { '56': 'BINANCE', // Rhino's name for BNB Chain (display: BNB) '100': 'GNOSIS', '137': 'MATIC_POS', // Rhino's name for Polygon (display: POLYGON) - '534352': 'SCROLL', + // SCROLL (534352) removed 2026-07-10: Rhino disabled it ("SCROLL is disabled" + // InvalidRequest on quote). Re-add only after confirming via getBridgeConfig(). '42161': 'ARBITRUM', '421614': 'ARBITRUM', // Arb Sepolia — same Rhino bucket for sandbox runs '8453': 'BASE', '42220': 'CELO', + // Added 2026-07-10 after verifying each against Rhino's live bridge config + // (status=enabled) AND a real ARBITRUM→X quote + outflow-SDA create. + // PLASMA/STABLE are USDT-only routes; token gating lives in token-details.json. + '43114': 'AVALANCHE', + '999': 'HYPEREVM', + '57073': 'INK', + '747474': 'KATANA', + '59144': 'LINEA', + '5000': 'MANTLE', + '9745': 'PLASMA', + '988': 'STABLE', + '4217': 'TEMPO', } export function evmChainIdToRhinoName(chainId: string | number): string | undefined { 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" + } + ] } ] From bde348738ebe270a8e1d7dad2401e4ecffa8272f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 10 Jul 2026 16:16:42 -0700 Subject: [PATCH 09/19] feat: wire new Rhino chains into the withdraw selector gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The withdraw destination list is gated by RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN ∩ the wagmi source-network list — so the chainId→name mapping alone never surfaced the new chains. Extend the gate with the 9 verified destinations (USDT-only for Plasma/Stable), and derive the withdraw list from the gate's own keys instead of the wagmi list: destinations need no wallet connection or balance reads, and several deliverable chains (Avalanche, Linea, Ink…) are intentionally not source chains. Also exclude Scroll from the source selector: Rhino disabled it and it isn't an SDA deposit chain, so every cross-chain route from it dead-ends (completes the mapping removal in the previous commit). --- .../TokenSelector/TokenSelector.consts.ts | 20 +++++++++++++++++-- .../Global/TokenSelector/TokenSelector.tsx | 7 ++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/Global/TokenSelector/TokenSelector.consts.ts b/src/components/Global/TokenSelector/TokenSelector.consts.ts index 1201cdfee1..a02a5601d0 100644 --- a/src/components/Global/TokenSelector/TokenSelector.consts.ts +++ b/src/components/Global/TokenSelector/TokenSelector.consts.ts @@ -1,7 +1,7 @@ import { SOLANA_ICON, TRON_ICON } from '@/assets' import { networks } from '@/config' 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 +69,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,6 +94,11 @@ 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 @@ -100,4 +107,13 @@ export const RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN: Record = ({ 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. 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) : TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS ), [restrictToRhino] From 58aa9d9dbba9e842f2f0137b17f3bfda9e5074f5 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 10 Jul 2026 16:25:33 -0700 Subject: [PATCH 10/19] fix: search withdraw tokens across the Rhino destination set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit catch: with the withdraw list now derived from the Rhino gate, the search path still scanned the wagmi source list — so searching 'USDT' before picking a network omitted destination-only chains (Linea, Avalanche, …). --- src/components/Global/TokenSelector/TokenSelector.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index e80c6995ef..de09b04a8e 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -311,8 +311,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 @@ -329,6 +332,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT isCrossChainDisabled, restrictToRhino, isRhinoSupported, + allowedChainIds, ]) // filter popular tokens by search From 60e81842df9e2e98e0cf7dba565537f68075820b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 10 Jul 2026 16:40:11 -0700 Subject: [PATCH 11/19] feat: add Kaia + Plasma deposit chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are in Rhino's live SDA catalog (USDT-only) and the backend already provisions inflow SDAs accepting them — only the FE list hid them. The deposit UI advertises tokens per EVM family (incl. USDC), so a USDC deposit on these chains relies on Rhino support to return it — accepted risk (Hugo, 2026-07-10); per-chain token gating is the proper fix, tracked as a follow-up. --- src/constants/rhino.consts.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts index 0a3999150b..d7e9df0e86 100644 --- a/src/constants/rhino.consts.ts +++ b/src/constants/rhino.consts.ts @@ -15,6 +15,8 @@ export const CHAIN_LOGOS = { TRON: 'https://assets.coingecko.com/asset_platforms/images/1094/standard/TRON_LOGO.png?1706606652', SOLANA: 'https://assets.coingecko.com/asset_platforms/images/5/standard/solana.png?1706606708', TEMPO: 'https://icons.llamao.fi/icons/chains/rsz_tempo.jpg', + KAIA: 'https://coin-images.coingecko.com/asset_platforms/images/9672/small/kaia.png?1734946776', + PLASMA: 'https://coin-images.coingecko.com/asset_platforms/images/32256/small/plasma.jpg?1758000963', } as const /** Token symbol to logo URL mapping - reusable across the app */ @@ -40,11 +42,14 @@ export const SUPPORTED_EVM_CHAINS = [ 'KATANA', 'GNOSIS', 'CELO', - // TEMPO added 2026-07-10: live SDA catalog lists it with USDC+USDT (the - // REQUIRED_TOKENS bar). KAIA/PLASMA stay excluded — USDT-only on Rhino, and - // the deposit UI advertises USDC per EVM family, so a USDC deposit there - // would be silently lost. + // TEMPO/KAIA/PLASMA added 2026-07-10 from Rhino's live SDA catalog. KAIA and + // PLASMA are USDT-only on Rhino while the deposit UI advertises tokens per + // EVM family (incl. USDC) — accepted risk (Hugo, 2026-07-10): a USDC deposit + // there is recoverable via the Rhino team. Per-chain token gating is the + // proper fix (follow-up). 'TEMPO', + 'KAIA', + 'PLASMA', ] as const export const OTHER_SUPPORTED_CHAINS = ['SOLANA', 'TRON'] as const From bbadbf12388c2a6da627e513af34861c2aa9bbbe Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 10 Jul 2026 19:41:01 -0700 Subject: [PATCH 12/19] feat: Solana + Tron withdrawals, plus chain-expansion polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rhino delivers to Solana (USDC+USDT) and Tron (USDT) — verified with live quotes and outflow-SDA creates. The user-side tx is unchanged (an ERC20 transfer to the SDA on Arbitrum); what was missing was FE plumbing keyed on EVM chainIds: - synthetic non-EVM chain records merged into the withdraw selector ONLY (send/pay/claim keep their EVM+wagmi assumptions) - per-family recipient validation (base58 / base58check), family from the SELECTED chain — never string-sniffed (every Tron address is also valid base58 in Solana's length range) - chainIdToRhinoName handles slugs + EVM ids for the withdraw path Polish from the #2396 rollout review: - Linea logo: chain-details ships an ipfs SVG that next/image refuses → CHAIN_ICON_OVERRIDES raster (the Arbitrum mechanism) - deposit fee note (~0.1%) — the crypto deposit flow showed no fee messaging at all - Kaia/Plasma deposit chips annotated 'USDT only' (a USDC deposit there is Rhino-unrecoverable-by-webhook; removes the accepted-risk) - cross-chain withdraw receipts: fall back to the source-chain explorer when the destination has none (Tempo receipts were linkless; the recorded hash is the Arbitrum tx anyway) - withdraw view outer shell to flex/gap per the page-layout rules (space-y clipped the CTA on short viewports) Depends on peanut-api-ts#1166 (Rhino-native delivery verification) deploying first — the old destination-RPC probe can't verify non-EVM deliveries. --- src/app/actions/supported-chains.ts | 4 ++ .../components/ChooseNetworkDrawer.tsx | 15 +++-- .../components/SupportedNetworksModal.tsx | 10 +-- .../AddMoney/views/CryptoDeposit.view.tsx | 6 ++ .../Global/GeneralRecipientInput/index.tsx | 21 +++++- .../TokenSelector/TokenSelector.consts.ts | 4 ++ .../Global/TokenSelector/TokenSelector.tsx | 22 ++++++- .../transactionTransformer.ts | 10 ++- .../Withdraw/views/Initial.withdraw.view.tsx | 26 +++++++- src/constants/nonEvmWithdraw.consts.ts | 66 +++++++++++++++++++ src/constants/rhino.consts.ts | 30 +++++++++ .../shared/hooks/useCrossChainTransfer.ts | 32 ++++++--- .../__tests__/addressFamily.test.ts | 52 +++++++++++++++ src/lib/validation/addressFamily.ts | 33 ++++++++++ src/lib/validation/recipient.ts | 14 +++- src/services/rhino-sda.ts | 4 +- 16 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 src/constants/nonEvmWithdraw.consts.ts create mode 100644 src/lib/validation/__tests__/addressFamily.test.ts create mode 100644 src/lib/validation/addressFamily.ts 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/components/AddMoney/components/ChooseNetworkDrawer.tsx b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx index 2ba3113ab2..30a12ebf5c 100644 --- a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx +++ b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx @@ -3,7 +3,12 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/Global/Drawer' import { ActionListCard } from '@/components/ActionListCard' import ChainChip from './ChainChip' -import { CHAIN_LOGOS, SUPPORTED_EVM_CHAINS, getSupportedTokens } from '@/constants/rhino.consts' +import { + CHAIN_LOGOS, + SUPPORTED_EVM_CHAINS, + getSupportedTokens, + EVM_DEPOSIT_TOKEN_EXCEPTIONS, +} from '@/constants/rhino.consts' import type { RhinoChainType } from '@/services/services.types' import Image from 'next/image' @@ -44,9 +49,11 @@ const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerPro {/* expanded chain list */}
onSelect('EVM')} className="mx-4 border-t border-dashed border-black py-3">
- {SUPPORTED_EVM_CHAINS.map((chain) => ( - - ))} + {SUPPORTED_EVM_CHAINS.map((chain) => { + const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain] + const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain + return + })}
diff --git a/src/components/AddMoney/components/SupportedNetworksModal.tsx b/src/components/AddMoney/components/SupportedNetworksModal.tsx index 29953b2e9f..2d1cccf24d 100644 --- a/src/components/AddMoney/components/SupportedNetworksModal.tsx +++ b/src/components/AddMoney/components/SupportedNetworksModal.tsx @@ -3,7 +3,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 { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS, EVM_DEPOSIT_TOKEN_EXCEPTIONS } from '@/constants/rhino.consts' interface SupportedNetworksModalProps { visible: boolean @@ -25,9 +25,11 @@ const SupportedNetworksModal = ({ visible, onClose }: SupportedNetworksModalProp

- {SUPPORTED_EVM_CHAINS.map((chain) => ( - - ))} + {SUPPORTED_EVM_CHAINS.map((chain) => { + const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain] + const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain + return + })}
+ {!isOfframp && ( +

+ A small bridging fee (~0.1%) applies — you'll receive slightly less than you + send. +

+ )} {isOfframp && (

Moving more than the max? Send it in multiple transfers. diff --git a/src/components/Global/GeneralRecipientInput/index.tsx b/src/components/Global/GeneralRecipientInput/index.tsx index 6e4f7026f7..076675606c 100644 --- a/src/components/Global/GeneralRecipientInput/index.tsx +++ b/src/components/Global/GeneralRecipientInput/index.tsx @@ -7,6 +7,7 @@ import * as Senty from '@sentry/nextjs' import { useCallback, useRef } from 'react' import { isIBAN } from 'validator' import { validateAndResolveRecipient } from '@/lib/validation/recipient' +import { isValidAddressForFamily, type WithdrawAddressFamily } from '@/lib/validation/addressFamily' import { BASE_URL } from '@/constants/general.consts' type GeneralRecipientInputProps = { @@ -17,6 +18,10 @@ type GeneralRecipientInputProps = { infoText?: string showInfoText?: boolean isWithdrawal?: boolean + /** Address family of the selected withdraw destination ('evm' default). + * Solana/Tron short-circuit the IBAN/US-routing/ENS branches — a base58 + * address is the only valid input for them. */ + addressFamily?: WithdrawAddressFamily } export type GeneralRecipientUpdate = { @@ -35,6 +40,7 @@ const GeneralRecipientInput = ({ infoText, showInfoText = true, isWithdrawal = false, + addressFamily = 'evm', }: GeneralRecipientInputProps) => { const recipientType = useRef('address') const errorMessage = useRef('') @@ -50,6 +56,19 @@ const GeneralRecipientInput = ({ const trimmedInput = recipient.trim().replace(`${BASE_URL}/`, '') const sanitizedInput = sanitizeBankAccount(trimmedInput) + // Non-EVM destination: base58 address or nothing — never IBAN, + // US-routing, ENS, or username. + if (addressFamily !== 'evm') { + const familyValid = isValidAddressForFamily(trimmedInput, addressFamily) + if (familyValid) { + resolvedAddress.current = trimmedInput + } else { + errorMessage.current = `Invalid ${addressFamily === 'solana' ? 'Solana' : 'Tron'} address` + } + recipientType.current = 'address' + return familyValid + } + if (isIBAN(sanitizedInput)) { type = 'iban' isValid = await validateBankAccount(sanitizedInput) @@ -82,7 +101,7 @@ const GeneralRecipientInput = ({ return false } }, - [isWithdrawal] + [isWithdrawal, addressFamily] ) const onInputUpdate = useCallback( diff --git a/src/components/Global/TokenSelector/TokenSelector.consts.ts b/src/components/Global/TokenSelector/TokenSelector.consts.ts index a02a5601d0..cee368c2c3 100644 --- a/src/components/Global/TokenSelector/TokenSelector.consts.ts +++ b/src/components/Global/TokenSelector/TokenSelector.consts.ts @@ -116,4 +116,8 @@ export const RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN: Record = ({ classNameButton, viewT // state for image loading errors const [buttonImageError, setButtonImageError] = useState(false) const { - supportedChainsAndTokens, + supportedChainsAndTokens: contextChainsAndTokens, setSelectedTokenAddress, setSelectedChainID, selectedTokenAddress, selectedChainID, } = useContext(tokenSelectorContext) + // Withdraw mode also offers non-EVM destinations (Solana/Tron) that have + // no chain-details entry — merge their synthetic records so every internal + // lookup (network list, token list, button display) resolves them. Other + // modes must NOT see them: sources/claims assume EVM addresses + wagmi. + const supportedChainsAndTokens = useMemo( + () => (restrictToRhino ? { ...contextChainsAndTokens, ...NON_EVM_WITHDRAW_CHAINS } : contextChainsAndTokens), + [contextChainsAndTokens, restrictToRhino] + ) + // drawer utility functions const openDrawer = useCallback(() => setIsDrawerOpen(true), []) const closeDrawer = useCallback(() => { @@ -131,7 +141,13 @@ const TokenSelector: React.FC = ({ classNameButton, viewT // selected network name memo, being used ui const selectedNetworkName = useMemo(() => { if (!selectedChainID) return null - return getChainName(selectedChainID) || `Chain ${selectedChainID}` + // record first — non-EVM slugs ('solana'/'tron') aren't in the + // chain-details-backed getChainName lookup + return ( + supportedChainsAndTokens?.[selectedChainID]?.networkName || + getChainName(selectedChainID) || + `Chain ${selectedChainID}` + ) }, [selectedChainID, supportedChainsAndTokens]) const peanutWalletTokenDetails = useMemo(() => { @@ -444,7 +460,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT setSearchValue={setNetworkSearchValue} selectedChainID={selectedChainID} allowedChainIds={allowedChainIds} - comingSoonNetworks={TOKEN_SELECTOR_COMING_SOON_NETWORKS} + comingSoonNetworks={restrictToRhino ? [] : TOKEN_SELECTOR_COMING_SOON_NETWORKS} /> ) : (

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