Skip to content

Commit

Permalink
add base avatar support, fallback to mainnet
Browse files Browse the repository at this point in the history
  • Loading branch information
kirkas committed Aug 6, 2024
1 parent c1f2cf6 commit 23e8d6c
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-lies-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

df
1 change: 1 addition & 0 deletions site/docs/pages/identity/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};
```
Expand Down
1 change: 1 addition & 0 deletions site/docs/pages/identity/use-avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const { data: avatar, isLoading } = useAvatar({ ensName: 'vitalik.eth' });
```ts
type UseAvatarOptions = {
ensName: string;
chain?: Chain;
};

type UseAvatarQueryOptions = {
Expand Down
30 changes: 29 additions & 1 deletion src/identity/components/Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -70,3 +70,31 @@ export const WithBadge: Story = {
children: <Badge />,
},
};

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,
},
};
4 changes: 2 additions & 2 deletions src/identity/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);

Expand Down
63 changes: 60 additions & 3 deletions src/identity/hooks/useAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
/**
* @vitest-environment jsdom
*/
import { renderHook, waitFor } from '@testing-library/react';
import { base, baseSepolia, mainnet } 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;

Expand All @@ -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 () => {
Expand All @@ -50,4 +57,54 @@ 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);
});
});
23 changes: 10 additions & 13 deletions src/identity/hooks/useAvatar.ts
Original file line number Diff line number Diff line change
@@ -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<GetAvatarReturnType>({
queryKey: ['useAvatar', ensActionKey],
queryFn: async () => {
return getAvatar({ ensName });
return getAvatar({ ensName, chain });
},
gcTime: cacheTime,
enabled,
Expand Down
17 changes: 17 additions & 0 deletions src/identity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type GetAttestationsOptions = {
* Note: exported as public Type
*/
export type GetAvatar = {
chain?: Chain; // Optional chain for domain resolution
ensName: string; // The ENS name to fetch the avatar for.
};

Expand Down Expand Up @@ -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
*/
Expand Down
42 changes: 41 additions & 1 deletion src/identity/utils/getAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { base, baseSepolia, mainnet } 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();
});
Expand All @@ -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 () => {
Expand All @@ -30,4 +38,36 @@ 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);
});

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);
});
});
37 changes: 31 additions & 6 deletions src/identity/utils/getAvatar.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import { mainnet } from 'viem/chains';
import { normalize } from 'viem/ens';
import { publicClient } from '../../network/client';
import type { GetEnsAvatarReturnType } from 'wagmi/actions';
import { isBase } from '../../isBase';
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<GetAvatarReturnType> => {
return await publicClient.getEnsAvatar({
name: normalize(params.ensName),
export const getAvatar = async ({
ensName,
chain = mainnet,
}: GetAvatar): Promise<GetAvatarReturnType> => {
let client = getChainPublicClient(chain);

if (isBase({ chainId: chain.id })) {
client = getChainPublicClient(chain);
try {
const baseEnsAvatar = await client.getEnsAvatar({
name: normalize(ensName),
universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id],
});

if (baseEnsAvatar) {
return baseEnsAvatar as GetEnsAvatarReturnType;
}
} 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),
});
};

0 comments on commit 23e8d6c

Please sign in to comment.