diff --git a/.env.test b/.env.test
new file mode 100644
index 00000000..f4bf82d2
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,7 @@
+# Test environment variables for Vitest
+PUBLIC_APP_NAME=dAppBooster Test
+PUBLIC_NATIVE_TOKEN_ADDRESS=0x0000000000000000000000000000000000000000
+PUBLIC_WALLETCONNECT_PROJECT_ID=test-project-id
+PUBLIC_SUBGRAPHS_API_KEY=test-api-key
+PUBLIC_SUBGRAPHS_CHAINS_RESOURCE_IDS=1:test:test-resource-id
+PUBLIC_SUBGRAPHS_ENVIRONMENT=production
diff --git a/.gitignore b/.gitignore
index be85071d..9203123b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,4 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
+.worktrees
diff --git a/setupTests.ts b/setupTests.ts
index a55b20e6..a9d13f77 100644
--- a/setupTests.ts
+++ b/setupTests.ts
@@ -4,6 +4,20 @@ import { afterEach, expect } from 'vitest'
expect.extend(matchers)
+// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers).
+// Use a real class rather than vi.fn() so vi.restoreAllMocks() in test files cannot clear it.
+if (typeof globalThis.ResizeObserver === 'undefined') {
+ class ResizeObserver {
+ // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment
+ observe(_target: any) {}
+ // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment
+ unobserve(_target: any) {}
+ disconnect() {}
+ }
+ // @ts-expect-error ResizeObserver is not in the Node/jsdom type definitions
+ globalThis.ResizeObserver = ResizeObserver
+}
+
afterEach(() => {
cleanup()
})
diff --git a/src/components/pageComponents/NotFound404.test.tsx b/src/components/pageComponents/NotFound404.test.tsx
new file mode 100644
index 00000000..6ce87039
--- /dev/null
+++ b/src/components/pageComponents/NotFound404.test.tsx
@@ -0,0 +1,22 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import NotFound404 from './NotFound404'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@tanstack/react-router', () => ({
+ useNavigate: vi.fn(() => vi.fn()),
+}))
+
+describe('NotFound404', () => {
+ it('renders 404 title and message', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('404 - Not Found')).toBeDefined()
+ expect(screen.getByRole('button', { name: 'Home' })).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx
new file mode 100644
index 00000000..ab37345f
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx
@@ -0,0 +1,17 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import connectWallet from './index'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/providers/Web3Provider', () => ({
+ ConnectWalletButton: () => ,
+}))
+
+describe('ConnectWallet demo', () => {
+ it('renders the connect wallet button', () => {
+ render({connectWallet.demo})
+ expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx
new file mode 100644
index 00000000..2463d875
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx
@@ -0,0 +1,20 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import ensName from './index'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('wagmi', () => ({
+ useEnsName: vi.fn(() => ({ data: undefined, error: undefined, status: 'pending' })),
+}))
+
+describe('EnsName demo', () => {
+ it('renders the ENS name search interface', () => {
+ render({ensName.demo})
+ expect(screen.getByText('Find ENS name')).toBeDefined()
+ expect(
+ screen.getByPlaceholderText('Enter an address or select one from the dropdown'),
+ ).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx
new file mode 100644
index 00000000..90fc9c0f
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx
@@ -0,0 +1,28 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import hashHandling from './index'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(() => ({
+ isWalletConnected: false,
+ walletChainId: undefined,
+ })),
+}))
+
+vi.mock('@/src/utils/hash', () => {
+ const mockFn = vi.fn(() => Promise.resolve(null))
+ return {
+ default: mockFn,
+ detectHash: mockFn,
+ }
+})
+
+describe('HashHandling demo', () => {
+ it('renders the hash input field', () => {
+ render({hashHandling.demo})
+ expect(screen.getByPlaceholderText(/address|hash/i)).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
new file mode 100644
index 00000000..d673002c
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
@@ -0,0 +1,27 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import signMessage from './index'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(() => ({
+ isWalletConnected: false,
+ isWalletSynced: false,
+ walletChainId: undefined,
+ appChainId: 11155420,
+ switchChain: vi.fn(),
+ })),
+}))
+
+vi.mock('@/src/providers/Web3Provider', () => ({
+ ConnectWalletButton: () => ,
+}))
+
+describe('SignMessage demo', () => {
+ it('renders connect wallet fallback when wallet not connected', () => {
+ render({signMessage.demo})
+ expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx
new file mode 100644
index 00000000..7b93bb7b
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx
@@ -0,0 +1,23 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import switchNetwork from './index'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(() => ({
+ isWalletConnected: false,
+ })),
+}))
+
+vi.mock('@/src/providers/Web3Provider', () => ({
+ ConnectWalletButton: () => ,
+}))
+
+describe('SwitchNetwork demo', () => {
+ it('renders connect wallet button when wallet not connected', () => {
+ render({switchNetwork.demo})
+ expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx
new file mode 100644
index 00000000..ade29b7c
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx
@@ -0,0 +1,20 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import tokenDropdown from './index'
+
+const system = createSystem(defaultConfig)
+
+// Mock the shared component to avoid its deep dependency chain
+// (TokenSelect uses withSuspenseAndRetry, useTokenLists, useTokens, etc.)
+vi.mock('@/src/components/sharedComponents/TokenDropdown', () => ({
+ default: () =>
Token Dropdown
,
+}))
+
+describe('TokenDropdown demo', () => {
+ it('renders the token dropdown container', () => {
+ render({tokenDropdown.demo})
+ expect(screen.getByText('Search and select a token')).toBeDefined()
+ expect(screen.getByTestId('token-dropdown-mock')).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
new file mode 100644
index 00000000..9e86b8b7
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
@@ -0,0 +1,49 @@
+import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import tokenInput from './index'
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(() => createMockWeb3Status()),
+}))
+
+vi.mock('@/src/hooks/useTokenLists', () => ({
+ useTokenLists: vi.fn(() => ({
+ tokens: [],
+ tokensByChainId: {},
+ tokensByAddress: {},
+ tokensBySymbol: {},
+ })),
+}))
+
+vi.mock('@/src/hooks/useTokenSearch', () => ({
+ useTokenSearch: vi.fn(() => ({
+ searchResult: [],
+ })),
+}))
+
+vi.mock('@/src/components/sharedComponents/TokenInput/useTokenInput', () => ({
+ useTokenInput: vi.fn(() => ({
+ amount: 0n,
+ setAmount: vi.fn(),
+ amountError: null,
+ setAmountError: vi.fn(),
+ balance: 0n,
+ balanceError: null,
+ isLoadingBalance: false,
+ selectedToken: undefined,
+ setTokenSelected: vi.fn(),
+ })),
+}))
+
+describe('TokenInput demo', () => {
+ it('renders the token input container', () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ renderWithProviders(
+ {tokenInput.demo},
+ )
+ // The mode dropdown should be visible
+ expect(screen.getByText('Single token')).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx
new file mode 100644
index 00000000..1d7d3be8
--- /dev/null
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx
@@ -0,0 +1,19 @@
+import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils'
+import { screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import transactionButton from './index'
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(() => createMockWeb3Status({ appChainId: 11155420 })),
+}))
+
+vi.mock('@/src/providers/Web3Provider', () => ({
+ ConnectWalletButton: () => ,
+}))
+
+describe('TransactionButton demo', () => {
+ it('renders connect wallet fallback when wallet not connected', () => {
+ renderWithProviders(transactionButton.demo)
+ expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined()
+ })
+})
diff --git a/src/components/pageComponents/home/index.test.tsx b/src/components/pageComponents/home/index.test.tsx
new file mode 100644
index 00000000..d498d8a1
--- /dev/null
+++ b/src/components/pageComponents/home/index.test.tsx
@@ -0,0 +1,21 @@
+import { renderWithProviders } from '@/src/test-utils'
+import { screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { Home } from './index'
+
+// Mock sub-components that pull in Web3 dependencies to keep this a pure structural test
+vi.mock('@/src/components/pageComponents/home/Examples', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/src/components/pageComponents/home/Welcome', () => ({
+ default: () => ,
+}))
+
+describe('Home', () => {
+ it('renders Welcome and Examples sections', () => {
+ renderWithProviders()
+ expect(screen.getByTestId('welcome')).toBeDefined()
+ expect(screen.getByTestId('examples')).toBeDefined()
+ })
+})
diff --git a/src/components/sharedComponents/BigNumberInput.test.tsx b/src/components/sharedComponents/BigNumberInput.test.tsx
index 7554dc5a..03588853 100644
--- a/src/components/sharedComponents/BigNumberInput.test.tsx
+++ b/src/components/sharedComponents/BigNumberInput.test.tsx
@@ -1,24 +1,126 @@
import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
import { render, screen } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
+import userEvent from '@testing-library/user-event'
+import type { ComponentProps } from 'react'
+import { maxUint256 } from 'viem'
+import { describe, expect, it, vi } from 'vitest'
import { BigNumberInput } from './BigNumberInput'
const system = createSystem(defaultConfig)
+function renderInput(
+ props: Partial> & {
+ onChange?: (v: bigint) => void
+ } = {},
+) {
+ const onChange = props.onChange ?? vi.fn()
+ const { container } = render(
+
+
+ ,
+ )
+ return { input: screen.getByRole('textbox') as HTMLInputElement, onChange, container }
+}
+
describe('BigNumberInput', () => {
it('renders without crashing', () => {
- render(
-
- {}}
- />
- ,
- )
-
- const input = screen.getByRole('textbox')
+ const { input } = renderInput()
expect(input).not.toBeNull()
expect(input.tagName).toBe('INPUT')
})
+
+ it('renders with default placeholder', () => {
+ const { input } = renderInput()
+ expect(input.placeholder).toBe('0.00')
+ })
+
+ it('renders with custom placeholder', () => {
+ const { input } = renderInput({ placeholder: 'Amount' })
+ expect(input.placeholder).toBe('Amount')
+ })
+
+ it('renders as disabled when disabled prop is set', () => {
+ const { input } = renderInput({ disabled: true })
+ expect(input.disabled).toBe(true)
+ })
+
+ it('calls onChange with BigInt(0) when input is cleared after typing', async () => {
+ const onChange = vi.fn()
+ renderInput({ decimals: 0, onChange })
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '5')
+ await userEvent.clear(input)
+ expect(onChange).toHaveBeenLastCalledWith(BigInt(0))
+ })
+
+ it('calls onChange with the parsed bigint when a valid value is typed', async () => {
+ const onChange = vi.fn()
+ renderInput({ decimals: 6, onChange })
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '1.5')
+ // 1.5 * 10^6 = 1500000
+ expect(onChange).toHaveBeenLastCalledWith(BigInt(1_500_000))
+ })
+
+ it('calls onError when value exceeds max', async () => {
+ const onChange = vi.fn()
+ const onError = vi.fn()
+ renderInput({
+ decimals: 0,
+ max: BigInt(100),
+ onChange,
+ onError,
+ })
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '200')
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '200' }))
+ })
+
+ it('calls onError when value is below min', async () => {
+ const onChange = vi.fn()
+ const onError = vi.fn()
+ renderInput({
+ decimals: 0,
+ min: BigInt(10),
+ onChange,
+ onError,
+ })
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '5')
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '5' }))
+ })
+
+ it('does not call onError for valid values', async () => {
+ const onError = vi.fn()
+ renderInput({ decimals: 0, max: BigInt(100), onError })
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '50')
+ // onError is only called on invalid values; valid value should not trigger it
+ expect(onError).not.toHaveBeenCalled()
+ })
+
+ it('ignores the extra digit when input has too many decimals', async () => {
+ const onChange = vi.fn()
+ renderInput({ decimals: 2, onChange })
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '1.123')
+ // '1.12' is valid (2 decimals), '1.123' has 3 and is ignored
+ // Last valid call should be for 1.12 = 112n (2 decimals)
+ const lastCall = onChange.mock.calls.at(-1)?.[0]
+ expect(lastCall).toBe(BigInt(112))
+ })
+
+ it('does not call onError when value is within max constraint', async () => {
+ const onError = vi.fn()
+ renderInput({ decimals: 0, max: maxUint256, onError })
+ // Large but valid number — well within maxUint256, so no error
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, '9999999')
+ expect(onError).not.toHaveBeenCalled()
+ })
})
diff --git a/src/components/sharedComponents/HashInput.test.tsx b/src/components/sharedComponents/HashInput.test.tsx
index 72f11389..34a8c3df 100644
--- a/src/components/sharedComponents/HashInput.test.tsx
+++ b/src/components/sharedComponents/HashInput.test.tsx
@@ -1,28 +1,118 @@
import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { ComponentProps } from 'react'
import { mainnet } from 'viem/chains'
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import HashInput from './HashInput'
const system = createSystem(defaultConfig)
+const detectHashMock = vi.fn().mockResolvedValue({ type: 'EOA', data: '0xabc' })
vi.mock('@/src/utils/hash', () => ({
- default: vi.fn().mockResolvedValue(null),
+ default: (...args: unknown[]) => detectHashMock(...args),
}))
+function renderHashInput(props: Partial> = {}) {
+ const onSearch = props.onSearch ?? vi.fn()
+ render(
+
+
+ ,
+ )
+ return { input: screen.getByTestId('hash-input') as HTMLInputElement, onSearch }
+}
+
describe('HashInput', () => {
+ beforeEach(() => {
+ detectHashMock.mockClear()
+ detectHashMock.mockResolvedValue({ type: 'EOA', data: '0xabc' })
+ })
+
it('renders without crashing', () => {
+ const { input } = renderHashInput()
+ expect(input).not.toBeNull()
+ expect(input.tagName).toBe('INPUT')
+ })
+
+ it('renders with placeholder prop', () => {
+ renderHashInput({ placeholder: 'Enter address or hash' })
+ expect(screen.getByPlaceholderText('Enter address or hash')).toBeDefined()
+ })
+
+ it('reflects typed value in the input', async () => {
+ const { input } = renderHashInput()
+ await userEvent.type(input, 'test.eth')
+ expect(input.value).toBe('test.eth')
+ })
+
+ it('renders with initial value', () => {
+ renderHashInput({ value: '0xInitial' })
+ const input = screen.getByTestId('hash-input') as HTMLInputElement
+ expect(input.value).toBe('0xInitial')
+ })
+
+ it('calls onSearch with null when input is cleared', async () => {
+ const onSearch = vi.fn()
+ // debounceTime: 0 to avoid relying on real timers for non-debounce behavior
+ const { input } = renderHashInput({ onSearch, debounceTime: 0 })
+ await userEvent.type(input, 'abc')
+ await userEvent.clear(input)
+ await waitFor(() => {
+ expect(onSearch).toHaveBeenLastCalledWith(null)
+ })
+ })
+
+ it('calls onSearch with the detected result', async () => {
+ // debounceTime: 0 keeps the test fast; this covers the callback wiring,
+ // not the debounce delay itself (fake timers + waitFor + userEvent conflict in jsdom)
+ const onSearch = vi.fn()
+ detectHashMock.mockResolvedValueOnce({ type: 'ENS', data: '0x123' })
+ renderHashInput({ onSearch, debounceTime: 0 })
+ const input = screen.getByTestId('hash-input')
+ await userEvent.type(input, 'vitalik.eth')
+ await waitFor(() => {
+ expect(onSearch).toHaveBeenCalledWith({ type: 'ENS', data: '0x123' })
+ })
+ })
+
+ it('calls onLoading(true) then onLoading(false) during search', async () => {
+ const onLoading = vi.fn()
+ let resolveDetect!: (v: unknown) => void
+ detectHashMock.mockImplementationOnce(
+ () =>
+ new Promise((res) => {
+ resolveDetect = res
+ }),
+ )
+ renderHashInput({ onLoading, debounceTime: 0 })
+ const input = screen.getByTestId('hash-input')
+ await userEvent.type(input, 'q')
+ await waitFor(() => expect(onLoading).toHaveBeenCalledWith(true))
+ resolveDetect({ type: null, data: null })
+ await waitFor(() => expect(onLoading).toHaveBeenCalledWith(false))
+ })
+
+ it('uses custom renderInput when provided', () => {
render(
{}}
+ onSearch={vi.fn()}
+ renderInput={(props) => (
+
+ )}
/>
,
)
-
- const input = screen.getByTestId('hash-input')
- expect(input).not.toBeNull()
- expect(input.tagName).toBe('INPUT')
+ expect(screen.getByTestId('custom-input')).toBeDefined()
})
})
diff --git a/src/components/sharedComponents/SwitchNetwork.test.tsx b/src/components/sharedComponents/SwitchNetwork.test.tsx
new file mode 100644
index 00000000..edbe1408
--- /dev/null
+++ b/src/components/sharedComponents/SwitchNetwork.test.tsx
@@ -0,0 +1,115 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import SwitchNetwork, { type Networks } from './SwitchNetwork'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(),
+}))
+
+vi.mock('wagmi', () => ({
+ useSwitchChain: vi.fn(),
+}))
+
+import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status'
+import * as wagmiModule from 'wagmi'
+
+const mockNetworks: Networks = [
+ { id: 1, label: 'Ethereum', icon: ETH },
+ { id: 137, label: 'Polygon', icon: MATIC },
+]
+
+function defaultWeb3Status(overrides = {}) {
+ return {
+ isWalletConnected: true,
+ walletChainId: undefined as number | undefined,
+ walletClient: undefined,
+ ...overrides,
+ }
+}
+
+function defaultSwitchChain() {
+ return {
+ chains: [
+ { id: 1, name: 'Ethereum' },
+ { id: 137, name: 'Polygon' },
+ ],
+ switchChain: vi.fn(),
+ }
+}
+
+function renderSwitchNetwork(networks = mockNetworks) {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('SwitchNetwork', () => {
+ it('shows "Select a network" when wallet chain does not match any network', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultWeb3Status({ walletChainId: 999 }) as any,
+ )
+ vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultSwitchChain() as any,
+ )
+ renderSwitchNetwork()
+ expect(screen.getByText('Select a network')).toBeDefined()
+ })
+
+ it('shows current network label when wallet is on a listed chain', async () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultWeb3Status({ walletChainId: 1 }) as any,
+ )
+ vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultSwitchChain() as any,
+ )
+ renderSwitchNetwork()
+ expect(screen.getByText('Ethereum')).toBeDefined()
+ })
+
+ it('trigger button is disabled when wallet not connected', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultWeb3Status({ isWalletConnected: false }) as any,
+ )
+ vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultSwitchChain() as any,
+ )
+ renderSwitchNetwork()
+ const button = screen.getByRole('button')
+ expect(button).toBeDefined()
+ expect(button.hasAttribute('disabled') || button.getAttribute('data-disabled') !== null).toBe(
+ true,
+ )
+ })
+
+ it('shows all network options in the menu after opening it', async () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultWeb3Status({ isWalletConnected: true }) as any,
+ )
+ vi.mocked(wagmiModule.useSwitchChain).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ defaultSwitchChain() as any,
+ )
+ renderSwitchNetwork()
+
+ // Open the menu by clicking the trigger
+ const trigger = screen.getByRole('button')
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText('Ethereum')).toBeDefined()
+ expect(screen.getByText('Polygon')).toBeDefined()
+ })
+ })
+})
diff --git a/src/components/sharedComponents/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo.test.tsx
new file mode 100644
index 00000000..b71166ed
--- /dev/null
+++ b/src/components/sharedComponents/TokenLogo.test.tsx
@@ -0,0 +1,75 @@
+import type { Token } from '@/src/types/token'
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import TokenLogo from './TokenLogo'
+
+const system = createSystem(defaultConfig)
+
+const mockToken: Token = {
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ chainId: 1,
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+ logoURI: 'https://example.com/usdc.png',
+}
+
+const tokenWithoutLogo: Token = {
+ ...mockToken,
+ logoURI: undefined,
+}
+
+function renderTokenLogo(token: Token, size?: number) {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('TokenLogo', () => {
+ it('renders an img with correct src when logoURI is present', () => {
+ renderTokenLogo(mockToken)
+ const img = screen.getByRole('img')
+ expect(img).toBeDefined()
+ expect(img.getAttribute('src')).toBe(mockToken.logoURI)
+ })
+
+ it('renders an img with correct alt text', () => {
+ renderTokenLogo(mockToken)
+ const img = screen.getByAltText('USD Coin')
+ expect(img).toBeDefined()
+ })
+
+ it('applies correct width and height from size prop', () => {
+ renderTokenLogo(mockToken, 48)
+ const img = screen.getByRole('img')
+ expect(img.getAttribute('width')).toBe('48')
+ expect(img.getAttribute('height')).toBe('48')
+ })
+
+ it('renders placeholder with token symbol initial when no logoURI', () => {
+ renderTokenLogo(tokenWithoutLogo)
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.getByText('U')).toBeDefined() // first char of 'USDC'
+ })
+
+ it('renders placeholder when img fails to load', () => {
+ renderTokenLogo(mockToken)
+ const img = screen.getByRole('img')
+ fireEvent.error(img)
+ expect(screen.queryByRole('img')).toBeNull()
+ expect(screen.getByText('U')).toBeDefined()
+ })
+
+ it('converts ipfs:// URLs to https://ipfs.io gateway URLs', () => {
+ const ipfsToken: Token = { ...mockToken, logoURI: 'ipfs://QmHash123' }
+ renderTokenLogo(ipfsToken)
+ const img = screen.getByRole('img')
+ expect(img.getAttribute('src')).toBe('https://ipfs.io/ipfs/QmHash123')
+ })
+})
diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx
new file mode 100644
index 00000000..74e4686d
--- /dev/null
+++ b/src/components/sharedComponents/TransactionButton.test.tsx
@@ -0,0 +1,146 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import TransactionButton from './TransactionButton'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(),
+}))
+
+vi.mock('@/src/providers/TransactionNotificationProvider', () => ({
+ useTransactionNotification: vi.fn(() => ({
+ watchTx: vi.fn(),
+ watchHash: vi.fn(),
+ watchSignature: vi.fn(),
+ })),
+}))
+
+vi.mock('wagmi', () => ({
+ useWaitForTransactionReceipt: vi.fn(() => ({ data: undefined })),
+}))
+
+vi.mock('@/src/providers/Web3Provider', () => ({
+ ConnectWalletButton: () => ,
+}))
+
+import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status'
+import * as wagmiModule from 'wagmi'
+
+// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default)
+const OP_SEPOLIA_ID = 11155420 as const
+
+function connectedStatus() {
+ return {
+ isWalletConnected: true,
+ isWalletSynced: true,
+ walletChainId: OP_SEPOLIA_ID,
+ appChainId: OP_SEPOLIA_ID,
+ address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`,
+ balance: undefined,
+ connectingWallet: false,
+ switchingChain: false,
+ walletClient: undefined,
+ readOnlyClient: undefined,
+ switchChain: vi.fn(),
+ disconnect: vi.fn(),
+ }
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: test helper accepts flexible props
+function renderButton(props: any = {}) {
+ return render(
+
+ Promise.resolve('0x1' as `0x${string}`)}
+ {...props}
+ />
+ ,
+ )
+}
+
+describe('TransactionButton', () => {
+ it('renders fallback when wallet not connected', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({
+ ...connectedStatus(),
+ isWalletConnected: false,
+ isWalletSynced: false,
+ })
+ renderButton()
+ expect(screen.getByText('Connect Wallet')).toBeDefined()
+ })
+
+ it('renders switch chain button when wallet is on wrong chain', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({
+ ...connectedStatus(),
+ isWalletSynced: false,
+ walletChainId: 1,
+ })
+ renderButton()
+ expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to')
+ })
+
+ it('renders with default label when wallet is connected and synced', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
+ vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
+ data: undefined,
+ } as ReturnType)
+ renderButton()
+ expect(screen.getByText('Send Transaction')).toBeDefined()
+ })
+
+ it('renders with custom children label', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
+ vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
+ data: undefined,
+ } as ReturnType)
+ renderButton({ children: 'Deposit ETH' })
+ expect(screen.getByText('Deposit ETH')).toBeDefined()
+ })
+
+ it('shows labelSending while transaction is pending', async () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
+ vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({
+ data: undefined,
+ } as ReturnType)
+
+ const neverResolves = () => new Promise<`0x${string}`>(() => {})
+ renderButton({ transaction: neverResolves, labelSending: 'Processing...' })
+
+ expect(screen.getByRole('button').textContent).not.toContain('Processing...')
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button').textContent).toContain('Processing...')
+ })
+ })
+
+ it('calls onMined when receipt becomes available', async () => {
+ // biome-ignore lint/suspicious/noExplicitAny: mock receipt shape
+ const mockReceipt = { status: 'success', transactionHash: '0x1' } as any
+ const onMined = vi.fn()
+
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus())
+ // Only return a receipt when called with the matching hash so the mock
+ // doesn't fire prematurely before the transaction is submitted.
+ vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockImplementation(
+ (config) =>
+ ({
+ data: config?.hash === '0x1' ? mockReceipt : undefined,
+ }) as ReturnType,
+ )
+
+ renderButton({
+ transaction: () => Promise.resolve('0x1' as `0x${string}`),
+ onMined,
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(onMined).toHaveBeenCalledWith(mockReceipt)
+ })
+ })
+})
diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx
new file mode 100644
index 00000000..bd7d75ed
--- /dev/null
+++ b/src/components/sharedComponents/WalletStatusVerifier.test.tsx
@@ -0,0 +1,121 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { WalletStatusVerifier, withWalletStatusVerifier } from './WalletStatusVerifier'
+
+const system = createSystem(defaultConfig)
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(),
+}))
+
+vi.mock('@/src/providers/Web3Provider', () => ({
+ ConnectWalletButton: () => ,
+}))
+
+import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status'
+
+// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default)
+const OP_SEPOLIA_ID = 11155420 as const
+
+function connectedSyncedStatus(overrides = {}) {
+ return {
+ isWalletConnected: true,
+ isWalletSynced: true,
+ walletChainId: OP_SEPOLIA_ID,
+ appChainId: OP_SEPOLIA_ID,
+ switchChain: vi.fn(),
+ disconnect: vi.fn(),
+ address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`,
+ balance: undefined,
+ connectingWallet: false,
+ switchingChain: false,
+ walletClient: undefined,
+ readOnlyClient: undefined,
+ ...overrides,
+ }
+}
+
+function wrap(ui: React.ReactElement) {
+ return render({ui})
+}
+
+describe('WalletStatusVerifier', () => {
+ it('renders default ConnectWalletButton fallback when wallet not connected', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any,
+ )
+ wrap(
+
+ Protected Content
+ ,
+ )
+ expect(screen.getByText('Connect Wallet')).toBeDefined()
+ expect(screen.queryByText('Protected Content')).toBeNull()
+ })
+
+ it('renders custom fallback when wallet not connected', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any,
+ )
+ wrap(
+ Custom Fallback}>
+ Protected Content
+ ,
+ )
+ expect(screen.getByText('Custom Fallback')).toBeDefined()
+ })
+
+ it('renders switch chain button when wallet is on wrong chain', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ connectedSyncedStatus({ isWalletSynced: false, walletChainId: 1 }) as any,
+ )
+ wrap(
+
+ Protected Content
+ ,
+ )
+ expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to')
+ expect(screen.queryByText('Protected Content')).toBeNull()
+ })
+
+ it('renders children when wallet is connected and synced', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ connectedSyncedStatus() as any,
+ )
+ wrap(
+
+ Protected Content
+ ,
+ )
+ expect(screen.getByText('Protected Content')).toBeDefined()
+ })
+})
+
+describe('withWalletStatusVerifier HOC', () => {
+ const ProtectedComponent = () => Protected Component
+ const Wrapped = withWalletStatusVerifier(ProtectedComponent)
+
+ it('renders fallback when wallet not connected', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any,
+ )
+ wrap()
+ expect(screen.getByText('Connect Wallet')).toBeDefined()
+ expect(screen.queryByText('Protected Component')).toBeNull()
+ })
+
+ it('renders wrapped component when wallet is connected and synced', () => {
+ vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock
+ connectedSyncedStatus() as any,
+ )
+ wrap()
+ expect(screen.getByText('Protected Component')).toBeDefined()
+ })
+})
diff --git a/src/env.test.ts b/src/env.test.ts
new file mode 100644
index 00000000..07c3a923
--- /dev/null
+++ b/src/env.test.ts
@@ -0,0 +1,54 @@
+import { zeroAddress } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+// env.ts reads import.meta.env at module load time.
+// Vitest loads .env.test automatically for the "test" mode,
+// so PUBLIC_APP_NAME, PUBLIC_SUBGRAPHS_*, etc. are set via .env.test.
+import { env } from '@/src/env'
+
+describe('env', () => {
+ it('exposes PUBLIC_APP_NAME from test env', () => {
+ expect(env.PUBLIC_APP_NAME).toBe('dAppBooster Test')
+ })
+
+ it('reads and normalizes PUBLIC_NATIVE_TOKEN_ADDRESS from env', () => {
+ // .env.test sets it to the zero address; the schema lowercases the value
+ expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(zeroAddress.toLowerCase())
+ })
+
+ it('lowercases PUBLIC_NATIVE_TOKEN_ADDRESS', () => {
+ expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(env.PUBLIC_NATIVE_TOKEN_ADDRESS.toLowerCase())
+ })
+
+ it('defaults PUBLIC_ENABLE_PORTO to true', () => {
+ expect(env.PUBLIC_ENABLE_PORTO).toBe(true)
+ })
+
+ it('defaults PUBLIC_USE_DEFAULT_TOKENS to true', () => {
+ expect(env.PUBLIC_USE_DEFAULT_TOKENS).toBe(true)
+ })
+
+ it('defaults PUBLIC_INCLUDE_TESTNETS to true', () => {
+ expect(env.PUBLIC_INCLUDE_TESTNETS).toBe(true)
+ })
+
+ it('reads PUBLIC_SUBGRAPHS_ENVIRONMENT from test env', () => {
+ // .env.test sets it to 'production'; to test the schema default use vi.resetModules()
+ expect(env.PUBLIC_SUBGRAPHS_ENVIRONMENT).toBe('production')
+ })
+
+ it('exposes PUBLIC_SUBGRAPHS_API_KEY from test env', () => {
+ expect(env.PUBLIC_SUBGRAPHS_API_KEY).toBe('test-api-key')
+ })
+
+ it('exposes PUBLIC_WALLETCONNECT_PROJECT_ID from test env', () => {
+ // .env.test sets it to 'test-project-id'
+ expect(env.PUBLIC_WALLETCONNECT_PROJECT_ID).toBe('test-project-id')
+ })
+
+ it('optional RPC vars are undefined when not set in test env', () => {
+ // None of the RPC vars are set in .env.test
+ expect(env.PUBLIC_RPC_MAINNET).toBeUndefined()
+ expect(env.PUBLIC_RPC_SEPOLIA).toBeUndefined()
+ })
+})
diff --git a/src/hooks/useErc20Balance.test.ts b/src/hooks/useErc20Balance.test.ts
new file mode 100644
index 00000000..04b3f033
--- /dev/null
+++ b/src/hooks/useErc20Balance.test.ts
@@ -0,0 +1,86 @@
+import type { Token } from '@/src/types/token'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { renderHook, waitFor } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { createElement } from 'react'
+import { zeroAddress } from 'viem'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useErc20Balance } from './useErc20Balance'
+
+const mockReadContract = vi.fn()
+
+vi.mock('wagmi', () => ({
+ usePublicClient: vi.fn(() => ({
+ readContract: mockReadContract,
+ })),
+}))
+
+vi.mock('@/src/env', () => ({
+ env: { PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase() },
+}))
+
+const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(
+ QueryClientProvider,
+ { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) },
+ children,
+ )
+
+const mockToken: Token = {
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ chainId: 1,
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+}
+
+const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as `0x${string}`
+
+describe('useErc20Balance', () => {
+ beforeEach(() => {
+ mockReadContract.mockClear()
+ })
+
+ it('returns undefined balance when address is missing', () => {
+ const { result } = renderHook(() => useErc20Balance({ token: mockToken }), { wrapper })
+ expect(result.current.balance).toBeUndefined()
+ expect(result.current.isLoadingBalance).toBe(false)
+ })
+
+ it('returns undefined balance when token is missing', () => {
+ const { result } = renderHook(() => useErc20Balance({ address: walletAddress }), { wrapper })
+ expect(result.current.balance).toBeUndefined()
+ expect(result.current.isLoadingBalance).toBe(false)
+ })
+
+ it('does not fetch balance for native token address', () => {
+ const nativeToken: Token = { ...mockToken, address: zeroAddress }
+ const { result } = renderHook(
+ () => useErc20Balance({ address: walletAddress, token: nativeToken }),
+ { wrapper },
+ )
+ expect(mockReadContract).not.toHaveBeenCalled()
+ expect(result.current.isLoadingBalance).toBe(false)
+ })
+
+ it('returns balance when query resolves', async () => {
+ mockReadContract.mockResolvedValueOnce(BigInt(1_000_000))
+ const { result } = renderHook(
+ () => useErc20Balance({ address: walletAddress, token: mockToken }),
+ { wrapper },
+ )
+ await waitFor(() => expect(result.current.isLoadingBalance).toBe(false))
+ expect(result.current.balance).toBe(BigInt(1_000_000))
+ expect(result.current.balanceError).toBeNull()
+ })
+
+ it('returns error when query fails', async () => {
+ mockReadContract.mockRejectedValueOnce(new Error('RPC error'))
+ const { result } = renderHook(
+ () => useErc20Balance({ address: walletAddress, token: mockToken }),
+ { wrapper },
+ )
+ await waitFor(() => expect(result.current.balanceError).toBeTruthy())
+ expect(result.current.balance).toBeUndefined()
+ })
+})
diff --git a/src/hooks/useTokenLists.test.ts b/src/hooks/useTokenLists.test.ts
new file mode 100644
index 00000000..bb8ddc0f
--- /dev/null
+++ b/src/hooks/useTokenLists.test.ts
@@ -0,0 +1,136 @@
+import type { Token } from '@/src/types/token'
+import tokenListsCache, { updateTokenListsCache } from '@/src/utils/tokenListsCache'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { renderHook } from '@testing-library/react'
+import { createElement } from 'react'
+import type { ReactNode } from 'react'
+import { zeroAddress } from 'viem'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/src/utils/tokenListsCache', () => {
+ const cache = { tokens: [] as Token[], tokensByChainId: {} as Record }
+ return {
+ default: cache,
+ updateTokenListsCache: vi.fn((map: typeof cache) => {
+ cache.tokens = map.tokens
+ cache.tokensByChainId = map.tokensByChainId
+ }),
+ addTokenToTokenList: vi.fn(),
+ }
+})
+
+vi.mock('@/src/env', () => ({
+ env: {
+ PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase(),
+ PUBLIC_USE_DEFAULT_TOKENS: false,
+ },
+}))
+
+vi.mock('@/src/constants/tokenLists', () => ({
+ tokenLists: {},
+}))
+
+vi.mock('@tanstack/react-query', async (importActual) => {
+ const actual = await importActual()
+ return { ...actual, useSuspenseQueries: vi.fn() }
+})
+
+import * as tanstackQuery from '@tanstack/react-query'
+import { useTokenLists } from './useTokenLists'
+
+const mockToken1: Token = {
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ chainId: 1,
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+}
+const mockToken2: Token = {
+ address: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
+ chainId: 1,
+ decimals: 6,
+ name: 'Tether USD',
+ symbol: 'USDT',
+}
+
+const mockSuspenseQueryResult = (tokens: Token[]) => ({
+ data: { name: 'Mock List', timestamp: '', version: { major: 1, minor: 0, patch: 0 }, tokens },
+ isLoading: false,
+ isSuccess: true,
+ error: null,
+})
+
+const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: new QueryClient() }, children)
+
+beforeEach(() => {
+ // Reset cache between tests
+ tokenListsCache.tokens = []
+ tokenListsCache.tokensByChainId = {}
+ vi.mocked(updateTokenListsCache).mockImplementation((map) => {
+ tokenListsCache.tokens = map.tokens
+ tokenListsCache.tokensByChainId = map.tokensByChainId
+ })
+})
+
+describe('useTokenLists', () => {
+ it('returns tokens and tokensByChainId', () => {
+ vi.mocked(tanstackQuery.useSuspenseQueries).mockReturnValueOnce(
+ // biome-ignore lint/suspicious/noExplicitAny: mocking overloaded hook return type
+ { tokens: [mockToken1], tokensByChainId: { 1: [mockToken1] } } as any,
+ )
+
+ const { result } = renderHook(() => useTokenLists(), { wrapper })
+ expect(result.current.tokens).toBeDefined()
+ expect(result.current.tokensByChainId).toBeDefined()
+ })
+
+ it('deduplicates tokens with the same chainId and address', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param
+ vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => {
+ const results = [
+ mockSuspenseQueryResult([mockToken1, mockToken2]),
+ mockSuspenseQueryResult([{ ...mockToken1 }]), // duplicate
+ ]
+ return combine(results)
+ })
+
+ const { result } = renderHook(() => useTokenLists(), { wrapper })
+ const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase())
+ expect(erc20Tokens).toHaveLength(2)
+ expect(erc20Tokens.map((t) => t.symbol)).toContain('USDC')
+ expect(erc20Tokens.map((t) => t.symbol)).toContain('USDT')
+ })
+
+ it('injects a native ETH token for mainnet (chainId 1) tokens', () => {
+ vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(
+ // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param
+ ({ combine }: any) => combine([mockSuspenseQueryResult([mockToken1])]),
+ )
+
+ const { result } = renderHook(() => useTokenLists(), { wrapper })
+ const nativeToken = result.current.tokensByChainId[1]?.[0]
+ expect(nativeToken?.address).toBe(zeroAddress.toLowerCase())
+ expect(nativeToken?.symbol).toBe('ETH')
+ })
+
+ it('filters out tokens that fail schema validation', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param
+ vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => {
+ const invalidToken = {
+ address: 'not-an-address',
+ chainId: 1,
+ name: 'Bad',
+ symbol: 'BAD',
+ decimals: 18,
+ }
+ // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid token input
+ return combine([mockSuspenseQueryResult([mockToken1, invalidToken as any])])
+ })
+
+ const { result } = renderHook(() => useTokenLists(), { wrapper })
+ const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase())
+ expect(erc20Tokens).toHaveLength(1)
+ expect(erc20Tokens[0].symbol).toBe('USDC')
+ })
+})
diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts
new file mode 100644
index 00000000..1010ea0b
--- /dev/null
+++ b/src/hooks/useWeb3Status.test.ts
@@ -0,0 +1,126 @@
+import { renderHook } from '@testing-library/react'
+import type { Address } from 'viem'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useWeb3Status, useWeb3StatusConnected } from './useWeb3Status'
+
+const mockDisconnect = vi.fn()
+const mockSwitchChain = vi.fn()
+
+vi.mock('wagmi', () => ({
+ useAccount: vi.fn(() => ({
+ address: undefined,
+ chainId: undefined,
+ isConnected: false,
+ isConnecting: false,
+ })),
+ useChainId: vi.fn(() => 1),
+ useSwitchChain: vi.fn(() => ({ isPending: false, switchChain: mockSwitchChain })),
+ usePublicClient: vi.fn(() => undefined),
+ useWalletClient: vi.fn(() => ({ data: undefined })),
+ useBalance: vi.fn(() => ({ data: undefined })),
+ useDisconnect: vi.fn(() => ({ disconnect: mockDisconnect })),
+}))
+
+import * as wagmi from 'wagmi'
+
+type MockAccount = ReturnType
+type MockSwitchChain = ReturnType
+
+describe('useWeb3Status', () => {
+ beforeEach(() => {
+ mockDisconnect.mockClear()
+ mockSwitchChain.mockClear()
+ })
+
+ it('returns disconnected state when no wallet connected', () => {
+ const { result } = renderHook(() => useWeb3Status())
+ expect(result.current.isWalletConnected).toBe(false)
+ expect(result.current.address).toBeUndefined()
+ expect(result.current.walletChainId).toBeUndefined()
+ })
+
+ it('returns connected state with wallet address', () => {
+ const mock = {
+ address: '0xabc123' as Address,
+ chainId: 1,
+ isConnected: true,
+ isConnecting: false,
+ } as unknown as MockAccount
+ vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock)
+ const { result } = renderHook(() => useWeb3Status())
+ expect(result.current.isWalletConnected).toBe(true)
+ expect(result.current.address).toBe('0xabc123')
+ })
+
+ it('sets isWalletSynced true when wallet chainId matches app chainId', () => {
+ const mock = {
+ address: '0xabc123' as Address,
+ chainId: 1,
+ isConnected: true,
+ isConnecting: false,
+ } as unknown as MockAccount
+ vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock)
+ vi.mocked(wagmi.useChainId).mockReturnValueOnce(1)
+ const { result } = renderHook(() => useWeb3Status())
+ expect(result.current.isWalletSynced).toBe(true)
+ })
+
+ it('sets isWalletSynced false when wallet chainId differs from app chainId', () => {
+ const mock = {
+ address: '0xabc123' as Address,
+ chainId: 137,
+ isConnected: true,
+ isConnecting: false,
+ } as unknown as MockAccount
+ vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock)
+ vi.mocked(wagmi.useChainId).mockReturnValueOnce(1)
+ const { result } = renderHook(() => useWeb3Status())
+ expect(result.current.isWalletSynced).toBe(false)
+ })
+
+ it('sets switchingChain when useSwitchChain is pending', () => {
+ const mock = { isPending: true, switchChain: mockSwitchChain } as unknown as MockSwitchChain
+ vi.mocked(wagmi.useSwitchChain).mockReturnValueOnce(mock)
+ const { result } = renderHook(() => useWeb3Status())
+ expect(result.current.switchingChain).toBe(true)
+ })
+
+ it('exposes disconnect function', () => {
+ const { result } = renderHook(() => useWeb3Status())
+ result.current.disconnect()
+ expect(mockDisconnect).toHaveBeenCalled()
+ })
+
+ it('calls switchChain with chainId when switchChain action is invoked', () => {
+ const { result } = renderHook(() => useWeb3Status())
+ result.current.switchChain(137)
+ expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: 137 })
+ })
+
+ it('exposes appChainId from useChainId', () => {
+ vi.mocked(wagmi.useChainId).mockReturnValueOnce(42161)
+ const { result } = renderHook(() => useWeb3Status())
+ expect(result.current.appChainId).toBe(42161)
+ })
+})
+
+describe('useWeb3StatusConnected', () => {
+ it('throws when wallet is not connected', () => {
+ expect(() => renderHook(() => useWeb3StatusConnected())).toThrow(
+ 'Use useWeb3StatusConnected only when a wallet is connected',
+ )
+ })
+
+ it('returns status when wallet is connected', () => {
+ const mock = {
+ address: '0xdeadbeef' as Address,
+ chainId: 1,
+ isConnected: true,
+ isConnecting: false,
+ } as unknown as MockAccount
+ // useWeb3StatusConnected calls useWeb3Status twice; both calls must see connected state
+ vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock).mockReturnValueOnce(mock)
+ const { result } = renderHook(() => useWeb3StatusConnected())
+ expect(result.current.isWalletConnected).toBe(true)
+ })
+})
diff --git a/src/test-utils.tsx b/src/test-utils.tsx
new file mode 100644
index 00000000..d60fd802
--- /dev/null
+++ b/src/test-utils.tsx
@@ -0,0 +1,57 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { render } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import type { Chain } from 'viem'
+
+const system = createSystem(defaultConfig)
+
+/**
+ * Wraps a component in the providers needed for most tests.
+ */
+export function renderWithProviders(ui: ReactNode) {
+ return render({ui})
+}
+
+/**
+ * Returns a minimal mock of the useWeb3Status return value.
+ * Pass overrides to test specific states.
+ */
+export function createMockWeb3Status(overrides?: Partial>) {
+ return { ..._mockShape(), ...overrides }
+}
+
+function _mockShape() {
+ return {
+ // AppWeb3Status
+ readOnlyClient: undefined,
+ appChainId: 1 as number,
+ // WalletWeb3Status
+ address: undefined as `0x${string}` | undefined,
+ balance: undefined,
+ connectingWallet: false,
+ switchingChain: false,
+ isWalletConnected: false,
+ walletClient: undefined,
+ isWalletSynced: false,
+ walletChainId: undefined as number | undefined,
+ // Web3Actions
+ disconnect: () => {},
+ switchChain: (_chainId?: number) => {},
+ }
+}
+
+/**
+ * Returns a minimal valid viem Chain object for tests.
+ */
+export function createMockChain(overrides?: Partial): Chain {
+ return {
+ id: 1,
+ name: 'Mock Chain',
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
+ rpcUrls: { default: { http: ['https://mock.rpc.url'] } },
+ blockExplorers: {
+ default: { name: 'MockExplorer', url: 'https://mock.explorer.url' },
+ },
+ ...overrides,
+ } as Chain
+}
diff --git a/src/utils/address.test.ts b/src/utils/address.test.ts
new file mode 100644
index 00000000..49c079a9
--- /dev/null
+++ b/src/utils/address.test.ts
@@ -0,0 +1,26 @@
+import { zeroAddress } from 'viem'
+import { describe, expect, it, vi } from 'vitest'
+
+// Mock env before importing isNativeToken so the module sees the mock
+vi.mock('@/src/env', () => ({
+ env: {
+ PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress,
+ },
+}))
+
+import { isNativeToken } from './address'
+
+describe('isNativeToken', () => {
+ it('returns true for the zero address (default native token)', () => {
+ expect(isNativeToken(zeroAddress)).toBe(true)
+ })
+
+ it('returns true for the zero address string literal', () => {
+ // zeroAddress is already lowercase; the literal string is identical — testing the exact value
+ expect(isNativeToken('0x0000000000000000000000000000000000000000')).toBe(true)
+ })
+
+ it('returns false for a regular ERC20 contract address', () => {
+ expect(isNativeToken('0x71C7656EC7ab88b098defB751B7401B5f6d8976F')).toBe(false)
+ })
+})
diff --git a/src/utils/getExplorerLink.test.ts b/src/utils/getExplorerLink.test.ts
new file mode 100644
index 00000000..c7ca4fad
--- /dev/null
+++ b/src/utils/getExplorerLink.test.ts
@@ -0,0 +1,52 @@
+import { createMockChain } from '@/src/test-utils'
+import type { Chain } from 'viem'
+import { describe, expect, it } from 'vitest'
+import { getExplorerLink } from './getExplorerLink'
+
+const chain = createMockChain()
+// A valid address (40 hex chars after 0x)
+const address = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as const
+// A valid tx hash (64 hex chars after 0x)
+const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e19' as const
+
+describe('getExplorerLink', () => {
+ it('returns address URL using chain block explorer', () => {
+ const url = getExplorerLink({ chain, hashOrAddress: address })
+ expect(url).toBe(`https://mock.explorer.url/address/${address}`)
+ })
+
+ it('returns tx URL using chain block explorer for a hash', () => {
+ const url = getExplorerLink({ chain, hashOrAddress: txHash })
+ expect(url).toBe(`https://mock.explorer.url/tx/${txHash}`)
+ })
+
+ it('uses custom explorerUrl for an address', () => {
+ const explorerUrl = 'https://custom.explorer.io'
+ const url = getExplorerLink({ chain, hashOrAddress: address, explorerUrl })
+ expect(url).toBe(`${explorerUrl}/address/${address}`)
+ })
+
+ it('uses custom explorerUrl for a tx hash', () => {
+ const explorerUrl = 'https://custom.explorer.io'
+ const url = getExplorerLink({ chain, hashOrAddress: txHash, explorerUrl })
+ expect(url).toBe(`${explorerUrl}/tx/${txHash}`)
+ })
+
+ it('throws for an invalid hash or address', () => {
+ expect(() =>
+ // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid input
+ getExplorerLink({ chain, hashOrAddress: 'not-valid' as any }),
+ ).toThrow('Invalid hash or address')
+ })
+
+ it('works with a chain that has no default block explorer (explorerUrl provided)', () => {
+ const chainWithoutExplorer: Chain = { ...chain, blockExplorers: undefined }
+ const explorerUrl = 'https://fallback.explorer.io'
+ const url = getExplorerLink({
+ chain: chainWithoutExplorer,
+ hashOrAddress: address,
+ explorerUrl,
+ })
+ expect(url).toBe(`${explorerUrl}/address/${address}`)
+ })
+})
diff --git a/src/utils/strings.test.ts b/src/utils/strings.test.ts
new file mode 100644
index 00000000..e3e8d775
--- /dev/null
+++ b/src/utils/strings.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest'
+import { getTruncatedHash, truncateStringInTheMiddle } from './strings'
+
+describe('truncateStringInTheMiddle', () => {
+ it('truncates a long string keeping start and end', () => {
+ const result = truncateStringInTheMiddle('0x1234567890abcdef1234567890abcdef12345678', 8, 6)
+ expect(result).toBe('0x123456...345678')
+ })
+
+ it('returns the original string when it fits within start + end length', () => {
+ const result = truncateStringInTheMiddle('short', 4, 4)
+ expect(result).toBe('short')
+ })
+
+ it('returns the original string when length equals start + end exactly', () => {
+ const result = truncateStringInTheMiddle('1234567890', 5, 5)
+ expect(result).toBe('1234567890')
+ })
+
+ it('truncates when length exceeds start + end by one', () => {
+ const result = truncateStringInTheMiddle('12345678901', 5, 5)
+ expect(result).toBe('12345...78901')
+ })
+
+ it('handles empty string', () => {
+ const result = truncateStringInTheMiddle('', 4, 4)
+ expect(result).toBe('')
+ })
+
+ it('handles asymmetric start and end positions', () => {
+ const result = truncateStringInTheMiddle('abcdefghijklmnop', 3, 7)
+ expect(result).toBe('abc...jklmnop')
+ })
+
+ it('handles start position of 0', () => {
+ const result = truncateStringInTheMiddle('abcdefghij', 0, 3)
+ expect(result).toBe('...hij')
+ })
+})
+
+describe('getTruncatedHash', () => {
+ const address = '0x1234567890abcdef1234567890abcdef12345678'
+ const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e19'
+
+ it('truncates with default length of 6', () => {
+ const result = getTruncatedHash(address)
+ // 0x + 6 chars ... last 6 chars
+ expect(result).toBe('0x123456...345678')
+ })
+
+ it('truncates with custom length', () => {
+ const result = getTruncatedHash(address, 4)
+ // 0x + 4 chars ... last 4 chars
+ expect(result).toBe('0x1234...5678')
+ })
+
+ it('truncates a transaction hash', () => {
+ const result = getTruncatedHash(txHash, 6)
+ expect(result).toBe('0xd85ef8...d38e19')
+ })
+
+ it('clamps length to minimum of 1', () => {
+ const result = getTruncatedHash(address, 0)
+ // length clamped to 1: 0x + 1 char ... last 1 char
+ expect(result).toBe('0x1...8')
+ })
+
+ it('clamps length to maximum of 16', () => {
+ const result = getTruncatedHash(address, 100)
+ // address = '0x1234567890abcdef1234567890abcdef12345678' (42 chars)
+ // length clamped to 16: slice(0, 18) = '0x1234567890abcdef', slice(42-16, 42) = '90abcdef12345678'
+ expect(result).toBe('0x1234567890abcdef...90abcdef12345678')
+ })
+
+ it('handles negative length by clamping to 1', () => {
+ const result = getTruncatedHash(address, -5)
+ expect(result).toBe('0x1...8')
+ })
+})
diff --git a/src/utils/suspenseWrapper.test.tsx b/src/utils/suspenseWrapper.test.tsx
new file mode 100644
index 00000000..bd529c57
--- /dev/null
+++ b/src/utils/suspenseWrapper.test.tsx
@@ -0,0 +1,118 @@
+import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { withSuspense, withSuspenseAndRetry } from './suspenseWrapper'
+
+const system = createSystem(defaultConfig)
+
+// Silence expected React error boundary console.errors.
+// Only restore this specific spy — vi.restoreAllMocks() would also wipe global
+// polyfills set up in setupTests.ts (e.g. ResizeObserver).
+let consoleErrorSpy: ReturnType
+beforeEach(() => {
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+})
+afterEach(() => {
+ consoleErrorSpy.mockRestore()
+})
+
+function wrap(ui: ReactNode, withQuery = false) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const inner = withQuery ? (
+ {ui}
+ ) : (
+ ui
+ )
+ return render({inner})
+}
+
+const NormalComponent = () => Normal Content
+const SuspendedComponent = () => {
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
+ throw new Promise(() => {})
+}
+
+function makeErrorComponent(message: string) {
+ return function ErrorComponent() {
+ throw new Error(message)
+ }
+}
+
+describe('withSuspense', () => {
+ it('renders the wrapped component when no error or suspension', () => {
+ const Wrapped = withSuspense(NormalComponent)
+ wrap()
+ expect(screen.getByText('Normal Content')).toBeDefined()
+ })
+
+ it('shows custom suspense fallback while component is suspended', () => {
+ const Wrapped = withSuspense(SuspendedComponent)
+ wrap(Loading...} />)
+ expect(screen.getByText('Loading...')).toBeDefined()
+ })
+
+ it('shows default error message when component throws', async () => {
+ const Wrapped = withSuspense(makeErrorComponent('boom'))
+ wrap()
+ await waitFor(() => {
+ expect(screen.getByText('Something went wrong...')).toBeDefined()
+ })
+ })
+
+ it('shows custom errorFallback text when provided', async () => {
+ const Wrapped = withSuspense(makeErrorComponent('boom'))
+ wrap()
+ await waitFor(() => {
+ expect(screen.getByText('Custom error text')).toBeDefined()
+ })
+ })
+})
+
+describe('withSuspenseAndRetry', () => {
+ it('renders the wrapped component when no error or suspension', () => {
+ const Wrapped = withSuspenseAndRetry(NormalComponent)
+ wrap(, true)
+ expect(screen.getByText('Normal Content')).toBeDefined()
+ })
+
+ it('shows custom suspense fallback while component is suspended', () => {
+ const Wrapped = withSuspenseAndRetry(SuspendedComponent)
+ wrap(Loading...} />, true)
+ expect(screen.getByText('Loading...')).toBeDefined()
+ })
+
+ it('shows error message and Try Again button when component throws', async () => {
+ const Wrapped = withSuspenseAndRetry(makeErrorComponent('Fetch failed'))
+ wrap(, true)
+ await waitFor(() => {
+ expect(screen.getByText('Fetch failed')).toBeDefined()
+ expect(screen.getByText('Try Again')).toBeDefined()
+ })
+ })
+
+ it('resets error boundary when Try Again is clicked', async () => {
+ // Use an external flag so React 19 retries also throw (React retries after first throw
+ // before giving up to the error boundary, which would reset renderCount-based approaches)
+ const state = { shouldThrow: true }
+ const RecoveryComponent = () => {
+ if (state.shouldThrow) throw new Error('Persistent error')
+ return Recovered
+ }
+
+ const Wrapped = withSuspenseAndRetry(RecoveryComponent)
+ wrap(, true)
+
+ await waitFor(() => {
+ expect(screen.getByText('Persistent error')).toBeDefined()
+ })
+
+ state.shouldThrow = false
+ fireEvent.click(screen.getByText('Try Again'))
+
+ await waitFor(() => {
+ expect(screen.getByText('Recovered')).toBeDefined()
+ })
+ })
+})