Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Batch support for Basenames and ENS names #2102

Merged
merged 13 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 as unknown as typeof mainnet,
}),
{
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 as unknown as typeof mainnet,
}),
{
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,
});
});
});
37 changes: 37 additions & 0 deletions src/identity/hooks/useNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getNames } from '@/identity/utils/getNames';
import { DEFAULT_QUERY_OPTIONS } from '@/internal/constants';
import { useQuery } from '@tanstack/react-query';
import type { Address } from 'viem';
import { mainnet } from 'viem/chains';
import type { GetNameReturnType, UseQueryOptions } from '../types';

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, allow dev to pass through any query options that Tanstack supports - will make the hook a lot more useful to support specific use cases

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fast follow along with Avatars.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you expose gcTime instead of cacheTime so that when you switch to Tanstack query options it won't be a breaking change and we won't have to support both?

) => {
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';
43 changes: 32 additions & 11 deletions src/identity/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { HTMLAttributes, ImgHTMLAttributes, ReactNode } from 'react';
import type { Address, Chain } from 'viem';
import type { mainnet } from 'viem/_types/chains/definitions/mainnet';

/**
* Note: exported as public Type
Expand Down Expand Up @@ -160,7 +161,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 +187,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?: typeof mainnet;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why wouldn't this be Chain?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be Chain, good catch

};

/**
* Note: exported as public Type
*/
Expand Down Expand Up @@ -272,6 +283,26 @@ export type UseAvatarOptions = {
chain?: Chain;
};

/**
* Note: exported as public Type
*/
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 UseNamesOptions = {
/** Array of addresses to resolve ENS or Basenames for */
addresses: Address[];
/** Optional chain for domain resolution */
chain?: typeof mainnet;
};

/**
* Note: exported as public Type
*/
Expand All @@ -283,13 +314,3 @@ export type UseQueryOptions = {
/** Stale time in milliseconds */
staleTime?: number;
};

/**
* Note: exported as public Type
*/
export type UseNameOptions = {
/** The Ethereum address for which the ENS name is to be fetched. */
address: Address;
/** Optional chain for domain resolution */
chain?: Chain;
};
Loading