diff --git a/apps/zerodev-signer-demo/src/app/wagmi-config.ts b/apps/zerodev-signer-demo/src/app/wagmi-config.ts index 2d587b7b..6de3f3c9 100644 --- a/apps/zerodev-signer-demo/src/app/wagmi-config.ts +++ b/apps/zerodev-signer-demo/src/app/wagmi-config.ts @@ -43,7 +43,7 @@ export const config = createConfig({ // (testnets above), so the demo points it at real mainnet chains. smartRoutingAddress: { enabled: true, - destinationChains: [arbitrum], + destinationChains: [arbitrum, mainnet], sourceChains: [mainnet, arbitrum], }, }, diff --git a/packages/react-kit/src/smart-routing/hooks/useSmartRoutingAddress.test.tsx b/packages/react-kit/src/smart-routing/hooks/useSmartRoutingAddress.test.tsx index 2f0dd71c..e3e6f20e 100644 --- a/packages/react-kit/src/smart-routing/hooks/useSmartRoutingAddress.test.tsx +++ b/packages/react-kit/src/smart-routing/hooks/useSmartRoutingAddress.test.tsx @@ -7,7 +7,7 @@ import type { ReactNode } from 'react' import { mainnet, optimism } from 'viem/chains' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createStore } from '../store' +import { createStore } from '../../store' import { useSmartRoutingAddress } from './useSmartRoutingAddress' const mockCreateSmartRoutingAddress = vi.fn() diff --git a/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts b/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts index 28b5cb92..307c7c6b 100644 --- a/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts +++ b/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts @@ -23,12 +23,36 @@ export function useSmartRoutingFlow() { const step = useStore(store, (state) => state.smartRouting.step) const stepHistory = useStore(store, (state) => state.smartRouting.stepHistory) + const sendChain = useStore(store, (state) => state.smartRouting.sendChain) + const receiveChain = useStore( + store, + (state) => state.smartRouting.receiveChain, + ) + const editingChainSlot = useStore( + store, + (state) => state.smartRouting.editingChainSlot, + ) + const sourceChains = useStore( + store, + (state) => state.smartRouting.config?.sourceChains, + ) + const destinationChains = useStore( + store, + (state) => state.smartRouting.config?.destinationChains, + ) return { step, + sendChain, + receiveChain, + editingChainSlot, + sourceChains, + destinationChains, goToStep: store.getState().smartRouting.goToStep, goBack: - stepHistory.length > 0 ? store.getState().smartRouting.goBack : null, + stepHistory.length > 1 ? store.getState().smartRouting.goBack : null, + setEditingChainSlot: store.getState().smartRouting.setEditingChainSlot, + setChain: store.getState().smartRouting.setChain, reset: store.getState().smartRouting.reset, } } diff --git a/packages/react-kit/src/smart-routing/index.tsx b/packages/react-kit/src/smart-routing/index.tsx index 5c23bcfc..6d48fc75 100644 --- a/packages/react-kit/src/smart-routing/index.tsx +++ b/packages/react-kit/src/smart-routing/index.tsx @@ -4,21 +4,32 @@ import { ScreenWrapper } from '../shared/components/ScreenWrapper' import { TopNav } from '../shared/components/TopNav' import { QrModal } from './components/QrModal' import { useSmartRoutingFlow } from './hooks/useSmartRoutingFlow' +import { SelectNetwork } from './pages/SelectNetwork' import { TransferFromWallet } from './pages/TransferFromWallet' import type { SmartRoutingStep } from './types' const TITLE_BY_STEP: Partial> = { 'transfer-from-wallet': 'Transfer from wallet', + 'select-network': 'Select a network', } function renderStep( step: SmartRoutingStep | null, onGotIt: () => void, onShowQr: (address: string) => void, + onSelectNetwork: () => void, ): ReactNode { switch (step) { case 'transfer-from-wallet': - return + return ( + + ) + case 'select-network': + return default: return null } @@ -40,7 +51,7 @@ export function SmartRoutingAddress({ style?: CSSProperties | undefined onClose?: () => void } = {}) { - const { step, goToStep, reset } = useSmartRoutingFlow() + const { step, goToStep, goBack, reset } = useSmartRoutingFlow() const [qrAddress, setQrAddress] = useState(null) // When the card mounts and no step is active, kick off the flow at its @@ -62,7 +73,9 @@ export function SmartRoutingAddress({ setQrAddress(null) } - const content = renderStep(step, handleClose, setQrAddress) + const content = renderStep(step, handleClose, setQrAddress, () => + goToStep('select-network'), + ) if (!content) return null const title = step ? TITLE_BY_STEP[step] : undefined @@ -71,7 +84,13 @@ export function SmartRoutingAddress({ } + topNav={ + + } overlay={ qrAddress ? ( void +}) { + const iconName = getChainIcon(chain.id) + return ( + onSelect(chain)} + /> + ) +} + +export function SelectNetwork() { + const wagmiChains = useChains() + const { + editingChainSlot, + sendChain, + receiveChain, + sourceChains, + destinationChains, + setChain, + setEditingChainSlot, + goBack, + } = useSmartRoutingFlow() + const [query, setQuery] = useState('') + + // Pick the slot-specific list from the connector's SRA config (which lists + // SRA-supported mainnets) so the picker doesn't surface chains the API + // would reject. Falls back to wagmi's chains if the consumer didn't + // configure source/destination lists. + const chains = + editingChainSlot === 'send' + ? (sourceChains ?? wagmiChains) + : editingChainSlot === 'receive' + ? (destinationChains ?? wagmiChains) + : wagmiChains + + const filtered = query + ? chains.filter((c) => + c.name.toLowerCase().includes(query.trim().toLowerCase()), + ) + : chains + + // Picking the chain that's currently in the *other* slot swaps the two, + // since Across can't quote a same-chain route. + const handleSelect = (chain: Chain) => { + if (!editingChainSlot) { + goBack?.() + return + } + const otherSlot = editingChainSlot === 'send' ? 'receive' : 'send' + const otherChain = editingChainSlot === 'send' ? receiveChain : sendChain + const currentChain = editingChainSlot === 'send' ? sendChain : receiveChain + if (otherChain && otherChain.id === chain.id && currentChain) { + setChain(otherSlot, currentChain) + } + setChain(editingChainSlot, chain) + setEditingChainSlot(null) + goBack?.() + } + + return ( +
+
+ setQuery(e.target.value)} + /> + +
+ {filtered.map((chain) => ( + + ))} +
+
+
+ ) +} diff --git a/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx b/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx index ea7a0549..9000df76 100644 --- a/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx +++ b/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx @@ -10,26 +10,41 @@ import { DataRow } from '../../signing/components/DataRow' import { DetailsContainer } from '../../signing/components/DetailsContainer' import { AddressDisplay } from '../components/AddressDisplay' import { useSmartRoutingAddress } from '../hooks/useSmartRoutingAddress' +import { useSmartRoutingFlow } from '../hooks/useSmartRoutingFlow' +import { getChainIcon } from '../utils/chainIcon' const PLACEHOLDER_ADDRESS = '0x0000000000000000000000000000000000000000' interface TransferFromWalletProps { onGotIt: () => void onShowQr: (address: string) => void + onSelectNetwork: () => void } export function TransferFromWallet({ onGotIt, onShowQr, + onSelectNetwork, }: TransferFromWalletProps) { const { address: connectedAddress } = useAccount() - // Placeholder src/dest pair until the UI has real selectors. We use a - // specific token (USDC) — the SRA API's Across simulation cannot quote a - // route when the src/dest tokens resolve to a generic FLEX placeholder. + const { sendChain, receiveChain, setEditingChainSlot } = useSmartRoutingFlow() + + const sendChainIcon = getChainIcon(sendChain?.id) ?? 'arbitrum' + const receiveChainIcon = getChainIcon(receiveChain?.id) ?? 'arbitrum' + + const openChainPicker = (slot: 'send' | 'receive') => { + setEditingChainSlot(slot) + onSelectNetwork() + } + // The SRA refetches whenever the user picks a new chain in either slot. + // We use a specific token (USDC) — the SRA API's Across simulation cannot + // quote a route when the src/dest tokens resolve to a generic FLEX + // placeholder. Defaults fall back to mainnet/arbitrum (which the SRA API + // supports) until the user makes a selection. const { data, error, isPending } = useSmartRoutingAddress({ owner: (connectedAddress ?? zeroAddress) as Address, - destChain: arbitrum, - srcTokens: [{ tokenType: 'USDC', chain: mainnet }], + destChain: receiveChain ?? arbitrum, + srcTokens: [{ tokenType: 'USDC', chain: sendChain ?? mainnet }], }) const address = data?.smartRoutingAddress ?? PLACEHOLDER_ADDRESS const canCopy = !!data?.smartRoutingAddress @@ -75,9 +90,10 @@ export function TransferFromWallet({ className="flex-1 basis-0" /> openChainPicker('receive')} /> diff --git a/packages/react-kit/src/smart-routing/smartRoutingStoreSlice.ts b/packages/react-kit/src/smart-routing/smartRoutingStoreSlice.ts index 855f3e27..a0a811d7 100644 --- a/packages/react-kit/src/smart-routing/smartRoutingStoreSlice.ts +++ b/packages/react-kit/src/smart-routing/smartRoutingStoreSlice.ts @@ -1,14 +1,28 @@ +import type { Chain } from 'viem' import type { StateCreator } from 'zustand' import type { SmartRoutingAddressConfig, SmartRoutingStep } from './types' +export type ChainSlot = 'send' | 'receive' + export interface SmartRoutingStoreSlice { smartRouting: { config: SmartRoutingAddressConfig | null step: SmartRoutingStep | null stepHistory: SmartRoutingStep[] + /** Chain selected for the Send (source) row. */ + sendChain: Chain | null + /** Chain selected for the Receive (destination) row. */ + receiveChain: Chain | null + /** + * Which chain row triggered the `select-network` step — read by + * `SelectNetwork` to know which slot's chain to update on tap. + */ + editingChainSlot: ChainSlot | null initialize: (config: SmartRoutingAddressConfig) => void goToStep: (step: SmartRoutingStep | null) => void goBack: () => void + setEditingChainSlot: (slot: ChainSlot | null) => void + setChain: (slot: ChainSlot, chain: Chain) => void reset: () => void } } @@ -23,10 +37,22 @@ export const createSmartRoutingStoreSlice: StateCreator< config: null, step: null, stepHistory: [], + sendChain: null, + receiveChain: null, + editingChainSlot: null, initialize: (config) => { set((state) => ({ - smartRouting: { ...state.smartRouting, config }, + smartRouting: { + ...state.smartRouting, + config, + // Seed slot defaults to the first configured chain so the UI's + // displayed chain matches the SRA hook's effective inputs. The + // user can override either via `SelectNetwork`. + sendChain: config.sourceChains?.[0] ?? state.smartRouting.sendChain, + receiveChain: + config.destinationChains?.[0] ?? state.smartRouting.receiveChain, + }, })) }, @@ -57,12 +83,30 @@ export const createSmartRoutingStoreSlice: StateCreator< })) }, + setEditingChainSlot: (slot) => { + set((state) => ({ + smartRouting: { ...state.smartRouting, editingChainSlot: slot }, + })) + }, + + setChain: (slot, chain) => { + set((state) => ({ + smartRouting: { + ...state.smartRouting, + ...(slot === 'send' ? { sendChain: chain } : { receiveChain: chain }), + }, + })) + }, + reset: () => { set((state) => ({ smartRouting: { ...state.smartRouting, step: null, stepHistory: [], + sendChain: null, + receiveChain: null, + editingChainSlot: null, }, })) }, diff --git a/packages/react-kit/src/smart-routing/types.ts b/packages/react-kit/src/smart-routing/types.ts index abc1fa90..d879d18a 100644 --- a/packages/react-kit/src/smart-routing/types.ts +++ b/packages/react-kit/src/smart-routing/types.ts @@ -6,7 +6,7 @@ import type { Address, Chain } from 'viem' * the entry component dispatches each step to its page, and steps push/pop * a history so the TopNav back button works. */ -export type SmartRoutingStep = 'transfer-from-wallet' +export type SmartRoutingStep = 'transfer-from-wallet' | 'select-network' /** * Connector-level config for the Smart Routing Address (SRA) feature. diff --git a/packages/react-kit/src/smart-routing/utils/chainIcon.ts b/packages/react-kit/src/smart-routing/utils/chainIcon.ts new file mode 100644 index 00000000..43a2fec0 --- /dev/null +++ b/packages/react-kit/src/smart-routing/utils/chainIcon.ts @@ -0,0 +1,20 @@ +import type { IconName } from '../../shared/components/Icon' + +/** + * Maps wagmi `Chain.id` to a kit `Icon` name. Mainnets and their testnet + * counterparts share the same logo. Returns `undefined` for unknown chains + * (the caller can either omit the icon or pick its own fallback). + */ +const CHAIN_ICON_BY_ID: Record = { + 1: 'ethereum', + 11155111: 'ethereum', // sepolia + 42161: 'arbitrum', + 421614: 'arbitrum', // arbitrumSepolia +} + +export function getChainIcon( + chainId: number | undefined, +): IconName | undefined { + if (chainId === undefined) return undefined + return CHAIN_ICON_BY_ID[chainId] +}