diff --git a/.changeset/sixty-tables-build.md b/.changeset/sixty-tables-build.md new file mode 100644 index 0000000000..06f3e92578 --- /dev/null +++ b/.changeset/sixty-tables-build.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +- **feat**: Batch support for Basenames and ENS names. By @cpcramer #2102 diff --git a/src/identity/hooks/useNames.test.tsx b/src/identity/hooks/useNames.test.tsx new file mode 100644 index 0000000000..5611a8ba9f --- /dev/null +++ b/src/identity/hooks/useNames.test.tsx @@ -0,0 +1,203 @@ +import { publicClient } from '@/core/network/client'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { Address } from 'viem'; +import { base, mainnet, optimism } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNames } from '../utils/getNames'; +import { getNewReactQueryTestProvider } from './getNewReactQueryTestProvider'; +import { useNames } from './useNames'; + +vi.mock('@/core/network/client'); +vi.mock('@/core/network/getChainPublicClient', () => ({ + ...vi.importActual('@/core/network/getChainPublicClient'), + getChainPublicClient: vi.fn(() => publicClient), +})); + +vi.mock('../utils/getNames'); + +describe('useNames', () => { + const testAddresses = [ + '0x1234567890123456789012345678901234567890', + '0x2345678901234567890123456789012345678901', + '0x3456789012345678901234567890123456789012', + ] as Address[]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it('returns the correct ENS names and loading state', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + + vi.mocked(getNames).mockResolvedValue(testEnsNames); + + const { result } = renderHook( + () => useNames({ addresses: testAddresses }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + expect(result.current.isPending).toBe(true); + expect(result.current.data).toBe(undefined); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsNames); + expect(result.current.isPending).toBe(false); + }); + + expect(getNames).toHaveBeenCalledWith({ + addresses: testAddresses, + chain: mainnet, + }); + }); + + it('returns the correct names for custom chain', async () => { + const testBaseNames = ['user1.base', 'user2.base', 'user3.base']; + + vi.mocked(getNames).mockResolvedValue(testBaseNames); + + const { result } = renderHook( + () => + useNames({ + addresses: testAddresses, + chain: base, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testBaseNames); + expect(result.current.isPending).toBe(false); + }); + + expect(getNames).toHaveBeenCalledWith({ + addresses: testAddresses, + chain: base, + }); + }); + + it('returns error for unsupported chain', async () => { + const errorMessage = + 'ChainId not supported, name resolution is only supported on Ethereum and Base.'; + vi.mocked(getNames).mockRejectedValue(errorMessage); + + const { result } = renderHook( + () => + useNames({ + addresses: testAddresses, + chain: optimism, + }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toBe(undefined); + expect(result.current.isPending).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe(errorMessage); + }); + }); + + it('is disabled when addresses array is empty', async () => { + vi.mocked(getNames).mockImplementation(() => { + throw new Error('This should not be called'); + }); + + const { result } = renderHook(() => useNames({ addresses: [] }), { + wrapper: getNewReactQueryTestProvider(), + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(getNames).not.toHaveBeenCalled(); + expect(result.current.isError).toBe(false); + }); + + it('uses the default query options when no queryOptions are provided', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + + vi.mocked(getNames).mockResolvedValue(testEnsNames); + + renderHook(() => useNames({ addresses: testAddresses }), { + wrapper: getNewReactQueryTestProvider(), + }); + + await waitFor(() => { + expect(getNames).toHaveBeenCalled(); + }); + }); + + it('merges custom queryOptions with default options', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + const customCacheTime = 120000; + + vi.mocked(getNames).mockResolvedValue(testEnsNames); + + const { result } = renderHook( + () => + useNames({ addresses: testAddresses }, { cacheTime: customCacheTime }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsNames); + }); + + expect(getNames).toHaveBeenCalled(); + }); + + it('creates a stable query key based on addresses and chain', async () => { + const testEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + vi.mocked(getNames).mockResolvedValue(testEnsNames); + + const { result, rerender } = renderHook( + (props: { addresses: Address[] }) => + useNames({ addresses: props.addresses }), + { + wrapper: getNewReactQueryTestProvider(), + initialProps: { addresses: testAddresses }, + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testEnsNames); + }); + + vi.clearAllMocks(); + + rerender({ addresses: [...testAddresses] }); + + expect(getNames).not.toHaveBeenCalled(); + }); + + it('handles partial failures in name resolution', async () => { + const partialResults = [null, null, null]; + vi.mocked(getNames).mockResolvedValue(partialResults); + + const { result } = renderHook( + () => useNames({ addresses: testAddresses }), + { + wrapper: getNewReactQueryTestProvider(), + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(partialResults); + expect(result.current.isPending).toBe(false); + expect(result.current.isError).toBe(false); + }); + + expect(getNames).toHaveBeenCalledWith({ + addresses: testAddresses, + chain: mainnet, + }); + }); +}); diff --git a/src/identity/hooks/useNames.ts b/src/identity/hooks/useNames.ts new file mode 100644 index 0000000000..2dd4b5daf0 --- /dev/null +++ b/src/identity/hooks/useNames.ts @@ -0,0 +1,35 @@ +import { getNames } from '@/identity/utils/getNames'; +import { DEFAULT_QUERY_OPTIONS } from '@/internal/constants'; +import { useQuery } from '@tanstack/react-query'; +import { mainnet } from 'viem/chains'; +import type { + GetNameReturnType, + UseNamesOptions, + UseQueryOptions, +} from '../types'; + +/** + * A React hook that leverages the `@tanstack/react-query` for fetching and optionally caching + * multiple Basenames or ENS names in a single batch request. + */ +export const useNames = ( + { addresses, chain = mainnet }: UseNamesOptions, + queryOptions?: UseQueryOptions, +) => { + const { enabled, cacheTime, staleTime, refetchOnWindowFocus } = { + ...DEFAULT_QUERY_OPTIONS, + ...queryOptions, + }; + + const addressesKey = addresses.join(','); + const queryKey = ['useNames', addressesKey, chain.id]; + + return useQuery({ + queryKey, + queryFn: () => getNames({ addresses, chain }), + gcTime: cacheTime, + staleTime, + enabled: enabled && addresses.length > 0, + refetchOnWindowFocus, + }); +}; diff --git a/src/identity/index.ts b/src/identity/index.ts index 4c941d9e0d..0ebb2907d5 100644 --- a/src/identity/index.ts +++ b/src/identity/index.ts @@ -12,10 +12,12 @@ export { getAddress } from './utils/getAddress'; export { getAttestations } from './utils/getAttestations'; export { getAvatar } from './utils/getAvatar'; export { getName } from './utils/getName'; +export { getNames } from './utils/getNames'; export { useAddress } from './hooks/useAddress'; export { useAttestations } from './hooks/useAttestations'; export { useAvatar } from './hooks/useAvatar'; export { useName } from './hooks/useName'; +export { useNames } from './hooks/useNames'; export type { AddressReact, Attestation, @@ -34,11 +36,13 @@ export type { GetAvatarReturnType, GetName, GetNameReturnType, + GetNames, IdentityContextType, IdentityReact, NameReact, UseAddressOptions, UseAvatarOptions, + UseNamesOptions, UseQueryOptions, UseNameOptions, } from './types'; diff --git a/src/identity/types.ts b/src/identity/types.ts index 2b2e554602..7bf5128039 100644 --- a/src/identity/types.ts +++ b/src/identity/types.ts @@ -160,7 +160,7 @@ export type GetAttestationsOptions = { * Note: exported as public Type */ export type GetAvatar = { - /** The ENS name to fetch the avatar for. */ + /** The ENS or Basename to fetch the avatar for. */ ensName: string; /** Optional chain for domain resolution */ chain?: Chain; @@ -186,6 +186,16 @@ export type GetName = { */ export type GetNameReturnType = string | Basename | null; +/** + * Note: exported as public Type + */ +export type GetNames = { + /** Array of Ethereum addresses to resolve names for */ + addresses: Address[]; + /** Optional chain for domain resolution */ + chain?: Chain; +}; + /** * Note: exported as public Type */ @@ -275,21 +285,31 @@ export type UseAvatarOptions = { /** * Note: exported as public Type */ -export type UseQueryOptions = { - /** Whether the query should execute */ - enabled?: boolean; - /** Cache time in milliseconds */ - cacheTime?: number; - /** Stale time in milliseconds */ - staleTime?: number; +export type UseNameOptions = { + /** The address for which the ENS or Basename is to be fetched. */ + address: Address; + /** Optional chain for domain resolution */ + chain?: Chain; }; /** * Note: exported as public Type */ -export type UseNameOptions = { - /** The Ethereum address for which the ENS name is to be fetched. */ - address: Address; +export type UseNamesOptions = { + /** Array of addresses to resolve ENS or Basenames for */ + addresses: Address[]; /** Optional chain for domain resolution */ chain?: Chain; }; + +/** + * Note: exported as public Type + */ +export type UseQueryOptions = { + /** Whether the query should execute */ + enabled?: boolean; + /** Cache time in milliseconds */ + cacheTime?: number; + /** Stale time in milliseconds */ + staleTime?: number; +}; diff --git a/src/identity/utils/getNames.test.ts b/src/identity/utils/getNames.test.ts new file mode 100644 index 0000000000..e9ee66f50a --- /dev/null +++ b/src/identity/utils/getNames.test.ts @@ -0,0 +1,207 @@ +import { publicClient } from '@/core/network/client'; +import type { Address } from 'viem'; +import { base, optimism } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import L2ResolverAbi from '../abis/L2ResolverAbi'; +import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants'; +import { convertReverseNodeToBytes } from './convertReverseNodeToBytes'; +import { getNames } from './getNames'; + +vi.mock('@/core/network/client'); + +vi.mock('@/core/utils/getSlicedAddress', () => ({ + getSlicedAddress: vi.fn(), +})); + +vi.mock('@/core/network/getChainPublicClient', () => ({ + ...vi.importActual('@/core/network/getChainPublicClient'), + getChainPublicClient: vi.fn(() => publicClient), +})); + +vi.mock('./convertReverseNodeToBytes', () => ({ + convertReverseNodeToBytes: vi.fn((address) => `${address}-bytes`), +})); + +describe('getNames', () => { + const mockGetEnsName = publicClient.getEnsName as Mock; + const mockMulticall = publicClient.multicall as Mock; + const walletAddresses = [ + '0x1234567890123456789012345678901234567890', + '0x2345678901234567890123456789012345678901', + '0x3456789012345678901234567890123456789012', + ] as Address[]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return empty array when no addresses are provided', async () => { + const names = await getNames({ addresses: [] }); + expect(names).toEqual([]); + }); + + it('should fetch ENS names for multiple addresses on mainnet', async () => { + const expectedEnsNames = ['user1.eth', 'user2.eth', null]; + mockGetEnsName.mockImplementation((params) => { + const index = walletAddresses.findIndex( + (addr) => addr === params.address, + ); + return Promise.resolve(expectedEnsNames[index]); + }); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual(expectedEnsNames); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + walletAddresses.forEach((address, index) => { + expect(mockGetEnsName).toHaveBeenNthCalledWith(index + 1, { address }); + }); + }); + + it('should fetch Basenames for multiple addresses on Base chain', async () => { + const expectedBaseNames = ['user1.base', 'user2.base', 'user3.base']; + + mockMulticall.mockResolvedValue( + expectedBaseNames.map((name) => ({ + status: 'success', + result: name, + })), + ); + + const names = await getNames({ + addresses: walletAddresses, + chain: base, + }); + + expect(names).toEqual(expectedBaseNames); + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(mockMulticall).toHaveBeenCalledWith({ + contracts: walletAddresses.map((address) => ({ + address: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id], + abi: L2ResolverAbi, + functionName: 'name', + args: [convertReverseNodeToBytes(address, base.id)], + })), + allowFailure: true, + }); + }); + + it('should fall back to ENS resolution for unresolved Base names', async () => { + const baseResults = [ + { status: 'success', result: 'user1.base' }, + { status: 'success', result: 'user2.base' }, + { status: 'failure', result: null }, + ]; + + mockMulticall.mockResolvedValue(baseResults); + + mockGetEnsName.mockImplementation((params) => { + if (params.address === walletAddresses[2]) { + return Promise.resolve('user3.eth'); + } + return Promise.resolve(null); + }); + + const names = await getNames({ + addresses: walletAddresses, + chain: base, + }); + + expect(names).toEqual(['user1.base', 'user2.base', 'user3.eth']); + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(mockGetEnsName).toHaveBeenCalledTimes(1); + expect(mockGetEnsName).toHaveBeenCalledWith({ + address: walletAddresses[2], + }); + }); + + it('should handle multicall errors gracefully and fall back to ENS', async () => { + mockMulticall.mockRejectedValue(new Error('Multicall failed')); + + const expectedEnsNames = ['user1.eth', 'user2.eth', 'user3.eth']; + mockGetEnsName.mockImplementation((params) => { + const index = walletAddresses.findIndex( + (addr) => addr === params.address, + ); + return Promise.resolve(expectedEnsNames[index]); + }); + + const names = await getNames({ + addresses: walletAddresses, + chain: base, + }); + + expect(names).toEqual(expectedEnsNames); + expect(mockMulticall).toHaveBeenCalledTimes(1); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + }); + + it('should throw an error for unsupported chains', async () => { + await expect( + getNames({ + addresses: walletAddresses, + chain: optimism, + }), + ).rejects.toBe( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + + expect(mockGetEnsName).not.toHaveBeenCalled(); + expect(mockMulticall).not.toHaveBeenCalled(); + }); + + it('should handle ENS resolution errors gracefully', async () => { + mockGetEnsName.mockRejectedValue(new Error('ENS resolution failed')); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual([null, null, null]); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + }); + + it('should handle partial ENS resolution failures', async () => { + mockGetEnsName.mockImplementation((params) => { + if (params.address === walletAddresses[0]) { + return Promise.resolve('user1.eth'); + } + + if (params.address === walletAddresses[1]) { + return Promise.reject(new Error('ENS resolution failed')); + } + + return Promise.resolve('user3.eth'); + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual(['user1.eth', null, 'user3.eth']); + expect(mockGetEnsName).toHaveBeenCalledTimes(3); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle errors during batch ENS resolution process', async () => { + const originalPromiseAll = Promise.all; + global.Promise.all = vi.fn().mockImplementation(() => { + throw new Error('Batch ENS resolution failed'); + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const names = await getNames({ addresses: walletAddresses }); + + expect(names).toEqual([null, null, null]); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error resolving ENS names in batch:', + expect.any(Error), + ); + + global.Promise.all = originalPromiseAll; + consoleSpy.mockRestore(); + }); +}); diff --git a/src/identity/utils/getNames.ts b/src/identity/utils/getNames.ts new file mode 100644 index 0000000000..e18ecec632 --- /dev/null +++ b/src/identity/utils/getNames.ts @@ -0,0 +1,105 @@ +import type { Basename, GetNameReturnType, GetNames } from '@/identity/types'; +import { mainnet } from 'viem/chains'; +import { getChainPublicClient } from '../../core/network/getChainPublicClient'; +import { isBase } from '../../core/utils/isBase'; +import { isEthereum } from '../../core/utils/isEthereum'; +import L2ResolverAbi from '../abis/L2ResolverAbi'; +import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants'; +import { convertReverseNodeToBytes } from './convertReverseNodeToBytes'; + +/** + * An asynchronous function to fetch multiple Basenames or Ethereum Name Service (ENS) + * names for a given array of Ethereum addresses in a single batch request. + * It returns an array of ENS names in the same order as the input addresses. + */ +export const getNames = async ({ + addresses, + chain = mainnet, +}: // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore +GetNames): Promise => { + if (!addresses || addresses.length === 0) { + return []; + } + + const chainIsBase = isBase({ chainId: chain.id }); + const chainIsEthereum = isEthereum({ chainId: chain.id }); + const chainSupportsUniversalResolver = chainIsEthereum || chainIsBase; + + if (!chainSupportsUniversalResolver) { + return Promise.reject( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + } + + const client = getChainPublicClient(chain); + const results: GetNameReturnType[] = Array(addresses.length).fill(null); + + if (chainIsBase) { + try { + // Create batch of calls for the multicall contract + const calls = addresses.map((address) => ({ + address: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id], + abi: L2ResolverAbi, + functionName: 'name', + args: [convertReverseNodeToBytes(address, chain.id)], + })); + + const batchResults = await client.multicall({ + contracts: calls, + allowFailure: true, + }); + + batchResults.forEach((result, index) => { + if (result.status === 'success' && result.result) { + results[index] = result.result as Basename; + } + }); + + // If we have all results, return them + if (results.every((result) => result !== null)) { + return results; + } + } catch (error) { + console.error('Error resolving Base names in batch:', error); + } + } + + // Default fallback to mainnet + // ENS resolution is not well-supported on Base, so want to ensure that we fall back to mainnet + const fallbackClient = getChainPublicClient(mainnet); + + // For addresses that don't have a result yet, try ENS resolution on mainnet + const unresolvedIndices = results + .map((result, index) => (result === null ? index : -1)) + .filter((index) => index !== -1); + + if (unresolvedIndices.length > 0) { + try { + const ensPromises = unresolvedIndices.map((index) => + fallbackClient + .getEnsName({ + address: addresses[index], + }) + .catch((error) => { + console.error( + `Error resolving ENS name for ${addresses[index]}:`, + error, + ); + return null; // Return null for failed resolutions + }), + ); + + const ensResults = await Promise.all(ensPromises); + + // Update results with ENS names + ensResults.forEach((ensName, i) => { + const originalIndex = unresolvedIndices[i]; + results[originalIndex] = ensName; + }); + } catch (error) { + console.error('Error resolving ENS names in batch:', error); + } + } + + return results; +};