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/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/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..754488d8
--- /dev/null
+++ b/src/utils/address.test.ts
@@ -0,0 +1,35 @@
+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.toLowerCase(),
+ },
+}))
+
+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 in lowercase', () => {
+ expect(isNativeToken(zeroAddress.toLowerCase())).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)
+ })
+
+ it('comparison is case-insensitive', () => {
+ // Both upper and lower case should match the native token (zero address)
+ expect(isNativeToken('0X0000000000000000000000000000000000000000')).toBe(true)
+ })
+})
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')
+ })
+})