From 2da4b4fe7c0bd227ea959a778a1e17d174c915ac Mon Sep 17 00:00:00 2001 From: Paul Cramer Date: Fri, 7 Mar 2025 13:03:14 -0800 Subject: [PATCH] feat: batch ens basenames --- src/identity/hooks/useAvatars.ts | 40 ++++++++++ src/identity/hooks/useNames.ts | 41 ++++++++++ src/identity/utils/getAvatars.ts | 125 +++++++++++++++++++++++++++++++ src/identity/utils/getNames.ts | 111 +++++++++++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 src/identity/hooks/useAvatars.ts create mode 100644 src/identity/hooks/useNames.ts create mode 100644 src/identity/utils/getAvatars.ts create mode 100644 src/identity/utils/getNames.ts diff --git a/src/identity/hooks/useAvatars.ts b/src/identity/hooks/useAvatars.ts new file mode 100644 index 0000000000..929bdbf3e9 --- /dev/null +++ b/src/identity/hooks/useAvatars.ts @@ -0,0 +1,40 @@ +import { getAvatars } from '@/identity/utils/getAvatars'; +import { DEFAULT_QUERY_OPTIONS } from '@/internal/constants'; +import { useQuery } from '@tanstack/react-query'; +import { mainnet } from 'viem/chains'; +import type { GetAvatarReturnType, UseQueryOptions } from '../types'; + +/** + * Interface for the useAvatars hook options + */ +export type UseAvatarsOptions = { + ensNames: string[]; + chain?: typeof mainnet; +} + +/** + * A React hook that leverages the `@tanstack/react-query` for fetching and optionally caching + * multiple Basenames or ENS avatars in a single batch request. + */ +export const useAvatars = ( + { ensNames, chain = mainnet }: UseAvatarsOptions, + queryOptions?: UseQueryOptions, +) => { + const { enabled, cacheTime, staleTime, refetchOnWindowFocus } = { + ...DEFAULT_QUERY_OPTIONS, + ...queryOptions, + }; + + // Create a stable query key that includes all ENS names + const namesKey = ensNames.join(','); + const queryKey = ['useAvatars', namesKey, chain.id]; + + return useQuery({ + queryKey, + queryFn: () => getAvatars({ ensNames, chain }), + gcTime: cacheTime, + staleTime, + enabled: enabled && ensNames.length > 0, + refetchOnWindowFocus, + }); +}; diff --git a/src/identity/hooks/useNames.ts b/src/identity/hooks/useNames.ts new file mode 100644 index 0000000000..26105aeacd --- /dev/null +++ b/src/identity/hooks/useNames.ts @@ -0,0 +1,41 @@ +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 { Address } from 'viem'; +import type { GetNameReturnType, UseQueryOptions } from '../types'; + +/** + * Interface for the useNames hook options + */ +export type UseNamesOptions = { + addresses: Address[]; + chain?: typeof mainnet; +} + +/** + * 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, + }; + + // Create a stable query key that includes all addresses + 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/utils/getAvatars.ts b/src/identity/utils/getAvatars.ts new file mode 100644 index 0000000000..0867fc899a --- /dev/null +++ b/src/identity/utils/getAvatars.ts @@ -0,0 +1,125 @@ +import { getChainPublicClient } from '@/core/network/getChainPublicClient'; +import { isBase } from '@/core/utils/isBase'; +import { isEthereum } from '@/core/utils/isEthereum'; +import type { Basename, GetAvatarReturnType } from '@/identity/types'; +import { mainnet } from 'viem/chains'; +import { normalize } from 'viem/ens'; +import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants'; +import { getBaseDefaultProfilePicture } from './getBaseDefaultProfilePicture'; +import { isBasename } from './isBasename'; + +/** + * Interface for the getAvatars function parameters + */ +export type GetAvatars = { + ensNames: string[]; + chain?: typeof mainnet; +} + +/** + * An asynchronous function to fetch multiple Basenames or Ethereum Name Service (ENS) + * avatars for a given array of ENS names in a single batch request. + * It returns an array of avatar URLs in the same order as the input names. + */ +export const getAvatars = async ({ + ensNames, + chain = mainnet, +}: GetAvatars): Promise => { + if (!ensNames || ensNames.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, avatar resolution is only supported on Ethereum and Base.', + ); + } + + // Initialize results array + const results: GetAvatarReturnType[] = Array(ensNames.length).fill(null); + + // Categorize names by type for optimized processing + const basenameIndices: number[] = []; + const normalIndices: number[] = []; + + ensNames.forEach((name, index) => { + if (isBasename(name)) { + basenameIndices.push(index); + } else { + normalIndices.push(index); + } + }); + + // Process Base chain avatars if applicable + if (chainIsBase && basenameIndices.length > 0) { + const client = getChainPublicClient(chain); + + try { + // Create batch of calls for Base avatars + const baseAvatarPromises = basenameIndices.map((index) => + client.getEnsAvatar({ + name: normalize(ensNames[index]), + universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id], + }), + ); + + // Execute all Base avatar resolution calls + const baseAvatarResults = await Promise.all(baseAvatarPromises); + + // Update results with Base avatars + baseAvatarResults.forEach((avatar, i) => { + const originalIndex = basenameIndices[i]; + if (avatar) { + results[originalIndex] = avatar; + } + }); + } catch (error) { + // This is a best effort attempt, so we continue to fallback + console.error('Error resolving Base avatars in batch:', error); + } + } + + // Process mainnet avatars for all names + const fallbackClient = getChainPublicClient(mainnet); + + // For all names, try mainnet resolution + try { + // Create batch of ENS avatar resolution calls + const ensAvatarPromises = ensNames.map((name, index) => { + // Skip if we already have a result + if (results[index] !== null) { + return Promise.resolve(null); + } + return fallbackClient.getEnsAvatar({ + name: normalize(name), + }); + }); + + // Execute all ENS avatar resolution calls + const ensAvatarResults = await Promise.all(ensAvatarPromises); + + // Update results with ENS avatars + ensAvatarResults.forEach((avatar, index) => { + if (avatar && results[index] === null) { + results[index] = avatar; + } + }); + } catch (error) { + console.error('Error resolving ENS avatars in batch:', error); + } + + // Apply default Base profile pictures for basenames that don't have avatars + basenameIndices.forEach((index) => { + if (results[index] === null) { + results[index] = getBaseDefaultProfilePicture( + ensNames[index] as Basename, + ); + } + }); + + return results; +}; diff --git a/src/identity/utils/getNames.ts b/src/identity/utils/getNames.ts new file mode 100644 index 0000000000..1991962043 --- /dev/null +++ b/src/identity/utils/getNames.ts @@ -0,0 +1,111 @@ +import type { Address } from 'viem'; +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'; +import type { Basename, GetNameReturnType } from '@/identity/types'; + +/** + * Interface for the getNames function parameters + */ +export type GetNames = { + addresses: Address[]; + chain?: typeof mainnet; +} + +/** + * 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, +}: 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); + + // Handle Base chain resolution + 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)], + })); + + // Execute batch request + const batchResults = await client.multicall({ + contracts: calls, + allowFailure: true, + }); + + // Process results + 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) { + // This is a best effort attempt, so we continue to fallback + console.error('Error resolving Base names in batch:', error); + } + } + + // Default fallback to mainnet for any unresolved names + // 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 { + // Create batch of ENS resolution calls + const ensPromises = unresolvedIndices.map((index) => + fallbackClient.getEnsName({ + address: addresses[index], + }), + ); + + // Execute all ENS resolution calls + 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; +};