diff --git a/src/OnchainKitProvider.test.tsx b/src/OnchainKitProvider.test.tsx index 812c7ac227..520792a4a0 100644 --- a/src/OnchainKitProvider.test.tsx +++ b/src/OnchainKitProvider.test.tsx @@ -2,14 +2,38 @@ import '@testing-library/jest-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import { base } from 'viem/chains'; -import { describe, expect, it, vi } from 'vitest'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { http, WagmiProvider, createConfig } from 'wagmi'; +import { useConfig } from 'wagmi'; import { mock } from 'wagmi/connectors'; import { setOnchainKitConfig } from './OnchainKitConfig'; import { OnchainKitProvider } from './OnchainKitProvider'; import { COINBASE_VERIFIED_ACCOUNT_SCHEMA_ID } from './identity/constants'; import type { EASSchemaUid } from './identity/types'; import { useOnchainKit } from './useOnchainKit'; +import { useProviderDependencies } from './useProviderDependencies'; + +vi.mock('wagmi', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConfig: vi.fn(), + }; +}); + +vi.mock('./useProviderDependencies', () => ({ + useProviderDependencies: vi.fn(() => ({ + providedWagmiConfig: null, + providedQueryClient: null, + })), +})); + +vi.mock('./useProviderDependencies', () => ({ + useProviderDependencies: vi.fn(() => ({ + providedWagmiConfig: null, + providedQueryClient: null, + })), +})); const queryClient = new QueryClient(); const mockConfig = createConfig({ @@ -51,8 +75,17 @@ describe('OnchainKitProvider', () => { const apiKey = 'test-api-key'; const paymasterUrl = 'https://api.developer.coinbase.com/rpc/v1/base/test-api-key'; - const appLogo = undefined; - const appName = undefined; + const appLogo = ''; + const appName = 'Dapp'; + + beforeEach(() => { + vi.clearAllMocks(); + (useConfig as Mock).mockReturnValue(mockConfig); + (useProviderDependencies as Mock).mockReturnValue({ + providedWagmiConfig: mockConfig, + providedQueryClient: queryClient, + }); + }); it('provides the context value correctly', async () => { render( @@ -71,6 +104,22 @@ describe('OnchainKitProvider', () => { }); }); + it('provides the context value correctly without WagmiProvider', async () => { + (useProviderDependencies as Mock).mockReturnValue({ + providedWagmiConfig: null, + providedQueryClient: null, + }); + render( + + + , + ); + await waitFor(() => { + expect(screen.getByText(schemaId)).toBeInTheDocument(); + expect(screen.getByText(apiKey)).toBeInTheDocument(); + }); + }); + it('throws an error if schemaId does not meet the required length', () => { expect(() => { render( diff --git a/src/OnchainKitProvider.tsx b/src/OnchainKitProvider.tsx index 401bbc28e0..5131e55590 100644 --- a/src/OnchainKitProvider.tsx +++ b/src/OnchainKitProvider.tsx @@ -1,8 +1,12 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createContext, useMemo } from 'react'; +import { WagmiProvider } from 'wagmi'; import { ONCHAIN_KIT_CONFIG, setOnchainKitConfig } from './OnchainKitConfig'; +import { createWagmiConfig } from './createWagmiConfig'; import { COINBASE_VERIFIED_ACCOUNT_SCHEMA_ID } from './identity/constants'; import { checkHashLength } from './internal/utils/checkHashLength'; import type { OnchainKitContextType, OnchainKitProviderReact } from './types'; +import { useProviderDependencies } from './useProviderDependencies'; export const OnchainKitContext = createContext(ONCHAIN_KIT_CONFIG); @@ -24,6 +28,7 @@ export function OnchainKitProvider({ throw Error('EAS schemaId must be 64 characters prefixed with "0x"'); } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore const value = useMemo(() => { const defaultPaymasterUrl = apiKey ? `https://api.developer.coinbase.com/rpc/v1/${chain.name @@ -36,8 +41,8 @@ export function OnchainKitProvider({ chain: chain, config: { appearance: { - name: config?.appearance?.name, - logo: config?.appearance?.logo, + name: config?.appearance?.name ?? 'Dapp', + logo: config?.appearance?.logo ?? '', mode: config?.appearance?.mode ?? 'auto', theme: config?.appearance?.theme ?? 'default', }, @@ -51,6 +56,47 @@ export function OnchainKitProvider({ return onchainKitConfig; }, [address, apiKey, chain, config, projectId, rpcUrl, schemaId]); + // Check the React context for WagmiProvider and QueryClientProvider + const { providedWagmiConfig, providedQueryClient } = + useProviderDependencies(); + + const defaultConfig = useMemo(() => { + // IMPORTANT: Don't create a new Wagmi configuration if one already exists + // This prevents the user-provided WagmiConfig from being overriden + return ( + providedWagmiConfig || + createWagmiConfig({ + apiKey, + appName: value.config.appearance.name, + appLogoUrl: value.config.appearance.logo, + }) + ); + }, [ + apiKey, + providedWagmiConfig, + value.config.appearance.name, + value.config.appearance.logo, + ]); + const defaultQueryClient = useMemo(() => { + // IMPORTANT: Don't create a new QueryClient if one already exists + // This prevents the user-provided QueryClient from being overriden + return providedQueryClient || new QueryClient(); + }, [providedQueryClient]); + + // If both dependencies are missing, return a context with default parent providers + // If only one dependency is provided, expect the user to also provide the missing one + if (!providedWagmiConfig && !providedQueryClient) { + return ( + + + + {children} + + + + ); + } + return ( {children} diff --git a/src/createWagmiConfig.test.ts b/src/createWagmiConfig.test.ts new file mode 100644 index 0000000000..7fcda2f6ef --- /dev/null +++ b/src/createWagmiConfig.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createConfig } from 'wagmi'; +import { http } from 'wagmi'; +import { base, baseSepolia } from 'wagmi/chains'; +import { coinbaseWallet } from 'wagmi/connectors'; +import { createWagmiConfig } from './createWagmiConfig'; + +// Mock the imported modules +vi.mock('wagmi', async () => { + const actual = await vi.importActual('wagmi'); + return { + ...actual, + createConfig: vi.fn(), + createStorage: vi.fn(), + }; +}); + +vi.mock('wagmi/chains', async () => { + const actual = await vi.importActual('wagmi/chains'); + return { + ...actual, + base: { id: 8453 }, + baseSepolia: { id: 84532 }, + }; +}); + +vi.mock('wagmi/connectors', async () => { + const actual = await vi.importActual('wagmi/connectors'); + return { + ...actual, + coinbaseWallet: vi.fn(), + }; +}); + +describe('createWagmiConfig', () => { + it('should create config with default values when no parameters are provided', () => { + createWagmiConfig({}); + expect(createConfig).toHaveBeenCalledWith( + expect.objectContaining({ + chains: [base, baseSepolia], + ssr: true, + transports: { + [base.id]: expect.any(Function), + [baseSepolia.id]: expect.any(Function), + }, + }), + ); + expect(coinbaseWallet).toHaveBeenCalledWith({ + appName: undefined, + appLogoUrl: undefined, + preference: 'all', + }); + }); + + it('should create config with custom values when parameters are provided', () => { + const customConfig = { + appearance: { + name: 'Custom App', + logo: 'https://example.com/logo.png', + }, + }; + createWagmiConfig({ + apiKey: 'test-api-key', + appName: customConfig.appearance.name, + appLogoUrl: customConfig.appearance.logo, + }); + expect(createConfig).toHaveBeenCalledWith( + expect.objectContaining({ + chains: [base, baseSepolia], + ssr: true, + transports: { + [base.id]: expect.any(Function), + [baseSepolia.id]: expect.any(Function), + }, + }), + ); + expect(coinbaseWallet).toHaveBeenCalledWith({ + appName: 'Custom App', + appLogoUrl: 'https://example.com/logo.png', + preference: 'all', + }); + }); + + it('should use API key in transports when provided', () => { + const testApiKey = 'test-api-key'; + const result = createWagmiConfig({ apiKey: testApiKey }); + expect(result).toContain( + http(`https://api.developer.coinbase.com/rpc/v1/base/${testApiKey}`), + ); + expect(result).toContain( + http( + `https://api.developer.coinbase.com/rpc/v1/base-sepolia/${testApiKey}`, + ), + ); + }); +}); diff --git a/src/createWagmiConfig.ts b/src/createWagmiConfig.ts new file mode 100644 index 0000000000..777045afa1 --- /dev/null +++ b/src/createWagmiConfig.ts @@ -0,0 +1,37 @@ +import { http, cookieStorage, createConfig, createStorage } from 'wagmi'; +import { base, baseSepolia } from 'wagmi/chains'; +import { coinbaseWallet } from 'wagmi/connectors'; +import type { CreateWagmiConfigParams } from './types'; + +// createWagmiConfig returns a WagmiConfig (https://wagmi.sh/react/api/createConfig) using OnchainKit provided settings. +// This function should only be used if the user does not provide WagmiProvider as a parent in the React context. +export const createWagmiConfig = ({ + apiKey, + appName, + appLogoUrl, +}: CreateWagmiConfigParams) => { + return createConfig({ + chains: [base, baseSepolia], + connectors: [ + coinbaseWallet({ + appName, + appLogoUrl, + preference: 'all', + }), + ], + storage: createStorage({ + storage: cookieStorage, + }), + ssr: true, + transports: { + [base.id]: apiKey + ? http(`https://api.developer.coinbase.com/rpc/v1/base/${apiKey}`) + : http(), + [baseSepolia.id]: apiKey + ? http( + `https://api.developer.coinbase.com/rpc/v1/base-sepolia/${apiKey}`, + ) + : http(), + }, + }); +}; diff --git a/src/types.ts b/src/types.ts index 4711a0bfc3..6487a85091 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,12 @@ export type AppConfig = { paymaster?: string | null; // Paymaster URL for gas sponsorship }; +export type CreateWagmiConfigParams = { + apiKey?: string; + appName?: string; + appLogoUrl?: string; +}; + /** * Note: exported as public Type */ diff --git a/src/useProviderDependencies.test.tsx b/src/useProviderDependencies.test.tsx new file mode 100644 index 0000000000..4c606477a3 --- /dev/null +++ b/src/useProviderDependencies.test.tsx @@ -0,0 +1,110 @@ +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Config, WagmiProviderNotFoundError, useConfig } from 'wagmi'; +import { useProviderDependencies } from './useProviderDependencies'; + +// Mock the wagmi and react-query hooks +vi.mock('wagmi', async () => { + const actual = await vi.importActual('wagmi'); + return { + ...actual, + useConfig: vi.fn(), + }; +}); + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +describe('useProviderDependencies', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return both configs when they exist', () => { + const mockWagmiConfig = { testWagmi: true } as unknown as Config; + const mockQueryClient = { testQuery: true } as unknown as QueryClient; + vi.mocked(useConfig).mockReturnValue(mockWagmiConfig); + vi.mocked(useQueryClient).mockReturnValue(mockQueryClient); + const { result } = renderHook(() => useProviderDependencies()); + expect(result.current).toEqual({ + providedWagmiConfig: mockWagmiConfig, + providedQueryClient: mockQueryClient, + }); + }); + + it('should handle missing WagmiProvider gracefully', () => { + vi.mocked(useConfig).mockImplementation(() => { + throw new WagmiProviderNotFoundError(); + }); + const mockQueryClient = { testQuery: true } as unknown as QueryClient; + vi.mocked(useQueryClient).mockReturnValue(mockQueryClient); + const { result } = renderHook(() => useProviderDependencies()); + expect(result.current).toEqual({ + providedWagmiConfig: null, + providedQueryClient: mockQueryClient, + }); + }); + + it('should handle missing QueryClient gracefully', () => { + const mockWagmiConfig = { testWagmi: true } as unknown as Config; + vi.mocked(useConfig).mockReturnValue(mockWagmiConfig); + vi.mocked(useQueryClient).mockImplementation(() => { + throw new Error('No QueryClient set, use QueryClientProvider to set one'); + }); + const { result } = renderHook(() => useProviderDependencies()); + expect(result.current).toEqual({ + providedWagmiConfig: mockWagmiConfig, + providedQueryClient: null, + }); + }); + + it('should log non-WagmiProvider errors and return null config', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const error = new Error('Different error'); + vi.mocked(useConfig).mockImplementation(() => { + throw error; + }); + const mockQueryClient = { testQuery: true } as unknown as QueryClient; + vi.mocked(useQueryClient).mockReturnValue(mockQueryClient); + const { result } = renderHook(() => useProviderDependencies()); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error fetching WagmiProvider, using default:', + error, + ); + expect(result.current).toEqual({ + providedWagmiConfig: null, + providedQueryClient: mockQueryClient, + }); + consoleErrorSpy.mockRestore(); + }); + + it('should log non-QueryClient provider errors and return null client', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockWagmiConfig = { testWagmi: true } as unknown as Config; + vi.mocked(useConfig).mockReturnValue(mockWagmiConfig); + const error = new Error('Different query error'); + vi.mocked(useQueryClient).mockImplementation(() => { + throw error; + }); + const { result } = renderHook(() => useProviderDependencies()); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error fetching QueryClient, using default:', + error, + ); + expect(result.current).toEqual({ + providedWagmiConfig: mockWagmiConfig, + providedQueryClient: null, + }); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/useProviderDependencies.tsx b/src/useProviderDependencies.tsx new file mode 100644 index 0000000000..7bbd7a5224 --- /dev/null +++ b/src/useProviderDependencies.tsx @@ -0,0 +1,42 @@ +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { type Config, WagmiProviderNotFoundError, useConfig } from 'wagmi'; + +// useProviderDependencies will return the provided Wagmi configuration and QueryClient if they exist in the React context, otherwise it will return null +// NotFound errors will fail gracefully +// Unexpected errors will be logged to the console as an error, and will return null for the problematic dependency +export function useProviderDependencies() { + // Check the context for WagmiProvider + // Wagmi configuration defaults to the provided config if it exists + // Otherwise, use the OnchainKit-provided Wagmi configuration + let providedWagmiConfig: Config | null = null; + let providedQueryClient: QueryClient | null = null; + + try { + providedWagmiConfig = useConfig(); + } catch (error) { + if (!(error instanceof WagmiProviderNotFoundError)) { + console.error('Error fetching WagmiProvider, using default:', error); + } + } + + try { + providedQueryClient = useQueryClient(); + } catch (error) { + if ( + !( + (error as Error).message === + 'No QueryClient set, use QueryClientProvider to set one' + ) + ) { + console.error('Error fetching QueryClient, using default:', error); + } + } + + return useMemo(() => { + return { + providedWagmiConfig, + providedQueryClient, + }; + }, [providedWagmiConfig, providedQueryClient]); +}