From 531b9cb6751c9a81247f77686486fc72e4dc5efd Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 15:28:32 +0200
Subject: [PATCH 01/52] refactor: extract shared wallet lifecycle utils to
internal module
---
src/sdk/react/hooks/useMultiWallet.ts | 66 +----
src/sdk/react/hooks/useWallet.ts | 48 +---
.../react/internal/walletLifecycle.test.ts | 225 ++++++++++++++++++
src/sdk/react/internal/walletLifecycle.ts | 86 +++++++
4 files changed, 320 insertions(+), 105 deletions(-)
create mode 100644 src/sdk/react/internal/walletLifecycle.test.ts
create mode 100644 src/sdk/react/internal/walletLifecycle.ts
diff --git a/src/sdk/react/hooks/useMultiWallet.ts b/src/sdk/react/hooks/useMultiWallet.ts
index 9114e4ef..b0a4b1fc 100644
--- a/src/sdk/react/hooks/useMultiWallet.ts
+++ b/src/sdk/react/hooks/useMultiWallet.ts
@@ -1,71 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
-import type { WalletLifecycle } from '../../core/adapters/lifecycle'
-import type {
- SignatureResult,
- SignMessageInput,
- SignTypedDataInput,
- WalletAdapter,
- WalletStatus,
-} from '../../core/adapters/wallet'
+import type { WalletStatus } from '../../core/adapters/wallet'
+import { wrapSignMessage, wrapSignTypedData } from '../internal/walletLifecycle'
import { useProviderContext } from '../provider/context'
import type { UseWalletReturn } from './useWallet'
-function fireWalletLifecycle(
- key: K,
- lifecycle: WalletLifecycle | undefined,
- ...args: Parameters>
-): void {
- const fn = lifecycle?.[key] as ((...a: unknown[]) => void) | undefined
- if (!fn) {
- return
- }
- try {
- fn(...(args as unknown[]))
- } catch (err) {
- console.error(`useMultiWallet lifecycle hook "${key}" threw:`, err)
- }
-}
-
-function wrapSignMessage(
- adapter: WalletAdapter,
- lifecycle: WalletLifecycle | undefined,
-): (input: SignMessageInput) => Promise {
- return async (input) => {
- fireWalletLifecycle('onSign', lifecycle, 'message', input)
- try {
- const result = await adapter.signMessage(input)
- fireWalletLifecycle('onSignComplete', lifecycle, result)
- return result
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err))
- fireWalletLifecycle('onSignError', lifecycle, error)
- throw err
- }
- }
-}
-
-function wrapSignTypedData(
- adapter: WalletAdapter,
- lifecycle: WalletLifecycle | undefined,
-): ((input: SignTypedDataInput) => Promise) | undefined {
- if (!adapter.signTypedData) {
- return undefined
- }
- const { signTypedData } = adapter
- return async (input) => {
- fireWalletLifecycle('onSign', lifecycle, 'typedData', input)
- try {
- const result = await signTypedData(input)
- fireWalletLifecycle('onSignComplete', lifecycle, result)
- return result
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err))
- fireWalletLifecycle('onSignError', lifecycle, error)
- throw err
- }
- }
-}
-
/** Returns one UseWalletReturn entry per registered wallet adapter, keyed by adapter name. */
export type UseMultiWalletReturn = Record
diff --git a/src/sdk/react/hooks/useWallet.ts b/src/sdk/react/hooks/useWallet.ts
index c6f82848..f1d2cb9b 100644
--- a/src/sdk/react/hooks/useWallet.ts
+++ b/src/sdk/react/hooks/useWallet.ts
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
-import type { WalletLifecycle } from '../../core/adapters/lifecycle'
import type {
ChainSigner,
ConnectOptions,
@@ -15,24 +14,9 @@ import {
AmbiguousAdapterError,
CapabilityNotSupportedError,
} from '../../core/errors'
+import { wrapSignMessage, wrapSignTypedData } from '../internal/walletLifecycle'
import { useProviderContext } from '../provider/context'
-function fireWalletLifecycle(
- key: K,
- lifecycle: WalletLifecycle | undefined,
- ...args: Parameters>
-): void {
- const fn = lifecycle?.[key] as ((...a: unknown[]) => void) | undefined
- if (!fn) {
- return
- }
- try {
- fn(...(args as unknown[]))
- } catch (err) {
- console.error(`useWallet lifecycle hook "${key}" threw:`, err)
- }
-}
-
export interface UseWalletOptions {
/** Resolve by chainId — finds the adapter whose supportedChains includes this chainId. */
chainId?: string | number
@@ -161,36 +145,18 @@ export function useWallet(options: UseWalletOptions = {}): UseWalletReturn {
!status.connectedChainIds.some((id) => chainIdMatch(id, chainId))
const signMessage = useCallback(
- async (input: SignMessageInput): Promise => {
- fireWalletLifecycle('onSign', walletLifecycle, 'message', input)
- try {
- const result = await adapter.signMessage(input)
- fireWalletLifecycle('onSignComplete', walletLifecycle, result)
- return result
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err))
- fireWalletLifecycle('onSignError', walletLifecycle, error)
- throw err
- }
- },
+ (input: SignMessageInput): Promise =>
+ wrapSignMessage(adapter, walletLifecycle)(input),
[adapter, walletLifecycle],
)
const signTypedDataImpl = useCallback(
- async (input: SignTypedDataInput): Promise => {
- if (!adapter.signTypedData) {
+ (input: SignTypedDataInput): Promise => {
+ const wrapped = wrapSignTypedData(adapter, walletLifecycle)
+ if (!wrapped) {
throw new CapabilityNotSupportedError('signTypedData')
}
- fireWalletLifecycle('onSign', walletLifecycle, 'typedData', input)
- try {
- const result = await adapter.signTypedData(input)
- fireWalletLifecycle('onSignComplete', walletLifecycle, result)
- return result
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err))
- fireWalletLifecycle('onSignError', walletLifecycle, error)
- throw err
- }
+ return wrapped(input)
},
[adapter, walletLifecycle],
)
diff --git a/src/sdk/react/internal/walletLifecycle.test.ts b/src/sdk/react/internal/walletLifecycle.test.ts
new file mode 100644
index 00000000..934b6cfe
--- /dev/null
+++ b/src/sdk/react/internal/walletLifecycle.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { WalletLifecycle } from '../../core/adapters/lifecycle'
+import type {
+ SignatureResult,
+ SignMessageInput,
+ SignTypedDataInput,
+ WalletAdapter,
+} from '../../core/adapters/wallet'
+import { fireWalletLifecycle, wrapSignMessage, wrapSignTypedData } from './walletLifecycle'
+
+// ---------------------------------------------------------------------------
+// fireWalletLifecycle
+// ---------------------------------------------------------------------------
+
+describe('fireWalletLifecycle', () => {
+ it('calls the lifecycle hook with the provided arguments', () => {
+ const onSign = vi.fn()
+ const lifecycle: WalletLifecycle = { onSign }
+ const input: SignMessageInput = { message: 'hello' }
+
+ fireWalletLifecycle('onSign', lifecycle, 'message', input)
+
+ expect(onSign).toHaveBeenCalledOnce()
+ expect(onSign).toHaveBeenCalledWith('message', input)
+ })
+
+ it('is a no-op when lifecycle is undefined', () => {
+ expect(() => {
+ fireWalletLifecycle('onSign', undefined, 'message', { message: 'hello' })
+ }).not.toThrow()
+ })
+
+ it('is a no-op when the specific hook is not defined on the lifecycle object', () => {
+ const lifecycle: WalletLifecycle = {}
+
+ expect(() => {
+ fireWalletLifecycle('onSign', lifecycle, 'message', { message: 'hello' })
+ }).not.toThrow()
+ })
+
+ it('catches and logs errors thrown by lifecycle hooks', () => {
+ const error = new Error('hook boom')
+ const onSignComplete = vi.fn(() => {
+ throw error
+ })
+ const lifecycle: WalletLifecycle = { onSignComplete }
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ fireWalletLifecycle('onSignComplete', lifecycle, {
+ signature: '0x',
+ address: '0xabc',
+ } as SignatureResult)
+
+ expect(consoleSpy).toHaveBeenCalledOnce()
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('onSignComplete'), error)
+ consoleSpy.mockRestore()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+const makeMockAdapter = (overrides?: Partial): WalletAdapter =>
+ ({
+ chainType: 'evm',
+ supportedChains: [],
+ metadata: {
+ chainType: 'evm',
+ capabilities: { signTypedData: false, switchChain: false },
+ formatAddress: (addr: string) => addr,
+ availableWallets: () => [],
+ },
+ connect: vi.fn(),
+ reconnect: vi.fn(),
+ disconnect: vi.fn(),
+ getStatus: vi.fn(),
+ onStatusChange: vi.fn(),
+ signMessage: vi.fn(),
+ getSigner: vi.fn(),
+ switchChain: vi.fn(),
+ ...overrides,
+ }) as unknown as WalletAdapter
+
+// ---------------------------------------------------------------------------
+// wrapSignMessage
+// ---------------------------------------------------------------------------
+
+describe('wrapSignMessage', () => {
+ it('delegates to adapter.signMessage and returns the result', async () => {
+ const expectedResult: SignatureResult = { signature: '0xsig', address: '0xabc' }
+ const adapter = makeMockAdapter({
+ signMessage: vi.fn().mockResolvedValue(expectedResult),
+ })
+
+ const sign = wrapSignMessage(adapter, undefined)
+ const result = await sign({ message: 'hello' })
+
+ expect(adapter.signMessage).toHaveBeenCalledWith({ message: 'hello' })
+ expect(result).toBe(expectedResult)
+ })
+
+ it('fires onSign before signing and onSignComplete after', async () => {
+ const expectedResult: SignatureResult = { signature: '0xsig', address: '0xabc' }
+ const adapter = makeMockAdapter({
+ signMessage: vi.fn().mockResolvedValue(expectedResult),
+ })
+ const onSign = vi.fn()
+ const onSignComplete = vi.fn()
+ const lifecycle: WalletLifecycle = { onSign, onSignComplete }
+
+ const sign = wrapSignMessage(adapter, lifecycle)
+ await sign({ message: 'hello' })
+
+ expect(onSign).toHaveBeenCalledWith('message', { message: 'hello' })
+ expect(onSignComplete).toHaveBeenCalledWith(expectedResult)
+ })
+
+ it('fires onSignError and re-throws when adapter.signMessage fails', async () => {
+ const signingError = new Error('user rejected')
+ const adapter = makeMockAdapter({
+ signMessage: vi.fn().mockRejectedValue(signingError),
+ })
+ const onSignError = vi.fn()
+ const lifecycle: WalletLifecycle = { onSignError }
+
+ const sign = wrapSignMessage(adapter, lifecycle)
+
+ await expect(sign({ message: 'hello' })).rejects.toThrow('user rejected')
+ expect(onSignError).toHaveBeenCalledWith(signingError)
+ })
+
+ it('wraps non-Error thrown values in an Error for onSignError', async () => {
+ const adapter = makeMockAdapter({
+ signMessage: vi.fn().mockRejectedValue('string error'),
+ })
+ const onSignError = vi.fn()
+ const lifecycle: WalletLifecycle = { onSignError }
+
+ const sign = wrapSignMessage(adapter, lifecycle)
+
+ await expect(sign({ message: 'hello' })).rejects.toBe('string error')
+ expect(onSignError).toHaveBeenCalledWith(expect.any(Error))
+ expect(onSignError.mock.calls[0][0].message).toBe('string error')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// wrapSignTypedData
+// ---------------------------------------------------------------------------
+
+describe('wrapSignTypedData', () => {
+ it('returns undefined when adapter lacks signTypedData capability', () => {
+ const adapter = makeMockAdapter()
+ const result = wrapSignTypedData(adapter, undefined)
+ expect(result).toBeUndefined()
+ })
+
+ it('delegates to adapter.signTypedData and returns the result', async () => {
+ const expectedResult: SignatureResult = { signature: '0xsig', address: '0xabc' }
+ const signTypedData = vi.fn().mockResolvedValue(expectedResult)
+ const adapter = makeMockAdapter({ signTypedData })
+ const input: SignTypedDataInput = {
+ domain: {},
+ types: {},
+ primaryType: 'Test',
+ message: {},
+ }
+
+ const wrapped = wrapSignTypedData(adapter, undefined)
+ if (!wrapped) {
+ throw new Error('expected wrapSignTypedData to return a function')
+ }
+ const result = await wrapped(input)
+
+ expect(signTypedData).toHaveBeenCalledWith(input)
+ expect(result).toBe(expectedResult)
+ })
+
+ it('fires onSign before signing and onSignComplete after', async () => {
+ const expectedResult: SignatureResult = { signature: '0xsig', address: '0xabc' }
+ const signTypedData = vi.fn().mockResolvedValue(expectedResult)
+ const adapter = makeMockAdapter({ signTypedData })
+ const onSign = vi.fn()
+ const onSignComplete = vi.fn()
+ const lifecycle: WalletLifecycle = { onSign, onSignComplete }
+ const input: SignTypedDataInput = {
+ domain: {},
+ types: {},
+ primaryType: 'Test',
+ message: {},
+ }
+
+ const wrapped = wrapSignTypedData(adapter, lifecycle)
+ if (!wrapped) {
+ throw new Error('expected wrapSignTypedData to return a function')
+ }
+ await wrapped(input)
+
+ expect(onSign).toHaveBeenCalledWith('typedData', input)
+ expect(onSignComplete).toHaveBeenCalledWith(expectedResult)
+ })
+
+ it('fires onSignError and re-throws when adapter.signTypedData fails', async () => {
+ const signingError = new Error('typed data rejected')
+ const signTypedData = vi.fn().mockRejectedValue(signingError)
+ const adapter = makeMockAdapter({ signTypedData })
+ const onSignError = vi.fn()
+ const lifecycle: WalletLifecycle = { onSignError }
+ const input: SignTypedDataInput = {
+ domain: {},
+ types: {},
+ primaryType: 'Test',
+ message: {},
+ }
+
+ const wrapped = wrapSignTypedData(adapter, lifecycle)
+ if (!wrapped) {
+ throw new Error('expected wrapSignTypedData to return a function')
+ }
+
+ await expect(wrapped(input)).rejects.toThrow('typed data rejected')
+ expect(onSignError).toHaveBeenCalledWith(signingError)
+ })
+})
diff --git a/src/sdk/react/internal/walletLifecycle.ts b/src/sdk/react/internal/walletLifecycle.ts
new file mode 100644
index 00000000..425b662a
--- /dev/null
+++ b/src/sdk/react/internal/walletLifecycle.ts
@@ -0,0 +1,86 @@
+import type { WalletLifecycle } from '../../core/adapters/lifecycle'
+import type {
+ SignatureResult,
+ SignMessageInput,
+ SignTypedDataInput,
+ WalletAdapter,
+} from '../../core/adapters/wallet'
+
+/**
+ * Invokes a single WalletLifecycle hook by key, swallowing any error it throws.
+ *
+ * @precondition key must be a valid WalletLifecycle method name
+ * @postcondition the hook is called with args if defined; errors are logged, never propagated
+ * @throws never — errors thrown by hooks are caught and logged to console.error
+ */
+export function fireWalletLifecycle(
+ key: K,
+ lifecycle: WalletLifecycle | undefined,
+ ...args: Parameters>
+): void {
+ const fn = lifecycle?.[key] as ((...a: unknown[]) => void) | undefined
+ if (!fn) {
+ return
+ }
+ try {
+ fn(...(args as unknown[]))
+ } catch (err) {
+ console.error(`wallet lifecycle hook "${key}" threw:`, err)
+ }
+}
+
+/**
+ * Wraps adapter.signMessage with lifecycle dispatch (onSign, onSignComplete, onSignError).
+ *
+ * @precondition adapter must implement signMessage
+ * @postcondition returned function delegates to adapter.signMessage with full lifecycle hooks
+ * @throws re-throws the original error from adapter.signMessage after firing onSignError
+ */
+export function wrapSignMessage(
+ adapter: WalletAdapter,
+ lifecycle: WalletLifecycle | undefined,
+): (input: SignMessageInput) => Promise {
+ return async (input) => {
+ fireWalletLifecycle('onSign', lifecycle, 'message', input)
+ try {
+ const result = await adapter.signMessage(input)
+ fireWalletLifecycle('onSignComplete', lifecycle, result)
+ return result
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err))
+ fireWalletLifecycle('onSignError', lifecycle, error)
+ throw err
+ }
+ }
+}
+
+/**
+ * Wraps adapter.signTypedData with lifecycle dispatch (onSign, onSignComplete, onSignError).
+ * Returns undefined when the adapter does not support signTypedData.
+ *
+ * @precondition adapter may or may not have signTypedData
+ * @postcondition returns undefined if adapter.signTypedData is not defined
+ * @postcondition returned function (when defined) delegates with full lifecycle hooks
+ * @throws re-throws the original error from adapter.signTypedData after firing onSignError
+ */
+export function wrapSignTypedData(
+ adapter: WalletAdapter,
+ lifecycle: WalletLifecycle | undefined,
+): ((input: SignTypedDataInput) => Promise) | undefined {
+ if (!adapter.signTypedData) {
+ return undefined
+ }
+ const { signTypedData } = adapter
+ return async (input) => {
+ fireWalletLifecycle('onSign', lifecycle, 'typedData', input)
+ try {
+ const result = await signTypedData(input)
+ fireWalletLifecycle('onSignComplete', lifecycle, result)
+ return result
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err))
+ fireWalletLifecycle('onSignError', lifecycle, error)
+ throw err
+ }
+ }
+}
From 45ef50b033dcb750e4a06a6766ba2e200ae14024 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 15:41:32 +0200
Subject: [PATCH 02/52] feat: add address param and explorerAddressUrl to
useReadOnly
---
src/sdk/react/hooks/useReadOnly.test.ts | 45 +++++++++++++++++++++++++
src/sdk/react/hooks/useReadOnly.ts | 26 ++++++++++++--
2 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/src/sdk/react/hooks/useReadOnly.test.ts b/src/sdk/react/hooks/useReadOnly.test.ts
index 43cb2368..f80926c7 100644
--- a/src/sdk/react/hooks/useReadOnly.test.ts
+++ b/src/sdk/react/hooks/useReadOnly.test.ts
@@ -24,6 +24,15 @@ const mockChainWithEndpoint = {
endpoints: [{ url: 'https://rpc.example.com', protocol: 'json-rpc' as const }],
}
+const mockChainWithExplorer = {
+ ...mockChainWithEndpoint,
+ explorer: {
+ url: 'https://etherscan.io',
+ txPath: '/tx/{id}',
+ addressPath: '/address/{id}',
+ },
+}
+
const makeWrapper =
(config: Parameters[0]['config']) =>
({ children }: { children: ReactNode }) =>
@@ -87,4 +96,40 @@ describe('useReadOnly', () => {
chainId: mockChainWithEndpoint.chainId,
})
})
+
+ it('returns address in the result when address option is provided', () => {
+ const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
+ const { result } = renderHook(() => useReadOnly({ chainId: 1, address: '0xabc' }), {
+ wrapper,
+ })
+ expect(result.current.address).toBe('0xabc')
+ })
+
+ it('returns null address when address option is not provided', () => {
+ const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
+ const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
+ expect(result.current.address).toBeNull()
+ })
+
+ it('returns explorerAddressUrl when address and explorer config are present', () => {
+ const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
+ const { result } = renderHook(() => useReadOnly({ chainId: 1, address: '0xabc' }), {
+ wrapper,
+ })
+ expect(result.current.explorerAddressUrl).toBe('https://etherscan.io/address/0xabc')
+ })
+
+ it('returns null explorerAddressUrl when address is not provided', () => {
+ const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
+ const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
+ expect(result.current.explorerAddressUrl).toBeNull()
+ })
+
+ it('returns null explorerAddressUrl when chain has no explorer', () => {
+ const wrapper = makeWrapper({ chains: [mockChain] })
+ const { result } = renderHook(() => useReadOnly({ chainId: 1, address: '0xabc' }), {
+ wrapper,
+ })
+ expect(result.current.explorerAddressUrl).toBeNull()
+ })
})
diff --git a/src/sdk/react/hooks/useReadOnly.ts b/src/sdk/react/hooks/useReadOnly.ts
index 3971caab..8cc2b606 100644
--- a/src/sdk/react/hooks/useReadOnly.ts
+++ b/src/sdk/react/hooks/useReadOnly.ts
@@ -1,21 +1,32 @@
import { useMemo } from 'react'
import type { ChainDescriptor } from '../../core/chain'
+import { getExplorerUrl } from '../../core/chain/explorer'
import { useProviderContext } from '../provider/context'
export interface UseReadOnlyOptions {
chainId: string | number
+ address?: string
}
export interface UseReadOnlyReturn {
chain: ChainDescriptor | null
/** Opaque read-only client created by the matching ReadClientFactory. null if no factory registered. */
client: unknown
+ /** The address passed in options, or null if not provided. */
+ address: string | null
+ /** Explorer URL for the given address, or null if address or explorer config is missing. */
+ explorerAddressUrl: string | null
}
/**
- * Returns the ChainDescriptor and a read-only client for the given chainId.
+ * Returns the ChainDescriptor, a read-only client, and optional address info for the given chainId.
* The client is created by the matching ReadClientFactory registered in DAppBoosterConfig.
- * Returns null for client if no factory is registered or the chain has no endpoints.
+ *
+ * @precondition Must be called inside a DAppBoosterProvider
+ * @precondition options.chainId identifies a chain registered in the provider config
+ * @postcondition returns chain descriptor and read-only client (null when chain/factory/endpoint missing)
+ * @postcondition returns address as-is from options, or null when not provided
+ * @postcondition returns explorerAddressUrl when both address and chain explorer config are present, null otherwise
*/
export function useReadOnly(options: UseReadOnlyOptions): UseReadOnlyReturn {
const { registry, readClientFactories } = useProviderContext()
@@ -37,5 +48,14 @@ export function useReadOnly(options: UseReadOnlyOptions): UseReadOnlyReturn {
return factory.createClient(endpoint, chain.chainId)
}, [chain, readClientFactories])
- return { chain, client }
+ const address = options.address ?? null
+
+ const explorerAddressUrl = useMemo(() => {
+ if (!address) {
+ return null
+ }
+ return getExplorerUrl(registry, { chainId: options.chainId, address })
+ }, [registry, options.chainId, address])
+
+ return { chain, client, address, explorerAddressUrl }
}
From c7a1a937dc91b48f0b725044962f664373cbba11 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 15:58:55 +0200
Subject: [PATCH 03/52] feat: add getWallet, getWalletByChainId,
connectedAddresses to useMultiWallet
---
src/sdk/react/hooks/useMultiWallet.test.tsx | 229 ++++++++++++++++----
src/sdk/react/hooks/useMultiWallet.ts | 64 +++++-
2 files changed, 248 insertions(+), 45 deletions(-)
diff --git a/src/sdk/react/hooks/useMultiWallet.test.tsx b/src/sdk/react/hooks/useMultiWallet.test.tsx
index b39154f6..fef691ba 100644
--- a/src/sdk/react/hooks/useMultiWallet.test.tsx
+++ b/src/sdk/react/hooks/useMultiWallet.test.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { WalletAdapter, WalletStatus } from '../../core/adapters/wallet'
+import type { ChainDescriptor } from '../../core/chain/descriptor'
import { DAppBoosterProvider } from '../provider/DAppBoosterProvider'
import { useMultiWallet } from './useMultiWallet'
@@ -17,20 +18,29 @@ const mockStatus: WalletStatus = {
connecting: false,
}
+const evmChain: ChainDescriptor = {
+ caip2Id: 'eip155:1',
+ chainId: 1,
+ name: 'Ethereum',
+ chainType: 'evm',
+ nativeCurrency: { symbol: 'ETH', decimals: 18 },
+ addressConfig: { format: 'hex', patterns: [], example: '0x...' },
+}
+
+const svmChain: ChainDescriptor = {
+ caip2Id: 'solana:mainnet',
+ chainId: 'solana:mainnet',
+ name: 'Solana',
+ chainType: 'solana',
+ nativeCurrency: { symbol: 'SOL', decimals: 9 },
+ addressConfig: { format: 'base58', patterns: [], example: '...' },
+}
+
const makeMockAdapter = (overrides?: Partial): WalletAdapter => {
const unsubscribe = vi.fn()
return {
chainType: 'evm',
- supportedChains: [
- {
- caip2Id: 'eip155:1',
- chainId: 1,
- name: 'Ethereum',
- chainType: 'evm',
- nativeCurrency: { symbol: 'ETH', decimals: 18 },
- addressConfig: { format: 'hex', patterns: [], example: '0x...' },
- },
- ],
+ supportedChains: [evmChain],
metadata: {
chainType: 'evm',
capabilities: { signTypedData: false, switchChain: false },
@@ -49,6 +59,36 @@ const makeMockAdapter = (overrides?: Partial): WalletAdapter => {
} as unknown as WalletAdapter
}
+const makeMockWalletAdapter = ({
+ chainType,
+ supportedChains,
+ connected,
+ activeAccount,
+}: {
+ chainType: string
+ supportedChains: ChainDescriptor[]
+ connected: boolean
+ activeAccount: string | null
+}): WalletAdapter => {
+ const status: WalletStatus = {
+ connected,
+ activeAccount,
+ connectedChainIds: connected ? supportedChains.map((c) => c.chainId) : [],
+ connecting: false,
+ }
+ return makeMockAdapter({
+ chainType,
+ supportedChains,
+ metadata: {
+ chainType,
+ capabilities: { signTypedData: false, switchChain: false },
+ formatAddress: (addr: string) => addr,
+ availableWallets: () => [],
+ },
+ getStatus: vi.fn(() => status),
+ })
+}
+
const makeWrapper =
(wallets: Record) =>
({ children }: { children: ReactNode }) =>
@@ -60,32 +100,23 @@ const makeEmptyWrapper =
createElement(DAppBoosterProvider, { config: {} }, children)
describe('useMultiWallet', () => {
- it('returns empty record with no adapters', () => {
+ it('returns empty wallets record with no adapters', () => {
const wrapper = makeEmptyWrapper()
const { result } = renderHook(() => useMultiWallet(), { wrapper })
- expect(result.current).toEqual({})
+ expect(result.current.wallets).toEqual({})
})
it('returns one entry per adapter', () => {
const evmAdapter = makeMockAdapter({ chainType: 'evm' })
const solAdapter = makeMockAdapter({
chainType: 'solana',
- supportedChains: [
- {
- caip2Id: 'solana:mainnet',
- chainId: 'solana:mainnet',
- name: 'Solana',
- chainType: 'solana',
- nativeCurrency: { symbol: 'SOL', decimals: 9 },
- addressConfig: { format: 'base58', patterns: [], example: '...' },
- },
- ],
+ supportedChains: [svmChain],
})
const wrapper = makeWrapper({ evm: { adapter: evmAdapter }, solana: { adapter: solAdapter } })
const { result } = renderHook(() => useMultiWallet(), { wrapper })
- expect(Object.keys(result.current)).toHaveLength(2)
- expect(result.current).toHaveProperty('evm')
- expect(result.current).toHaveProperty('solana')
+ expect(Object.keys(result.current.wallets)).toHaveLength(2)
+ expect(result.current.wallets).toHaveProperty('evm')
+ expect(result.current.wallets).toHaveProperty('solana')
})
it('status is correct for connected adapter', () => {
@@ -98,9 +129,9 @@ describe('useMultiWallet', () => {
const adapter = makeMockAdapter({ getStatus: vi.fn(() => connectedStatus) })
const wrapper = makeWrapper({ evm: { adapter } })
const { result } = renderHook(() => useMultiWallet(), { wrapper })
- expect(result.current.evm.status).toEqual(connectedStatus)
- expect(result.current.evm.isReady).toBe(true)
- expect(result.current.evm.needsConnect).toBe(false)
+ expect(result.current.wallets.evm.status).toEqual(connectedStatus)
+ expect(result.current.wallets.evm.isReady).toBe(true)
+ expect(result.current.wallets.evm.needsConnect).toBe(false)
})
it('status subscription updates when onStatusChange fires', () => {
@@ -125,7 +156,7 @@ describe('useMultiWallet', () => {
capturedListener?.(updatedStatus)
})
- expect(result.current.evm.status).toEqual(updatedStatus)
+ expect(result.current.wallets.evm.status).toEqual(updatedStatus)
})
it('calls unsubscribes for all adapters on unmount', () => {
@@ -134,16 +165,7 @@ describe('useMultiWallet', () => {
const evmAdapter = makeMockAdapter({ onStatusChange: vi.fn(() => unsubscribeEvm) })
const solAdapter = makeMockAdapter({
chainType: 'solana',
- supportedChains: [
- {
- caip2Id: 'solana:mainnet',
- chainId: 'solana:mainnet',
- name: 'Solana',
- chainType: 'solana',
- nativeCurrency: { symbol: 'SOL', decimals: 9 },
- addressConfig: { format: 'base58', patterns: [], example: '...' },
- },
- ],
+ supportedChains: [svmChain],
onStatusChange: vi.fn(() => unsubscribeSol),
})
const wrapper = makeWrapper({ evm: { adapter: evmAdapter }, solana: { adapter: solAdapter } })
@@ -153,3 +175,132 @@ describe('useMultiWallet', () => {
expect(unsubscribeSol).toHaveBeenCalledOnce()
})
})
+
+describe('useMultiWallet convenience methods', () => {
+ it('getWallet returns the wallet entry matching chainType', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: true,
+ activeAccount: '0xabc',
+ })
+ const solanaAdapter = makeMockWalletAdapter({
+ chainType: 'solana',
+ supportedChains: [svmChain],
+ connected: true,
+ activeAccount: 'ABC123',
+ })
+ const wrapper = makeWrapper({
+ evm: { adapter: evmAdapter },
+ solana: { adapter: solanaAdapter },
+ })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ const evmWallet = result.current.getWallet('evm')
+ expect(evmWallet).toBeDefined()
+ expect(evmWallet?.adapter.chainType).toBe('evm')
+ })
+
+ it('getWallet returns undefined for unregistered chainType', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: false,
+ activeAccount: null,
+ })
+ const wrapper = makeWrapper({ evm: { adapter: evmAdapter } })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ expect(result.current.getWallet('solana')).toBeUndefined()
+ })
+
+ it('getWalletByChainId returns the wallet entry whose adapter supports that chainId', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: true,
+ activeAccount: '0xabc',
+ })
+ const wrapper = makeWrapper({ evm: { adapter: evmAdapter } })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ const wallet = result.current.getWalletByChainId(1)
+ expect(wallet).toBeDefined()
+ expect(wallet?.adapter.chainType).toBe('evm')
+ })
+
+ it('getWalletByChainId returns undefined for unsupported chainId', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: false,
+ activeAccount: null,
+ })
+ const wrapper = makeWrapper({ evm: { adapter: evmAdapter } })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ expect(result.current.getWalletByChainId(999)).toBeUndefined()
+ })
+
+ it('connectedAddresses returns a record of adapter name to activeAccount for connected wallets', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: true,
+ activeAccount: '0xabc',
+ })
+ const solanaAdapter = makeMockWalletAdapter({
+ chainType: 'solana',
+ supportedChains: [svmChain],
+ connected: true,
+ activeAccount: 'ABC123',
+ })
+ const wrapper = makeWrapper({
+ evm: { adapter: evmAdapter },
+ solana: { adapter: solanaAdapter },
+ })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ expect(result.current.connectedAddresses).toEqual({
+ evm: '0xabc',
+ solana: 'ABC123',
+ })
+ })
+
+ it('connectedAddresses omits disconnected wallets', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: true,
+ activeAccount: '0xabc',
+ })
+ const solanaAdapter = makeMockWalletAdapter({
+ chainType: 'solana',
+ supportedChains: [svmChain],
+ connected: false,
+ activeAccount: null,
+ })
+ const wrapper = makeWrapper({
+ evm: { adapter: evmAdapter },
+ solana: { adapter: solanaAdapter },
+ })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ expect(result.current.connectedAddresses).toEqual({ evm: '0xabc' })
+ expect(result.current.connectedAddresses).not.toHaveProperty('solana')
+ })
+
+ it('wallets record is still accessible on the return value', () => {
+ const evmAdapter = makeMockWalletAdapter({
+ chainType: 'evm',
+ supportedChains: [evmChain],
+ connected: false,
+ activeAccount: null,
+ })
+ const wrapper = makeWrapper({ evm: { adapter: evmAdapter } })
+ const { result } = renderHook(() => useMultiWallet(), { wrapper })
+
+ expect(result.current.wallets.evm).toBeDefined()
+ expect(result.current.wallets.evm.adapter.chainType).toBe('evm')
+ })
+})
diff --git a/src/sdk/react/hooks/useMultiWallet.ts b/src/sdk/react/hooks/useMultiWallet.ts
index b0a4b1fc..8f4ef6c1 100644
--- a/src/sdk/react/hooks/useMultiWallet.ts
+++ b/src/sdk/react/hooks/useMultiWallet.ts
@@ -1,15 +1,36 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import type { WalletStatus } from '../../core/adapters/wallet'
import { wrapSignMessage, wrapSignTypedData } from '../internal/walletLifecycle'
import { useProviderContext } from '../provider/context'
import type { UseWalletReturn } from './useWallet'
-/** Returns one UseWalletReturn entry per registered wallet adapter, keyed by adapter name. */
-export type UseMultiWalletReturn = Record
+/** Return type for the useMultiWallet hook. */
+export interface UseMultiWalletReturn {
+ /** All wallet entries keyed by adapter name (e.g. 'evm', 'solana'). */
+ wallets: Record
+ /**
+ * Returns the wallet entry whose adapter matches the given chainType.
+ * @precondition chainType is a non-empty string
+ * @postcondition returns the first matching UseWalletReturn or undefined
+ */
+ getWallet(chainType: string): UseWalletReturn | undefined
+ /**
+ * Returns the wallet entry whose adapter's supportedChains includes the given chainId.
+ * @precondition chainId is a string or number identifying a chain
+ * @postcondition returns the first matching UseWalletReturn or undefined
+ */
+ getWalletByChainId(chainId: string | number): UseWalletReturn | undefined
+ /**
+ * Record of adapter name to active account address for all connected wallets.
+ * Disconnected wallets are omitted.
+ * @postcondition every value is a non-null address string
+ */
+ connectedAddresses: Record
+}
/**
- * Returns a record of UseWalletReturn for every registered wallet adapter.
- * Keys match the names used in DAppBoosterConfig.wallets (e.g. 'evm', 'solana').
+ * Returns a UseMultiWalletReturn with all registered wallet adapters and convenience methods.
+ * @postcondition wallets record keys match the names used in DAppBoosterConfig.wallets
*/
export function useMultiWallet(): UseMultiWalletReturn {
const { walletAdapters, walletLifecycle, connectModalsRef } = useProviderContext()
@@ -40,7 +61,7 @@ export function useMultiWallet(): UseMultiWalletReturn {
}
}, [walletAdapters])
- return useMemo(
+ const wallets = useMemo>(
() =>
Object.fromEntries(
Object.entries(walletAdapters).map(([key, adapter]) => {
@@ -81,4 +102,35 @@ export function useMultiWallet(): UseMultiWalletReturn {
),
[statuses, walletAdapters, walletLifecycle, connectModalsRef],
)
+
+ const getWallet = useCallback(
+ (chainType: string): UseWalletReturn | undefined =>
+ Object.values(wallets).find((entry) => entry.adapter.chainType === chainType),
+ [wallets],
+ )
+
+ const getWalletByChainId = useCallback(
+ (chainId: string | number): UseWalletReturn | undefined => {
+ const normalized = String(chainId)
+ return Object.values(wallets).find((entry) =>
+ entry.adapter.supportedChains.some((chain) => String(chain.chainId) === normalized),
+ )
+ },
+ [wallets],
+ )
+
+ const connectedAddresses = useMemo>(() => {
+ const result: Record = {}
+ for (const [key, entry] of Object.entries(wallets)) {
+ if (entry.status.connected && entry.status.activeAccount) {
+ result[key] = entry.status.activeAccount
+ }
+ }
+ return result
+ }, [wallets])
+
+ return useMemo(
+ () => ({ wallets, getWallet, getWalletByChainId, connectedAddresses }),
+ [wallets, getWallet, getWalletByChainId, connectedAddresses],
+ )
}
From 0d365bee1bd697e2d4effb4e420260f1d66bdddc Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 16:35:08 +0200
Subject: [PATCH 04/52] feat: add manual pre-step control to useTransaction
(prepare, executePreStep, executeAllPreSteps)
---
.../TransactionButton.test.tsx | 8 +
src/sdk/react/hooks/index.ts | 1 +
src/sdk/react/hooks/useTransaction.test.tsx | 295 ++++++++++++++++++
src/sdk/react/hooks/useTransaction.ts | 229 +++++++++++++-
.../components/TransactionButton.test.tsx | 8 +
5 files changed, 524 insertions(+), 17 deletions(-)
diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx
index bebe37f3..2c74231c 100644
--- a/src/components/sharedComponents/TransactionButton.test.tsx
+++ b/src/components/sharedComponents/TransactionButton.test.tsx
@@ -268,8 +268,12 @@ describe('TransactionButton', () => {
ref: null,
result: null,
preStepResults: [],
+ preStepStatuses: [],
explorerUrl: null,
error: null,
+ prepare: vi.fn(),
+ executePreStep: vi.fn(),
+ executeAllPreSteps: vi.fn(),
})
renderWithChakra(Send ETH)
@@ -288,8 +292,12 @@ describe('TransactionButton', () => {
ref: null,
result: null,
preStepResults: [],
+ preStepStatuses: [],
explorerUrl: null,
error: null,
+ prepare: vi.fn(),
+ executePreStep: vi.fn(),
+ executeAllPreSteps: vi.fn(),
})
renderWithChakra(
diff --git a/src/sdk/react/hooks/index.ts b/src/sdk/react/hooks/index.ts
index b2a754d6..a443160c 100644
--- a/src/sdk/react/hooks/index.ts
+++ b/src/sdk/react/hooks/index.ts
@@ -5,6 +5,7 @@ export { useMultiWallet } from './useMultiWallet'
export type { UseReadOnlyOptions, UseReadOnlyReturn } from './useReadOnly'
export { useReadOnly } from './useReadOnly'
export type {
+ PreStepStatus,
TransactionExecutionPhase,
UseTransactionOptions,
UseTransactionReturn,
diff --git a/src/sdk/react/hooks/useTransaction.test.tsx b/src/sdk/react/hooks/useTransaction.test.tsx
index 4f06421f..b4965428 100644
--- a/src/sdk/react/hooks/useTransaction.test.tsx
+++ b/src/sdk/react/hooks/useTransaction.test.tsx
@@ -358,4 +358,299 @@ describe('useTransaction', () => {
expect(globalOnError).toHaveBeenCalledWith('prepare', expect.any(Error))
})
+
+ describe('manual pre-step control', () => {
+ const preStep1: PreStep = {
+ label: 'Approve USDC',
+ params: { chainId: 1, payload: { type: 'approve-1' } },
+ }
+ const preStep2: PreStep = {
+ label: 'Approve WETH',
+ params: { chainId: 1, payload: { type: 'approve-2' } },
+ }
+
+ it('prepare() stores the prepare result and transitions to prepare phase', async () => {
+ const txAdapter = makeMockTxAdapter({
+ prepare: vi.fn(async () => ({ ready: true })),
+ })
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ await act(async () => {
+ const prepared = await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1],
+ })
+ expect(prepared.ready).toBe(true)
+ })
+
+ expect(result.current.prepareResult).toEqual({ ready: true })
+ })
+
+ it('prepare() throws TransactionNotReadyError when prepare returns ready: false', async () => {
+ const txAdapter = makeMockTxAdapter({
+ prepare: vi.fn(async () => ({ ready: false, reason: 'Insufficient balance' })),
+ })
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ let caught: unknown
+ await act(async () => {
+ try {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1],
+ })
+ } catch (error) {
+ caught = error
+ }
+ })
+
+ expect(caught).toBeInstanceOf(TransactionNotReadyError)
+ })
+
+ it('preStepStatuses is populated after prepare() with preSteps', async () => {
+ const txAdapter = makeMockTxAdapter({
+ prepare: vi.fn(async () => ({ ready: true })),
+ })
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ expect(result.current.preStepStatuses).toEqual([])
+
+ await act(async () => {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1, preStep2],
+ })
+ })
+
+ expect(result.current.preStepStatuses).toEqual(['pending', 'pending'])
+ })
+
+ it('executePreStep(index) executes a single pre-step and updates its status', async () => {
+ const txAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ await act(async () => {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1, preStep2],
+ })
+ })
+
+ await act(async () => {
+ await result.current.executePreStep(0)
+ })
+
+ expect(result.current.preStepStatuses[0]).toBe('completed')
+ expect(result.current.preStepStatuses[1]).toBe('pending')
+ expect(result.current.preStepResults[0]).toBeDefined()
+ expect(result.current.preStepResults[0]?.status).toBe('success')
+ })
+
+ it('executePreStep(index) throws when prepare() has not been called', async () => {
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper(),
+ })
+
+ let caught: unknown
+ await act(async () => {
+ try {
+ await result.current.executePreStep(0)
+ } catch (error) {
+ caught = error
+ }
+ })
+
+ expect(caught).toBeInstanceOf(Error)
+ expect((caught as Error).message).toContain('prepare')
+ })
+
+ it('executePreStep(index) throws for out-of-bounds index', async () => {
+ const txAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ await act(async () => {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1],
+ })
+ })
+
+ let caught: unknown
+ await act(async () => {
+ try {
+ await result.current.executePreStep(5)
+ } catch (error) {
+ caught = error
+ }
+ })
+
+ expect(caught).toBeInstanceOf(RangeError)
+ })
+
+ it('executeAllPreSteps() executes all pending pre-steps in order', async () => {
+ const executionOrder: number[] = []
+ const txAdapter = makeMockTxAdapter({
+ execute: vi.fn(async (params) => {
+ const payload = params.payload as { type?: string }
+ if (payload.type === 'approve-1') {
+ executionOrder.push(1)
+ }
+ if (payload.type === 'approve-2') {
+ executionOrder.push(2)
+ }
+ return { chainType: 'evm', id: '0xhash', chainId: 1 }
+ }),
+ })
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ await act(async () => {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1, preStep2],
+ })
+ })
+
+ await act(async () => {
+ await result.current.executeAllPreSteps()
+ })
+
+ expect(executionOrder).toEqual([1, 2])
+ expect(result.current.preStepStatuses).toEqual(['completed', 'completed'])
+ expect(result.current.preStepResults).toHaveLength(2)
+ })
+
+ it('executeAllPreSteps() skips already-completed pre-steps', async () => {
+ const txAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ await act(async () => {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1, preStep2],
+ })
+ })
+
+ // Execute only the first pre-step
+ await act(async () => {
+ await result.current.executePreStep(0)
+ })
+
+ // Now execute all — should skip index 0
+ await act(async () => {
+ await result.current.executeAllPreSteps()
+ })
+
+ // execute called: 1 for preStep0, 1 for preStep1 = 2 total (not 3)
+ expect(txAdapter.execute).toHaveBeenCalledTimes(2)
+ expect(result.current.preStepStatuses).toEqual(['completed', 'completed'])
+ })
+
+ it('execute() succeeds after all pre-steps are manually completed', async () => {
+ const txAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ const paramsWithPreSteps = { ...testParams, preSteps: [preStep1, preStep2] }
+
+ await act(async () => {
+ await result.current.prepare(paramsWithPreSteps)
+ })
+
+ await act(async () => {
+ await result.current.executeAllPreSteps()
+ })
+
+ await act(async () => {
+ const txResult = await result.current.execute(paramsWithPreSteps)
+ expect(txResult.status).toBe('success')
+ })
+
+ expect(result.current.result?.status).toBe('success')
+ })
+
+ it('execute() throws PreStepsNotExecutedError when pre-steps are not all completed and autoPreSteps is false', async () => {
+ const txAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ const paramsWithPreSteps = { ...testParams, preSteps: [preStep1, preStep2] }
+
+ await act(async () => {
+ await result.current.prepare(paramsWithPreSteps)
+ })
+
+ // Only complete the first pre-step
+ await act(async () => {
+ await result.current.executePreStep(0)
+ })
+
+ let caught: unknown
+ await act(async () => {
+ try {
+ await result.current.execute(paramsWithPreSteps)
+ } catch (error) {
+ caught = error
+ }
+ })
+
+ expect(caught).toBeInstanceOf(PreStepsNotExecutedError)
+ expect((caught as PreStepsNotExecutedError).pendingCount).toBe(1)
+ })
+
+ it('marks pre-step as failed when executePreStep encounters an error', async () => {
+ const txAdapter = makeMockTxAdapter({
+ execute: vi.fn(async () => {
+ throw new Error('pre-step execution failed')
+ }),
+ })
+
+ const { result } = renderHook(() => useTransaction({ autoPreSteps: false }), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ await act(async () => {
+ await result.current.prepare({
+ ...testParams,
+ preSteps: [preStep1],
+ })
+ })
+
+ let caught: unknown
+ await act(async () => {
+ try {
+ await result.current.executePreStep(0)
+ } catch (error) {
+ caught = error
+ }
+ })
+
+ expect(caught).toBeInstanceOf(Error)
+ expect(result.current.preStepStatuses[0]).toBe('failed')
+ })
+ })
})
diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts
index fc09ff43..3cfd5780 100644
--- a/src/sdk/react/hooks/useTransaction.ts
+++ b/src/sdk/react/hooks/useTransaction.ts
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
import type { TransactionLifecycle, TransactionPhase } from '../../core/adapters/lifecycle'
import type {
ConfirmOptions,
@@ -18,6 +18,9 @@ import { useProviderContext } from '../provider/context'
export type TransactionExecutionPhase = 'idle' | 'prepare' | 'preStep' | 'submit' | 'confirm'
+/** Status of an individual pre-step in the manual pre-step control flow. */
+export type PreStepStatus = 'pending' | 'executing' | 'completed' | 'failed'
+
export interface UseTransactionOptions {
/** Per-operation lifecycle hooks — merged with global lifecycle. Global fires first. */
lifecycle?: TransactionLifecycle
@@ -33,9 +36,13 @@ export interface UseTransactionReturn {
ref: TransactionRef | null
result: TransactionResult | null
preStepResults: TransactionResult[]
+ preStepStatuses: PreStepStatus[]
explorerUrl: string | null
error: Error | null
execute: (params: TransactionParams) => Promise
+ prepare: (params: TransactionParams) => Promise
+ executePreStep: (index: number) => Promise
+ executeAllPreSteps: () => Promise
reset: () => void
}
@@ -92,8 +99,13 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
const [ref, setRef] = useState(null)
const [result, setResult] = useState(null)
const [preStepResults, setPreStepResults] = useState([])
+ const [preStepStatuses, setPreStepStatuses] = useState([])
const [error, setError] = useState(null)
+ const preparedParamsRef = useRef(null)
+ const preStepStatusesRef = useRef([])
+ const preStepResultsRef = useRef([])
+
const explorerUrl = useMemo(
() => (ref ? getExplorerUrl(registry, { chainId: ref.chainId, tx: ref.id }) : null),
[ref, registry],
@@ -105,7 +117,11 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
setRef(null)
setResult(null)
setPreStepResults([])
+ setPreStepStatuses([])
setError(null)
+ preparedParamsRef.current = null
+ preStepStatusesRef.current = []
+ preStepResultsRef.current = []
}, [])
const { lifecycle: localLifecycle, autoPreSteps = true, confirmOptions } = options
@@ -147,22 +163,30 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
if (params.preSteps && params.preSteps.length > 0) {
currentPhase = 'preStep'
if (!autoPreSteps) {
- throw new PreStepsNotExecutedError(params.preSteps.length)
- }
- setPhase('preStep')
- for (const [index, preStep] of params.preSteps.entries()) {
- fireLifecycle('onPreStep', globalLifecycle, localLifecycle, preStep, index)
- const preStepRef = await transactionAdapter.execute(preStep.params, signer)
- const preStepResult = await transactionAdapter.confirm(preStepRef, confirmOptions)
- fireLifecycle(
- 'onPreStepComplete',
- globalLifecycle,
- localLifecycle,
- preStep,
- index,
- preStepResult,
- )
- setPreStepResults((previous) => [...previous, preStepResult])
+ const statuses = preStepStatusesRef.current
+ const pendingCount = statuses.filter((s) => s !== 'completed').length
+ if (statuses.length !== params.preSteps.length || pendingCount > 0) {
+ throw new PreStepsNotExecutedError(
+ statuses.length === params.preSteps.length ? pendingCount : params.preSteps.length,
+ )
+ }
+ // All pre-steps already completed manually — skip to main tx
+ } else {
+ setPhase('preStep')
+ for (const [index, preStep] of params.preSteps.entries()) {
+ fireLifecycle('onPreStep', globalLifecycle, localLifecycle, preStep, index)
+ const preStepRef = await transactionAdapter.execute(preStep.params, signer)
+ const preStepResult = await transactionAdapter.confirm(preStepRef, confirmOptions)
+ fireLifecycle(
+ 'onPreStepComplete',
+ globalLifecycle,
+ localLifecycle,
+ preStep,
+ index,
+ preStepResult,
+ )
+ setPreStepResults((previous) => [...previous, preStepResult])
+ }
}
}
@@ -210,15 +234,186 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
],
)
+ /**
+ * Standalone prepare — resolves the transaction adapter, calls prepare(),
+ * fires the onPrepare lifecycle, and initializes preStepStatuses.
+ *
+ * @precondition params.chainId must match a registered TransactionAdapter
+ * @postcondition prepareResult state is set; preStepStatuses initialized to 'pending' for each preStep
+ * @throws {AdapterNotFoundError} if no transaction adapter supports params.chainId
+ * @throws {TransactionNotReadyError} if prepare() returns ready === false
+ */
+ const prepare = useCallback(
+ async (params: TransactionParams): Promise => {
+ const chainIdStr = String(params.chainId)
+
+ try {
+ const transactionAdapter = Object.values(transactionAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+
+ if (!transactionAdapter) {
+ throw new AdapterNotFoundError(params.chainId, 'transaction')
+ }
+
+ setPhase('prepare')
+ const prepared = await transactionAdapter.prepare(params)
+ setPrepareResult(prepared)
+ fireLifecycle('onPrepare', globalLifecycle, localLifecycle, prepared)
+
+ if (!prepared.ready) {
+ throw new TransactionNotReadyError(prepared.reason ?? 'Transaction preparation failed.')
+ }
+
+ const stepCount = params.preSteps?.length ?? 0
+ const initialStatuses: PreStepStatus[] = Array.from({
+ length: stepCount,
+ }).fill('pending')
+ setPreStepStatuses(initialStatuses)
+ preStepStatusesRef.current = initialStatuses
+ preStepResultsRef.current = new Array(stepCount)
+
+ preparedParamsRef.current = params
+ setPhase('idle')
+ return prepared
+ } catch (err) {
+ const errorObj = err instanceof Error ? err : new Error(String(err))
+ setError(errorObj)
+ setPhase('idle')
+ throw errorObj
+ }
+ },
+ [transactionAdapters, globalLifecycle, localLifecycle],
+ )
+
+ /**
+ * Execute a single pre-step by index, updating its status through the
+ * executing -> completed | failed lifecycle.
+ *
+ * @precondition prepare() must have been called first
+ * @precondition index must be within bounds of the preSteps array
+ * @postcondition preStepStatuses[index] is 'completed' on success, 'failed' on error
+ * @throws {Error} if prepare() has not been called
+ * @throws {RangeError} if index is out of bounds
+ */
+ const executePreStep = useCallback(
+ async (index: number): Promise => {
+ const params = preparedParamsRef.current
+ if (!params) {
+ throw new Error('Cannot executePreStep: prepare() has not been called.')
+ }
+
+ const preSteps = params.preSteps ?? []
+ if (index < 0 || index >= preSteps.length) {
+ throw new RangeError(
+ `Pre-step index ${index} is out of bounds (0..${preSteps.length - 1}).`,
+ )
+ }
+
+ const chainIdStr = String(params.chainId)
+
+ const transactionAdapter = Object.values(transactionAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+ const walletAdapter = Object.values(walletAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+
+ if (!transactionAdapter) {
+ throw new AdapterNotFoundError(params.chainId, 'transaction')
+ }
+ if (!walletAdapter) {
+ throw new AdapterNotFoundError(params.chainId, 'wallet')
+ }
+
+ const signer = await walletAdapter.getSigner()
+ if (signer === null) {
+ throw new WalletNotConnectedError()
+ }
+
+ const preStep = preSteps[index]
+
+ const updateStatus = (status: PreStepStatus) => {
+ preStepStatusesRef.current = preStepStatusesRef.current.map((s, i) =>
+ i === index ? status : s,
+ )
+ setPreStepStatuses([...preStepStatusesRef.current])
+ }
+
+ try {
+ setPhase('preStep')
+ updateStatus('executing')
+
+ fireLifecycle('onPreStep', globalLifecycle, localLifecycle, preStep, index)
+ const preStepRef = await transactionAdapter.execute(preStep.params, signer)
+ const preStepResult = await transactionAdapter.confirm(preStepRef, confirmOptions)
+ fireLifecycle(
+ 'onPreStepComplete',
+ globalLifecycle,
+ localLifecycle,
+ preStep,
+ index,
+ preStepResult,
+ )
+
+ updateStatus('completed')
+
+ preStepResultsRef.current[index] = preStepResult
+ setPreStepResults([...preStepResultsRef.current])
+
+ setPhase('idle')
+ return preStepResult
+ } catch (err) {
+ updateStatus('failed')
+ const errorObj = err instanceof Error ? err : new Error(String(err))
+ setError(errorObj)
+ setPhase('idle')
+ throw errorObj
+ }
+ },
+ [transactionAdapters, walletAdapters, globalLifecycle, localLifecycle, confirmOptions],
+ )
+
+ /**
+ * Execute all pending pre-steps in order, skipping already-completed ones.
+ *
+ * @precondition prepare() must have been called first
+ * @postcondition all preStepStatuses are 'completed' on success
+ * @throws {Error} if prepare() has not been called
+ */
+ const executeAllPreSteps = useCallback(async (): Promise => {
+ const params = preparedParamsRef.current
+ if (!params) {
+ throw new Error('Cannot executeAllPreSteps: prepare() has not been called.')
+ }
+
+ const preSteps = params.preSteps ?? []
+ const results: TransactionResult[] = []
+
+ for (let i = 0; i < preSteps.length; i++) {
+ if (preStepStatusesRef.current[i] === 'completed') {
+ continue
+ }
+ const stepResult = await executePreStep(i)
+ results.push(stepResult)
+ }
+
+ return results
+ }, [executePreStep])
+
return {
phase,
prepareResult,
ref,
result,
preStepResults,
+ preStepStatuses,
explorerUrl,
error,
execute,
+ prepare,
+ executePreStep,
+ executeAllPreSteps,
reset,
}
}
diff --git a/src/transactions/components/TransactionButton.test.tsx b/src/transactions/components/TransactionButton.test.tsx
index 38073c8e..c0044c66 100644
--- a/src/transactions/components/TransactionButton.test.tsx
+++ b/src/transactions/components/TransactionButton.test.tsx
@@ -268,8 +268,12 @@ describe('TransactionButton', () => {
ref: null,
result: null,
preStepResults: [],
+ preStepStatuses: [],
explorerUrl: null,
error: null,
+ prepare: vi.fn(),
+ executePreStep: vi.fn(),
+ executeAllPreSteps: vi.fn(),
})
renderWithChakra(Send ETH)
@@ -288,8 +292,12 @@ describe('TransactionButton', () => {
ref: null,
result: null,
preStepResults: [],
+ preStepStatuses: [],
explorerUrl: null,
error: null,
+ prepare: vi.fn(),
+ executePreStep: vi.fn(),
+ executeAllPreSteps: vi.fn(),
})
renderWithChakra(
From 22857be05c35b7148b5fc36082a8f8da957715f8 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 16:45:58 +0200
Subject: [PATCH 05/52] feat: add transforming hooks (beforeCall, afterCall) to
wrapAdapter
---
src/sdk/core/utils/index.ts | 1 +
src/sdk/core/utils/wrap-adapter.test.ts | 150 ++++++++++++++++++++++++
src/sdk/core/utils/wrap-adapter.ts | 97 +++++++++++----
3 files changed, 228 insertions(+), 20 deletions(-)
diff --git a/src/sdk/core/utils/index.ts b/src/sdk/core/utils/index.ts
index 2c1d56ba..b70c8fb0 100644
--- a/src/sdk/core/utils/index.ts
+++ b/src/sdk/core/utils/index.ts
@@ -1 +1,2 @@
+export type { WrapAdapterHooks } from './wrap-adapter'
export { wrapAdapter } from './wrap-adapter'
diff --git a/src/sdk/core/utils/wrap-adapter.test.ts b/src/sdk/core/utils/wrap-adapter.test.ts
index a0b401f0..183efcd8 100644
--- a/src/sdk/core/utils/wrap-adapter.test.ts
+++ b/src/sdk/core/utils/wrap-adapter.test.ts
@@ -108,4 +108,154 @@ describe('wrapAdapter', () => {
wrapped.getChainId()
expect(order).toEqual(['before', 'method', 'after'])
})
+
+ describe('transforming hooks', () => {
+ it('beforeCall can modify the arguments passed to the method', async () => {
+ const adapter = { greet: async (name: string) => `hello ${name}` }
+ const wrapped = wrapAdapter(adapter, {
+ beforeCall(_method, args) {
+ return [`${args[0]}!`]
+ },
+ })
+ const result = await wrapped.greet('world')
+ expect(result).toBe('hello world!')
+ })
+
+ it('beforeCall returning the input unchanged acts as pass-through', async () => {
+ const adapter = { greet: async (name: string) => `hello ${name}` }
+ const wrapped = wrapAdapter(adapter, {
+ beforeCall(_method, args) {
+ return args
+ },
+ })
+ const result = await wrapped.greet('world')
+ expect(result).toBe('hello world')
+ })
+
+ it('afterCall can modify the result', async () => {
+ const adapter = { getValue: async () => 42 }
+ const wrapped = wrapAdapter(adapter, {
+ afterCall(_method, result) {
+ return (result as number) * 2
+ },
+ })
+ const result = await wrapped.getValue()
+ expect(result).toBe(84)
+ })
+
+ it('afterCall returning the input unchanged acts as pass-through', async () => {
+ const adapter = { getValue: async () => 42 }
+ const wrapped = wrapAdapter(adapter, {
+ afterCall(_method, result) {
+ return result
+ },
+ })
+ const result = await wrapped.getValue()
+ expect(result).toBe(42)
+ })
+
+ it('beforeCall runs before onBefore', async () => {
+ const order: string[] = []
+ const adapter = { doWork: async () => 'done' }
+ const wrapped = wrapAdapter(adapter, {
+ beforeCall(_method, args) {
+ order.push('beforeCall')
+ return args
+ },
+ onBefore() {
+ order.push('onBefore')
+ },
+ })
+ await wrapped.doWork()
+ expect(order).toEqual(['beforeCall', 'onBefore'])
+ })
+
+ it('afterCall runs after onAfter', async () => {
+ const order: string[] = []
+ const adapter = { doWork: async () => 'done' }
+ const wrapped = wrapAdapter(adapter, {
+ onAfter() {
+ order.push('onAfter')
+ },
+ afterCall(_method, result) {
+ order.push('afterCall')
+ return result
+ },
+ })
+ await wrapped.doWork()
+ expect(order).toEqual(['onAfter', 'afterCall'])
+ })
+
+ it('full execution order: beforeCall -> onBefore -> method -> onAfter -> afterCall', async () => {
+ const order: string[] = []
+ const adapter = {
+ doWork: async () => {
+ order.push('method')
+ return 'done'
+ },
+ }
+ const wrapped = wrapAdapter(adapter, {
+ beforeCall(_method, args) {
+ order.push('beforeCall')
+ return args
+ },
+ onBefore() {
+ order.push('onBefore')
+ },
+ onAfter() {
+ order.push('onAfter')
+ },
+ afterCall(_method, result) {
+ order.push('afterCall')
+ return result
+ },
+ })
+ await wrapped.doWork()
+ expect(order).toEqual(['beforeCall', 'onBefore', 'method', 'onAfter', 'afterCall'])
+ })
+
+ it('beforeCall errors abort the call (not fire-and-forget)', () => {
+ const adapter = { doWork: async () => 'done' }
+ const wrapped = wrapAdapter(adapter, {
+ beforeCall() {
+ throw new Error('beforeCall failed')
+ },
+ })
+ expect(() => wrapped.doWork()).toThrow('beforeCall failed')
+ })
+
+ it('afterCall errors abort the call (not fire-and-forget)', async () => {
+ const adapter = { doWork: async () => 'done' }
+ const wrapped = wrapAdapter(adapter, {
+ afterCall() {
+ throw new Error('afterCall failed')
+ },
+ })
+ await expect(wrapped.doWork()).rejects.toThrow('afterCall failed')
+ })
+
+ it('works with synchronous methods', () => {
+ const adapter = { getValue: () => 42 }
+ const wrapped = wrapAdapter(adapter, {
+ afterCall(_method, result) {
+ return (result as number) * 2
+ },
+ })
+ const result = wrapped.getValue()
+ expect(result).toBe(84)
+ expect(result).not.toBeInstanceOf(Promise)
+ })
+
+ it('beforeCall can transform args for synchronous methods', () => {
+ const adapter = { greet: (name: string) => `hello ${name}` }
+ const wrapped = wrapAdapter(adapter, {
+ beforeCall(_method, args) {
+ return [`${args[0]}!`]
+ },
+ })
+ const result = wrapped.greet('world')
+ expect(result).toBe('hello world!')
+ expect(result).not.toBeInstanceOf(Promise)
+ })
+ })
})
diff --git a/src/sdk/core/utils/wrap-adapter.ts b/src/sdk/core/utils/wrap-adapter.ts
index 515c1e4b..8e93b525 100644
--- a/src/sdk/core/utils/wrap-adapter.ts
+++ b/src/sdk/core/utils/wrap-adapter.ts
@@ -1,9 +1,53 @@
/**
- * Wraps every function method on an adapter with optional before/after/error hooks.
+ * Wraps every function method on an adapter with optional observation and transform hooks.
* Returns a new object with identical interface; original adapter is untouched.
- * Hooks are fire-and-forget: hook errors are caught and ignored to avoid aborting adapter calls.
+ *
+ * Observation hooks (`onBefore`, `onAfter`, `onError`) are fire-and-forget: hook errors are
+ * caught and ignored to avoid aborting adapter calls.
+ *
+ * Transform hooks (`beforeCall`, `afterCall`) propagate errors and can modify args/results.
+ * Returning `void` from a transform hook passes the original value through.
*/
+/** Hook configuration for `wrapAdapter`. */
+export interface WrapAdapterHooks {
+ /**
+ * Transform hook that runs before the method call.
+ * Always return an args array — return the input unchanged for pass-through.
+ * Errors propagate (not fire-and-forget).
+ *
+ * @precondition args is the original arguments array
+ * @postcondition returned array replaces args for the method call
+ * @throws any error thrown here aborts the method call
+ */
+ beforeCall?(method: string, args: unknown[]): unknown[]
+ /**
+ * Observation hook that runs after `beforeCall` and before the method executes.
+ * Fire-and-forget: errors are caught and ignored.
+ */
+ onBefore?(method: string, args: unknown[]): void
+ /**
+ * Observation hook that runs after the method returns (before `afterCall`).
+ * Fire-and-forget: errors are caught and ignored.
+ */
+ onAfter?(method: string, result: unknown): void
+ /**
+ * Observation hook that runs when the method throws or rejects.
+ * Fire-and-forget: errors are caught and ignored.
+ */
+ onError?(method: string, error: Error): void
+ /**
+ * Transform hook that runs after `onAfter`.
+ * Always return a value — return the input unchanged for pass-through.
+ * Errors propagate (not fire-and-forget).
+ *
+ * @precondition result is the resolved value from the method
+ * @postcondition returned value replaces the method result
+ * @throws any error thrown here aborts the call
+ */
+ afterCall?(method: string, result: unknown): unknown
+}
+
/** Collects own + inherited enumerable property keys up to (but not including) Object.prototype. */
function collectMethodKeys(obj: object): string[] {
const keys = new Set()
@@ -17,14 +61,16 @@ function collectMethodKeys(obj: object): string[] {
return [...keys]
}
-export function wrapAdapter(
- adapter: T,
- hooks: {
- onBefore?(method: string, args: unknown[]): void
- onAfter?(method: string, result: unknown): void
- onError?(method: string, error: Error): void
- },
-): T {
+/**
+ * Wraps every function method on an adapter with optional observation and transform hooks.
+ *
+ * Execution order: `beforeCall` -> `onBefore` -> method -> `onAfter` -> `afterCall`
+ *
+ * @precondition adapter is a non-null object
+ * @postcondition returned object has the same interface as adapter with hooks applied
+ * @throws errors from `beforeCall`/`afterCall` propagate; observation hook errors are swallowed
+ */
+export function wrapAdapter(adapter: T, hooks: WrapAdapterHooks): T {
const wrapped: Record = {}
for (const key of collectMethodKeys(adapter as object)) {
@@ -34,20 +80,25 @@ export function wrapAdapter(
continue
}
wrapped[key] = function wrappedMethod(...args: unknown[]) {
+ // 1. beforeCall — transform hook, errors propagate
+ const effectiveArgs = hooks.beforeCall ? hooks.beforeCall(key, args) : args
+
+ // 2. onBefore — observation hook, fire-and-forget
try {
- hooks.onBefore?.(key, args)
+ hooks.onBefore?.(key, effectiveArgs)
} catch {
- // hook errors are caught and ignored
+ // observation hook errors are caught and ignored
}
+ // 3. Execute the method
let result: unknown
try {
- result = (value as (...a: unknown[]) => unknown).apply(adapter, args)
+ result = (value as (...a: unknown[]) => unknown).apply(adapter, effectiveArgs)
} catch (error) {
try {
hooks.onError?.(key, error instanceof Error ? error : new Error(String(error)))
} catch {
- // hook errors are caught and ignored
+ // observation hook errors are caught and ignored
}
throw error
}
@@ -56,30 +107,36 @@ export function wrapAdapter(
if (result instanceof Promise) {
return (result as Promise)
.then((resolved: unknown) => {
+ // 4. onAfter — observation hook, fire-and-forget
try {
hooks.onAfter?.(key, resolved)
} catch {
- // hook errors are caught and ignored
+ // observation hook errors are caught and ignored
}
- return resolved
+
+ // 5. afterCall — transform hook, errors propagate
+ return hooks.afterCall ? hooks.afterCall(key, resolved) : resolved
})
.catch((error: unknown) => {
try {
hooks.onError?.(key, error instanceof Error ? error : new Error(String(error)))
} catch {
- // hook errors are caught and ignored
+ // observation hook errors are caught and ignored
}
throw error
})
}
- // Synchronous method: fire after hook immediately
+ // Synchronous method: fire hooks immediately
+ // 4. onAfter — observation hook, fire-and-forget
try {
hooks.onAfter?.(key, result)
} catch {
- // hook errors are caught and ignored
+ // observation hook errors are caught and ignored
}
- return result
+
+ // 5. afterCall — transform hook, errors propagate
+ return hooks.afterCall ? hooks.afterCall(key, result) : result
}
}
From 4122b4d511bc9b833a6a30ed58d0afbd4cf14c23 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 17:01:34 +0200
Subject: [PATCH 06/52] feat: add multi-chain WalletGuard with require prop for
bridge-style gating
---
src/sdk/react/components/WalletGuard.test.tsx | 105 +++++++++++++++++-
src/sdk/react/components/WalletGuard.tsx | 89 ++++++++++++++-
src/sdk/react/components/index.ts | 2 +-
3 files changed, 191 insertions(+), 5 deletions(-)
diff --git a/src/sdk/react/components/WalletGuard.test.tsx b/src/sdk/react/components/WalletGuard.test.tsx
index 5755efc6..fa49246a 100644
--- a/src/sdk/react/components/WalletGuard.test.tsx
+++ b/src/sdk/react/components/WalletGuard.test.tsx
@@ -2,7 +2,7 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
import { render, screen } from '@testing-library/react'
import { createElement, type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { WalletGuard } from './WalletGuard'
+import { WalletGuard, type WalletRequirement } from './WalletGuard'
const mockSwitchChain = vi.fn()
@@ -29,6 +29,12 @@ vi.mock('../hooks', () => ({
getChainsByType: vi.fn(() => []),
getAllChains: vi.fn(() => []),
})),
+ useMultiWallet: vi.fn(() => ({
+ wallets: {},
+ getWallet: vi.fn(() => undefined),
+ getWalletByChainId: vi.fn(() => undefined),
+ connectedAddresses: {},
+ })),
}))
vi.mock('@/src/wallet/providers', () => ({
@@ -49,9 +55,10 @@ vi.mock('@/src/wallet/components/SwitchChainButton', () => ({
),
}))
-const { useWallet, useChainRegistry } = await import('../hooks')
+const { useWallet, useChainRegistry, useMultiWallet } = await import('../hooks')
const mockedUseWallet = vi.mocked(useWallet)
const mockedUseChainRegistry = vi.mocked(useChainRegistry)
+const mockedUseMultiWallet = vi.mocked(useMultiWallet)
const system = createSystem(defaultConfig)
@@ -178,3 +185,97 @@ describe('WalletGuard', () => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument()
})
})
+
+describe('WalletGuard multi-chain (require prop)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders children when all requirements are met', () => {
+ const evmWallet = makeWalletReady()
+ const svmWallet = {
+ ...makeWalletReady(),
+ adapterKey: 'svm',
+ adapter: { chainType: 'svm', supportedChains: [] } as never,
+ }
+
+ mockedUseMultiWallet.mockReturnValue({
+ wallets: { evm: evmWallet, svm: svmWallet },
+ getWallet: vi.fn((chainType: string) => {
+ if (chainType === 'evm') {
+ return evmWallet
+ }
+ if (chainType === 'svm') {
+ return svmWallet
+ }
+ return undefined
+ }),
+ getWalletByChainId: vi.fn(() => undefined),
+ connectedAddresses: { evm: '0xabc', svm: 'abc123' },
+ })
+
+ const requirements: WalletRequirement[] = [{ chainType: 'evm' }, { chainType: 'svm' }]
+
+ renderWithChakra(
+ createElement(
+ WalletGuard,
+ { require: requirements },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(screen.getByTestId('protected-content')).toBeInTheDocument()
+ expect(screen.queryByTestId('connect-wallet-button')).toBeNull()
+ })
+
+ it('renders fallback when first requirement is not met', () => {
+ mockedUseMultiWallet.mockReturnValue({
+ wallets: {},
+ getWallet: vi.fn(() => undefined),
+ getWalletByChainId: vi.fn(() => undefined),
+ connectedAddresses: {},
+ })
+
+ const requirements: WalletRequirement[] = [{ chainType: 'evm' }]
+
+ renderWithChakra(
+ createElement(
+ WalletGuard,
+ { require: requirements },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
+ expect(screen.queryByTestId('protected-content')).toBeNull()
+ })
+
+ it('renders fallback for second unmet requirement when first is met', () => {
+ const evmWallet = makeWalletReady()
+
+ mockedUseMultiWallet.mockReturnValue({
+ wallets: { evm: evmWallet },
+ getWallet: vi.fn((chainType: string) => {
+ if (chainType === 'evm') {
+ return evmWallet
+ }
+ return undefined
+ }),
+ getWalletByChainId: vi.fn(() => undefined),
+ connectedAddresses: { evm: '0xabc' },
+ })
+
+ const requirements: WalletRequirement[] = [{ chainType: 'evm' }, { chainType: 'svm' }]
+
+ renderWithChakra(
+ createElement(
+ WalletGuard,
+ { require: requirements },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
+ expect(screen.queryByTestId('protected-content')).toBeNull()
+ })
+})
diff --git a/src/sdk/react/components/WalletGuard.tsx b/src/sdk/react/components/WalletGuard.tsx
index 58e7a7e2..9c3b62f6 100644
--- a/src/sdk/react/components/WalletGuard.tsx
+++ b/src/sdk/react/components/WalletGuard.tsx
@@ -1,13 +1,29 @@
import type { FC, ReactElement, ReactNode } from 'react'
import SwitchChainButton from '@/src/wallet/components/SwitchChainButton'
import { ConnectWalletButton } from '@/src/wallet/providers'
-import { useChainRegistry, useWallet } from '../hooks'
+import { useChainRegistry, useMultiWallet, useWallet } from '../hooks'
+
+/** A single wallet requirement for multi-chain gating. */
+export interface WalletRequirement {
+ /** Resolve requirement by specific chain ID. */
+ chainId?: string | number
+ /** Resolve requirement by chain type (e.g. 'evm', 'svm'). */
+ chainType?: string
+ /** Human-readable label for fallback UI context. */
+ label?: string
+}
export interface WalletGuardProps {
chainId?: string | number
chainType?: string
children?: ReactNode
fallback?: ReactElement
+ /**
+ * Multi-chain requirements. When provided, the guard checks each requirement
+ * and renders children only when all are met. Mutually exclusive with
+ * top-level chainId/chainType props.
+ */
+ require?: WalletRequirement[]
switchChainLabel?: string
}
@@ -15,8 +31,31 @@ export interface WalletGuardProps {
* Gates content on wallet connection and correct chain.
* Shows ConnectWalletButton when disconnected, SwitchChainButton when on wrong chain,
* or renders children when ready.
+ *
+ * Supports two mutually exclusive modes:
+ * - **Single-chain** (chainId/chainType props): uses useWallet for one adapter
+ * - **Multi-chain** (require prop): uses useMultiWallet, checks each requirement
+ *
+ * @precondition Either `require` or `chainId`/`chainType` should be provided, not both
+ * @postcondition Renders children only when all wallet requirements are satisfied
+ * @throws Never — renders fallback UI instead of throwing
*/
-export const WalletGuard: FC = ({
+export const WalletGuard: FC = (props) => {
+ const { require: requirements, children } = props
+
+ if (requirements && requirements.length > 0) {
+ return {children}
+ }
+
+ return
+}
+
+/**
+ * Internal component for single-chain wallet gating (original behavior).
+ * @precondition useWallet hook is available via provider context
+ * @postcondition Renders children when wallet is connected to the correct chain
+ */
+const SingleChainGuard: FC = ({
chainId,
chainType,
children,
@@ -46,3 +85,49 @@ export const WalletGuard: FC = ({
return children
}
+
+interface MultiChainGuardProps {
+ requirements: WalletRequirement[]
+ children?: ReactNode
+}
+
+/**
+ * Internal component for multi-chain wallet gating.
+ * Iterates requirements and renders fallback for the first unmet one.
+ * @precondition useMultiWallet hook is available via provider context
+ * @postcondition Renders children only when every requirement has a connected wallet
+ */
+const MultiChainGuard: FC = ({ requirements, children }) => {
+ const { getWallet, getWalletByChainId } = useMultiWallet()
+ const registry = useChainRegistry()
+
+ for (const requirement of requirements) {
+ const wallet =
+ requirement.chainId !== undefined
+ ? getWalletByChainId(requirement.chainId)
+ : requirement.chainType !== undefined
+ ? getWallet(requirement.chainType)
+ : undefined
+
+ if (!wallet || wallet.needsConnect) {
+ return (
+
+ )
+ }
+
+ const requirementChainId = requirement.chainId
+ if (wallet.needsChainSwitch && requirementChainId !== undefined) {
+ const targetChain = registry.getChain(requirementChainId)
+ return (
+ wallet.switchChain(requirementChainId)}>
+ Switch to {targetChain?.name ?? String(requirementChainId)}
+
+ )
+ }
+ }
+
+ return children
+}
diff --git a/src/sdk/react/components/index.ts b/src/sdk/react/components/index.ts
index 058d8c98..88c99303 100644
--- a/src/sdk/react/components/index.ts
+++ b/src/sdk/react/components/index.ts
@@ -1,3 +1,3 @@
export { ConnectWalletButton } from './ConnectWalletButton'
-export type { WalletGuardProps } from './WalletGuard'
+export type { WalletGuardProps, WalletRequirement } from './WalletGuard'
export { WalletGuard } from './WalletGuard'
From b69dc6c7fffb979fb39caf3fe74b8897677ab8e8 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 17:04:59 +0200
Subject: [PATCH 07/52] docs: add consumer error handling guide to adapter
architecture spec
---
.../architecture/adapter-architecture-spec.md | 109 ++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/docs/architecture/adapter-architecture-spec.md b/docs/architecture/adapter-architecture-spec.md
index 8e7bc4f4..ff7f8ff7 100644
--- a/docs/architecture/adapter-architecture-spec.md
+++ b/docs/architecture/adapter-architecture-spec.md
@@ -1804,6 +1804,115 @@ const bridge = useTransactionFlow([
---
+## Consumer Error Handling Guide
+
+This section maps every user-facing action to the exact SDK errors it can produce, documents whether the hook handles those errors internally or propagates them, and clarifies the two distinct error handling patterns consumers encounter.
+
+### Per-action error table
+
+Each row lists a user action, the SDK error classes that can be thrown, and how the hook layer handles them. "Propagates" means the consumer must `try/catch`. "Catches + re-throws" means the hook sets its `error` state AND re-throws — the consumer can read `error` reactively or `try/catch` the async call.
+
+| User Action | Possible Errors | Hook Behavior |
+|---|---|---|
+| Connect wallet | `WalletNotInstalledError`, `WalletConnectionRejectedError`, `ChainNotSupportedError` | `useWallet().connect()` propagates — consumer must try/catch |
+| Disconnect wallet | _(none — no-op if already disconnected)_ | `useWallet().disconnect()` propagates (but does not throw in practice) |
+| Sign message | `WalletNotConnectedError`, `SigningRejectedError` | `useWallet().signMessage()` propagates — consumer must try/catch |
+| Sign typed data | `WalletNotConnectedError`, `SigningRejectedError`, `CapabilityNotSupportedError` | `useWallet().signTypedData()` propagates — consumer must try/catch. `CapabilityNotSupportedError` is thrown synchronously if the adapter does not support the capability. |
+| Switch chain | `WalletNotConnectedError`, `ChainNotSupportedError` | `useWallet().switchChain()` propagates — consumer must try/catch |
+| Execute transaction | `AdapterNotFoundError`, `WalletNotConnectedError`, `TransactionNotReadyError`, `PreStepsNotExecutedError`, `InsufficientFundsError`, `InvalidSignerError`, `ChainNotSupportedError` | `useTransaction().execute()` catches all, sets `error` state, fires `lifecycle.onError`, AND re-throws |
+| Prepare transaction | `AdapterNotFoundError`, `TransactionNotReadyError`, `InsufficientFundsError` | `useTransaction().prepare()` catches all, sets `error` state AND re-throws |
+| Execute single pre-step | `Error` (prepare not called), `RangeError` (index out of bounds), `AdapterNotFoundError`, `WalletNotConnectedError` | `useTransaction().executePreStep()` — precondition errors (`Error`, `RangeError`, `AdapterNotFoundError`, `WalletNotConnectedError`) propagate directly without setting `error` state. Execution errors (from adapter `execute`/`confirm`) are caught, set `error` state, and re-thrown. |
+| Execute all pre-steps | Same as single pre-step (delegates to `executePreStep`) | `useTransaction().executeAllPreSteps()` — errors propagate from `executePreStep` |
+| Resolve wallet adapter | `AdapterNotFoundError`, `AmbiguousAdapterError` | `useWallet()` throws synchronously during render — React error boundary catches |
+| Provider initialization | `ChainRegistryConflictError`, `Error` (chainType mismatch) | `DAppBoosterProvider` throws synchronously during render — React error boundary catches |
+
+### Error semantics: `ChainNotSupportedError` vs `AdapterNotFoundError`
+
+Both errors mean "this chain doesn't work" to a consumer, but they occur at different levels:
+
+- **`AdapterNotFoundError`** — thrown by the hook resolution layer. No registered adapter's `supportedChains` includes the requested `chainId`. This means the SDK has no adapter at all for this chain.
+ - **Throw sites:** `useWallet()` (via `resolveAdapter()`), `useTransaction().execute()`, `useTransaction().prepare()`, `useTransaction().executePreStep()`
+
+- **`ChainNotSupportedError`** — thrown by the adapter itself. An adapter was found but its own validation rejects the `chainId`. This occurs inside adapter methods like `connect()`, `switchChain()`, and `execute()`.
+ - **Throw sites:** `WalletAdapter.connect()` (when `options.chainId` is not in `supportedChains`), `WalletAdapter.switchChain()`, `TransactionAdapter.execute()`
+
+In practice, `AdapterNotFoundError` fires first (during resolution) if no adapter matches. `ChainNotSupportedError` fires later (during execution) if the adapter was resolved via a different chain but the target chain is not supported.
+
+### Error semantics: `InvalidSignerError`
+
+`InvalidSignerError` is an internal safety check at the adapter boundary. The `TransactionAdapter.execute()` method validates that the signer it receives is the correct type (e.g., a viem `WalletClient` for EVM). This guards against passing an SVM signer to an EVM adapter. Consumers should not normally encounter this error — it indicates a misconfigured adapter stack, not a user action failure.
+
+### Hook error handling patterns
+
+The SDK uses two distinct patterns for error handling at the hook layer:
+
+#### Pattern 1: `useWallet` methods — raw passthrough
+
+All `useWallet()` methods (`connect`, `disconnect`, `signMessage`, `signTypedData`, `switchChain`, `getSigner`) delegate directly to the adapter. Errors propagate unmodified to the consumer. There is no `error` state on the return object.
+
+```ts
+const { connect, signMessage } = useWallet({ chainType: 'evm' })
+
+try {
+ await connect()
+} catch (err) {
+ if (err instanceof WalletConnectionRejectedError) {
+ // User cancelled — show a dismissable message
+ }
+ if (err instanceof WalletNotInstalledError) {
+ // Wallet not found — show install instructions
+ }
+}
+
+try {
+ const result = await signMessage({ message: 'Hello' })
+} catch (err) {
+ if (err instanceof SigningRejectedError) {
+ // User cancelled signing
+ }
+}
+```
+
+The `signMessage` and `signTypedData` wrappers fire `walletLifecycle.onSignError` before re-throwing, but the error still propagates to the consumer.
+
+#### Pattern 2: `useTransaction` methods — catch, set state, re-throw
+
+All `useTransaction()` async methods (`execute`, `prepare`, `executePreStep`, `executeAllPreSteps`) catch errors, set the `error` property on the hook return, and re-throw. Consumers can handle errors in two ways:
+
+**Reactive (read `error` state):**
+
+```ts
+const { execute, error, phase } = useTransaction()
+
+// In a click handler:
+execute(params).catch(() => {}) // swallow — read error reactively
+
+// In JSX:
+{error && }
+```
+
+**Imperative (try/catch):**
+
+```ts
+const { execute } = useTransaction()
+
+try {
+ const result = await execute(params)
+ // success
+} catch (err) {
+ if (err instanceof TransactionNotReadyError) {
+ // preparation failed
+ }
+ if (err instanceof PreStepsNotExecutedError) {
+ // manual pre-steps not completed
+ }
+}
+```
+
+**Important nuance for `executePreStep`:** Precondition checks (prepare not called, index out of bounds, adapter not found, wallet not connected) throw directly without setting `error` state. Only errors during the actual on-chain execution (adapter `execute`/`confirm` calls) go through the catch-set-rethrow pattern. This means the reactive `error` state only captures execution failures, not programmer errors.
+
+---
+
## 12. Migration Path
### From current codebase to adapter architecture
From 27d1307ce4221ac7173346846b3fd07bfdae520f Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 17:22:24 +0200
Subject: [PATCH 08/52] refactor: migrate token infrastructure from
useWeb3Status to useWallet
---
.../AddERC20TokenButton.migration.test.ts | 24 +++++++++++++++++++
.../TokenSelect/List/AddERC20TokenButton.tsx | 8 +++++--
.../TokenSelect/TokenSelect.migration.test.ts | 23 ++++++++++++++++++
src/tokens/components/TokenSelect/index.tsx | 12 +++++++---
src/tokens/hooks/useTokens.migration.test.ts | 18 ++++++++++++++
src/tokens/hooks/useTokens.ts | 5 ++--
6 files changed, 83 insertions(+), 7 deletions(-)
create mode 100644 src/tokens/components/TokenSelect/List/AddERC20TokenButton.migration.test.ts
create mode 100644 src/tokens/components/TokenSelect/TokenSelect.migration.test.ts
create mode 100644 src/tokens/hooks/useTokens.migration.test.ts
diff --git a/src/tokens/components/TokenSelect/List/AddERC20TokenButton.migration.test.ts b/src/tokens/components/TokenSelect/List/AddERC20TokenButton.migration.test.ts
new file mode 100644
index 00000000..7651f981
--- /dev/null
+++ b/src/tokens/components/TokenSelect/List/AddERC20TokenButton.migration.test.ts
@@ -0,0 +1,24 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Migration test: AddERC20TokenButton must use useWallet from the SDK instead of useWeb3Status.
+ */
+describe('AddERC20TokenButton migration', () => {
+ it('does not import from @/src/wallet/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './AddERC20TokenButton.tsx'), 'utf-8')
+ expect(source).not.toContain('@/src/wallet/hooks')
+ })
+
+ it('imports useWallet from @/src/sdk/react/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './AddERC20TokenButton.tsx'), 'utf-8')
+ expect(source).toContain("from '@/src/sdk/react/hooks'")
+ })
+
+ it('imports useWalletClient from wagmi', () => {
+ const source = readFileSync(resolve(__dirname, './AddERC20TokenButton.tsx'), 'utf-8')
+ expect(source).toContain("from 'wagmi'")
+ expect(source).toContain('useWalletClient')
+ })
+})
diff --git a/src/tokens/components/TokenSelect/List/AddERC20TokenButton.tsx b/src/tokens/components/TokenSelect/List/AddERC20TokenButton.tsx
index ad4788e0..3b8972ae 100644
--- a/src/tokens/components/TokenSelect/List/AddERC20TokenButton.tsx
+++ b/src/tokens/components/TokenSelect/List/AddERC20TokenButton.tsx
@@ -1,7 +1,8 @@
import { chakra } from '@chakra-ui/react'
import type { ComponentPropsWithoutRef, FC, MouseEventHandler } from 'react'
+import { useWalletClient } from 'wagmi'
import { isNativeToken } from '@/src/core/utils'
-import { useWeb3Status } from '@/src/wallet/hooks'
+import { useWallet } from '@/src/sdk/react/hooks'
import type { Token } from '../../../types'
interface AddERC20TokenButtonProps extends ComponentPropsWithoutRef<'button'> {
@@ -20,7 +21,10 @@ const AddERC20TokenButton: FC = ({
onClick,
...restProps
}) => {
- const { isWalletConnected, walletChainId, walletClient } = useWeb3Status()
+ const { status } = useWallet()
+ const { data: walletClient } = useWalletClient()
+ const isWalletConnected = status.connected
+ const walletChainId = status.connectedChainIds[0] as number | undefined
const { address, chainId, decimals, logoURI, symbol } = $token
const disabled = !isWalletConnected || walletChainId !== chainId
diff --git a/src/tokens/components/TokenSelect/TokenSelect.migration.test.ts b/src/tokens/components/TokenSelect/TokenSelect.migration.test.ts
new file mode 100644
index 00000000..b58436b3
--- /dev/null
+++ b/src/tokens/components/TokenSelect/TokenSelect.migration.test.ts
@@ -0,0 +1,23 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Migration test: TokenSelect must use useWallet and useChainRegistry from the SDK instead of useWeb3Status.
+ */
+describe('TokenSelect migration', () => {
+ it('does not import from @/src/wallet/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './index.tsx'), 'utf-8')
+ expect(source).not.toContain('@/src/wallet/hooks')
+ })
+
+ it('imports useWallet from @/src/sdk/react/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './index.tsx'), 'utf-8')
+ expect(source).toContain("from '@/src/sdk/react/hooks'")
+ })
+
+ it('imports useChainRegistry from @/src/sdk/react/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './index.tsx'), 'utf-8')
+ expect(source).toContain('useChainRegistry')
+ })
+})
diff --git a/src/tokens/components/TokenSelect/index.tsx b/src/tokens/components/TokenSelect/index.tsx
index 79b28809..51a16946 100644
--- a/src/tokens/components/TokenSelect/index.tsx
+++ b/src/tokens/components/TokenSelect/index.tsx
@@ -1,9 +1,9 @@
import { Flex, type FlexProps } from '@chakra-ui/react'
import { useEffect, useRef, useState } from 'react'
import type { Chain } from 'viem/chains'
-import { chains } from '@/src/core/types'
+import { type ChainsIds, chains } from '@/src/core/types'
import { withSuspenseAndRetry } from '@/src/core/utils'
-import { useWeb3Status } from '@/src/wallet/hooks'
+import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks'
import { useTokenSearch } from '../../hooks/useTokenSearch'
import { useTokens } from '../../hooks/useTokens'
import type { Token } from '../../types'
@@ -61,7 +61,13 @@ const TokenSelect = withSuspenseAndRetry(
showTopTokens = false,
...restProps
}) => {
- const { appChainId, walletChainId } = useWeb3Status()
+ const { status } = useWallet()
+ const registry = useChainRegistry()
+ const walletChainId = status.connectedChainIds[0] as number | undefined
+ const allChains = registry.getAllChains()
+ const appChainId = (allChains.length > 0 ? allChains[0].chainId : undefined) as
+ | ChainsIds
+ | undefined
const [chainId, setChainId] = useState(() =>
getValidChainId({
diff --git a/src/tokens/hooks/useTokens.migration.test.ts b/src/tokens/hooks/useTokens.migration.test.ts
new file mode 100644
index 00000000..41cfe867
--- /dev/null
+++ b/src/tokens/hooks/useTokens.migration.test.ts
@@ -0,0 +1,18 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Migration test: useTokens must use useWallet from the SDK instead of useWeb3Status.
+ */
+describe('useTokens migration', () => {
+ it('does not import from @/src/wallet/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './useTokens.ts'), 'utf-8')
+ expect(source).not.toContain('@/src/wallet/hooks')
+ })
+
+ it('imports useWallet from @/src/sdk/react/hooks', () => {
+ const source = readFileSync(resolve(__dirname, './useTokens.ts'), 'utf-8')
+ expect(source).toContain("from '@/src/sdk/react/hooks'")
+ })
+})
diff --git a/src/tokens/hooks/useTokens.ts b/src/tokens/hooks/useTokens.ts
index a46e4448..b3187f8a 100644
--- a/src/tokens/hooks/useTokens.ts
+++ b/src/tokens/hooks/useTokens.ts
@@ -13,7 +13,7 @@ import { type Address, type Chain, formatUnits } from 'viem'
import { logger } from '@/src/core/utils'
import { env } from '@/src/env'
-import { useWeb3Status } from '@/src/wallet/hooks'
+import { useWallet } from '@/src/sdk/react/hooks'
import type { Token, Tokens } from '../types'
import type { TokensMap } from '../utils/tokenListsCache'
import { useTokenLists } from './useTokenLists'
@@ -77,7 +77,8 @@ export const useTokens = (
withBalance: true,
},
) => {
- const { address } = useWeb3Status()
+ const { status } = useWallet()
+ const address = status.activeAccount as Address | undefined
const tokensData = useTokenLists()
account ??= address
From bc377131d664c931b6237b4294f10ab8324aaab5 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 17:31:47 +0200
Subject: [PATCH 09/52] refactor: migrate remaining useWeb3Status consumers to
SDK hooks
---
.../home/Examples/demos/HashHandling/index.test.tsx | 7 +++----
.../home/Examples/demos/HashHandling/index.tsx | 6 ++++--
.../home/Examples/demos/SwitchNetwork/index.test.tsx | 6 +++---
.../home/Examples/demos/SwitchNetwork/index.tsx | 6 ++++--
.../home/Examples/demos/TokenInput/index.test.tsx | 8 +++++---
.../home/Examples/demos/TokenInput/index.tsx | 6 ++++--
src/core/ui/NotificationToast.tsx | 6 +-----
src/wallet/components/SwitchNetwork.tsx | 9 ++++++---
8 files changed, 30 insertions(+), 24 deletions(-)
diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx
index 5dcfd3e0..45d80096 100644
--- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx
@@ -5,10 +5,9 @@ import hashHandling from './index'
const system = createSystem(defaultConfig)
-vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => ({
- isWalletConnected: false,
- walletChainId: undefined,
+vi.mock('@/src/sdk/react/hooks', () => ({
+ useWallet: vi.fn(() => ({
+ status: { connected: false, activeAccount: null, connectedChainIds: [], connecting: false },
})),
}))
diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx
index 4854ba23..63eea909 100644
--- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx
@@ -7,7 +7,7 @@ import Icon from '@/src/components/pageComponents/home/Examples/demos/HashHandli
import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { HashInput, Spinner } from '@/src/core/components'
import type { DetectedHash } from '@/src/core/utils'
-import { useWeb3Status } from '@/src/wallet/hooks'
+import { useWallet } from '@/src/sdk/react/hooks'
const AlertIcon = () => (
{
const [loading, setLoading] = useState()
const notFound = searchResult && searchResult.type === null
const found = searchResult && searchResult.type !== null
- const { isWalletConnected, walletChainId } = useWeb3Status()
+ const { status } = useWallet()
+ const isWalletConnected = status.connected
+ const walletChainId = status.connectedChainIds[0] as number | undefined
const onLoading = (isLoading: boolean) => {
setLoading(isLoading)
diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx
index 9f930c9f..10d44eea 100644
--- a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx
@@ -5,9 +5,9 @@ import switchNetwork from './index'
const system = createSystem(defaultConfig)
-vi.mock('@/src/wallet/hooks', () => ({
- useWeb3Status: vi.fn(() => ({
- isWalletConnected: false,
+vi.mock('@/src/sdk/react/hooks', () => ({
+ useWallet: vi.fn(() => ({
+ status: { connected: false, activeAccount: null, connectedChainIds: [], connecting: false },
})),
}))
diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx
index 243f1685..3562491d 100644
--- a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.tsx
@@ -6,13 +6,15 @@ import {
} from '@web3icons/react'
import { arbitrum, mainnet, optimism, polygon } from 'viem/chains'
import Icon from '@/src/components/pageComponents/home/Examples/demos/SwitchNetwork/Icon'
+import { useWallet } from '@/src/sdk/react/hooks'
import { SwitchNetwork as BaseSwitchNetwork } from '@/src/wallet/components'
-import { useWeb3Status } from '@/src/wallet/hooks'
import { ConnectWalletButton } from '@/src/wallet/providers'
import type { Networks } from '@/src/wallet/types'
const SwitchNetwork = () => {
- const { isWalletConnected } = useWeb3Status()
+ const {
+ status: { connected: isWalletConnected },
+ } = useWallet()
const networks: Networks = [
{
icon: (
diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
index eb6848cf..beac18bc 100644
--- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
@@ -1,11 +1,13 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
-import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils'
+import { renderWithProviders } from '@/src/test-utils'
import tokenInput from './index'
-vi.mock('@/src/hooks/useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => createMockWeb3Status()),
+vi.mock('@/src/sdk/react/hooks', () => ({
+ useWallet: vi.fn(() => ({
+ status: { connected: false, activeAccount: null, connectedChainIds: [], connecting: false },
+ })),
}))
vi.mock('@/src/hooks/useTokenLists', () => ({
diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx
index 32f27b44..e9d6377a 100644
--- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx
@@ -10,10 +10,10 @@ import { arbitrum, mainnet, optimism, polygon } from 'viem/chains'
import OptionsDropdown from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown'
import Icon from '@/src/components/pageComponents/home/Examples/demos/TokenInput/Icon'
import { withSuspenseAndRetry } from '@/src/core/utils'
+import { useWallet } from '@/src/sdk/react/hooks'
import { TokenInput as BaseTokenInput } from '@/src/tokens/components'
import { useTokenInput, useTokenLists, useTokenSearch } from '@/src/tokens/hooks'
import type { Networks } from '@/src/tokens/types'
-import { useWeb3Status } from '@/src/wallet/hooks'
type Options = 'single' | 'multi'
@@ -48,7 +48,9 @@ const SkeletonLoadingTokenInput = () => (
*/
const TokenInputMode = withSuspenseAndRetry(
({ currentTokenInput }: { currentTokenInput: Options }) => {
- const { isWalletConnected } = useWeb3Status()
+ const {
+ status: { connected: isWalletConnected },
+ } = useWallet()
const [currentNetworkId, setCurrentNetworkId] = useState()
const { tokensByChainId } = useTokenLists()
const { searchResult } = useTokenSearch({
diff --git a/src/core/ui/NotificationToast.tsx b/src/core/ui/NotificationToast.tsx
index 17fab839..39e036c0 100644
--- a/src/core/ui/NotificationToast.tsx
+++ b/src/core/ui/NotificationToast.tsx
@@ -1,8 +1,6 @@
'use client'
import { Toaster as ChakraToaster, createToaster, Portal, Stack, Toast } from '@chakra-ui/react'
-// TODO(task-3): move to app shell — core/ should not import from wallet/
-import { useWeb3Status } from '@/src/wallet/hooks'
import Spinner from './Spinner'
export const notificationToaster = createToaster({
@@ -13,9 +11,7 @@ export const notificationToaster = createToaster({
})
export const NotificationToast = () => {
- const { readOnlyClient } = useWeb3Status()
- const chain = readOnlyClient?.chain
- return !chain ? null : (
+ return (
= ({ networks }: SwitchNetworkProps)
const findChain = (chainId: number) => Object.values(chains).find((chain) => chain.id === chainId)
const { chains: configuredChains, switchChain } = useSwitchChain()
- const { isWalletConnected, walletChainId, walletClient } = useWeb3Status()
+ const { status } = useWallet()
+ const { data: walletClient } = useWalletClient()
+ const isWalletConnected = status.connected
+ const walletChainId = status.connectedChainIds[0] as number | undefined
const [networkItem, setNetworkItem] = useState()
const handleClick = (chainId: number) => {
From abbc7be8a528e9a8d148d18e60dfb3371ad020fe Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 17:38:39 +0200
Subject: [PATCH 10/52] refactor: replace TransactionNotificationProvider with
lifecycle hooks
Extend createNotificationLifecycle with onReplace handler, optional
ChainRegistry for explorer URLs, and shortMessage extraction for viem
errors. Add createSigningNotificationLifecycle factory for wallet signing
toasts. Wire both lifecycle instances in __root.tsx via DAppBoosterProvider
config, removing the legacy TransactionNotificationProvider wrapper.
---
src/routes/__root.tsx | 50 ++--
.../createNotificationLifecycle.test.ts | 234 +++++++++++++++++-
.../lifecycle/createNotificationLifecycle.ts | 111 ++++++++-
src/sdk/react/lifecycle/index.ts | 7 +-
4 files changed, 376 insertions(+), 26 deletions(-)
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index bce42a4e..45458a98 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,6 +1,8 @@
import {
Footer,
Header,
+ NotificationToast,
+ notificationToaster,
Provider,
TanStackReactQueryDevtools,
TanStackRouterDevtools,
@@ -8,8 +10,11 @@ import {
} from '@/src/core/components'
import { chains, transports } from '@/src/core/types'
import { createEvmTransactionAdapter, createEvmWalletAdapter } from '@/src/sdk/core/evm'
-import { DAppBoosterProvider } from '@/src/sdk/react'
-import { TransactionNotificationProvider } from '@/src/transactions/providers'
+import {
+ createNotificationLifecycle,
+ createSigningNotificationLifecycle,
+ DAppBoosterProvider,
+} from '@/src/sdk/react'
import { connector, config as wagmiConfig } from '@/src/wallet/connectors/wagmi.config'
import '@/src/wallet/connectors/portoInit'
import { Flex } from '@chakra-ui/react'
@@ -31,9 +36,19 @@ const evmTransactionAdapter = createEvmTransactionAdapter({
transports,
})
+const notificationLifecycle = createNotificationLifecycle({
+ toaster: notificationToaster,
+})
+
+const signingLifecycle = createSigningNotificationLifecycle({
+ toaster: notificationToaster,
+})
+
const dappboosterConfig = {
wallets: { evm: evmWalletBundle },
transactions: { evm: evmTransactionAdapter },
+ lifecycle: notificationLifecycle,
+ walletLifecycle: signingLifecycle,
}
export const Route = createRootRoute({
@@ -44,26 +59,25 @@ function Root() {
return (
-
+
+
-
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts
index 51ec5c89..d529bf2c 100644
--- a/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts
+++ b/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { createNotificationLifecycle } from './createNotificationLifecycle'
+import type { ChainRegistry } from '../../core/chain/registry'
+import {
+ createNotificationLifecycle,
+ createSigningNotificationLifecycle,
+} from './createNotificationLifecycle'
const mockCreate = vi.fn()
const mockToaster = { create: mockCreate }
@@ -10,6 +14,33 @@ const makeResult = (status: 'success' | 'reverted' | 'timeout') => ({
receipt: {},
})
+function makeRegistry(_explorerUrl: string | null): ChainRegistry {
+ return {
+ getChain: vi.fn((chainId) => {
+ if (!_explorerUrl) {
+ return null
+ }
+ return {
+ caip2Id: `eip155:${chainId}`,
+ chainId,
+ name: 'Ethereum',
+ chainType: 'evm',
+ nativeCurrency: { symbol: 'ETH', decimals: 18 },
+ explorer: {
+ url: 'https://etherscan.io',
+ txPath: '/tx/{id}',
+ addressPath: '/address/{id}',
+ },
+ addressConfig: { format: 'hex' as const, patterns: [] },
+ }
+ }),
+ getChainByCaip2: vi.fn(() => null),
+ getChainType: vi.fn(() => null),
+ getChainsByType: vi.fn(() => []),
+ getAllChains: vi.fn(() => []),
+ }
+}
+
describe('createNotificationLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -150,4 +181,205 @@ describe('createNotificationLifecycle', () => {
expect.objectContaining({ id: 'toast-1', type: 'success' }),
)
})
+
+ describe('onReplace', () => {
+ it('creates a toast with replacement info', () => {
+ const lifecycle = createNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 })
+
+ lifecycle.onReplace?.(
+ { chainType: 'evm', id: '0x1', chainId: 1 },
+ { chainType: 'evm', id: '0x2', chainId: 1 },
+ 'repriced',
+ )
+
+ expect(mockCreate).toHaveBeenCalledTimes(2)
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ type: 'loading',
+ id: 'toast-1',
+ }),
+ )
+ const call = mockCreate.mock.calls[1][0]
+ expect(call.description).toContain('repriced')
+ })
+
+ it('uses custom replaced message when provided', () => {
+ const lifecycle = createNotificationLifecycle({
+ toaster: mockToaster,
+ messages: { replaced: 'Tx was replaced' },
+ })
+ lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 })
+
+ lifecycle.onReplace?.(
+ { chainType: 'evm', id: '0x1', chainId: 1 },
+ { chainType: 'evm', id: '0x2', chainId: 1 },
+ 'cancelled',
+ )
+
+ const call = mockCreate.mock.calls[1][0]
+ expect(call.description).toBe('Tx was replaced')
+ })
+ })
+
+ describe('explorer URL in confirm toast', () => {
+ it('includes explorer URL when registry is provided', () => {
+ const registry = makeRegistry('https://etherscan.io/tx/0xhash')
+ const lifecycle = createNotificationLifecycle({ toaster: mockToaster, registry })
+
+ lifecycle.onSubmit?.({ chainType: 'evm', id: '0xhash', chainId: 1 })
+ lifecycle.onConfirm?.(makeResult('success'))
+
+ const call = mockCreate.mock.calls[1][0]
+ expect(call.description).toContain('https://etherscan.io/tx/0xhash')
+ })
+
+ it('does not include explorer URL when registry is not provided', () => {
+ const lifecycle = createNotificationLifecycle({ toaster: mockToaster })
+
+ lifecycle.onSubmit?.({ chainType: 'evm', id: '0xhash', chainId: 1 })
+ lifecycle.onConfirm?.(makeResult('success'))
+
+ const call = mockCreate.mock.calls[1][0]
+ expect(call.description).toBe('Transaction confirmed!')
+ })
+ })
+
+ describe('shortMessage extraction for viem errors', () => {
+ it('uses shortMessage when present on error', () => {
+ const lifecycle = createNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 })
+
+ const viemError = Object.assign(new Error('Full verbose error'), {
+ shortMessage: 'User rejected the request',
+ })
+ lifecycle.onError?.('submit', viemError)
+
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ description: 'User rejected the request',
+ type: 'error',
+ }),
+ )
+ })
+
+ it('falls back to error.message when shortMessage is absent', () => {
+ const lifecycle = createNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 })
+
+ lifecycle.onError?.('submit', new Error('Plain error'))
+
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ description: 'Plain error',
+ type: 'error',
+ }),
+ )
+ })
+ })
+})
+
+describe('createSigningNotificationLifecycle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCreate.mockReturnValue('toast-1')
+ })
+
+ it('onSign creates a loading toast', () => {
+ const lifecycle = createSigningNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+
+ expect(mockCreate).toHaveBeenCalledOnce()
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'loading', description: 'Signature requested' }),
+ )
+ })
+
+ it('uses custom signatureRequested message', () => {
+ const lifecycle = createSigningNotificationLifecycle({
+ toaster: mockToaster,
+ messages: { signatureRequested: 'Please sign...' },
+ })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ description: 'Please sign...' }),
+ )
+ })
+
+ it('onSignComplete creates a success toast', () => {
+ const lifecycle = createSigningNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+ lifecycle.onSignComplete?.({ signature: '0xsig', address: '0xaddr' })
+
+ expect(mockCreate).toHaveBeenCalledTimes(2)
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ type: 'success',
+ description: 'Signature received!',
+ id: 'toast-1',
+ }),
+ )
+ })
+
+ it('uses custom signatureReceived message', () => {
+ const lifecycle = createSigningNotificationLifecycle({
+ toaster: mockToaster,
+ messages: { signatureReceived: 'Signed!' },
+ })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+ lifecycle.onSignComplete?.({ signature: '0xsig', address: '0xaddr' })
+
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ description: 'Signed!' }),
+ )
+ })
+
+ it('onSignError creates an error toast', () => {
+ const lifecycle = createSigningNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+ lifecycle.onSignError?.(new Error('Rejected'))
+
+ expect(mockCreate).toHaveBeenCalledTimes(2)
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ type: 'error',
+ description: 'Rejected',
+ id: 'toast-1',
+ }),
+ )
+ })
+
+ it('uses shortMessage from viem errors', () => {
+ const lifecycle = createSigningNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+
+ const viemError = Object.assign(new Error('Long error'), {
+ shortMessage: 'User rejected',
+ })
+ lifecycle.onSignError?.(viemError)
+
+ expect(mockCreate).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ description: 'User rejected' }),
+ )
+ })
+
+ it('clears toastId after onSignComplete', () => {
+ const lifecycle = createSigningNotificationLifecycle({ toaster: mockToaster })
+ lifecycle.onSign?.('message', { message: 'Hello' })
+ lifecycle.onSignComplete?.({ signature: '0xsig', address: '0xaddr' })
+
+ mockCreate.mockReturnValue('toast-2')
+ lifecycle.onSign?.('message', { message: 'Hello again' })
+
+ const thirdCall = mockCreate.mock.calls[2][0]
+ expect(thirdCall.id).toBeUndefined()
+ })
})
diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
index ef9e57ca..0cc82228 100644
--- a/src/sdk/react/lifecycle/createNotificationLifecycle.ts
+++ b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
@@ -1,5 +1,7 @@
-import type { TransactionLifecycle } from '../../core/adapters/lifecycle'
-import type { TransactionResult } from '../../core/adapters/transaction'
+import type { TransactionLifecycle, WalletLifecycle } from '../../core/adapters/lifecycle'
+import type { TransactionRef, TransactionResult } from '../../core/adapters/transaction'
+import { getExplorerUrl } from '../../core/chain/explorer'
+import type { ChainRegistry } from '../../core/chain/registry'
/** Minimal interface for the toast notification API. */
export interface ToasterAPI {
@@ -19,21 +21,71 @@ export interface NotificationLifecycleMessages {
reverted?: string
/** Shown when an error occurs. Defaults to the error message. */
error?: string
+ /** Shown when a transaction is replaced (speed-up or cancellation). Defaults to a message including the reason. */
+ replaced?: string
+ /** Shown when a transaction is cancelled. Defaults to a message including the reason. */
+ cancelled?: string
+}
+
+export interface SigningNotificationMessages {
+ /** Shown when a signature is requested. Defaults to 'Signature requested'. */
+ signatureRequested?: string
+ /** Shown when a signature is received. Defaults to 'Signature received!'. */
+ signatureReceived?: string
+ /** Shown when a signing error occurs. Defaults to the error message. */
+ error?: string
}
export interface NotificationLifecycleOptions {
toaster: ToasterAPI
messages?: NotificationLifecycleMessages
+ /** When provided, explorer URLs are appended to confirm and replace toasts. */
+ registry?: ChainRegistry
+}
+
+export interface SigningNotificationLifecycleOptions {
+ toaster: ToasterAPI
+ messages?: SigningNotificationMessages
+}
+
+/** Extracts the most user-friendly message from an error, preferring viem's shortMessage. */
+function extractErrorMessage(error: Error): string {
+ if (
+ 'shortMessage' in error &&
+ typeof (error as Record).shortMessage === 'string'
+ ) {
+ return (error as Record).shortMessage as string
+ }
+ return error.message
+}
+
+/**
+ * Builds an explorer URL suffix for a transaction, or empty string if unavailable.
+ *
+ * @precondition ref.id is a valid transaction hash and ref.chainId is a known chain
+ * @postcondition returns a string like ' — https://etherscan.io/tx/0x...' or ''
+ */
+function buildExplorerSuffix(registry: ChainRegistry | undefined, ref: TransactionRef): string {
+ if (!registry) {
+ return ''
+ }
+ const url = getExplorerUrl(registry, { chainId: ref.chainId, tx: ref.id })
+ return url ? ` — ${url}` : ''
}
/**
- * Creates a TransactionLifecycle that fires toast notifications for submit, confirm, and error events.
+ * Creates a TransactionLifecycle that fires toast notifications for submit, confirm, replace, and error events.
*
* Pass the result to useTransaction({ lifecycle }) or TransactionButton lifecycle prop.
+ *
+ * @precondition toaster implements the ToasterAPI interface
+ * @postcondition returned lifecycle fires toasts for onSubmit, onConfirm, onReplace, and onError
+ * @postcondition when registry is provided, confirm and replace toasts include explorer URLs
*/
export function createNotificationLifecycle({
toaster,
messages = {},
+ registry,
}: NotificationLifecycleOptions): TransactionLifecycle {
let toastId: string | undefined
@@ -46,18 +98,65 @@ export function createNotificationLifecycle({
},
onConfirm(result: TransactionResult) {
const isSuccess = result.status === 'success'
+ const suffix = buildExplorerSuffix(registry, result.ref)
toaster.create({
description: isSuccess
- ? (messages.confirmed ?? 'Transaction confirmed!')
- : (messages.reverted ?? 'Transaction was reverted'),
+ ? `${messages.confirmed ?? 'Transaction confirmed!'}${suffix}`
+ : `${messages.reverted ?? 'Transaction was reverted'}${suffix}`,
type: isSuccess ? 'success' : 'error',
id: toastId,
})
toastId = undefined
},
+ onReplace(_oldRef: TransactionRef, newRef: TransactionRef, reason: string) {
+ const suffix = buildExplorerSuffix(registry, newRef)
+ toaster.create({
+ description: messages.replaced ?? `Transaction ${reason}${suffix}`,
+ type: 'loading',
+ id: toastId,
+ })
+ },
onError(_phase, error) {
toaster.create({
- description: messages.error ?? error.message,
+ description: messages.error ?? extractErrorMessage(error),
+ type: 'error',
+ id: toastId,
+ })
+ toastId = undefined
+ },
+ }
+}
+
+/**
+ * Creates a WalletLifecycle that fires toast notifications for signing operations.
+ *
+ * @precondition toaster implements the ToasterAPI interface
+ * @postcondition returned lifecycle fires toasts for onSign, onSignComplete, and onSignError
+ */
+export function createSigningNotificationLifecycle({
+ toaster,
+ messages = {},
+}: SigningNotificationLifecycleOptions): WalletLifecycle {
+ let toastId: string | undefined
+
+ return {
+ onSign() {
+ toastId = toaster.create({
+ description: messages.signatureRequested ?? 'Signature requested',
+ type: 'loading',
+ })
+ },
+ onSignComplete() {
+ toaster.create({
+ description: messages.signatureReceived ?? 'Signature received!',
+ type: 'success',
+ id: toastId,
+ })
+ toastId = undefined
+ },
+ onSignError(error) {
+ toaster.create({
+ description: messages.error ?? extractErrorMessage(error),
type: 'error',
id: toastId,
})
diff --git a/src/sdk/react/lifecycle/index.ts b/src/sdk/react/lifecycle/index.ts
index ed1120a3..e9a0f8c9 100644
--- a/src/sdk/react/lifecycle/index.ts
+++ b/src/sdk/react/lifecycle/index.ts
@@ -1,6 +1,11 @@
export type {
NotificationLifecycleMessages,
NotificationLifecycleOptions,
+ SigningNotificationLifecycleOptions,
+ SigningNotificationMessages,
ToasterAPI,
} from './createNotificationLifecycle'
-export { createNotificationLifecycle } from './createNotificationLifecycle'
+export {
+ createNotificationLifecycle,
+ createSigningNotificationLifecycle,
+} from './createNotificationLifecycle'
From 3c4b15d7720412556cd95966ce13b9f5d5526998 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 17:46:27 +0200
Subject: [PATCH 11/52] refactor: migrate demo pages from
LegacyTransactionButton to adapter-based TransactionButton
Migrate MintUSDC, ERC20ApproveAndTransferButton, NativeToken, and
parent TransactionButton demo to use the new adapter-based
TransactionButton with TransactionParams + lifecycle props. Replace
useWeb3StatusConnected with useWallet, WalletStatusVerifier with
WalletGuard, and getExplorerLink with getExplorerUrl.
Optimism cross-domain messenger demo gets a partial migration (keeps
LegacyTransactionButton with local TransactionNotificationProvider
wrapper) since useL1CrossDomainMessengerProxy returns Promise
rather than TransactionParams.
---
.../OptimismCrossDomainMessenger/index.tsx | 54 ++++++++-----
.../ERC20ApproveAndTransferButton.tsx | 75 ++++++++-----------
.../MintUSDC.tsx | 35 +++++----
.../ERC20ApproveAndTransferButton/index.tsx | 34 +++++----
.../demos/TransactionButton/NativeToken.tsx | 31 ++++----
.../demos/TransactionButton/index.tsx | 35 ++++-----
6 files changed, 135 insertions(+), 129 deletions(-)
diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
index 756ff68f..896e9d8d 100644
--- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
@@ -4,19 +4,27 @@ import type { Address } from 'viem'
import { parseEther } from 'viem'
import { optimismSepolia, sepolia } from 'viem/chains'
import { extractTransactionDepositedLogs, getL2TransactionHash } from 'viem/op-stack'
+import { usePublicClient } from 'wagmi'
import Icon from '@/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/Icon'
import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { getContract } from '@/src/contracts/definitions'
import { useL1CrossDomainMessengerProxy } from '@/src/contracts/hooks/useOPL1CrossDomainMessengerProxy'
import { Hash } from '@/src/core/components'
-import { getExplorerLink, withSuspenseAndRetry } from '@/src/core/utils'
+import { withSuspenseAndRetry } from '@/src/core/utils'
+import { getExplorerUrl } from '@/src/sdk/core/chain/explorer'
+import { WalletGuard } from '@/src/sdk/react/components'
+import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks'
+// TODO: full migration requires refactoring useL1CrossDomainMessengerProxy to return TransactionParams
import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components'
-import { useWeb3StatusConnected, WalletStatusVerifier } from '@/src/wallet/components'
+import { TransactionNotificationProvider } from '@/src/transactions/providers'
const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
// https://sepolia-optimism.etherscan.io/address/0xb50201558b00496a145fe76f7424749556e326d8
const AAVEProxy = '0xb50201558b00496a145fe76f7424749556e326d8'
- const { address: walletAddress, readOnlyClient } = useWeb3StatusConnected()
+ const wallet = useWallet({ chainId: sepolia.id })
+ const walletAddress = wallet.status.activeAccount as Address
+ const readOnlyClient = usePublicClient()
+ const registry = useChainRegistry()
const contract = getContract('AAVEWeth', optimismSepolia.id)
const depositValue = parseEther('0.01')
@@ -33,6 +41,10 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
walletAddress,
})
+ const l2ExplorerUrl = l2Hash
+ ? getExplorerUrl(registry, { chainId: optimismSepolia.id, tx: l2Hash })
+ : null
+
return (
@@ -46,20 +58,22 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
{' '}
from Sepolia.
- {
- setL2Hash(null)
- const hash = await sendCrossChainMessage()
- const receipt = await readOnlyClient.waitForTransactionReceipt({ hash })
- const [log] = extractTransactionDepositedLogs(receipt)
- const l2Hash = getL2TransactionHash({ log })
- setL2Hash(l2Hash)
- return hash
- }}
- >
- Deposit ETH
-
+
+ {
+ setL2Hash(null)
+ const hash = await sendCrossChainMessage()
+ const receipt = await readOnlyClient!.waitForTransactionReceipt({ hash })
+ const [log] = extractTransactionDepositedLogs(receipt)
+ const l2Hash = getL2TransactionHash({ log })
+ setL2Hash(l2Hash)
+ return hash
+ }}
+ >
+ Deposit ETH
+
+
{l2Hash && (
{
>
OpSepolia tx
@@ -79,9 +93,9 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
const optimismCrossdomainMessenger = {
demo: (
-
+
-
+
),
href: 'https://bootnodedev.github.io/dAppBooster/functions/hooks_useOPL1CrossDomainMessengerProxy.useL1CrossDomainMessengerProxy.html',
icon: ,
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
index b4503c1b..6bce72d2 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
@@ -1,42 +1,30 @@
import type { FC } from 'react'
-import { type Address, erc20Abi, type Hash, type TransactionReceipt } from 'viem'
-import * as chains from 'viem/chains'
-import { useWriteContract } from 'wagmi'
+import { type Abi, type Address, erc20Abi } from 'viem'
import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
import { useSuspenseReadErc20Allowance } from '@/src/contracts/generated'
-import { getExplorerLink } from '@/src/core/utils'
+import type { TransactionParams } from '@/src/sdk/core'
+import { getExplorerUrl } from '@/src/sdk/core/chain/explorer'
+import type { EvmContractCall } from '@/src/sdk/core/evm/types'
+import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks'
import type { Token } from '@/src/tokens/types'
-import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components'
-import { useWeb3StatusConnected } from '@/src/wallet/components'
-import { useWeb3Status } from '@/src/wallet/hooks'
+import { TransactionButton } from '@/src/transactions/components'
interface Props {
amount: bigint
disabled?: boolean
label?: string
labelSending?: string
- onSuccess?: (receipt: TransactionReceipt) => void
+ onSuccess?: () => void
spender: Address
token: Token
- transaction: () => Promise
+ transferParams: TransactionParams
}
/**
* Dynamically renders either an approval button or a transaction button based on the user's current token allowance.
* After the approval, the transaction button will be rendered.
*
- * @dev Use with to add an skeleton loader while fetching the allowance.
- *
- * @param {Props}
- * @param {Token} props.token - The token to be approved.
- * @param {Address} props.spender - The address of the spender to be approved.
- * @param {bigint} props.amount - The amount of tokens to approve (or send).
- * @param {Function} props.onMined - The callback function to be called when transaction is mined.
- * @param {boolean} props.disabled - The flag to disable the button.
- * @param {Function} props.transaction - The transaction function that send after approval.
- * @param {string} props.label - The label for the button.
- * @param {string} props.labelSending - The label for the button when the transaction is pending.
- *
+ * @dev Use with to add a skeleton loader while fetching the allowance.
*/
const ERC20ApproveAndTransferButton: FC = ({
amount,
@@ -46,34 +34,32 @@ const ERC20ApproveAndTransferButton: FC = ({
onSuccess,
spender,
token,
- transaction,
+ transferParams,
}) => {
- const { address } = useWeb3StatusConnected()
- const { writeContractAsync } = useWriteContract()
- const { isWalletConnected, walletChainId } = useWeb3Status()
+ const wallet = useWallet({ chainId: token.chainId })
+ const address = wallet.status.activeAccount as Address
+ const registry = useChainRegistry()
- const { data: allowance, refetch: getAllowance } = useSuspenseReadErc20Allowance({
+ const { data: allowance, refetch: refetchAllowance } = useSuspenseReadErc20Allowance({
address: token.address as Address, // TODO: token.address should be Address type
args: [address, spender],
})
const isApprovalRequired = allowance < amount
- const handleApprove = () => {
- return writeContractAsync({
- abi: erc20Abi,
- address: token.address as Address,
- functionName: 'approve',
- args: [spender, amount],
- })
+ const approveParams: TransactionParams = {
+ chainId: token.chainId,
+ payload: {
+ contract: {
+ address: token.address as Address,
+ abi: erc20Abi as Abi,
+ functionName: 'approve',
+ args: [spender, amount],
+ },
+ } satisfies EvmContractCall,
}
- handleApprove.methodId = 'Approve USDC'
-
- const findChain = (chainId: number) => Object.values(chains).find((chain) => chain.id === chainId)
- // mainnet is the default chain if not connected or the chain is not found
- const currentChain =
- isWalletConnected && walletChainId ? findChain(walletChainId) || chains.mainnet : chains.mainnet
+ const explorerUrl = getExplorerUrl(registry, { chainId: token.chainId, address: spender })
return isApprovalRequired ? (
= ({
disabled={disabled}
key="approve"
labelSending={`Approving ${token.symbol}`}
- onMined={() => getAllowance()}
- transaction={handleApprove}
+ lifecycle={{ onConfirm: () => refetchAllowance() }}
+ params={approveParams}
>
Approve
@@ -96,7 +82,7 @@ const ERC20ApproveAndTransferButton: FC = ({
<>
Supply {token.symbol} to the{' '}
@@ -108,12 +94,11 @@ const ERC20ApproveAndTransferButton: FC = ({
title="Execute the transaction"
>
onSuccess?.() }}
+ params={transferParams}
>
{label}
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx
index 368e6c6f..7810d947 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx
@@ -1,33 +1,36 @@
+import type { Abi, Address } from 'viem'
import { sepolia } from 'viem/chains'
-import { useWriteContract } from 'wagmi'
import { AaveFaucetABI } from '@/src/contracts/abis/AaveFaucet'
import { getContract } from '@/src/contracts/definitions'
-import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components'
-import { useWeb3StatusConnected } from '@/src/wallet/components'
+import type { TransactionParams } from '@/src/sdk/core'
+import type { EvmContractCall } from '@/src/sdk/core/evm/types'
+import { useWallet } from '@/src/sdk/react/hooks'
+import { TransactionButton } from '@/src/transactions/components'
export default function MintUSDC({ onSuccess }: { onSuccess: () => void }) {
- const { address } = useWeb3StatusConnected()
- const { writeContractAsync } = useWriteContract()
+ const wallet = useWallet({ chainId: sepolia.id })
+ const address = wallet.status.activeAccount as Address
const aaveContract = getContract('AaveFaucet', sepolia.id)
const aaveUSDC = '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8'
- const handleMint = () => {
- return writeContractAsync({
- abi: AaveFaucetABI,
- address: aaveContract.address,
- functionName: 'mint',
- args: [aaveUSDC, address, 10000000000n],
- })
+ const mintParams: TransactionParams = {
+ chainId: sepolia.id,
+ payload: {
+ contract: {
+ address: aaveContract.address,
+ abi: AaveFaucetABI as Abi,
+ functionName: 'mint',
+ args: [aaveUSDC, address, BigInt(10000000000)],
+ },
+ } satisfies EvmContractCall,
}
- handleMint.methodId = 'Mint USDC'
return (
onSuccess() }}
+ params={mintParams}
>
Mint USDC
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
index 786d0415..43673440 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
@@ -1,13 +1,14 @@
-import { type Address, formatUnits } from 'viem'
+import { type Abi, type Address, formatUnits } from 'viem'
import { sepolia } from 'viem/chains'
-import { useWriteContract } from 'wagmi'
import BaseERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton'
import MintUSDC from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC'
import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
import { useSuspenseReadErc20BalanceOf } from '@/src/contracts/generated'
import { formatNumberOrString, NumberType, withSuspense } from '@/src/core/utils'
+import type { TransactionParams } from '@/src/sdk/core'
+import type { EvmContractCall } from '@/src/sdk/core/evm/types'
+import { useWallet } from '@/src/sdk/react/hooks'
import type { Token } from '@/src/tokens/types'
-import { useWeb3StatusConnected } from '@/src/wallet/components'
// USDC token on Sepolia chain
const tokenUSDC_sepolia: Token = {
@@ -56,8 +57,8 @@ const ABIExample = [
* Works only on Sepolia chain.
*/
const ERC20ApproveAndTransferButton = withSuspense(() => {
- const { address } = useWeb3StatusConnected()
- const { writeContractAsync } = useWriteContract()
+ const wallet = useWallet({ chainId: sepolia.id })
+ const address = wallet.status.activeAccount as Address
const { data: balance, refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({
address: tokenUSDC_sepolia.address as Address,
@@ -67,16 +68,19 @@ const ERC20ApproveAndTransferButton = withSuspense(() => {
// AAVE staging contract pool address
const spender = '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951'
- const amount = 10000000000n // 10,000.00 USDC
+ const amount = BigInt(10000000000) // 10,000.00 USDC
- const handleTransaction = () =>
- writeContractAsync({
- abi: ABIExample,
- address: spender,
- functionName: 'supply',
- args: [tokenUSDC_sepolia.address as Address, amount, address, 0],
- })
- handleTransaction.methodId = 'Supply USDC'
+ const transferParams: TransactionParams = {
+ chainId: sepolia.id,
+ payload: {
+ contract: {
+ address: spender,
+ abi: ABIExample as Abi,
+ functionName: 'supply',
+ args: [tokenUSDC_sepolia.address as Address, amount, address, 0],
+ },
+ } satisfies EvmContractCall,
+ }
const formattedAmount = formatNumberOrString(
formatUnits(amount, tokenUSDC_sepolia.decimals),
@@ -98,7 +102,7 @@ const ERC20ApproveAndTransferButton = withSuspense(() => {
onSuccess={() => refetchBalance()}
spender={spender}
token={tokenUSDC_sepolia}
- transaction={handleTransaction}
+ transferParams={transferParams}
/>
)
})
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
index afabae8a..0a0bdae0 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
@@ -1,11 +1,14 @@
import { Dialog } from '@chakra-ui/react'
import { type ReactElement, useState } from 'react'
-import { type Hash, parseEther, type TransactionReceipt } from 'viem'
-import { useSendTransaction } from 'wagmi'
+import type { Address, TransactionReceipt } from 'viem'
+import { parseEther } from 'viem'
+import { sepolia } from 'viem/chains'
import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
import { GeneralMessage, PrimaryButton } from '@/src/core/components'
-import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components'
-import { useWeb3StatusConnected } from '@/src/wallet/components'
+import type { TransactionParams, TransactionResult } from '@/src/sdk/core'
+import type { EvmRawTransaction } from '@/src/sdk/core/evm/types'
+import { useWallet } from '@/src/sdk/react/hooks'
+import { TransactionButton } from '@/src/transactions/components'
/**
* This demo shows how to send a native token transaction.
@@ -14,11 +17,12 @@ import { useWeb3StatusConnected } from '@/src/wallet/components'
*/
const NativeToken = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
- const { address } = useWeb3StatusConnected()
- const { sendTransactionAsync } = useSendTransaction()
+ const wallet = useWallet({ chainId: sepolia.id })
+ const address = wallet.status.activeAccount as Address
const [minedMessage, setMinedMessage] = useState()
- const handleOnMined = (receipt: TransactionReceipt) => {
+ const handleConfirm = (result: TransactionResult) => {
+ const receipt = result.receipt as TransactionReceipt
setMinedMessage(
<>
Hash: {receipt.transactionHash}
@@ -27,14 +31,13 @@ const NativeToken = () => {
setIsModalOpen(true)
}
- const handleSendTransaction = (): Promise => {
- // Send native token
- return sendTransactionAsync({
+ const sendParams: TransactionParams = {
+ chainId: sepolia.id,
+ payload: {
to: address,
value: parseEther('0.1'),
- })
+ } satisfies EvmRawTransaction,
}
- handleSendTransaction.methodId = 'sendTransaction'
return (
{
>
Send 0.1 Sepolia ETH
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
index d5a1eb23..d1bda6d7 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
@@ -1,11 +1,11 @@
import { Flex } from '@chakra-ui/react'
import { useState } from 'react'
-import { sepolia } from 'wagmi/chains'
+import { sepolia } from 'viem/chains'
import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown'
import ERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton'
import Icon from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Icon'
import NativeToken from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken'
-import { WalletStatusVerifier } from '@/src/wallet/components'
+import { WalletGuard } from '@/src/sdk/react/components'
type Options = 'erc20' | 'native'
@@ -17,23 +17,20 @@ const TransactionButton = () => {
]
return (
-
- {/* biome-ignore lint/complexity/noUselessFragments: WalletStatusVerifier expects a single ReactElement child */}
- <>
-
-
- {currentTokenInput === 'erc20' && }
- {currentTokenInput === 'native' && }
-
- >
-
+
+
+
+ {currentTokenInput === 'erc20' && }
+ {currentTokenInput === 'native' && }
+
+
)
}
From 3225739360c3c5aa26f51a53efb2056bc7f2e536 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 18:01:59 +0200
Subject: [PATCH 12/52] chore: delete legacy hooks, providers, and duplicate
files
Remove 39 files from the old code paths that have been superseded by the
SDK adapter architecture:
- src/hooks/ legacy hooks (useWeb3Status, useWalletStatus, useTokens)
- src/providers/ (Web3Provider, TransactionNotificationProvider)
- src/components/sharedComponents/ duplicates (TransactionButton,
SignButton, WalletStatusVerifier, SwitchNetwork, NotificationToast,
TokenSelect, TokenDropdown, TokenInput)
- src/wallet/connectors/*.config.tsx (connectkit, rainbowkit, reown)
- src/wallet/providers/Web3Provider.tsx (dead code after config deletion)
Files kept because they're still used by the Optimism and SignMessage
demos: wallet/hooks/*, wallet/types.ts, wallet/components/WalletStatusVerifier,
transactions/providers/*, transactions/components/LegacyTransactionButton.
---
.../sharedComponents/NotificationToast.tsx | 50 ---
.../sharedComponents/SignButton.test.tsx | 216 ------------
.../sharedComponents/SignButton.tsx | 93 ------
.../sharedComponents/SwitchNetwork.test.tsx | 115 -------
.../sharedComponents/SwitchNetwork.tsx | 130 -------
.../sharedComponents/TokenDropdown.tsx | 117 -------
.../TokenInput/Components.tsx | 290 ----------------
.../sharedComponents/TokenInput/index.tsx | 253 --------------
.../sharedComponents/TokenInput/styles.ts | 70 ----
.../TokenInput/useTokenInput.tsx | 81 -----
.../TokenSelect/List/AddERC20TokenButton.tsx | 78 -----
.../sharedComponents/TokenSelect/List/Row.tsx | 124 -------
.../TokenSelect/List/TokenBalance.tsx | 71 ----
.../TokenSelect/List/VirtualizedList.tsx | 59 ----
.../TokenSelect/List/index.tsx | 86 -----
.../TokenSelect/Search/Input.tsx | 94 ------
.../TokenSelect/Search/NetworkButton.tsx | 63 ----
.../TokenSelect/Search/index.tsx | 89 -----
.../TokenSelect/TopTokens/Item.tsx | 72 ----
.../TokenSelect/TopTokens/index.tsx | 55 ---
.../sharedComponents/TokenSelect/index.tsx | 185 ----------
.../sharedComponents/TokenSelect/styles.ts | 70 ----
.../sharedComponents/TokenSelect/types.ts | 8 -
.../sharedComponents/TokenSelect/utils.tsx | 54 ---
.../TransactionButton.test.tsx | 316 ------------------
.../sharedComponents/TransactionButton.tsx | 85 -----
.../WalletStatusVerifier.test.tsx | 193 -----------
.../sharedComponents/WalletStatusVerifier.tsx | 81 -----
src/hooks/useTokens.ts | 240 -------------
src/hooks/useWalletStatus.test.ts | 160 ---------
src/hooks/useWalletStatus.ts | 40 ---
src/hooks/useWeb3Status.test.ts | 160 ---------
src/hooks/useWeb3Status.tsx | 138 --------
.../TransactionNotificationProvider.tsx | 237 -------------
src/providers/Web3Provider.tsx | 35 --
src/wallet/connectors/connectkit.config.tsx | 100 ------
src/wallet/connectors/rainbowkit.config.tsx | 43 ---
src/wallet/connectors/reown.config.tsx | 59 ----
src/wallet/providers/Web3Provider.tsx | 35 --
39 files changed, 4445 deletions(-)
delete mode 100644 src/components/sharedComponents/NotificationToast.tsx
delete mode 100644 src/components/sharedComponents/SignButton.test.tsx
delete mode 100644 src/components/sharedComponents/SignButton.tsx
delete mode 100644 src/components/sharedComponents/SwitchNetwork.test.tsx
delete mode 100644 src/components/sharedComponents/SwitchNetwork.tsx
delete mode 100644 src/components/sharedComponents/TokenDropdown.tsx
delete mode 100644 src/components/sharedComponents/TokenInput/Components.tsx
delete mode 100644 src/components/sharedComponents/TokenInput/index.tsx
delete mode 100644 src/components/sharedComponents/TokenInput/styles.ts
delete mode 100644 src/components/sharedComponents/TokenInput/useTokenInput.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/List/Row.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/List/index.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/Search/Input.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/Search/index.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/TopTokens/index.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/index.tsx
delete mode 100644 src/components/sharedComponents/TokenSelect/styles.ts
delete mode 100644 src/components/sharedComponents/TokenSelect/types.ts
delete mode 100644 src/components/sharedComponents/TokenSelect/utils.tsx
delete mode 100644 src/components/sharedComponents/TransactionButton.test.tsx
delete mode 100644 src/components/sharedComponents/TransactionButton.tsx
delete mode 100644 src/components/sharedComponents/WalletStatusVerifier.test.tsx
delete mode 100644 src/components/sharedComponents/WalletStatusVerifier.tsx
delete mode 100644 src/hooks/useTokens.ts
delete mode 100644 src/hooks/useWalletStatus.test.ts
delete mode 100644 src/hooks/useWalletStatus.ts
delete mode 100644 src/hooks/useWeb3Status.test.ts
delete mode 100644 src/hooks/useWeb3Status.tsx
delete mode 100644 src/providers/TransactionNotificationProvider.tsx
delete mode 100644 src/providers/Web3Provider.tsx
delete mode 100644 src/wallet/connectors/connectkit.config.tsx
delete mode 100644 src/wallet/connectors/rainbowkit.config.tsx
delete mode 100644 src/wallet/connectors/reown.config.tsx
delete mode 100644 src/wallet/providers/Web3Provider.tsx
diff --git a/src/components/sharedComponents/NotificationToast.tsx b/src/components/sharedComponents/NotificationToast.tsx
deleted file mode 100644
index 1d5595aa..00000000
--- a/src/components/sharedComponents/NotificationToast.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-'use client'
-
-import { Toaster as ChakraToaster, createToaster, Portal, Stack, Toast } from '@chakra-ui/react'
-import Spinner from '@/src/components/sharedComponents/ui/Spinner'
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-
-export const notificationToaster = createToaster({
- placement: 'bottom-end',
- pauseOnPageIdle: true,
- max: 1,
- overlap: false,
-})
-
-export const NotificationToast = () => {
- const { readOnlyClient } = useWeb3Status()
- const chain = readOnlyClient?.chain
- return !chain ? null : (
-
-
- {(toast) => (
-
- {toast.type === 'loading' ? : }
-
- {toast.title && {toast.title}}
- {toast.description && (
-
- {toast.description}
-
- )}
-
- {toast.meta?.closable && }
-
- )}
-
-
- )
-}
diff --git a/src/components/sharedComponents/SignButton.test.tsx b/src/components/sharedComponents/SignButton.test.tsx
deleted file mode 100644
index 30a8f99b..00000000
--- a/src/components/sharedComponents/SignButton.test.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { createElement, type ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import SignButton from './SignButton'
-
-const mockSwitchChain = vi.fn()
-const mockSignMessage = vi.fn()
-
-vi.mock('@/src/sdk/react/hooks', () => ({
- useWallet: vi.fn(() => ({
- adapter: {} as never,
- needsConnect: true,
- needsChainSwitch: false,
- isReady: false,
- status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: mockSignMessage,
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- })),
- useChainRegistry: vi.fn(() => ({
- getChain: vi.fn(() => null),
- getChainByCaip2: vi.fn(() => null),
- getChainType: vi.fn(() => null),
- getChainsByType: vi.fn(() => []),
- getAllChains: vi.fn(() => []),
- })),
-}))
-
-vi.mock('@/src/wallet/providers', () => ({
- ConnectWalletButton: () =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'connect-wallet-button' },
- 'Connect Wallet',
- ),
-}))
-
-vi.mock('@/src/components/sharedComponents/ui/SwitchChainButton', () => ({
- default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'switch-chain-button', onClick },
- children,
- ),
-}))
-
-const { useWallet, useChainRegistry } = await import('@/src/sdk/react/hooks')
-const mockedUseWallet = vi.mocked(useWallet)
-const mockedUseChainRegistry = vi.mocked(useChainRegistry)
-
-const system = createSystem(defaultConfig)
-const renderWithChakra = (ui: ReactNode) =>
- render({ui})
-
-const makeWalletReady = () =>
- mockedUseWallet.mockReturnValue({
- adapter: {} as never,
- needsConnect: false,
- needsChainSwitch: false,
- isReady: true,
- status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [1] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: mockSignMessage,
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- })
-
-describe('SignButton', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockSignMessage.mockResolvedValue({ signature: '0xsig', address: '0xabc' })
- })
-
- it('renders connect button when wallet needsConnect', () => {
- renderWithChakra()
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
- expect(screen.queryByText('Sign Message')).toBeNull()
- })
-
- it('renders custom fallback when provided and wallet needsConnect', () => {
- renderWithChakra(
- ,
- )
- expect(screen.getByTestId('custom-fallback')).toBeInTheDocument()
- expect(screen.queryByText('Sign Message')).toBeNull()
- })
-
- it('renders switch chain button when wallet needsChainSwitch', () => {
- mockedUseWallet.mockReturnValue({
- adapter: {} as never,
- needsConnect: false,
- needsChainSwitch: true,
- isReady: false,
- status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: mockSignMessage,
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- })
- mockedUseChainRegistry.mockReturnValue({
- getChain: vi.fn(() => ({
- name: 'OP Mainnet',
- chainId: 10,
- chainType: 'evm',
- caip2Id: 'eip155:10',
- nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
- addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
- })),
- getChainByCaip2: vi.fn(() => null),
- getChainType: vi.fn(() => null),
- getChainsByType: vi.fn(() => []),
- getAllChains: vi.fn(() => []),
- })
- renderWithChakra(
- ,
- )
- expect(screen.getByTestId('switch-chain-button')).toBeInTheDocument()
- expect(screen.getByText(/Switch to/)).toBeInTheDocument()
- expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument()
- expect(screen.queryByText('Sign Message')).toBeNull()
- })
-
- it('renders custom switchChainLabel when wallet needsChainSwitch', () => {
- mockedUseWallet.mockReturnValue({
- adapter: {} as never,
- needsConnect: false,
- needsChainSwitch: true,
- isReady: false,
- status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: mockSignMessage,
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- })
- mockedUseChainRegistry.mockReturnValue({
- getChain: vi.fn(() => ({
- name: 'OP Mainnet',
- chainId: 10,
- chainType: 'evm',
- caip2Id: 'eip155:10',
- nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
- addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
- })),
- getChainByCaip2: vi.fn(() => null),
- getChainType: vi.fn(() => null),
- getChainsByType: vi.fn(() => []),
- getAllChains: vi.fn(() => []),
- })
- renderWithChakra(
- ,
- )
- expect(screen.getByText(/Change to/)).toBeInTheDocument()
- })
-
- it('renders sign button when wallet is ready', () => {
- makeWalletReady()
- renderWithChakra()
- expect(screen.getByText('Sign Message')).toBeInTheDocument()
- })
-
- it('calls onSign with signature when signing succeeds', async () => {
- makeWalletReady()
- const onSign = vi.fn()
- renderWithChakra(
- ,
- )
- await userEvent.click(screen.getByText('Sign Message'))
- expect(onSign).toHaveBeenCalledWith('0xsig')
- })
-
- it('calls onError when signing fails', async () => {
- makeWalletReady()
- mockSignMessage.mockRejectedValue(new Error('sign failed'))
- const onError = vi.fn()
- renderWithChakra(
- ,
- )
- await userEvent.click(screen.getByText('Sign Message'))
- expect(onError).toHaveBeenCalledWith(expect.any(Error))
- })
-})
diff --git a/src/components/sharedComponents/SignButton.tsx b/src/components/sharedComponents/SignButton.tsx
deleted file mode 100644
index e209d8d9..00000000
--- a/src/components/sharedComponents/SignButton.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { type ButtonProps, chakra } from '@chakra-ui/react'
-import type { FC, ReactElement } from 'react'
-import { useState } from 'react'
-import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton'
-import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks'
-import { ConnectWalletButton } from '@/src/wallet/providers'
-
-interface SignButtonProps extends Omit {
- /** Target chain ID for wallet status verification. */
- chainId?: string | number
- /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */
- fallback?: ReactElement
- /** Button label while signing. Defaults to 'Signing...'. */
- labelSigning?: string
- /** The message to sign. */
- message: string
- /** Callback function called when an error occurs. */
- onError?: (error: Error) => void
- /** Callback function called when the message is signed. */
- onSign?: (signature: string) => void
- /** Label for the switch chain button. Defaults to 'Switch to'. */
- switchChainLabel?: string
-}
-
-/**
- * Self-contained message signing button with wallet verification.
- *
- * Handles wallet connection status internally — shows a connect button if not connected,
- * a switch chain button if on the wrong chain, or the sign button when ready.
- *
- * @example
- * ```tsx
- * console.error(error)}
- * onSign={(signature) => console.log(signature)}
- * />
- * ```
- */
-const SignButton: FC = ({
- chainId,
- children = 'Sign Message',
- disabled,
- fallback = ,
- labelSigning = 'Signing...',
- message,
- onError,
- onSign,
- switchChainLabel = 'Switch to',
- ...restProps
-}) => {
- const wallet = useWallet({ chainId })
- const registry = useChainRegistry()
- const [isPending, setIsPending] = useState(false)
-
- if (wallet.needsConnect) {
- return fallback
- }
-
- if (wallet.needsChainSwitch && chainId !== undefined) {
- const targetChain = registry.getChain(chainId)
- return (
- wallet.switchChain(chainId)}>
- {switchChainLabel} {targetChain?.name ?? String(chainId)}
-
- )
- }
-
- const handleSign = async () => {
- setIsPending(true)
- try {
- const result = await wallet.signMessage({ message })
- onSign?.(result.signature)
- } catch (error) {
- const errorObj = error instanceof Error ? error : new Error(String(error))
- onError?.(errorObj)
- } finally {
- setIsPending(false)
- }
- }
-
- return (
-
- {isPending ? labelSigning : children}
-
- )
-}
-
-export default SignButton
diff --git a/src/components/sharedComponents/SwitchNetwork.test.tsx b/src/components/sharedComponents/SwitchNetwork.test.tsx
deleted file mode 100644
index 9cfad6a4..00000000
--- a/src/components/sharedComponents/SwitchNetwork.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-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 wagmiModule from 'wagmi'
-import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status'
-
-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/SwitchNetwork.tsx b/src/components/sharedComponents/SwitchNetwork.tsx
deleted file mode 100644
index 7e1ac77e..00000000
--- a/src/components/sharedComponents/SwitchNetwork.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { Box, Flex, Menu } from '@chakra-ui/react'
-import {
- type ComponentPropsWithoutRef,
- type FC,
- type ReactElement,
- useEffect,
- useState,
-} from 'react'
-import * as chains from 'viem/chains'
-import { useSwitchChain } from 'wagmi'
-import DropdownButton from '@/src/components/sharedComponents/ui/DropdownButton'
-import { MenuContent, MenuItem } from '@/src/components/sharedComponents/ui/Menu'
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-
-type NetworkItem = {
- icon: ReactElement
- id: number
- label: string
-}
-
-export type Networks = Array
-
-interface SwitchNetworkProps extends ComponentPropsWithoutRef<'div'> {
- networks: Networks
-}
-
-/**
- * SwitchNetwork component for selecting and switching blockchain networks.
- *
- * This component renders a dropdown menu that allows users to select from a list of
- * blockchain networks and switch the connected wallet to the selected network.
- *
- * @param {SwitchNetworkProps} props - SwitchNetwork component props.
- * @param {Networks} props.networks - List of networks to display in the dropdown.
- * @param {ReactElement} props.networks[].icon - Icon representing the network.
- * @param {number} props.networks[].id - Chain ID of the network.
- * @param {string} props.networks[].label - Display name of the network.
- * @param {ComponentPropsWithoutRef<'div'>} [props.restProps] - Additional props inherited from div element.
- *
- * @example
- * ```tsx
- * },
- * { id: 10, label: "Optimism", icon: }
- * ]}
- * />
- * ```
- */
-const SwitchNetwork: FC = ({ networks }: SwitchNetworkProps) => {
- const findChain = (chainId: number) => Object.values(chains).find((chain) => chain.id === chainId)
-
- const { chains: configuredChains, switchChain } = useSwitchChain()
- const { isWalletConnected, walletChainId, walletClient } = useWeb3Status()
- const [networkItem, setNetworkItem] = useState()
-
- const handleClick = (chainId: number) => {
- /**
- * First, attempt to switch to the chain if it's already configured
- */
- if (configuredChains.some((chain) => chain.id === chainId)) {
- switchChain({ chainId })
- } else {
- /**
- * If the chain isn't configured, allow to switch to it based on the chain id
- */
- const selectedChain = findChain(chainId)
- if (selectedChain) {
- walletClient?.addChain({ chain: selectedChain })
- }
- }
- }
-
- useEffect(() => {
- setNetworkItem(networks.find((networkItem) => networkItem.id === walletChainId))
- }, [walletChainId, networks])
-
- return (
-
-
-
- {networkItem ? (
- <>
-
-
- {networkItem?.icon}
-
- {' '}
- {networkItem?.label}
- >
- ) : (
- 'Select a network'
- )}
-
-
-
-
- {networks.map(({ icon, id, label }) => (
-
- ))}
-
-
-
- )
-}
-
-export default SwitchNetwork
diff --git a/src/components/sharedComponents/TokenDropdown.tsx b/src/components/sharedComponents/TokenDropdown.tsx
deleted file mode 100644
index 5e68f989..00000000
--- a/src/components/sharedComponents/TokenDropdown.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { Flex, Menu } from '@chakra-ui/react'
-import type { ComponentPropsWithoutRef, FC } from 'react'
-import { useState } from 'react'
-import TokenLogo from '@/src/components/sharedComponents/TokenLogo'
-import TokenSelect, { type TokenSelectProps } from '@/src/components/sharedComponents/TokenSelect'
-import DropdownButton from '@/src/components/sharedComponents/ui/DropdownButton'
-import { MenuContent } from '@/src/components/sharedComponents/ui/Menu'
-import type { Token } from '@/src/types/token'
-
-export interface TokenDropdownProps extends TokenSelectProps {
- currentToken?: Token | undefined
- iconSize?: number
-}
-
-/** @ignore */
-type Props = ComponentPropsWithoutRef<'span'> & TokenDropdownProps
-
-/**
- * A dropdown component that allows users to select a token.
- *
- * @param {TokenDropdownProps & ComponentPropsWithoutRef<'span'>} props - TokenDropdown component props.
- * @param {Token} [props.currentToken] - The currently selected token.
- * @param {number} [props.iconSize=24] - The size of the token icon in the dropdown button.
- * @param {(token: Token | undefined) => void} props.onTokenSelect - Callback function called when a token is selected.
- * @param {boolean} [props.showAddTokenButton] - Whether to show a button to add a custom token.
- * @param {number} [props.currentNetworkId] - The current network id to filter tokens.
- * @param {Networks} [props.networks] - List of networks to display in the dropdown.
- * @param {string} [props.placeholder] - Placeholder text for the search input.
- * @param {number} [props.containerHeight] - Height of the virtualized tokens list.
- * @param {number} [props.itemHeight] - Height of each item in the tokens list.
- * @param {boolean} [props.showBalance] - Whether to show the token balance in the list.
- * @param {boolean} [props.showTopTokens] - Whether to show the top tokens section in the list.
- * @param {ComponentPropsWithoutRef<'span'>} props.restProps - Additional props for the span element.
- *
- * @example
- * ```tsx
- * setSelectedToken(token)}
- * showAddTokenButton={true}
- * showBalance={true}
- * />
- * ```
- */
-const TokenDropdown: FC = ({
- className,
- currentToken,
- iconSize = 24,
- onTokenSelect,
- showAddTokenButton,
- style,
- ...restProps
-}: Props) => {
- const [isOpen, setIsOpen] = useState(false)
-
- /**
- * Handle token selection and close the dropdown
- * @param {Token} [token=undefined] - The selected token. Default is undefined.
- */
- const handleTokenSelect = (token: Token | undefined) => {
- onTokenSelect(token)
- setIsOpen(false)
- }
-
- return (
- setIsOpen(state.open)}
- positioning={{ placement: 'bottom' }}
- >
-
-
- {currentToken ? (
- <>
-
-
-
- {currentToken.symbol}
- >
- ) : (
- 'Select token'
- )}
-
-
-
-
-
-
-
-
- )
-}
-
-export default TokenDropdown
diff --git a/src/components/sharedComponents/TokenInput/Components.tsx b/src/components/sharedComponents/TokenInput/Components.tsx
deleted file mode 100644
index 51be2218..00000000
--- a/src/components/sharedComponents/TokenInput/Components.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import {
- type ButtonProps,
- chakra,
- Flex,
- type FlexProps,
- Heading,
- type HeadingProps,
- Input,
- type InputProps,
- Span,
- type SpanProps,
-} from '@chakra-ui/react'
-import type { FC } from 'react'
-
-const BaseChevronDown = ({ ...restProps }) => (
-
-)
-
-const CloseIcon = ({ ...restProps }) => (
-
-)
-
-export const Wrapper: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const Title: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const TopRow: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const Textfield: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const ChevronDown = chakra(BaseChevronDown, {
- base: {
- marginLeft: 2,
- },
-})
-
-const buttonCSS = {
- alignItems: 'center',
- backgroundColor: 'var(--dropdown-button-background-color)',
- borderColor: 'var(--dropdown-button-border-color)',
- borderRadius: 4,
- color: 'var(--dropdown-button-color)',
- columnGap: 2,
- cursor: 'pointer',
- display: 'flex',
- flexShrink: 0,
- fontFamily: '{fonts.body}',
- fontSize: { base: '12px', lg: '16px' },
- fontWeight: 500,
- height: 'auto',
- minWidth: '100px',
- padding: { base: 2, lg: 4 },
- _hover: {
- backgroundColor: 'var(--dropdown-button-background-color-hover)',
- borderColor: 'var(--dropdown-button-border-color-hover)',
- color: 'var(--dropdown-button-color-hover)',
- },
- _active: {
- backgroundColor: 'var(--dropdown-button-background-color-active)',
- borderColor: 'var(--dropdown-button-border-color-active)',
- color: 'var(--dropdown-button-color-active)',
- },
-}
-
-export const DropdownButton: FC = ({ children, ...restProps }) => (
-
- {children}
-
-
-)
-
-export const SingleToken: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const ErrorComponent: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const BottomRow: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const EstimatedUSDValue: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const Balance: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const BalanceValue: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const MaxButton: FC = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export const Icon: FC<{ $iconSize?: number } & FlexProps> = ({
- $iconSize,
- children,
- ...restProps
-}) => (
-
- {children}
-
-)
-
-export const CloseButton: FC = ({ children, ...restProps }) => (
-
-
-
-)
diff --git a/src/components/sharedComponents/TokenInput/index.tsx b/src/components/sharedComponents/TokenInput/index.tsx
deleted file mode 100644
index 85d8a409..00000000
--- a/src/components/sharedComponents/TokenInput/index.tsx
+++ /dev/null
@@ -1,253 +0,0 @@
-import { Dialog, type FlexProps, Portal } from '@chakra-ui/react'
-import { type FC, useMemo, useState } from 'react'
-import { type NumberFormatValues, NumericFormat } from 'react-number-format'
-import { formatUnits } from 'viem'
-import {
- BigNumberInput,
- type BigNumberInputProps,
- type RenderInputProps,
-} from '@/src/components/sharedComponents/BigNumberInput'
-import {
- Balance,
- BalanceValue,
- BottomRow,
- CloseButton,
- DropdownButton,
- ErrorComponent,
- EstimatedUSDValue,
- Icon,
- MaxButton,
- SingleToken,
- Textfield,
- Title,
- TopRow,
- Wrapper,
-} from '@/src/components/sharedComponents/TokenInput/Components'
-import type { UseTokenInputReturnType } from '@/src/components/sharedComponents/TokenInput/useTokenInput'
-import TokenLogo from '@/src/components/sharedComponents/TokenLogo'
-import TokenSelect, { type TokenSelectProps } from '@/src/components/sharedComponents/TokenSelect'
-import Spinner from '@/src/components/sharedComponents/ui/Spinner'
-import type { Token } from '@/src/types/token'
-import styles from './styles'
-
-interface TokenInputProps extends Omit {
- singleToken?: boolean
- thousandSeparator?: boolean
- title?: string
- tokenInput: UseTokenInputReturnType
-}
-
-/** @ignore */
-type Props = FlexProps & TokenInputProps
-
-/**
- * TokenInput component allows users to input token amounts and select tokens from a list.
- * It displays the token input field, token balance, and a dropdown list of available tokens.
- *
- * @param {TokenInputProps} props - TokenInput component props.
- * @param {boolean} [props.thousandSeparator=true] - Optional flag to enable thousands separator. Default is true.
- * @param {string} props.title - The title of the token input.
- * @param {number} [props.currentNetworkId=mainnet.id] - The current network id. Default is mainnet's id.
- * @param {function} props.onTokenSelect - Callback function to be called when a token is selected.
- * @param {Networks} [props.networks] - Optional list of networks to display in the dropdown. The dropdown won't show up if undefined. Default is undefined.
- * @param {string} [props.placeholder='Search by name or address'] - Optional placeholder text for the search input. Default is 'Search by name or address'.
- * @param {number} [props.containerHeight=320] - Optional height of the virtualized tokens list. Default is 320.
- * @param {number} [props.iconSize=32] - Optional size of the token icon in the list. Default is 32.
- * @param {number} [props.itemHeight=64] - Optional height of each item in the list. Default is 64.
- * @param {boolean} [props.showAddTokenButton=false] - Optional flag to allow adding a token. Default is false.
- * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance in the list. Default is false.
- * @param {boolean} [props.showTopTokens=false] - Optional flag to show the top tokens in the list. Default is false.
- */
-const TokenInput: FC = ({
- containerHeight,
- currentNetworkId,
- css,
- iconSize,
- itemHeight,
- networks,
- placeholder,
- showAddTokenButton,
- showBalance,
- showTopTokens,
- singleToken,
- thousandSeparator = true,
- title,
- tokenInput,
- ...restProps
-}: Props) => {
- const [isOpen, setIsOpen] = useState(false)
- const {
- amount,
- amountError,
- balance,
- balanceError,
- isLoadingBalance,
- selectedToken,
- setAmount,
- setAmountError,
- setTokenSelected,
- } = tokenInput
-
- const max = useMemo(
- () => (balance && selectedToken ? balance : BigInt(0)),
- [balance, selectedToken],
- )
- const selectIconSize = 24
- const decimals = selectedToken ? selectedToken.decimals : 2
-
- const handleSelectedToken = (token: Token | undefined) => {
- setAmount(BigInt(0))
- setTokenSelected(token)
- setIsOpen(false)
- }
-
- const handleSetMax = () => {
- setAmount(max)
- }
-
- const handleError: BigNumberInputProps['onError'] = (error) => {
- setAmountError(error?.message)
- }
-
- const CurrentToken = () =>
- selectedToken ? (
- <>
-
-
-
- {selectedToken.symbol}
- >
- ) : (
- 'Select'
- )
-
- return singleToken && !selectedToken ? (
- When single token is true, a token is required.
- ) : (
- setIsOpen(state.open)}
- >
-
- {title && {title}}
-
- (
-
- )}
- value={amount}
- />
- {singleToken ? (
-
-
-
- ) : (
-
-
-
-
-
- )}
-
-
- ~$0.00
-
-
- {balanceError && 'Error...'}
- {isLoadingBalance ? (
-
- ) : (
- `Balance: ${formatUnits(balance ?? 0n, selectedToken?.decimals ?? 0)}`
- )}
-
-
- Max
-
-
-
- {amountError && {amountError}}
-
-
-
-
-
-
- setIsOpen(false)}
- />
-
-
-
-
-
- )
-}
-
-function TokenAmountField({
- amountError,
- decimals,
- renderInputProps,
- thousandSeparator,
-}: {
- amountError?: string | null
- decimals: number
- renderInputProps: RenderInputProps
- thousandSeparator: boolean
-}) {
- const { onChange, inputRef, ...restProps } = renderInputProps
-
- const isAllowed = ({ value }: NumberFormatValues) => {
- const [inputDecimals] = value.toString().split('.')
-
- if (!inputDecimals) {
- return true
- }
-
- return decimals >= inputDecimals?.length
- }
-
- return (
- onChange?.(value)}
- thousandSeparator={thousandSeparator}
- // biome-ignore lint/suspicious/noExplicitAny: NumericFormat has defaultValue prop overwritten and is not compatible with the standard
- {...(restProps as any)}
- />
- )
-}
-
-export default TokenInput
diff --git a/src/components/sharedComponents/TokenInput/styles.ts b/src/components/sharedComponents/TokenInput/styles.ts
deleted file mode 100644
index c4ca608e..00000000
--- a/src/components/sharedComponents/TokenInput/styles.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-export const styles = {
- 'html.light &': {
- '--title-color': '#2e3048',
- '--background': '#fff',
- '--textfield-background-color': '#fff',
- '--textfield-background-color-active': 'rgb(0 0 0 / 5%)',
- '--textfield-border-color': '#e2e0e7',
- '--textfield-border-color-active': '#e2e0e7',
- '--textfield-color': '#2e3048',
- '--textfield-color-active': '#2e3048',
- '--textfield-placeholder-color': 'rgb(22 29 26 / 60%)',
- '--dropdown-button-background-color': '#fff',
- '--dropdown-button-background-color-hover': 'rgb(0 0 0 / 5%)',
- '--dropdown-button-border-color': '#e2e0e7',
- '--dropdown-button-border-color-hover': '#e2e0e7',
- '--dropdown-button-border-color-active': '#e2e0e7',
- '--dropdown-button-color': '#2e3048',
- '--dropdown-button-color-hover': '#2e3048',
- '--dropdown-button-background-color-disabled': '#fff',
- '--dropdown-button-border-color-disabled': '#e2e0e7',
- '--dropdown-button-color-disabled': '#2e3048',
- '--max-button-background-color': '#fff',
- '--max-button-background-color-hover': 'rgb(0 0 0 / 5%)',
- '--max-button-border-color': '#e2e0e7',
- '--max-button-border-color-hover': '#e2e0e7',
- '--max-button-border-color-active': '#e2e0e7',
- '--max-button-color': '#8b46a4',
- '--max-button-color-hover': '#8b46a4',
- '--max-button-background-color-disabled': '#fff',
- '--max-button-border-color-disabled': '#e2e0e7',
- '--max-button-color-disabled': '#8b46a4',
- '--estimated-usd-color': '#4b4d60',
- '--balance-color': '#4b4d60',
- },
- 'html.dark &': {
- '--title-color': '#fff',
- '--background': '#373954',
- '--textfield-background-color': '#373954',
- '--textfield-background-color-active': 'rgb(255 255 255 / 5%)',
- '--textfield-border-color': '#5f6178',
- '--textfield-border-color-active': '#5f6178',
- '--textfield-color': 'rgb(255 255 255 / 80%)',
- '--textfield-color-active': 'rgb(255 255 255 / 80%)',
- '--textfield-placeholder-color': 'rgb(255 255 255 / 50%)',
- '--dropdown-button-background-color': '#373954',
- '--dropdown-button-background-color-hover': 'rgb(255 255 255 / 5%)',
- '--dropdown-button-border-color': '#5f6178',
- '--dropdown-button-border-color-hover': '#5f6178',
- '--dropdown-button-border-color-active': '#5f6178',
- '--dropdown-button-color': '#fff',
- '--dropdown-button-color-hover': '#fff',
- '--dropdown-button-background-color-disabled': '#373954',
- '--dropdown-button-border-color-disabled': '#5f6178',
- '--dropdown-button-color-disabled': '#fff',
- '--max-button-background-color': '#373954',
- '--max-button-background-color-hover': 'rgb(255 255 255 / 5%)',
- '--max-button-border-color': '#c5c2cb',
- '--max-button-border-color-hover': '#c5c2cb',
- '--max-button-border-color-active': '#c5c2cb',
- '--max-button-color': '#c5c2cb',
- '--max-button-color-hover': '#fff',
- '--max-button-background-color-disabled': '#373954',
- '--max-button-border-color-disabled': '#5f6178',
- '--max-button-color-disabled': '#fff',
- '--estimated-usd-color': '#e2e0e7',
- '--balance-color': '#e2e0e7',
- },
-}
-
-export default styles
diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx
deleted file mode 100644
index 7bc881a2..00000000
--- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { useQuery } from '@tanstack/react-query'
-import { useEffect, useState } from 'react'
-import { getAddress } from 'viem'
-import { useAccount, usePublicClient } from 'wagmi'
-import { useErc20Balance } from '@/src/hooks/useErc20Balance'
-import type { Token } from '@/src/types/token'
-import { isNativeToken } from '@/src/utils/address'
-
-export type UseTokenInputReturnType = ReturnType
-
-/**
- * Token Input Hook
- *
- * Manages state and logic for token input components, handling:
- * - Token amount and validation errors
- * - Selected token state
- * - Balance fetching for ERC20 and native tokens
- *
- * @param {Token} [token] - Optional initial token to select
- * @returns {Object} Hook return object
- * @returns {bigint} returns.amount - The current input amount as a bigint
- * @returns {function} returns.setAmount - Function to update the amount
- * @returns {string|null} returns.amountError - Error message for invalid amount
- * @returns {function} returns.setAmountError - Function to update amount errors
- * @returns {bigint} returns.balance - Current token balance (ERC20 or native)
- * @returns {Error|null} returns.balanceError - Error from balance fetching
- * @returns {boolean} returns.isLoadingBalance - Loading state for balance
- * @returns {Token|undefined} returns.selectedToken - Currently selected token
- * @returns {function} returns.setTokenSelected - Function to update selected token
- *
- * @example
- * ```tsx
- * const {
- * amount,
- * balance,
- * selectedToken,
- * setAmount,
- * setTokenSelected
- * } = useTokenInput(defaultToken);
- * ```
- */
-export function useTokenInput(token?: Token) {
- const [amount, setAmount] = useState(BigInt(0))
- const [amountError, setAmountError] = useState()
- const [selectedToken, setTokenSelected] = useState(token)
-
- useEffect(() => {
- setTokenSelected(token)
- }, [token])
-
- const { address: userWallet } = useAccount()
- const { balance, balanceError, isLoadingBalance } = useErc20Balance({
- address: userWallet ? getAddress(userWallet) : undefined,
- token: selectedToken,
- })
-
- const publicClient = usePublicClient({ chainId: token?.chainId })
-
- const isNative = selectedToken?.address ? isNativeToken(selectedToken.address) : false
- const {
- data: nativeBalance,
- error: nativeBalanceError,
- isLoading: isLoadingNativeBalance,
- } = useQuery({
- queryKey: ['nativeBalance', selectedToken?.address, selectedToken?.chainId, userWallet],
- queryFn: () => publicClient?.getBalance({ address: getAddress(userWallet ?? '') }),
- enabled: isNative,
- })
-
- return {
- amount,
- setAmount,
- amountError,
- setAmountError,
- balance: isNative ? nativeBalance : balance,
- balanceError: isNative ? nativeBalanceError : balanceError,
- isLoadingBalance: isNative ? isLoadingNativeBalance : isLoadingBalance,
- selectedToken,
- setTokenSelected,
- }
-}
diff --git a/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx b/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx
deleted file mode 100644
index f1501a15..00000000
--- a/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { chakra } from '@chakra-ui/react'
-import type { ComponentPropsWithoutRef, FC, MouseEventHandler } from 'react'
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-import type { Token } from '@/src/types/token'
-import { isNativeToken } from '@/src/utils/address'
-
-interface AddERC20TokenButtonProps extends ComponentPropsWithoutRef<'button'> {
- $token: Token
-}
-
-/**
- * Renders a button that adds an ERC20 token to the wallet.
- *
- * @param {AddERC20TokenButtonProps} props - AddERC20TokenButton component props.
- * @param {Token} props.$token - The ERC20 token object.
- */
-const AddERC20TokenButton: FC = ({
- $token,
- children,
- onClick,
- ...restProps
-}) => {
- const { isWalletConnected, walletChainId, walletClient } = useWeb3Status()
- const { address, chainId, decimals, logoURI, symbol } = $token
- const disabled = !isWalletConnected || walletChainId !== chainId
-
- const handleClick: MouseEventHandler = (e) => {
- e.stopPropagation()
-
- walletClient?.watchAsset({
- options: {
- address: address,
- decimals: decimals,
- image: logoURI,
- symbol: symbol,
- },
- type: 'ERC20',
- })
-
- onClick?.(e)
- }
-
- return isNativeToken(address) ? null : (
-
- {children}
-
- )
-}
-
-export default AddERC20TokenButton
diff --git a/src/components/sharedComponents/TokenSelect/List/Row.tsx b/src/components/sharedComponents/TokenSelect/List/Row.tsx
deleted file mode 100644
index 5e981137..00000000
--- a/src/components/sharedComponents/TokenSelect/List/Row.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { Box, Flex, type FlexProps, Skeleton } from '@chakra-ui/react'
-import type { FC } from 'react'
-import TokenLogo from '@/src/components/sharedComponents/TokenLogo'
-import AddERC20TokenButton from '@/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton'
-import TokenBalance from '@/src/components/sharedComponents/TokenSelect/List/TokenBalance'
-import type { Token } from '@/src/types/token'
-
-const Icon: FC<{ size: number } & FlexProps> = ({ size, children, ...restProps }) => (
-
- {children}
-
-)
-
-const BalanceLoading: FC = ({ ...restProps }) => (
-
-
-
-
-)
-
-interface TokenSelectRowProps extends Omit {
- iconSize: number
- isLoadingBalances?: boolean
- onClick: (token: Token) => void
- showAddTokenButton?: boolean
- showBalance?: boolean
- token: Token
-}
-
-/**
- * A row in the token select list.
- *
- * @param {object} props - TokenSelect List's Row props.
- * @param {Token} prop.token - The token to display.
- * @param {number} prop.iconSize - The size of the token icon.
- * @param {(token: Token) => void} prop.onClick - Callback function to be called when the row is clicked.
- * @param {boolean} prop.showAddTokenButton - Whether to display an add token button.
- * @param {boolean} [prop.showBalance=false] - Optional flag to show the token balance. Default is false.
- * @param {boolean} [prop.showBalance=false] - Optional flag to inform the balances are being loaded. Default is false.
- */
-const Row: FC = ({
- iconSize,
- isLoadingBalances,
- onClick,
- showAddTokenButton,
- showBalance,
- token,
- ...restProps
-}) => {
- const { name } = token
-
- return (
- onClick(token)}
- {...restProps}
- >
-
-
-
-
- {name}
-
- {showAddTokenButton && Add token}
- {showBalance && (
-
- }
- token={token}
- />
-
- )}
-
- )
-}
-
-export default Row
diff --git a/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx b/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx
deleted file mode 100644
index 4a0fec9e..00000000
--- a/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Box, Flex } from '@chakra-ui/react'
-import { formatUnits } from 'viem'
-import type { Token } from '@/src/types/token'
-import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper'
-
-interface TokenBalanceProps {
- isLoading?: boolean
- token: Token
-}
-
-/**
- * Renders the token balance in the token list row.
- *
- * @param {object} props - The component props.
- * @param {boolean} props.isLoading - Indicates if the token balance is currently being loaded.
- * @param {Token} props.token - The token object containing the amount, decimals, and price in USD.
- *
- * @throws {Promise} If the token balance is still loading or if the token does not have balance information.
- * @returns {JSX.Element} The rendered token balance component.
- *
- * @example
- * ```tsx
- *
- * ```
- */
-const TokenBalance = withSuspenseAndRetry(({ isLoading, token }) => {
- const tokenHasBalanceInfo = !!token.extensions
-
- if (isLoading || !tokenHasBalanceInfo) {
- throw Promise.reject()
- }
-
- const balance = formatUnits((token.extensions?.balance ?? 0n) as bigint, token.decimals)
- const value = (
- Number.parseFloat((token.extensions?.priceUSD ?? '0') as string) * Number.parseFloat(balance)
- ).toFixed(2)
-
- return (
-
-
- {balance}
-
-
- $ {value}
-
-
- )
-})
-
-export default TokenBalance
diff --git a/src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx b/src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx
deleted file mode 100644
index 1e1b3ad5..00000000
--- a/src/components/sharedComponents/TokenSelect/List/VirtualizedList.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Box } from '@chakra-ui/react'
-import { useVirtualizer } from '@tanstack/react-virtual'
-import { type ReactNode, useRef } from 'react'
-
-type VirtualizedListProps- = {
- containerHeight: number
- itemHeight: number
- items: Array
-
- renderItem: (item: Item) => ReactNode
-}
-
-const VirtualizedList =
- ({
- containerHeight,
- itemHeight,
- items,
- renderItem,
- ...restProps
-}: VirtualizedListProps
- ) => {
- const parentRef = useRef(null)
-
- const rowVirtualizer = useVirtualizer({
- count: items.length,
- estimateSize: () => itemHeight,
- getScrollElement: () => parentRef.current,
- overscan: 5,
- })
-
- return (
-
-
- {rowVirtualizer.getVirtualItems().map(({ index, key, size, start }) => (
-
- {renderItem(items[index])}
-
- ))}
-
-
- )
-}
-
-export default VirtualizedList
diff --git a/src/components/sharedComponents/TokenSelect/List/index.tsx b/src/components/sharedComponents/TokenSelect/List/index.tsx
deleted file mode 100644
index 5aea0c56..00000000
--- a/src/components/sharedComponents/TokenSelect/List/index.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Flex, type FlexProps } from '@chakra-ui/react'
-import type { FC } from 'react'
-import Row from '@/src/components/sharedComponents/TokenSelect/List/Row'
-import VirtualizedList from '@/src/components/sharedComponents/TokenSelect/List/VirtualizedList'
-import type { Token, Tokens } from '@/src/types/token'
-
-interface TokenSelectListProps extends FlexProps {
- containerHeight: number
- iconSize: number
- isLoadingBalances: boolean
- itemHeight: number
- onTokenSelect: (token: Token | undefined) => void
- showAddTokenButton?: boolean
- showBalance: boolean
- tokenList?: Tokens
-}
-
-/**
- * List component for TokenSelect. Displays a list of tokens.
- *
- * @param {object} props - TokenSelect List props.
- * @param {number} props.containerHeight - The height of the virtualized list container.
- * @param {number} props.iconSize - The size of the token icon for each item in the list.
- * @param {number} props.itemHeight - The height of each item in the list.
- * @param {function} props.onTokenSelect - Callback function to be called when a token is selected.
- * @param {boolean} props.showAddTokenButton - Whether to display an add token button.
- * @param {boolean} props.showBalance - Flag to show the token balance in the list.
- * @param {boolean} props.isLoadingBalances - Flag to inform the balances are loading.
- * @param {Tokens} props.tokenList - The list of tokens to display.
- */
-const List: FC = ({
- className,
- containerHeight,
- iconSize,
- isLoadingBalances,
- itemHeight,
- onTokenSelect,
- showAddTokenButton,
- showBalance,
- tokenList,
- ...restProps
-}) => {
- return (
-
- {tokenList?.length ? (
-
- containerHeight={containerHeight}
- itemHeight={itemHeight}
- items={tokenList}
- renderItem={(token) => (
-
onTokenSelect(token)}
- showAddTokenButton={showAddTokenButton}
- showBalance={showBalance}
- token={token}
- />
- )}
- {...restProps}
- />
- ) : (
-
- No tokens
-
- )}
-
- )
-}
-
-export default List
diff --git a/src/components/sharedComponents/TokenSelect/Search/Input.tsx b/src/components/sharedComponents/TokenSelect/Search/Input.tsx
deleted file mode 100644
index 021406f2..00000000
--- a/src/components/sharedComponents/TokenSelect/Search/Input.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Input as BaseInput, Flex, type InputProps } from '@chakra-ui/react'
-import type { FC } from 'react'
-
-const SearchIcon = () => (
-
-)
-
-/**
- * A search input with a search icon
- */
-const Input: FC = ({ className, ...inputProps }) => {
- return (
-
-
-
-
- )
-}
-
-export default Input
diff --git a/src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx b/src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx
deleted file mode 100644
index 3df92a36..00000000
--- a/src/components/sharedComponents/TokenSelect/Search/NetworkButton.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { chakra } from '@chakra-ui/react'
-import type { ComponentProps, FC } from 'react'
-
-const ChevronDown = () => (
-
-)
-
-/**
- * A button to select a network from a dropdown
- */
-const NetworkButton: FC> = ({ children, ...restProps }) => (
-
- {children}
-
-)
-
-export default NetworkButton
diff --git a/src/components/sharedComponents/TokenSelect/Search/index.tsx b/src/components/sharedComponents/TokenSelect/Search/index.tsx
deleted file mode 100644
index ea841685..00000000
--- a/src/components/sharedComponents/TokenSelect/Search/index.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Box, Flex, type FlexProps, Menu } from '@chakra-ui/react'
-import type { Dispatch, FC, SetStateAction } from 'react'
-import SearchInput from '@/src/components/sharedComponents/TokenSelect/Search/Input'
-import NetworkButton from '@/src/components/sharedComponents/TokenSelect/Search/NetworkButton'
-import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types'
-import { MenuContent, MenuItem } from '@/src/components/sharedComponents/ui/Menu'
-
-interface SearchProps extends FlexProps {
- currentNetworkId: number
- disabled?: boolean
- networks?: Networks
- placeholder?: string
- searchTerm: string
- setSearchTerm: Dispatch>
-}
-
-/**
- * Search component for TokenSelect. Includes a search input and a networks dropdown.
- *
- * @param {SearchProps} props - Search component props.
- * @param {number} props.currentNetworkId - The current network id.
- * @param {boolean} [props.disabled] - Optional flag to disable the search input.
- * @param {Networks} [props.networks] - Optional list of networks to display in the dropdown.
- * @param {string} [props.placeholder] - Optional placeholder text for the search input.
- * @param {string} props.searchTerm - The current search term.
- * @param {Function} props.setSearchTerm - Callback function to set the search term.
- */
-const Search: FC = ({
- currentNetworkId,
- disabled,
- networks,
- placeholder,
- searchTerm,
- setSearchTerm,
- ...restProps
-}: SearchProps) => {
- return (
-
- setSearchTerm(e.target.value)}
- placeholder={placeholder}
- value={searchTerm}
- />
- {networks && networks.length > 1 && (
-
-
-
-
- {networks.find((item) => item.id === currentNetworkId)?.icon}
-
-
-
-
-
- {networks.map(({ icon, id, label, onClick }) => (
-
- ))}
-
-
-
- )}
-
- )
-}
-
-export default Search
diff --git a/src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx b/src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx
deleted file mode 100644
index cba34232..00000000
--- a/src/components/sharedComponents/TokenSelect/TopTokens/Item.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { Box, chakra, Flex } from '@chakra-ui/react'
-import type { ComponentPropsWithoutRef, FC } from 'react'
-import TokenLogo from '@/src/components/sharedComponents/TokenLogo'
-import type { Token } from '@/src/types/token'
-
-const ICON_SIZE = 24
-
-interface ItemProps extends ComponentPropsWithoutRef<'button'> {
- token: Token
-}
-
-/**
- * A single token item in the top tokens list
- *
- * @param {Token} token - The token to display
- */
-const Item: FC = ({ token, ...restProps }) => {
- const { symbol } = token
-
- return (
-
-
-
-
-
- {symbol}
-
-
- )
-}
-
-export default Item
diff --git a/src/components/sharedComponents/TokenSelect/TopTokens/index.tsx b/src/components/sharedComponents/TokenSelect/TopTokens/index.tsx
deleted file mode 100644
index e8ab61a1..00000000
--- a/src/components/sharedComponents/TokenSelect/TopTokens/index.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Flex, type FlexProps } from '@chakra-ui/react'
-import type { FC } from 'react'
-import Item from '@/src/components/sharedComponents/TokenSelect/TopTokens/Item'
-import type { Token, Tokens } from '@/src/types/token'
-import { isNativeToken } from '@/src/utils/address'
-
-interface TopTokensProps extends FlexProps {
- onTokenSelect: (token: Token | undefined) => void
- tokens: Tokens
-}
-
-/**
- * TopTokens component for TokenSelect. Displays a list of top / preferred tokens.
- *
- * @param {function} onTokenSelect - Callback function to be called when a token is selected.
- * @param {Tokens} tokens - The list of tokens to display.
- */
-const TopTokens: FC = ({ onTokenSelect, tokens, ...restProps }) => {
- const topTokenSymbols = ['op', 'usdc', 'usdt', 'dai', 'weth', 'wbtc', 'aave']
-
- return (
-
- {[
- // append native token at the beginning
- tokens.find((token) => isNativeToken(token.address)),
- ...tokens
- .filter((token) => topTokenSymbols.includes(token.symbol.toLowerCase()))
- .sort(
- (a, b) =>
- topTokenSymbols.indexOf(a.symbol.toLowerCase()) -
- topTokenSymbols.indexOf(b.symbol.toLowerCase()),
- ),
- ]
- // if token is not found, filter it out
- .filter(Boolean)
- // render the token
- .map((token) => (
- - onTokenSelect(token)}
- // biome-ignore lint/style/noNonNullAssertion: token is defined when rendered via filter above
- token={token!}
- />
- ))}
-
- )
-}
-
-export default TopTokens
diff --git a/src/components/sharedComponents/TokenSelect/index.tsx b/src/components/sharedComponents/TokenSelect/index.tsx
deleted file mode 100644
index 0ca21ff8..00000000
--- a/src/components/sharedComponents/TokenSelect/index.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import { Flex, type FlexProps } from '@chakra-ui/react'
-import { useEffect, useRef, useState } from 'react'
-import type { Chain } from 'viem/chains'
-import List from '@/src/components/sharedComponents/TokenSelect/List'
-import Search from '@/src/components/sharedComponents/TokenSelect/Search'
-import TopTokens from '@/src/components/sharedComponents/TokenSelect/TopTokens'
-import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types'
-import { getValidChainId } from '@/src/components/sharedComponents/TokenSelect/utils'
-import { useTokenSearch } from '@/src/hooks/useTokenSearch'
-import { useTokens } from '@/src/hooks/useTokens'
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-import { chains } from '@/src/lib/networks.config'
-import type { Token } from '@/src/types/token'
-import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper'
-import styles from './styles'
-
-export interface TokenSelectProps {
- containerHeight?: number
- currentNetworkId?: number
- iconSize?: number
- itemHeight?: number
- networks?: Networks | undefined
- onTokenSelect: (token: Token | undefined) => void
- placeholder?: string
- showAddTokenButton?: boolean
- showTopTokens?: boolean
- showBalance?: boolean
-}
-
-/** @ignore */
-type Props = FlexProps & TokenSelectProps
-
-/**
- * TokenSelect component, used to search and select a token from a list.
- *
- * @param {object} props - TokenSelect props.
- * @param {number} [props.currentNetworkId=mainnet.id] - The current network id. Default is mainnet's id.
- * @param {function} props.onTokenSelect - Callback function to be called when a token is selected.
- * @param {Networks} [props.networks] - Optional list of networks to display in the dropdown. The dropdown won't show up if undefined. Default is undefined.
- * @param {string} [props.placeholder='Search by name or address'] - Optional placeholder text for the search input. Default is 'Search by name or address'.
- * @param {number} [props.containerHeight=320] - Optional height of the virtualized tokens list. Default is 320.
- * @param {number} [props.iconSize=32] - Optional size of the token icon in the list. Default is 32.
- * @param {number} [props.itemHeight=64] - Optional height of each item in the list. Default is 64.
- * @param {boolean} [props.showAddTokenButton=false] - Optional flag to allow adding a token. Default is false.
- * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance in the list. Default is false.
- * @param {boolean} [props.showTopTokens=false] - Optional flag to show the top tokens in the list. Default is false.
- */
-const TokenSelect = withSuspenseAndRetry(
- ({
- children,
- containerHeight = 320,
- currentNetworkId,
- css,
- iconSize = 32,
- itemHeight = 52,
- networks = undefined,
- onTokenSelect,
- placeholder = 'Search by name or address',
- showAddTokenButton = false,
- showBalance = false,
- showTopTokens = false,
- ...restProps
- }) => {
- const { appChainId, walletChainId } = useWeb3Status()
-
- const [chainId, setChainId] = useState(() =>
- getValidChainId({
- appChainId,
- currentNetworkId,
- dappChains: chains,
- networks,
- walletChainId,
- }),
- )
-
- const previousDepsRef = useRef([appChainId, currentNetworkId, walletChainId])
-
- /**
- * This is a sort-of observer, that listens to changes in the `appChainId` and `currentNetworkId`
- * identifies which one changed and updates the chainId accordingly.
- *
- * This way, we can have a mixed behavior between app-based and wallet-based chain change.
- */
- useEffect(() => {
- const previousDeps = previousDepsRef.current
- const currentDeps = [appChainId, currentNetworkId, walletChainId]
-
- previousDeps.forEach((previousDep, index) => {
- const currentDep = currentDeps[index]
-
- if (previousDep !== currentDep) {
- const currentChainId = currentDeps[1]
-
- if (index === 1 && !!currentChainId) {
- // currentNetworkId changed, we stick with it
- setChainId(currentChainId)
- } else {
- if (!currentDep) {
- // if the chainId is undefined, we don't do anything
- return
- }
-
- // appChainId or walletChainId changed,
- // we need to check that it's valid in the current context
- if (networks) {
- // if `networks` is defined,
- // we need to check if the chainId is valid in the list and set it
- if (networks.some((network) => network.id === currentDep)) {
- setChainId(currentDep)
- }
- } else {
- // if `networks` is not defined,
- // we need to check if the chainId is valid in the dApp chains list and set it
- if (chains.some((chain) => chain.id === currentDep)) {
- setChainId(currentDep)
- }
- }
- }
- }
- })
-
- previousDepsRef.current = [appChainId, currentNetworkId, walletChainId]
- }, [appChainId, currentNetworkId, networks, walletChainId])
-
- const { isLoadingBalances, tokensByChainId } = useTokens({
- chainId,
- withBalance: showBalance,
- })
-
- const { searchResult, searchTerm, setSearchTerm } = useTokenSearch(
- { tokens: tokensByChainId[chainId] },
- [currentNetworkId, tokensByChainId[chainId]],
- )
-
- return (
-
-
- {showTopTokens && (
-
- )}
-
- {children}
-
- )
- },
-)
-
-export default TokenSelect
diff --git a/src/components/sharedComponents/TokenSelect/styles.ts b/src/components/sharedComponents/TokenSelect/styles.ts
deleted file mode 100644
index 51f889e5..00000000
--- a/src/components/sharedComponents/TokenSelect/styles.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-export const styles = {
- 'html.light &': {
- '--background-color': '#fff',
- '--border-color': '#fff',
- '--title-color': '#2e3048',
- '--box-shadow': '0 9.6px 13px 0 rgb(0 0 0 / 8%)',
- '--network-button-color': '#2e3048',
- '--network-button-background-color': '#f7f7f7',
- '--list-border-top-color': '#e2e0e7',
- '--row-background-color': 'transparent',
- '--row-background-color-hover': 'rgb(0 0 0 / 5%)',
- '--row-token-name-color': '#2e3048',
- '--row-token-balance-color': '#2e3048',
- '--row-token-value-color': '#2e3048',
- '--top-token-item-background-color': '#fff',
- '--top-token-item-border-color': '#e2e0e7',
- '--top-token-item-color': '#2e3048',
- '--top-token-item-background-color-hover': 'rgb(0 0 0 / 5%)',
- '--search-field-color': '#2e3048',
- '--search-field-color-active': '#2e3048',
- '--search-field-background-color': '#f7f7f7',
- '--search-field-background-color-active': '#f7f7f7',
- '--search-field-placeholder-color': '#161d1a',
- '--search-field-box-shadow': 'none',
- '--search-field-box-shadow-active': 'rgb(0 0 0 / 10%)',
- '--search-field-border-color': '#e2e0e7',
- '--search-field-border-color-active': '#e2e0e7',
- '--add-erc20-token-button-background-color': '#2e3048',
- '--add-erc20-token-button-background-color-hover': '#3d405f',
- '--add-erc20-token-button-border-color': '#2e3048',
- '--add-erc20-token-button-border-color-hover': '#3d405f',
- '--add-erc20-token-button-color': '#fff',
- '--add-erc20-token-button-color-hover': '#fff',
- },
- 'html.dark &': {
- '--background-color': '#2e3048',
- '--border-color': '#2e3048',
- '--title-color': '#fff',
- '--box-shadow': '0 0 20px 0 rgb(255 255 255 / 8%)',
- '--network-button-color': '#fff',
- '--network-button-background-color': '#292b43',
- '--list-border-top-color': '#4b4d60',
- '--row-background-color': 'transparent',
- '--row-background-color-hover': 'rgb(255 255 255 / 5%)',
- '--row-token-name-color': '#fff',
- '--row-token-balance-color': '#fff',
- '--row-token-value-color': '#fff',
- '--top-token-item-background-color': '#2e3048',
- '--top-token-item-border-color': '#4b4d60',
- '--top-token-item-color': '#fff',
- '--top-token-item-background-color-hover': 'rgb(255 255 255 / 5%)',
- '--search-field-color': '#fff',
- '--search-field-color-active': '#fff',
- '--search-field-background-color': '#292b43',
- '--search-field-background-color-active': '#292b43',
- '--search-field-placeholder-color': '#ddd',
- '--search-field-box-shadow': 'none',
- '--search-field-box-shadow-active': 'none',
- '--search-field-border-color': '#5f6178',
- '--search-field-border-color-active': '#5f6178',
- '--add-erc20-token-button-background-color': '#5f6178',
- '--add-erc20-token-button-background-color-hover': '#4a4c5f',
- '--add-erc20-token-button-border-color': '#5f6178',
- '--add-erc20-token-button-border-color-hover': '#4a4c5f',
- '--add-erc20-token-button-color': '#fff',
- '--add-erc20-token-button-color-hover': '#fff',
- },
-}
-
-export default styles
diff --git a/src/components/sharedComponents/TokenSelect/types.ts b/src/components/sharedComponents/TokenSelect/types.ts
deleted file mode 100644
index 1b778dbc..00000000
--- a/src/components/sharedComponents/TokenSelect/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { ReactElement } from 'react'
-
-export type Networks = Array<{
- icon: ReactElement
- id: number
- label: string
- onClick: () => void
-}>
diff --git a/src/components/sharedComponents/TokenSelect/utils.tsx b/src/components/sharedComponents/TokenSelect/utils.tsx
deleted file mode 100644
index 9666c46e..00000000
--- a/src/components/sharedComponents/TokenSelect/utils.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { Chain } from 'viem/chains'
-
-import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types'
-import type { ChainsIds, chains } from '@/src/lib/networks.config'
-
-interface GetValidChainIdParams {
- currentNetworkId?: Chain['id']
- networks?: Networks
- dappChains: typeof chains
- walletChainId?: Chain['id']
- appChainId?: ChainsIds
-}
-
-export const getValidChainId = ({
- appChainId,
- currentNetworkId,
- dappChains,
- networks,
- walletChainId,
-}: GetValidChainIdParams): Chain['id'] => {
- // If the current network is defined, use it
- if (currentNetworkId) {
- return currentNetworkId
- }
-
- // if `networks` has been passed, then we need to stick with
- if (networks !== undefined) {
- // we prioritze the wallet chainId over the app chainId because it may be valid in this case,
- // but not supported by the app to interact with but as a read-only chain.
- if (typeof walletChainId === 'number' && networks.some(({ id }) => id === walletChainId)) {
- return walletChainId
- }
-
- if (typeof appChainId === 'number' && networks.some(({ id }) => id === appChainId)) {
- return appChainId
- }
-
- // if nothing matches, we default to the first network in the list
- return networks[0].id
- }
-
- // if `networks` is not defined, we need to verify the dApp configuration
- // Same as before, we prioritize the wallet chainId over the app chainId
- if (typeof walletChainId === 'number' && dappChains.some(({ id }) => id === walletChainId)) {
- return walletChainId
- }
-
- if (typeof appChainId === 'number' && dappChains.some(({ id }) => id === appChainId)) {
- return appChainId
- }
-
- // if nothing matches, we default to the first chain in the list
- return dappChains[0].id
-}
diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx
deleted file mode 100644
index 2c74231c..00000000
--- a/src/components/sharedComponents/TransactionButton.test.tsx
+++ /dev/null
@@ -1,316 +0,0 @@
-import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
-import { render, screen } from '@testing-library/react'
-import { createElement, type ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import TransactionButton from './TransactionButton'
-
-const mockSwitchChain = vi.fn()
-const mockExecute = vi.fn(async () => ({
- status: 'success' as const,
- ref: { chainType: 'evm', id: '0xabc', chainId: 1 },
- receipt: {},
-}))
-
-vi.mock('@/src/sdk/react/hooks', () => ({
- useWallet: vi.fn(() => ({
- needsConnect: true,
- needsChainSwitch: false,
- isReady: false,
- status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: vi.fn(),
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- adapter: {} as never,
- })),
- useTransaction: vi.fn(() => ({
- phase: 'idle',
- execute: mockExecute,
- reset: vi.fn(),
- prepareResult: null,
- ref: null,
- result: null,
- preStepResults: [],
- explorerUrl: null,
- error: null,
- })),
- useChainRegistry: vi.fn(() => ({
- getChain: vi.fn(() => null),
- getChainByCaip2: vi.fn(() => null),
- getChainType: vi.fn(() => null),
- getChainsByType: vi.fn(() => []),
- getAllChains: vi.fn(() => []),
- })),
-}))
-
-vi.mock('@/src/wallet/providers', () => ({
- ConnectWalletButton: () =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'connect-wallet-button' },
- 'Connect Wallet',
- ),
-}))
-
-vi.mock('@/src/components/sharedComponents/ui/SwitchChainButton', () => ({
- default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'switch-chain-button', onClick },
- children,
- ),
-}))
-
-const { useWallet, useTransaction, useChainRegistry } = await import('@/src/sdk/react/hooks')
-const mockedUseWallet = vi.mocked(useWallet)
-const mockedUseTransaction = vi.mocked(useTransaction)
-const mockedUseChainRegistry = vi.mocked(useChainRegistry)
-
-const system = createSystem(defaultConfig)
-const renderWithChakra = (ui: ReactNode) =>
- render({ui})
-
-const testParams = { chainId: 1, payload: { to: '0x1234', value: '0' } }
-
-const makeWalletReady = () =>
- mockedUseWallet.mockReturnValue({
- needsConnect: false,
- needsChainSwitch: false,
- isReady: true,
- status: { connected: true, connecting: false, activeAccount: '0xabc', connectedChainIds: [1] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: vi.fn(),
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- adapter: {} as never,
- })
-
-describe('TransactionButton', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockExecute.mockResolvedValue({
- status: 'success' as const,
- ref: { chainType: 'evm', id: '0xabc', chainId: 1 },
- receipt: {},
- })
- })
-
- it('renders connect button when wallet needsConnect', () => {
- mockedUseWallet.mockReturnValue({
- needsConnect: true,
- needsChainSwitch: false,
- isReady: false,
- status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: vi.fn(),
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- adapter: {} as never,
- })
-
- renderWithChakra(Send)
-
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
- expect(screen.queryByText('Send')).toBeNull()
- })
-
- it('renders custom fallback when provided and wallet needsConnect', () => {
- mockedUseWallet.mockReturnValue({
- needsConnect: true,
- needsChainSwitch: false,
- isReady: false,
- status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: vi.fn(),
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- adapter: {} as never,
- })
-
- renderWithChakra(
-
- Send
- ,
- )
-
- expect(screen.getByTestId('custom-fallback')).toBeInTheDocument()
- expect(screen.queryByText('Send')).toBeNull()
- })
-
- it('renders switch chain button when wallet needsChainSwitch', () => {
- mockedUseWallet.mockReturnValue({
- needsConnect: false,
- needsChainSwitch: true,
- isReady: false,
- status: {
- connected: true,
- connecting: false,
- activeAccount: '0xabc',
- connectedChainIds: [1],
- },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: vi.fn(),
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- adapter: {} as never,
- })
- mockedUseChainRegistry.mockReturnValue({
- getChain: vi.fn(() => ({
- name: 'OP Mainnet',
- chainId: 10,
- chainType: 'evm',
- caip2Id: 'eip155:10',
- nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
- addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
- })),
- getChainByCaip2: vi.fn(() => null),
- getChainType: vi.fn(() => null),
- getChainsByType: vi.fn(() => []),
- getAllChains: vi.fn(() => []),
- })
-
- renderWithChakra(Send)
-
- expect(screen.getByTestId('switch-chain-button')).toBeInTheDocument()
- expect(screen.getByText(/Switch to/)).toBeInTheDocument()
- expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument()
- expect(screen.queryByText('Send')).toBeNull()
- })
-
- it('renders switch chain label with chain name', () => {
- mockedUseWallet.mockReturnValue({
- needsConnect: false,
- needsChainSwitch: true,
- isReady: false,
- status: {
- connected: true,
- connecting: false,
- activeAccount: '0xabc',
- connectedChainIds: [1],
- },
- switchChain: mockSwitchChain,
- connect: vi.fn(),
- disconnect: vi.fn(),
- signMessage: vi.fn(),
- getSigner: vi.fn(),
- adapterKey: 'evm',
- openConnectModal: vi.fn(),
- openAccountModal: vi.fn(),
- adapter: {} as never,
- })
- mockedUseChainRegistry.mockReturnValue({
- getChain: vi.fn(() => ({
- name: 'OP Mainnet',
- chainId: 10,
- chainType: 'evm',
- caip2Id: 'eip155:10',
- nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
- addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
- })),
- getChainByCaip2: vi.fn(() => null),
- getChainType: vi.fn(() => null),
- getChainsByType: vi.fn(() => []),
- getAllChains: vi.fn(() => []),
- })
-
- renderWithChakra(
-
- Send
- ,
- )
-
- expect(screen.getByText(/Change to/)).toBeInTheDocument()
- expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument()
- })
-
- it('renders transaction button when wallet is ready', () => {
- makeWalletReady()
-
- renderWithChakra(Send ETH)
-
- expect(screen.getByText('Send ETH')).toBeInTheDocument()
- expect(screen.queryByTestId('connect-wallet-button')).toBeNull()
- })
-
- it('shows labelSending when phase is not idle', () => {
- makeWalletReady()
- mockedUseTransaction.mockReturnValue({
- phase: 'submit',
- execute: mockExecute,
- reset: vi.fn(),
- prepareResult: null,
- ref: null,
- result: null,
- preStepResults: [],
- preStepStatuses: [],
- explorerUrl: null,
- error: null,
- prepare: vi.fn(),
- executePreStep: vi.fn(),
- executeAllPreSteps: vi.fn(),
- })
-
- renderWithChakra(Send ETH)
-
- expect(screen.getByText('Sending...')).toBeInTheDocument()
- expect(screen.queryByText('Send ETH')).toBeNull()
- })
-
- it('is disabled when disabled prop passed', () => {
- makeWalletReady()
- mockedUseTransaction.mockReturnValue({
- phase: 'idle',
- execute: mockExecute,
- reset: vi.fn(),
- prepareResult: null,
- ref: null,
- result: null,
- preStepResults: [],
- preStepStatuses: [],
- explorerUrl: null,
- error: null,
- prepare: vi.fn(),
- executePreStep: vi.fn(),
- executeAllPreSteps: vi.fn(),
- })
-
- renderWithChakra(
-
- Send ETH
- ,
- )
-
- const button = screen.getByText('Send ETH').closest('button')
- expect(button).toBeDefined()
- expect(button?.disabled).toBe(true)
- })
-})
diff --git a/src/components/sharedComponents/TransactionButton.tsx b/src/components/sharedComponents/TransactionButton.tsx
deleted file mode 100644
index 15d05361..00000000
--- a/src/components/sharedComponents/TransactionButton.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import type { ButtonProps } from '@chakra-ui/react'
-import type { ReactElement } from 'react'
-import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton'
-import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton'
-import type { TransactionLifecycle, TransactionParams } from '@/src/sdk/core'
-import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks'
-import { ConnectWalletButton } from '@/src/wallet/providers'
-
-interface TransactionButtonProps extends ButtonProps {
- /** Transaction parameters. The chainId field drives wallet resolution. */
- params: TransactionParams
- /** Per-operation lifecycle hooks merged with global lifecycle. */
- lifecycle?: TransactionLifecycle
- /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */
- fallback?: ReactElement
- /** Button label during pending transaction. Defaults to 'Sending...'. */
- labelSending?: string
- /** Label for the switch chain button. Defaults to 'Switch to'. */
- switchChainLabel?: string
-}
-
-/**
- * Self-contained transaction button with wallet verification and submission.
- *
- * Shows a connect button if not connected, a switch chain button if on the wrong chain,
- * or the transaction button when ready.
- *
- * @example
- * ```tsx
- * console.log('confirmed', result) }}
- * >
- * Send ETH
- *
- * ```
- */
-function TransactionButton({
- params,
- lifecycle,
- children = 'Send Transaction',
- disabled,
- fallback = ,
- labelSending = 'Sending...',
- switchChainLabel = 'Switch to',
- ...restProps
-}: TransactionButtonProps) {
- const wallet = useWallet({ chainId: params.chainId })
- const { execute, phase } = useTransaction({ lifecycle })
- const registry = useChainRegistry()
- const isPending = phase !== 'idle'
-
- if (wallet.needsConnect) {
- return fallback
- }
-
- if (wallet.needsChainSwitch) {
- const targetChain = registry.getChain(params.chainId)
- return (
- wallet.switchChain(params.chainId)}>
- {switchChainLabel} {targetChain?.name ?? String(params.chainId)}
-
- )
- }
-
- const handleClick = async () => {
- try {
- await execute(params)
- } catch {
- // useTransaction sets error state internally
- }
- }
-
- return (
-
- {isPending ? labelSending : children}
-
- )
-}
-
-export default TransactionButton
diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx
deleted file mode 100644
index 389e2525..00000000
--- a/src/components/sharedComponents/WalletStatusVerifier.test.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { createElement, type ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useWeb3StatusConnected, WalletStatusVerifier } from './WalletStatusVerifier'
-
-const mockSwitchChain = vi.fn()
-
-vi.mock('@/src/hooks/useWalletStatus', () => ({
- useWalletStatus: vi.fn(() => ({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' },
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })),
-}))
-
-vi.mock('@/src/hooks/useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => ({
- readOnlyClient: {},
- appChainId: 1,
- address: '0xdeadbeef',
- balance: undefined,
- connectingWallet: false,
- switchingChain: false,
- isWalletConnected: true,
- walletClient: undefined,
- isWalletSynced: true,
- walletChainId: 1,
- switchChain: vi.fn(),
- disconnect: vi.fn(),
- })),
-}))
-
-vi.mock('@/src/wallet/providers', () => ({
- ConnectWalletButton: () =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'connect-wallet-button' },
- 'Connect Wallet',
- ),
-}))
-
-const { useWalletStatus } = await import('@/src/hooks/useWalletStatus')
-const mockedUseWalletStatus = vi.mocked(useWalletStatus)
-
-const system = createSystem(defaultConfig)
-
-const renderWithChakra = (ui: ReactNode) =>
- render({ui})
-
-describe('WalletStatusVerifier', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('renders default fallback (ConnectWalletButton) when wallet needs connect', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- null,
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('renders custom fallback when provided and wallet needs connect', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- { fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Custom') },
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('custom-fallback')).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('renders switch chain button when wallet needs chain switch', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: false,
- needsChainSwitch: true,
- targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType<
- typeof useWalletStatus
- >['targetChain'],
- targetChainId: 10,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- null,
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByText(/Switch to/)).toBeInTheDocument()
- expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('renders children when wallet is ready', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: true,
- needsConnect: false,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- null,
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('protected-content')).toBeInTheDocument()
- })
-
- it('calls switchChain when switch button is clicked', async () => {
- const user = userEvent.setup()
-
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: false,
- needsChainSwitch: true,
- targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType<
- typeof useWalletStatus
- >['targetChain'],
- targetChainId: 10,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(WalletStatusVerifier, null, createElement('div', null, 'Protected')),
- )
-
- const switchButton = screen.getByText(/Switch to/)
- await user.click(switchButton)
-
- expect(mockSwitchChain).toHaveBeenCalledWith(10)
- })
-
- it('provides web3 status context to children when wallet is ready', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: true,
- needsConnect: false,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- const ChildComponent = () => {
- const { address } = useWeb3StatusConnected()
- return createElement('div', { 'data-testid': 'address' }, address)
- }
-
- renderWithChakra(createElement(WalletStatusVerifier, null, createElement(ChildComponent)))
-
- expect(screen.getByTestId('address')).toHaveTextContent('0xdeadbeef')
- })
-})
diff --git a/src/components/sharedComponents/WalletStatusVerifier.tsx b/src/components/sharedComponents/WalletStatusVerifier.tsx
deleted file mode 100644
index ab88e5b2..00000000
--- a/src/components/sharedComponents/WalletStatusVerifier.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { createContext, type FC, type ReactElement, useContext } from 'react'
-import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton'
-import { useWalletStatus } from '@/src/hooks/useWalletStatus'
-import { useWeb3Status, type Web3Status } from '@/src/hooks/useWeb3Status'
-import type { ChainsIds } from '@/src/lib/networks.config'
-import type { RequiredNonNull } from '@/src/types/utils'
-import { DeveloperError } from '@/src/utils/DeveloperError'
-import { ConnectWalletButton } from '@/src/wallet/providers'
-
-const WalletStatusVerifierContext = createContext | null>(null)
-
-/**
- * Returns the connected wallet's Web3 status.
- *
- * Must be called inside a `` component tree.
- * Throws if called outside one.
- *
- * @deprecated Use the new SDK hooks from `@/src/sdk/react/hooks` instead.
- */
-export const useWeb3StatusConnected = () => {
- const context = useContext(WalletStatusVerifierContext)
- if (context === null) {
- throw new DeveloperError(
- 'useWeb3StatusConnected must be used inside a component.',
- )
- }
- return context
-}
-
-interface WalletStatusVerifierProps {
- chainId?: ChainsIds
- children?: ReactElement
- fallback?: ReactElement
- switchChainLabel?: string
-}
-
-/**
- * Wrapper component that gates content on wallet connection and chain status.
- *
- * This is the primary API for protecting UI that requires a connected wallet.
- * Components that call `useWeb3StatusConnected` must be rendered inside this component.
- *
- * @deprecated Use {@link WalletGuard} from `@/src/sdk/react` instead.
- *
- * @example
- * ```tsx
- *
- *
- *
- * ```
- */
-const WalletStatusVerifier: FC = ({
- chainId,
- children,
- fallback = ,
- switchChainLabel = 'Switch to',
-}: WalletStatusVerifierProps) => {
- const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } =
- useWalletStatus({ chainId })
- const web3Status = useWeb3Status()
-
- if (needsConnect) {
- return fallback
- }
-
- if (needsChainSwitch) {
- return (
- switchChain(targetChainId)}>
- {switchChainLabel} {targetChain.name}
-
- )
- }
-
- return (
- }>
- {children}
-
- )
-}
-
-export { WalletStatusVerifier }
diff --git a/src/hooks/useTokens.ts b/src/hooks/useTokens.ts
deleted file mode 100644
index d829a12d..00000000
--- a/src/hooks/useTokens.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import {
- createConfig,
- EVM,
- getChains,
- getTokenBalances,
- getTokens,
- type TokenAmount,
- type TokensResponse,
-} from '@lifi/sdk'
-import { useQuery } from '@tanstack/react-query'
-import { useMemo } from 'react'
-import { type Address, type Chain, formatUnits } from 'viem'
-
-import { env } from '@/src/env'
-import { useTokenLists } from '@/src/hooks/useTokenLists'
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-import type { Token, Tokens } from '@/src/types/token'
-import { logger } from '@/src/utils/logger'
-import type { TokensMap } from '@/src/utils/tokenListsCache'
-
-const BALANCE_EXPIRATION_TIME = 32_000
-
-/** @ignore */
-export const lifiConfig = createConfig({
- integrator: env.PUBLIC_APP_NAME,
- providers: [EVM()],
-})
-
-/**
- * Custom hook for fetching and managing tokens data with price and balances.
- *
- * Combines token list data with real-time price and balance information from LI.FI SDK.
- * Features include:
- * - Token data fetching from token lists
- * - Balance fetching for specified accounts across multiple chains
- * - Price information retrieval
- * - Automatic sorting by token value (balance × price)
- * - Periodic refetching for up-to-date balances and prices
- *
- * @param {Object} params - Parameters for tokens fetching
- * @param {Address} [params.account] - Account address for balance fetching (defaults to connected wallet)
- * @param {Chain['id']} [params.chainId] - Specific chain ID to filter tokens (defaults to all supported chains)
- * @param {boolean} [params.withBalance=true] - Whether to fetch token balances
- *
- * @returns {Object} Token data and loading state
- * @returns {Token[]} returns.tokens - Array of tokens with price and balance information
- * @returns {Record} returns.tokensByChainId - Tokens organized by chain ID
- * @returns {boolean} returns.isLoadingBalances - Loading state for token balances and prices
- *
- * @example
- * ```tsx
- * // Fetch all tokens with balances for connected wallet
- * const { tokens, tokensByChainId, isLoadingBalances } = useTokens();
- *
- * // Fetch tokens for specific chain without balances
- * const { tokens } = useTokens({
- * chainId: 1,
- * withBalance: false
- * });
- *
- * // Fetch balances for specific account
- * const { tokens } = useTokens({
- * account: '0x123...'
- * });
- * ```
- */
-export const useTokens = (
- {
- account,
- chainId,
- withBalance,
- }: {
- account?: Address
- chainId?: Chain['id']
- withBalance?: boolean
- } = {
- withBalance: true,
- },
-) => {
- const { address } = useWeb3Status()
- const tokensData = useTokenLists()
- account ??= address
-
- const canFetchBalance = !!account && withBalance
-
- const { data: chains, isLoading: isLoadingChains } = useQuery({
- queryKey: ['lifi', 'chains'],
- queryFn: () => getChains(),
- staleTime: Number.POSITIVE_INFINITY,
- refetchInterval: Number.POSITIVE_INFINITY,
- gcTime: Number.POSITIVE_INFINITY,
- enabled: canFetchBalance,
- })
-
- const dAppChainsId = chainId
- ? [chainId]
- : Object.keys(tokensData.tokensByChainId).map((id) => Number.parseInt(id, 10))
- const lifiChainsId = chains?.map((chain) => chain.id) ?? []
- const chainsToFetch = dAppChainsId.filter((id) => lifiChainsId.includes(id))
-
- const { data: tokensPricesByChain, isLoading: isLoadingPrices } = useQuery({
- queryKey: ['lifi', 'tokens', 'prices', chainsToFetch],
- queryFn: () => getTokens({ chains: chainsToFetch }),
- staleTime: BALANCE_EXPIRATION_TIME,
- refetchInterval: BALANCE_EXPIRATION_TIME,
- gcTime: Number.POSITIVE_INFINITY,
- enabled: canFetchBalance && !!chains,
- })
-
- const { data: tokensBalances, isLoading: isLoadingBalances } = useQuery({
- queryKey: ['lifi', 'tokens', 'balances', account, chainsToFetch],
- queryFn: () =>
- getTokenBalances(
- // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: canFetchBalance && !!tokensPricesByChain
- account!,
- // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: canFetchBalance && !!tokensPricesByChain
- Object.entries(tokensPricesByChain!.tokens)
- .filter(([chainId]) => chainsToFetch.includes(Number.parseInt(chainId, 10)))
- .flatMap(([, tokens]) => tokens),
- ),
- staleTime: BALANCE_EXPIRATION_TIME,
- refetchInterval: BALANCE_EXPIRATION_TIME,
- gcTime: Number.POSITIVE_INFINITY,
- enabled: canFetchBalance && !!tokensPricesByChain,
- })
-
- const cache = useMemo(() => {
- if (
- withBalance &&
- account &&
- !isLoadingPrices &&
- !isLoadingBalances &&
- tokensBalances &&
- tokensPricesByChain
- ) {
- return udpateTokensBalances(tokensData.tokens, [tokensBalances, tokensPricesByChain])
- }
- return tokensData
- }, [
- account,
- isLoadingBalances,
- isLoadingPrices,
- tokensBalances,
- tokensData,
- tokensPricesByChain,
- withBalance,
- ])
-
- return {
- ...cache,
- isLoadingBalances: Boolean(isLoadingChains || isLoadingBalances || isLoadingPrices),
- }
-}
-
-/**
- * Updates the tokens balances by extending the tokens with balance information and sorting them by balance.
- *
- * @param tokens - The array of tokens.
- * @param results - The results containing the balance tokens and prices.
- * @returns An object containing the updated tokens and tokens grouped by chain ID.
- */
-function udpateTokensBalances(tokens: Tokens, results: [Array, TokensResponse]) {
- const [balanceTokens, prices] = results
-
- logger.time('extending tokens with balance info')
- const priceByChainAddress = Object.entries(prices.tokens).reduce(
- (acc, [chainId, tokens]) => {
- acc[chainId] = {}
-
- tokens.forEach((token) => {
- acc[chainId][token.address] = token.priceUSD ?? '0'
- })
-
- return acc
- },
- {} as { [chainId: string]: { [address: string]: string } },
- )
-
- const balanceTokensByChain = balanceTokens.reduce(
- (acc, balanceToken) => {
- if (!acc[balanceToken.chainId]) {
- acc[balanceToken.chainId] = {}
- }
-
- acc[balanceToken.chainId][balanceToken.address] = balanceToken.amount ?? 0n
-
- return acc
- },
- {} as { [chainId: number]: { [address: string]: bigint } },
- )
-
- const tokensWithBalances = tokens.map((token): Token => {
- const tokenPrice = priceByChainAddress[token.chainId]?.[token.address] ?? '0'
- const tokenBalance = balanceTokensByChain[token.chainId]?.[token.address] ?? 0n
-
- return {
- ...token,
- extensions: {
- priceUSD: tokenPrice,
- balance: tokenBalance,
- },
- }
- })
- logger.timeEnd('extending tokens with balance info')
-
- logger.time('sorting tokens by balance')
- tokensWithBalances.sort(sortFn)
- logger.timeEnd('sorting tokens by balance')
-
- logger.time('updating tokens cache')
- const tokensByChain = tokensWithBalances.reduce(
- (acc, token) => {
- if (!acc[token.chainId]) {
- acc[token.chainId] = [token]
- } else {
- acc[token.chainId].push(token)
- }
- return acc
- },
- {} as TokensMap['tokensByChainId'],
- )
-
- return { tokens: tokensWithBalances, tokensByChainId: tokensByChain }
-}
-
-/**
- * A sorting function used to sort tokens by balance.
- * @param a The first token.
- * @param b The second token.
- * @returns A negative number if a should be sorted before b, a positive number
- * if b should be sorted before a, or 0 if they have the same order.
- */
-function sortFn(a: Token, b: Token) {
- return (
- Number.parseFloat(formatUnits((b.extensions?.balance as bigint) ?? 0n, b.decimals)) *
- Number.parseFloat((b.extensions?.priceUSD as string) ?? '0') -
- Number.parseFloat(formatUnits((a.extensions?.balance as bigint) ?? 0n, a.decimals)) *
- Number.parseFloat((a.extensions?.priceUSD as string) ?? '0')
- )
-}
diff --git a/src/hooks/useWalletStatus.test.ts b/src/hooks/useWalletStatus.test.ts
deleted file mode 100644
index ce5bd0fd..00000000
--- a/src/hooks/useWalletStatus.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useWalletStatus } from './useWalletStatus'
-
-// Mock useWeb3Status
-const mockSwitchChain = vi.fn()
-const mockDisconnect = vi.fn()
-
-vi.mock('@/src/hooks/useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => ({
- appChainId: 1,
- isWalletConnected: false,
- isWalletSynced: false,
- switchChain: mockSwitchChain,
- walletChainId: undefined,
- })),
-}))
-
-vi.mock('@/src/lib/networks.config', () => ({
- chains: [
- { id: 1, name: 'Ethereum' },
- { id: 10, name: 'OP Mainnet' },
- { id: 137, name: 'Polygon' },
- ],
-}))
-
-vi.mock('viem', async () => {
- const actual = await vi.importActual('viem')
- return {
- ...actual,
- extractChain: vi.fn(({ chains, id }) => {
- const chain = chains.find((c: { id: number }) => c.id === id)
- if (!chain) {
- throw new Error(`Chain with id ${id} not found`)
- }
- return chain
- }),
- }
-})
-
-// Import after mocks are set up
-const { useWeb3Status } = await import('@/src/hooks/useWeb3Status')
-const mockedUseWeb3Status = vi.mocked(useWeb3Status)
-
-const baseWeb3Status: ReturnType = {
- readOnlyClient: undefined,
- appChainId: 1,
- address: undefined,
- balance: undefined,
- connectingWallet: false,
- switchingChain: false,
- isWalletConnected: false,
- walletClient: undefined,
- isWalletSynced: false,
- walletChainId: undefined,
- switchChain: mockSwitchChain,
- disconnect: mockDisconnect,
-}
-
-describe('useWalletStatus', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('returns needsConnect when wallet is not connected', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: false,
- isWalletSynced: false,
- walletChainId: undefined,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.needsConnect).toBe(true)
- expect(result.current.needsChainSwitch).toBe(false)
- expect(result.current.isReady).toBe(false)
- })
-
- it('returns needsChainSwitch when connected but on wrong chain', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: false,
- walletChainId: 137,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.needsConnect).toBe(false)
- expect(result.current.needsChainSwitch).toBe(true)
- expect(result.current.isReady).toBe(false)
- expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' })
- expect(result.current.targetChainId).toBe(1)
- })
-
- it('returns isReady when connected and on correct chain', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: true,
- walletChainId: 1,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.needsConnect).toBe(false)
- expect(result.current.needsChainSwitch).toBe(false)
- expect(result.current.isReady).toBe(true)
- })
-
- it('uses provided chainId over appChainId', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: true,
- walletChainId: 1,
- })
-
- const { result } = renderHook(() => useWalletStatus({ chainId: 10 }))
-
- expect(result.current.needsChainSwitch).toBe(true)
- expect(result.current.isReady).toBe(false)
- expect(result.current.targetChain).toEqual({ id: 10, name: 'OP Mainnet' })
- expect(result.current.targetChainId).toBe(10)
- })
-
- it('falls back to chains[0].id when no chainId or appChainId', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: undefined as unknown as ReturnType['appChainId'],
- isWalletConnected: true,
- isWalletSynced: false,
- walletChainId: 137,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' })
- })
-
- it('switchChain calls through to useWeb3Status switchChain', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: false,
- walletChainId: 137,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- result.current.switchChain(10)
- expect(mockSwitchChain).toHaveBeenCalledWith(10)
- })
-})
diff --git a/src/hooks/useWalletStatus.ts b/src/hooks/useWalletStatus.ts
deleted file mode 100644
index 973be211..00000000
--- a/src/hooks/useWalletStatus.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { Chain } from 'viem'
-import { extractChain } from 'viem'
-
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-import { type ChainsIds, chains } from '@/src/lib/networks.config'
-
-interface UseWalletStatusOptions {
- chainId?: ChainsIds
-}
-
-interface WalletStatus {
- isReady: boolean
- needsConnect: boolean
- needsChainSwitch: boolean
- targetChain: Chain
- targetChainId: ChainsIds
- switchChain: (chainId: ChainsIds) => void
-}
-
-/** @deprecated Use {@link useWallet} from `@/src/sdk/react/hooks` instead. */
-export const useWalletStatus = (options?: UseWalletStatusOptions): WalletStatus => {
- const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } =
- useWeb3Status()
-
- const targetChainId = options?.chainId || appChainId || chains[0].id
- const targetChain = extractChain({ chains, id: targetChainId })
-
- const needsConnect = !isWalletConnected
- const needsChainSwitch = isWalletConnected && (!isWalletSynced || walletChainId !== targetChainId)
- const isReady = isWalletConnected && !needsChainSwitch
-
- return {
- isReady,
- needsConnect,
- needsChainSwitch,
- targetChain,
- targetChainId,
- switchChain,
- }
-}
diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts
deleted file mode 100644
index 3fe34ca3..00000000
--- a/src/hooks/useWeb3Status.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { createElement } from 'react'
-import type { Address } from 'viem'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useWeb3StatusConnected } from '@/src/components/sharedComponents/WalletStatusVerifier'
-import { useWeb3Status } 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 })),
-}))
-
-vi.mock('@/src/hooks/useWalletStatus', () => ({
- useWalletStatus: vi.fn(() => ({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' },
- targetChainId: 1,
- switchChain: vi.fn(),
- })),
-}))
-
-vi.mock('@/src/providers/Web3Provider', () => ({
- ConnectWalletButton: () =>
- createElement('button', { type: 'button', 'data-testid': 'connect-wallet-button' }, 'Connect'),
-}))
-
-import * as wagmi from 'wagmi'
-import { WalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier'
-
-const { useWalletStatus } = await import('@/src/hooks/useWalletStatus')
-const mockedUseWalletStatus = vi.mocked(useWalletStatus)
-
-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(
- 'useWeb3StatusConnected must be used inside a component.',
- )
- })
-
- it('returns status when wallet is connected', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: true,
- needsConnect: false,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: vi.fn(),
- })
-
- vi.mocked(wagmi.useAccount).mockReturnValueOnce({
- address: '0xdeadbeef' as Address,
- chainId: 1,
- isConnected: true,
- isConnecting: false,
- } as unknown as ReturnType)
-
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- createElement(WalletStatusVerifier, null, children)
-
- const { result } = renderHook(() => useWeb3StatusConnected(), { wrapper })
- expect(result.current.address).toBe('0xdeadbeef')
- expect(result.current.isWalletConnected).toBe(true)
- })
-})
diff --git a/src/hooks/useWeb3Status.tsx b/src/hooks/useWeb3Status.tsx
deleted file mode 100644
index 50808d0b..00000000
--- a/src/hooks/useWeb3Status.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import type { Address, Chain } from 'viem'
-import {
- type UseBalanceReturnType,
- type UsePublicClientReturnType,
- type UseWalletClientReturnType,
- useAccount,
- useBalance,
- useChainId,
- useDisconnect,
- usePublicClient,
- useSwitchChain,
- useWalletClient,
-} from 'wagmi'
-
-import { type ChainsIds, chains } from '@/src/lib/networks.config'
-
-export type AppWeb3Status = {
- readOnlyClient: UsePublicClientReturnType
- appChainId: ChainsIds
-}
-
-export type WalletWeb3Status = {
- address: Address | undefined
- balance?: UseBalanceReturnType['data'] | undefined
- connectingWallet: boolean
- switchingChain: boolean
- isWalletConnected: boolean
- walletClient: UseWalletClientReturnType['data']
- isWalletSynced: boolean
- walletChainId: Chain['id'] | undefined
-}
-
-export type Web3Actions = {
- switchChain: (chainId?: ChainsIds) => void
- disconnect: () => void
-}
-
-export type Web3Status = AppWeb3Status & WalletWeb3Status & Web3Actions
-
-/**
- * Custom hook that provides comprehensive Web3 connection state and actions.
- *
- * Aggregates various Wagmi hooks to provide a unified interface for Web3 state management,
- * including wallet connection status, chain information, and common actions.
- *
- * The hook provides three categories of data:
- * - App Web3 Status: Information about the app's current blockchain context
- * - Wallet Web3 Status: Information about the connected wallet
- * - Web3 Actions: Functions to modify connection state
- *
- * @returns {Web3Status} Combined object containing:
- * @returns {UsePublicClientReturnType} returns.readOnlyClient - Public client for read operations
- * @returns {ChainsIds} returns.appChainId - Current chain ID of the application
- * @returns {Address|undefined} returns.address - Connected wallet address (if any)
- * @returns {UseBalanceReturnType['data']|undefined} returns.balance - Wallet balance information
- * @returns {boolean} returns.connectingWallet - Indicates if wallet connection is in progress
- * @returns {boolean} returns.switchingChain - Indicates if chain switching is in progress
- * @returns {boolean} returns.isWalletConnected - Whether a wallet is currently connected
- * @returns {UseWalletClientReturnType['data']} returns.walletClient - Wallet client for write operations
- * @returns {boolean} returns.isWalletSynced - Whether wallet chain matches app chain
- * @returns {Chain['id']|undefined} returns.walletChainId - Current chain ID of connected wallet
- * @returns {Function} returns.switchChain - Function to switch to a different chain
- * @returns {Function} returns.disconnect - Function to disconnect wallet
- *
- * @deprecated Use {@link useWallet} or `useChainRegistry` from `@/src/sdk/react/hooks` instead.
- *
- * @example
- * ```tsx
- * const {
- * address,
- * balance,
- * isWalletConnected,
- * appChainId,
- * switchChain,
- * disconnect
- * } = useWeb3Status();
- *
- * return (
- *
- * {isWalletConnected ? (
- * <>
- *
Connected to: {address}
- *
Balance: {balance?.formatted} {balance?.symbol}
- *
- *
- * >
- * ) : (
- *
Wallet not connected
- * )}
- *
- * );
- * ```
- */
-export const useWeb3Status = () => {
- const {
- address,
- chainId: walletChainId,
- isConnected: isWalletConnected,
- isConnecting: connectingWallet,
- } = useAccount()
- const appChainId = useChainId() as ChainsIds
- const { isPending: switchingChain, switchChain } = useSwitchChain()
- const readOnlyClient = usePublicClient()
- const { data: walletClient } = useWalletClient()
- const { data: balance } = useBalance()
- const { disconnect } = useDisconnect()
-
- const isWalletSynced = isWalletConnected && walletChainId === appChainId
-
- const appWeb3Status: AppWeb3Status = {
- readOnlyClient,
- appChainId,
- }
-
- const walletWeb3Status: WalletWeb3Status = {
- address,
- balance,
- isWalletConnected,
- connectingWallet,
- switchingChain,
- walletClient,
- isWalletSynced,
- walletChainId,
- }
-
- const web3Actions: Web3Actions = {
- switchChain: (chainId: number = chains[0].id) => switchChain({ chainId }), // default to the first chain in the config
- disconnect: disconnect,
- }
-
- const web3Connection: Web3Status = {
- ...appWeb3Status,
- ...walletWeb3Status,
- ...web3Actions,
- }
-
- return web3Connection
-}
diff --git a/src/providers/TransactionNotificationProvider.tsx b/src/providers/TransactionNotificationProvider.tsx
deleted file mode 100644
index debaa4b5..00000000
--- a/src/providers/TransactionNotificationProvider.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext } from 'react'
-import type {
- Hash,
- ReplacementReturnType,
- SignMessageErrorType,
- TransactionExecutionError,
-} from 'viem'
-import { ExplorerLink } from '@/src/components/sharedComponents/ExplorerLink'
-import {
- NotificationToast,
- notificationToaster,
-} from '@/src/components/sharedComponents/NotificationToast'
-import { useWeb3Status } from '@/src/hooks/useWeb3Status'
-
-type WatchSignatureArgs = {
- successMessage?: string
- message: ReactNode | string
- signaturePromise: Promise
- onToastId?: (toastId: string) => void
- showSuccessToast?: boolean
-}
-
-type WatchHashArgs = {
- message?: string
- successMessage?: string
- errorMessage?: string
- hash: Hash
- toastId?: string
-}
-
-type WatchTxArgs = { txPromise: Promise; methodId?: string }
-
-type TransactionContextValue = {
- watchSignature: (args: WatchSignatureArgs) => void
- watchHash: (args: WatchHashArgs) => void
- watchTx: (args: WatchTxArgs) => void
-}
-
-const TransactionContext = createContext(undefined)
-
-/**
- * Provider component for transaction notifications
- *
- * Manages transaction-related notifications including signature requests,
- * transaction submissions, and transaction confirmations.
- *
- * Provides context with methods for:
- * - watchSignature: Tracks a signature request and displays appropriate notifications
- * - watchHash: Monitors a transaction by hash and shows its progress/outcome
- * - watchTx: Combines signature and transaction monitoring in one method
- *
- * @example
- * ```tsx
- *
- *
- *
- * ```
- */
-export const TransactionNotificationProvider: FC = ({ children }) => {
- const { readOnlyClient } = useWeb3Status()
- const chain = readOnlyClient?.chain
-
- async function watchSignature({
- message,
- onToastId,
- showSuccessToast = true,
- signaturePromise,
- successMessage = 'Signature received!',
- }: WatchSignatureArgs) {
- const toastId = notificationToaster.create({
- description: message,
- type: 'loading',
- })
- onToastId?.(toastId)
-
- try {
- await signaturePromise
- if (showSuccessToast) {
- notificationToaster.create({
- description: successMessage,
- type: 'success',
- id: toastId,
- })
- }
- } catch (e) {
- const error = e as TransactionExecutionError | SignMessageErrorType
- const message =
- 'shortMessage' in error ? error.shortMessage : error.message || 'An error occurred'
-
- notificationToaster.create({
- description: message,
- type: 'success',
- id: toastId,
- })
- }
- }
-
- async function watchHash({
- errorMessage = 'Transaction was reverted!',
- hash,
- message = 'Transaction sent',
- successMessage = 'Transaction has been mined!',
- toastId,
- }: WatchHashArgs) {
- if (!chain) {
- console.error('Chain is not defined')
- return
- }
-
- if (!readOnlyClient) {
- console.error('ReadOnlyClient is not defined')
- return
- }
-
- notificationToaster.create({
- description: message,
- type: 'loading',
- id: toastId,
- })
-
- try {
- let replacedTx = null as ReplacementReturnType | null
- const receipt = await readOnlyClient.waitForTransactionReceipt({
- hash,
- onReplaced: (replacedTxData) => {
- replacedTx = replacedTxData
- },
- })
-
- if (replacedTx !== null) {
- if (['replaced', 'cancelled'].includes(replacedTx.reason)) {
- notificationToaster.create({
- description: (
-
-
Transaction has been {replacedTx.reason}!
-
-
- ),
- type: 'error',
- id: toastId,
- })
- } else {
- notificationToaster.create({
- description: (
-
- ),
- type: 'success',
- id: toastId,
- })
- }
- return
- }
-
- if (receipt.status === 'success') {
- notificationToaster.create({
- description: (
-
- ),
- type: 'success',
- id: toastId,
- })
- } else {
- notificationToaster.create({
- description: (
-
- ),
- type: 'error',
- id: toastId,
- })
- }
- } catch (error) {
- console.error('Error watching hash', error)
- }
- }
-
- async function watchTx({ methodId, txPromise }: WatchTxArgs) {
- const transactionMessage = methodId ? `Transaction for calling ${methodId}` : 'Transaction'
-
- let toastId = ''
- await watchSignature({
- message: `Signature requested: ${transactionMessage}`,
- signaturePromise: txPromise,
- showSuccessToast: false,
- onToastId: (id) => {
- toastId = id
- },
- })
-
- const hash = await txPromise
- await watchHash({
- hash,
- toastId,
- message: `${transactionMessage} is pending to be mined ...`,
- successMessage: `${transactionMessage} has been mined!`,
- errorMessage: `${transactionMessage} has reverted!`,
- })
- }
-
- return (
-
- {children}
-
-
- )
-}
-
-export function useTransactionNotification() {
- const context = useContext(TransactionContext)
-
- if (context === undefined) {
- throw new Error(
- 'useTransactionNotification must be used within a TransactionNotificationProvider',
- )
- }
- return context
-}
diff --git a/src/providers/Web3Provider.tsx b/src/providers/Web3Provider.tsx
deleted file mode 100644
index 98401513..00000000
--- a/src/providers/Web3Provider.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { FC, PropsWithChildren } from 'react'
-import { WagmiProvider } from 'wagmi'
-
-import '@/src/lib/wallets/portoInit'
-import { ConnectWalletButton, config, WalletProvider } from '@/src/lib/wallets/connectkit.config'
-
-const queryClient = new QueryClient()
-
-export { ConnectWalletButton }
-
-/**
- * Provider component for web3 functionality
- *
- * Sets up the necessary providers for blockchain interactions:
- * - WagmiProvider for blockchain connectivity
- * - QueryClientProvider for data fetching
- * - WalletProvider for wallet connection
- *
- * @example
- * ```tsx
- *
- *
- *
- * ```
- */
-export const Web3Provider: FC = ({ children }) => {
- return (
-
-
- {children}
-
-
- )
-}
diff --git a/src/wallet/connectors/connectkit.config.tsx b/src/wallet/connectors/connectkit.config.tsx
deleted file mode 100644
index 897d3831..00000000
--- a/src/wallet/connectors/connectkit.config.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { ButtonProps } from '@chakra-ui/react'
-import { ConnectKitButton, ConnectKitProvider, getDefaultConfig, type Types } from 'connectkit'
-import type { FC, ReactNode } from 'react'
-import type { Address } from 'viem'
-import { normalize } from 'viem/ens'
-import { createConfig, useEnsAvatar, useEnsName } from 'wagmi'
-import { Avatar } from '@/src/core/components'
-import { chains, transports } from '@/src/core/types'
-import { env } from '@/src/env'
-import ConnectButton from '../components/ConnectButton'
-
-interface Props {
- address: Address
- size: number
-}
-
-const UserAvatar: FC = ({ address, size }: Props) => {
- const { data: ensName } = useEnsName({ address })
-
- const { data: avatarImg } = useEnsAvatar({
- name: ensName ? normalize(ensName) : undefined,
- })
-
- return (
-
- )
-}
-
-export const WalletProvider = ({ children }: { children: ReactNode }) => {
- return (
- ,
- initialChainId: 0,
- enforceSupportedChains: false,
- }}
- >
- {children}
-
- )
-}
-
-export const ConnectWalletButton = ({
- label = 'Connect',
- ...restProps
-}: { label?: string } & ButtonProps) => {
- return (
-
- {({ address, isConnected, isConnecting, show, truncatedAddress }) => {
- return (
-
- {isConnected ? (
- <>
- {address && (
-
- )}
- {truncatedAddress}
- >
- ) : (
- label
- )}
-
- )
- }}
-
- )
-}
-
-const defaultConfig = {
- chains,
- transports,
-
- // Required API Keys
- walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID,
-
- // Required App Info
- appName: env.PUBLIC_APP_NAME,
-
- // Optional App Info
- appDescription: env.PUBLIC_APP_DESCRIPTION,
- appUrl: env.PUBLIC_APP_URL,
- appIcon: env.PUBLIC_APP_LOGO,
-} as const
-
-const connectkitConfig = getDefaultConfig(defaultConfig)
-
-export const config = createConfig(connectkitConfig)
diff --git a/src/wallet/connectors/rainbowkit.config.tsx b/src/wallet/connectors/rainbowkit.config.tsx
deleted file mode 100644
index 0e07dc73..00000000
--- a/src/wallet/connectors/rainbowkit.config.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Uncomment to use dAppBooster with RainbowKit
- * version used: 2.0.8
- */
-
-// import type { ReactNode } from 'react'
-
-// import { type AvatarComponent, ConnectButton, RainbowKitProvider } from '@rainbow-me/rainbowkit'
-// import { getDefaultConfig } from '@rainbow-me/rainbowkit';
-
-// import { env } from '@/src/env'
-// import { chains, transports } from '@/src/core'
-
-// import { Avatar as CustomAvatar } from '@/src/core'
-
-// export const WalletProvider = ({ children }: { children: ReactNode }) => {
-// return (
-// {children}
-// )
-// }
-
-// export const ConnectWalletButton = ({ label = 'Connect' }: { label?: string }) => (
-//
-// )
-
-// const defaultConfig = {
-// chains,
-// transports,
-
-// // Required API Keys
-// walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID,
-// projectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID,
-
-// // Required App Info
-// appName: env.PUBLIC_APP_NAME,
-
-// // Optional App Info
-// appDescription: env.PUBLIC_APP_DESCRIPTION,
-// appUrl: env.PUBLIC_APP_URL,
-// appIcon: env.PUBLIC_APP_LOGO,
-// } as const
-
-// export const config = getDefaultConfig(defaultConfig)
diff --git a/src/wallet/connectors/reown.config.tsx b/src/wallet/connectors/reown.config.tsx
deleted file mode 100644
index ac6aed6c..00000000
--- a/src/wallet/connectors/reown.config.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Uncomment to use dAppBooster with web3Modal
- * version used: 4.2.1
- */
-
-import { createAppKit } from '@reown/appkit/react'
-
-import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
-import type { DetailedHTMLProps, FC, HTMLAttributes, PropsWithChildren } from 'react'
-import type { Chain } from 'viem'
-
-import { chains } from '@/src/core/types'
-import { env } from '@/src/env'
-
-export const WalletProvider: FC = ({ children }) => children
-
-declare global {
- namespace JSX {
- interface IntrinsicElements {
- 'w3m-button': DetailedHTMLProps, HTMLElement>
- 'appkit-button': DetailedHTMLProps<
- HTMLAttributes & { label?: string },
- HTMLElement
- >
- }
- }
-}
-export const ConnectWalletButton = ({ label = 'Connect' }: { label?: string }) => (
-
-)
-
-// Required API Keys
-const projectId = env.PUBLIC_WALLETCONNECT_PROJECT_ID
-
-const metadata = {
- // Required App Info
- name: env.PUBLIC_APP_NAME,
- description: env.PUBLIC_APP_DESCRIPTION ?? '',
- url: env.PUBLIC_APP_URL ?? '',
- icons: [env.PUBLIC_APP_LOGO ?? ''],
-}
-
-// TODO avoid readonly types mismatch
-const wagmiAdapter = new WagmiAdapter({
- networks: chains as unknown as Chain[],
- projectId,
-})
-
-createAppKit({
- adapters: [wagmiAdapter],
- networks: chains as unknown as [Chain, ...Chain[]],
- metadata: metadata,
- projectId,
- features: {
- analytics: true,
- },
-})
-
-export const config = wagmiAdapter.wagmiConfig
diff --git a/src/wallet/providers/Web3Provider.tsx b/src/wallet/providers/Web3Provider.tsx
deleted file mode 100644
index 54f20b44..00000000
--- a/src/wallet/providers/Web3Provider.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { FC, PropsWithChildren } from 'react'
-import { WagmiProvider } from 'wagmi'
-
-import '../connectors/portoInit'
-import { ConnectWalletButton, config, WalletProvider } from '../connectors/connectkit.config'
-
-const queryClient = new QueryClient()
-
-export { ConnectWalletButton }
-
-/**
- * Provider component for web3 functionality
- *
- * Sets up the necessary providers for blockchain interactions:
- * - WagmiProvider for blockchain connectivity
- * - QueryClientProvider for data fetching
- * - WalletProvider for wallet connection
- *
- * @example
- * ```tsx
- *
- *
- *
- * ```
- */
-export const Web3Provider: FC = ({ children }) => {
- return (
-
-
- {children}
-
-
- )
-}
From 21ef3054652090e6c95eb22f18b15e302eabc380 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 19:06:35 +0200
Subject: [PATCH 13/52] refactor: replace WalletStatusVerifier with WalletGuard
in SignMessage demo
---
.../Examples/demos/SignMessage/index.test.tsx | 38 +++-
.../home/Examples/demos/SignMessage/index.tsx | 6 +-
src/wallet/components.ts | 1 -
.../components/WalletStatusVerifier.test.tsx | 205 ------------------
.../components/WalletStatusVerifier.tsx | 73 -------
5 files changed, 35 insertions(+), 288 deletions(-)
delete mode 100644 src/wallet/components/WalletStatusVerifier.test.tsx
delete mode 100644 src/wallet/components/WalletStatusVerifier.tsx
diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
index a39e295d..4c6b5d07 100644
--- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
@@ -5,13 +5,39 @@ import signMessage from './index'
const system = createSystem(defaultConfig)
-vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => ({
- isWalletConnected: false,
- isWalletSynced: false,
- walletChainId: undefined,
- appChainId: 11155420,
+vi.mock('@/src/sdk/react/hooks', () => ({
+ useWallet: vi.fn(() => ({
+ adapter: {} as never,
+ adapterKey: 'evm',
+ status: {
+ connected: false,
+ activeAccount: null,
+ connectedChainIds: [],
+ connecting: false,
+ },
+ isReady: false,
+ needsConnect: true,
+ needsChainSwitch: false,
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ signMessage: vi.fn(),
+ getSigner: vi.fn(),
switchChain: vi.fn(),
+ openConnectModal: vi.fn(),
+ openAccountModal: vi.fn(),
+ })),
+ useChainRegistry: vi.fn(() => ({
+ getChain: vi.fn(() => null),
+ getChainByCaip2: vi.fn(() => null),
+ getChainType: vi.fn(() => null),
+ getChainsByType: vi.fn(() => []),
+ getAllChains: vi.fn(() => []),
+ })),
+ useMultiWallet: vi.fn(() => ({
+ wallets: {},
+ getWallet: vi.fn(() => undefined),
+ getWalletByChainId: vi.fn(() => undefined),
+ connectedAddresses: {},
})),
}))
diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx
index a2c0ff95..86c8eb5f 100644
--- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx
@@ -1,8 +1,8 @@
import Icon from '@/src/components/pageComponents/home/Examples/demos/SignMessage/Icon'
import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { PrimaryButton } from '@/src/core/components'
+import { WalletGuard } from '@/src/sdk/react/components'
import { SignButton } from '@/src/transactions/components'
-import { WalletStatusVerifier } from '@/src/wallet/components'
const message = `
👻🚀 Welcome to dAppBooster! 🚀👻
@@ -18,7 +18,7 @@ dAppBooster Team 💪
const SignMessage = () => {
return (
-
+
When pressing the button, your connected wallet will display a prompt asking you to sign
@@ -33,7 +33,7 @@ const SignMessage = () => {
paddingX={6}
/>
-
+
)
}
diff --git a/src/wallet/components.ts b/src/wallet/components.ts
index 0d7a66eb..4b95e6d0 100644
--- a/src/wallet/components.ts
+++ b/src/wallet/components.ts
@@ -1,4 +1,3 @@
export { default as ConnectButton } from './components/ConnectButton/index'
export { default as SwitchChainButton } from './components/SwitchChainButton'
export { default as SwitchNetwork } from './components/SwitchNetwork'
-export { useWeb3StatusConnected, WalletStatusVerifier } from './components/WalletStatusVerifier'
diff --git a/src/wallet/components/WalletStatusVerifier.test.tsx b/src/wallet/components/WalletStatusVerifier.test.tsx
deleted file mode 100644
index bafd6720..00000000
--- a/src/wallet/components/WalletStatusVerifier.test.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { createElement, type ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { DeveloperError } from '@/src/core/utils/DeveloperError'
-import { useWeb3StatusConnected, WalletStatusVerifier } from './WalletStatusVerifier'
-
-const mockSwitchChain = vi.fn()
-
-vi.mock('@/src/wallet/hooks/useWalletStatus', () => ({
- useWalletStatus: vi.fn(() => ({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' },
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })),
-}))
-
-vi.mock('@/src/wallet/hooks/useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => ({
- address: '0x1234567890abcdef1234567890abcdef12345678',
- appChainId: 1,
- balance: undefined,
- connectingWallet: false,
- disconnect: vi.fn(),
- isWalletConnected: true,
- isWalletSynced: true,
- readOnlyClient: {},
- switchChain: vi.fn(),
- switchingChain: false,
- walletChainId: 1,
- walletClient: {},
- })),
-}))
-
-vi.mock('@/src/wallet/providers', () => ({
- ConnectWalletButton: () =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'connect-wallet-button' },
- 'Connect Wallet',
- ),
-}))
-
-const { useWalletStatus } = await import('@/src/wallet/hooks/useWalletStatus')
-const mockedUseWalletStatus = vi.mocked(useWalletStatus)
-
-const system = createSystem(defaultConfig)
-
-const renderWithChakra = (ui: ReactNode) =>
- render({ui})
-
-describe('WalletStatusVerifier', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('renders default fallback (ConnectWalletButton) when wallet needs connect', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- null,
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('renders custom fallback when provided and wallet needs connect', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: true,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- { fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Custom') },
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('custom-fallback')).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('renders switch chain button when wallet needs chain switch', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: false,
- needsChainSwitch: true,
- targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType<
- typeof useWalletStatus
- >['targetChain'],
- targetChainId: 10,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- null,
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByText(/Switch to/)).toBeInTheDocument()
- expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('renders children when wallet is ready', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: true,
- needsConnect: false,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(
- WalletStatusVerifier,
- null,
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('protected-content')).toBeInTheDocument()
- })
-
- it('calls switchChain when switch button is clicked', async () => {
- const user = userEvent.setup()
-
- mockedUseWalletStatus.mockReturnValue({
- isReady: false,
- needsConnect: false,
- needsChainSwitch: true,
- targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType<
- typeof useWalletStatus
- >['targetChain'],
- targetChainId: 10,
- switchChain: mockSwitchChain,
- })
-
- renderWithChakra(
- createElement(WalletStatusVerifier, null, createElement('div', null, 'Protected')),
- )
-
- const switchButton = screen.getByText(/Switch to/)
- await user.click(switchButton)
-
- expect(mockSwitchChain).toHaveBeenCalledWith(10)
- })
-
- it('provides web3 status via context when wallet is ready', () => {
- mockedUseWalletStatus.mockReturnValue({
- isReady: true,
- needsConnect: false,
- needsChainSwitch: false,
- targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'],
- targetChainId: 1,
- switchChain: mockSwitchChain,
- })
-
- const Consumer = () => {
- const { address } = useWeb3StatusConnected()
- return createElement('div', { 'data-testid': 'address' }, address)
- }
-
- renderWithChakra(createElement(WalletStatusVerifier, null, createElement(Consumer)))
-
- expect(screen.getByTestId('address')).toHaveTextContent(
- '0x1234567890abcdef1234567890abcdef12345678',
- )
- })
-
- it('throws DeveloperError when useWeb3StatusConnected is used outside WalletStatusVerifier', () => {
- const Consumer = () => {
- const { address } = useWeb3StatusConnected()
- return createElement('div', null, address)
- }
-
- expect(() => renderWithChakra(createElement(Consumer))).toThrow(DeveloperError)
- })
-})
diff --git a/src/wallet/components/WalletStatusVerifier.tsx b/src/wallet/components/WalletStatusVerifier.tsx
deleted file mode 100644
index 838efb11..00000000
--- a/src/wallet/components/WalletStatusVerifier.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import type { FC, ReactElement } from 'react'
-import { createContext, useContext } from 'react'
-import type { ChainsIds, RequiredNonNull } from '@/src/core/types'
-import { DeveloperError } from '@/src/core/utils/DeveloperError'
-import { useWalletStatus } from '../hooks/useWalletStatus'
-import { useWeb3Status, type Web3Status } from '../hooks/useWeb3Status'
-import { ConnectWalletButton } from '../providers'
-import SwitchChainButton from './SwitchChainButton'
-
-type ConnectedWeb3Status = RequiredNonNull
-
-const WalletStatusVerifierContext = createContext(null)
-
-interface WalletStatusVerifierProps {
- chainId?: ChainsIds
- children?: ReactElement
- fallback?: ReactElement
- switchChainLabel?: string
-}
-
-/**
- * Wrapper component that gates content on wallet connection and chain status.
- *
- * This is the primary API for protecting UI that requires a connected wallet.
- *
- * @deprecated Use {@link WalletGuard} from `@/src/sdk/react` instead.
- *
- * @example
- * ```tsx
- *
- *
- *
- * ```
- */
-const WalletStatusVerifier: FC = ({
- chainId,
- children,
- fallback = ,
- switchChainLabel = 'Switch to',
-}: WalletStatusVerifierProps) => {
- const web3Status = useWeb3Status()
- const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } =
- useWalletStatus({ chainId })
-
- if (needsConnect) {
- return fallback
- }
-
- if (needsChainSwitch) {
- return (
- switchChain(targetChainId)}>
- {switchChainLabel} {targetChain.name}
-
- )
- }
-
- return (
-
- {children}
-
- )
-}
-
-/** Reads the connected Web3 status from WalletStatusVerifier context. */
-const useWeb3StatusConnected = (): ConnectedWeb3Status => {
- const context = useContext(WalletStatusVerifierContext)
- if (context === null) {
- throw new DeveloperError('useWeb3StatusConnected must be used inside ')
- }
- return context
-}
-
-export { useWeb3StatusConnected, WalletStatusVerifier }
From af9091784567b10779735ce7e03563054d47f482 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Tue, 7 Apr 2026 19:27:02 +0200
Subject: [PATCH 14/52] refactor: migrate Optimism demo to adapter
TransactionButton, delete legacy code
---
.../OptimismCrossDomainMessenger/index.tsx | 91 ++++---
.../hooks/useOPL1CrossDomainMessengerProxy.ts | 172 ++++++-------
src/transactions/components.ts | 1 -
.../components/LegacyTransactionButton.tsx | 113 --------
src/transactions/providers.ts | 4 -
.../TransactionNotificationProvider.test.tsx | 82 ------
.../TransactionNotificationProvider.tsx | 242 ------------------
7 files changed, 129 insertions(+), 576 deletions(-)
delete mode 100644 src/transactions/components/LegacyTransactionButton.tsx
delete mode 100644 src/transactions/providers.ts
delete mode 100644 src/transactions/providers/TransactionNotificationProvider.test.tsx
delete mode 100644 src/transactions/providers/TransactionNotificationProvider.tsx
diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
index 896e9d8d..3b09e628 100644
--- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
@@ -1,29 +1,29 @@
import { Flex, Span } from '@chakra-ui/react'
import { useState } from 'react'
-import type { Address } from 'viem'
+import type { Address, TransactionReceipt } from 'viem'
import { parseEther } from 'viem'
import { optimismSepolia, sepolia } from 'viem/chains'
import { extractTransactionDepositedLogs, getL2TransactionHash } from 'viem/op-stack'
-import { usePublicClient } from 'wagmi'
import Icon from '@/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/Icon'
import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { getContract } from '@/src/contracts/definitions'
-import { useL1CrossDomainMessengerProxy } from '@/src/contracts/hooks/useOPL1CrossDomainMessengerProxy'
-import { Hash } from '@/src/core/components'
+import { buildCrossDomainMessageParams } from '@/src/contracts/hooks/useOPL1CrossDomainMessengerProxy'
+import { Hash, PrimaryButton, Spinner } from '@/src/core/components'
import { withSuspenseAndRetry } from '@/src/core/utils'
import { getExplorerUrl } from '@/src/sdk/core/chain/explorer'
import { WalletGuard } from '@/src/sdk/react/components'
-import { useChainRegistry, useWallet } from '@/src/sdk/react/hooks'
-// TODO: full migration requires refactoring useL1CrossDomainMessengerProxy to return TransactionParams
-import { LegacyTransactionButton as TransactionButton } from '@/src/transactions/components'
-import { TransactionNotificationProvider } from '@/src/transactions/providers'
+import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks'
+/**
+ * Cross-chain deposit demo using the adapter architecture.
+ *
+ * Level 5 (buildCrossDomainMessageParams) for OP-specific gas estimation,
+ * Level 2 (useTransaction) for lifecycle + execution.
+ */
const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
- // https://sepolia-optimism.etherscan.io/address/0xb50201558b00496a145fe76f7424749556e326d8
const AAVEProxy = '0xb50201558b00496a145fe76f7424749556e326d8'
const wallet = useWallet({ chainId: sepolia.id })
const walletAddress = wallet.status.activeAccount as Address
- const readOnlyClient = usePublicClient()
const registry = useChainRegistry()
const contract = getContract('AAVEWeth', optimismSepolia.id)
@@ -31,20 +31,42 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
const [l2Hash, setL2Hash] = useState(null)
- const sendCrossChainMessage = useL1CrossDomainMessengerProxy({
- fromChain: sepolia,
- contractName: 'AAVEWeth',
- functionName: 'depositETH',
- l2ContractAddress: contract.address,
- args: [AAVEProxy, walletAddress, 0],
- value: depositValue,
- walletAddress,
+ const tx = useTransaction({
+ lifecycle: {
+ onConfirm: (result) => {
+ const receipt = result.receipt as TransactionReceipt
+ const [log] = extractTransactionDepositedLogs(receipt)
+ if (log) {
+ setL2Hash(getL2TransactionHash({ log }))
+ }
+ },
+ },
})
const l2ExplorerUrl = l2Hash
? getExplorerUrl(registry, { chainId: optimismSepolia.id, tx: l2Hash })
: null
+ const handleDeposit = async () => {
+ setL2Hash(null)
+ try {
+ const params = await buildCrossDomainMessageParams({
+ fromChain: sepolia,
+ contractName: 'AAVEWeth',
+ functionName: 'depositETH',
+ l2ContractAddress: contract.address,
+ args: [AAVEProxy, walletAddress, 0],
+ value: depositValue,
+ walletAddress,
+ })
+ await tx.execute(params)
+ } catch {
+ // useTransaction sets tx.error internally — no additional handling needed
+ }
+ }
+
+ const isPending = tx.phase !== 'idle'
+
return (
@@ -58,22 +80,23 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
{' '}
from Sepolia.
-
- {
- setL2Hash(null)
- const hash = await sendCrossChainMessage()
- const receipt = await readOnlyClient!.waitForTransactionReceipt({ hash })
- const [log] = extractTransactionDepositedLogs(receipt)
- const l2Hash = getL2TransactionHash({ log })
- setL2Hash(l2Hash)
- return hash
- }}
- >
- Deposit ETH
-
-
+
+ {isPending ? (
+
+
+ {tx.phase === 'prepare' ? 'Estimating...' : 'Sending...'}
+
+ ) : (
+ 'Deposit ETH'
+ )}
+
+ {tx.error && {tx.error.message}}
{l2Hash && (
at Level 1-2.
+ *
+ * @precondition fromChain is sepolia or mainnet
+ * @precondition walletAddress is a valid connected account
+ * @postcondition returns TransactionParams targeting L1CrossDomainMessenger.sendMessage
+ * @postcondition gas field includes 20% safety buffer over combined L1+L2 estimates
+ */
import { type Address, createPublicClient, encodeFunctionData, type Hash } from 'viem'
import type { mainnet } from 'viem/chains'
import { optimism, optimismSepolia, sepolia } from 'viem/chains'
-import { useWriteContract } from 'wagmi'
import { transports } from '@/src/core/types'
+import type { TransactionParams } from '@/src/sdk/core'
+import type { EvmContractCall } from '@/src/sdk/core/evm/types'
import {
type ContractFunctionArgs,
type ContractFunctionName,
@@ -13,7 +25,17 @@ import {
getContract,
} from '../definitions'
-async function l2ContractCallInfo({
+export interface BuildCrossDomainMessageConfig {
+ fromChain: typeof sepolia | typeof mainnet
+ l2ContractAddress: Address
+ contractName: ContractNames
+ functionName: ContractFunctionName
+ args: ContractFunctionArgs>
+ value: bigint
+ walletAddress: Address
+}
+
+async function estimateL2Gas({
contractName,
functionName,
args,
@@ -21,10 +43,10 @@ async function l2ContractCallInfo({
walletAddress,
chain,
}: {
- args: ContractFunctionArgs
+ args: ContractFunctionArgs>
chain: typeof optimismSepolia | typeof optimism
contractName: ContractNames
- functionName: ContractFunctionName
+ functionName: ContractFunctionName
value?: bigint
walletAddress: Address
}) {
@@ -39,10 +61,10 @@ async function l2ContractCallInfo({
address: contract.address,
abi: contract.abi,
functionName,
- // biome-ignore lint/suspicious/noExplicitAny: TS does not infer correctly the type of valueuseop
+ // biome-ignore lint/suspicious/noExplicitAny: viem generic inference limitation
args: args as any,
account: walletAddress,
- // biome-ignore lint/suspicious/noExplicitAny: TS does not infer correctly the type of value
+ // biome-ignore lint/suspicious/noExplicitAny: viem generic inference limitation
value: value as any,
})
@@ -55,7 +77,7 @@ async function l2ContractCallInfo({
return { message, gas }
}
-function estimateGasL1CrossDomainMessenger({
+async function estimateL1Gas({
chain,
l2Gas,
message,
@@ -78,108 +100,58 @@ function estimateGasL1CrossDomainMessenger({
abi: contract.abi,
functionName: 'sendMessage',
args: [contract.address, message, Number(l2Gas)],
- value: value,
+ value,
})
}
/**
- * Custom hook to send a cross-domain message from L1 (Ethereum Mainnet or Sepolia) to Optimism.
+ * Builds TransactionParams for an L1→L2 cross-domain message via OP stack.
*
- * Handles the complex process of sending a message from L1 to L2 through Optimism's
- * CrossDomainMessenger contract, including:
- * - Estimating gas on both L1 and L2
- * - Encoding function data for the message
- * - Adding safety buffer to gas estimates (20%)
- * - Executing the cross-chain transaction
+ * Call this on user action (e.g., button click), then pass the result
+ * to useTransaction().execute(params) or .
*
- * @param {Object} params - The parameters object
- * @param {Chain} params.fromChain - Source chain (sepolia or mainnet)
- * @param {Address} params.l2ContractAddress - Target contract address on L2
- * @param {ContractNames} params.contractName - Name of the contract from contracts registry
- * @param {ContractFunctionName} params.functionName - Name of function to call on the L2 contract
- * @param {ContractFunctionArgs} params.args - Arguments to pass to the L2 function
- * @param {bigint} params.value - Value in wei to send with the transaction
- *
- * @returns {Function} Async function that executes the cross-domain message when called
- *
- * @example
- * ```tsx
- * const sendToOptimism = useL1CrossDomainMessengerProxy({
- * fromChain: sepolia,
- * l2ContractAddress: '0x...',
- * contractName: 'MyContract',
- * functionName: 'myFunction',
- * args: [arg1, arg2],
- * value: parseEther('0.1')
- * });
- *
- * // Later in your code
- * const handleClick = async () => {
- * try {
- * const txHash = await sendToOptimism();
- * console.log('Transaction sent:', txHash);
- * } catch (error) {
- * console.error('Failed to send cross-domain message:', error);
- * }
- * };
- * ```
+ * @precondition wallet must be connected to fromChain
+ * @postcondition returns TransactionParams with EvmContractCall targeting sendMessage
*/
-export function useL1CrossDomainMessengerProxy({
- fromChain,
- l2ContractAddress,
- contractName,
- functionName,
- args,
- value,
- walletAddress,
-}: {
- fromChain: typeof sepolia | typeof mainnet
- l2ContractAddress: Address
- contractName: ContractNames
- functionName: ContractFunctionName
- args: ContractFunctionArgs
- value: bigint
- walletAddress: Address
-}) {
- const contract = getContract('OPL1CrossDomainMessengerProxy', fromChain.id)
- const { writeContractAsync } = useWriteContract()
-
- return useCallback(async () => {
- const { gas: l2Gas, message } = await l2ContractCallInfo({
- contractName,
- functionName,
- args,
- value,
- walletAddress,
- chain: fromChain === sepolia ? optimismSepolia : optimism,
- })
-
- const l1Gas = await estimateGasL1CrossDomainMessenger({
- chain: fromChain,
- message,
- value,
- l2Gas,
- })
-
- return writeContractAsync({
- chainId: fromChain.id,
- abi: contract.abi,
- address: contract.address,
- functionName: 'sendMessage',
- args: [l2ContractAddress, message, Number(l2Gas)],
- value,
- gas: ((l1Gas + l2Gas) * 120n) / 100n,
- })
- }, [
+export async function buildCrossDomainMessageParams(
+ config: BuildCrossDomainMessageConfig,
+): Promise {
+ const { fromChain, l2ContractAddress, contractName, functionName, args, value, walletAddress } =
+ config
+
+ const l2Chain = fromChain === sepolia ? optimismSepolia : optimism
+
+ const { gas: l2Gas, message } = await estimateL2Gas({
contractName,
functionName,
args,
value,
walletAddress,
- fromChain,
- writeContractAsync,
- contract.abi,
- contract.address,
- l2ContractAddress,
- ])
+ chain: l2Chain,
+ })
+
+ const l1Gas = await estimateL1Gas({
+ chain: fromChain,
+ message,
+ value,
+ l2Gas,
+ })
+
+ const contract = getContract('OPL1CrossDomainMessengerProxy', fromChain.id)
+
+ const payload: EvmContractCall = {
+ contract: {
+ address: contract.address,
+ abi: [...contract.abi],
+ functionName: 'sendMessage',
+ args: [l2ContractAddress, message, Number(l2Gas)],
+ },
+ value,
+ gas: ((l1Gas + l2Gas) * 120n) / 100n,
+ }
+
+ return {
+ chainId: fromChain.id,
+ payload,
+ }
}
diff --git a/src/transactions/components.ts b/src/transactions/components.ts
index 9f2abe29..2a0ec500 100644
--- a/src/transactions/components.ts
+++ b/src/transactions/components.ts
@@ -1,3 +1,2 @@
-export { default as LegacyTransactionButton } from './components/LegacyTransactionButton'
export { default as SignButton } from './components/SignButton'
export { default as TransactionButton } from './components/TransactionButton'
diff --git a/src/transactions/components/LegacyTransactionButton.tsx b/src/transactions/components/LegacyTransactionButton.tsx
deleted file mode 100644
index 7e44488c..00000000
--- a/src/transactions/components/LegacyTransactionButton.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * @deprecated Legacy TransactionButton that uses wagmi hooks directly.
- * Migrate to the new adapter-based TransactionButton with `params` + `lifecycle` props.
- */
-
-import type { ButtonProps } from '@chakra-ui/react'
-import type { ReactElement } from 'react'
-import { useEffect, useState } from 'react'
-import type { Hash, TransactionReceipt } from 'viem'
-import { useWaitForTransactionReceipt } from 'wagmi'
-import { PrimaryButton } from '@/src/core/components'
-import type { ChainsIds } from '@/src/core/types'
-import { useTransactionNotification } from '@/src/transactions/providers'
-import SwitchChainButton from '@/src/wallet/components/SwitchChainButton'
-import { useWalletStatus } from '@/src/wallet/hooks'
-import { ConnectWalletButton } from '@/src/wallet/providers'
-
-interface LegacyTransactionButtonProps extends ButtonProps {
- /** Target chain ID for wallet status verification. */
- chainId?: ChainsIds
- /** Number of confirmations to wait for. Defaults to 1. */
- confirmations?: number
- /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */
- fallback?: ReactElement
- /** Button label during pending transaction. Defaults to 'Sending...'. */
- labelSending?: string
- /** Callback function called when transaction is mined. */
- onMined?: (receipt: TransactionReceipt) => void
- /** Label for the switch chain button. Defaults to 'Switch to'. */
- switchChainLabel?: string
- /** Function that initiates the transaction and returns a hash. */
- transaction: {
- (): Promise
- methodId?: string
- }
-}
-
-/**
- * @deprecated Use the adapter-based TransactionButton instead.
- */
-function LegacyTransactionButton({
- chainId,
- children = 'Send Transaction',
- confirmations = 1,
- disabled,
- fallback = ,
- labelSending = 'Sending...',
- onMined,
- switchChainLabel = 'Switch to',
- transaction,
- ...restProps
-}: LegacyTransactionButtonProps) {
- const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } =
- useWalletStatus({ chainId })
-
- const [hash, setHash] = useState()
- const [isPending, setIsPending] = useState(false)
-
- const { watchTx } = useTransactionNotification()
- const { data: receipt } = useWaitForTransactionReceipt({
- hash: hash,
- confirmations,
- })
-
- useEffect(() => {
- const handleMined = async () => {
- if (receipt && isPending) {
- await onMined?.(receipt)
- setIsPending(false)
- setHash(undefined)
- }
- }
-
- handleMined()
- }, [isPending, onMined, receipt])
-
- if (needsConnect) {
- return fallback
- }
-
- if (needsChainSwitch) {
- return (
- switchChain(targetChainId)}>
- {switchChainLabel} {targetChain.name}
-
- )
- }
-
- const handleSendTransaction = async () => {
- setIsPending(true)
- try {
- const txPromise = transaction()
- watchTx({ txPromise, methodId: transaction.methodId })
- const hash = await txPromise
- setHash(hash)
- } catch (error: unknown) {
- console.error('Error sending transaction', error instanceof Error ? error.message : error)
- setIsPending(false)
- }
- }
-
- return (
-
- {isPending ? labelSending : children}
-
- )
-}
-
-export default LegacyTransactionButton
diff --git a/src/transactions/providers.ts b/src/transactions/providers.ts
deleted file mode 100644
index 9b7a2c63..00000000
--- a/src/transactions/providers.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export {
- TransactionNotificationProvider,
- useTransactionNotification,
-} from './providers/TransactionNotificationProvider'
diff --git a/src/transactions/providers/TransactionNotificationProvider.test.tsx b/src/transactions/providers/TransactionNotificationProvider.test.tsx
deleted file mode 100644
index 46a0e538..00000000
--- a/src/transactions/providers/TransactionNotificationProvider.test.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { act, renderHook } from '@testing-library/react'
-import type { ReactNode } from 'react'
-import { createElement } from 'react'
-import { describe, expect, it, vi } from 'vitest'
-
-import {
- TransactionNotificationProvider,
- useTransactionNotification,
-} from './TransactionNotificationProvider'
-
-const mockCreate = vi.fn((_options: Record) => 'toast-1')
-
-vi.mock('@/src/core/components', () => ({
- notificationToaster: {
- create: (options: Record) => mockCreate(options),
- },
- ExplorerLink: () => null,
- NotificationToast: () => null,
-}))
-
-vi.mock('@/src/wallet/hooks', () => ({
- useWeb3Status: vi.fn(() => ({
- readOnlyClient: {
- chain: { id: 1, name: 'Ethereum' },
- waitForTransactionReceipt: vi.fn(async () => ({ status: 'success' })),
- },
- })),
-}))
-
-const wrapper = ({ children }: { children: ReactNode }) =>
- createElement(TransactionNotificationProvider, null, children)
-
-describe('TransactionNotificationProvider', () => {
- describe('watchSignature', () => {
- it('shows error toast (not success) when signature is rejected', async () => {
- const { result } = renderHook(() => useTransactionNotification(), { wrapper })
-
- const rejectedPromise = Promise.reject(
- Object.assign(new Error('User rejected'), { shortMessage: 'User rejected the request' }),
- )
-
- await act(async () => {
- result.current.watchSignature({
- message: 'Sign this',
- signaturePromise: rejectedPromise,
- })
- // Let microtasks settle
- await rejectedPromise.catch(() => {})
- })
-
- const allArgs = mockCreate.mock.calls.map((call) => call[0])
- const errorToast = allArgs.find((arg) => arg.description === 'User rejected the request')
- expect(errorToast).toBeDefined()
- expect(errorToast?.type).toBe('error')
- })
- })
-
- describe('watchTx', () => {
- it('does not cause unhandled rejection when txPromise rejects', async () => {
- const { result } = renderHook(() => useTransactionNotification(), { wrapper })
-
- const rejectedPromise = Promise.reject(
- Object.assign(new Error('User rejected'), { shortMessage: 'User rejected the request' }),
- )
-
- // If watchTx awaits the same promise twice, the second await
- // would throw an unhandled rejection. This test verifies that
- // watchTx returns cleanly after the first rejection.
- await act(async () => {
- await result.current.watchTx({ txPromise: rejectedPromise })
- })
-
- // If we reach here without an unhandled rejection, the fix is correct.
- // Also verify the error toast was shown, not a success toast.
- const allArgs = mockCreate.mock.calls.map((call) => call[0])
- const errorToast = allArgs.find(
- (arg) => arg.type === 'error' && arg.description === 'User rejected the request',
- )
- expect(errorToast).toBeDefined()
- })
- })
-})
diff --git a/src/transactions/providers/TransactionNotificationProvider.tsx b/src/transactions/providers/TransactionNotificationProvider.tsx
deleted file mode 100644
index 6f83c2ce..00000000
--- a/src/transactions/providers/TransactionNotificationProvider.tsx
+++ /dev/null
@@ -1,242 +0,0 @@
-import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext } from 'react'
-import type {
- Hash,
- ReplacementReturnType,
- SignMessageErrorType,
- TransactionExecutionError,
-} from 'viem'
-import { ExplorerLink, NotificationToast, notificationToaster } from '@/src/core/components'
-import { useWeb3Status } from '@/src/wallet/hooks'
-
-type WatchSignatureArgs = {
- successMessage?: string
- message: ReactNode | string
- signaturePromise: Promise
- onToastId?: (toastId: string) => void
- showSuccessToast?: boolean
-}
-
-type WatchHashArgs = {
- message?: string
- successMessage?: string
- errorMessage?: string
- hash: Hash
- toastId?: string
-}
-
-type WatchTxArgs = { txPromise: Promise; methodId?: string }
-
-type TransactionContextValue = {
- watchSignature: (args: WatchSignatureArgs) => void
- watchHash: (args: WatchHashArgs) => void
- watchTx: (args: WatchTxArgs) => void
-}
-
-const TransactionContext = createContext(undefined)
-
-/**
- * Provider component for transaction notifications
- *
- * Manages transaction-related notifications including signature requests,
- * transaction submissions, and transaction confirmations.
- *
- * Provides context with methods for:
- * - watchSignature: Tracks a signature request and displays appropriate notifications
- * - watchHash: Monitors a transaction by hash and shows its progress/outcome
- * - watchTx: Combines signature and transaction monitoring in one method
- *
- * @example
- * ```tsx
- *
- *
- *
- * ```
- */
-export const TransactionNotificationProvider: FC = ({ children }) => {
- const { readOnlyClient } = useWeb3Status()
- const chain = readOnlyClient?.chain
-
- async function watchSignature({
- message,
- onToastId,
- showSuccessToast = true,
- signaturePromise,
- successMessage = 'Signature received!',
- }: WatchSignatureArgs) {
- const toastId = notificationToaster.create({
- description: message,
- type: 'loading',
- })
- onToastId?.(toastId)
-
- try {
- await signaturePromise
- if (showSuccessToast) {
- notificationToaster.create({
- description: successMessage,
- type: 'success',
- id: toastId,
- })
- }
- } catch (e) {
- const error = e as TransactionExecutionError | SignMessageErrorType
- const message =
- 'shortMessage' in error ? error.shortMessage : error.message || 'An error occurred'
-
- notificationToaster.create({
- description: message,
- type: 'error',
- id: toastId,
- })
- }
- }
-
- async function watchHash({
- errorMessage = 'Transaction was reverted!',
- hash,
- message = 'Transaction sent',
- successMessage = 'Transaction has been mined!',
- toastId,
- }: WatchHashArgs) {
- if (!chain) {
- console.error('Chain is not defined')
- return
- }
-
- if (!readOnlyClient) {
- console.error('ReadOnlyClient is not defined')
- return
- }
-
- notificationToaster.create({
- description: message,
- type: 'loading',
- id: toastId,
- })
-
- try {
- let replacedTx = null as ReplacementReturnType | null
- const receipt = await readOnlyClient.waitForTransactionReceipt({
- hash,
- // biome-ignore lint/suspicious/noAssignInExpressions:
- onReplaced: (replacedTxData) => (replacedTx = replacedTxData),
- })
-
- if (replacedTx !== null) {
- if (['replaced', 'cancelled'].includes(replacedTx.reason)) {
- notificationToaster.create({
- description: (
-
-
Transaction has been {replacedTx.reason}!
-
-
- ),
- type: 'error',
- id: toastId,
- })
- } else {
- notificationToaster.create({
- description: (
-
- ),
- type: 'success',
- id: toastId,
- })
- }
- return
- }
-
- if (receipt.status === 'success') {
- notificationToaster.create({
- description: (
-
- ),
- type: 'success',
- id: toastId,
- })
- } else {
- notificationToaster.create({
- description: (
-
- ),
- type: 'error',
- id: toastId,
- })
- }
- } catch (error) {
- console.error('Error watching hash', error)
- }
- }
-
- async function watchTx({ methodId, txPromise }: WatchTxArgs) {
- const transactionMessage = methodId ? `Transaction for calling ${methodId}` : 'Transaction'
-
- let toastId = ''
- let hash: Hash | undefined
-
- try {
- toastId = notificationToaster.create({
- description: `Signature requested: ${transactionMessage}`,
- type: 'loading',
- })
- hash = await txPromise
- } catch (e) {
- const error = e as TransactionExecutionError | SignMessageErrorType
- const message =
- 'shortMessage' in error ? error.shortMessage : error.message || 'An error occurred'
- notificationToaster.create({
- description: message,
- type: 'error',
- id: toastId,
- })
- return
- }
-
- await watchHash({
- hash,
- toastId,
- message: `${transactionMessage} is pending to be mined ...`,
- successMessage: `${transactionMessage} has been mined!`,
- errorMessage: `${transactionMessage} has reverted!`,
- })
- }
-
- return (
-
- {children}
-
-
- )
-}
-
-export function useTransactionNotification() {
- const context = useContext(TransactionContext)
-
- if (context === undefined) {
- throw new Error(
- 'useTransactionNotification must be used within a TransactionNotificationProvider',
- )
- }
- return context
-}
From f744d923c8980d01571eca54e0ff76d587e0ee2e Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 12:12:36 +0200
Subject: [PATCH 15/52] fix: prevent undefined toast id from crashing zag-js
toaster
---
src/sdk/react/lifecycle/createNotificationLifecycle.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
index 0cc82228..a2099e7b 100644
--- a/src/sdk/react/lifecycle/createNotificationLifecycle.ts
+++ b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
@@ -104,7 +104,7 @@ export function createNotificationLifecycle({
? `${messages.confirmed ?? 'Transaction confirmed!'}${suffix}`
: `${messages.reverted ?? 'Transaction was reverted'}${suffix}`,
type: isSuccess ? 'success' : 'error',
- id: toastId,
+ ...(toastId ? { id: toastId } : {}),
})
toastId = undefined
},
@@ -113,14 +113,14 @@ export function createNotificationLifecycle({
toaster.create({
description: messages.replaced ?? `Transaction ${reason}${suffix}`,
type: 'loading',
- id: toastId,
+ ...(toastId ? { id: toastId } : {}),
})
},
onError(_phase, error) {
toaster.create({
description: messages.error ?? extractErrorMessage(error),
type: 'error',
- id: toastId,
+ ...(toastId ? { id: toastId } : {}),
})
toastId = undefined
},
@@ -150,7 +150,7 @@ export function createSigningNotificationLifecycle({
toaster.create({
description: messages.signatureReceived ?? 'Signature received!',
type: 'success',
- id: toastId,
+ ...(toastId ? { id: toastId } : {}),
})
toastId = undefined
},
@@ -158,7 +158,7 @@ export function createSigningNotificationLifecycle({
toaster.create({
description: messages.error ?? extractErrorMessage(error),
type: 'error',
- id: toastId,
+ ...(toastId ? { id: toastId } : {}),
})
toastId = undefined
},
From cfc9ee6f93965d26724b5d1f0ccebfe86f4355e4 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 12:19:36 +0200
Subject: [PATCH 16/52] fix: show shortMessage instead of full viem error dump
in OP demo
---
.../Examples/demos/OptimismCrossDomainMessenger/index.tsx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
index 3b09e628..9cb65559 100644
--- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
@@ -96,7 +96,13 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
'Deposit ETH'
)}
- {tx.error && {tx.error.message}}
+ {tx.error && (
+
+ {'shortMessage' in tx.error
+ ? (tx.error as { shortMessage: string }).shortMessage
+ : tx.error.message}
+
+ )}
{l2Hash && (
Date: Wed, 8 Apr 2026 13:02:16 +0200
Subject: [PATCH 17/52] =?UTF-8?q?refactor:=20split=20createEvmWalletAdapte?=
=?UTF-8?q?r=20=E2=80=94=20remove=20React=20from=20core,=20create=20bundle?=
=?UTF-8?q?=20in=20react=20layer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Core adapter (src/sdk/core/evm/wallet.ts) returns WalletAdapter directly,
no React imports, no Provider/useConnectModal. Exposes wagmiConfig on return.
- React bundle (src/sdk/react/evm/wallet-bundle.tsx) wraps core adapter with
WagmiProvider + QueryClientProvider + connector WalletProvider.
- EvmConnectorConfig (React-specific) moved from core/evm/types to react/evm/types.
- Connector files (connectkit, rainbowkit, reown) moved from core to react layer.
- All consumers updated: __root.tsx uses createEvmWalletBundle, wagmi.config.ts
imports connector from react layer.
- Tests updated: unit + integration tests use EvmCoreConnectorConfig stub,
new test verifies adapter is returned directly (not as bundle).
---
src/routes/__root.tsx | 5 +-
src/sdk/core/evm/index.ts | 4 +-
src/sdk/core/evm/types.ts | 8 ---
src/sdk/core/evm/wallet.integration.test.ts | 19 ++++--
src/sdk/core/evm/wallet.test.ts | 68 ++++++++++++-------
src/sdk/core/evm/{wallet.tsx => wallet.ts} | 36 ++++------
.../evm/connectors/connectkit.tsx | 0
.../evm/connectors/connectors.test.ts | 0
.../{core => react}/evm/connectors/index.ts | 0
.../evm/connectors/rainbowkit.tsx | 0
.../{core => react}/evm/connectors/reown.tsx | 0
src/sdk/react/evm/index.ts | 4 ++
src/sdk/react/evm/types.ts | 10 +++
src/sdk/react/evm/wallet-bundle.tsx | 61 +++++++++++++++++
src/sdk/react/index.ts | 1 +
src/wallet/connectors/wagmi.config.ts | 8 +--
16 files changed, 157 insertions(+), 67 deletions(-)
rename src/sdk/core/evm/{wallet.tsx => wallet.ts} (91%)
rename src/sdk/{core => react}/evm/connectors/connectkit.tsx (100%)
rename src/sdk/{core => react}/evm/connectors/connectors.test.ts (100%)
rename src/sdk/{core => react}/evm/connectors/index.ts (100%)
rename src/sdk/{core => react}/evm/connectors/rainbowkit.tsx (100%)
rename src/sdk/{core => react}/evm/connectors/reown.tsx (100%)
create mode 100644 src/sdk/react/evm/index.ts
create mode 100644 src/sdk/react/evm/types.ts
create mode 100644 src/sdk/react/evm/wallet-bundle.tsx
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 45458a98..3feec0cb 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -9,8 +9,9 @@ import {
Toaster,
} from '@/src/core/components'
import { chains, transports } from '@/src/core/types'
-import { createEvmTransactionAdapter, createEvmWalletAdapter } from '@/src/sdk/core/evm'
+import { createEvmTransactionAdapter } from '@/src/sdk/core/evm'
import {
+ createEvmWalletBundle,
createNotificationLifecycle,
createSigningNotificationLifecycle,
DAppBoosterProvider,
@@ -24,7 +25,7 @@ import type { Chain } from 'viem'
const evmChains: Chain[] = [...chains]
-const evmWalletBundle = createEvmWalletAdapter({
+const evmWalletBundle = createEvmWalletBundle({
connector,
chains: evmChains,
transports,
diff --git a/src/sdk/core/evm/index.ts b/src/sdk/core/evm/index.ts
index dca95906..727cc5b2 100644
--- a/src/sdk/core/evm/index.ts
+++ b/src/sdk/core/evm/index.ts
@@ -1,5 +1,4 @@
export { fromViemChain } from './chains'
-export { connectkitConnector, rainbowkitConnector, reownConnector } from './connectors'
export type { ApprovalPreStepParams, PermitPreStepParams } from './pre-steps'
export { createApprovalPreStep, createPermitPreStep } from './pre-steps'
export type { EvmServerWalletConfig } from './server-wallet'
@@ -7,11 +6,10 @@ export { createEvmServerWallet } from './server-wallet'
export type { EvmTransactionConfig } from './transaction'
export { createEvmTransactionAdapter } from './transaction'
export type {
- EvmConnectorConfig,
EvmContractCall,
EvmCoreConnectorConfig,
EvmRawTransaction,
EvmTransactionPayload,
} from './types'
-export type { EvmWalletConfig } from './wallet'
+export type { EvmWalletAdapterResult, EvmWalletConfig } from './wallet'
export { createEvmWalletAdapter } from './wallet'
diff --git a/src/sdk/core/evm/types.ts b/src/sdk/core/evm/types.ts
index ec9d814f..21accbcf 100644
--- a/src/sdk/core/evm/types.ts
+++ b/src/sdk/core/evm/types.ts
@@ -1,4 +1,3 @@
-import type { FC, ReactNode } from 'react'
import type { Abi, Address, Chain, Hex, Transport } from 'viem'
import type { Config } from 'wagmi'
@@ -33,10 +32,3 @@ export type EvmTransactionPayload = EvmRawTransaction | EvmContractCall
export interface EvmCoreConnectorConfig {
createConfig(chains: Chain[], transports: Record): Config
}
-
-/** React-layer EVM connector config — extends core with UI components. */
-export interface EvmConnectorConfig extends EvmCoreConnectorConfig {
- WalletProvider: FC<{ children: ReactNode }>
- /** Hook that returns functions to open the connector's connect and account modals. */
- useConnectModal: () => { open: () => void; openAccount?: () => void }
-}
diff --git a/src/sdk/core/evm/wallet.integration.test.ts b/src/sdk/core/evm/wallet.integration.test.ts
index 45a8c3f4..457f03c0 100644
--- a/src/sdk/core/evm/wallet.integration.test.ts
+++ b/src/sdk/core/evm/wallet.integration.test.ts
@@ -10,11 +10,20 @@ import { createConfig } from 'wagmi'
import { mock } from 'wagmi/connectors'
import type { WalletStatus } from '../adapters/wallet'
-import { connectkitConnector } from './connectors'
+import type { EvmCoreConnectorConfig } from './types'
import { createEvmWalletAdapter } from './wallet'
const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const
+const stubCoreConnector: EvmCoreConnectorConfig = {
+ createConfig(chains, transports) {
+ return createConfig({
+ chains: chains as [typeof mainnet],
+ transports,
+ })
+ },
+}
+
function makeRealConfig() {
return createConfig({
chains: [mainnet],
@@ -26,8 +35,8 @@ function makeRealConfig() {
describe('createEvmWalletAdapter — integration tests', () => {
it('connect → getStatus → disconnect lifecycle', async () => {
const wagmiConfig = makeRealConfig()
- const { adapter } = createEvmWalletAdapter({
- connector: connectkitConnector,
+ const adapter = createEvmWalletAdapter({
+ coreConnector: stubCoreConnector,
chains: [mainnet],
transports: { [mainnet.id]: http() },
wagmiConfig,
@@ -45,8 +54,8 @@ describe('createEvmWalletAdapter — integration tests', () => {
it('onStatusChange subscription fires on connect', async () => {
const wagmiConfig = makeRealConfig()
- const { adapter } = createEvmWalletAdapter({
- connector: connectkitConnector,
+ const adapter = createEvmWalletAdapter({
+ coreConnector: stubCoreConnector,
chains: [mainnet],
transports: { [mainnet.id]: http() },
wagmiConfig,
diff --git a/src/sdk/core/evm/wallet.test.ts b/src/sdk/core/evm/wallet.test.ts
index 27fba669..f46e26cc 100644
--- a/src/sdk/core/evm/wallet.test.ts
+++ b/src/sdk/core/evm/wallet.test.ts
@@ -1,4 +1,3 @@
-import type { ReactNode } from 'react'
import { http } from 'viem'
import { mainnet } from 'viem/chains'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -22,7 +21,7 @@ import {
WalletNotConnectedError,
WalletNotInstalledError,
} from '../errors'
-import { connectkitConnector } from './connectors'
+import type { EvmCoreConnectorConfig } from './types'
import { createEvmWalletAdapter } from './wallet'
// ---------------------------------------------------------------------------
@@ -49,10 +48,7 @@ vi.mock('wagmi/actions', async (importOriginal) => {
vi.mock('wagmi', async (importOriginal) => {
const actual = await importOriginal()
- return {
- ...actual,
- WagmiProvider: ({ children }: { children: ReactNode }) => children,
- }
+ return { ...actual }
})
// ---------------------------------------------------------------------------
@@ -126,9 +122,18 @@ describe('createEvmWalletAdapter — unit tests', () => {
vi.mocked(getConnectors).mockReturnValue([])
})
+ const stubCoreConnector: EvmCoreConnectorConfig = {
+ createConfig(chains, transports) {
+ return createConfig({
+ chains: chains as [typeof mainnet],
+ transports,
+ })
+ },
+ }
+
function makeAdapter() {
return createEvmWalletAdapter({
- connector: connectkitConnector,
+ coreConnector: stubCoreConnector,
chains: [mainnet],
transports: { [mainnet.id]: http() },
wagmiConfig,
@@ -142,7 +147,7 @@ describe('createEvmWalletAdapter — unit tests', () => {
describe('getStatus()', () => {
it('maps connected account to WalletStatus', () => {
vi.mocked(getAccount).mockReturnValue(makeConnectedAccount('0xabc' as `0x${string}`, 1))
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
expect(adapter.getStatus()).toEqual({
connected: true,
activeAccount: '0xabc',
@@ -153,7 +158,7 @@ describe('createEvmWalletAdapter — unit tests', () => {
it('maps disconnected state', () => {
vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount())
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
expect(adapter.getStatus()).toEqual({
connected: false,
activeAccount: null,
@@ -164,7 +169,7 @@ describe('createEvmWalletAdapter — unit tests', () => {
it('maps connecting state', () => {
vi.mocked(getAccount).mockReturnValue(makeConnectingAccount())
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
expect(adapter.getStatus()).toEqual({
connected: false,
activeAccount: null,
@@ -180,12 +185,12 @@ describe('createEvmWalletAdapter — unit tests', () => {
describe('metadata', () => {
it('chainType is "evm"', () => {
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
expect(adapter.metadata.chainType).toBe('evm')
})
it('capabilities has signTypedData and switchChain true', () => {
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
expect(adapter.metadata.capabilities).toEqual({ signTypedData: true, switchChain: true })
})
})
@@ -197,7 +202,7 @@ describe('createEvmWalletAdapter — unit tests', () => {
describe('signMessage()', () => {
it('throws WalletNotConnectedError when disconnected', async () => {
vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount())
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.signMessage({ message: 'hello' })).rejects.toThrow(
WalletNotConnectedError,
)
@@ -208,14 +213,14 @@ describe('createEvmWalletAdapter — unit tests', () => {
vi.mocked(signMessage).mockRejectedValue(
Object.assign(new Error('User rejected request'), { name: 'UserRejectedRequestError' }),
)
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.signMessage({ message: 'hello' })).rejects.toThrow(SigningRejectedError)
})
it('returns SignatureResult with signature and address when connected', async () => {
vi.mocked(getAccount).mockReturnValue(makeConnectedAccount(TEST_ADDRESS, 1))
vi.mocked(signMessage).mockResolvedValue('0xsig' as `0x${string}`)
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
const result = await adapter.signMessage({ message: 'test' })
expect(result).toEqual({ signature: '0xsig', address: TEST_ADDRESS })
})
@@ -223,7 +228,7 @@ describe('createEvmWalletAdapter — unit tests', () => {
it('passes Uint8Array message as raw form', async () => {
vi.mocked(getAccount).mockReturnValue(makeConnectedAccount(TEST_ADDRESS, 1))
vi.mocked(signMessage).mockResolvedValue('0xsig' as `0x${string}`)
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
const bytes = new Uint8Array([1, 2, 3])
await adapter.signMessage({ message: bytes })
expect(vi.mocked(signMessage)).toHaveBeenCalledWith(
@@ -243,7 +248,7 @@ describe('createEvmWalletAdapter — unit tests', () => {
vi.mocked(connect).mockRejectedValue(
Object.assign(new Error('User rejected'), { name: 'UserRejectedRequestError' }),
)
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.connect()).rejects.toThrow(WalletConnectionRejectedError)
})
@@ -252,13 +257,13 @@ describe('createEvmWalletAdapter — unit tests', () => {
vi.mocked(connect).mockRejectedValue(
Object.assign(new Error('Connector not found'), { name: 'ConnectorNotFoundError' }),
)
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.connect()).rejects.toThrow(WalletNotInstalledError)
})
it('throws ChainNotSupportedError when options.chainId is not in supportedChains', async () => {
wagmiConfig = makeConfig({ withConnector: true })
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.connect({ chainId: 999999 })).rejects.toThrow(ChainNotSupportedError)
})
})
@@ -273,14 +278,14 @@ describe('createEvmWalletAdapter — unit tests', () => {
vi.mocked(getWalletClient).mockResolvedValue({ type: 'walletClient' } as unknown as Awaited<
ReturnType
>)
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
const signer = await adapter.getSigner()
expect(signer).not.toBeNull()
})
it('returns null when disconnected', async () => {
vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount())
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
const signer = await adapter.getSigner()
expect(signer).toBeNull()
})
@@ -293,13 +298,13 @@ describe('createEvmWalletAdapter — unit tests', () => {
describe('switchChain()', () => {
it('throws ChainNotSupportedError for unsupported chainId', async () => {
vi.mocked(getAccount).mockReturnValue(makeConnectedAccount())
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.switchChain(999999)).rejects.toThrow(ChainNotSupportedError)
})
it('throws WalletNotConnectedError when switchChain is called while disconnected', async () => {
vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount())
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
await expect(adapter.switchChain(mainnet.id)).rejects.toThrow(WalletNotConnectedError)
})
})
@@ -309,7 +314,22 @@ describe('createEvmWalletAdapter — unit tests', () => {
// -------------------------------------------------------------------------
it('chainType is "evm"', () => {
- const { adapter } = makeAdapter()
+ const adapter = makeAdapter()
+ expect(adapter.chainType).toBe('evm')
+ })
+
+ // -------------------------------------------------------------------------
+ // return shape
+ // -------------------------------------------------------------------------
+
+ it('returns WalletAdapter directly with wagmiConfig, not a bundle', () => {
+ const adapter = makeAdapter()
+ // Adapter is returned directly — not wrapped in { adapter, Provider }
expect(adapter.chainType).toBe('evm')
+ expect(adapter.wagmiConfig).toBeDefined()
+ expect(adapter.getStatus).toBeTypeOf('function')
+ // No bundle properties
+ expect('Provider' in adapter).toBe(false)
+ expect('useConnectModal' in adapter).toBe(false)
})
})
diff --git a/src/sdk/core/evm/wallet.tsx b/src/sdk/core/evm/wallet.ts
similarity index 91%
rename from src/sdk/core/evm/wallet.tsx
rename to src/sdk/core/evm/wallet.ts
index cf5ec341..4816e5bc 100644
--- a/src/sdk/core/evm/wallet.tsx
+++ b/src/sdk/core/evm/wallet.ts
@@ -1,11 +1,10 @@
/**
* EVM implementation of the WalletAdapter interface using @wagmi/core actions.
+ * Framework-agnostic — no React imports.
*/
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { FC, ReactNode } from 'react'
import type { Chain, Transport } from 'viem'
-import { type Config, WagmiProvider } from 'wagmi'
+import type { Config } from 'wagmi'
import {
connect,
disconnect,
@@ -20,7 +19,6 @@ import {
watchChainId,
} from 'wagmi/actions'
-import type { WalletAdapterBundle } from '../adapters/provider'
import type {
ChainSigner,
ConnectOptions,
@@ -40,17 +38,20 @@ import {
WalletNotInstalledError,
} from '../errors'
import { fromViemChain } from './chains'
-import type { EvmConnectorConfig } from './types'
+import type { EvmCoreConnectorConfig } from './types'
// ---------------------------------------------------------------------------
// Public config interface
// ---------------------------------------------------------------------------
+/** Return type of createEvmWalletAdapter — the adapter plus the wagmiConfig for use by the React bundle. */
+export type EvmWalletAdapterResult = WalletAdapter<'evm'> & { wagmiConfig: Config }
+
export interface EvmWalletConfig {
- connector: EvmConnectorConfig
+ coreConnector: EvmCoreConnectorConfig
chains: Chain[]
transports: Record
- /** Pre-created wagmi Config. If provided, used directly instead of calling connector.createConfig(). */
+ /** Pre-created wagmi Config. If provided, used directly instead of calling coreConnector.createConfig(). */
wagmiConfig?: Config
}
@@ -107,20 +108,21 @@ function toWalletStatus(account: ReturnType): WalletStatus {
// ---------------------------------------------------------------------------
/**
- * Creates a browser-side EVM WalletAdapter backed by wagmi actions and a connector UI.
+ * Creates a browser-side EVM WalletAdapter backed by wagmi actions.
+ * Returns the adapter directly (no React Provider) — use createEvmWalletBundle for the React wrapper.
*
* @precondition config.chains.length >= 1
- * @precondition config.connector provides createConfig and WalletProvider
+ * @precondition config.coreConnector provides createConfig
* @postcondition returned adapter.chainType === 'evm'
* @postcondition returned adapter.supportedChains matches config.chains (mapped via fromViemChain)
+ * @postcondition returned adapter.wagmiConfig is the wagmi Config used internally
* @invariant adapter.chainType never changes after construction
* @invariant adapter.supportedChains never changes after construction
*/
-export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBundle {
+export function createEvmWalletAdapter(config: EvmWalletConfig): EvmWalletAdapterResult {
const wagmiConfig =
- config.wagmiConfig ?? config.connector.createConfig(config.chains, config.transports)
+ config.wagmiConfig ?? config.coreConnector.createConfig(config.chains, config.transports)
const supportedChains = config.chains.map(fromViemChain)
- const queryClient = new QueryClient()
const adapter: WalletAdapter<'evm'> = {
chainType: 'evm',
@@ -342,13 +344,5 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): WalletAdapterBu
},
}
- const Provider: FC<{ children: ReactNode }> = ({ children }) => (
-
-
- {children}
-
-
- )
-
- return { adapter, Provider, useConnectModal: config.connector.useConnectModal }
+ return Object.assign(adapter, { wagmiConfig })
}
diff --git a/src/sdk/core/evm/connectors/connectkit.tsx b/src/sdk/react/evm/connectors/connectkit.tsx
similarity index 100%
rename from src/sdk/core/evm/connectors/connectkit.tsx
rename to src/sdk/react/evm/connectors/connectkit.tsx
diff --git a/src/sdk/core/evm/connectors/connectors.test.ts b/src/sdk/react/evm/connectors/connectors.test.ts
similarity index 100%
rename from src/sdk/core/evm/connectors/connectors.test.ts
rename to src/sdk/react/evm/connectors/connectors.test.ts
diff --git a/src/sdk/core/evm/connectors/index.ts b/src/sdk/react/evm/connectors/index.ts
similarity index 100%
rename from src/sdk/core/evm/connectors/index.ts
rename to src/sdk/react/evm/connectors/index.ts
diff --git a/src/sdk/core/evm/connectors/rainbowkit.tsx b/src/sdk/react/evm/connectors/rainbowkit.tsx
similarity index 100%
rename from src/sdk/core/evm/connectors/rainbowkit.tsx
rename to src/sdk/react/evm/connectors/rainbowkit.tsx
diff --git a/src/sdk/core/evm/connectors/reown.tsx b/src/sdk/react/evm/connectors/reown.tsx
similarity index 100%
rename from src/sdk/core/evm/connectors/reown.tsx
rename to src/sdk/react/evm/connectors/reown.tsx
diff --git a/src/sdk/react/evm/index.ts b/src/sdk/react/evm/index.ts
new file mode 100644
index 00000000..f629957c
--- /dev/null
+++ b/src/sdk/react/evm/index.ts
@@ -0,0 +1,4 @@
+export { connectkitConnector, rainbowkitConnector, reownConnector } from './connectors'
+export type { EvmConnectorConfig } from './types'
+export type { EvmWalletBundleConfig } from './wallet-bundle'
+export { createEvmWalletBundle } from './wallet-bundle'
diff --git a/src/sdk/react/evm/types.ts b/src/sdk/react/evm/types.ts
new file mode 100644
index 00000000..a4932a2d
--- /dev/null
+++ b/src/sdk/react/evm/types.ts
@@ -0,0 +1,10 @@
+import type { FC, ReactNode } from 'react'
+
+import type { EvmCoreConnectorConfig } from '../../core/evm/types'
+
+/** React-layer EVM connector config — extends core with UI components. */
+export interface EvmConnectorConfig extends EvmCoreConnectorConfig {
+ WalletProvider: FC<{ children: ReactNode }>
+ /** Hook that returns functions to open the connector's connect and account modals. */
+ useConnectModal: () => { open: () => void; openAccount?: () => void }
+}
diff --git a/src/sdk/react/evm/wallet-bundle.tsx b/src/sdk/react/evm/wallet-bundle.tsx
new file mode 100644
index 00000000..bff348a1
--- /dev/null
+++ b/src/sdk/react/evm/wallet-bundle.tsx
@@ -0,0 +1,61 @@
+/**
+ * React bundle wrapper around the core EVM wallet adapter.
+ * Adds WagmiProvider, QueryClientProvider, and the connector's WalletProvider.
+ */
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { FC, ReactNode } from 'react'
+import type { Chain, Transport } from 'viem'
+import { type Config, WagmiProvider } from 'wagmi'
+
+import type { WalletAdapterBundle } from '../../core/adapters/provider'
+import { createEvmWalletAdapter } from '../../core/evm/wallet'
+import type { EvmConnectorConfig } from './types'
+
+// ---------------------------------------------------------------------------
+// Public config interface
+// ---------------------------------------------------------------------------
+
+export interface EvmWalletBundleConfig {
+ connector: EvmConnectorConfig
+ chains: Chain[]
+ transports: Record
+ /** Pre-created wagmi Config. If provided, used directly instead of calling connector.createConfig(). */
+ wagmiConfig?: Config
+}
+
+// ---------------------------------------------------------------------------
+// Factory
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a React-ready EVM WalletAdapterBundle — adapter + Provider + useConnectModal.
+ * Wraps the core createEvmWalletAdapter with WagmiProvider, QueryClientProvider, and the
+ * connector's WalletProvider.
+ *
+ * @precondition config.chains.length >= 1
+ * @precondition config.connector provides createConfig, WalletProvider, and useConnectModal
+ * @postcondition returned bundle.adapter.chainType === 'evm'
+ * @postcondition returned bundle.Provider wraps children with wagmi + query + connector providers
+ */
+export function createEvmWalletBundle(config: EvmWalletBundleConfig): WalletAdapterBundle {
+ const adapter = createEvmWalletAdapter({
+ coreConnector: config.connector,
+ chains: config.chains,
+ transports: config.transports,
+ wagmiConfig: config.wagmiConfig,
+ })
+
+ const { wagmiConfig } = adapter
+ const queryClient = new QueryClient()
+
+ const Provider: FC<{ children: ReactNode }> = ({ children }) => (
+
+
+ {children}
+
+
+ )
+
+ return { adapter, Provider, useConnectModal: config.connector.useConnectModal }
+}
diff --git a/src/sdk/react/index.ts b/src/sdk/react/index.ts
index 075b4a08..67d0f15e 100644
--- a/src/sdk/react/index.ts
+++ b/src/sdk/react/index.ts
@@ -1,4 +1,5 @@
export * from './components'
+export * from './evm'
export * from './hooks'
export * from './lifecycle'
export * from './provider'
diff --git a/src/wallet/connectors/wagmi.config.ts b/src/wallet/connectors/wagmi.config.ts
index 221d3775..5246ede8 100644
--- a/src/wallet/connectors/wagmi.config.ts
+++ b/src/wallet/connectors/wagmi.config.ts
@@ -3,12 +3,12 @@
* Both the SDK adapter (in __root.tsx) and generated contract hooks reference this file.
*
* To switch connectors, change the import below:
- * import { connectkitConnector as connector } from '@/src/sdk/core/evm'
- * import { rainbowkitConnector as connector } from '@/src/sdk/core/evm'
- * import { reownConnector as connector } from '@/src/sdk/core/evm'
+ * import { connectkitConnector as connector } from '@/src/sdk/react/evm'
+ * import { rainbowkitConnector as connector } from '@/src/sdk/react/evm'
+ * import { reownConnector as connector } from '@/src/sdk/react/evm'
*/
import { chains, transports } from '@/src/core/types'
-import { connectkitConnector as connector } from '@/src/sdk/core/evm'
+import { connectkitConnector as connector } from '@/src/sdk/react/evm'
export { connector }
export const config = connector.createConfig([...chains], transports)
From 280fd9ea870c33e7fc0f206526a3530a9d92ef51 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 13:14:26 +0200
Subject: [PATCH 18/52] refactor: make WalletGuard and ConnectWalletButton
headless with render props
Remove Chakra UI imports from SDK components. WalletGuard now accepts
renderConnect and renderSwitchChain render props (fallback kept but
deprecated). ConnectWalletButton accepts a render prop receiving wallet
state and action callbacks.
Chakra-styled wrappers in src/chakra/ compose the headless components
with styled buttons. Demos and wallet/providers re-export from the
Chakra layer so consumers see no behavioral change.
---
src/chakra/ConnectWalletButton.tsx | 29 +++
src/chakra/WalletGuard.tsx | 53 ++++
src/chakra/index.ts | 2 +
.../OptimismCrossDomainMessenger/index.tsx | 2 +-
.../Examples/demos/SignMessage/index.test.tsx | 6 +-
.../home/Examples/demos/SignMessage/index.tsx | 2 +-
.../demos/TransactionButton/index.test.tsx | 6 +
.../demos/TransactionButton/index.tsx | 2 +-
.../components/ConnectWalletButton.test.tsx | 155 ++++++++++++
.../react/components/ConnectWalletButton.tsx | 41 ++--
src/sdk/react/components/WalletGuard.test.tsx | 231 ++++++++++++++----
src/sdk/react/components/WalletGuard.tsx | 98 +++++---
src/sdk/react/components/index.ts | 3 +-
src/wallet/providers.ts | 2 +-
14 files changed, 528 insertions(+), 104 deletions(-)
create mode 100644 src/chakra/ConnectWalletButton.tsx
create mode 100644 src/chakra/WalletGuard.tsx
create mode 100644 src/chakra/index.ts
create mode 100644 src/sdk/react/components/ConnectWalletButton.test.tsx
diff --git a/src/chakra/ConnectWalletButton.tsx b/src/chakra/ConnectWalletButton.tsx
new file mode 100644
index 00000000..98413199
--- /dev/null
+++ b/src/chakra/ConnectWalletButton.tsx
@@ -0,0 +1,29 @@
+import type { FC } from 'react'
+import { ConnectWalletButton as HeadlessConnectWalletButton } from '@/src/sdk/react/components/ConnectWalletButton'
+import type { UseWalletOptions } from '@/src/sdk/react/hooks/useWallet'
+import ConnectButton from '@/src/wallet/components/ConnectButton'
+
+/**
+ * Chakra-styled connect/account button.
+ *
+ * Composes the headless ConnectWalletButton with the Chakra ConnectButton
+ * for styled rendering.
+ */
+export const ConnectWalletButton: FC = ({
+ label = 'Connect',
+ ...walletOptions
+}) => {
+ return (
+ (
+
+ {status.connected && truncatedAddress ? truncatedAddress : label}
+
+ )}
+ />
+ )
+}
diff --git a/src/chakra/WalletGuard.tsx b/src/chakra/WalletGuard.tsx
new file mode 100644
index 00000000..3d6b07c8
--- /dev/null
+++ b/src/chakra/WalletGuard.tsx
@@ -0,0 +1,53 @@
+import type { FC, ReactElement, ReactNode } from 'react'
+import {
+ WalletGuard as HeadlessWalletGuard,
+ type WalletRequirement,
+} from '@/src/sdk/react/components/WalletGuard'
+import SwitchChainButton from '@/src/wallet/components/SwitchChainButton'
+import { ConnectWalletButton } from './ConnectWalletButton'
+
+interface ChakraWalletGuardProps {
+ chainId?: string | number
+ chainType?: string
+ children?: ReactNode
+ fallback?: ReactElement
+ require?: WalletRequirement[]
+ switchChainLabel?: string
+}
+
+/**
+ * Chakra-styled WalletGuard.
+ *
+ * Composes the headless WalletGuard with Chakra-styled ConnectWalletButton
+ * and SwitchChainButton as default render props.
+ */
+export const WalletGuard: FC = ({
+ chainId,
+ chainType,
+ children,
+ fallback,
+ require: requirements,
+ switchChainLabel = 'Switch to',
+}) => {
+ return (
+ (
+
+ )}
+ renderSwitchChain={({ chainName, onSwitch }) => (
+
+ {switchChainLabel} {chainName}
+
+ )}
+ >
+ {children}
+
+ )
+}
diff --git a/src/chakra/index.ts b/src/chakra/index.ts
new file mode 100644
index 00000000..bd7a5379
--- /dev/null
+++ b/src/chakra/index.ts
@@ -0,0 +1,2 @@
+export { ConnectWalletButton } from './ConnectWalletButton'
+export { WalletGuard } from './WalletGuard'
diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
index 9cb65559..36d060f0 100644
--- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
@@ -4,6 +4,7 @@ import type { Address, TransactionReceipt } from 'viem'
import { parseEther } from 'viem'
import { optimismSepolia, sepolia } from 'viem/chains'
import { extractTransactionDepositedLogs, getL2TransactionHash } from 'viem/op-stack'
+import { WalletGuard } from '@/src/chakra'
import Icon from '@/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/Icon'
import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { getContract } from '@/src/contracts/definitions'
@@ -11,7 +12,6 @@ import { buildCrossDomainMessageParams } from '@/src/contracts/hooks/useOPL1Cros
import { Hash, PrimaryButton, Spinner } from '@/src/core/components'
import { withSuspenseAndRetry } from '@/src/core/utils'
import { getExplorerUrl } from '@/src/sdk/core/chain/explorer'
-import { WalletGuard } from '@/src/sdk/react/components'
import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks'
/**
diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
index 4c6b5d07..a67007ae 100644
--- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx
@@ -41,8 +41,10 @@ vi.mock('@/src/sdk/react/hooks', () => ({
})),
}))
-vi.mock('@/src/wallet/providers', () => ({
- ConnectWalletButton: () => ,
+vi.mock('@/src/chakra', () => ({
+ WalletGuard: ({ children: _children }: { children: React.ReactNode }) => (
+
+ ),
}))
describe('SignMessage demo', () => {
diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx
index 86c8eb5f..95a6b47e 100644
--- a/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.tsx
@@ -1,7 +1,7 @@
+import { WalletGuard } from '@/src/chakra'
import Icon from '@/src/components/pageComponents/home/Examples/demos/SignMessage/Icon'
import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { PrimaryButton } from '@/src/core/components'
-import { WalletGuard } from '@/src/sdk/react/components'
import { SignButton } from '@/src/transactions/components'
const message = `
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx
index 346fcf8b..c98aeec1 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx
@@ -11,6 +11,12 @@ vi.mock('@/src/wallet/providers', () => ({
ConnectWalletButton: () => ,
}))
+vi.mock('@/src/chakra', () => ({
+ WalletGuard: ({ children: _children }: { children: React.ReactNode }) => (
+
+ ),
+}))
+
vi.mock('@/src/sdk/react/hooks', () => ({
useWallet: vi.fn(() => ({
needsConnect: true,
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
index d1bda6d7..c8918f36 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
@@ -1,11 +1,11 @@
import { Flex } from '@chakra-ui/react'
import { useState } from 'react'
import { sepolia } from 'viem/chains'
+import { WalletGuard } from '@/src/chakra'
import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown'
import ERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton'
import Icon from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Icon'
import NativeToken from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken'
-import { WalletGuard } from '@/src/sdk/react/components'
type Options = 'erc20' | 'native'
diff --git a/src/sdk/react/components/ConnectWalletButton.test.tsx b/src/sdk/react/components/ConnectWalletButton.test.tsx
new file mode 100644
index 00000000..86fa18c6
--- /dev/null
+++ b/src/sdk/react/components/ConnectWalletButton.test.tsx
@@ -0,0 +1,155 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { createElement } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConnectWalletButton } from './ConnectWalletButton'
+
+const mockOpenConnectModal = vi.fn()
+const mockOpenAccountModal = vi.fn()
+
+vi.mock('../hooks/useWallet', () => ({
+ useWallet: vi.fn(() => ({
+ adapter: {} as never,
+ needsConnect: true,
+ needsChainSwitch: false,
+ isReady: false,
+ status: { connected: false, connecting: false, activeAccount: null, connectedChainIds: [] },
+ switchChain: vi.fn(),
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ signMessage: vi.fn(),
+ getSigner: vi.fn(),
+ adapterKey: 'evm',
+ openConnectModal: mockOpenConnectModal,
+ openAccountModal: mockOpenAccountModal,
+ })),
+}))
+
+const { useWallet } = await import('../hooks/useWallet')
+const mockedUseWallet = vi.mocked(useWallet)
+
+describe('ConnectWalletButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('calls render with disconnected status and connect callback', () => {
+ render(
+ createElement(ConnectWalletButton, {
+ render: ({ status, onConnect }) =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'connect-btn', onClick: onConnect },
+ status.connected ? 'Connected' : 'Connect',
+ ),
+ }),
+ )
+
+ expect(screen.getByTestId('connect-btn')).toHaveTextContent('Connect')
+ })
+
+ it('calls onConnect (openConnectModal) when disconnected', async () => {
+ const user = userEvent.setup()
+
+ render(
+ createElement(ConnectWalletButton, {
+ render: ({ onConnect }) =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'connect-btn', onClick: onConnect },
+ 'Connect',
+ ),
+ }),
+ )
+
+ await user.click(screen.getByTestId('connect-btn'))
+ expect(mockOpenConnectModal).toHaveBeenCalledOnce()
+ })
+
+ it('provides truncated address when connected', () => {
+ mockedUseWallet.mockReturnValue({
+ adapter: {} as never,
+ needsConnect: false,
+ needsChainSwitch: false,
+ isReady: true,
+ status: {
+ connected: true,
+ connecting: false,
+ activeAccount: '0x1234567890abcdef1234567890abcdef12345678',
+ connectedChainIds: [1],
+ },
+ switchChain: vi.fn(),
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ signMessage: vi.fn(),
+ getSigner: vi.fn(),
+ adapterKey: 'evm',
+ openConnectModal: mockOpenConnectModal,
+ openAccountModal: mockOpenAccountModal,
+ })
+
+ render(
+ createElement(ConnectWalletButton, {
+ render: ({ status, truncatedAddress }) =>
+ createElement(
+ 'span',
+ { 'data-testid': 'address' },
+ status.connected ? truncatedAddress : 'Not connected',
+ ),
+ }),
+ )
+
+ expect(screen.getByTestId('address')).toHaveTextContent('0x1234\u20265678')
+ })
+
+ it('calls onManageAccount (openAccountModal) when connected', async () => {
+ const user = userEvent.setup()
+
+ mockedUseWallet.mockReturnValue({
+ adapter: {} as never,
+ needsConnect: false,
+ needsChainSwitch: false,
+ isReady: true,
+ status: {
+ connected: true,
+ connecting: false,
+ activeAccount: '0xabc',
+ connectedChainIds: [1],
+ },
+ switchChain: vi.fn(),
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ signMessage: vi.fn(),
+ getSigner: vi.fn(),
+ adapterKey: 'evm',
+ openConnectModal: mockOpenConnectModal,
+ openAccountModal: mockOpenAccountModal,
+ })
+
+ render(
+ createElement(ConnectWalletButton, {
+ render: ({ onManageAccount }) =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'account-btn', onClick: onManageAccount },
+ 'Account',
+ ),
+ }),
+ )
+
+ await user.click(screen.getByTestId('account-btn'))
+ expect(mockOpenAccountModal).toHaveBeenCalledOnce()
+ })
+
+ it('passes wallet options (chainId) to useWallet', () => {
+ render(
+ createElement(ConnectWalletButton, {
+ chainId: 10,
+ render: ({ status }) =>
+ createElement('span', { 'data-testid': 'status' }, String(status.connected)),
+ }),
+ )
+
+ expect(mockedUseWallet).toHaveBeenCalledWith(expect.objectContaining({ chainId: 10 }))
+ })
+})
diff --git a/src/sdk/react/components/ConnectWalletButton.tsx b/src/sdk/react/components/ConnectWalletButton.tsx
index b14444b6..580d71e0 100644
--- a/src/sdk/react/components/ConnectWalletButton.tsx
+++ b/src/sdk/react/components/ConnectWalletButton.tsx
@@ -1,30 +1,37 @@
-import type { FC } from 'react'
-import ConnectButton from '@/src/wallet/components/ConnectButton'
+import type { FC, ReactElement } from 'react'
+import type { WalletStatus } from '../../core/adapters/wallet'
import type { UseWalletOptions } from '../hooks/useWallet'
import { useWallet } from '../hooks/useWallet'
+/** Props passed to the render prop of ConnectWalletButton. */
+export interface ConnectWalletButtonRenderProps {
+ status: WalletStatus
+ truncatedAddress: string | undefined
+ onConnect: () => void
+ onManageAccount: () => void
+}
+
+interface ConnectWalletButtonProps extends UseWalletOptions {
+ render: (props: ConnectWalletButtonRenderProps) => ReactElement
+}
+
/**
- * Styled connect/account button that works with any connector.
+ * Headless connect/account button that works with any connector.
*
- * Resolves the wallet adapter via `useWallet(options)` and opens the
- * adapter-specific connect modal. In a multi-wallet setup, pass `chainType`
+ * Resolves the wallet adapter via `useWallet(options)` and delegates all
+ * rendering to the `render` prop. In a multi-wallet setup, pass `chainType`
* or `chainId` to target a specific adapter's modal.
*/
-export const ConnectWalletButton: FC = ({
- label = 'Connect',
- ...walletOptions
-}) => {
+export const ConnectWalletButton: FC = ({ render, ...walletOptions }) => {
const { status, openConnectModal, openAccountModal } = useWallet(walletOptions)
const address = status.activeAccount
const truncatedAddress = address ? `${address.slice(0, 6)}\u2026${address.slice(-4)}` : undefined
- return (
-
- {status.connected && truncatedAddress ? truncatedAddress : label}
-
- )
+ return render({
+ status,
+ truncatedAddress,
+ onConnect: openConnectModal,
+ onManageAccount: openAccountModal,
+ })
}
diff --git a/src/sdk/react/components/WalletGuard.test.tsx b/src/sdk/react/components/WalletGuard.test.tsx
index fa49246a..ce8e4cbb 100644
--- a/src/sdk/react/components/WalletGuard.test.tsx
+++ b/src/sdk/react/components/WalletGuard.test.tsx
@@ -1,6 +1,6 @@
-import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react'
import { render, screen } from '@testing-library/react'
-import { createElement, type ReactNode } from 'react'
+import userEvent from '@testing-library/user-event'
+import { createElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WalletGuard, type WalletRequirement } from './WalletGuard'
@@ -37,34 +37,11 @@ vi.mock('../hooks', () => ({
})),
}))
-vi.mock('@/src/wallet/providers', () => ({
- ConnectWalletButton: () =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'connect-wallet-button' },
- 'Connect Wallet',
- ),
-}))
-
-vi.mock('@/src/wallet/components/SwitchChainButton', () => ({
- default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) =>
- createElement(
- 'button',
- { type: 'button', 'data-testid': 'switch-chain-button', onClick },
- children,
- ),
-}))
-
const { useWallet, useChainRegistry, useMultiWallet } = await import('../hooks')
const mockedUseWallet = vi.mocked(useWallet)
const mockedUseChainRegistry = vi.mocked(useChainRegistry)
const mockedUseMultiWallet = vi.mocked(useMultiWallet)
-const system = createSystem(defaultConfig)
-
-const renderWithChakra = (ui: ReactNode) =>
- render(createElement(ChakraProvider, { value: system } as never, ui))
-
const makeWalletReady = () => ({
adapter: {} as never,
needsConnect: false,
@@ -86,8 +63,8 @@ describe('WalletGuard', () => {
vi.clearAllMocks()
})
- it('renders fallback when wallet needsConnect', () => {
- renderWithChakra(
+ it('renders nothing when wallet needsConnect and no render props provided', () => {
+ const { container } = render(
createElement(
WalletGuard,
null,
@@ -95,12 +72,28 @@ describe('WalletGuard', () => {
),
)
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
+ expect(container.innerHTML).toBe('')
expect(screen.queryByTestId('protected-content')).toBeNull()
})
- it('renders custom fallback when provided and needsConnect', () => {
- renderWithChakra(
+ it('renders renderConnect when wallet needsConnect', () => {
+ render(
+ createElement(
+ WalletGuard,
+ {
+ renderConnect: () =>
+ createElement('button', { type: 'button', 'data-testid': 'custom-connect' }, 'Connect'),
+ },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(screen.getByTestId('custom-connect')).toBeInTheDocument()
+ expect(screen.queryByTestId('protected-content')).toBeNull()
+ })
+
+ it('renders deprecated fallback when provided and needsConnect (no renderConnect)', () => {
+ render(
createElement(
WalletGuard,
{ fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Custom') },
@@ -112,7 +105,25 @@ describe('WalletGuard', () => {
expect(screen.queryByTestId('protected-content')).toBeNull()
})
- it('renders switch chain button when needsChainSwitch', () => {
+ it('prefers renderConnect over fallback when both provided', () => {
+ render(
+ createElement(
+ WalletGuard,
+ {
+ renderConnect: () =>
+ createElement('button', { type: 'button', 'data-testid': 'render-connect' }, 'RC'),
+ fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Fallback'),
+ },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(screen.getByTestId('render-connect')).toBeInTheDocument()
+ expect(screen.queryByTestId('custom-fallback')).toBeNull()
+ })
+
+ it('renders renderSwitchChain when needsChainSwitch with correct props', async () => {
+ const user = userEvent.setup()
mockedUseWallet.mockReturnValue({
...makeWalletReady(),
needsConnect: false,
@@ -135,7 +146,40 @@ describe('WalletGuard', () => {
getAllChains: vi.fn(() => []),
})
- renderWithChakra(
+ render(
+ createElement(
+ WalletGuard,
+ {
+ chainId: 10,
+ renderSwitchChain: ({ chainName, onSwitch }) =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'switch-chain-btn', onClick: onSwitch },
+ `Switch to ${chainName}`,
+ ),
+ },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ const switchBtn = screen.getByTestId('switch-chain-btn')
+ expect(switchBtn).toBeInTheDocument()
+ expect(switchBtn).toHaveTextContent('Switch to OP Mainnet')
+ expect(screen.queryByTestId('protected-content')).toBeNull()
+
+ await user.click(switchBtn)
+ expect(mockSwitchChain).toHaveBeenCalledWith(10)
+ })
+
+ it('renders nothing when needsChainSwitch and no renderSwitchChain provided', () => {
+ mockedUseWallet.mockReturnValue({
+ ...makeWalletReady(),
+ needsConnect: false,
+ needsChainSwitch: true,
+ isReady: false,
+ })
+
+ const { container } = render(
createElement(
WalletGuard,
{ chainId: 10 },
@@ -143,16 +187,14 @@ describe('WalletGuard', () => {
),
)
- expect(screen.getByTestId('switch-chain-button')).toBeInTheDocument()
- expect(screen.getByText(/Switch to/)).toBeInTheDocument()
- expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument()
+ expect(container.innerHTML).toBe('')
expect(screen.queryByTestId('protected-content')).toBeNull()
})
it('renders children when wallet is ready', () => {
mockedUseWallet.mockReturnValue(makeWalletReady())
- renderWithChakra(
+ render(
createElement(
WalletGuard,
null,
@@ -174,7 +216,7 @@ describe('WalletGuard', () => {
},
})
- renderWithChakra(
+ render(
createElement(
WalletGuard,
{ chainId: 10 },
@@ -216,7 +258,7 @@ describe('WalletGuard multi-chain (require prop)', () => {
const requirements: WalletRequirement[] = [{ chainType: 'evm' }, { chainType: 'svm' }]
- renderWithChakra(
+ render(
createElement(
WalletGuard,
{ require: requirements },
@@ -225,10 +267,9 @@ describe('WalletGuard multi-chain (require prop)', () => {
)
expect(screen.getByTestId('protected-content')).toBeInTheDocument()
- expect(screen.queryByTestId('connect-wallet-button')).toBeNull()
})
- it('renders fallback when first requirement is not met', () => {
+ it('renders renderConnect when first requirement is not met', () => {
mockedUseMultiWallet.mockReturnValue({
wallets: {},
getWallet: vi.fn(() => undefined),
@@ -238,7 +279,37 @@ describe('WalletGuard multi-chain (require prop)', () => {
const requirements: WalletRequirement[] = [{ chainType: 'evm' }]
- renderWithChakra(
+ render(
+ createElement(
+ WalletGuard,
+ {
+ require: requirements,
+ renderConnect: () =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'multi-connect' },
+ 'Connect EVM',
+ ),
+ },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(screen.getByTestId('multi-connect')).toBeInTheDocument()
+ expect(screen.queryByTestId('protected-content')).toBeNull()
+ })
+
+ it('renders nothing when requirement not met and no renderConnect', () => {
+ mockedUseMultiWallet.mockReturnValue({
+ wallets: {},
+ getWallet: vi.fn(() => undefined),
+ getWalletByChainId: vi.fn(() => undefined),
+ connectedAddresses: {},
+ })
+
+ const requirements: WalletRequirement[] = [{ chainType: 'evm' }]
+
+ const { container } = render(
createElement(
WalletGuard,
{ require: requirements },
@@ -246,11 +317,71 @@ describe('WalletGuard multi-chain (require prop)', () => {
),
)
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
+ expect(container.innerHTML).toBe('')
expect(screen.queryByTestId('protected-content')).toBeNull()
})
- it('renders fallback for second unmet requirement when first is met', () => {
+ it('renders renderSwitchChain for multi-chain when needsChainSwitch', async () => {
+ const user = userEvent.setup()
+ const evmWallet = {
+ ...makeWalletReady(),
+ needsChainSwitch: true,
+ isReady: false,
+ }
+
+ mockedUseMultiWallet.mockReturnValue({
+ wallets: { evm: evmWallet },
+ getWallet: vi.fn(() => undefined),
+ getWalletByChainId: vi.fn((chainId: string | number) => {
+ if (String(chainId) === '10') {
+ return evmWallet
+ }
+ return undefined
+ }),
+ connectedAddresses: { evm: '0xabc' },
+ })
+
+ mockedUseChainRegistry.mockReturnValue({
+ getChain: vi.fn(() => ({
+ name: 'OP Mainnet',
+ chainId: 10,
+ caip2Id: 'eip155:10',
+ chainType: 'evm',
+ nativeCurrency: { symbol: 'ETH', decimals: 18 },
+ addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
+ })),
+ getChainByCaip2: vi.fn(() => null),
+ getChainType: vi.fn(() => null),
+ getChainsByType: vi.fn(() => []),
+ getAllChains: vi.fn(() => []),
+ })
+
+ const requirements: WalletRequirement[] = [{ chainId: 10 }]
+
+ render(
+ createElement(
+ WalletGuard,
+ {
+ require: requirements,
+ renderSwitchChain: ({ chainName, onSwitch }) =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'multi-switch', onClick: onSwitch },
+ `Switch to ${chainName}`,
+ ),
+ },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ const switchBtn = screen.getByTestId('multi-switch')
+ expect(switchBtn).toHaveTextContent('Switch to OP Mainnet')
+
+ await user.click(switchBtn)
+ expect(mockSwitchChain).toHaveBeenCalledWith(10)
+ })
+
+ it('renders renderConnect for second unmet requirement when first is met', () => {
const evmWallet = makeWalletReady()
mockedUseMultiWallet.mockReturnValue({
@@ -267,15 +398,23 @@ describe('WalletGuard multi-chain (require prop)', () => {
const requirements: WalletRequirement[] = [{ chainType: 'evm' }, { chainType: 'svm' }]
- renderWithChakra(
+ render(
createElement(
WalletGuard,
- { require: requirements },
+ {
+ require: requirements,
+ renderConnect: () =>
+ createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'multi-connect' },
+ 'Connect SVM',
+ ),
+ },
createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
),
)
- expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument()
+ expect(screen.getByTestId('multi-connect')).toBeInTheDocument()
expect(screen.queryByTestId('protected-content')).toBeNull()
})
})
diff --git a/src/sdk/react/components/WalletGuard.tsx b/src/sdk/react/components/WalletGuard.tsx
index 9c3b62f6..eee55fef 100644
--- a/src/sdk/react/components/WalletGuard.tsx
+++ b/src/sdk/react/components/WalletGuard.tsx
@@ -1,6 +1,4 @@
import type { FC, ReactElement, ReactNode } from 'react'
-import SwitchChainButton from '@/src/wallet/components/SwitchChainButton'
-import { ConnectWalletButton } from '@/src/wallet/providers'
import { useChainRegistry, useMultiWallet, useWallet } from '../hooks'
/** A single wallet requirement for multi-chain gating. */
@@ -13,10 +11,18 @@ export interface WalletRequirement {
label?: string
}
+/** Props passed to the renderSwitchChain render prop. */
+export interface SwitchChainRenderProps {
+ chainId: string | number
+ chainName: string
+ onSwitch: () => void
+}
+
export interface WalletGuardProps {
chainId?: string | number
chainType?: string
children?: ReactNode
+ /** @deprecated Use renderConnect instead. Kept for backward compatibility. */
fallback?: ReactElement
/**
* Multi-chain requirements. When provided, the guard checks each requirement
@@ -24,13 +30,17 @@ export interface WalletGuardProps {
* top-level chainId/chainType props.
*/
require?: WalletRequirement[]
+ /** Render prop for the connect wallet UI. Called when wallet needs connection. */
+ renderConnect?: () => ReactElement
+ /** Render prop for the switch chain UI. Called when wallet is on wrong chain. */
+ renderSwitchChain?: (props: SwitchChainRenderProps) => ReactElement
switchChainLabel?: string
}
/**
* Gates content on wallet connection and correct chain.
- * Shows ConnectWalletButton when disconnected, SwitchChainButton when on wrong chain,
- * or renders children when ready.
+ * Headless component: uses render props for connect/switch UI.
+ * Returns null when no render prop is provided for the needed state.
*
* Supports two mutually exclusive modes:
* - **Single-chain** (chainId/chainType props): uses useWallet for one adapter
@@ -38,13 +48,20 @@ export interface WalletGuardProps {
*
* @precondition Either `require` or `chainId`/`chainType` should be provided, not both
* @postcondition Renders children only when all wallet requirements are satisfied
- * @throws Never — renders fallback UI instead of throwing
+ * @throws Never — renders fallback UI or null instead of throwing
*/
export const WalletGuard: FC = (props) => {
const { require: requirements, children } = props
if (requirements && requirements.length > 0) {
- return {children}
+ return (
+
+ {children}
+
+ )
}
return
@@ -59,34 +76,40 @@ const SingleChainGuard: FC = ({
chainId,
chainType,
children,
- fallback = (
-
- ),
- switchChainLabel = 'Switch to',
+ fallback,
+ renderConnect,
+ renderSwitchChain,
}) => {
const wallet = useWallet({ chainId, chainType })
const registry = useChainRegistry()
if (wallet.needsConnect) {
- return fallback
+ if (renderConnect) {
+ return renderConnect()
+ }
+ if (fallback) {
+ return fallback
+ }
+ return null
}
if (wallet.needsChainSwitch && chainId !== undefined) {
- const targetChain = registry.getChain(chainId)
- return (
- wallet.switchChain(chainId)}>
- {switchChainLabel} {targetChain?.name ?? String(chainId)}
-
- )
+ if (renderSwitchChain) {
+ const targetChain = registry.getChain(chainId)
+ const chainName = targetChain?.name ?? String(chainId)
+ return renderSwitchChain({
+ chainId,
+ chainName,
+ onSwitch: () => wallet.switchChain(chainId),
+ })
+ }
+ return null
}
return children
}
-interface MultiChainGuardProps {
+interface MultiChainGuardProps extends WalletGuardProps {
requirements: WalletRequirement[]
children?: ReactNode
}
@@ -97,7 +120,12 @@ interface MultiChainGuardProps {
* @precondition useMultiWallet hook is available via provider context
* @postcondition Renders children only when every requirement has a connected wallet
*/
-const MultiChainGuard: FC = ({ requirements, children }) => {
+const MultiChainGuard: FC = ({
+ requirements,
+ children,
+ renderConnect,
+ renderSwitchChain,
+}) => {
const { getWallet, getWalletByChainId } = useMultiWallet()
const registry = useChainRegistry()
@@ -110,22 +138,24 @@ const MultiChainGuard: FC = ({ requirements, children }) =
: undefined
if (!wallet || wallet.needsConnect) {
- return (
-
- )
+ if (renderConnect) {
+ return renderConnect()
+ }
+ return null
}
const requirementChainId = requirement.chainId
if (wallet.needsChainSwitch && requirementChainId !== undefined) {
- const targetChain = registry.getChain(requirementChainId)
- return (
- wallet.switchChain(requirementChainId)}>
- Switch to {targetChain?.name ?? String(requirementChainId)}
-
- )
+ if (renderSwitchChain) {
+ const targetChain = registry.getChain(requirementChainId)
+ const chainName = targetChain?.name ?? String(requirementChainId)
+ return renderSwitchChain({
+ chainId: requirementChainId,
+ chainName,
+ onSwitch: () => wallet.switchChain(requirementChainId),
+ })
+ }
+ return null
}
}
diff --git a/src/sdk/react/components/index.ts b/src/sdk/react/components/index.ts
index 88c99303..f0f796c0 100644
--- a/src/sdk/react/components/index.ts
+++ b/src/sdk/react/components/index.ts
@@ -1,3 +1,4 @@
+export type { ConnectWalletButtonRenderProps } from './ConnectWalletButton'
export { ConnectWalletButton } from './ConnectWalletButton'
-export type { WalletGuardProps, WalletRequirement } from './WalletGuard'
+export type { SwitchChainRenderProps, WalletGuardProps, WalletRequirement } from './WalletGuard'
export { WalletGuard } from './WalletGuard'
diff --git a/src/wallet/providers.ts b/src/wallet/providers.ts
index fde1a54f..79db3394 100644
--- a/src/wallet/providers.ts
+++ b/src/wallet/providers.ts
@@ -1 +1 @@
-export { ConnectWalletButton } from '../sdk/react/components/ConnectWalletButton'
+export { ConnectWalletButton } from '../chakra/ConnectWalletButton'
From 18b9c1f2668408a08b2b75510e64d56a2dc16df3 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 13:18:46 +0200
Subject: [PATCH 19/52] fix: remove cross-domain Avatar import from RainbowKit
connector
---
src/sdk/react/evm/connectors/rainbowkit.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/sdk/react/evm/connectors/rainbowkit.tsx b/src/sdk/react/evm/connectors/rainbowkit.tsx
index 76866eb2..dea94e85 100644
--- a/src/sdk/react/evm/connectors/rainbowkit.tsx
+++ b/src/sdk/react/evm/connectors/rainbowkit.tsx
@@ -1,4 +1,3 @@
-import type { AvatarComponent } from '@rainbow-me/rainbowkit'
import {
getDefaultConfig,
RainbowKitProvider,
@@ -10,11 +9,10 @@ import '@rainbow-me/rainbowkit/styles.css'
import type { FC, ReactNode } from 'react'
import type { Chain, Transport } from 'viem'
-import { Avatar as CustomAvatar } from '@/src/core/components'
import type { EvmConnectorConfig } from '../types'
const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => (
- {children}
+ {children}
)
function useConnectModal() {
From ca78f6af8e7ed63b2b829ca744065c73cab867d8 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 13:18:55 +0200
Subject: [PATCH 20/52] refactor: document React type-only import in core
adapters provider
---
src/sdk/core/adapters/provider.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/sdk/core/adapters/provider.ts b/src/sdk/core/adapters/provider.ts
index db115b58..140d5f72 100644
--- a/src/sdk/core/adapters/provider.ts
+++ b/src/sdk/core/adapters/provider.ts
@@ -3,6 +3,8 @@
* No runtime code — types only.
*/
+// Type-only import — erased at compile time. Used by WalletAdapterBundle.Provider
+// which must be JSX-renderable. No runtime React dependency.
import type { FC, ReactNode } from 'react'
import type { ChainDescriptor, EndpointConfig } from '../chain'
import type { TransactionLifecycle, WalletLifecycle } from './lifecycle'
From 9e9fca1a68e020e00723cd93e74423751b37aff8 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 14:28:29 +0200
Subject: [PATCH 21/52] chore: delete remaining deprecated hooks and dead
wallet types
---
src/chakra/WalletGuard.tsx | 5 +-
.../Examples/demos/SwitchNetwork/index.tsx | 2 +-
src/sdk/react/components/WalletGuard.test.tsx | 30 ----
src/sdk/react/components/WalletGuard.tsx | 6 -
.../react/provider/DAppBoosterProvider.tsx | 2 +-
src/wallet/hooks.ts | 2 -
src/wallet/hooks/useWalletStatus.test.ts | 160 ------------------
src/wallet/hooks/useWalletStatus.ts | 40 -----
src/wallet/hooks/useWeb3Status.tsx | 138 ---------------
src/wallet/types.ts | 7 -
10 files changed, 3 insertions(+), 389 deletions(-)
delete mode 100644 src/wallet/hooks.ts
delete mode 100644 src/wallet/hooks/useWalletStatus.test.ts
delete mode 100644 src/wallet/hooks/useWalletStatus.ts
delete mode 100644 src/wallet/hooks/useWeb3Status.tsx
delete mode 100644 src/wallet/types.ts
diff --git a/src/chakra/WalletGuard.tsx b/src/chakra/WalletGuard.tsx
index 3d6b07c8..b7343abb 100644
--- a/src/chakra/WalletGuard.tsx
+++ b/src/chakra/WalletGuard.tsx
@@ -1,4 +1,4 @@
-import type { FC, ReactElement, ReactNode } from 'react'
+import type { FC, ReactNode } from 'react'
import {
WalletGuard as HeadlessWalletGuard,
type WalletRequirement,
@@ -10,7 +10,6 @@ interface ChakraWalletGuardProps {
chainId?: string | number
chainType?: string
children?: ReactNode
- fallback?: ReactElement
require?: WalletRequirement[]
switchChainLabel?: string
}
@@ -25,7 +24,6 @@ export const WalletGuard: FC = ({
chainId,
chainType,
children,
- fallback,
require: requirements,
switchChainLabel = 'Switch to',
}) => {
@@ -33,7 +31,6 @@ export const WalletGuard: FC = ({
(
{
const {
diff --git a/src/sdk/react/components/WalletGuard.test.tsx b/src/sdk/react/components/WalletGuard.test.tsx
index ce8e4cbb..b6514480 100644
--- a/src/sdk/react/components/WalletGuard.test.tsx
+++ b/src/sdk/react/components/WalletGuard.test.tsx
@@ -92,36 +92,6 @@ describe('WalletGuard', () => {
expect(screen.queryByTestId('protected-content')).toBeNull()
})
- it('renders deprecated fallback when provided and needsConnect (no renderConnect)', () => {
- render(
- createElement(
- WalletGuard,
- { fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Custom') },
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('custom-fallback')).toBeInTheDocument()
- expect(screen.queryByTestId('protected-content')).toBeNull()
- })
-
- it('prefers renderConnect over fallback when both provided', () => {
- render(
- createElement(
- WalletGuard,
- {
- renderConnect: () =>
- createElement('button', { type: 'button', 'data-testid': 'render-connect' }, 'RC'),
- fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Fallback'),
- },
- createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
- ),
- )
-
- expect(screen.getByTestId('render-connect')).toBeInTheDocument()
- expect(screen.queryByTestId('custom-fallback')).toBeNull()
- })
-
it('renders renderSwitchChain when needsChainSwitch with correct props', async () => {
const user = userEvent.setup()
mockedUseWallet.mockReturnValue({
diff --git a/src/sdk/react/components/WalletGuard.tsx b/src/sdk/react/components/WalletGuard.tsx
index eee55fef..5e072a2d 100644
--- a/src/sdk/react/components/WalletGuard.tsx
+++ b/src/sdk/react/components/WalletGuard.tsx
@@ -22,8 +22,6 @@ export interface WalletGuardProps {
chainId?: string | number
chainType?: string
children?: ReactNode
- /** @deprecated Use renderConnect instead. Kept for backward compatibility. */
- fallback?: ReactElement
/**
* Multi-chain requirements. When provided, the guard checks each requirement
* and renders children only when all are met. Mutually exclusive with
@@ -76,7 +74,6 @@ const SingleChainGuard: FC = ({
chainId,
chainType,
children,
- fallback,
renderConnect,
renderSwitchChain,
}) => {
@@ -87,9 +84,6 @@ const SingleChainGuard: FC = ({
if (renderConnect) {
return renderConnect()
}
- if (fallback) {
- return fallback
- }
return null
}
diff --git a/src/sdk/react/provider/DAppBoosterProvider.tsx b/src/sdk/react/provider/DAppBoosterProvider.tsx
index 1653e39a..8ca20d61 100644
--- a/src/sdk/react/provider/DAppBoosterProvider.tsx
+++ b/src/sdk/react/provider/DAppBoosterProvider.tsx
@@ -46,7 +46,7 @@ function ConnectModalBridge({
*
* Automatically mounts each wallet bundle's Provider (wagmi, query client,
* wallet-specific UI provider) so the active connector is determined entirely
- * by the `config.wallets` you pass in — no hardcoded Web3Provider.
+ * by the `config.wallets` you pass in.
*
* Each bundle's `useConnectModal` hook is called via a bridge component inside
* the bundle's Provider tree. The resulting `open` functions are stored per
diff --git a/src/wallet/hooks.ts b/src/wallet/hooks.ts
deleted file mode 100644
index 9c9a67cc..00000000
--- a/src/wallet/hooks.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { useWalletStatus } from './hooks/useWalletStatus'
-export { useWeb3Status } from './hooks/useWeb3Status'
diff --git a/src/wallet/hooks/useWalletStatus.test.ts b/src/wallet/hooks/useWalletStatus.test.ts
deleted file mode 100644
index 567635ee..00000000
--- a/src/wallet/hooks/useWalletStatus.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useWalletStatus } from './useWalletStatus'
-
-// Mock useWeb3Status
-const mockSwitchChain = vi.fn()
-const mockDisconnect = vi.fn()
-
-vi.mock('./useWeb3Status', () => ({
- useWeb3Status: vi.fn(() => ({
- appChainId: 1,
- isWalletConnected: false,
- isWalletSynced: false,
- switchChain: mockSwitchChain,
- walletChainId: undefined,
- })),
-}))
-
-vi.mock('@/src/core/types', () => ({
- chains: [
- { id: 1, name: 'Ethereum' },
- { id: 10, name: 'OP Mainnet' },
- { id: 137, name: 'Polygon' },
- ],
-}))
-
-vi.mock('viem', async () => {
- const actual = await vi.importActual('viem')
- return {
- ...actual,
- extractChain: vi.fn(({ chains, id }) => {
- const chain = chains.find((c: { id: number }) => c.id === id)
- if (!chain) {
- throw new Error(`Chain with id ${id} not found`)
- }
- return chain
- }),
- }
-})
-
-// Import after mocks are set up
-const { useWeb3Status } = await import('./useWeb3Status')
-const mockedUseWeb3Status = vi.mocked(useWeb3Status)
-
-const baseWeb3Status: ReturnType = {
- readOnlyClient: undefined,
- appChainId: 1,
- address: undefined,
- balance: undefined,
- connectingWallet: false,
- switchingChain: false,
- isWalletConnected: false,
- walletClient: undefined,
- isWalletSynced: false,
- walletChainId: undefined,
- switchChain: mockSwitchChain,
- disconnect: mockDisconnect,
-}
-
-describe('useWalletStatus', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('returns needsConnect when wallet is not connected', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: false,
- isWalletSynced: false,
- walletChainId: undefined,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.needsConnect).toBe(true)
- expect(result.current.needsChainSwitch).toBe(false)
- expect(result.current.isReady).toBe(false)
- })
-
- it('returns needsChainSwitch when connected but on wrong chain', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: false,
- walletChainId: 137,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.needsConnect).toBe(false)
- expect(result.current.needsChainSwitch).toBe(true)
- expect(result.current.isReady).toBe(false)
- expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' })
- expect(result.current.targetChainId).toBe(1)
- })
-
- it('returns isReady when connected and on correct chain', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: true,
- walletChainId: 1,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.needsConnect).toBe(false)
- expect(result.current.needsChainSwitch).toBe(false)
- expect(result.current.isReady).toBe(true)
- })
-
- it('uses provided chainId over appChainId', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: true,
- walletChainId: 1,
- })
-
- const { result } = renderHook(() => useWalletStatus({ chainId: 10 }))
-
- expect(result.current.needsChainSwitch).toBe(true)
- expect(result.current.isReady).toBe(false)
- expect(result.current.targetChain).toEqual({ id: 10, name: 'OP Mainnet' })
- expect(result.current.targetChainId).toBe(10)
- })
-
- it('falls back to chains[0].id when no chainId or appChainId', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: undefined as unknown as ReturnType['appChainId'],
- isWalletConnected: true,
- isWalletSynced: false,
- walletChainId: 137,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' })
- })
-
- it('switchChain calls through to useWeb3Status switchChain', () => {
- mockedUseWeb3Status.mockReturnValue({
- ...baseWeb3Status,
- appChainId: 1,
- isWalletConnected: true,
- isWalletSynced: false,
- walletChainId: 137,
- })
-
- const { result } = renderHook(() => useWalletStatus())
-
- result.current.switchChain(10)
- expect(mockSwitchChain).toHaveBeenCalledWith(10)
- })
-})
diff --git a/src/wallet/hooks/useWalletStatus.ts b/src/wallet/hooks/useWalletStatus.ts
deleted file mode 100644
index 0f495c3f..00000000
--- a/src/wallet/hooks/useWalletStatus.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { Chain } from 'viem'
-import { extractChain } from 'viem'
-
-import { type ChainsIds, chains } from '@/src/core/types'
-import { useWeb3Status } from './useWeb3Status'
-
-interface UseWalletStatusOptions {
- chainId?: ChainsIds
-}
-
-interface WalletStatus {
- isReady: boolean
- needsConnect: boolean
- needsChainSwitch: boolean
- targetChain: Chain
- targetChainId: ChainsIds
- switchChain: (chainId: ChainsIds) => void
-}
-
-/** @deprecated Use {@link useWallet} from `@/src/sdk/react/hooks` instead. */
-export const useWalletStatus = (options?: UseWalletStatusOptions): WalletStatus => {
- const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } =
- useWeb3Status()
-
- const targetChainId = options?.chainId || appChainId || chains[0].id
- const targetChain = extractChain({ chains, id: targetChainId })
-
- const needsConnect = !isWalletConnected
- const needsChainSwitch = isWalletConnected && (!isWalletSynced || walletChainId !== targetChainId)
- const isReady = isWalletConnected && !needsChainSwitch
-
- return {
- isReady,
- needsConnect,
- needsChainSwitch,
- targetChain,
- targetChainId,
- switchChain,
- }
-}
diff --git a/src/wallet/hooks/useWeb3Status.tsx b/src/wallet/hooks/useWeb3Status.tsx
deleted file mode 100644
index 8d0c7f72..00000000
--- a/src/wallet/hooks/useWeb3Status.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import type { Address, Chain } from 'viem'
-import {
- type UseBalanceReturnType,
- type UsePublicClientReturnType,
- type UseWalletClientReturnType,
- useAccount,
- useBalance,
- useChainId,
- useDisconnect,
- usePublicClient,
- useSwitchChain,
- useWalletClient,
-} from 'wagmi'
-
-import { type ChainsIds, chains } from '@/src/core/types'
-
-export type AppWeb3Status = {
- readOnlyClient: UsePublicClientReturnType
- appChainId: ChainsIds
-}
-
-export type WalletWeb3Status = {
- address: Address | undefined
- balance?: UseBalanceReturnType['data'] | undefined
- connectingWallet: boolean
- switchingChain: boolean
- isWalletConnected: boolean
- walletClient: UseWalletClientReturnType['data']
- isWalletSynced: boolean
- walletChainId: Chain['id'] | undefined
-}
-
-export type Web3Actions = {
- switchChain: (chainId?: ChainsIds) => void
- disconnect: () => void
-}
-
-export type Web3Status = AppWeb3Status & WalletWeb3Status & Web3Actions
-
-/**
- * Custom hook that provides comprehensive Web3 connection state and actions.
- *
- * Aggregates various Wagmi hooks to provide a unified interface for Web3 state management,
- * including wallet connection status, chain information, and common actions.
- *
- * The hook provides three categories of data:
- * - App Web3 Status: Information about the app's current blockchain context
- * - Wallet Web3 Status: Information about the connected wallet
- * - Web3 Actions: Functions to modify connection state
- *
- * @returns {Web3Status} Combined object containing:
- * @returns {UsePublicClientReturnType} returns.readOnlyClient - Public client for read operations
- * @returns {ChainsIds} returns.appChainId - Current chain ID of the application
- * @returns {Address|undefined} returns.address - Connected wallet address (if any)
- * @returns {UseBalanceReturnType['data']|undefined} returns.balance - Wallet balance information
- * @returns {boolean} returns.connectingWallet - Indicates if wallet connection is in progress
- * @returns {boolean} returns.switchingChain - Indicates if chain switching is in progress
- * @returns {boolean} returns.isWalletConnected - Whether a wallet is currently connected
- * @returns {UseWalletClientReturnType['data']} returns.walletClient - Wallet client for write operations
- * @returns {boolean} returns.isWalletSynced - Whether wallet chain matches app chain
- * @returns {Chain['id']|undefined} returns.walletChainId - Current chain ID of connected wallet
- * @returns {Function} returns.switchChain - Function to switch to a different chain
- * @returns {Function} returns.disconnect - Function to disconnect wallet
- *
- * @deprecated Use {@link useWallet} or `useChainRegistry` from `@/src/sdk/react/hooks` instead.
- *
- * @example
- * ```tsx
- * const {
- * address,
- * balance,
- * isWalletConnected,
- * appChainId,
- * switchChain,
- * disconnect
- * } = useWeb3Status();
- *
- * return (
- *
- * {isWalletConnected ? (
- * <>
- *
Connected to: {address}
- *
Balance: {balance?.formatted} {balance?.symbol}
- *
- *
- * >
- * ) : (
- *
Wallet not connected
- * )}
- *
- * );
- * ```
- */
-export const useWeb3Status = () => {
- const {
- address,
- chainId: walletChainId,
- isConnected: isWalletConnected,
- isConnecting: connectingWallet,
- } = useAccount()
- const appChainId = useChainId() as ChainsIds
- const { isPending: switchingChain, switchChain } = useSwitchChain()
- const readOnlyClient = usePublicClient()
- const { data: walletClient } = useWalletClient()
- const { data: balance } = useBalance()
- const { disconnect } = useDisconnect()
-
- const isWalletSynced = isWalletConnected && walletChainId === appChainId
-
- const appWeb3Status: AppWeb3Status = {
- readOnlyClient,
- appChainId,
- }
-
- const walletWeb3Status: WalletWeb3Status = {
- address,
- balance,
- isWalletConnected,
- connectingWallet,
- switchingChain,
- walletClient,
- isWalletSynced,
- walletChainId,
- }
-
- const web3Actions: Web3Actions = {
- switchChain: (chainId: number = chains[0].id) => switchChain({ chainId }), // default to the first chain in the config
- disconnect: disconnect,
- }
-
- const web3Connection: Web3Status = {
- ...appWeb3Status,
- ...walletWeb3Status,
- ...web3Actions,
- }
-
- return web3Connection
-}
diff --git a/src/wallet/types.ts b/src/wallet/types.ts
deleted file mode 100644
index 19e65e8a..00000000
--- a/src/wallet/types.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export type { Networks } from './components/SwitchNetwork'
-export type {
- AppWeb3Status,
- WalletWeb3Status,
- Web3Actions,
- Web3Status,
-} from './hooks/useWeb3Status'
From a6ff03749b862553bc66564cd71e4887244bdb28 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 15:38:27 +0200
Subject: [PATCH 22/52] fix: return null instead of throwing on invalid hash in
getExplorerLink
---
.../home/Examples/demos/HashHandling/Hash.tsx | 2 +-
src/components/sharedComponents/ExplorerLink.tsx | 2 +-
src/core/ui/ExplorerLink.tsx | 2 +-
src/core/utils/getExplorerLink.ts | 8 ++++++--
src/utils/getExplorerLink.test.ts | 9 ++++-----
src/utils/getExplorerLink.ts | 2 +-
6 files changed, 14 insertions(+), 11 deletions(-)
diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx
index cfe50ea5..c29537cd 100644
--- a/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx
+++ b/src/components/pageComponents/home/Examples/demos/HashHandling/Hash.tsx
@@ -44,7 +44,7 @@ const Hash: FC = ({ chain, hash, truncatedHashLength }) => {
borderRadius="8px"
color="var(--theme-hash-color)"
cursor="default"
- explorerURL={getExplorerLink({ chain, hashOrAddress: hash })}
+ explorerURL={getExplorerLink({ chain, hashOrAddress: hash }) ?? ''}
fontSize="14px"
hash={hash}
minHeight="64px"
diff --git a/src/components/sharedComponents/ExplorerLink.tsx b/src/components/sharedComponents/ExplorerLink.tsx
index f342e87d..384e2254 100644
--- a/src/components/sharedComponents/ExplorerLink.tsx
+++ b/src/components/sharedComponents/ExplorerLink.tsx
@@ -34,7 +34,7 @@ export const ExplorerLink: FC = ({
}: ExplorerLinkProps) => {
return (
diff --git a/src/core/ui/ExplorerLink.tsx b/src/core/ui/ExplorerLink.tsx
index 120ab0be..3b9e15fc 100644
--- a/src/core/ui/ExplorerLink.tsx
+++ b/src/core/ui/ExplorerLink.tsx
@@ -34,7 +34,7 @@ export const ExplorerLink: FC = ({
}: ExplorerLinkProps) => {
return (
diff --git a/src/core/utils/getExplorerLink.ts b/src/core/utils/getExplorerLink.ts
index 17237132..6f330544 100644
--- a/src/core/utils/getExplorerLink.ts
+++ b/src/core/utils/getExplorerLink.ts
@@ -42,7 +42,11 @@ export type GetExplorerUrlParams = {
* // Returns: "https://optimistic.etherscan.io/tx/0x123...abc"
* ```
*/
-export const getExplorerLink = ({ chain, explorerUrl, hashOrAddress }: GetExplorerUrlParams) => {
+export const getExplorerLink = ({
+ chain,
+ explorerUrl,
+ hashOrAddress,
+}: GetExplorerUrlParams): string | null => {
if (isAddress(hashOrAddress)) {
return explorerUrl
? `${explorerUrl}/address/${hashOrAddress}`
@@ -54,5 +58,5 @@ export const getExplorerLink = ({ chain, explorerUrl, hashOrAddress }: GetExplor
: `${chain.blockExplorers?.default.url}/tx/${hashOrAddress}`
}
- throw new Error('Invalid hash or address')
+ return null
}
diff --git a/src/utils/getExplorerLink.test.ts b/src/utils/getExplorerLink.test.ts
index 8a052a1b..377a94c4 100644
--- a/src/utils/getExplorerLink.test.ts
+++ b/src/utils/getExplorerLink.test.ts
@@ -32,11 +32,10 @@ describe('getExplorerLink', () => {
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('returns null for an invalid hash or address', () => {
+ // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid input
+ const result = getExplorerLink({ chain, hashOrAddress: 'not-valid' as any })
+ expect(result).toBeNull()
})
it('throws when chain has no block explorer and no explorerUrl is provided', () => {
diff --git a/src/utils/getExplorerLink.ts b/src/utils/getExplorerLink.ts
index 50e4fb3e..dc7fc5ae 100644
--- a/src/utils/getExplorerLink.ts
+++ b/src/utils/getExplorerLink.ts
@@ -57,5 +57,5 @@ export const getExplorerLink = ({ chain, explorerUrl, hashOrAddress }: GetExplor
return `${baseUrl}/tx/${hashOrAddress}`
}
- throw new Error('Invalid hash or address')
+ return null
}
From d4fa503e9fd6ab3075e1da64e290ba08c15efa47 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 15:42:55 +0200
Subject: [PATCH 23/52] fix: extract tx hash from transaction object in hash
handling demo
---
.../home/Examples/demos/HashHandling/index.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx
index 63eea909..b0eb7cd5 100644
--- a/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.tsx
@@ -187,7 +187,14 @@ const HashHandling = ({ ...restProps }) => {
)}
From 7555281d3eac2b73164a2053be24f13a9b34aff4 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 16:04:10 +0200
Subject: [PATCH 24/52] fix: show friendly message instead of raw viem error in
ENS demo
---
.../pageComponents/home/Examples/demos/EnsName/index.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx
index 90201aae..3191afac 100644
--- a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx
@@ -9,7 +9,7 @@ import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/d
import { Spinner } from '@/src/core/components'
const EnsNameSearch = ({ address }: { address?: Address }) => {
- const { data, error, status } = useEnsName({
+ const { data, status } = useEnsName({
address: address,
chainId: mainnet.id,
})
@@ -19,7 +19,7 @@ const EnsNameSearch = ({ address }: { address?: Address }) => {
{status === 'pending' ? (
) : status === 'error' ? (
- `Error fetching ENS name (${error.message})`
+ 'ENS resolution unavailable'
) : data === undefined || data === null ? (
'Not available'
) : (
From a49a4e12ecb115af668d8c05ea173805f0bc447b Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 16:19:34 +0200
Subject: [PATCH 25/52] fix: add BigInt.toJSON polyfill to prevent React 19 dev
mode serialization crash
---
src/main.tsx | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/main.tsx b/src/main.tsx
index a246ed66..c15a44ca 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,13 @@ import { createRouter, RouterProvider } from '@tanstack/react-router'
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
+// BigInt is not serializable by JSON.stringify. React 19 dev mode tries to serialize
+// component props for dev tools logging, which crashes when props contain BigInt values
+// (e.g., TransactionParams with value: parseEther('0.1')). This polyfill prevents the crash.
+;(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
+ return this.toString()
+}
+
import NotFound404 from '@/src/components/pageComponents/NotFound404'
import { routeTree } from '@/src/routeTree.gen'
import { printAppInfo } from '@/src/utils/printAppInfo'
From 183fc7f15ff53764e4aa6976ce6fc4005616cba3 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 16:22:43 +0200
Subject: [PATCH 26/52] fix: use viem shortMessage in prepare error reason for
user-friendly toast display
---
src/sdk/core/evm/transaction.ts | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts
index 0c0d545b..487c4076 100644
--- a/src/sdk/core/evm/transaction.ts
+++ b/src/sdk/core/evm/transaction.ts
@@ -144,11 +144,18 @@ export function createEvmTransactionAdapter(
},
}
} catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- if (message.includes('insufficient funds')) {
+ const error = err instanceof Error ? err : new Error(String(err))
+ const fullMessage = error.message
+ if (fullMessage.includes('insufficient funds')) {
throw new InsufficientFundsError()
}
- return { ready: false, reason: message }
+ // Prefer viem's shortMessage for user-friendly display
+ const reason =
+ 'shortMessage' in error &&
+ typeof (error as Record).shortMessage === 'string'
+ ? ((error as Record).shortMessage as string)
+ : fullMessage
+ return { ready: false, reason }
}
},
From 6514228339abb82f57d0f74d67d6d0421e08d04b Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 16:53:05 +0200
Subject: [PATCH 27/52] feat: add formatErrorMessage utility for user-friendly
blockchain error display
Port generic error formatting: extractViemErrorMessage (walks cause chain),
sanitizeErrorMessage (strips hex, addresses, technical blocks), and
formatErrorMessage (maps common patterns like user rejection, insufficient
funds, execution reverted). Wire into lifecycle toasts and transaction
adapter prepare() reason. Replace all inline shortMessage checks.
---
.../OptimismCrossDomainMessenger/index.tsx | 9 +-
src/sdk/core/errors/format.test.ts | 118 +++++++++++++
src/sdk/core/errors/format.ts | 156 ++++++++++++++++++
src/sdk/core/errors/index.ts | 6 +
src/sdk/core/evm/transaction.ts | 18 +-
.../createNotificationLifecycle.test.ts | 8 +-
.../lifecycle/createNotificationLifecycle.ts | 16 +-
7 files changed, 297 insertions(+), 34 deletions(-)
create mode 100644 src/sdk/core/errors/format.test.ts
create mode 100644 src/sdk/core/errors/format.ts
diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
index 36d060f0..fc08badd 100644
--- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx
@@ -12,6 +12,7 @@ import { buildCrossDomainMessageParams } from '@/src/contracts/hooks/useOPL1Cros
import { Hash, PrimaryButton, Spinner } from '@/src/core/components'
import { withSuspenseAndRetry } from '@/src/core/utils'
import { getExplorerUrl } from '@/src/sdk/core/chain/explorer'
+import { formatErrorMessage } from '@/src/sdk/core/errors/format'
import { useChainRegistry, useTransaction, useWallet } from '@/src/sdk/react/hooks'
/**
@@ -96,13 +97,7 @@ const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => {
'Deposit ETH'
)}
- {tx.error && (
-
- {'shortMessage' in tx.error
- ? (tx.error as { shortMessage: string }).shortMessage
- : tx.error.message}
-
- )}
+ {tx.error && {formatErrorMessage(tx.error)}}
{l2Hash && (
{
+ it('returns shortMessage from a viem-like error', () => {
+ const error = Object.assign(new Error('verbose message'), {
+ shortMessage: 'User rejected the request.',
+ })
+ expect(extractViemErrorMessage(error)).toBe('User rejected the request.')
+ })
+
+ it('returns details when shortMessage is absent', () => {
+ const error = Object.assign(new Error('verbose'), { details: 'execution reverted: 51' })
+ expect(extractViemErrorMessage(error)).toBe('execution reverted: 51')
+ })
+
+ it('walks the cause chain', () => {
+ const inner = Object.assign(new Error('inner'), {
+ shortMessage: 'User rejected the request.',
+ })
+ const outer = Object.assign(new Error('outer'), { cause: inner })
+ expect(extractViemErrorMessage(outer)).toBe('User rejected the request.')
+ })
+
+ it('returns null for plain Error', () => {
+ expect(extractViemErrorMessage(new Error('plain'))).toBeNull()
+ })
+
+ it('returns null for non-object', () => {
+ expect(extractViemErrorMessage('string error')).toBeNull()
+ expect(extractViemErrorMessage(null)).toBeNull()
+ expect(extractViemErrorMessage(undefined)).toBeNull()
+ })
+})
+
+describe('sanitizeErrorMessage', () => {
+ it('strips Request Arguments block', () => {
+ const msg = 'User rejected. Request Arguments: from: 0xabc to: 0xdef'
+ expect(sanitizeErrorMessage(msg)).toBe('User rejected.')
+ })
+
+ it('strips Contract Call block', () => {
+ const msg = 'Reverted with reason: 51 Contract Call: address: 0x123'
+ expect(sanitizeErrorMessage(msg)).toBe('Reverted with reason: 51')
+ })
+
+ it('strips viem version string', () => {
+ const msg = 'Some error Version: viem@2.47.6'
+ expect(sanitizeErrorMessage(msg)).toBe('Some error')
+ })
+
+ it('replaces long hex values with [...]', () => {
+ const msg = 'data: 0x3dbb202b000000000000000000000000589750ba rest'
+ expect(sanitizeErrorMessage(msg)).not.toContain('0x3dbb202b')
+ })
+
+ it('replaces 40-char hex addresses with [address]', () => {
+ const msg = 'from: 0x19433c47bF16f0D6E14Ff68ec4f5fafA2d9C8756 done'
+ expect(sanitizeErrorMessage(msg)).toContain('[address]')
+ expect(sanitizeErrorMessage(msg)).not.toContain('0x19433c47')
+ })
+
+ it('returns fallback for empty result', () => {
+ expect(sanitizeErrorMessage('0x1234567890abcdef1234567890abcdef12345678')).toBe(
+ 'An unexpected error occurred',
+ )
+ })
+})
+
+describe('formatErrorMessage', () => {
+ it('extracts shortMessage from viem errors and maps to friendly message', () => {
+ const error = Object.assign(new Error('verbose'), {
+ shortMessage: 'User rejected the request.',
+ })
+ expect(formatErrorMessage(error)).toBe('Transaction rejected by user')
+ })
+
+ it('maps user rejection patterns to friendly message', () => {
+ const error = new Error('User rejected the request.')
+ expect(formatErrorMessage(error)).toBe('Transaction rejected by user')
+ })
+
+ it('maps insufficient funds to friendly message', () => {
+ const error = new Error('insufficient funds for gas')
+ expect(formatErrorMessage(error)).toBe('Insufficient ETH for gas fees')
+ })
+
+ it('extracts revert reason from execution reverted', () => {
+ const error = new Error('execution reverted: 51')
+ expect(formatErrorMessage(error)).toBe('Transaction reverted: 51')
+ })
+
+ it('sanitizes verbose viem messages as fallback', () => {
+ const error = new Error(
+ 'Something failed Request Arguments: from: 0xabc data: 0x1234 Contract Call: address: 0xdef',
+ )
+ const result = formatErrorMessage(error)
+ expect(result).not.toContain('Request Arguments')
+ expect(result).not.toContain('Contract Call')
+ })
+
+ it('handles string errors', () => {
+ expect(formatErrorMessage('something broke')).toBe('something broke')
+ })
+
+ it('handles null/undefined', () => {
+ expect(formatErrorMessage(null)).toBe('An unexpected error occurred')
+ expect(formatErrorMessage(undefined)).toBe('An unexpected error occurred')
+ })
+
+ it('walks cause chain for nested viem errors', () => {
+ const inner = Object.assign(new Error('inner'), {
+ shortMessage: 'Connector not found.',
+ })
+ const outer = Object.assign(new Error('outer verbose'), { cause: inner })
+ expect(formatErrorMessage(outer)).toBe('Connector not found.')
+ })
+})
diff --git a/src/sdk/core/errors/format.ts b/src/sdk/core/errors/format.ts
new file mode 100644
index 00000000..bba9db68
--- /dev/null
+++ b/src/sdk/core/errors/format.ts
@@ -0,0 +1,156 @@
+/**
+ * User-friendly error message formatting for blockchain errors.
+ *
+ * Extracts clean messages from viem errors (shortMessage, details, cause chain),
+ * sanitizes verbose technical output, and maps common patterns to friendly text.
+ *
+ * Ported from covenant-interface's error library — generic parts only,
+ * no app-specific error selectors.
+ */
+
+/**
+ * Extracts shortMessage from viem errors, walking the cause chain if needed.
+ *
+ * @precondition error is any value (null-safe)
+ * @postcondition returns the first shortMessage or details found, or null
+ */
+export function extractViemErrorMessage(error: unknown): string | null {
+ if (!error || typeof error !== 'object') {
+ return null
+ }
+
+ const e = error as Record
+
+ if (typeof e.shortMessage === 'string' && e.shortMessage) {
+ return e.shortMessage
+ }
+
+ if (typeof e.details === 'string' && e.details) {
+ return e.details
+ }
+
+ if (e.cause && typeof e.cause === 'object') {
+ return extractViemErrorMessage(e.cause)
+ }
+
+ return null
+}
+
+/**
+ * Strips technical data (hex, addresses, viem internals) from error messages.
+ * Acts as a safety net so no raw data leaks to the user.
+ *
+ * @precondition message is a non-empty string
+ * @postcondition returns a sanitized string with technical data removed
+ */
+export function sanitizeErrorMessage(message: string): string {
+ let sanitized = message
+
+ const requestArgsIdx = sanitized.indexOf('Request Arguments:')
+ if (requestArgsIdx !== -1) {
+ sanitized = sanitized.substring(0, requestArgsIdx).trim()
+ }
+
+ const contractCallIdx = sanitized.indexOf('Contract Call:')
+ if (contractCallIdx !== -1) {
+ sanitized = sanitized.substring(0, contractCallIdx).trim()
+ }
+
+ sanitized = sanitized.replace(/Version:\s*viem@[\d.]+/g, '').trim()
+
+ // Strip hex values in length order: long calldata → addresses → shorter hex
+ sanitized = sanitized.replace(/0x[a-fA-F0-9]{41,}/g, '[...]').trim()
+ sanitized = sanitized.replace(/0x[a-fA-F0-9]{40}(?![a-fA-F0-9])/g, '[address]').trim()
+ sanitized = sanitized.replace(/0x[a-fA-F0-9]{9,39}(?![a-fA-F0-9])/g, '[...]').trim()
+ sanitized = sanitized.replace(/0x[a-fA-F0-9]{8}(?![a-fA-F0-9])/g, '[...]').trim()
+ sanitized = sanitized.replace(/\n{3,}/g, '\n').trim()
+
+ if (!sanitized || sanitized === '[...]' || sanitized === '[address]') {
+ return 'An unexpected error occurred'
+ }
+
+ return sanitized
+}
+
+/**
+ * Formats any error into a user-friendly message string.
+ *
+ * Priority:
+ * 1. Extract viem shortMessage (walks cause chain)
+ * 2. Map common patterns (user rejection, insufficient funds, gas, nonce)
+ * 3. Extract revert reason from "execution reverted: ..." pattern
+ * 4. Sanitize remaining verbose messages (strip hex, addresses, technical blocks)
+ *
+ * @precondition error is any value — string, Error, viem error, null, undefined
+ * @postcondition returns a clean, user-friendly string (never empty, never throws)
+ */
+export function formatErrorMessage(error: unknown): string {
+ if (error === null || error === undefined) {
+ return 'An unexpected error occurred'
+ }
+
+ if (typeof error === 'string') {
+ return mapCommonPatterns(error) ?? sanitizeErrorMessage(error)
+ }
+
+ if (typeof error !== 'object') {
+ return String(error)
+ }
+
+ // Try viem's shortMessage first (cleanest source)
+ const viemMessage = extractViemErrorMessage(error)
+ if (viemMessage) {
+ return mapCommonPatterns(viemMessage) ?? sanitizeErrorMessage(viemMessage)
+ }
+
+ // Fall back to Error.message
+ const message = error instanceof Error ? error.message : String(error)
+ return mapCommonPatterns(message) ?? sanitizeErrorMessage(message)
+}
+
+/**
+ * Maps common error patterns to user-friendly messages.
+ * Returns null if no pattern matches (caller should use sanitize as fallback).
+ */
+function mapCommonPatterns(message: string): string | null {
+ const lower = message.toLowerCase()
+
+ if (lower.includes('insufficient funds')) {
+ return 'Insufficient ETH for gas fees'
+ }
+
+ if (
+ lower.includes('user rejected') ||
+ lower.includes('user denied') ||
+ lower.includes('request was denied') ||
+ lower.includes('action_rejected')
+ ) {
+ return 'Transaction rejected by user'
+ }
+
+ if (lower.includes('gas required exceeds allowance')) {
+ return 'Transaction requires more gas than allowed'
+ }
+
+ if (lower.includes('execution reverted')) {
+ const revertMatch = message.match(/execution reverted: (.+)/)
+ if (revertMatch) {
+ return `Transaction reverted: ${sanitizeErrorMessage(revertMatch[1])}`
+ }
+ return 'Transaction reverted'
+ }
+
+ if (lower.includes('nonce too low')) {
+ return 'Transaction nonce is too low. Please try again.'
+ }
+
+ if (lower.includes('already known')) {
+ return 'Transaction already submitted'
+ }
+
+ if (lower.includes('replacement transaction underpriced')) {
+ return 'Transaction replacement fee too low'
+ }
+
+ return null
+}
diff --git a/src/sdk/core/errors/index.ts b/src/sdk/core/errors/index.ts
index 801d3807..d331d3ed 100644
--- a/src/sdk/core/errors/index.ts
+++ b/src/sdk/core/errors/index.ts
@@ -174,3 +174,9 @@ export class AmbiguousAdapterError extends Error {
this.availableChainTypes = [...availableChainTypes]
}
}
+
+// ---------------------------------------------------------------------------
+// Error formatting utilities
+// ---------------------------------------------------------------------------
+
+export { extractViemErrorMessage, formatErrorMessage, sanitizeErrorMessage } from './format'
diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts
index 487c4076..14246f0d 100644
--- a/src/sdk/core/evm/transaction.ts
+++ b/src/sdk/core/evm/transaction.ts
@@ -16,7 +16,12 @@ import type {
TransactionResult,
} from '../adapters/transaction'
import type { ChainSigner } from '../adapters/wallet'
-import { ChainNotSupportedError, InsufficientFundsError, InvalidSignerError } from '../errors'
+import {
+ ChainNotSupportedError,
+ formatErrorMessage,
+ InsufficientFundsError,
+ InvalidSignerError,
+} from '../errors'
import { fromViemChain } from './chains'
import type { EvmContractCall, EvmRawTransaction, EvmTransactionPayload } from './types'
@@ -145,17 +150,10 @@ export function createEvmTransactionAdapter(
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
- const fullMessage = error.message
- if (fullMessage.includes('insufficient funds')) {
+ if (error.message.includes('insufficient funds')) {
throw new InsufficientFundsError()
}
- // Prefer viem's shortMessage for user-friendly display
- const reason =
- 'shortMessage' in error &&
- typeof (error as Record).shortMessage === 'string'
- ? ((error as Record).shortMessage as string)
- : fullMessage
- return { ready: false, reason }
+ return { ready: false, reason: formatErrorMessage(error) }
}
},
diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts
index d529bf2c..f526cb05 100644
--- a/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts
+++ b/src/sdk/react/lifecycle/createNotificationLifecycle.test.ts
@@ -247,7 +247,7 @@ describe('createNotificationLifecycle', () => {
})
describe('shortMessage extraction for viem errors', () => {
- it('uses shortMessage when present on error', () => {
+ it('uses formatErrorMessage to extract friendly message from viem errors', () => {
const lifecycle = createNotificationLifecycle({ toaster: mockToaster })
lifecycle.onSubmit?.({ chainType: 'evm', id: '0x1', chainId: 1 })
@@ -259,7 +259,7 @@ describe('createNotificationLifecycle', () => {
expect(mockCreate).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
- description: 'User rejected the request',
+ description: 'Transaction rejected by user',
type: 'error',
}),
)
@@ -356,7 +356,7 @@ describe('createSigningNotificationLifecycle', () => {
)
})
- it('uses shortMessage from viem errors', () => {
+ it('uses formatErrorMessage to extract friendly message from viem errors', () => {
const lifecycle = createSigningNotificationLifecycle({ toaster: mockToaster })
lifecycle.onSign?.('message', { message: 'Hello' })
@@ -367,7 +367,7 @@ describe('createSigningNotificationLifecycle', () => {
expect(mockCreate).toHaveBeenNthCalledWith(
2,
- expect.objectContaining({ description: 'User rejected' }),
+ expect.objectContaining({ description: 'Transaction rejected by user' }),
)
})
diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
index a2099e7b..4d9c0af2 100644
--- a/src/sdk/react/lifecycle/createNotificationLifecycle.ts
+++ b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
@@ -2,6 +2,7 @@ import type { TransactionLifecycle, WalletLifecycle } from '../../core/adapters/
import type { TransactionRef, TransactionResult } from '../../core/adapters/transaction'
import { getExplorerUrl } from '../../core/chain/explorer'
import type { ChainRegistry } from '../../core/chain/registry'
+import { formatErrorMessage } from '../../core/errors/format'
/** Minimal interface for the toast notification API. */
export interface ToasterAPI {
@@ -48,17 +49,6 @@ export interface SigningNotificationLifecycleOptions {
messages?: SigningNotificationMessages
}
-/** Extracts the most user-friendly message from an error, preferring viem's shortMessage. */
-function extractErrorMessage(error: Error): string {
- if (
- 'shortMessage' in error &&
- typeof (error as Record).shortMessage === 'string'
- ) {
- return (error as Record).shortMessage as string
- }
- return error.message
-}
-
/**
* Builds an explorer URL suffix for a transaction, or empty string if unavailable.
*
@@ -118,7 +108,7 @@ export function createNotificationLifecycle({
},
onError(_phase, error) {
toaster.create({
- description: messages.error ?? extractErrorMessage(error),
+ description: messages.error ?? formatErrorMessage(error),
type: 'error',
...(toastId ? { id: toastId } : {}),
})
@@ -156,7 +146,7 @@ export function createSigningNotificationLifecycle({
},
onSignError(error) {
toaster.create({
- description: messages.error ?? extractErrorMessage(error),
+ description: messages.error ?? formatErrorMessage(error),
type: 'error',
...(toastId ? { id: toastId } : {}),
})
From 51dbac1f9535038a9e2d12f2786ceeec5cf68d22 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Wed, 8 Apr 2026 17:52:02 +0200
Subject: [PATCH 28/52] fix: switch ERC20 and native token demos from Sepolia
to Base Sepolia (Aave V3)
Aave V3 pool on Sepolia has frozen reserves. Switch all transaction
demos to Base Sepolia where the pool is active. Update USDC token
address, Pool address, and Faucet address to Base Sepolia deployments.
Add baseSepolia to the chain config.
---
.../ERC20ApproveAndTransferButton.tsx | 12 ++++--
.../MintUSDC.tsx | 10 ++---
.../ERC20ApproveAndTransferButton/index.tsx | 40 ++++++++++---------
.../demos/TransactionButton/NativeToken.tsx | 8 ++--
.../demos/TransactionButton/index.tsx | 4 +-
src/contracts/definitions.ts | 7 ++--
src/core/config/networks.config.ts | 13 +++++-
7 files changed, 56 insertions(+), 38 deletions(-)
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
index 6bce72d2..25ced5a7 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
@@ -37,14 +37,18 @@ const ERC20ApproveAndTransferButton: FC = ({
transferParams,
}) => {
const wallet = useWallet({ chainId: token.chainId })
- const address = wallet.status.activeAccount as Address
+ const address = wallet.status.activeAccount as Address | null
const registry = useChainRegistry()
- const { data: allowance, refetch: refetchAllowance } = useSuspenseReadErc20Allowance({
- address: token.address as Address, // TODO: token.address should be Address type
- args: [address, spender],
+ const { data: allowance = BigInt(0), refetch: refetchAllowance } = useSuspenseReadErc20Allowance({
+ address: token.address as Address,
+ args: [address ?? ('0x0000000000000000000000000000000000000000' as Address), spender],
})
+ if (!address) {
+ return null
+ }
+
const isApprovalRequired = allowance < amount
const approveParams: TransactionParams = {
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx
index 7810d947..3ad06132 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC.tsx
@@ -1,5 +1,5 @@
import type { Abi, Address } from 'viem'
-import { sepolia } from 'viem/chains'
+import { baseSepolia } from 'viem/chains'
import { AaveFaucetABI } from '@/src/contracts/abis/AaveFaucet'
import { getContract } from '@/src/contracts/definitions'
import type { TransactionParams } from '@/src/sdk/core'
@@ -8,13 +8,13 @@ import { useWallet } from '@/src/sdk/react/hooks'
import { TransactionButton } from '@/src/transactions/components'
export default function MintUSDC({ onSuccess }: { onSuccess: () => void }) {
- const wallet = useWallet({ chainId: sepolia.id })
+ const wallet = useWallet({ chainId: baseSepolia.id })
const address = wallet.status.activeAccount as Address
- const aaveContract = getContract('AaveFaucet', sepolia.id)
- const aaveUSDC = '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8'
+ const aaveContract = getContract('AaveFaucet', baseSepolia.id)
+ const aaveUSDC = '0xba50cd2a20f6da35d788639e581bca8d0b5d4d5f'
const mintParams: TransactionParams = {
- chainId: sepolia.id,
+ chainId: baseSepolia.id,
payload: {
contract: {
address: aaveContract.address,
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
index 43673440..b4f701f5 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
@@ -1,5 +1,5 @@
import { type Abi, type Address, formatUnits } from 'viem'
-import { sepolia } from 'viem/chains'
+import { baseSepolia } from 'viem/chains'
import BaseERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton'
import MintUSDC from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC'
import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
@@ -10,10 +10,10 @@ import type { EvmContractCall } from '@/src/sdk/core/evm/types'
import { useWallet } from '@/src/sdk/react/hooks'
import type { Token } from '@/src/tokens/types'
-// USDC token on Sepolia chain
-const tokenUSDC_sepolia: Token = {
- address: '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8',
- chainId: sepolia.id,
+// USDC token on Base Sepolia (Aave V3 testnet)
+const tokenUSDC: Token = {
+ address: '0xba50cd2a20f6da35d788639e581bca8d0b5d4d5f',
+ chainId: baseSepolia.id,
decimals: 6,
name: 'USD Coin',
symbol: 'USDC',
@@ -54,42 +54,46 @@ const ABIExample = [
/**
* This demo shows how to approve and send an ERC20 token transaction using the `TransactionButton` component.
*
- * Works only on Sepolia chain.
+ * Works only on Base Sepolia chain.
*/
const ERC20ApproveAndTransferButton = withSuspense(() => {
- const wallet = useWallet({ chainId: sepolia.id })
- const address = wallet.status.activeAccount as Address
+ const wallet = useWallet({ chainId: baseSepolia.id })
+ const address = wallet.status.activeAccount as Address | null
- const { data: balance, refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({
- address: tokenUSDC_sepolia.address as Address,
- args: [address],
+ const { data: balance = BigInt(0), refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({
+ address: tokenUSDC.address as Address,
+ args: [address ?? ('0x0000000000000000000000000000000000000000' as Address)],
})
- // AAVE staging contract pool address
- const spender = '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951'
+ if (!address) {
+ return null
+ }
+
+ // AAVE V3 Pool on Base Sepolia
+ const spender = '0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27'
const amount = BigInt(10000000000) // 10,000.00 USDC
const transferParams: TransactionParams = {
- chainId: sepolia.id,
+ chainId: baseSepolia.id,
payload: {
contract: {
address: spender,
abi: ABIExample as Abi,
functionName: 'supply',
- args: [tokenUSDC_sepolia.address as Address, amount, address, 0],
+ args: [tokenUSDC.address as Address, amount, address, 0],
},
} satisfies EvmContractCall,
}
const formattedAmount = formatNumberOrString(
- formatUnits(amount, tokenUSDC_sepolia.decimals),
+ formatUnits(amount, tokenUSDC.decimals),
NumberType.TokenTx,
)
return balance < amount ? (
@@ -101,7 +105,7 @@ const ERC20ApproveAndTransferButton = withSuspense(() => {
labelSending="Sending..."
onSuccess={() => refetchBalance()}
spender={spender}
- token={tokenUSDC_sepolia}
+ token={tokenUSDC}
transferParams={transferParams}
/>
)
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
index 0a0bdae0..17b9a1d7 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
@@ -2,7 +2,7 @@ import { Dialog } from '@chakra-ui/react'
import { type ReactElement, useState } from 'react'
import type { Address, TransactionReceipt } from 'viem'
import { parseEther } from 'viem'
-import { sepolia } from 'viem/chains'
+import { baseSepolia } from 'viem/chains'
import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
import { GeneralMessage, PrimaryButton } from '@/src/core/components'
import type { TransactionParams, TransactionResult } from '@/src/sdk/core'
@@ -13,11 +13,11 @@ import { TransactionButton } from '@/src/transactions/components'
/**
* This demo shows how to send a native token transaction.
*
- * Works only on Sepolia chain.
+ * Works only on Base Sepolia chain.
*/
const NativeToken = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
- const wallet = useWallet({ chainId: sepolia.id })
+ const wallet = useWallet({ chainId: baseSepolia.id })
const address = wallet.status.activeAccount as Address
const [minedMessage, setMinedMessage] = useState()
@@ -32,7 +32,7 @@ const NativeToken = () => {
}
const sendParams: TransactionParams = {
- chainId: sepolia.id,
+ chainId: baseSepolia.id,
payload: {
to: address,
value: parseEther('0.1'),
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
index c8918f36..a9305d2c 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.tsx
@@ -1,6 +1,6 @@
import { Flex } from '@chakra-ui/react'
import { useState } from 'react'
-import { sepolia } from 'viem/chains'
+import { baseSepolia } from 'viem/chains'
import { WalletGuard } from '@/src/chakra'
import { OptionsDropdown } from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown'
import ERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton'
@@ -17,7 +17,7 @@ const TransactionButton = () => {
]
return (
-
+
Date: Wed, 8 Apr 2026 18:14:45 +0200
Subject: [PATCH 29/52] fix: pass signer account to gas estimation in prepare()
to prevent zero address reverts
---
src/sdk/core/adapters/transaction.ts | 4 ++--
src/sdk/core/evm/transaction.ts | 7 ++++++-
src/sdk/react/hooks/useTransaction.ts | 12 +++++++++---
3 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/src/sdk/core/adapters/transaction.ts b/src/sdk/core/adapters/transaction.ts
index a137fd00..55f1a156 100644
--- a/src/sdk/core/adapters/transaction.ts
+++ b/src/sdk/core/adapters/transaction.ts
@@ -76,8 +76,8 @@ export interface TransactionAdapter {
readonly supportedChains: ChainDescriptor[]
readonly metadata: TransactionAdapterMetadata
- /** Validates and estimates a transaction before execution. */
- prepare(params: TransactionParams): Promise
+ /** Validates and estimates a transaction before execution. Signer is optional — used for accurate gas estimation. */
+ prepare(params: TransactionParams, signer?: ChainSigner): Promise
/** Submits the transaction using the provided signer. Returns a ref immediately. */
execute(params: TransactionParams, signer: ChainSigner): Promise
/** Polls until the transaction reaches a terminal state. */
diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts
index 14246f0d..75bed8fc 100644
--- a/src/sdk/core/evm/transaction.ts
+++ b/src/sdk/core/evm/transaction.ts
@@ -91,7 +91,7 @@ export function createEvmTransactionAdapter(
* @postcondition if ready === false -> reason explains why (human-readable)
* @throws {InsufficientFundsError} if balance too low for gas estimation
*/
- async prepare(params: TransactionParams): Promise {
+ async prepare(params: TransactionParams, signer?: ChainSigner): Promise {
const numericId =
typeof params.chainId === 'string' ? Number.parseInt(params.chainId, 10) : params.chainId
const publicClient = publicClients.get(numericId)
@@ -119,6 +119,9 @@ export function createEvmTransactionAdapter(
const payload = params.payload as EvmTransactionPayload
+ // Extract account from signer for gas estimation — prevents "approve from zero address" errors
+ const account = signer && isWalletClient(signer) ? signer.account : undefined
+
try {
const estimatedGas = isEvmContractCall(payload)
? await publicClient.estimateContractGas({
@@ -127,11 +130,13 @@ export function createEvmTransactionAdapter(
functionName: payload.contract.functionName,
args: payload.contract.args,
value: payload.value,
+ account: account ?? undefined,
})
: await publicClient.estimateGas({
to: (payload as EvmRawTransaction).to,
data: (payload as EvmRawTransaction).data,
value: (payload as EvmRawTransaction).value,
+ account: account ?? undefined,
})
const gasPrice = await publicClient.getGasPrice()
diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts
index 3cfd5780..998c21df 100644
--- a/src/sdk/react/hooks/useTransaction.ts
+++ b/src/sdk/react/hooks/useTransaction.ts
@@ -152,7 +152,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
}
setPhase('prepare')
- const prepared = await transactionAdapter.prepare(params)
+ const prepared = await transactionAdapter.prepare(params, signer)
setPrepareResult(prepared)
fireLifecycle('onPrepare', globalLifecycle, localLifecycle, prepared)
@@ -256,8 +256,14 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
throw new AdapterNotFoundError(params.chainId, 'transaction')
}
+ // Resolve signer for gas estimation (optional — prepare works without it)
+ const walletAdapter = Object.values(walletAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+ const prepareSigner = walletAdapter ? await walletAdapter.getSigner() : null
+
setPhase('prepare')
- const prepared = await transactionAdapter.prepare(params)
+ const prepared = await transactionAdapter.prepare(params, prepareSigner ?? undefined)
setPrepareResult(prepared)
fireLifecycle('onPrepare', globalLifecycle, localLifecycle, prepared)
@@ -283,7 +289,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
throw errorObj
}
},
- [transactionAdapters, globalLifecycle, localLifecycle],
+ [transactionAdapters, walletAdapters, globalLifecycle, localLifecycle],
)
/**
From b74d51d1009fd4ee2c89d2a2c0de77d2db4c458a Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:44:45 +0200
Subject: [PATCH 30/52] feat: expose resolveAdapters and accept explicit
adapters on useTransaction
Restores escape hatch levels 3 and 4 for the transaction surface.
resolveAdapters(chainId) gives raw adapter access for one-off
customization. Explicit transactionAdapter/walletAdapter options
bypass provider resolution entirely.
---
src/sdk/react/hooks/useTransaction.test.tsx | 138 +++++++++++++++++++-
src/sdk/react/hooks/useTransaction.ts | 128 ++++++++++--------
2 files changed, 214 insertions(+), 52 deletions(-)
diff --git a/src/sdk/react/hooks/useTransaction.test.tsx b/src/sdk/react/hooks/useTransaction.test.tsx
index b4965428..bbd835c1 100644
--- a/src/sdk/react/hooks/useTransaction.test.tsx
+++ b/src/sdk/react/hooks/useTransaction.test.tsx
@@ -8,7 +8,11 @@ import type {
TransactionParams,
} from '../../core/adapters/transaction'
import type { WalletAdapter } from '../../core/adapters/wallet'
-import { PreStepsNotExecutedError, TransactionNotReadyError } from '../../core/errors'
+import {
+ AdapterNotFoundError,
+ PreStepsNotExecutedError,
+ TransactionNotReadyError,
+} from '../../core/errors'
import { DAppBoosterProvider } from '../provider/DAppBoosterProvider'
import { useTransaction } from './useTransaction'
@@ -653,4 +657,136 @@ describe('useTransaction', () => {
expect(result.current.preStepStatuses[0]).toBe('failed')
})
})
+
+ describe('resolveAdapters', () => {
+ it('returns resolveAdapters function on the hook return', () => {
+ const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() })
+ expect(typeof result.current.resolveAdapters).toBe('function')
+ })
+
+ it('resolves transaction and wallet adapters by chainId', () => {
+ const txAdapter = makeMockTxAdapter()
+ const walletAdapter = makeMockWalletAdapter()
+
+ const { result } = renderHook(() => useTransaction(), {
+ wrapper: makeWrapper({ txAdapter, walletAdapter }),
+ })
+
+ const resolved = result.current.resolveAdapters(1)
+ expect(resolved.transactionAdapter).toBe(txAdapter)
+ expect(resolved.walletAdapter).toBe(walletAdapter)
+ })
+
+ it('throws AdapterNotFoundError for unknown chainId', () => {
+ const { result } = renderHook(() => useTransaction(), { wrapper: makeWrapper() })
+
+ expect(() => result.current.resolveAdapters(999)).toThrow(AdapterNotFoundError)
+ })
+
+ it('throws AdapterNotFoundError when no wallet adapter matches', () => {
+ const txAdapter = makeMockTxAdapter()
+ const { result } = renderHook(() => useTransaction(), {
+ wrapper: makeWrapper({ txAdapter }),
+ })
+
+ expect(() => result.current.resolveAdapters(999)).toThrow(AdapterNotFoundError)
+ })
+ })
+
+ describe('explicit adapter options', () => {
+ it('execute() uses explicit transactionAdapter instead of provider lookup', async () => {
+ const explicitTxAdapter = makeMockTxAdapter()
+ const providerTxAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(
+ () => useTransaction({ transactionAdapter: explicitTxAdapter }),
+ { wrapper: makeWrapper({ txAdapter: providerTxAdapter }) },
+ )
+
+ await act(async () => {
+ await result.current.execute(testParams)
+ })
+
+ expect(explicitTxAdapter.prepare).toHaveBeenCalledOnce()
+ expect(providerTxAdapter.prepare).not.toHaveBeenCalled()
+ })
+
+ it('execute() uses explicit walletAdapter for signer instead of provider lookup', async () => {
+ const explicitWalletAdapter = makeMockWalletAdapter()
+ const providerWalletAdapter = makeMockWalletAdapter()
+
+ const { result } = renderHook(
+ () => useTransaction({ walletAdapter: explicitWalletAdapter }),
+ { wrapper: makeWrapper({ walletAdapter: providerWalletAdapter }) },
+ )
+
+ await act(async () => {
+ await result.current.execute(testParams)
+ })
+
+ expect(explicitWalletAdapter.getSigner).toHaveBeenCalledOnce()
+ expect(providerWalletAdapter.getSigner).not.toHaveBeenCalled()
+ })
+
+ it('can mix explicit transactionAdapter with provider-resolved walletAdapter', async () => {
+ const explicitTxAdapter = makeMockTxAdapter()
+ const providerWalletAdapter = makeMockWalletAdapter()
+
+ const { result } = renderHook(
+ () => useTransaction({ transactionAdapter: explicitTxAdapter }),
+ { wrapper: makeWrapper({ walletAdapter: providerWalletAdapter }) },
+ )
+
+ await act(async () => {
+ await result.current.execute(testParams)
+ })
+
+ expect(explicitTxAdapter.prepare).toHaveBeenCalledOnce()
+ expect(providerWalletAdapter.getSigner).toHaveBeenCalledOnce()
+ })
+
+ it('prepare() uses explicit transactionAdapter', async () => {
+ const explicitTxAdapter = makeMockTxAdapter()
+ const providerTxAdapter = makeMockTxAdapter()
+
+ const { result } = renderHook(
+ () => useTransaction({ transactionAdapter: explicitTxAdapter, autoPreSteps: false }),
+ { wrapper: makeWrapper({ txAdapter: providerTxAdapter }) },
+ )
+
+ await act(async () => {
+ await result.current.prepare(testParams)
+ })
+
+ expect(explicitTxAdapter.prepare).toHaveBeenCalledOnce()
+ expect(providerTxAdapter.prepare).not.toHaveBeenCalled()
+ })
+
+ it('executePreStep() uses explicit adapters', async () => {
+ const explicitTxAdapter = makeMockTxAdapter()
+ const explicitWalletAdapter = makeMockWalletAdapter()
+ const preStep: PreStep = { label: 'Approve', params: { chainId: 1, payload: {} } }
+
+ const { result } = renderHook(
+ () =>
+ useTransaction({
+ transactionAdapter: explicitTxAdapter,
+ walletAdapter: explicitWalletAdapter,
+ autoPreSteps: false,
+ }),
+ { wrapper: makeWrapper() },
+ )
+
+ await act(async () => {
+ await result.current.prepare({ ...testParams, preSteps: [preStep] })
+ })
+
+ await act(async () => {
+ await result.current.executePreStep(0)
+ })
+
+ expect(explicitTxAdapter.execute).toHaveBeenCalled()
+ expect(explicitWalletAdapter.getSigner).toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts
index 998c21df..5f586489 100644
--- a/src/sdk/react/hooks/useTransaction.ts
+++ b/src/sdk/react/hooks/useTransaction.ts
@@ -3,10 +3,12 @@ import type { TransactionLifecycle, TransactionPhase } from '../../core/adapters
import type {
ConfirmOptions,
PrepareResult,
+ TransactionAdapter,
TransactionParams,
TransactionRef,
TransactionResult,
} from '../../core/adapters/transaction'
+import type { WalletAdapter } from '../../core/adapters/wallet'
import { getExplorerUrl } from '../../core/chain/explorer'
import {
AdapterNotFoundError,
@@ -28,6 +30,16 @@ export interface UseTransactionOptions {
autoPreSteps?: boolean
/** Options forwarded to confirm(). */
confirmOptions?: ConfirmOptions
+ /** Explicit transaction adapter — bypasses provider resolution when set. */
+ transactionAdapter?: TransactionAdapter
+ /** Explicit wallet adapter — bypasses provider resolution when set. */
+ walletAdapter?: WalletAdapter
+}
+
+/** Resolved adapter pair returned by resolveAdapters(). */
+export interface ResolvedAdapters {
+ transactionAdapter: TransactionAdapter
+ walletAdapter: WalletAdapter
}
export interface UseTransactionReturn {
@@ -43,6 +55,7 @@ export interface UseTransactionReturn {
prepare: (params: TransactionParams) => Promise
executePreStep: (index: number) => Promise
executeAllPreSteps: () => Promise
+ resolveAdapters: (chainId: string | number) => ResolvedAdapters
reset: () => void
}
@@ -124,27 +137,49 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
preStepResultsRef.current = []
}, [])
- const { lifecycle: localLifecycle, autoPreSteps = true, confirmOptions } = options
+ const {
+ lifecycle: localLifecycle,
+ autoPreSteps = true,
+ confirmOptions,
+ transactionAdapter: explicitTxAdapter,
+ walletAdapter: explicitWalletAdapter,
+ } = options
+
+ /** Resolves the transaction and wallet adapter pair for a given chainId. */
+ const resolveAdapters = useCallback(
+ (chainId: string | number): ResolvedAdapters => {
+ const chainIdStr = String(chainId)
+
+ const transactionAdapter =
+ explicitTxAdapter ??
+ Object.values(transactionAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+
+ const walletAdapter =
+ explicitWalletAdapter ??
+ Object.values(walletAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+
+ if (!transactionAdapter) {
+ throw new AdapterNotFoundError(chainId, 'transaction')
+ }
+ if (!walletAdapter) {
+ throw new AdapterNotFoundError(chainId, 'wallet')
+ }
+
+ return { transactionAdapter, walletAdapter }
+ },
+ [transactionAdapters, walletAdapters, explicitTxAdapter, explicitWalletAdapter],
+ )
const execute = useCallback(
async (params: TransactionParams): Promise => {
- const chainIdStr = String(params.chainId)
let currentPhase: TransactionPhase = 'prepare'
try {
- const transactionAdapter = Object.values(transactionAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
- const walletAdapter = Object.values(walletAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
-
- if (!transactionAdapter) {
- throw new AdapterNotFoundError(params.chainId, 'transaction')
- }
- if (!walletAdapter) {
- throw new AdapterNotFoundError(params.chainId, 'wallet')
- }
+ const { transactionAdapter, walletAdapter } = resolveAdapters(params.chainId)
const signer = await walletAdapter.getSigner()
if (signer === null) {
@@ -224,14 +259,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
throw errorObj
}
},
- [
- transactionAdapters,
- walletAdapters,
- localLifecycle,
- autoPreSteps,
- confirmOptions,
- globalLifecycle,
- ],
+ [resolveAdapters, localLifecycle, autoPreSteps, confirmOptions, globalLifecycle],
)
/**
@@ -245,25 +273,29 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
*/
const prepare = useCallback(
async (params: TransactionParams): Promise => {
- const chainIdStr = String(params.chainId)
-
try {
- const transactionAdapter = Object.values(transactionAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
+ const chainIdStr = String(params.chainId)
+
+ const resolvedTxAdapter =
+ explicitTxAdapter ??
+ Object.values(transactionAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
- if (!transactionAdapter) {
+ if (!resolvedTxAdapter) {
throw new AdapterNotFoundError(params.chainId, 'transaction')
}
// Resolve signer for gas estimation (optional — prepare works without it)
- const walletAdapter = Object.values(walletAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
- const prepareSigner = walletAdapter ? await walletAdapter.getSigner() : null
+ const resolvedWalletAdapter =
+ explicitWalletAdapter ??
+ Object.values(walletAdapters).find((adapter) =>
+ adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
+ )
+ const prepareSigner = resolvedWalletAdapter ? await resolvedWalletAdapter.getSigner() : null
setPhase('prepare')
- const prepared = await transactionAdapter.prepare(params, prepareSigner ?? undefined)
+ const prepared = await resolvedTxAdapter.prepare(params, prepareSigner ?? undefined)
setPrepareResult(prepared)
fireLifecycle('onPrepare', globalLifecycle, localLifecycle, prepared)
@@ -289,7 +321,14 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
throw errorObj
}
},
- [transactionAdapters, walletAdapters, globalLifecycle, localLifecycle],
+ [
+ transactionAdapters,
+ walletAdapters,
+ explicitTxAdapter,
+ explicitWalletAdapter,
+ globalLifecycle,
+ localLifecycle,
+ ],
)
/**
@@ -316,21 +355,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
)
}
- const chainIdStr = String(params.chainId)
-
- const transactionAdapter = Object.values(transactionAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
- const walletAdapter = Object.values(walletAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
-
- if (!transactionAdapter) {
- throw new AdapterNotFoundError(params.chainId, 'transaction')
- }
- if (!walletAdapter) {
- throw new AdapterNotFoundError(params.chainId, 'wallet')
- }
+ const { transactionAdapter, walletAdapter } = resolveAdapters(params.chainId)
const signer = await walletAdapter.getSigner()
if (signer === null) {
@@ -377,7 +402,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
throw errorObj
}
},
- [transactionAdapters, walletAdapters, globalLifecycle, localLifecycle, confirmOptions],
+ [resolveAdapters, globalLifecycle, localLifecycle, confirmOptions],
)
/**
@@ -420,6 +445,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
prepare,
executePreStep,
executeAllPreSteps,
+ resolveAdapters,
reset,
}
}
From 9e57a511aafc0c28407c5b9f8a3e2e61989d95ad Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:49:06 +0200
Subject: [PATCH 31/52] fix: complete DbC annotations and dedup prepare()
adapter lookup in useTransaction
---
src/sdk/react/hooks/useTransaction.test.tsx | 5 +-
src/sdk/react/hooks/useTransaction.ts | 71 ++++++++++++---------
2 files changed, 45 insertions(+), 31 deletions(-)
diff --git a/src/sdk/react/hooks/useTransaction.test.tsx b/src/sdk/react/hooks/useTransaction.test.tsx
index bbd835c1..4d978397 100644
--- a/src/sdk/react/hooks/useTransaction.test.tsx
+++ b/src/sdk/react/hooks/useTransaction.test.tsx
@@ -684,12 +684,15 @@ describe('useTransaction', () => {
})
it('throws AdapterNotFoundError when no wallet adapter matches', () => {
- const txAdapter = makeMockTxAdapter()
+ const chain999 = { ...mockChain, chainId: 999, caip2Id: 'eip155:999', name: 'Unknown' }
+ const txAdapter = makeMockTxAdapter({ supportedChains: [chain999] })
+ // walletAdapter from makeWrapper only supports chain 1
const { result } = renderHook(() => useTransaction(), {
wrapper: makeWrapper({ txAdapter }),
})
expect(() => result.current.resolveAdapters(999)).toThrow(AdapterNotFoundError)
+ expect(() => result.current.resolveAdapters(999)).toThrow('No wallet adapter found')
})
})
diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts
index 5f586489..8376a2ce 100644
--- a/src/sdk/react/hooks/useTransaction.ts
+++ b/src/sdk/react/hooks/useTransaction.ts
@@ -30,9 +30,15 @@ export interface UseTransactionOptions {
autoPreSteps?: boolean
/** Options forwarded to confirm(). */
confirmOptions?: ConfirmOptions
- /** Explicit transaction adapter — bypasses provider resolution when set. */
+ /**
+ * Explicit transaction adapter — bypasses provider resolution when set.
+ * @precondition if provided, must support the chainId used in execute()/prepare()
+ */
transactionAdapter?: TransactionAdapter
- /** Explicit wallet adapter — bypasses provider resolution when set. */
+ /**
+ * Explicit wallet adapter — bypasses provider resolution when set.
+ * @precondition if provided, must be connected when execute() is called
+ */
walletAdapter?: WalletAdapter
}
@@ -145,22 +151,44 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
walletAdapter: explicitWalletAdapter,
} = options
- /** Resolves the transaction and wallet adapter pair for a given chainId. */
- const resolveAdapters = useCallback(
- (chainId: string | number): ResolvedAdapters => {
+ /** Finds the transaction adapter for a chainId, checking the explicit option first. */
+ const findTransactionAdapter = useCallback(
+ (chainId: string | number): TransactionAdapter | undefined => {
const chainIdStr = String(chainId)
-
- const transactionAdapter =
+ return (
explicitTxAdapter ??
Object.values(transactionAdapters).find((adapter) =>
adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
)
+ )
+ },
+ [transactionAdapters, explicitTxAdapter],
+ )
- const walletAdapter =
+ /** Finds the wallet adapter for a chainId, checking the explicit option first. */
+ const findWalletAdapter = useCallback(
+ (chainId: string | number): WalletAdapter | undefined => {
+ const chainIdStr = String(chainId)
+ return (
explicitWalletAdapter ??
Object.values(walletAdapters).find((adapter) =>
adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
)
+ )
+ },
+ [walletAdapters, explicitWalletAdapter],
+ )
+
+ /**
+ * Resolves the transaction and wallet adapter pair for a given chainId.
+ *
+ * @precondition chainId matches a registered TransactionAdapter and WalletAdapter
+ * @throws {AdapterNotFoundError} if no adapter supports the given chainId
+ */
+ const resolveAdapters = useCallback(
+ (chainId: string | number): ResolvedAdapters => {
+ const transactionAdapter = findTransactionAdapter(chainId)
+ const walletAdapter = findWalletAdapter(chainId)
if (!transactionAdapter) {
throw new AdapterNotFoundError(chainId, 'transaction')
@@ -171,7 +199,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
return { transactionAdapter, walletAdapter }
},
- [transactionAdapters, walletAdapters, explicitTxAdapter, explicitWalletAdapter],
+ [findTransactionAdapter, findWalletAdapter],
)
const execute = useCallback(
@@ -274,24 +302,14 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
const prepare = useCallback(
async (params: TransactionParams): Promise => {
try {
- const chainIdStr = String(params.chainId)
-
- const resolvedTxAdapter =
- explicitTxAdapter ??
- Object.values(transactionAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
+ const resolvedTxAdapter = findTransactionAdapter(params.chainId)
if (!resolvedTxAdapter) {
throw new AdapterNotFoundError(params.chainId, 'transaction')
}
- // Resolve signer for gas estimation (optional — prepare works without it)
- const resolvedWalletAdapter =
- explicitWalletAdapter ??
- Object.values(walletAdapters).find((adapter) =>
- adapter.supportedChains.some((chain) => String(chain.chainId) === chainIdStr),
- )
+ // Resolve wallet adapter for gas estimation (optional — prepare works without a signer)
+ const resolvedWalletAdapter = findWalletAdapter(params.chainId)
const prepareSigner = resolvedWalletAdapter ? await resolvedWalletAdapter.getSigner() : null
setPhase('prepare')
@@ -321,14 +339,7 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
throw errorObj
}
},
- [
- transactionAdapters,
- walletAdapters,
- explicitTxAdapter,
- explicitWalletAdapter,
- globalLifecycle,
- localLifecycle,
- ],
+ [findTransactionAdapter, findWalletAdapter, globalLifecycle, localLifecycle],
)
/**
From 9b54fd04cd083dd0e3fb83e0f6f719d83b750ebe Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:52:32 +0200
Subject: [PATCH 32/52] fix: export ResolvedAdapters type from hooks barrel
---
src/sdk/react/hooks/index.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/sdk/react/hooks/index.ts b/src/sdk/react/hooks/index.ts
index a443160c..02c7c879 100644
--- a/src/sdk/react/hooks/index.ts
+++ b/src/sdk/react/hooks/index.ts
@@ -6,6 +6,7 @@ export type { UseReadOnlyOptions, UseReadOnlyReturn } from './useReadOnly'
export { useReadOnly } from './useReadOnly'
export type {
PreStepStatus,
+ ResolvedAdapters,
TransactionExecutionPhase,
UseTransactionOptions,
UseTransactionReturn,
From 3fa6a7d4c4f1352943b7e025c133ec95e26330eb Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:54:15 +0200
Subject: [PATCH 33/52] feat: accept explicit adapter prop on WalletGuard
---
src/sdk/react/components/WalletGuard.test.tsx | 19 +++++++++++++++++++
src/sdk/react/components/WalletGuard.tsx | 10 +++++++++-
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/src/sdk/react/components/WalletGuard.test.tsx b/src/sdk/react/components/WalletGuard.test.tsx
index b6514480..d63a0b73 100644
--- a/src/sdk/react/components/WalletGuard.test.tsx
+++ b/src/sdk/react/components/WalletGuard.test.tsx
@@ -196,6 +196,25 @@ describe('WalletGuard', () => {
expect(screen.getByTestId('protected-content')).toBeInTheDocument()
})
+
+ it('passes adapter prop to useWallet for single-chain mode', () => {
+ const customAdapter = {} as never
+
+ mockedUseWallet.mockReturnValue(makeWalletReady())
+
+ render(
+ createElement(
+ WalletGuard,
+ { adapter: customAdapter, chainId: 1 },
+ createElement('div', { 'data-testid': 'protected-content' }, 'Protected'),
+ ),
+ )
+
+ expect(mockedUseWallet).toHaveBeenCalledWith(
+ expect.objectContaining({ adapter: customAdapter }),
+ )
+ expect(screen.getByTestId('protected-content')).toBeInTheDocument()
+ })
})
describe('WalletGuard multi-chain (require prop)', () => {
diff --git a/src/sdk/react/components/WalletGuard.tsx b/src/sdk/react/components/WalletGuard.tsx
index 5e072a2d..664bd642 100644
--- a/src/sdk/react/components/WalletGuard.tsx
+++ b/src/sdk/react/components/WalletGuard.tsx
@@ -1,4 +1,5 @@
import type { FC, ReactElement, ReactNode } from 'react'
+import type { WalletAdapter } from '../../core/adapters/wallet'
import { useChainRegistry, useMultiWallet, useWallet } from '../hooks'
/** A single wallet requirement for multi-chain gating. */
@@ -21,6 +22,12 @@ export interface SwitchChainRenderProps {
export interface WalletGuardProps {
chainId?: string | number
chainType?: string
+ /**
+ * Level 4 escape hatch: explicit wallet adapter — bypasses provider resolution.
+ * Only applies in single-chain mode (not with `require` prop).
+ * @precondition if adapter provided, require must not be set (single-chain mode only)
+ */
+ adapter?: WalletAdapter
children?: ReactNode
/**
* Multi-chain requirements. When provided, the guard checks each requirement
@@ -73,11 +80,12 @@ export const WalletGuard: FC = (props) => {
const SingleChainGuard: FC = ({
chainId,
chainType,
+ adapter,
children,
renderConnect,
renderSwitchChain,
}) => {
- const wallet = useWallet({ chainId, chainType })
+ const wallet = useWallet({ chainId, chainType, adapter })
const registry = useChainRegistry()
if (wallet.needsConnect) {
From 269aad65ae55f2a0797cc1035103c86ff61e3e4f Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:56:30 +0200
Subject: [PATCH 34/52] feat: export useProviderContext from public API
---
src/sdk/react/provider/context.test.tsx | 28 +++++++++++++++++++++++++
src/sdk/react/provider/index.ts | 1 +
2 files changed, 29 insertions(+)
create mode 100644 src/sdk/react/provider/context.test.tsx
diff --git a/src/sdk/react/provider/context.test.tsx b/src/sdk/react/provider/context.test.tsx
new file mode 100644
index 00000000..60cbabad
--- /dev/null
+++ b/src/sdk/react/provider/context.test.tsx
@@ -0,0 +1,28 @@
+import { renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { createElement } from 'react'
+import { describe, expect, it } from 'vitest'
+import { DAppBoosterProvider, useProviderContext } from './index'
+
+describe('useProviderContext export', () => {
+ it('is importable from the provider barrel', () => {
+ expect(typeof useProviderContext).toBe('function')
+ })
+
+ it('returns context value inside DAppBoosterProvider', () => {
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(DAppBoosterProvider, { config: {} }, children)
+
+ const { result } = renderHook(() => useProviderContext(), { wrapper })
+
+ expect(result.current).toHaveProperty('walletAdapters')
+ expect(result.current).toHaveProperty('transactionAdapters')
+ expect(result.current).toHaveProperty('registry')
+ })
+
+ it('throws when called outside DAppBoosterProvider', () => {
+ expect(() => {
+ renderHook(() => useProviderContext())
+ }).toThrow('useProviderContext must be called inside a DAppBoosterProvider')
+ })
+})
diff --git a/src/sdk/react/provider/index.ts b/src/sdk/react/provider/index.ts
index 58cf54be..8c89d629 100644
--- a/src/sdk/react/provider/index.ts
+++ b/src/sdk/react/provider/index.ts
@@ -1,2 +1,3 @@
export type { DAppBoosterContextValue } from './context'
+export { useProviderContext } from './context'
export { DAppBoosterProvider } from './DAppBoosterProvider'
From a72c0db11134348b30efcf12774937a2d1060375 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:57:58 +0200
Subject: [PATCH 35/52] fix: enforce chains.length >= 1 precondition on
createEvmWalletAdapter
---
src/sdk/core/evm/wallet.test.ts | 10 ++++++++++
src/sdk/core/evm/wallet.ts | 5 +++++
2 files changed, 15 insertions(+)
diff --git a/src/sdk/core/evm/wallet.test.ts b/src/sdk/core/evm/wallet.test.ts
index f46e26cc..60421f04 100644
--- a/src/sdk/core/evm/wallet.test.ts
+++ b/src/sdk/core/evm/wallet.test.ts
@@ -140,6 +140,16 @@ describe('createEvmWalletAdapter — unit tests', () => {
})
}
+ it('throws when config.chains is empty', () => {
+ expect(() =>
+ createEvmWalletAdapter({
+ coreConnector: stubCoreConnector,
+ chains: [],
+ transports: {},
+ }),
+ ).toThrow('createEvmWalletAdapter requires at least one chain')
+ })
+
// -------------------------------------------------------------------------
// getStatus()
// -------------------------------------------------------------------------
diff --git a/src/sdk/core/evm/wallet.ts b/src/sdk/core/evm/wallet.ts
index 4816e5bc..80ab1197 100644
--- a/src/sdk/core/evm/wallet.ts
+++ b/src/sdk/core/evm/wallet.ts
@@ -120,6 +120,11 @@ function toWalletStatus(account: ReturnType): WalletStatus {
* @invariant adapter.supportedChains never changes after construction
*/
export function createEvmWalletAdapter(config: EvmWalletConfig): EvmWalletAdapterResult {
+ if (config.chains.length === 0) {
+ throw new Error(
+ 'createEvmWalletAdapter requires at least one chain. Provide chains in config.chains.',
+ )
+ }
const wagmiConfig =
config.wagmiConfig ?? config.coreConnector.createConfig(config.chains, config.transports)
const supportedChains = config.chains.map(fromViemChain)
From 5be2482c1d6ae512a824debe6b8fccb6f6fb8460 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 15:58:34 +0200
Subject: [PATCH 36/52] fix: enforce chains.length >= 1 precondition on
createEvmTransactionAdapter
---
src/sdk/core/evm/transaction.test.ts | 6 ++++++
src/sdk/core/evm/transaction.ts | 7 ++++++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/sdk/core/evm/transaction.test.ts b/src/sdk/core/evm/transaction.test.ts
index 26c443c0..b230806c 100644
--- a/src/sdk/core/evm/transaction.test.ts
+++ b/src/sdk/core/evm/transaction.test.ts
@@ -33,6 +33,12 @@ describe('createEvmTransactionAdapter', () => {
vi.mocked(createPublicClient).mockReturnValue(mockPublicClient as never)
})
+ it('throws when config.chains is empty', () => {
+ expect(() => createEvmTransactionAdapter({ chains: [], transports: {} })).toThrow(
+ 'createEvmTransactionAdapter requires at least one chain',
+ )
+ })
+
// ---------------------------------------------------------------------------
// structural / metadata
// ---------------------------------------------------------------------------
diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts
index 75bed8fc..9759803b 100644
--- a/src/sdk/core/evm/transaction.ts
+++ b/src/sdk/core/evm/transaction.ts
@@ -54,8 +54,13 @@ function isWalletClient(signer: unknown): signer is WalletClient {
* @invariant adapter.supportedChains never changes after construction
*/
export function createEvmTransactionAdapter(
- config: EvmTransactionConfig = { chains: [], transports: {} },
+ config: EvmTransactionConfig,
): TransactionAdapter<'evm'> {
+ if (config.chains.length === 0) {
+ throw new Error(
+ 'createEvmTransactionAdapter requires at least one chain. Provide chains in config.chains.',
+ )
+ }
const publicClients = new Map(
config.chains.map((chain) => [
chain.id,
From c95286baaf1c8a39318024fcdbf5c14cbdbfff74 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 16:00:20 +0200
Subject: [PATCH 37/52] fix: add resolveAdapters to UseTransactionReturn mocks
in TransactionButton tests
---
src/transactions/components/TransactionButton.test.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/transactions/components/TransactionButton.test.tsx b/src/transactions/components/TransactionButton.test.tsx
index c0044c66..d2729e16 100644
--- a/src/transactions/components/TransactionButton.test.tsx
+++ b/src/transactions/components/TransactionButton.test.tsx
@@ -274,6 +274,7 @@ describe('TransactionButton', () => {
prepare: vi.fn(),
executePreStep: vi.fn(),
executeAllPreSteps: vi.fn(),
+ resolveAdapters: vi.fn(),
})
renderWithChakra(Send ETH)
@@ -298,6 +299,7 @@ describe('TransactionButton', () => {
prepare: vi.fn(),
executePreStep: vi.fn(),
executeAllPreSteps: vi.fn(),
+ resolveAdapters: vi.fn(),
})
renderWithChakra(
From 032ec05c54c8bf9692dd42256fa0930d3042413a Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:07:16 +0200
Subject: [PATCH 38/52] refactor: make ReadClientFactory generic and add
readClientFactory to WalletAdapterBundle
---
src/sdk/core/adapters/provider.ts | 8 +++++---
src/sdk/react/provider/context.ts | 2 +-
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/sdk/core/adapters/provider.ts b/src/sdk/core/adapters/provider.ts
index 140d5f72..392b564f 100644
--- a/src/sdk/core/adapters/provider.ts
+++ b/src/sdk/core/adapters/provider.ts
@@ -18,15 +18,17 @@ export interface WalletAdapterBundle {
Provider?: FC<{ children: ReactNode }>
/** Hook that returns functions to open the connector's connect and account modals. */
useConnectModal?: () => { open: () => void; openAccount?: () => void }
+ /** Factory for creating read-only RPC clients specific to this adapter's chain type. */
+ readClientFactory?: ReadClientFactory
}
/**
* Factory for creating chain-type-specific read-only RPC clients.
* Used to configure read operations without requiring a connected wallet.
*/
-export interface ReadClientFactory {
+export interface ReadClientFactory {
readonly chainType: string
- createClient(endpoint: EndpointConfig, chainId: string | number): unknown
+ createClient(endpoint: EndpointConfig, chainId: string | number): TClient
}
/**
@@ -41,7 +43,7 @@ export interface DAppBoosterConfig {
/** Chains the app operates on. Merged with each adapter's supportedChains at runtime. */
chains?: ChainDescriptor[]
/** Factories for constructing read-only RPC clients per chain type. */
- readClientFactories?: ReadClientFactory[]
+ readClientFactories?: ReadClientFactory[]
/** Global transaction lifecycle hooks applied to all transactions. */
lifecycle?: TransactionLifecycle
/** Global wallet lifecycle hooks applied to all signing operations. */
diff --git a/src/sdk/react/provider/context.ts b/src/sdk/react/provider/context.ts
index a86defc2..6313a88c 100644
--- a/src/sdk/react/provider/context.ts
+++ b/src/sdk/react/provider/context.ts
@@ -12,7 +12,7 @@ export interface DAppBoosterContextValue {
registry: ChainRegistry
lifecycle: TransactionLifecycle | undefined
walletLifecycle: WalletLifecycle | undefined
- readClientFactories: ReadClientFactory[]
+ readClientFactories: ReadClientFactory[]
/** Per-adapter modal openers, populated by bridge components inside bundle providers. */
connectModalsRef: RefObject void; openAccount?: () => void }>>
}
From fe9a96f43bf65b754350ec86db262867f9d3bba6 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:13:28 +0200
Subject: [PATCH 39/52] feat: add evmReadClientFactory for typed EVM read-only
clients
---
src/sdk/core/evm/index.ts | 1 +
src/sdk/core/evm/read-client.test.ts | 33 ++++++++++++++++++++++++++++
src/sdk/core/evm/read-client.ts | 17 ++++++++++++++
3 files changed, 51 insertions(+)
create mode 100644 src/sdk/core/evm/read-client.test.ts
create mode 100644 src/sdk/core/evm/read-client.ts
diff --git a/src/sdk/core/evm/index.ts b/src/sdk/core/evm/index.ts
index 727cc5b2..ce76dbc5 100644
--- a/src/sdk/core/evm/index.ts
+++ b/src/sdk/core/evm/index.ts
@@ -1,6 +1,7 @@
export { fromViemChain } from './chains'
export type { ApprovalPreStepParams, PermitPreStepParams } from './pre-steps'
export { createApprovalPreStep, createPermitPreStep } from './pre-steps'
+export { evmReadClientFactory } from './read-client'
export type { EvmServerWalletConfig } from './server-wallet'
export { createEvmServerWallet } from './server-wallet'
export type { EvmTransactionConfig } from './transaction'
diff --git a/src/sdk/core/evm/read-client.test.ts b/src/sdk/core/evm/read-client.test.ts
new file mode 100644
index 00000000..3dd367a6
--- /dev/null
+++ b/src/sdk/core/evm/read-client.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from 'vitest'
+import { evmReadClientFactory } from './read-client'
+
+vi.mock('viem', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ createPublicClient: vi.fn(() => ({ type: 'mock-public-client' })),
+ }
+})
+
+describe('evmReadClientFactory', () => {
+ it('has chainType "evm"', () => {
+ expect(evmReadClientFactory.chainType).toBe('evm')
+ })
+
+ it('creates a PublicClient from an endpoint', () => {
+ const endpoint = { url: 'https://eth.example.com', protocol: 'json-rpc' as const }
+ const client = evmReadClientFactory.createClient(endpoint, 1)
+ expect(client).toEqual({ type: 'mock-public-client' })
+ })
+
+ it('passes the endpoint URL to createPublicClient transport', async () => {
+ const { createPublicClient } = await import('viem')
+ const endpoint = { url: 'https://custom-rpc.io', protocol: 'json-rpc' as const }
+
+ evmReadClientFactory.createClient(endpoint, 42161)
+
+ expect(createPublicClient).toHaveBeenCalledWith(
+ expect.objectContaining({ transport: expect.any(Function) }),
+ )
+ })
+})
diff --git a/src/sdk/core/evm/read-client.ts b/src/sdk/core/evm/read-client.ts
new file mode 100644
index 00000000..690f73fe
--- /dev/null
+++ b/src/sdk/core/evm/read-client.ts
@@ -0,0 +1,17 @@
+import { createPublicClient, http, type PublicClient } from 'viem'
+
+import type { ReadClientFactory } from '../adapters/provider'
+import type { EndpointConfig } from '../chain/descriptor'
+
+/**
+ * Read-only client factory for EVM chains. Wraps viem's createPublicClient.
+ *
+ * @precondition endpoint.url is a valid JSON-RPC URL
+ * @postcondition returns a viem PublicClient for read-only chain queries
+ */
+export const evmReadClientFactory: ReadClientFactory = {
+ chainType: 'evm',
+ createClient(endpoint: EndpointConfig, _chainId: string | number): PublicClient {
+ return createPublicClient({ transport: http(endpoint.url) })
+ },
+}
From 45bc394ab8c5f9336cac5a506ae2346ce93c83cb Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:15:07 +0200
Subject: [PATCH 40/52] feat: add createReadClient and resolveReadClient core
utilities
---
src/sdk/core/index.ts | 1 +
src/sdk/core/read-client.test.ts | 95 ++++++++++++++++++++++++++++++++
src/sdk/core/read-client.ts | 52 +++++++++++++++++
3 files changed, 148 insertions(+)
create mode 100644 src/sdk/core/read-client.test.ts
create mode 100644 src/sdk/core/read-client.ts
diff --git a/src/sdk/core/index.ts b/src/sdk/core/index.ts
index 5cc9ab31..7df9003d 100644
--- a/src/sdk/core/index.ts
+++ b/src/sdk/core/index.ts
@@ -2,4 +2,5 @@ export * from './adapters'
export * from './chain'
export * from './errors'
export * from './evm'
+export * from './read-client'
export * from './utils'
diff --git a/src/sdk/core/read-client.test.ts b/src/sdk/core/read-client.test.ts
new file mode 100644
index 00000000..5a25dee6
--- /dev/null
+++ b/src/sdk/core/read-client.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { ReadClientFactory } from './adapters/provider'
+import { createChainRegistry } from './chain/registry'
+import { createReadClient, resolveReadClient } from './read-client'
+
+const mockEndpoint = { url: 'https://eth.example.com', protocol: 'json-rpc' as const }
+
+const mockEvmChain = {
+ caip2Id: 'eip155:1',
+ chainId: 1,
+ name: 'Ethereum',
+ chainType: 'evm',
+ nativeCurrency: { symbol: 'ETH', decimals: 18 },
+ addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
+ endpoints: [mockEndpoint],
+}
+
+const mockSvmChain = {
+ caip2Id: 'solana:mainnet',
+ chainId: 'mainnet-beta',
+ name: 'Solana',
+ chainType: 'svm',
+ nativeCurrency: { symbol: 'SOL', decimals: 9 },
+ addressConfig: { format: 'base58' as const, patterns: [], example: '...' },
+ endpoints: [{ url: 'https://sol.example.com', protocol: 'json-rpc' as const }],
+}
+
+type MockEvmClient = { type: 'evm-client' }
+type MockSvmClient = { type: 'svm-client' }
+
+const evmFactory: ReadClientFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'evm-client' })),
+}
+
+const svmFactory: ReadClientFactory = {
+ chainType: 'svm',
+ createClient: vi.fn(() => ({ type: 'svm-client' })),
+}
+
+describe('createReadClient', () => {
+ it('returns a typed client for a valid chainId', () => {
+ const registry = createChainRegistry([mockEvmChain])
+ const client = createReadClient(evmFactory, registry, 1)
+ expect(client).toEqual({ type: 'evm-client' })
+ })
+
+ it('returns null when chainId is not in registry', () => {
+ const registry = createChainRegistry([mockEvmChain])
+ const client = createReadClient(evmFactory, registry, 999)
+ expect(client).toBeNull()
+ })
+
+ it('returns null when chain has no endpoints', () => {
+ const chainNoEndpoints = { ...mockEvmChain, endpoints: undefined }
+ const registry = createChainRegistry([chainNoEndpoints])
+ const client = createReadClient(evmFactory, registry, 1)
+ expect(client).toBeNull()
+ })
+
+ it('passes endpoint and chainId to factory.createClient', () => {
+ const registry = createChainRegistry([mockEvmChain])
+ createReadClient(evmFactory, registry, 1)
+ expect(evmFactory.createClient).toHaveBeenCalledWith(mockEndpoint, 1)
+ })
+})
+
+describe('resolveReadClient', () => {
+ it('finds the correct factory by chainType and returns a client', () => {
+ const registry = createChainRegistry([mockEvmChain, mockSvmChain])
+ const factories: ReadClientFactory[] = [evmFactory, svmFactory]
+
+ const evmClient = resolveReadClient(factories, registry, 1)
+ expect(evmClient).toEqual({ type: 'evm-client' })
+
+ const svmClient = resolveReadClient(factories, registry, 'mainnet-beta')
+ expect(svmClient).toEqual({ type: 'svm-client' })
+ })
+
+ it('returns null when no factory matches the chainType', () => {
+ const registry = createChainRegistry([mockSvmChain])
+ const factories: ReadClientFactory[] = [evmFactory]
+
+ const client = resolveReadClient(factories, registry, 'mainnet-beta')
+ expect(client).toBeNull()
+ })
+
+ it('returns null when chainId is not in registry', () => {
+ const registry = createChainRegistry([mockEvmChain])
+ const factories: ReadClientFactory[] = [evmFactory]
+
+ const client = resolveReadClient(factories, registry, 999)
+ expect(client).toBeNull()
+ })
+})
diff --git a/src/sdk/core/read-client.ts b/src/sdk/core/read-client.ts
new file mode 100644
index 00000000..ca15f8fa
--- /dev/null
+++ b/src/sdk/core/read-client.ts
@@ -0,0 +1,52 @@
+import type { ReadClientFactory } from './adapters/provider'
+import type { ChainRegistry } from './chain/registry'
+
+/**
+ * Creates a typed read-only client using a specific factory.
+ * For CLI tools, agent scripts, and other non-React consumers.
+ *
+ * @precondition chainId is registered in the registry
+ * @postcondition returns a typed client, or null if chain/endpoint not found
+ */
+export function createReadClient(
+ factory: ReadClientFactory,
+ registry: ChainRegistry,
+ chainId: string | number,
+): TClient | null {
+ const chain = registry.getChain(chainId)
+ if (!chain) {
+ return null
+ }
+ const endpoint = chain.endpoints?.[0]
+ if (!endpoint) {
+ return null
+ }
+ return factory.createClient(endpoint, chain.chainId)
+}
+
+/**
+ * Resolves a read-only client from a heterogeneous factory array.
+ * For multi-VM loops where the chain type isn't known ahead of time.
+ *
+ * @precondition chainId is registered in the registry
+ * @postcondition returns a client, or null if chain/factory/endpoint not found
+ */
+export function resolveReadClient(
+ factories: ReadClientFactory[],
+ registry: ChainRegistry,
+ chainId: string | number,
+): unknown | null {
+ const chain = registry.getChain(chainId)
+ if (!chain) {
+ return null
+ }
+ const factory = factories.find((f) => f.chainType === chain.chainType)
+ if (!factory) {
+ return null
+ }
+ const endpoint = chain.endpoints?.[0]
+ if (!endpoint) {
+ return null
+ }
+ return factory.createClient(endpoint, chain.chainId)
+}
From d13093930663485a622c7d4e5f9f140f0ae48d3d Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:18:30 +0200
Subject: [PATCH 41/52] feat: make useReadOnly generic with factory bypass
option
useReadOnly now accepts an optional `factory` prop that
bypasses provider-level readClientFactories resolution (Level 4
escape hatch). Default generic stays `unknown` for backward compat.
---
src/sdk/core/adapters/index.test.ts | 2 +-
src/sdk/react/hooks/useReadOnly.test.ts | 156 +++++++++++-------------
src/sdk/react/hooks/useReadOnly.ts | 42 +++++--
3 files changed, 102 insertions(+), 98 deletions(-)
diff --git a/src/sdk/core/adapters/index.test.ts b/src/sdk/core/adapters/index.test.ts
index fee218e0..83455578 100644
--- a/src/sdk/core/adapters/index.test.ts
+++ b/src/sdk/core/adapters/index.test.ts
@@ -56,7 +56,7 @@ describe('adapter interfaces', () => {
const _txLifecycle: TransactionLifecycle | undefined = undefined
const _walletLifecycle: WalletLifecycle | undefined = undefined
const _bundle: WalletAdapterBundle | undefined = undefined
- const _factory: ReadClientFactory | undefined = undefined
+ const _factory: ReadClientFactory | undefined = undefined
const _config: DAppBoosterConfig | undefined = undefined
void _signer
void _options
diff --git a/src/sdk/react/hooks/useReadOnly.test.ts b/src/sdk/react/hooks/useReadOnly.test.ts
index f80926c7..d79daf54 100644
--- a/src/sdk/react/hooks/useReadOnly.test.ts
+++ b/src/sdk/react/hooks/useReadOnly.test.ts
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
+
import type { ReadClientFactory } from '../../core/adapters/provider'
import { DAppBoosterProvider } from '../provider/DAppBoosterProvider'
import { useReadOnly } from './useReadOnly'
@@ -10,6 +11,8 @@ vi.mock('@/src/wallet/providers', () => ({
Web3Provider: ({ children }: { children: ReactNode }) => children,
}))
+const mockEndpoint = { url: 'https://eth.example.com', protocol: 'json-rpc' as const }
+
const mockChain = {
caip2Id: 'eip155:1',
chainId: 1,
@@ -17,119 +20,104 @@ const mockChain = {
chainType: 'evm',
nativeCurrency: { symbol: 'ETH', decimals: 18 },
addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
-}
-
-const mockChainWithEndpoint = {
- ...mockChain,
- endpoints: [{ url: 'https://rpc.example.com', protocol: 'json-rpc' as const }],
-}
-
-const mockChainWithExplorer = {
- ...mockChainWithEndpoint,
+ endpoints: [mockEndpoint],
explorer: {
+ name: 'Etherscan',
url: 'https://etherscan.io',
txPath: '/tx/{id}',
addressPath: '/address/{id}',
},
}
+type MockClient = { type: 'mock-client' }
+
+const mockFactory: ReadClientFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'mock-client' })),
+}
+
const makeWrapper =
- (config: Parameters[0]['config']) =>
+ (
+ config: {
+ readClientFactories?: ReadClientFactory[]
+ chains?: (typeof mockChain)[]
+ } = {},
+ ) =>
({ children }: { children: ReactNode }) =>
- createElement(DAppBoosterProvider, { config }, children)
+ createElement(
+ DAppBoosterProvider,
+ {
+ config: {
+ chains: config.chains ?? [mockChain],
+ readClientFactories: config.readClientFactories,
+ },
+ },
+ children,
+ )
describe('useReadOnly', () => {
- it('returns null chain when chainId not in registry', () => {
- const wrapper = makeWrapper({ chains: [] })
- const { result } = renderHook(() => useReadOnly({ chainId: 999 }), { wrapper })
- expect(result.current.chain).toBeNull()
- expect(result.current.client).toBeNull()
- })
-
- it('returns chain descriptor when chainId found', () => {
- const wrapper = makeWrapper({ chains: [mockChain] })
- const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
- expect(result.current.chain).not.toBeNull()
+ it('returns chain and null client when no factory matches', () => {
+ const { result } = renderHook(() => useReadOnly({ chainId: 1 }), {
+ wrapper: makeWrapper(),
+ })
expect(result.current.chain?.name).toBe('Ethereum')
- })
-
- it('client is null when no factory registered', () => {
- const wrapper = makeWrapper({ chains: [mockChainWithEndpoint] })
- const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
- expect(result.current.chain).not.toBeNull()
expect(result.current.client).toBeNull()
})
- it('client is null when chain has no endpoints', () => {
- const mockFactory: ReadClientFactory = {
- chainType: 'evm',
- createClient: vi.fn(),
- }
- const wrapper = makeWrapper({
- chains: [mockChain],
- readClientFactories: [mockFactory],
+ it('returns a client when factory matches', () => {
+ const { result } = renderHook(() => useReadOnly({ chainId: 1 }), {
+ wrapper: makeWrapper({ readClientFactories: [mockFactory] }),
})
- const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
- expect(result.current.chain).not.toBeNull()
- expect(result.current.client).toBeNull()
- expect(mockFactory.createClient).not.toHaveBeenCalled()
+ expect(result.current.client).toEqual({ type: 'mock-client' })
})
- it('client is created from factory when factory and endpoint exist', () => {
- const mockFactory: ReadClientFactory = {
- chainType: 'evm',
- createClient: vi.fn((endpoint, chainId) => ({ endpoint, chainId })),
- }
- const wrapper = makeWrapper({
- chains: [mockChainWithEndpoint],
- readClientFactories: [mockFactory],
- })
- const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
- expect(result.current.chain).not.toBeNull()
- expect(result.current.client).not.toBeNull()
- expect(mockFactory.createClient).toHaveBeenCalledWith(
- mockChainWithEndpoint.endpoints[0],
- mockChainWithEndpoint.chainId,
- )
- expect(result.current.client).toEqual({
- endpoint: mockChainWithEndpoint.endpoints[0],
- chainId: mockChainWithEndpoint.chainId,
+ it('returns null chain and null client for unknown chainId', () => {
+ const { result } = renderHook(() => useReadOnly({ chainId: 999 }), {
+ wrapper: makeWrapper(),
})
+ expect(result.current.chain).toBeNull()
+ expect(result.current.client).toBeNull()
})
- it('returns address in the result when address option is provided', () => {
- const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
+ it('returns explorerAddressUrl when address and explorer are configured', () => {
const { result } = renderHook(() => useReadOnly({ chainId: 1, address: '0xabc' }), {
- wrapper,
+ wrapper: makeWrapper(),
})
- expect(result.current.address).toBe('0xabc')
+ expect(result.current.explorerAddressUrl).toBe('https://etherscan.io/address/0xabc')
})
- it('returns null address when address option is not provided', () => {
- const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
- const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
- expect(result.current.address).toBeNull()
- })
+ describe('factory option (Level 4 bypass)', () => {
+ it('uses explicit factory instead of provider readClientFactories', () => {
+ const providerFactory: ReadClientFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'provider-client' })),
+ }
+ const explicitFactory: ReadClientFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'explicit-client' })),
+ }
- it('returns explorerAddressUrl when address and explorer config are present', () => {
- const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
- const { result } = renderHook(() => useReadOnly({ chainId: 1, address: '0xabc' }), {
- wrapper,
+ const { result } = renderHook(
+ () => useReadOnly({ chainId: 1, factory: explicitFactory }),
+ { wrapper: makeWrapper({ readClientFactories: [providerFactory] }) },
+ )
+
+ expect(result.current.client).toEqual({ type: 'explicit-client' })
+ expect(providerFactory.createClient).not.toHaveBeenCalled()
})
- expect(result.current.explorerAddressUrl).toBe('https://etherscan.io/address/0xabc')
- })
- it('returns null explorerAddressUrl when address is not provided', () => {
- const wrapper = makeWrapper({ chains: [mockChainWithExplorer] })
- const { result } = renderHook(() => useReadOnly({ chainId: 1 }), { wrapper })
- expect(result.current.explorerAddressUrl).toBeNull()
- })
+ it('works even when provider has no readClientFactories', () => {
+ const explicitFactory: ReadClientFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'explicit-client' })),
+ }
- it('returns null explorerAddressUrl when chain has no explorer', () => {
- const wrapper = makeWrapper({ chains: [mockChain] })
- const { result } = renderHook(() => useReadOnly({ chainId: 1, address: '0xabc' }), {
- wrapper,
+ const { result } = renderHook(
+ () => useReadOnly({ chainId: 1, factory: explicitFactory }),
+ { wrapper: makeWrapper() },
+ )
+
+ expect(result.current.client).toEqual({ type: 'explicit-client' })
})
- expect(result.current.explorerAddressUrl).toBeNull()
})
})
diff --git a/src/sdk/react/hooks/useReadOnly.ts b/src/sdk/react/hooks/useReadOnly.ts
index 8cc2b606..672df8f5 100644
--- a/src/sdk/react/hooks/useReadOnly.ts
+++ b/src/sdk/react/hooks/useReadOnly.ts
@@ -1,17 +1,24 @@
import { useMemo } from 'react'
+
+import type { ReadClientFactory } from '../../core/adapters/provider'
import type { ChainDescriptor } from '../../core/chain'
import { getExplorerUrl } from '../../core/chain/explorer'
import { useProviderContext } from '../provider/context'
-export interface UseReadOnlyOptions {
+export interface UseReadOnlyOptions {
chainId: string | number
address?: string
+ /**
+ * Level 4 escape hatch: explicit factory — bypasses provider resolution.
+ * @precondition factory.chainType should match the chain's chainType
+ */
+ factory?: ReadClientFactory
}
-export interface UseReadOnlyReturn {
+export interface UseReadOnlyReturn {
chain: ChainDescriptor | null
- /** Opaque read-only client created by the matching ReadClientFactory. null if no factory registered. */
- client: unknown
+ /** Read-only client created by the matching factory. null if no factory registered or chain not found. */
+ client: TClient | null
/** The address passed in options, or null if not provided. */
address: string | null
/** Explorer URL for the given address, or null if address or explorer config is missing. */
@@ -20,15 +27,18 @@ export interface UseReadOnlyReturn {
/**
* Returns the ChainDescriptor, a read-only client, and optional address info for the given chainId.
- * The client is created by the matching ReadClientFactory registered in DAppBoosterConfig.
+ * The client is created by the matching ReadClientFactory — either from the explicit `factory` option
+ * (Level 4 bypass) or from the provider's readClientFactories.
*
* @precondition Must be called inside a DAppBoosterProvider
* @precondition options.chainId identifies a chain registered in the provider config
* @postcondition returns chain descriptor and read-only client (null when chain/factory/endpoint missing)
* @postcondition returns address as-is from options, or null when not provided
- * @postcondition returns explorerAddressUrl when both address and chain explorer config are present, null otherwise
+ * @postcondition returns explorerAddressUrl when both address and chain explorer config are present
*/
-export function useReadOnly(options: UseReadOnlyOptions): UseReadOnlyReturn {
+export function useReadOnly(
+ options: UseReadOnlyOptions,
+): UseReadOnlyReturn {
const { registry, readClientFactories } = useProviderContext()
const chain = useMemo(() => registry.getChain(options.chainId), [registry, options.chainId])
@@ -37,16 +47,22 @@ export function useReadOnly(options: UseReadOnlyOptions): UseReadOnlyReturn {
if (!chain) {
return null
}
- const factory = readClientFactories.find((f) => f.chainType === chain.chainType)
- if (!factory) {
- return null
- }
+
const endpoint = chain.endpoints?.[0]
if (!endpoint) {
return null
}
- return factory.createClient(endpoint, chain.chainId)
- }, [chain, readClientFactories])
+
+ if (options.factory) {
+ return options.factory.createClient(endpoint, chain.chainId)
+ }
+
+ const providerFactory = readClientFactories.find((f) => f.chainType === chain.chainType)
+ if (!providerFactory) {
+ return null
+ }
+ return providerFactory.createClient(endpoint, chain.chainId) as TClient
+ }, [chain, readClientFactories, options.factory])
const address = options.address ?? null
From 0825c12c6635f26391e43edeb8fd77041542e046 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:21:14 +0200
Subject: [PATCH 42/52] feat: add useEvmReadOnly typed hook for EVM read-only
clients
---
src/sdk/react/evm/index.ts | 2 +
src/sdk/react/evm/read-only.test.tsx | 68 ++++++++++++++++++++++++++++
src/sdk/react/evm/read-only.ts | 22 +++++++++
3 files changed, 92 insertions(+)
create mode 100644 src/sdk/react/evm/read-only.test.tsx
create mode 100644 src/sdk/react/evm/read-only.ts
diff --git a/src/sdk/react/evm/index.ts b/src/sdk/react/evm/index.ts
index f629957c..31fe5fe6 100644
--- a/src/sdk/react/evm/index.ts
+++ b/src/sdk/react/evm/index.ts
@@ -1,4 +1,6 @@
export { connectkitConnector, rainbowkitConnector, reownConnector } from './connectors'
+export type { UseEvmReadOnlyOptions } from './read-only'
+export { useEvmReadOnly } from './read-only'
export type { EvmConnectorConfig } from './types'
export type { EvmWalletBundleConfig } from './wallet-bundle'
export { createEvmWalletBundle } from './wallet-bundle'
diff --git a/src/sdk/react/evm/read-only.test.tsx b/src/sdk/react/evm/read-only.test.tsx
new file mode 100644
index 00000000..0b30fedc
--- /dev/null
+++ b/src/sdk/react/evm/read-only.test.tsx
@@ -0,0 +1,68 @@
+import { renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { createElement } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { DAppBoosterProvider } from '../provider/DAppBoosterProvider'
+import { useEvmReadOnly } from './read-only'
+
+vi.mock('@/src/wallet/providers', () => ({
+ Web3Provider: ({ children }: { children: ReactNode }) => <>{children}>,
+}))
+
+vi.mock('viem', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ createPublicClient: vi.fn(() => ({
+ type: 'mock-public-client',
+ readContract: vi.fn(),
+ })),
+ }
+})
+
+const mockChain = {
+ caip2Id: 'eip155:1',
+ chainId: 1,
+ name: 'Ethereum',
+ chainType: 'evm',
+ nativeCurrency: { symbol: 'ETH', decimals: 18 },
+ addressConfig: { format: 'hex' as const, patterns: [], example: '0x...' },
+ endpoints: [{ url: 'https://eth.example.com', protocol: 'json-rpc' as const }],
+}
+
+const makeWrapper =
+ () =>
+ ({ children }: { children: ReactNode }) =>
+ createElement(DAppBoosterProvider, { config: { chains: [mockChain] } }, children)
+
+describe('useEvmReadOnly', () => {
+ it('returns a PublicClient-typed client for an EVM chain', () => {
+ const { result } = renderHook(() => useEvmReadOnly({ chainId: 1 }), {
+ wrapper: makeWrapper(),
+ })
+ expect(result.current.client).not.toBeNull()
+ expect(result.current.client).toHaveProperty('readContract')
+ })
+
+ it('returns the chain descriptor', () => {
+ const { result } = renderHook(() => useEvmReadOnly({ chainId: 1 }), {
+ wrapper: makeWrapper(),
+ })
+ expect(result.current.chain?.name).toBe('Ethereum')
+ })
+
+ it('works without provider readClientFactories configured', () => {
+ const { result } = renderHook(() => useEvmReadOnly({ chainId: 1 }), {
+ wrapper: makeWrapper(),
+ })
+ expect(result.current.client).not.toBeNull()
+ })
+
+ it('returns null client for unknown chainId', () => {
+ const { result } = renderHook(() => useEvmReadOnly({ chainId: 999 }), {
+ wrapper: makeWrapper(),
+ })
+ expect(result.current.chain).toBeNull()
+ expect(result.current.client).toBeNull()
+ })
+})
diff --git a/src/sdk/react/evm/read-only.ts b/src/sdk/react/evm/read-only.ts
new file mode 100644
index 00000000..44688f53
--- /dev/null
+++ b/src/sdk/react/evm/read-only.ts
@@ -0,0 +1,22 @@
+import type { PublicClient } from 'viem'
+
+import { evmReadClientFactory } from '../../core/evm/read-client'
+import type { UseReadOnlyReturn } from '../hooks/useReadOnly'
+import { useReadOnly } from '../hooks/useReadOnly'
+
+export interface UseEvmReadOnlyOptions {
+ chainId: string | number
+ address?: string
+}
+
+/**
+ * Typed EVM read-only hook. Returns a viem PublicClient for the given chain.
+ * Self-sufficient — works even without provider readClientFactories configured.
+ *
+ * @precondition Must be called inside a DAppBoosterProvider
+ * @precondition chainId must be an EVM chain registered in the provider
+ * @postcondition returns { client: PublicClient | null, chain, address, explorerAddressUrl }
+ */
+export function useEvmReadOnly(options: UseEvmReadOnlyOptions): UseReadOnlyReturn {
+ return useReadOnly({ ...options, factory: evmReadClientFactory })
+}
From 05b8c895f7527cddfea9b260d2156677cd32347b Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:23:30 +0200
Subject: [PATCH 43/52] feat: auto-contribute read client factories from wallet
bundles
---
src/sdk/react/evm/wallet-bundle.tsx | 8 +-
.../provider/DAppBoosterProvider.test.tsx | 85 +++++++++++++++++++
.../react/provider/DAppBoosterProvider.tsx | 23 ++++-
3 files changed, 113 insertions(+), 3 deletions(-)
diff --git a/src/sdk/react/evm/wallet-bundle.tsx b/src/sdk/react/evm/wallet-bundle.tsx
index bff348a1..d45e0816 100644
--- a/src/sdk/react/evm/wallet-bundle.tsx
+++ b/src/sdk/react/evm/wallet-bundle.tsx
@@ -9,6 +9,7 @@ import type { Chain, Transport } from 'viem'
import { type Config, WagmiProvider } from 'wagmi'
import type { WalletAdapterBundle } from '../../core/adapters/provider'
+import { evmReadClientFactory } from '../../core/evm/read-client'
import { createEvmWalletAdapter } from '../../core/evm/wallet'
import type { EvmConnectorConfig } from './types'
@@ -57,5 +58,10 @@ export function createEvmWalletBundle(config: EvmWalletBundleConfig): WalletAdap
)
- return { adapter, Provider, useConnectModal: config.connector.useConnectModal }
+ return {
+ adapter,
+ Provider,
+ useConnectModal: config.connector.useConnectModal,
+ readClientFactory: evmReadClientFactory,
+ }
}
diff --git a/src/sdk/react/provider/DAppBoosterProvider.test.tsx b/src/sdk/react/provider/DAppBoosterProvider.test.tsx
index 48f1ec03..008ea2d7 100644
--- a/src/sdk/react/provider/DAppBoosterProvider.test.tsx
+++ b/src/sdk/react/provider/DAppBoosterProvider.test.tsx
@@ -210,3 +210,88 @@ describe('useProviderContext', () => {
expect(() => renderHook(() => useProviderContext(), { wrapper })).toThrow('chainType mismatch')
})
})
+
+describe('auto-contribute readClientFactories', () => {
+ it('collects readClientFactory from wallet bundles', () => {
+ const mockFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'auto-client' })),
+ }
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useProviderContext(), { wrapper })
+ expect(result.current.readClientFactories).toHaveLength(1)
+ expect(result.current.readClientFactories[0].chainType).toBe('evm')
+ })
+
+ it('explicit readClientFactories take precedence over bundle factories', () => {
+ const bundleFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'bundle-client' })),
+ }
+ const explicitFactory = {
+ chainType: 'evm',
+ createClient: vi.fn(() => ({ type: 'explicit-client' })),
+ }
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useProviderContext(), { wrapper })
+ expect(result.current.readClientFactories).toHaveLength(1)
+ expect(result.current.readClientFactories[0].createClient(null as never, 1)).toEqual({
+ type: 'explicit-client',
+ })
+ })
+
+ it('deduplicates factories by chainType', () => {
+ const factory1 = { chainType: 'evm', createClient: vi.fn() }
+ const factory2 = { chainType: 'evm', createClient: vi.fn() }
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useProviderContext(), { wrapper })
+ expect(result.current.readClientFactories).toHaveLength(1)
+ })
+})
diff --git a/src/sdk/react/provider/DAppBoosterProvider.tsx b/src/sdk/react/provider/DAppBoosterProvider.tsx
index 8ca20d61..5670d400 100644
--- a/src/sdk/react/provider/DAppBoosterProvider.tsx
+++ b/src/sdk/react/provider/DAppBoosterProvider.tsx
@@ -7,7 +7,11 @@ import {
useMemo,
useRef,
} from 'react'
-import type { DAppBoosterConfig, WalletAdapterBundle } from '../../core/adapters/provider'
+import type {
+ DAppBoosterConfig,
+ ReadClientFactory,
+ WalletAdapterBundle,
+} from '../../core/adapters/provider'
import type { ChainDescriptor } from '../../core/chain/descriptor'
import { createChainRegistry } from '../../core/chain/registry'
import type { DAppBoosterContextValue } from './context'
@@ -126,13 +130,28 @@ export const DAppBoosterProvider: FC = ({ config = {},
const registry = createChainRegistry(deduped)
+ // Collect read client factories: explicit config first, then auto-contributed from bundles
+ const explicitFactories = config.readClientFactories ?? []
+ const bundleFactories = wallets
+ .map(([, bundle]) => bundle.readClientFactory)
+ .filter((f): f is ReadClientFactory => f != null)
+
+ const seenFactoryTypes = new Set()
+ const allFactories: ReadClientFactory[] = []
+ for (const factory of [...explicitFactories, ...bundleFactories]) {
+ if (!seenFactoryTypes.has(factory.chainType)) {
+ seenFactoryTypes.add(factory.chainType)
+ allFactories.push(factory)
+ }
+ }
+
return {
walletAdapters: Object.fromEntries(wallets.map(([key, bundle]) => [key, bundle.adapter])),
transactionAdapters: config.transactions ?? {},
registry,
lifecycle: config.lifecycle,
walletLifecycle: config.walletLifecycle,
- readClientFactories: config.readClientFactories ?? [],
+ readClientFactories: allFactories,
connectModalsRef,
}
}, [config])
From 0af32157c36a53262edb25caa741848e30a4a43d Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 18:27:11 +0200
Subject: [PATCH 44/52] fix: add type assertions to generic ReadClientFactory
mock factories in tests
---
src/sdk/core/read-client.test.ts | 4 ++--
src/sdk/react/hooks/useReadOnly.test.ts | 10 +++++-----
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/sdk/core/read-client.test.ts b/src/sdk/core/read-client.test.ts
index 5a25dee6..eff64939 100644
--- a/src/sdk/core/read-client.test.ts
+++ b/src/sdk/core/read-client.test.ts
@@ -30,12 +30,12 @@ type MockSvmClient = { type: 'svm-client' }
const evmFactory: ReadClientFactory = {
chainType: 'evm',
- createClient: vi.fn(() => ({ type: 'evm-client' })),
+ createClient: vi.fn(() => ({ type: 'evm-client' }) as MockEvmClient),
}
const svmFactory: ReadClientFactory = {
chainType: 'svm',
- createClient: vi.fn(() => ({ type: 'svm-client' })),
+ createClient: vi.fn(() => ({ type: 'svm-client' }) as MockSvmClient),
}
describe('createReadClient', () => {
diff --git a/src/sdk/react/hooks/useReadOnly.test.ts b/src/sdk/react/hooks/useReadOnly.test.ts
index d79daf54..293653a6 100644
--- a/src/sdk/react/hooks/useReadOnly.test.ts
+++ b/src/sdk/react/hooks/useReadOnly.test.ts
@@ -33,7 +33,7 @@ type MockClient = { type: 'mock-client' }
const mockFactory: ReadClientFactory = {
chainType: 'evm',
- createClient: vi.fn(() => ({ type: 'mock-client' })),
+ createClient: vi.fn(() => ({ type: 'mock-client' }) as MockClient),
}
const makeWrapper =
@@ -94,7 +94,7 @@ describe('useReadOnly', () => {
}
const explicitFactory: ReadClientFactory = {
chainType: 'evm',
- createClient: vi.fn(() => ({ type: 'explicit-client' })),
+ createClient: vi.fn(() => ({ type: 'mock-client' }) as MockClient),
}
const { result } = renderHook(
@@ -102,14 +102,14 @@ describe('useReadOnly', () => {
{ wrapper: makeWrapper({ readClientFactories: [providerFactory] }) },
)
- expect(result.current.client).toEqual({ type: 'explicit-client' })
+ expect(result.current.client).toEqual({ type: 'mock-client' })
expect(providerFactory.createClient).not.toHaveBeenCalled()
})
it('works even when provider has no readClientFactories', () => {
const explicitFactory: ReadClientFactory = {
chainType: 'evm',
- createClient: vi.fn(() => ({ type: 'explicit-client' })),
+ createClient: vi.fn(() => ({ type: 'mock-client' }) as MockClient),
}
const { result } = renderHook(
@@ -117,7 +117,7 @@ describe('useReadOnly', () => {
{ wrapper: makeWrapper() },
)
- expect(result.current.client).toEqual({ type: 'explicit-client' })
+ expect(result.current.client).toEqual({ type: 'mock-client' })
})
})
})
From c6d74c410b2d5dd8c0e7ee3877ecc156484cb0e5 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 21:30:07 +0200
Subject: [PATCH 45/52] fix: enforce transport-per-chain precondition on
createEvmTransactionAdapter
---
src/sdk/core/evm/transaction.test.ts | 9 +++++++++
src/sdk/core/evm/transaction.ts | 7 +++++++
2 files changed, 16 insertions(+)
diff --git a/src/sdk/core/evm/transaction.test.ts b/src/sdk/core/evm/transaction.test.ts
index b230806c..98e621ab 100644
--- a/src/sdk/core/evm/transaction.test.ts
+++ b/src/sdk/core/evm/transaction.test.ts
@@ -39,6 +39,15 @@ describe('createEvmTransactionAdapter', () => {
)
})
+ it('throws when a chain has no corresponding transport', () => {
+ expect(() =>
+ createEvmTransactionAdapter({
+ chains: [mainnet],
+ transports: {},
+ }),
+ ).toThrow('chain "Ethereum" (id: 1) has no transport configured')
+ })
+
// ---------------------------------------------------------------------------
// structural / metadata
// ---------------------------------------------------------------------------
diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts
index 9759803b..3e853f06 100644
--- a/src/sdk/core/evm/transaction.ts
+++ b/src/sdk/core/evm/transaction.ts
@@ -61,6 +61,13 @@ export function createEvmTransactionAdapter(
'createEvmTransactionAdapter requires at least one chain. Provide chains in config.chains.',
)
}
+ for (const chain of config.chains) {
+ if (!config.transports[chain.id]) {
+ throw new Error(
+ `createEvmTransactionAdapter: chain "${chain.name}" (id: ${chain.id}) has no transport configured.`,
+ )
+ }
+ }
const publicClients = new Map(
config.chains.map((chain) => [
chain.id,
From d0de245baf84a758ae438808cf344631f997352c Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 21:30:38 +0200
Subject: [PATCH 46/52] fix: enforce privateKey format precondition on
createEvmServerWallet
---
src/sdk/core/evm/server-wallet.test.ts | 20 ++++++++++++++++++++
src/sdk/core/evm/server-wallet.ts | 5 +++++
2 files changed, 25 insertions(+)
diff --git a/src/sdk/core/evm/server-wallet.test.ts b/src/sdk/core/evm/server-wallet.test.ts
index fc901e35..519746ed 100644
--- a/src/sdk/core/evm/server-wallet.test.ts
+++ b/src/sdk/core/evm/server-wallet.test.ts
@@ -1,4 +1,5 @@
import type { WalletClient } from 'viem'
+import { mainnet } from 'viem/chains'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { CapabilityNotSupportedError } from '../errors'
@@ -126,4 +127,23 @@ describe('createEvmServerWallet', () => {
})
expect(bundle.Provider).toBeUndefined()
})
+
+ it('throws when privateKey is not 0x-prefixed', () => {
+ expect(() =>
+ createEvmServerWallet({
+ privateKey:
+ 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234' as `0x${string}`,
+ chain: mainnet,
+ }),
+ ).toThrow('privateKey must be a 0x-prefixed 66-character hex string')
+ })
+
+ it('throws when privateKey is wrong length', () => {
+ expect(() =>
+ createEvmServerWallet({
+ privateKey: '0xabcd' as `0x${string}`,
+ chain: mainnet,
+ }),
+ ).toThrow('privateKey must be a 0x-prefixed 66-character hex string')
+ })
})
diff --git a/src/sdk/core/evm/server-wallet.ts b/src/sdk/core/evm/server-wallet.ts
index 186f0ed3..f503aad8 100644
--- a/src/sdk/core/evm/server-wallet.ts
+++ b/src/sdk/core/evm/server-wallet.ts
@@ -41,6 +41,11 @@ export interface EvmServerWalletConfig {
* @invariant getStatus().connected === true (always connected)
*/
export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdapterBundle {
+ if (!config.privateKey.startsWith('0x') || config.privateKey.length !== 66) {
+ throw new Error(
+ 'createEvmServerWallet: privateKey must be a 0x-prefixed 66-character hex string.',
+ )
+ }
const account = privateKeyToAccount(config.privateKey)
const walletClient = createWalletClient({
account,
From c541d49a2926329acdafe6c6e9ef43ecefc6d2e7 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 21:31:18 +0200
Subject: [PATCH 47/52] fix: enforce coreConnector.createConfig precondition on
createEvmWalletAdapter
---
src/sdk/core/evm/wallet.test.ts | 10 ++++++++++
src/sdk/core/evm/wallet.ts | 5 +++++
2 files changed, 15 insertions(+)
diff --git a/src/sdk/core/evm/wallet.test.ts b/src/sdk/core/evm/wallet.test.ts
index 60421f04..ab620c34 100644
--- a/src/sdk/core/evm/wallet.test.ts
+++ b/src/sdk/core/evm/wallet.test.ts
@@ -150,6 +150,16 @@ describe('createEvmWalletAdapter — unit tests', () => {
).toThrow('createEvmWalletAdapter requires at least one chain')
})
+ it('throws when coreConnector does not provide createConfig', () => {
+ expect(() =>
+ createEvmWalletAdapter({
+ coreConnector: {} as never,
+ chains: [mainnet],
+ transports: { [mainnet.id]: http() },
+ }),
+ ).toThrow('config.coreConnector must provide a createConfig function')
+ })
+
// -------------------------------------------------------------------------
// getStatus()
// -------------------------------------------------------------------------
diff --git a/src/sdk/core/evm/wallet.ts b/src/sdk/core/evm/wallet.ts
index 80ab1197..a24bb57f 100644
--- a/src/sdk/core/evm/wallet.ts
+++ b/src/sdk/core/evm/wallet.ts
@@ -125,6 +125,11 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): EvmWalletAdapte
'createEvmWalletAdapter requires at least one chain. Provide chains in config.chains.',
)
}
+ if (typeof config.coreConnector?.createConfig !== 'function') {
+ throw new Error(
+ 'createEvmWalletAdapter: config.coreConnector must provide a createConfig function.',
+ )
+ }
const wagmiConfig =
config.wagmiConfig ?? config.coreConnector.createConfig(config.chains, config.transports)
const supportedChains = config.chains.map(fromViemChain)
From f56c340b12717b60dde1de91c901135568645767 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 21:37:13 +0200
Subject: [PATCH 48/52] refactor: introduce @expects tag and relabel unenforced
@precondition annotations across SDK
---
CLAUDE.md | 1 +
src/sdk/core/chain/explorer.ts | 4 ++--
src/sdk/core/errors/format.ts | 6 +++---
src/sdk/core/evm/read-client.ts | 2 +-
src/sdk/core/evm/server-wallet.ts | 2 +-
src/sdk/core/evm/transaction.ts | 4 ++--
src/sdk/core/evm/wallet.ts | 2 +-
src/sdk/core/read-client.ts | 4 ++--
src/sdk/core/utils/wrap-adapter.ts | 6 +++---
src/sdk/react/components/WalletGuard.tsx | 8 ++++----
src/sdk/react/evm/read-only.ts | 4 ++--
src/sdk/react/evm/wallet-bundle.tsx | 4 ++--
src/sdk/react/hooks/useMultiWallet.ts | 4 ++--
src/sdk/react/hooks/useReadOnly.ts | 6 +++---
src/sdk/react/hooks/useTransaction.ts | 6 +++---
src/sdk/react/hooks/useWallet.ts | 4 ++--
src/sdk/react/internal/walletLifecycle.ts | 6 +++---
src/sdk/react/lifecycle/createNotificationLifecycle.ts | 6 +++---
18 files changed, 40 insertions(+), 39 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f782a89f..f113399a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,6 +100,7 @@ The `/issue` skill applies these labels automatically when creating issues via C
- Path aliases: `@/src/*` and `@packageJSON`
- All env vars prefixed with `PUBLIC_` and validated in `src/env.ts`
- JSDoc comments on exported functions/components (follow existing patterns)
+- Use `@precondition` in JSDoc ONLY for conditions enforced at runtime with a throw/guard. Use `@expects` for documented assumptions the caller is responsible for (TypeScript-enforced, gracefully handled, or downstream-validated). If you write `@precondition`, the function MUST validate and throw on violation.
## Styling
diff --git a/src/sdk/core/chain/explorer.ts b/src/sdk/core/chain/explorer.ts
index 156f6bae..30d2a942 100644
--- a/src/sdk/core/chain/explorer.ts
+++ b/src/sdk/core/chain/explorer.ts
@@ -8,8 +8,8 @@ type ExplorerParams =
/**
* Builds an explorer URL for a transaction, address, or block.
*
- * @precondition registry is a valid ChainRegistry
- * @precondition params.chainId identifies a chain, params contains exactly one of tx/address/block
+ * @expects registry is a valid ChainRegistry
+ * @expects params.chainId identifies a chain, params contains exactly one of tx/address/block
* @postcondition returns a fully qualified URL string, or null if chain/explorer/path not found
* @postcondition if explorer.queryParams is defined, they are appended as URL search params
*/
diff --git a/src/sdk/core/errors/format.ts b/src/sdk/core/errors/format.ts
index bba9db68..c4e552c1 100644
--- a/src/sdk/core/errors/format.ts
+++ b/src/sdk/core/errors/format.ts
@@ -11,7 +11,7 @@
/**
* Extracts shortMessage from viem errors, walking the cause chain if needed.
*
- * @precondition error is any value (null-safe)
+ * @expects error is any value (null-safe)
* @postcondition returns the first shortMessage or details found, or null
*/
export function extractViemErrorMessage(error: unknown): string | null {
@@ -40,7 +40,7 @@ export function extractViemErrorMessage(error: unknown): string | null {
* Strips technical data (hex, addresses, viem internals) from error messages.
* Acts as a safety net so no raw data leaks to the user.
*
- * @precondition message is a non-empty string
+ * @expects message is a non-empty string
* @postcondition returns a sanitized string with technical data removed
*/
export function sanitizeErrorMessage(message: string): string {
@@ -81,7 +81,7 @@ export function sanitizeErrorMessage(message: string): string {
* 3. Extract revert reason from "execution reverted: ..." pattern
* 4. Sanitize remaining verbose messages (strip hex, addresses, technical blocks)
*
- * @precondition error is any value — string, Error, viem error, null, undefined
+ * @expects error is any value — string, Error, viem error, null, undefined
* @postcondition returns a clean, user-friendly string (never empty, never throws)
*/
export function formatErrorMessage(error: unknown): string {
diff --git a/src/sdk/core/evm/read-client.ts b/src/sdk/core/evm/read-client.ts
index 690f73fe..5eed0d20 100644
--- a/src/sdk/core/evm/read-client.ts
+++ b/src/sdk/core/evm/read-client.ts
@@ -6,7 +6,7 @@ import type { EndpointConfig } from '../chain/descriptor'
/**
* Read-only client factory for EVM chains. Wraps viem's createPublicClient.
*
- * @precondition endpoint.url is a valid JSON-RPC URL
+ * @expects endpoint.url is a valid JSON-RPC URL
* @postcondition returns a viem PublicClient for read-only chain queries
*/
export const evmReadClientFactory: ReadClientFactory = {
diff --git a/src/sdk/core/evm/server-wallet.ts b/src/sdk/core/evm/server-wallet.ts
index f503aad8..f261c66c 100644
--- a/src/sdk/core/evm/server-wallet.ts
+++ b/src/sdk/core/evm/server-wallet.ts
@@ -33,7 +33,7 @@ export interface EvmServerWalletConfig {
* Returns no Provider — server wallets have no UI layer.
*
* @precondition config.privateKey is a valid hex-encoded private key
- * @precondition config.chain is a valid viem Chain
+ * @expects config.chain is a valid viem Chain
* @postcondition returned adapter.chainType === 'evm'
* @postcondition returned bundle has no Provider (server wallets have no UI)
* @invariant adapter.chainType never changes after construction
diff --git a/src/sdk/core/evm/transaction.ts b/src/sdk/core/evm/transaction.ts
index 3e853f06..fd3ec647 100644
--- a/src/sdk/core/evm/transaction.ts
+++ b/src/sdk/core/evm/transaction.ts
@@ -98,7 +98,7 @@ export function createEvmTransactionAdapter(
/**
* Estimates gas and validates readiness for the given transaction params.
*
- * @precondition params.chainId is in supportedChains
+ * @expects params.chainId is in supportedChains
* @postcondition if ready === true -> execute() can be called with these params
* @postcondition if ready === false -> reason explains why (human-readable)
* @throws {InsufficientFundsError} if balance too low for gas estimation
@@ -225,7 +225,7 @@ export function createEvmTransactionAdapter(
/**
* Waits for the transaction to be confirmed or times out.
*
- * @precondition ref was returned by a previous execute() call on this adapter
+ * @expects ref was returned by a previous execute() call on this adapter
* @postcondition result.status is 'success', 'reverted', or 'timeout'
* @postcondition if 'success' -> result.receipt contains a viem TransactionReceipt
* @throws never (timeout returns TransactionResult with status: 'timeout')
diff --git a/src/sdk/core/evm/wallet.ts b/src/sdk/core/evm/wallet.ts
index a24bb57f..1b71a916 100644
--- a/src/sdk/core/evm/wallet.ts
+++ b/src/sdk/core/evm/wallet.ts
@@ -288,7 +288,7 @@ export function createEvmWalletAdapter(config: EvmWalletConfig): EvmWalletAdapte
* Signs EIP-712 typed data with the connected wallet.
*
* @precondition getStatus().connected === true
- * @precondition metadata.capabilities.signTypedData === true
+ * @expects metadata.capabilities.signTypedData === true
* @postcondition result.address matches the signing account
* @throws {WalletNotConnectedError} if not connected
* @throws {SigningRejectedError} if user cancels
diff --git a/src/sdk/core/read-client.ts b/src/sdk/core/read-client.ts
index ca15f8fa..db137c1e 100644
--- a/src/sdk/core/read-client.ts
+++ b/src/sdk/core/read-client.ts
@@ -5,7 +5,7 @@ import type { ChainRegistry } from './chain/registry'
* Creates a typed read-only client using a specific factory.
* For CLI tools, agent scripts, and other non-React consumers.
*
- * @precondition chainId is registered in the registry
+ * @expects chainId is registered in the registry
* @postcondition returns a typed client, or null if chain/endpoint not found
*/
export function createReadClient(
@@ -28,7 +28,7 @@ export function createReadClient(
* Resolves a read-only client from a heterogeneous factory array.
* For multi-VM loops where the chain type isn't known ahead of time.
*
- * @precondition chainId is registered in the registry
+ * @expects chainId is registered in the registry
* @postcondition returns a client, or null if chain/factory/endpoint not found
*/
export function resolveReadClient(
diff --git a/src/sdk/core/utils/wrap-adapter.ts b/src/sdk/core/utils/wrap-adapter.ts
index 8e93b525..b8b0d834 100644
--- a/src/sdk/core/utils/wrap-adapter.ts
+++ b/src/sdk/core/utils/wrap-adapter.ts
@@ -16,7 +16,7 @@ export interface WrapAdapterHooks {
* Always return an args array — return the input unchanged for pass-through.
* Errors propagate (not fire-and-forget).
*
- * @precondition args is the original arguments array
+ * @expects args is the original arguments array
* @postcondition returned array replaces args for the method call
* @throws any error thrown here aborts the method call
*/
@@ -41,7 +41,7 @@ export interface WrapAdapterHooks {
* Always return a value — return the input unchanged for pass-through.
* Errors propagate (not fire-and-forget).
*
- * @precondition result is the resolved value from the method
+ * @expects result is the resolved value from the method
* @postcondition returned value replaces the method result
* @throws any error thrown here aborts the call
*/
@@ -66,7 +66,7 @@ function collectMethodKeys(obj: object): string[] {
*
* Execution order: `beforeCall` -> `onBefore` -> method -> `onAfter` -> `afterCall`
*
- * @precondition adapter is a non-null object
+ * @expects adapter is a non-null object
* @postcondition returned object has the same interface as adapter with hooks applied
* @throws errors from `beforeCall`/`afterCall` propagate; observation hook errors are swallowed
*/
diff --git a/src/sdk/react/components/WalletGuard.tsx b/src/sdk/react/components/WalletGuard.tsx
index 664bd642..6e29c41f 100644
--- a/src/sdk/react/components/WalletGuard.tsx
+++ b/src/sdk/react/components/WalletGuard.tsx
@@ -25,7 +25,7 @@ export interface WalletGuardProps {
/**
* Level 4 escape hatch: explicit wallet adapter — bypasses provider resolution.
* Only applies in single-chain mode (not with `require` prop).
- * @precondition if adapter provided, require must not be set (single-chain mode only)
+ * @expects if adapter provided, require must not be set (single-chain mode only)
*/
adapter?: WalletAdapter
children?: ReactNode
@@ -51,7 +51,7 @@ export interface WalletGuardProps {
* - **Single-chain** (chainId/chainType props): uses useWallet for one adapter
* - **Multi-chain** (require prop): uses useMultiWallet, checks each requirement
*
- * @precondition Either `require` or `chainId`/`chainType` should be provided, not both
+ * @expects Either `require` or `chainId`/`chainType` should be provided, not both
* @postcondition Renders children only when all wallet requirements are satisfied
* @throws Never — renders fallback UI or null instead of throwing
*/
@@ -74,7 +74,7 @@ export const WalletGuard: FC = (props) => {
/**
* Internal component for single-chain wallet gating (original behavior).
- * @precondition useWallet hook is available via provider context
+ * @expects useWallet hook is available via provider context
* @postcondition Renders children when wallet is connected to the correct chain
*/
const SingleChainGuard: FC = ({
@@ -119,7 +119,7 @@ interface MultiChainGuardProps extends WalletGuardProps {
/**
* Internal component for multi-chain wallet gating.
* Iterates requirements and renders fallback for the first unmet one.
- * @precondition useMultiWallet hook is available via provider context
+ * @expects useMultiWallet hook is available via provider context
* @postcondition Renders children only when every requirement has a connected wallet
*/
const MultiChainGuard: FC = ({
diff --git a/src/sdk/react/evm/read-only.ts b/src/sdk/react/evm/read-only.ts
index 44688f53..3623d2c8 100644
--- a/src/sdk/react/evm/read-only.ts
+++ b/src/sdk/react/evm/read-only.ts
@@ -13,8 +13,8 @@ export interface UseEvmReadOnlyOptions {
* Typed EVM read-only hook. Returns a viem PublicClient for the given chain.
* Self-sufficient — works even without provider readClientFactories configured.
*
- * @precondition Must be called inside a DAppBoosterProvider
- * @precondition chainId must be an EVM chain registered in the provider
+ * @expects Must be called inside a DAppBoosterProvider
+ * @expects chainId must be an EVM chain registered in the provider
* @postcondition returns { client: PublicClient | null, chain, address, explorerAddressUrl }
*/
export function useEvmReadOnly(options: UseEvmReadOnlyOptions): UseReadOnlyReturn {
diff --git a/src/sdk/react/evm/wallet-bundle.tsx b/src/sdk/react/evm/wallet-bundle.tsx
index d45e0816..1f9315db 100644
--- a/src/sdk/react/evm/wallet-bundle.tsx
+++ b/src/sdk/react/evm/wallet-bundle.tsx
@@ -34,8 +34,8 @@ export interface EvmWalletBundleConfig {
* Wraps the core createEvmWalletAdapter with WagmiProvider, QueryClientProvider, and the
* connector's WalletProvider.
*
- * @precondition config.chains.length >= 1
- * @precondition config.connector provides createConfig, WalletProvider, and useConnectModal
+ * @expects config.chains.length >= 1
+ * @expects config.connector provides createConfig, WalletProvider, and useConnectModal
* @postcondition returned bundle.adapter.chainType === 'evm'
* @postcondition returned bundle.Provider wraps children with wagmi + query + connector providers
*/
diff --git a/src/sdk/react/hooks/useMultiWallet.ts b/src/sdk/react/hooks/useMultiWallet.ts
index 8f4ef6c1..9502efba 100644
--- a/src/sdk/react/hooks/useMultiWallet.ts
+++ b/src/sdk/react/hooks/useMultiWallet.ts
@@ -10,13 +10,13 @@ export interface UseMultiWalletReturn {
wallets: Record
/**
* Returns the wallet entry whose adapter matches the given chainType.
- * @precondition chainType is a non-empty string
+ * @expects chainType is a non-empty string
* @postcondition returns the first matching UseWalletReturn or undefined
*/
getWallet(chainType: string): UseWalletReturn | undefined
/**
* Returns the wallet entry whose adapter's supportedChains includes the given chainId.
- * @precondition chainId is a string or number identifying a chain
+ * @expects chainId is a string or number identifying a chain
* @postcondition returns the first matching UseWalletReturn or undefined
*/
getWalletByChainId(chainId: string | number): UseWalletReturn | undefined
diff --git a/src/sdk/react/hooks/useReadOnly.ts b/src/sdk/react/hooks/useReadOnly.ts
index 672df8f5..d1aa3e0f 100644
--- a/src/sdk/react/hooks/useReadOnly.ts
+++ b/src/sdk/react/hooks/useReadOnly.ts
@@ -10,7 +10,7 @@ export interface UseReadOnlyOptions {
address?: string
/**
* Level 4 escape hatch: explicit factory — bypasses provider resolution.
- * @precondition factory.chainType should match the chain's chainType
+ * @expects factory.chainType should match the chain's chainType
*/
factory?: ReadClientFactory
}
@@ -30,8 +30,8 @@ export interface UseReadOnlyReturn {
* The client is created by the matching ReadClientFactory — either from the explicit `factory` option
* (Level 4 bypass) or from the provider's readClientFactories.
*
- * @precondition Must be called inside a DAppBoosterProvider
- * @precondition options.chainId identifies a chain registered in the provider config
+ * @expects Must be called inside a DAppBoosterProvider
+ * @expects options.chainId identifies a chain registered in the provider config
* @postcondition returns chain descriptor and read-only client (null when chain/factory/endpoint missing)
* @postcondition returns address as-is from options, or null when not provided
* @postcondition returns explorerAddressUrl when both address and chain explorer config are present
diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts
index 8376a2ce..1e08a84b 100644
--- a/src/sdk/react/hooks/useTransaction.ts
+++ b/src/sdk/react/hooks/useTransaction.ts
@@ -32,12 +32,12 @@ export interface UseTransactionOptions {
confirmOptions?: ConfirmOptions
/**
* Explicit transaction adapter — bypasses provider resolution when set.
- * @precondition if provided, must support the chainId used in execute()/prepare()
+ * @expects if provided, must support the chainId used in execute()/prepare()
*/
transactionAdapter?: TransactionAdapter
/**
* Explicit wallet adapter — bypasses provider resolution when set.
- * @precondition if provided, must be connected when execute() is called
+ * @expects if provided, must be connected when execute() is called
*/
walletAdapter?: WalletAdapter
}
@@ -88,7 +88,7 @@ function fireLifecycle(
* Executes a chain transaction through the registered TransactionAdapter,
* managing phase transitions, preSteps, lifecycle hooks, and error state.
*
- * @precondition must be called inside a DAppBoosterProvider
+ * @expects must be called inside a DAppBoosterProvider
* @postcondition execute() runs the full cycle: prepare -> preSteps -> submit -> confirm
* @postcondition lifecycle hooks fire: global (from provider) first, per-transaction (from options) second
* @postcondition hook errors in lifecycle callbacks are logged but never abort the transaction
diff --git a/src/sdk/react/hooks/useWallet.ts b/src/sdk/react/hooks/useWallet.ts
index f1d2cb9b..df658092 100644
--- a/src/sdk/react/hooks/useWallet.ts
+++ b/src/sdk/react/hooks/useWallet.ts
@@ -62,7 +62,7 @@ interface ResolvedAdapter {
/**
* Resolves a single WalletAdapter from the registered adapters using the provided options.
*
- * @precondition if options.adapter is set, it is used directly (bypasses provider resolution)
+ * @expects if options.adapter is set, it is used directly (bypasses provider resolution)
* @precondition if options.chainType is set, at least one adapter must match that chainType
* @precondition if options.chainId is set, at least one adapter must support that chainId
* @postcondition if exactly one adapter is registered and no options given, returns that adapter
@@ -111,7 +111,7 @@ function resolveAdapter(
* Pass `chainType`, `chainId`, or `adapter` in options to disambiguate when multiple adapters
* are registered. With a single adapter and no options, it resolves automatically.
*
- * @precondition must be called inside a DAppBoosterProvider
+ * @expects must be called inside a DAppBoosterProvider
* @precondition if multiple adapters registered, options must include chainType, chainId, or adapter
* @postcondition status is reactive — re-renders on every wallet status change
* @postcondition signMessage/signTypedData fire global walletLifecycle hooks from provider
diff --git a/src/sdk/react/internal/walletLifecycle.ts b/src/sdk/react/internal/walletLifecycle.ts
index 425b662a..32e639b6 100644
--- a/src/sdk/react/internal/walletLifecycle.ts
+++ b/src/sdk/react/internal/walletLifecycle.ts
@@ -9,7 +9,7 @@ import type {
/**
* Invokes a single WalletLifecycle hook by key, swallowing any error it throws.
*
- * @precondition key must be a valid WalletLifecycle method name
+ * @expects key must be a valid WalletLifecycle method name
* @postcondition the hook is called with args if defined; errors are logged, never propagated
* @throws never — errors thrown by hooks are caught and logged to console.error
*/
@@ -32,7 +32,7 @@ export function fireWalletLifecycle(
/**
* Wraps adapter.signMessage with lifecycle dispatch (onSign, onSignComplete, onSignError).
*
- * @precondition adapter must implement signMessage
+ * @expects adapter must implement signMessage
* @postcondition returned function delegates to adapter.signMessage with full lifecycle hooks
* @throws re-throws the original error from adapter.signMessage after firing onSignError
*/
@@ -58,7 +58,7 @@ export function wrapSignMessage(
* Wraps adapter.signTypedData with lifecycle dispatch (onSign, onSignComplete, onSignError).
* Returns undefined when the adapter does not support signTypedData.
*
- * @precondition adapter may or may not have signTypedData
+ * @expects adapter may or may not have signTypedData
* @postcondition returns undefined if adapter.signTypedData is not defined
* @postcondition returned function (when defined) delegates with full lifecycle hooks
* @throws re-throws the original error from adapter.signTypedData after firing onSignError
diff --git a/src/sdk/react/lifecycle/createNotificationLifecycle.ts b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
index 4d9c0af2..4ab2976d 100644
--- a/src/sdk/react/lifecycle/createNotificationLifecycle.ts
+++ b/src/sdk/react/lifecycle/createNotificationLifecycle.ts
@@ -52,7 +52,7 @@ export interface SigningNotificationLifecycleOptions {
/**
* Builds an explorer URL suffix for a transaction, or empty string if unavailable.
*
- * @precondition ref.id is a valid transaction hash and ref.chainId is a known chain
+ * @expects ref.id is a valid transaction hash and ref.chainId is a known chain
* @postcondition returns a string like ' — https://etherscan.io/tx/0x...' or ''
*/
function buildExplorerSuffix(registry: ChainRegistry | undefined, ref: TransactionRef): string {
@@ -68,7 +68,7 @@ function buildExplorerSuffix(registry: ChainRegistry | undefined, ref: Transacti
*
* Pass the result to useTransaction({ lifecycle }) or TransactionButton lifecycle prop.
*
- * @precondition toaster implements the ToasterAPI interface
+ * @expects toaster implements the ToasterAPI interface
* @postcondition returned lifecycle fires toasts for onSubmit, onConfirm, onReplace, and onError
* @postcondition when registry is provided, confirm and replace toasts include explorer URLs
*/
@@ -120,7 +120,7 @@ export function createNotificationLifecycle({
/**
* Creates a WalletLifecycle that fires toast notifications for signing operations.
*
- * @precondition toaster implements the ToasterAPI interface
+ * @expects toaster implements the ToasterAPI interface
* @postcondition returned lifecycle fires toasts for onSign, onSignComplete, and onSignError
*/
export function createSigningNotificationLifecycle({
From 681b0f16e0a959847cac1608af3f16a238dabd9c Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 21:53:06 +0200
Subject: [PATCH 49/52] =?UTF-8?q?refactor:=20decouple=20EVM=20connectors?=
=?UTF-8?q?=20from=20app=20env=20=E2=80=94=20accept=20metadata=20via=20fac?=
=?UTF-8?q?tory=20params?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/sdk/react/evm/connectors/connectkit.tsx | 37 +++++-----
.../react/evm/connectors/connectors.test.ts | 70 ++++++++++---------
src/sdk/react/evm/connectors/index.ts | 6 +-
src/sdk/react/evm/connectors/rainbowkit.tsx | 35 +++++-----
src/sdk/react/evm/connectors/reown.tsx | 65 ++++++++---------
src/sdk/react/evm/index.ts | 8 ++-
src/sdk/react/evm/types.ts | 9 +++
src/wallet/connectors/wagmi.config.ts | 20 ++++--
8 files changed, 137 insertions(+), 113 deletions(-)
diff --git a/src/sdk/react/evm/connectors/connectkit.tsx b/src/sdk/react/evm/connectors/connectkit.tsx
index c40ca359..a222cc61 100644
--- a/src/sdk/react/evm/connectors/connectkit.tsx
+++ b/src/sdk/react/evm/connectors/connectkit.tsx
@@ -2,9 +2,8 @@ import { ConnectKitProvider, getDefaultConfig, useModal } from 'connectkit'
import type { FC, ReactNode } from 'react'
import type { Chain, Transport } from 'viem'
import { createConfig } from 'wagmi'
-import { env } from '@/src/env'
-import type { EvmConnectorConfig } from '../types'
+import type { ConnectorAppMetadata, EvmConnectorConfig } from '../types'
const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => (
setOpen(true) }
}
-/** ConnectKit-backed EVM connector. */
-export const connectkitConnector: EvmConnectorConfig = {
- createConfig(chains: Chain[], transports: Record) {
- const connectkitParams = getDefaultConfig({
- chains: chains as [Chain, ...Chain[]],
- transports,
- walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID,
- appName: env.PUBLIC_APP_NAME,
- appDescription: env.PUBLIC_APP_DESCRIPTION,
- appUrl: env.PUBLIC_APP_URL,
- appIcon: env.PUBLIC_APP_LOGO,
- })
- return createConfig(connectkitParams)
- },
- WalletProvider,
- useConnectModal,
+/** Creates a ConnectKit-backed EVM connector from app metadata. */
+export function createConnectkitConnector(metadata: ConnectorAppMetadata): EvmConnectorConfig {
+ return {
+ createConfig(chains: Chain[], transports: Record) {
+ const connectkitParams = getDefaultConfig({
+ chains: chains as [Chain, ...Chain[]],
+ transports,
+ walletConnectProjectId: metadata.walletConnectProjectId,
+ appName: metadata.appName,
+ appDescription: metadata.appDescription,
+ appUrl: metadata.appUrl,
+ appIcon: metadata.appIcon,
+ })
+ return createConfig(connectkitParams)
+ },
+ WalletProvider,
+ useConnectModal,
+ }
}
diff --git a/src/sdk/react/evm/connectors/connectors.test.ts b/src/sdk/react/evm/connectors/connectors.test.ts
index 7e2d148a..d2f1264e 100644
--- a/src/sdk/react/evm/connectors/connectors.test.ts
+++ b/src/sdk/react/evm/connectors/connectors.test.ts
@@ -2,53 +2,57 @@ import { http } from 'viem'
import { mainnet } from 'viem/chains'
import { describe, expect, it } from 'vitest'
-import { connectkitConnector } from './connectkit'
-import { rainbowkitConnector } from './rainbowkit'
-import { reownConnector } from './reown'
+import type { ConnectorAppMetadata } from '../types'
+import { createConnectkitConnector } from './connectkit'
+import { createRainbowkitConnector } from './rainbowkit'
+import { createReownConnector } from './reown'
-describe('connectkitConnector', () => {
- it('createConfig returns a wagmi Config containing the supplied chain', () => {
- const config = connectkitConnector.createConfig([mainnet], { [mainnet.id]: http() })
- expect(config.chains).toContain(mainnet)
- })
-
- it('WalletProvider is a function', () => {
- expect(typeof connectkitConnector.WalletProvider).toBe('function')
- })
+const testMetadata: ConnectorAppMetadata = {
+ appName: 'Test App',
+ walletConnectProjectId: 'test-project-id',
+}
- it('useConnectModal is a function', () => {
- expect(typeof connectkitConnector.useConnectModal).toBe('function')
+describe('createConnectkitConnector', () => {
+ it('returns an EvmConnectorConfig with createConfig, WalletProvider, useConnectModal', () => {
+ const connector = createConnectkitConnector(testMetadata)
+ expect(typeof connector.createConfig).toBe('function')
+ expect(typeof connector.WalletProvider).toBe('function')
+ expect(typeof connector.useConnectModal).toBe('function')
})
-})
-describe('rainbowkitConnector', () => {
it('createConfig returns a wagmi Config containing the supplied chain', () => {
- const config = rainbowkitConnector.createConfig([mainnet], { [mainnet.id]: http() })
+ const connector = createConnectkitConnector(testMetadata)
+ const config = connector.createConfig([mainnet], { [mainnet.id]: http() })
expect(config.chains).toContain(mainnet)
})
+})
- it('WalletProvider is a function', () => {
- expect(typeof rainbowkitConnector.WalletProvider).toBe('function')
+describe('createRainbowkitConnector', () => {
+ it('returns an EvmConnectorConfig with createConfig, WalletProvider, useConnectModal', () => {
+ const connector = createRainbowkitConnector(testMetadata)
+ expect(typeof connector.createConfig).toBe('function')
+ expect(typeof connector.WalletProvider).toBe('function')
+ expect(typeof connector.useConnectModal).toBe('function')
})
- it('useConnectModal is a function', () => {
- expect(typeof rainbowkitConnector.useConnectModal).toBe('function')
+ it('createConfig returns a wagmi Config containing the supplied chain', () => {
+ const connector = createRainbowkitConnector(testMetadata)
+ const config = connector.createConfig([mainnet], { [mainnet.id]: http() })
+ expect(config.chains).toContain(mainnet)
})
})
-describe('reownConnector', () => {
- it('createConfig returns a wagmi Config containing the supplied chain id', () => {
- const config = reownConnector.createConfig([mainnet], { [mainnet.id]: http() })
- // Reown's WagmiAdapter wraps chains with extra fields (chainNamespace, caipNetworkId, assets),
- // so reference equality fails — assert by chain id instead.
- expect(config.chains).toContainEqual(expect.objectContaining({ id: mainnet.id }))
+describe('createReownConnector', () => {
+ it('returns an EvmConnectorConfig with createConfig, WalletProvider, useConnectModal', () => {
+ const connector = createReownConnector(testMetadata)
+ expect(typeof connector.createConfig).toBe('function')
+ expect(typeof connector.WalletProvider).toBe('function')
+ expect(typeof connector.useConnectModal).toBe('function')
})
- it('WalletProvider is a function', () => {
- expect(typeof reownConnector.WalletProvider).toBe('function')
- })
-
- it('useConnectModal is a function', () => {
- expect(typeof reownConnector.useConnectModal).toBe('function')
+ it('createConfig returns a wagmi Config containing the supplied chain id', () => {
+ const connector = createReownConnector(testMetadata)
+ const config = connector.createConfig([mainnet], { [mainnet.id]: http() })
+ expect(config.chains).toContainEqual(expect.objectContaining({ id: mainnet.id }))
})
})
diff --git a/src/sdk/react/evm/connectors/index.ts b/src/sdk/react/evm/connectors/index.ts
index ec866b10..a00d4476 100644
--- a/src/sdk/react/evm/connectors/index.ts
+++ b/src/sdk/react/evm/connectors/index.ts
@@ -1,3 +1,3 @@
-export { connectkitConnector } from './connectkit'
-export { rainbowkitConnector } from './rainbowkit'
-export { reownConnector } from './reown'
+export { createConnectkitConnector } from './connectkit'
+export { createRainbowkitConnector } from './rainbowkit'
+export { createReownConnector } from './reown'
diff --git a/src/sdk/react/evm/connectors/rainbowkit.tsx b/src/sdk/react/evm/connectors/rainbowkit.tsx
index dea94e85..05bfa9e1 100644
--- a/src/sdk/react/evm/connectors/rainbowkit.tsx
+++ b/src/sdk/react/evm/connectors/rainbowkit.tsx
@@ -4,12 +4,11 @@ import {
useAccountModal as useRainbowAccountModal,
useConnectModal as useRainbowConnectModal,
} from '@rainbow-me/rainbowkit'
-import { env } from '@/src/env'
import '@rainbow-me/rainbowkit/styles.css'
import type { FC, ReactNode } from 'react'
import type { Chain, Transport } from 'viem'
-import type { EvmConnectorConfig } from '../types'
+import type { ConnectorAppMetadata, EvmConnectorConfig } from '../types'
const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => (
{children}
@@ -24,19 +23,21 @@ function useConnectModal() {
}
}
-/** RainbowKit-backed EVM connector. */
-export const rainbowkitConnector: EvmConnectorConfig = {
- createConfig(chains: Chain[], transports: Record) {
- return getDefaultConfig({
- chains: chains as [Chain, ...Chain[]],
- transports,
- projectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID,
- appName: env.PUBLIC_APP_NAME,
- appDescription: env.PUBLIC_APP_DESCRIPTION,
- appUrl: env.PUBLIC_APP_URL,
- appIcon: env.PUBLIC_APP_LOGO,
- })
- },
- WalletProvider,
- useConnectModal,
+/** Creates a RainbowKit-backed EVM connector from app metadata. */
+export function createRainbowkitConnector(metadata: ConnectorAppMetadata): EvmConnectorConfig {
+ return {
+ createConfig(chains: Chain[], transports: Record) {
+ return getDefaultConfig({
+ chains: chains as [Chain, ...Chain[]],
+ transports,
+ projectId: metadata.walletConnectProjectId,
+ appName: metadata.appName,
+ appDescription: metadata.appDescription,
+ appUrl: metadata.appUrl,
+ appIcon: metadata.appIcon,
+ })
+ },
+ WalletProvider,
+ useConnectModal,
+ }
}
diff --git a/src/sdk/react/evm/connectors/reown.tsx b/src/sdk/react/evm/connectors/reown.tsx
index 460b777a..ddfde208 100644
--- a/src/sdk/react/evm/connectors/reown.tsx
+++ b/src/sdk/react/evm/connectors/reown.tsx
@@ -2,9 +2,8 @@ import { createAppKit, useAppKit } from '@reown/appkit/react'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
import type { FC, PropsWithChildren } from 'react'
import type { Chain, Transport } from 'viem'
-import { env } from '@/src/env'
-import type { EvmConnectorConfig } from '../types'
+import type { ConnectorAppMetadata, EvmConnectorConfig } from '../types'
const WalletProvider: FC = ({ children }) => <>{children}>
@@ -13,36 +12,34 @@ function useConnectModal() {
return { open }
}
-/** Reown/AppKit-backed EVM connector. */
-export const reownConnector: EvmConnectorConfig = {
- createConfig(chains: Chain[], transports: Record) {
- const projectId = env.PUBLIC_WALLETCONNECT_PROJECT_ID
-
- const metadata = {
- name: env.PUBLIC_APP_NAME,
- description: env.PUBLIC_APP_DESCRIPTION ?? '',
- url: env.PUBLIC_APP_URL ?? '',
- icons: [env.PUBLIC_APP_LOGO ?? ''],
- }
-
- const wagmiAdapter = new WagmiAdapter({
- networks: chains as unknown as Chain[],
- transports,
- projectId,
- })
-
- createAppKit({
- adapters: [wagmiAdapter],
- networks: chains as unknown as [Chain, ...Chain[]],
- metadata,
- projectId,
- features: {
- analytics: true,
- },
- })
-
- return wagmiAdapter.wagmiConfig
- },
- WalletProvider,
- useConnectModal,
+/** Creates a Reown/AppKit-backed EVM connector from app metadata. */
+export function createReownConnector(metadata: ConnectorAppMetadata): EvmConnectorConfig {
+ return {
+ createConfig(chains: Chain[], transports: Record) {
+ const wagmiAdapter = new WagmiAdapter({
+ networks: chains as unknown as Chain[],
+ transports,
+ projectId: metadata.walletConnectProjectId,
+ })
+
+ createAppKit({
+ adapters: [wagmiAdapter],
+ networks: chains as unknown as [Chain, ...Chain[]],
+ metadata: {
+ name: metadata.appName,
+ description: metadata.appDescription ?? '',
+ url: metadata.appUrl ?? '',
+ icons: [metadata.appIcon ?? ''],
+ },
+ projectId: metadata.walletConnectProjectId,
+ features: {
+ analytics: true,
+ },
+ })
+
+ return wagmiAdapter.wagmiConfig
+ },
+ WalletProvider,
+ useConnectModal,
+ }
}
diff --git a/src/sdk/react/evm/index.ts b/src/sdk/react/evm/index.ts
index 31fe5fe6..266f605d 100644
--- a/src/sdk/react/evm/index.ts
+++ b/src/sdk/react/evm/index.ts
@@ -1,6 +1,10 @@
-export { connectkitConnector, rainbowkitConnector, reownConnector } from './connectors'
+export {
+ createConnectkitConnector,
+ createRainbowkitConnector,
+ createReownConnector,
+} from './connectors'
export type { UseEvmReadOnlyOptions } from './read-only'
export { useEvmReadOnly } from './read-only'
-export type { EvmConnectorConfig } from './types'
+export type { ConnectorAppMetadata, EvmConnectorConfig } from './types'
export type { EvmWalletBundleConfig } from './wallet-bundle'
export { createEvmWalletBundle } from './wallet-bundle'
diff --git a/src/sdk/react/evm/types.ts b/src/sdk/react/evm/types.ts
index a4932a2d..fee647d2 100644
--- a/src/sdk/react/evm/types.ts
+++ b/src/sdk/react/evm/types.ts
@@ -2,6 +2,15 @@ import type { FC, ReactNode } from 'react'
import type { EvmCoreConnectorConfig } from '../../core/evm/types'
+/** App metadata passed to connector factories. Decouples SDK from app env config. */
+export interface ConnectorAppMetadata {
+ appName: string
+ appDescription?: string
+ appUrl?: string
+ appIcon?: string
+ walletConnectProjectId: string
+}
+
/** React-layer EVM connector config — extends core with UI components. */
export interface EvmConnectorConfig extends EvmCoreConnectorConfig {
WalletProvider: FC<{ children: ReactNode }>
diff --git a/src/wallet/connectors/wagmi.config.ts b/src/wallet/connectors/wagmi.config.ts
index 5246ede8..5435e281 100644
--- a/src/wallet/connectors/wagmi.config.ts
+++ b/src/wallet/connectors/wagmi.config.ts
@@ -2,13 +2,21 @@
* Shared wagmi Config and connector — the single place to choose which EVM connector to use.
* Both the SDK adapter (in __root.tsx) and generated contract hooks reference this file.
*
- * To switch connectors, change the import below:
- * import { connectkitConnector as connector } from '@/src/sdk/react/evm'
- * import { rainbowkitConnector as connector } from '@/src/sdk/react/evm'
- * import { reownConnector as connector } from '@/src/sdk/react/evm'
+ * To switch connectors, change the factory call below:
+ * createConnectkitConnector(metadata)
+ * createRainbowkitConnector(metadata)
+ * createReownConnector(metadata)
*/
import { chains, transports } from '@/src/core/types'
-import { connectkitConnector as connector } from '@/src/sdk/react/evm'
+import { env } from '@/src/env'
+import { createConnectkitConnector } from '@/src/sdk/react/evm'
+
+export const connector = createConnectkitConnector({
+ appName: env.PUBLIC_APP_NAME,
+ appDescription: env.PUBLIC_APP_DESCRIPTION,
+ appUrl: env.PUBLIC_APP_URL,
+ appIcon: env.PUBLIC_APP_LOGO,
+ walletConnectProjectId: env.PUBLIC_WALLETCONNECT_PROJECT_ID,
+})
-export { connector }
export const config = connector.createConfig([...chains], transports)
From 8a03d9fcaa6cc2819fd80cf9f10890c79bbff8c1 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Thu, 9 Apr 2026 21:57:05 +0200
Subject: [PATCH 50/52] test: add integration tests for createEvmWalletBundle
with real wagmi
---
src/sdk/react/evm/wallet-bundle.test.tsx | 96 ++++++++++++++++++++++++
1 file changed, 96 insertions(+)
create mode 100644 src/sdk/react/evm/wallet-bundle.test.tsx
diff --git a/src/sdk/react/evm/wallet-bundle.test.tsx b/src/sdk/react/evm/wallet-bundle.test.tsx
new file mode 100644
index 00000000..fc68053b
--- /dev/null
+++ b/src/sdk/react/evm/wallet-bundle.test.tsx
@@ -0,0 +1,96 @@
+import { render, screen } from '@testing-library/react'
+import type { FC, ReactNode } from 'react'
+import { createElement } from 'react'
+import { http } from 'viem'
+import { mainnet } from 'viem/chains'
+import { describe, expect, it } from 'vitest'
+import { createConfig } from 'wagmi'
+import { mock } from 'wagmi/connectors'
+
+import type { EvmConnectorConfig } from './types'
+import { createEvmWalletBundle } from './wallet-bundle'
+
+const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const
+
+const MockWalletProvider: FC<{ children: ReactNode }> = ({ children }) =>
+ createElement('div', { 'data-testid': 'mock-wallet-provider' }, children)
+
+const mockUseConnectModal = () => ({ open: () => {} })
+
+const testConnector: EvmConnectorConfig = {
+ createConfig(chains, transports) {
+ return createConfig({
+ chains: chains as [typeof mainnet],
+ transports,
+ connectors: [mock({ accounts: [TEST_ADDRESS] })],
+ })
+ },
+ WalletProvider: MockWalletProvider,
+ useConnectModal: mockUseConnectModal,
+}
+
+describe('createEvmWalletBundle', () => {
+ it('returns a bundle with adapter, Provider, useConnectModal, and readClientFactory', () => {
+ const bundle = createEvmWalletBundle({
+ connector: testConnector,
+ chains: [mainnet],
+ transports: { [mainnet.id]: http() },
+ })
+
+ expect(bundle.adapter).toBeDefined()
+ expect(bundle.Provider).toBeDefined()
+ expect(bundle.useConnectModal).toBeDefined()
+ expect(bundle.readClientFactory).toBeDefined()
+ })
+
+ it('adapter has chainType "evm"', () => {
+ const bundle = createEvmWalletBundle({
+ connector: testConnector,
+ chains: [mainnet],
+ transports: { [mainnet.id]: http() },
+ })
+
+ expect(bundle.adapter.chainType).toBe('evm')
+ })
+
+ it('readClientFactory has chainType "evm"', () => {
+ const bundle = createEvmWalletBundle({
+ connector: testConnector,
+ chains: [mainnet],
+ transports: { [mainnet.id]: http() },
+ })
+
+ expect(bundle.readClientFactory?.chainType).toBe('evm')
+ })
+
+ it('Provider renders children through the wagmi + query + connector provider stack', () => {
+ const bundle = createEvmWalletBundle({
+ connector: testConnector,
+ chains: [mainnet],
+ transports: { [mainnet.id]: http() },
+ })
+
+ const BundleProvider = bundle.Provider!
+
+ render(
+ createElement(
+ BundleProvider,
+ null,
+ createElement('div', { 'data-testid': 'child-content' }, 'Hello from child'),
+ ),
+ )
+
+ expect(screen.getByTestId('child-content')).toHaveTextContent('Hello from child')
+ expect(screen.getByTestId('mock-wallet-provider')).toBeInTheDocument()
+ })
+
+ it('throws when chains is empty', () => {
+ expect(() =>
+ createEvmWalletBundle({
+ connector: testConnector,
+ chains: [],
+ transports: {},
+ }),
+ ).toThrow('createEvmWalletAdapter requires at least one chain')
+ })
+})
From 146b27ce98e838c887b5b5ff7649c8cdc7c37043 Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Fri, 10 Apr 2026 14:25:41 +0200
Subject: [PATCH 51/52] fix: auto-reset execution state on
useTransaction.execute() for multi-step flows
---
src/sdk/react/hooks/useTransaction.ts | 10 ++++++++++
src/transactions/components/TransactionButton.tsx | 3 ++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/sdk/react/hooks/useTransaction.ts b/src/sdk/react/hooks/useTransaction.ts
index 1e08a84b..b5b51a3f 100644
--- a/src/sdk/react/hooks/useTransaction.ts
+++ b/src/sdk/react/hooks/useTransaction.ts
@@ -204,6 +204,16 @@ export function useTransaction(options: UseTransactionOptions = {}): UseTransact
const execute = useCallback(
async (params: TransactionParams): Promise => {
+ // Clear results from any previous execution before starting.
+ // Ensures each execute() call starts with a clean slate —
+ // no manual reset() needed between calls in multi-step flows.
+ // Only clears execution results (ref, result, error), NOT preparation
+ // state (prepareResult, preStepStatuses) which may have been set by
+ // a manual prepare() + executePreStep() workflow.
+ setRef(null)
+ setResult(null)
+ setError(null)
+
let currentPhase: TransactionPhase = 'prepare'
try {
diff --git a/src/transactions/components/TransactionButton.tsx b/src/transactions/components/TransactionButton.tsx
index ea61bbbe..34b119ad 100644
--- a/src/transactions/components/TransactionButton.tsx
+++ b/src/transactions/components/TransactionButton.tsx
@@ -67,7 +67,8 @@ function TransactionButton({
try {
await execute(params)
} catch {
- // useTransaction sets error state internally
+ // Error already set in hook state and onError lifecycle fired.
+ // Swallow re-throw to prevent unhandled promise rejection.
}
}
From 8de61871da202d2786b8f8b99c87b6e20456eeff Mon Sep 17 00:00:00 2001
From: fernandomg
Date: Fri, 10 Apr 2026 14:30:31 +0200
Subject: [PATCH 52/52] fix: consolidate shared Wrapper component and remove
duplicate debounce in EnsName demo
---
.../pageComponents/home/Examples/demos/EnsName/index.tsx | 5 +----
.../ERC20ApproveAndTransferButton.tsx | 2 +-
.../ERC20ApproveAndTransferButton/index.tsx | 2 +-
.../home/Examples/demos/TransactionButton/NativeToken.tsx | 2 +-
.../{demos/TransactionButton/Wrapper.tsx => wrapper.tsx} | 0
5 files changed, 4 insertions(+), 7 deletions(-)
rename src/components/pageComponents/home/Examples/{demos/TransactionButton/Wrapper.tsx => wrapper.tsx} (100%)
diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx
index 3191afac..9281bf3c 100644
--- a/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.tsx
@@ -39,10 +39,7 @@ const EnsName = () => {
}, debounceTime)
const onChange = (e: ChangeEvent) => {
- const value = e.target.value as Address
-
- setValue(value)
- debouncedSearch(value)
+ setValue(e.target.value as Address)
}
const addresses = [
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
index 25ced5a7..e70a4c46 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton.tsx
@@ -1,6 +1,6 @@
import type { FC } from 'react'
import { type Abi, type Address, erc20Abi } from 'viem'
-import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
+import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { useSuspenseReadErc20Allowance } from '@/src/contracts/generated'
import type { TransactionParams } from '@/src/sdk/core'
import { getExplorerUrl } from '@/src/sdk/core/chain/explorer'
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
index b4f701f5..e0426ead 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx
@@ -2,7 +2,7 @@ import { type Abi, type Address, formatUnits } from 'viem'
import { baseSepolia } from 'viem/chains'
import BaseERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton'
import MintUSDC from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC'
-import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
+import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { useSuspenseReadErc20BalanceOf } from '@/src/contracts/generated'
import { formatNumberOrString, NumberType, withSuspense } from '@/src/core/utils'
import type { TransactionParams } from '@/src/sdk/core'
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
index 17b9a1d7..4b34124f 100644
--- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx
@@ -3,7 +3,7 @@ import { type ReactElement, useState } from 'react'
import type { Address, TransactionReceipt } from 'viem'
import { parseEther } from 'viem'
import { baseSepolia } from 'viem/chains'
-import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper'
+import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper'
import { GeneralMessage, PrimaryButton } from '@/src/core/components'
import type { TransactionParams, TransactionResult } from '@/src/sdk/core'
import type { EvmRawTransaction } from '@/src/sdk/core/evm/types'
diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper.tsx b/src/components/pageComponents/home/Examples/wrapper.tsx
similarity index 100%
rename from src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper.tsx
rename to src/components/pageComponents/home/Examples/wrapper.tsx