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), }); };