Skip to content

Commit

Permalink
feat: Batch support for Basenames and ENS names (#2102)
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcramer authored Mar 11, 2025
1 parent 70631fd commit c5ddbdf
Show file tree
Hide file tree
Showing 7 changed files with 590 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-tables-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

- **feat**: Batch support for Basenames and ENS names. By @cpcramer #2102
203 changes: 203 additions & 0 deletions src/identity/hooks/useNames.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
35 changes: 35 additions & 0 deletions src/identity/hooks/useNames.ts
Original file line number Diff line number Diff line change
@@ -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<GetNameReturnType[]>({
queryKey,
queryFn: () => getNames({ addresses, chain }),
gcTime: cacheTime,
staleTime,
enabled: enabled && addresses.length > 0,
refetchOnWindowFocus,
});
};
4 changes: 4 additions & 0 deletions src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,11 +36,13 @@ export type {
GetAvatarReturnType,
GetName,
GetNameReturnType,
GetNames,
IdentityContextType,
IdentityReact,
NameReact,
UseAddressOptions,
UseAvatarOptions,
UseNamesOptions,
UseQueryOptions,
UseNameOptions,
} from './types';
42 changes: 31 additions & 11 deletions src/identity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand Down Expand Up @@ -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;
};
Loading

0 comments on commit c5ddbdf

Please sign in to comment.