diff --git a/.changeset/honest-lies-walk.md b/.changeset/honest-lies-walk.md
new file mode 100644
index 0000000000..d8a97f6375
--- /dev/null
+++ b/.changeset/honest-lies-walk.md
@@ -0,0 +1,6 @@
+---
+"@coinbase/onchainkit": minor
+---
+
+**feat**: Add chain props to `useAvatar` and `getAvatar` to resolve Base avatar. By @kirkas #986
+**feat**: Modify `getAvatar` to resolve Base avatar, and fallback to mainnet if none is found. By @kirkas #986
\ No newline at end of file
diff --git a/site/docs/pages/identity/types.mdx b/site/docs/pages/identity/types.mdx
index 939e8fe5b9..992b439f33 100644
--- a/site/docs/pages/identity/types.mdx
+++ b/site/docs/pages/identity/types.mdx
@@ -90,6 +90,7 @@ type GetAttestationsOptions = {
```ts
type GetAvatar = {
+ chain?: Chain; // Optional chain for domain resolution
ensName: string; // The ENS name to fetch the avatar for.
};
```
diff --git a/site/docs/pages/identity/use-avatar.mdx b/site/docs/pages/identity/use-avatar.mdx
index 3f85bc58b2..ff29b202b5 100644
--- a/site/docs/pages/identity/use-avatar.mdx
+++ b/site/docs/pages/identity/use-avatar.mdx
@@ -20,6 +20,7 @@ const { data: avatar, isLoading } = useAvatar({ ensName: 'vitalik.eth' });
```ts
type UseAvatarOptions = {
ensName: string;
+ chain?: Chain;
};
type UseAvatarQueryOptions = {
diff --git a/src/identity/components/Avatar.stories.tsx b/src/identity/components/Avatar.stories.tsx
index a0ef31884d..64326193f7 100644
--- a/src/identity/components/Avatar.stories.tsx
+++ b/src/identity/components/Avatar.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { base } from 'viem/chains';
+import { base, baseSepolia } from 'viem/chains';
import { OnchainKitProvider } from '../../OnchainKitProvider';
import { Avatar } from './Avatar';
import { Badge } from './Badge';
@@ -70,3 +70,31 @@ export const WithBadge: Story = {
children: ,
},
};
+
+export const Base: Story = {
+ args: {
+ address: '0xFd3d8ffE248173B710b4e24a7E75ac4424853503',
+ chain: base,
+ },
+};
+
+export const BaseSepolia: Story = {
+ args: {
+ address: '0xf75ca27C443768EE1876b027272DC8E3d00B8a23',
+ chain: baseSepolia,
+ },
+};
+
+export const BaseDefaultToMainnet: Story = {
+ args: {
+ address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
+ chain: base,
+ },
+};
+
+export const BaseSepoliaDefaultToMainnet: Story = {
+ args: {
+ address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
+ chain: baseSepolia,
+ },
+};
diff --git a/src/identity/components/Avatar.tsx b/src/identity/components/Avatar.tsx
index c1a62c6cf9..1fadfc327b 100644
--- a/src/identity/components/Avatar.tsx
+++ b/src/identity/components/Avatar.tsx
@@ -36,12 +36,12 @@ export function Avatar({
// The component first attempts to retrieve the ENS name and avatar for the given Ethereum address.
const { data: name, isLoading: isLoadingName } = useName({
- address: address ?? contextAddress,
+ address: accountAddress,
chain: accountChain,
});
const { data: avatar, isLoading: isLoadingAvatar } = useAvatar(
- { ensName: name ?? '' },
+ { ensName: name ?? '', chain: accountChain },
{ enabled: !!name },
);
diff --git a/src/identity/hooks/useAvatar.test.tsx b/src/identity/hooks/useAvatar.test.tsx
index 2c9aa6a7bc..2cd64279b5 100644
--- a/src/identity/hooks/useAvatar.test.tsx
+++ b/src/identity/hooks/useAvatar.test.tsx
@@ -1,13 +1,18 @@
-/**
- * @vitest-environment jsdom
- */
import { renderHook, waitFor } from '@testing-library/react';
+import { base, baseSepolia, mainnet, optimism } from 'viem/chains';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { publicClient } from '../../network/client';
+import { getChainPublicClient } from '../../network/getChainPublicClient';
import { getNewReactQueryTestProvider } from './getNewReactQueryTestProvider';
import { useAvatar } from './useAvatar';
vi.mock('../../network/client');
+vi.mock('../../network/getChainPublicClient', () => ({
+ ...vi.importActual('../../network/getChainPublicClient'),
+ getChainPublicClient: vi.fn(() => publicClient),
+}));
+
describe('useAvatar', () => {
const mockGetEnsAvatar = publicClient.getEnsAvatar as vi.Mock;
@@ -33,6 +38,8 @@ describe('useAvatar', () => {
expect(result.current.data).toBe(testEnsAvatar);
expect(result.current.isLoading).toBe(false);
});
+
+ expect(getChainPublicClient).toHaveBeenCalledWith(mainnet);
});
it('returns the loading state true while still fetching ENS avatar', async () => {
@@ -50,4 +57,81 @@ describe('useAvatar', () => {
expect(result.current.isLoading).toBe(true);
});
});
+
+ it('return correct base mainnet avatar', async () => {
+ const testEnsName = 'shrek.base.eth';
+ const testEnsAvatar = 'shrekface';
+
+ // Mock the getEnsAvatar method of the publicClient
+ mockGetEnsAvatar.mockResolvedValue(testEnsAvatar);
+
+ // Use the renderHook function to create a test harness for the useAvatar hook
+ const { result } = renderHook(
+ () => useAvatar({ ensName: testEnsName, chain: base }),
+ {
+ wrapper: getNewReactQueryTestProvider(),
+ },
+ );
+
+ // Wait for the hook to finish fetching the ENS avatar
+ await waitFor(() => {
+ // Check that the ENS avatar and loading state are correct
+ expect(result.current.data).toBe(testEnsAvatar);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(getChainPublicClient).toHaveBeenCalledWith(base);
+ });
+
+ it('return correct base sepolia avatar', async () => {
+ const testEnsName = 'shrek.basetest.eth';
+ const testEnsAvatar = 'shrektestface';
+
+ // Mock the getEnsAvatar method of the publicClient
+ mockGetEnsAvatar.mockResolvedValue(testEnsAvatar);
+
+ // Use the renderHook function to create a test harness for the useAvatar hook
+ const { result } = renderHook(
+ () => useAvatar({ ensName: testEnsName, chain: baseSepolia }),
+ {
+ wrapper: getNewReactQueryTestProvider(),
+ },
+ );
+
+ // Wait for the hook to finish fetching the ENS avatar
+ await waitFor(() => {
+ // Check that the ENS avatar and loading state are correct
+ expect(result.current.data).toBe(testEnsAvatar);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(getChainPublicClient).toHaveBeenCalledWith(baseSepolia);
+ });
+
+ it('returns error for unsupported chain ', async () => {
+ const testEnsName = 'shrek.basetest.eth';
+ const testEnsAvatar = 'shrektestface';
+
+ // Mock the getEnsAvatar method of the publicClient
+ mockGetEnsAvatar.mockResolvedValue(testEnsAvatar);
+
+ // Use the renderHook function to create a test harness for the useAvatar hook
+ const { result } = renderHook(
+ () => useAvatar({ ensName: testEnsName, chain: optimism }),
+ {
+ wrapper: getNewReactQueryTestProvider(),
+ },
+ );
+
+ // Wait for the hook to finish fetching the ENS name
+ await waitFor(() => {
+ // Check that the ENS name and loading state are correct
+ expect(result.current.data).toBe(undefined);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isError).toBe(true);
+ expect(result.current.error).toBe(
+ 'ChainId not supported, avatar resolution is only supported on Ethereum and Base.',
+ );
+ });
+ });
});
diff --git a/src/identity/hooks/useAvatar.ts b/src/identity/hooks/useAvatar.ts
index 6e025b8016..fdabdb0463 100644
--- a/src/identity/hooks/useAvatar.ts
+++ b/src/identity/hooks/useAvatar.ts
@@ -1,29 +1,26 @@
import { useQuery } from '@tanstack/react-query';
-import type { GetAvatarReturnType } from '../types';
+import { mainnet } from 'viem/chains';
+import type {
+ GetAvatarReturnType,
+ UseAvatarOptions,
+ UseAvatarQueryOptions,
+} from '../types';
import { getAvatar } from '../utils/getAvatar';
-type UseAvatarOptions = {
- ensName: string;
-};
-
-type UseAvatarQueryOptions = {
- enabled?: boolean;
- cacheTime?: number;
-};
-
/**
* Gets an ensName and resolves the Avatar
*/
export const useAvatar = (
- { ensName }: UseAvatarOptions,
+ { ensName, chain = mainnet }: UseAvatarOptions,
queryOptions?: UseAvatarQueryOptions,
) => {
const { enabled = true, cacheTime } = queryOptions ?? {};
- const ensActionKey = `ens-avatar-${ensName}`;
+ const ensActionKey = `ens-avatar-${ensName}-${chain.id}`;
+
return useQuery({
queryKey: ['useAvatar', ensActionKey],
queryFn: async () => {
- return getAvatar({ ensName });
+ return getAvatar({ ensName, chain });
},
gcTime: cacheTime,
enabled,
diff --git a/src/identity/index.ts b/src/identity/index.ts
index 8275be7b8e..1f5fc8cf08 100644
--- a/src/identity/index.ts
+++ b/src/identity/index.ts
@@ -26,4 +26,5 @@ export type {
GetNameReturnType,
IdentityContextType,
IdentityReact,
+ UseAvatarOptions,
} from './types';
diff --git a/src/identity/types.ts b/src/identity/types.ts
index 7a25f198e0..cedf293173 100644
--- a/src/identity/types.ts
+++ b/src/identity/types.ts
@@ -107,6 +107,7 @@ export type GetAttestationsOptions = {
*/
export type GetAvatar = {
ensName: string; // The ENS name to fetch the avatar for.
+ chain?: Chain; // Optional chain for domain resolution
};
/**
@@ -179,6 +180,22 @@ export type UseAttestations = {
schemaId: Address | null;
};
+/**
+ * Note: exported as public Type
+ */
+export type UseAvatarOptions = {
+ ensName: string;
+ chain?: Chain; // Optional chain for domain resolution
+};
+
+/**
+ * Note: exported as public Type
+ */
+export type UseAvatarQueryOptions = {
+ enabled?: boolean;
+ cacheTime?: number;
+};
+
/**
* Note: exported as public Type
*/
diff --git a/src/identity/utils/getAvatar.test.tsx b/src/identity/utils/getAvatar.test.tsx
index c1d15aa11f..e217cf9b04 100644
--- a/src/identity/utils/getAvatar.test.tsx
+++ b/src/identity/utils/getAvatar.test.tsx
@@ -1,12 +1,19 @@
+import { base, baseSepolia, mainnet, optimism } from 'viem/chains';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { publicClient } from '../../network/client';
+import { getChainPublicClient } from '../../network/getChainPublicClient';
+import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants';
import { getAvatar } from './getAvatar';
vi.mock('../../network/client');
+vi.mock('../../network/getChainPublicClient', () => ({
+ ...vi.importActual('../../network/getChainPublicClient'),
+ getChainPublicClient: vi.fn(() => publicClient),
+}));
+
describe('getAvatar', () => {
const mockGetEnsAvatar = publicClient.getEnsAvatar as vi.Mock;
-
beforeEach(() => {
vi.clearAllMocks();
});
@@ -21,6 +28,7 @@ describe('getAvatar', () => {
expect(avatarUrl).toBe(expectedAvatarUrl);
expect(mockGetEnsAvatar).toHaveBeenCalledWith({ name: ensName });
+ expect(getChainPublicClient).toHaveBeenCalledWith(mainnet);
});
it('should return null when client getAvatar throws an error', async () => {
@@ -30,4 +38,125 @@ describe('getAvatar', () => {
await expect(getAvatar({ ensName })).rejects.toThrow('This is an error');
});
+
+ it('should resolve to base mainnet avatar', async () => {
+ const ensName = 'shrek.base.eth';
+ const expectedAvatarUrl = 'shrekface';
+
+ mockGetEnsAvatar.mockResolvedValue(expectedAvatarUrl);
+
+ const avatarUrl = await getAvatar({ ensName, chain: base });
+
+ expect(avatarUrl).toBe(expectedAvatarUrl);
+ expect(mockGetEnsAvatar).toHaveBeenCalledWith({
+ name: ensName,
+ universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id],
+ });
+ expect(getChainPublicClient).toHaveBeenCalledWith(base);
+ expect(getChainPublicClient).not.toHaveBeenCalledWith(mainnet);
+ });
+
+ it('should resolve to base sepolia avatar', async () => {
+ const ensName = 'shrek.basetest.eth';
+ const expectedAvatarUrl = 'shrekfacetest';
+
+ mockGetEnsAvatar.mockResolvedValue(expectedAvatarUrl);
+
+ const avatarUrl = await getAvatar({ ensName, chain: baseSepolia });
+
+ expect(avatarUrl).toBe(expectedAvatarUrl);
+ expect(mockGetEnsAvatar).toHaveBeenCalledWith({
+ name: ensName,
+ universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[baseSepolia.id],
+ });
+ expect(getChainPublicClient).toHaveBeenCalledWith(baseSepolia);
+ expect(getChainPublicClient).not.toHaveBeenCalledWith(mainnet);
+ });
+
+ it('should default to mainnet when base mainnet avatar is not available', async () => {
+ const ensName = 'shrek.base.eth';
+ const expectedBaseAvatarUrl = null;
+ const expectedMainnetAvatarUrl = 'mainnetname.eth';
+
+ mockGetEnsAvatar
+ .mockResolvedValueOnce(expectedBaseAvatarUrl)
+ .mockResolvedValueOnce(expectedMainnetAvatarUrl);
+
+ const avatarUrl = await getAvatar({ ensName, chain: base });
+
+ expect(avatarUrl).toBe(expectedMainnetAvatarUrl);
+ expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, {
+ name: ensName,
+ universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id],
+ });
+ expect(getChainPublicClient).toHaveBeenNthCalledWith(1, base);
+
+ // getAvatar defaulted to mainnet
+ expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, {
+ name: ensName,
+ universalResolverAddress: undefined,
+ });
+ expect(getChainPublicClient).toHaveBeenNthCalledWith(2, mainnet);
+ });
+
+ it('should default to mainnet when base sepolia avatar is not available', async () => {
+ const ensName = 'shrek.basetest.eth';
+ const expectedBaseAvatarUrl = null;
+ const expectedMainnetAvatarUrl = 'mainnetname.eth';
+
+ mockGetEnsAvatar
+ .mockResolvedValueOnce(expectedBaseAvatarUrl)
+ .mockResolvedValueOnce(expectedMainnetAvatarUrl);
+
+ const avatarUrl = await getAvatar({ ensName, chain: baseSepolia });
+
+ expect(avatarUrl).toBe(expectedMainnetAvatarUrl);
+ expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, {
+ name: ensName,
+ universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[baseSepolia.id],
+ });
+ expect(getChainPublicClient).toHaveBeenNthCalledWith(1, baseSepolia);
+
+ // getAvatar defaulted to mainnet
+ expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, {
+ name: ensName,
+ universalResolverAddress: undefined,
+ });
+ expect(getChainPublicClient).toHaveBeenNthCalledWith(2, mainnet);
+ });
+
+ it('should throw an error on unsupported chain', async () => {
+ const ensName = 'shrek.basetest.eth';
+ await expect(getAvatar({ ensName, chain: optimism })).rejects.toBe(
+ 'ChainId not supported, avatar resolution is only supported on Ethereum and Base.',
+ );
+ expect(getChainPublicClient).not.toHaveBeenCalled();
+ });
+
+ it('should ignore base call error and default to mainnet', async () => {
+ const ensName = 'shrek.base.eth';
+ const expectedMainnetAvatarUrl = 'mainnetname.eth';
+
+ mockGetEnsAvatar
+ .mockImplementationOnce(() => {
+ throw new Error('thrown error');
+ })
+ .mockResolvedValueOnce(expectedMainnetAvatarUrl);
+
+ const avatarUrl = await getAvatar({ ensName, chain: base });
+
+ expect(avatarUrl).toBe(expectedMainnetAvatarUrl);
+ expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, {
+ name: ensName,
+ universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[base.id],
+ });
+ expect(getChainPublicClient).toHaveBeenNthCalledWith(1, base);
+
+ // getAvatar defaulted to mainnet
+ expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(2, {
+ name: ensName,
+ universalResolverAddress: undefined,
+ });
+ expect(getChainPublicClient).toHaveBeenNthCalledWith(2, mainnet);
+ });
});
diff --git a/src/identity/utils/getAvatar.ts b/src/identity/utils/getAvatar.ts
index eb2a5b3e94..6e4c239d92 100644
--- a/src/identity/utils/getAvatar.ts
+++ b/src/identity/utils/getAvatar.ts
@@ -1,11 +1,50 @@
+import { mainnet } from 'viem/chains';
import { normalize } from 'viem/ens';
-import { publicClient } from '../../network/client';
+import { isBase } from '../../isBase';
+import { isEthereum } from '../../isEthereum';
+import { getChainPublicClient } from '../../network/getChainPublicClient';
+import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '../constants';
import type { GetAvatar, GetAvatarReturnType } from '../types';
-export const getAvatar = async (
- params: GetAvatar,
-): Promise => {
- return await publicClient.getEnsAvatar({
- name: normalize(params.ensName),
+/**
+ * An asynchronous function to fetch the Ethereum Name Service (ENS)
+ * avatar for a given Ethereum name. It returns the ENS name if it exists,
+ * or null if it doesn't or in case of an error.
+ */
+export const getAvatar = async ({
+ ensName,
+ chain = mainnet,
+}: GetAvatar): Promise => {
+ 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.',
+ );
+ }
+
+ let client = getChainPublicClient(chain);
+
+ if (chainIsBase) {
+ try {
+ const baseEnsAvatar = await client.getEnsAvatar({
+ name: normalize(ensName),
+ universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id],
+ });
+
+ if (baseEnsAvatar) {
+ return baseEnsAvatar;
+ }
+ } catch (_error) {
+ // This is a best effort attempt, so we don't need to do anything here.
+ }
+ }
+
+ // Default to mainnet
+ client = getChainPublicClient(mainnet);
+ return await client.getEnsAvatar({
+ name: normalize(ensName),
});
};