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 ( - + +
-
- - - -