Skip to content

Commit

Permalink
feat: batch ens basenames
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcramer committed Mar 7, 2025
1 parent 602f53c commit 780109a
Show file tree
Hide file tree
Showing 4 changed files with 317 additions and 0 deletions.
40 changes: 40 additions & 0 deletions src/identity/hooks/useAvatars.ts
Original file line number Diff line number Diff line change
@@ -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<GetAvatarReturnType[]>({
queryKey,
queryFn: () => getAvatars({ ensNames, chain }),
gcTime: cacheTime,
staleTime,
enabled: enabled && ensNames.length > 0,
refetchOnWindowFocus,
});
};
41 changes: 41 additions & 0 deletions src/identity/hooks/useNames.ts
Original file line number Diff line number Diff line change
@@ -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<GetNameReturnType[]>({
queryKey,
queryFn: () => getNames({ addresses, chain }),
gcTime: cacheTime,
staleTime,
enabled: enabled && addresses.length > 0,
refetchOnWindowFocus,
});
};
125 changes: 125 additions & 0 deletions src/identity/utils/getAvatars.ts
Original file line number Diff line number Diff line change
@@ -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<GetAvatarReturnType[]> => {
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;
};
111 changes: 111 additions & 0 deletions src/identity/utils/getNames.ts
Original file line number Diff line number Diff line change
@@ -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<GetNameReturnType[]> => {
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;
};

0 comments on commit 780109a

Please sign in to comment.