From 20bff9b01591a45658da50c97b21adf2261c7872 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Fri, 5 Jun 2026 09:21:22 +0400 Subject: [PATCH 01/14] feat: SRA hook # Conflicts: # pnpm-lock.yaml --- packages/react-kit/src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-kit/src/index.ts b/packages/react-kit/src/index.ts index f520e6e8..099e2f11 100644 --- a/packages/react-kit/src/index.ts +++ b/packages/react-kit/src/index.ts @@ -29,4 +29,11 @@ export type { UseSmartRoutingAddressParams, } from './smart-routing/types.js' +// Smart Routing Address +export type { + SmartRoutingAddressConfig, + SmartRoutingAddressOverrides, +} from './smart-routing/types.js' +export { useSmartRoutingAddress } from './smart-routing/useSmartRoutingAddress.js' + export type { PendingRequest, Request, RequestMethod } from './types.js' From 5425f3f33ab22e37b3ec18c3192bb9fcfd8911ac Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Fri, 5 Jun 2026 09:21:48 +0400 Subject: [PATCH 02/14] feat: SRA hook --- .../useSmartRoutingAddress.test.tsx | 240 ++++++++++++++++++ .../smart-routing/useSmartRoutingAddress.ts | 125 +++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx create mode 100644 packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx new file mode 100644 index 00000000..0017bb88 --- /dev/null +++ b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx @@ -0,0 +1,240 @@ +/** + * @vitest-environment happy-dom + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, renderHook, waitFor } from '@testing-library/react' +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 { useSmartRoutingAddress } from './useSmartRoutingAddress' + +const mockCreateSmartRoutingAddress = vi.fn() +const mockCreateCall = vi.fn((args) => ({ __call: args })) + +vi.mock('@zerodev/smart-routing-address', () => ({ + createSmartRoutingAddress: (params: unknown) => + mockCreateSmartRoutingAddress(params), + createCall: (args: unknown) => mockCreateCall(args), + FLEX: { + TOKEN_ADDRESS: 'TOKEN_ADDRESS', + AMOUNT: 'AMOUNT', + NATIVE_AMOUNT: 'NATIVE_AMOUNT', + }, +})) + +const WALLET = '0x1111111111111111111111111111111111111111' as const + +const mockConnector = { + id: 'zerodev-wallet', + getKitStore: vi.fn(), +} +const mockConfig = { + connectors: [mockConnector] as Array<{ + id: string + getKitStore?: () => unknown + }>, +} +let mockChainId = mainnet.id +const mockChains = [mainnet, optimism] +const mockAccount = { address: WALLET as `0x${string}` | undefined } + +vi.mock('wagmi', () => ({ + useConfig: () => mockConfig, + useAccount: () => mockAccount, + useChainId: () => mockChainId, + useChains: () => mockChains, +})) + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +beforeEach(() => { + mockCreateSmartRoutingAddress.mockReset() + mockCreateCall.mockClear() + mockChainId = mainnet.id + mockAccount.address = WALLET + mockConfig.connectors = [mockConnector] + mockConnector.getKitStore.mockReset() +}) + +afterEach(() => { + cleanup() +}) + +describe('useSmartRoutingAddress', () => { + it('throws when used outside the kit connector', () => { + mockConfig.connectors = [] + const wrapper = makeWrapper() + expect(() => + renderHook(() => useSmartRoutingAddress(), { wrapper }), + ).toThrow( + /useSmartRoutingAddress must be used with zeroDevWallet connector/, + ) + }) + + it('does not fire the query when no SRA config is set', () => { + const store = createStore() + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + renderHook( + () => + useSmartRoutingAddress({ + srcTokens: [{ tokenType: 'ERC20', chain: optimism }], + }), + { wrapper }, + ) + + expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() + }) + + it('does not fire when explicitly disabled', () => { + const store = createStore() + store.getState().smartRouting.initialize({ enabled: false }) + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + renderHook( + () => + useSmartRoutingAddress({ + srcTokens: [{ tokenType: 'ERC20', chain: optimism }], + }), + { wrapper }, + ) + + expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() + }) + + it('does not fire when srcTokens is empty', () => { + const store = createStore() + store.getState().smartRouting.initialize({}) + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + renderHook(() => useSmartRoutingAddress({ srcTokens: [] }), { wrapper }) + + expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() + }) + + it('computes the SRA with connector defaults (owner, destChain, slippage, actions)', async () => { + mockCreateSmartRoutingAddress.mockResolvedValue({ + smartRoutingAddress: '0xSRA', + estimatedFees: [], + }) + + const store = createStore() + store.getState().smartRouting.initialize({}) // no overrides; defaults take over + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + const { result } = renderHook( + () => + useSmartRoutingAddress({ + srcTokens: [{ tokenType: 'ERC20', chain: optimism }], + }), + { wrapper }, + ) + + await waitFor(() => { + expect(result.current.data?.smartRoutingAddress).toBe('0xSRA') + }) + + const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] + expect(args.owner).toBe(WALLET) + expect(args.destChain.id).toBe(mainnet.id) // matches useChainId default + expect(args.slippage).toBe(100) // default 1% + expect(args.actions).toBeDefined() + expect(args.actions.NATIVE).toBeDefined() + expect(args.actions.ERC20).toBeDefined() + }) + + it('uses the first destinationChains entry from connector config as destChain', async () => { + mockCreateSmartRoutingAddress.mockResolvedValue({ + smartRoutingAddress: '0xSRA', + estimatedFees: [], + }) + + const store = createStore() + store + .getState() + .smartRouting.initialize({ destinationChains: [optimism, mainnet] }) + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + renderHook( + () => + useSmartRoutingAddress({ + srcTokens: [{ tokenType: 'ERC20', chain: mainnet }], + }), + { wrapper }, + ) + + await waitFor(() => { + expect(mockCreateSmartRoutingAddress).toHaveBeenCalledTimes(1) + }) + const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] + expect(args.destChain.id).toBe(optimism.id) + }) + + it('per-call overrides win over connector config defaults', async () => { + mockCreateSmartRoutingAddress.mockResolvedValue({ + smartRoutingAddress: '0xSRA', + estimatedFees: [], + }) + + const store = createStore() + store.getState().smartRouting.initialize({ + maxSlippage: 50, + destinationChains: [mainnet], + }) + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + const customOwner = '0x2222222222222222222222222222222222222222' as const + renderHook( + () => + useSmartRoutingAddress({ + owner: customOwner, + destChain: optimism, + maxSlippage: 200, + srcTokens: [{ tokenType: 'NATIVE', chain: mainnet }], + }), + { wrapper }, + ) + + await waitFor(() => { + expect(mockCreateSmartRoutingAddress).toHaveBeenCalledTimes(1) + }) + const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] + expect(args.owner).toBe(customOwner) + expect(args.destChain.id).toBe(optimism.id) + expect(args.slippage).toBe(200) + }) + + it('does not fire when no wallet is connected', () => { + mockAccount.address = undefined + + const store = createStore() + store.getState().smartRouting.initialize({}) + mockConnector.getKitStore.mockReturnValue(store) + + const wrapper = makeWrapper() + renderHook( + () => + useSmartRoutingAddress({ + srcTokens: [{ tokenType: 'ERC20', chain: optimism }], + }), + { wrapper }, + ) + + expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts new file mode 100644 index 00000000..da1113c9 --- /dev/null +++ b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts @@ -0,0 +1,125 @@ +import { useQuery } from '@tanstack/react-query' +import { + type CreateSmartRoutingAddressParams, + createCall, + createSmartRoutingAddress, + FLEX, +} from '@zerodev/smart-routing-address' +import { type Address, erc20Abi } from 'viem' +import { useAccount, useChainId, useChains, useConfig } from 'wagmi' +import { useStore } from 'zustand' +import type { createStore } from '../store' +import type { + SmartRoutingAddressConfig, + SmartRoutingAddressOverrides, +} from './types' + +type Store = ReturnType + +const DEFAULT_SLIPPAGE_BPS = 100 // 1% + +function getKitStore(config: ReturnType): Store | null { + const connector = config.connectors.find((c) => c.id === 'zerodev-wallet') + if (!connector || !('getKitStore' in connector)) return null + // @ts-expect-error - getKitStore is a custom method on the kit connector + return connector.getKitStore() +} + +function buildDefaultActions( + owner: Address, +): CreateSmartRoutingAddressParams['actions'] { + const nativeCall = createCall({ + target: owner, + value: FLEX.NATIVE_AMOUNT, + }) + const erc20Call = createCall({ + target: FLEX.TOKEN_ADDRESS, + value: 0n, + abi: erc20Abi, + functionName: 'transfer', + args: [owner, FLEX.AMOUNT], + }) + return { + NATIVE: { action: [nativeCall], fallBack: [] }, + ERC20: { action: [erc20Call], fallBack: [] }, + } +} + +/** + * Computes a Smart Routing Address from the connector's `smartRoutingAddress` + * config and optional per-call overrides. Returns a React Query state object. + * + * Defaults applied when `overrides` does not specify them: + * - `owner`: connected wallet address (`useAccount`) + * - `destChain`: first chain in `config.destinationChains`, else the chain + * matching the wallet's current `chainId` (`useChainId`/`useChains`) + * - `maxSlippage`: `config.maxSlippage`, else 100 BPS (1%) + * - `actions`: NATIVE + ERC-20 deposit-to-owner (mirror of doorway) + * + * The query is disabled when the feature is not enabled, when no owner is + * connected, when no destination chain is resolvable, or when `srcTokens` is + * empty — in those cases the hook returns `{ data: undefined, isPending: false }`. + */ +export function useSmartRoutingAddress( + overrides: SmartRoutingAddressOverrides = {}, +) { + const wagmiConfig = useConfig() + const store = getKitStore(wagmiConfig) + if (!store) { + throw new Error( + 'useSmartRoutingAddress must be used with zeroDevWallet connector', + ) + } + + const config = useStore( + store, + (state) => state.smartRouting.config, + ) as SmartRoutingAddressConfig | null + + const { address: connectedAddress } = useAccount() + const currentChainId = useChainId() + const availableChains = useChains() + + const owner = overrides.owner ?? connectedAddress + const destChain = + overrides.destChain ?? + config?.destinationChains?.[0] ?? + availableChains.find((c) => c.id === currentChainId) + const slippage = + overrides.maxSlippage ?? config?.maxSlippage ?? DEFAULT_SLIPPAGE_BPS + const srcTokens = overrides.srcTokens + const actions = + overrides.actions ?? (owner ? buildDefaultActions(owner) : undefined) + + const enabled = + config?.enabled !== false && + !!config && + !!owner && + !!destChain && + !!srcTokens && + srcTokens.length > 0 && + !!actions + + return useQuery({ + queryKey: [ + 'zerodev-wallet-kit', + 'smartRoutingAddress', + owner, + destChain?.id, + slippage, + srcTokens?.map((t) => [t.tokenType, t.chain.id, t.minAmount?.toString()]), + ], + enabled, + queryFn: async () => { + if (!owner || !destChain || !srcTokens || !actions) return null + return createSmartRoutingAddress({ + owner, + destChain, + slippage, + srcTokens, + actions, + }) + }, + meta: { persist: false }, + }) +} From d1c29a84484705c76c162b7dbb9dcf3ba1883bbe Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 07:47:30 +0400 Subject: [PATCH 03/14] review --- .../smart-routing/useSmartRoutingAddress.ts | 95 ++++++++----------- 1 file changed, 40 insertions(+), 55 deletions(-) diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts index da1113c9..a9e43735 100644 --- a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts +++ b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts @@ -6,13 +6,10 @@ import { FLEX, } from '@zerodev/smart-routing-address' import { type Address, erc20Abi } from 'viem' -import { useAccount, useChainId, useChains, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { useStore } from 'zustand' import type { createStore } from '../store' -import type { - SmartRoutingAddressConfig, - SmartRoutingAddressOverrides, -} from './types' +import type { UseSmartRoutingAddressParams } from './types' type Store = ReturnType @@ -27,6 +24,7 @@ function getKitStore(config: ReturnType): Store | null { function buildDefaultActions( owner: Address, + srcTokens: UseSmartRoutingAddressParams['srcTokens'], ): CreateSmartRoutingAddressParams['actions'] { const nativeCall = createCall({ target: owner, @@ -39,30 +37,35 @@ function buildDefaultActions( functionName: 'transfer', args: [owner, FLEX.AMOUNT], }) - return { - NATIVE: { action: [nativeCall], fallBack: [] }, - ERC20: { action: [erc20Call], fallBack: [] }, + // Build one action per unique src tokenType. Across needs to know the + // concrete token on the destination chain; using a generic "ERC20" key + // would resolve to a FLEX placeholder (0xff…ff) and fail simulation. + const actions: CreateSmartRoutingAddressParams['actions'] = {} + const seen = new Set() + for (const { tokenType } of srcTokens) { + if (seen.has(tokenType)) continue + seen.add(tokenType) + actions[tokenType] = + tokenType === 'NATIVE' + ? { action: [nativeCall], fallBack: [] } + : { action: [erc20Call], fallBack: [] } } + return actions } /** - * Computes a Smart Routing Address from the connector's `smartRoutingAddress` - * config and optional per-call overrides. Returns a React Query state object. + * Computes a Smart Routing Address. Returns a React Query result. * - * Defaults applied when `overrides` does not specify them: - * - `owner`: connected wallet address (`useAccount`) - * - `destChain`: first chain in `config.destinationChains`, else the chain - * matching the wallet's current `chainId` (`useChainId`/`useChains`) - * - `maxSlippage`: `config.maxSlippage`, else 100 BPS (1%) - * - `actions`: NATIVE + ERC-20 deposit-to-owner (mirror of doorway) - * - * The query is disabled when the feature is not enabled, when no owner is - * connected, when no destination chain is resolvable, or when `srcTokens` is - * empty — in those cases the hook returns `{ data: undefined, isPending: false }`. + * The query is disabled when the SRA feature flag is off + * (`config.smartRoutingAddress.enabled === false`). */ -export function useSmartRoutingAddress( - overrides: SmartRoutingAddressOverrides = {}, -) { +export function useSmartRoutingAddress({ + owner, + destChain, + srcTokens, + slippage = DEFAULT_SLIPPAGE_BPS, + actions, +}: UseSmartRoutingAddressParams) { const wagmiConfig = useConfig() const store = getKitStore(wagmiConfig) if (!store) { @@ -71,54 +74,36 @@ export function useSmartRoutingAddress( ) } - const config = useStore( - store, - (state) => state.smartRouting.config, - ) as SmartRoutingAddressConfig | null - - const { address: connectedAddress } = useAccount() - const currentChainId = useChainId() - const availableChains = useChains() - - const owner = overrides.owner ?? connectedAddress - const destChain = - overrides.destChain ?? - config?.destinationChains?.[0] ?? - availableChains.find((c) => c.id === currentChainId) - const slippage = - overrides.maxSlippage ?? config?.maxSlippage ?? DEFAULT_SLIPPAGE_BPS - const srcTokens = overrides.srcTokens - const actions = - overrides.actions ?? (owner ? buildDefaultActions(owner) : undefined) + const config = useStore(store, (state) => state.smartRouting.config) + const enabled = !!config && config.enabled !== false - const enabled = - config?.enabled !== false && - !!config && - !!owner && - !!destChain && - !!srcTokens && - srcTokens.length > 0 && - !!actions + const resolvedActions = actions ?? buildDefaultActions(owner, srcTokens) return useQuery({ queryKey: [ 'zerodev-wallet-kit', 'smartRoutingAddress', owner, - destChain?.id, + destChain.id, slippage, - srcTokens?.map((t) => [t.tokenType, t.chain.id, t.minAmount?.toString()]), + srcTokens.map((t) => [t.tokenType, t.chain.id, t.minAmount?.toString()]), ], enabled, queryFn: async () => { - if (!owner || !destChain || !srcTokens || !actions) return null - return createSmartRoutingAddress({ + const result = await createSmartRoutingAddress({ owner, destChain, slippage, srcTokens, - actions, + actions: resolvedActions, }) + // The SDK currently swallows JSON-RPC error bodies (200 OK with `error` + // instead of `result`) and returns `undefined`. React Query forbids + // undefined data, so promote it to a proper error. + if (!result) { + throw new Error('Smart routing address service returned no result') + } + return result }, meta: { persist: false }, }) From 3b4c992ae737ce6651b60bd5904ce70a10f663d7 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 07:50:18 +0400 Subject: [PATCH 04/14] test --- .../useSmartRoutingAddress.test.tsx | 126 +++++------------- 1 file changed, 31 insertions(+), 95 deletions(-) diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx index 0017bb88..c32cb79c 100644 --- a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx +++ b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx @@ -36,15 +36,9 @@ const mockConfig = { getKitStore?: () => unknown }>, } -let mockChainId = mainnet.id -const mockChains = [mainnet, optimism] -const mockAccount = { address: WALLET as `0x${string}` | undefined } vi.mock('wagmi', () => ({ useConfig: () => mockConfig, - useAccount: () => mockAccount, - useChainId: () => mockChainId, - useChains: () => mockChains, })) function makeWrapper() { @@ -56,11 +50,15 @@ function makeWrapper() { ) } +const baseParams = { + owner: WALLET, + destChain: mainnet, + srcTokens: [{ tokenType: 'USDC' as const, chain: optimism }], +} + beforeEach(() => { mockCreateSmartRoutingAddress.mockReset() mockCreateCall.mockClear() - mockChainId = mainnet.id - mockAccount.address = WALLET mockConfig.connectors = [mockConnector] mockConnector.getKitStore.mockReset() }) @@ -74,7 +72,7 @@ describe('useSmartRoutingAddress', () => { mockConfig.connectors = [] const wrapper = makeWrapper() expect(() => - renderHook(() => useSmartRoutingAddress(), { wrapper }), + renderHook(() => useSmartRoutingAddress(baseParams), { wrapper }), ).toThrow( /useSmartRoutingAddress must be used with zeroDevWallet connector/, ) @@ -85,13 +83,7 @@ describe('useSmartRoutingAddress', () => { mockConnector.getKitStore.mockReturnValue(store) const wrapper = makeWrapper() - renderHook( - () => - useSmartRoutingAddress({ - srcTokens: [{ tokenType: 'ERC20', chain: optimism }], - }), - { wrapper }, - ) + renderHook(() => useSmartRoutingAddress(baseParams), { wrapper }) expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() }) @@ -102,43 +94,31 @@ describe('useSmartRoutingAddress', () => { mockConnector.getKitStore.mockReturnValue(store) const wrapper = makeWrapper() - renderHook( - () => - useSmartRoutingAddress({ - srcTokens: [{ tokenType: 'ERC20', chain: optimism }], - }), - { wrapper }, - ) - - expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() - }) - - it('does not fire when srcTokens is empty', () => { - const store = createStore() - store.getState().smartRouting.initialize({}) - mockConnector.getKitStore.mockReturnValue(store) - - const wrapper = makeWrapper() - renderHook(() => useSmartRoutingAddress({ srcTokens: [] }), { wrapper }) + renderHook(() => useSmartRoutingAddress(baseParams), { wrapper }) expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() }) - it('computes the SRA with connector defaults (owner, destChain, slippage, actions)', async () => { + it('forwards inputs verbatim and applies the default slippage + actions', async () => { mockCreateSmartRoutingAddress.mockResolvedValue({ smartRoutingAddress: '0xSRA', estimatedFees: [], }) const store = createStore() - store.getState().smartRouting.initialize({}) // no overrides; defaults take over + store.getState().smartRouting.initialize({}) mockConnector.getKitStore.mockReturnValue(store) const wrapper = makeWrapper() const { result } = renderHook( () => useSmartRoutingAddress({ - srcTokens: [{ tokenType: 'ERC20', chain: optimism }], + owner: WALLET, + destChain: mainnet, + srcTokens: [ + { tokenType: 'NATIVE', chain: optimism }, + { tokenType: 'USDC', chain: optimism }, + ], }), { wrapper }, ) @@ -149,63 +129,38 @@ describe('useSmartRoutingAddress', () => { const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] expect(args.owner).toBe(WALLET) - expect(args.destChain.id).toBe(mainnet.id) // matches useChainId default - expect(args.slippage).toBe(100) // default 1% + expect(args.destChain.id).toBe(mainnet.id) + expect(args.slippage).toBe(100) // hook default expect(args.actions).toBeDefined() + // Actions are built per unique src tokenType. expect(args.actions.NATIVE).toBeDefined() - expect(args.actions.ERC20).toBeDefined() + expect(args.actions.USDC).toBeDefined() + expect(args.actions.ERC20).toBeUndefined() }) - it('uses the first destinationChains entry from connector config as destChain', async () => { + it('honors explicit slippage and actions overrides', async () => { mockCreateSmartRoutingAddress.mockResolvedValue({ smartRoutingAddress: '0xSRA', estimatedFees: [], }) const store = createStore() - store - .getState() - .smartRouting.initialize({ destinationChains: [optimism, mainnet] }) + store.getState().smartRouting.initialize({}) mockConnector.getKitStore.mockReturnValue(store) - const wrapper = makeWrapper() - renderHook( - () => - useSmartRoutingAddress({ - srcTokens: [{ tokenType: 'ERC20', chain: mainnet }], - }), - { wrapper }, - ) - - await waitFor(() => { - expect(mockCreateSmartRoutingAddress).toHaveBeenCalledTimes(1) - }) - const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] - expect(args.destChain.id).toBe(optimism.id) - }) - - it('per-call overrides win over connector config defaults', async () => { - mockCreateSmartRoutingAddress.mockResolvedValue({ - smartRoutingAddress: '0xSRA', - estimatedFees: [], - }) - - const store = createStore() - store.getState().smartRouting.initialize({ - maxSlippage: 50, - destinationChains: [mainnet], - }) - mockConnector.getKitStore.mockReturnValue(store) + const customActions = { + NATIVE: { action: [{ __custom: true }], fallBack: [] }, + } as never const wrapper = makeWrapper() - const customOwner = '0x2222222222222222222222222222222222222222' as const renderHook( () => useSmartRoutingAddress({ - owner: customOwner, + owner: WALLET, destChain: optimism, - maxSlippage: 200, srcTokens: [{ tokenType: 'NATIVE', chain: mainnet }], + slippage: 200, + actions: customActions, }), { wrapper }, ) @@ -214,27 +169,8 @@ describe('useSmartRoutingAddress', () => { expect(mockCreateSmartRoutingAddress).toHaveBeenCalledTimes(1) }) const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] - expect(args.owner).toBe(customOwner) expect(args.destChain.id).toBe(optimism.id) expect(args.slippage).toBe(200) - }) - - it('does not fire when no wallet is connected', () => { - mockAccount.address = undefined - - const store = createStore() - store.getState().smartRouting.initialize({}) - mockConnector.getKitStore.mockReturnValue(store) - - const wrapper = makeWrapper() - renderHook( - () => - useSmartRoutingAddress({ - srcTokens: [{ tokenType: 'ERC20', chain: optimism }], - }), - { wrapper }, - ) - - expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() + expect(args.actions).toBe(customActions) }) }) From 54938f6fd1c76a5e60788671d1d93d1f052b8ce8 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 07:54:00 +0400 Subject: [PATCH 05/14] fix export --- packages/react-kit/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-kit/src/index.ts b/packages/react-kit/src/index.ts index 099e2f11..9997e9b3 100644 --- a/packages/react-kit/src/index.ts +++ b/packages/react-kit/src/index.ts @@ -32,7 +32,7 @@ export type { // Smart Routing Address export type { SmartRoutingAddressConfig, - SmartRoutingAddressOverrides, + UseSmartRoutingAddressParams, } from './smart-routing/types.js' export { useSmartRoutingAddress } from './smart-routing/useSmartRoutingAddress.js' From 5d572e19431a34a9f3371ef6d9b84e9189ca6ad1 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 08:04:45 +0400 Subject: [PATCH 06/14] update test --- .../useSmartRoutingAddress.test.tsx | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx index c32cb79c..c4e5d82d 100644 --- a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx +++ b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx @@ -11,18 +11,20 @@ import { createStore } from '../store' import { useSmartRoutingAddress } from './useSmartRoutingAddress' const mockCreateSmartRoutingAddress = vi.fn() -const mockCreateCall = vi.fn((args) => ({ __call: args })) - -vi.mock('@zerodev/smart-routing-address', () => ({ - createSmartRoutingAddress: (params: unknown) => - mockCreateSmartRoutingAddress(params), - createCall: (args: unknown) => mockCreateCall(args), - FLEX: { - TOKEN_ADDRESS: 'TOKEN_ADDRESS', - AMOUNT: 'AMOUNT', - NATIVE_AMOUNT: 'NATIVE_AMOUNT', - }, -})) + +// Mock only the network-touching call. `createCall` and `FLEX` are pure +// helpers/constants — using the real ones keeps the test honest, otherwise +// we'd be asserting against our own mock's behavior. +vi.mock('@zerodev/smart-routing-address', async () => { + const actual = await vi.importActual< + typeof import('@zerodev/smart-routing-address') + >('@zerodev/smart-routing-address') + return { + ...actual, + createSmartRoutingAddress: (params: unknown) => + mockCreateSmartRoutingAddress(params), + } +}) const WALLET = '0x1111111111111111111111111111111111111111' as const @@ -58,7 +60,6 @@ const baseParams = { beforeEach(() => { mockCreateSmartRoutingAddress.mockReset() - mockCreateCall.mockClear() mockConfig.connectors = [mockConnector] mockConnector.getKitStore.mockReset() }) From ea6604c9e367d9756cf6babd07fbd39ea8224031 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Mon, 8 Jun 2026 11:57:21 +0400 Subject: [PATCH 07/14] feat: AddressDisplay --- .../AddressDisplay/AddressDisplay.stories.tsx | 27 ++++++++++++ .../AddressDisplay/AddressDisplay.test.tsx | 25 +++++++++++ .../components/AddressDisplay/index.tsx | 41 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx create mode 100644 packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx create mode 100644 packages/react-kit/src/shared/components/AddressDisplay/index.tsx diff --git a/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx b/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx new file mode 100644 index 00000000..a5e1a6e1 --- /dev/null +++ b/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { AddressDisplay } from '.' + +const meta = { + title: 'Shared/AddressDisplay', + component: AddressDisplay, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + address: '0x8f527b33CD7c791aEDe7EbA077140D81A0000001', + onQrClick: () => alert('Show QR'), + }, +} diff --git a/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx b/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx new file mode 100644 index 00000000..84fc481b --- /dev/null +++ b/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx @@ -0,0 +1,25 @@ +/** + * @vitest-environment happy-dom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { AddressDisplay } from '.' + +afterEach(() => cleanup()) + +const ADDRESS = '0x8f527b33CD7c791aEDe7EbA077140D81A0000001' + +describe('AddressDisplay', () => { + it('renders the address', () => { + render( {}} />) + expect(screen.getByText(ADDRESS)).toBeDefined() + }) + + it('renders the QR button and calls onQrClick when pressed', () => { + const onQrClick = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /qr code/i })) + expect(onQrClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-kit/src/shared/components/AddressDisplay/index.tsx b/packages/react-kit/src/shared/components/AddressDisplay/index.tsx new file mode 100644 index 00000000..4b33d6f8 --- /dev/null +++ b/packages/react-kit/src/shared/components/AddressDisplay/index.tsx @@ -0,0 +1,41 @@ +import type { HTMLAttributes } from 'react' + +import { cn } from '../../utils/common' +import { Icon } from '../Icon' +import { Text } from '../Text' +import { Wrapper } from '../Wrapper' + +export interface AddressDisplayProps extends HTMLAttributes { + /** The address (or other long identifier) to display. Wraps to the next line if it overflows. */ + address: string + /** Handler for the trailing QR icon button. */ + onQrClick: () => void + className?: string +} + +export function AddressDisplay({ + address, + onQrClick, + className, + ...rest +}: AddressDisplayProps) { + return ( + + {address} + + + ) +} From 44022791365252aed4a0ff63677cee02a2cfaaa9 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Mon, 8 Jun 2026 13:47:23 +0400 Subject: [PATCH 08/14] feat: SmartRoutingAddress component # Conflicts: # packages/react-kit/src/smart-routing/hooks/useSmartRoutingAddress.ts # Conflicts: # packages/react-kit/src/smart-routing/hooks/useSmartRoutingAddress.test.tsx --- .../AddressDisplay/AddressDisplay.stories.tsx | 27 --- .../AddressDisplay/AddressDisplay.test.tsx | 25 --- .../components/AddressDisplay/index.tsx | 41 ---- .../smart-routing/hooks/useSmartRouting.ts | 32 ++++ .../useSmartRoutingAddress.test.tsx | 177 ------------------ .../smart-routing/useSmartRoutingAddress.ts | 110 ----------- 6 files changed, 32 insertions(+), 380 deletions(-) delete mode 100644 packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx delete mode 100644 packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx delete mode 100644 packages/react-kit/src/shared/components/AddressDisplay/index.tsx create mode 100644 packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts delete mode 100644 packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx delete mode 100644 packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts diff --git a/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx b/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx deleted file mode 100644 index a5e1a6e1..00000000 --- a/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' - -import { AddressDisplay } from '.' - -const meta = { - title: 'Shared/AddressDisplay', - component: AddressDisplay, - parameters: { layout: 'centered' }, - tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - address: '0x8f527b33CD7c791aEDe7EbA077140D81A0000001', - onQrClick: () => alert('Show QR'), - }, -} diff --git a/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx b/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx deleted file mode 100644 index 84fc481b..00000000 --- a/packages/react-kit/src/shared/components/AddressDisplay/AddressDisplay.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { AddressDisplay } from '.' - -afterEach(() => cleanup()) - -const ADDRESS = '0x8f527b33CD7c791aEDe7EbA077140D81A0000001' - -describe('AddressDisplay', () => { - it('renders the address', () => { - render( {}} />) - expect(screen.getByText(ADDRESS)).toBeDefined() - }) - - it('renders the QR button and calls onQrClick when pressed', () => { - const onQrClick = vi.fn() - render() - fireEvent.click(screen.getByRole('button', { name: /qr code/i })) - expect(onQrClick).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/react-kit/src/shared/components/AddressDisplay/index.tsx b/packages/react-kit/src/shared/components/AddressDisplay/index.tsx deleted file mode 100644 index 4b33d6f8..00000000 --- a/packages/react-kit/src/shared/components/AddressDisplay/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { HTMLAttributes } from 'react' - -import { cn } from '../../utils/common' -import { Icon } from '../Icon' -import { Text } from '../Text' -import { Wrapper } from '../Wrapper' - -export interface AddressDisplayProps extends HTMLAttributes { - /** The address (or other long identifier) to display. Wraps to the next line if it overflows. */ - address: string - /** Handler for the trailing QR icon button. */ - onQrClick: () => void - className?: string -} - -export function AddressDisplay({ - address, - onQrClick, - className, - ...rest -}: AddressDisplayProps) { - return ( - - {address} - - - ) -} diff --git a/packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts b/packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts new file mode 100644 index 00000000..72e07980 --- /dev/null +++ b/packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts @@ -0,0 +1,32 @@ +import { useConfig } from 'wagmi' +import { useStore } from 'zustand' +import type { createStore } from '../../store' + +type Store = ReturnType + +function getKitStore(config: ReturnType): Store | null { + const connector = config.connectors.find((c) => c.id === 'zerodev-wallet') + if (!connector || !('getKitStore' in connector)) return null + // @ts-expect-error - getKitStore is a custom method on the kit connector + return connector.getKitStore() +} + +export function useSmartRouting() { + const config = useConfig() + const store = getKitStore(config) + + if (!store) { + throw new Error('useSmartRouting must be used with zeroDevWallet connector') + } + + const step = useStore(store, (state) => state.smartRouting.step) + const stepHistory = useStore(store, (state) => state.smartRouting.stepHistory) + + return { + step, + goToStep: store.getState().smartRouting.goToStep, + goBack: + stepHistory.length > 0 ? store.getState().smartRouting.goBack : null, + reset: store.getState().smartRouting.reset, + } +} diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx deleted file mode 100644 index c4e5d82d..00000000 --- a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, renderHook, waitFor } from '@testing-library/react' -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 { useSmartRoutingAddress } from './useSmartRoutingAddress' - -const mockCreateSmartRoutingAddress = vi.fn() - -// Mock only the network-touching call. `createCall` and `FLEX` are pure -// helpers/constants — using the real ones keeps the test honest, otherwise -// we'd be asserting against our own mock's behavior. -vi.mock('@zerodev/smart-routing-address', async () => { - const actual = await vi.importActual< - typeof import('@zerodev/smart-routing-address') - >('@zerodev/smart-routing-address') - return { - ...actual, - createSmartRoutingAddress: (params: unknown) => - mockCreateSmartRoutingAddress(params), - } -}) - -const WALLET = '0x1111111111111111111111111111111111111111' as const - -const mockConnector = { - id: 'zerodev-wallet', - getKitStore: vi.fn(), -} -const mockConfig = { - connectors: [mockConnector] as Array<{ - id: string - getKitStore?: () => unknown - }>, -} - -vi.mock('wagmi', () => ({ - useConfig: () => mockConfig, -})) - -function makeWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - return ({ children }: { children: ReactNode }) => ( - {children} - ) -} - -const baseParams = { - owner: WALLET, - destChain: mainnet, - srcTokens: [{ tokenType: 'USDC' as const, chain: optimism }], -} - -beforeEach(() => { - mockCreateSmartRoutingAddress.mockReset() - mockConfig.connectors = [mockConnector] - mockConnector.getKitStore.mockReset() -}) - -afterEach(() => { - cleanup() -}) - -describe('useSmartRoutingAddress', () => { - it('throws when used outside the kit connector', () => { - mockConfig.connectors = [] - const wrapper = makeWrapper() - expect(() => - renderHook(() => useSmartRoutingAddress(baseParams), { wrapper }), - ).toThrow( - /useSmartRoutingAddress must be used with zeroDevWallet connector/, - ) - }) - - it('does not fire the query when no SRA config is set', () => { - const store = createStore() - mockConnector.getKitStore.mockReturnValue(store) - - const wrapper = makeWrapper() - renderHook(() => useSmartRoutingAddress(baseParams), { wrapper }) - - expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() - }) - - it('does not fire when explicitly disabled', () => { - const store = createStore() - store.getState().smartRouting.initialize({ enabled: false }) - mockConnector.getKitStore.mockReturnValue(store) - - const wrapper = makeWrapper() - renderHook(() => useSmartRoutingAddress(baseParams), { wrapper }) - - expect(mockCreateSmartRoutingAddress).not.toHaveBeenCalled() - }) - - it('forwards inputs verbatim and applies the default slippage + actions', async () => { - mockCreateSmartRoutingAddress.mockResolvedValue({ - smartRoutingAddress: '0xSRA', - estimatedFees: [], - }) - - const store = createStore() - store.getState().smartRouting.initialize({}) - mockConnector.getKitStore.mockReturnValue(store) - - const wrapper = makeWrapper() - const { result } = renderHook( - () => - useSmartRoutingAddress({ - owner: WALLET, - destChain: mainnet, - srcTokens: [ - { tokenType: 'NATIVE', chain: optimism }, - { tokenType: 'USDC', chain: optimism }, - ], - }), - { wrapper }, - ) - - await waitFor(() => { - expect(result.current.data?.smartRoutingAddress).toBe('0xSRA') - }) - - const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] - expect(args.owner).toBe(WALLET) - expect(args.destChain.id).toBe(mainnet.id) - expect(args.slippage).toBe(100) // hook default - expect(args.actions).toBeDefined() - // Actions are built per unique src tokenType. - expect(args.actions.NATIVE).toBeDefined() - expect(args.actions.USDC).toBeDefined() - expect(args.actions.ERC20).toBeUndefined() - }) - - it('honors explicit slippage and actions overrides', async () => { - mockCreateSmartRoutingAddress.mockResolvedValue({ - smartRoutingAddress: '0xSRA', - estimatedFees: [], - }) - - const store = createStore() - store.getState().smartRouting.initialize({}) - mockConnector.getKitStore.mockReturnValue(store) - - const customActions = { - NATIVE: { action: [{ __custom: true }], fallBack: [] }, - } as never - - const wrapper = makeWrapper() - renderHook( - () => - useSmartRoutingAddress({ - owner: WALLET, - destChain: optimism, - srcTokens: [{ tokenType: 'NATIVE', chain: mainnet }], - slippage: 200, - actions: customActions, - }), - { wrapper }, - ) - - await waitFor(() => { - expect(mockCreateSmartRoutingAddress).toHaveBeenCalledTimes(1) - }) - const args = mockCreateSmartRoutingAddress.mock.calls[0]?.[0] - expect(args.destChain.id).toBe(optimism.id) - expect(args.slippage).toBe(200) - expect(args.actions).toBe(customActions) - }) -}) diff --git a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts b/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts deleted file mode 100644 index a9e43735..00000000 --- a/packages/react-kit/src/smart-routing/useSmartRoutingAddress.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { - type CreateSmartRoutingAddressParams, - createCall, - createSmartRoutingAddress, - FLEX, -} from '@zerodev/smart-routing-address' -import { type Address, erc20Abi } from 'viem' -import { useConfig } from 'wagmi' -import { useStore } from 'zustand' -import type { createStore } from '../store' -import type { UseSmartRoutingAddressParams } from './types' - -type Store = ReturnType - -const DEFAULT_SLIPPAGE_BPS = 100 // 1% - -function getKitStore(config: ReturnType): Store | null { - const connector = config.connectors.find((c) => c.id === 'zerodev-wallet') - if (!connector || !('getKitStore' in connector)) return null - // @ts-expect-error - getKitStore is a custom method on the kit connector - return connector.getKitStore() -} - -function buildDefaultActions( - owner: Address, - srcTokens: UseSmartRoutingAddressParams['srcTokens'], -): CreateSmartRoutingAddressParams['actions'] { - const nativeCall = createCall({ - target: owner, - value: FLEX.NATIVE_AMOUNT, - }) - const erc20Call = createCall({ - target: FLEX.TOKEN_ADDRESS, - value: 0n, - abi: erc20Abi, - functionName: 'transfer', - args: [owner, FLEX.AMOUNT], - }) - // Build one action per unique src tokenType. Across needs to know the - // concrete token on the destination chain; using a generic "ERC20" key - // would resolve to a FLEX placeholder (0xff…ff) and fail simulation. - const actions: CreateSmartRoutingAddressParams['actions'] = {} - const seen = new Set() - for (const { tokenType } of srcTokens) { - if (seen.has(tokenType)) continue - seen.add(tokenType) - actions[tokenType] = - tokenType === 'NATIVE' - ? { action: [nativeCall], fallBack: [] } - : { action: [erc20Call], fallBack: [] } - } - return actions -} - -/** - * Computes a Smart Routing Address. Returns a React Query result. - * - * The query is disabled when the SRA feature flag is off - * (`config.smartRoutingAddress.enabled === false`). - */ -export function useSmartRoutingAddress({ - owner, - destChain, - srcTokens, - slippage = DEFAULT_SLIPPAGE_BPS, - actions, -}: UseSmartRoutingAddressParams) { - const wagmiConfig = useConfig() - const store = getKitStore(wagmiConfig) - if (!store) { - throw new Error( - 'useSmartRoutingAddress must be used with zeroDevWallet connector', - ) - } - - const config = useStore(store, (state) => state.smartRouting.config) - const enabled = !!config && config.enabled !== false - - const resolvedActions = actions ?? buildDefaultActions(owner, srcTokens) - - return useQuery({ - queryKey: [ - 'zerodev-wallet-kit', - 'smartRoutingAddress', - owner, - destChain.id, - slippage, - srcTokens.map((t) => [t.tokenType, t.chain.id, t.minAmount?.toString()]), - ], - enabled, - queryFn: async () => { - const result = await createSmartRoutingAddress({ - owner, - destChain, - slippage, - srcTokens, - actions: resolvedActions, - }) - // The SDK currently swallows JSON-RPC error bodies (200 OK with `error` - // instead of `result`) and returns `undefined`. React Query forbids - // undefined data, so promote it to a proper error. - if (!result) { - throw new Error('Smart routing address service returned no result') - } - return result - }, - meta: { persist: false }, - }) -} From e82a7e0f1c753c790ca4df2a51630fa050e6d4ec Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Tue, 9 Jun 2026 08:57:49 +0400 Subject: [PATCH 09/14] refactor --- .../smart-routing/hooks/useSmartRouting.ts | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts diff --git a/packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts b/packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts deleted file mode 100644 index 72e07980..00000000 --- a/packages/react-kit/src/smart-routing/hooks/useSmartRouting.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useConfig } from 'wagmi' -import { useStore } from 'zustand' -import type { createStore } from '../../store' - -type Store = ReturnType - -function getKitStore(config: ReturnType): Store | null { - const connector = config.connectors.find((c) => c.id === 'zerodev-wallet') - if (!connector || !('getKitStore' in connector)) return null - // @ts-expect-error - getKitStore is a custom method on the kit connector - return connector.getKitStore() -} - -export function useSmartRouting() { - const config = useConfig() - const store = getKitStore(config) - - if (!store) { - throw new Error('useSmartRouting must be used with zeroDevWallet connector') - } - - const step = useStore(store, (state) => state.smartRouting.step) - const stepHistory = useStore(store, (state) => state.smartRouting.stepHistory) - - return { - step, - goToStep: store.getState().smartRouting.goToStep, - goBack: - stepHistory.length > 0 ? store.getState().smartRouting.goBack : null, - reset: store.getState().smartRouting.reset, - } -} From 26199e581fefaba1dd23f0660eed3d696439bc52 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Tue, 9 Jun 2026 13:14:37 +0400 Subject: [PATCH 10/14] feat: qr depost address --- CLAUDE.local.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 CLAUDE.local.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 00000000..ff56ffde --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,121 @@ +# Claude — local working notes + +Personal context for Claude Code in this repo. Gitignored (`*.local` rule). + +## Repo layout + +pnpm workspace monorepo. + +- `packages/` + - `wallet-core` — low-level SDK + - `react` (`@zerodev/wallet-react`) — wagmi connector + provider + hooks + - `react-kit` (`@zerodev/wallet-react-kit`) — UI components + enhanced connector +- `apps/zerodev-signer-demo` — Next.js demo app + +`apps/*` depends on `packages/*` via `workspace:*` and reads from each package's `dist/`. Source changes in a package need a rebuild before the demo picks them up. + +## Common commands + +Run from repo root unless noted. + +```bash +# Build packages (must run after package source changes) +pnpm --filter @zerodev/wallet-react build +pnpm --filter @zerodev/wallet-react-kit build + +# Dev (demo with watch-rebuild of react-kit) +pnpm --filter @zerodev/signer-demo hot-dev + +# Plain dev (no kit watch) +pnpm --filter @zerodev/signer-demo dev + +# Prod-mode test of the demo +pnpm --filter @zerodev/signer-demo build +pnpm --filter @zerodev/signer-demo start + +# Tests — run from REPO ROOT, not from the package dir +npx vitest run packages/react-kit +npx vitest run packages/react-kit/src/smart-routing + +# Typecheck a package +cd packages/react-kit && npx tsc --noEmit -p tsconfig.build.json +``` + +## Lint / style gotchas (biome) + +- `no-console` is enforced — don't ship `console.log`/`console.error` in package code. Pre-commit hook fails on violations. +- `exactOptionalPropertyTypes: true` is on: + - Helper-component props need `className?: string | undefined`. + - Pass conditional optional props with spreads: `{...(value && { prop: value })}`. +- Test files containing JSX must be `.tsx`, not `.ts`. + +## React-kit conventions + +### Flow components (`AuthFlow`, `SmartRoutingAddress`) + +- Entry component owns `ScreenWrapper` + `TopNav` and dispatches the current `step` to a content page via a `renderStep()` switch. +- Pages are *pure content* — no ScreenWrapper, no TopNav, just a child div. +- State machine lives in a zustand slice (`authStoreSlice`, `smartRoutingStoreSlice`) — `step`, `stepHistory`, `goToStep`, `goBack`, `reset`. +- Internal navigation hook (`useAuth`, `useSmartRoutingFlow`) exposes the slice to the entry component. `useSmartRoutingFlow` is not in the public package exports. + +### Layout pattern (signing/SRA pages) + +`ScreenWrapper`'s scroll container has `padding-top: 72px` — this *reserves space* for the absolutely-positioned TopNav (top:20, height:52, ends at y=72). It does **not** add visible gap below the nav. Pages must add their own: + +```tsx +
{/* fills ScreenWrapper */} +
{/* scrolls */} +
{/* wrap cards in ONE child */} + {/* cards */} +
+
+
{/* sticky footer (e.g. "Got it") */}
+
+``` + +The inner cards must be wrapped in a single flex-col child of the scroll — putting them as direct flex children of the `overflow-y-auto` div causes flex-shrink to clip tall content (e.g. `DetailsContainer` with many `DataRow`s). + +### Wrapper variants + +`` → `rgba(247, 245, 240, alpha)`: +- `ghost` = 0.2 +- `soft` = 0.4 (default) +- `solid` = 0.8 + +### Icon usage in `` / token rows - -- Prefer `iconName` (kit SVG asset) over `leadingImage` (URL). -- For URL images: the leading slot is a `size-11` (44px) white box; size the `` to `size-6 object-contain` to match icon visual weight (don't use `size-full`). - -### Connector (`zeroDevWallet`) auth flow - -- Optimistic try-connect: `connect()` first attempts `base.connect()`. If it throws `NotAuthenticatedError` (typed class exported from `@zerodev/wallet-react`), the kit pops `SignUp` and waits for the user to authenticate, then retries. -- Never `await connector.getStore()` in `connect()` — base `isAuthorized()` deliberately skips init for perf (see `packages/react/src/core/connector.ts:401`). Pre-flighting init causes a SignUp flash on first paint of production builds. -- `isReconnecting` (set by wagmi on `reconnectOnMount`) silences the SignUp popup — silent reconnect failures must rethrow. -- All "Not authenticated" throws in core/provider use `NotAuthenticatedError` so the kit can `instanceof` check rather than substring-match. - -## Smart Routing Address (SRA) — gotchas - -- **Mainnet-only API.** Despite the SDK's `TOKEN_ADDRESSES` table listing testnets, the SRA manager rejects testnet `executionChainId` with `Execution chain X is not supported by this SRA manager version`. Supported IDs: `1, 42161, 8453, 81457, 56, 999, 57073, 59144, 34443, 143, 10, 137, 9745, 534352, 1868, 4217, 130, 480, 7777777`. -- **Demo uses testnet wallet + mainnet SRA.** SRA only needs the owner *address* — it doesn't transact on the owner's chain — so the demo configures `chains: [arbitrumSepolia, sepolia]` for the wallet but `smartRoutingAddress.destinationChains: [arbitrum]` / `sourceChains: [mainnet, arbitrum]`. -- **FLEX placeholder causes Across simulation revert.** Using generic `tokenType: 'ERC20'` makes the SDK resolve both src and dest tokens to `0xff…ff` — Across can't quote a route. Use a specific token type (`'USDC'`, `'USDT'`, etc.) that exists in the SDK's `TOKEN_ADDRESSES` table for both chains. -- **SDK swallows JSON-RPC error bodies.** If the API responds 200 OK with `{error: ...}` instead of `{result: ...}`, the SDK returns `undefined` instead of throwing. The hook converts this to a thrown error so React Query surfaces it. - -### `useSmartRoutingAddress` hook shape - -```ts -useSmartRoutingAddress({ - owner: Address, // required — usually useAccount().address - destChain: Chain, // required — explicit, no fallback - srcTokens: SrcToken[], // required - slippage?: number, // default 100 (1%) - actions?: ..., // default: NATIVE + ERC20 deposit-to-owner per unique srcToken tokenType -}) -``` - -`SmartRoutingAddressConfig` (connector-level) is just `enabled` + `destinationChains`/`sourceChains` as **UI metadata** for the consumer's selectors — the hook doesn't read them as defaults. - -## Useful paths - -- Memory dir: `/Users/omar/.claude/projects/-Users-omar-dev-zerodev-wallet-sdk/memory/` -- Reference impl (RN/Expo): `/Users/omar/dev/temp/doorway-frontend/` — original SRA UI being ported From 0ffc24229fd9f0e27fd816bd768eab4763af4867 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 12:17:34 +0400 Subject: [PATCH 12/14] feat: network list --- .../hooks/useSmartRoutingAddress.test.tsx | 2 +- .../hooks/useSmartRoutingFlow.ts | 2 +- .../react-kit/src/smart-routing/index.tsx | 27 ++++- .../src/smart-routing/pages/SelectNetwork.tsx | 113 ++++++++++++++++++ .../pages/TransferFromWallet.tsx | 4 + packages/react-kit/src/smart-routing/types.ts | 2 +- 6 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 packages/react-kit/src/smart-routing/pages/SelectNetwork.tsx 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..53284804 100644 --- a/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts +++ b/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts @@ -28,7 +28,7 @@ export function useSmartRoutingFlow() { step, goToStep: store.getState().smartRouting.goToStep, goBack: - stepHistory.length > 0 ? store.getState().smartRouting.goBack : null, + stepHistory.length > 1 ? store.getState().smartRouting.goBack : null, 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 ? ( ([ + 1, + 11155111, // mainnet, sepolia + 42161, + 421614, // arbitrum, arbitrumSepolia + 10, + 11155420, // optimism, optimismSepolia + 8453, + 84532, // base, baseSepolia + 137, + 80002, // polygon, polygonAmoy + 130, // unichain + 59144, // linea + 534352, + 534351, // scroll, scrollSepolia + 81457, + 168587773, // blast, blastSepolia + 7777777, // zora + 480, // worldchain +]) + +const CHAIN_ICON_BY_ID: Record = { + 1: 'ethereum', + 11155111: 'ethereum', + 42161: 'arbitrum', + 421614: 'arbitrum', +} + +interface NetworkRowChain { + id: number + name: string +} + +function NetworkRow({ chain }: { chain: NetworkRowChain }) { + const { goBack } = useSmartRoutingFlow() + const iconName = CHAIN_ICON_BY_ID[chain.id] + return ( + goBack?.()} + /> + ) +} + +function Section({ + title, + chains, +}: { + title: string + chains: NetworkRowChain[] +}) { + if (chains.length === 0) return null + return ( +
+ {title} +
+ {chains.map((chain) => ( + + ))} +
+
+ ) +} + +export function SelectNetwork() { + const chains = useChains() + const [query, setQuery] = useState('') + + const filtered = query + ? chains.filter((c) => + c.name.toLowerCase().includes(query.trim().toLowerCase()), + ) + : chains + + const ethereumEcosystem = filtered.filter((c) => + ETHEREUM_ECOSYSTEM_CHAIN_IDS.has(c.id), + ) + const otherChains = filtered.filter( + (c) => !ETHEREUM_ECOSYSTEM_CHAIN_IDS.has(c.id), + ) + + return ( +
+
+ setQuery(e.target.value)} + /> + +
+
+
+
+ ) +} diff --git a/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx b/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx index ea7a0549..d616a83f 100644 --- a/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx +++ b/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx @@ -16,11 +16,13 @@ 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 @@ -78,6 +80,7 @@ export function TransferFromWallet({ label="Arbitrum" iconName="arbitrum" className="flex-1 basis-0" + onClick={onSelectNetwork} /> @@ -95,6 +98,7 @@ export function TransferFromWallet({ trailingIcon={false} variant="ghost" className="flex-1 basis-0" + onClick={onSelectNetwork} /> 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. From 80734d1967d7d05abbfca6e3dabbe1448e45e531 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 12:28:30 +0400 Subject: [PATCH 13/14] rebase --- packages/react-kit/src/index.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/react-kit/src/index.ts b/packages/react-kit/src/index.ts index 9997e9b3..f520e6e8 100644 --- a/packages/react-kit/src/index.ts +++ b/packages/react-kit/src/index.ts @@ -29,11 +29,4 @@ export type { UseSmartRoutingAddressParams, } from './smart-routing/types.js' -// Smart Routing Address -export type { - SmartRoutingAddressConfig, - UseSmartRoutingAddressParams, -} from './smart-routing/types.js' -export { useSmartRoutingAddress } from './smart-routing/useSmartRoutingAddress.js' - export type { PendingRequest, Request, RequestMethod } from './types.js' From 51ce4a14e4af15ad410a4b50bd651d4aa6dc741a Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 10 Jun 2026 13:01:41 +0400 Subject: [PATCH 14/14] update chain SRA --- .../src/app/wagmi-config.ts | 2 +- .../hooks/useSmartRoutingFlow.ts | 24 ++++ .../src/smart-routing/pages/SelectNetwork.tsx | 128 ++++++++---------- .../pages/TransferFromWallet.tsx | 35 +++-- .../smart-routing/smartRoutingStoreSlice.ts | 46 ++++++- .../src/smart-routing/utils/chainIcon.ts | 20 +++ 6 files changed, 169 insertions(+), 86 deletions(-) create mode 100644 packages/react-kit/src/smart-routing/utils/chainIcon.ts 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/useSmartRoutingFlow.ts b/packages/react-kit/src/smart-routing/hooks/useSmartRoutingFlow.ts index 53284804..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 > 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/pages/SelectNetwork.tsx b/packages/react-kit/src/smart-routing/pages/SelectNetwork.tsx index 2294b181..bd58f5e7 100644 --- a/packages/react-kit/src/smart-routing/pages/SelectNetwork.tsx +++ b/packages/react-kit/src/smart-routing/pages/SelectNetwork.tsx @@ -1,99 +1,78 @@ import { useState } from 'react' +import type { Chain } from 'viem' import { useChains } from 'wagmi' -import type { IconName } from '../../shared/components/Icon' import { Input } from '../../shared/components/Input' -import { Text } from '../../shared/components/Text' import { TokenListItem } from '../../shared/components/TokenListItem' import { useSmartRoutingFlow } from '../hooks/useSmartRoutingFlow' +import { getChainIcon } from '../utils/chainIcon' -// Loose grouping per the design's "Ethereum Ecosystem" / "Other chains" -// sections. Includes mainnets + corresponding testnets so the demo -// renders something under the first section. -const ETHEREUM_ECOSYSTEM_CHAIN_IDS = new Set([ - 1, - 11155111, // mainnet, sepolia - 42161, - 421614, // arbitrum, arbitrumSepolia - 10, - 11155420, // optimism, optimismSepolia - 8453, - 84532, // base, baseSepolia - 137, - 80002, // polygon, polygonAmoy - 130, // unichain - 59144, // linea - 534352, - 534351, // scroll, scrollSepolia - 81457, - 168587773, // blast, blastSepolia - 7777777, // zora - 480, // worldchain -]) - -const CHAIN_ICON_BY_ID: Record = { - 1: 'ethereum', - 11155111: 'ethereum', - 42161: 'arbitrum', - 421614: 'arbitrum', -} - -interface NetworkRowChain { - id: number - name: string -} - -function NetworkRow({ chain }: { chain: NetworkRowChain }) { - const { goBack } = useSmartRoutingFlow() - const iconName = CHAIN_ICON_BY_ID[chain.id] +function NetworkRow({ + chain, + onSelect, +}: { + chain: Chain + onSelect: (chain: Chain) => void +}) { + const iconName = getChainIcon(chain.id) return ( goBack?.()} + onClick={() => onSelect(chain)} /> ) } -function Section({ - title, - chains, -}: { - title: string - chains: NetworkRowChain[] -}) { - if (chains.length === 0) return null - return ( -
- {title} -
- {chains.map((chain) => ( - - ))} -
-
- ) -} - export function SelectNetwork() { - const chains = useChains() + 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 - const ethereumEcosystem = filtered.filter((c) => - ETHEREUM_ECOSYSTEM_CHAIN_IDS.has(c.id), - ) - const otherChains = filtered.filter( - (c) => !ETHEREUM_ECOSYSTEM_CHAIN_IDS.has(c.id), - ) + // 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 (
@@ -105,8 +84,11 @@ export function SelectNetwork() { onChange={(e) => 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 d616a83f..9000df76 100644 --- a/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx +++ b/packages/react-kit/src/smart-routing/pages/TransferFromWallet.tsx @@ -10,6 +10,8 @@ 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' @@ -25,13 +27,24 @@ export function TransferFromWallet({ 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 @@ -77,10 +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/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] +}