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/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/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()
+ })
+ })
+})