From 9fbb7ecf3294c4326e1f34320e81d84186fd8705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 7 Aug 2024 19:06:42 -0400 Subject: [PATCH] feat: Use default basename avatar when possible (#1002) --- .changeset/warm-rice-study.md | 5 + src/identity/components/Avatar.stories.tsx | 14 +++ src/identity/constants.ts | 19 +++ src/identity/utils/getAvatar.test.tsx | 110 ++++++++++++++++++ src/identity/utils/getAvatar.ts | 25 +++- .../getBaseDefaultProfilePicture.test.tsx | 19 +++ .../utils/getBaseDefaultProfilePicture.tsx | 15 +++ ...getBaseDefaultProfilePictureIndex.test.tsx | 19 +++ .../getBaseDefaultProfilePictureIndex.tsx | 16 +++ src/identity/utils/isBasename.test.tsx | 21 ++++ src/identity/utils/isBasename.tsx | 10 ++ 11 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 .changeset/warm-rice-study.md create mode 100644 src/identity/utils/getBaseDefaultProfilePicture.test.tsx create mode 100644 src/identity/utils/getBaseDefaultProfilePicture.tsx create mode 100644 src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx create mode 100644 src/identity/utils/getBaseDefaultProfilePictureIndex.tsx create mode 100644 src/identity/utils/isBasename.test.tsx create mode 100644 src/identity/utils/isBasename.tsx diff --git a/.changeset/warm-rice-study.md b/.changeset/warm-rice-study.md new file mode 100644 index 0000000000..620c964cbd --- /dev/null +++ b/.changeset/warm-rice-study.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": minor +--- +**feat**: Add `isBasename` and `getBaseDefaultProfilePicture` function to resolve to default avatars. By @kirkas #1002 +**feat**: Modify `getAvatar` to resolve default avatars, only for basenames. By @kirkas #1002 \ No newline at end of file diff --git a/src/identity/components/Avatar.stories.tsx b/src/identity/components/Avatar.stories.tsx index 64326193f7..ad99d4d98a 100644 --- a/src/identity/components/Avatar.stories.tsx +++ b/src/identity/components/Avatar.stories.tsx @@ -98,3 +98,17 @@ export const BaseSepoliaDefaultToMainnet: Story = { chain: baseSepolia, }, }; + +export const BaseDefaultProfile: Story = { + args: { + address: '0xdb39F11c909bFA976FdC27538152C1a0E4f0fCcA', + chain: base, + }, +}; + +export const BaseSepoliaDefaultProfile: Story = { + args: { + address: '0x8c8F1a1e1bFdb15E7ed562efc84e5A588E68aD73', + chain: baseSepolia, + }, +}; diff --git a/src/identity/constants.ts b/src/identity/constants.ts index eda0eac32d..f02eef9084 100644 --- a/src/identity/constants.ts +++ b/src/identity/constants.ts @@ -5,3 +5,22 @@ export const RESOLVER_ADDRESSES_BY_CHAIN_ID: ResolverAddressesByChainIdMap = { [baseSepolia.id]: '0x6533C94869D28fAA8dF77cc63f9e2b2D6Cf77eBA', [base.id]: '0xC6d566A56A1aFf6508b41f6c90ff131615583BCD', }; + +// Basename default profile pictures +const BASE_DEFAULT_PROFILE_PICTURES1 = ``; +const BASE_DEFAULT_PROFILE_PICTURES2 = ``; +const BASE_DEFAULT_PROFILE_PICTURES3 = ``; +const BASE_DEFAULT_PROFILE_PICTURES4 = ``; +const BASE_DEFAULT_PROFILE_PICTURES5 = ``; +const BASE_DEFAULT_PROFILE_PICTURES6 = ``; +const BASE_DEFAULT_PROFILE_PICTURES7 = ``; + +export const BASE_DEFAULT_PROFILE_PICTURES = [ + BASE_DEFAULT_PROFILE_PICTURES1, + BASE_DEFAULT_PROFILE_PICTURES2, + BASE_DEFAULT_PROFILE_PICTURES3, + BASE_DEFAULT_PROFILE_PICTURES4, + BASE_DEFAULT_PROFILE_PICTURES5, + BASE_DEFAULT_PROFILE_PICTURES6, + BASE_DEFAULT_PROFILE_PICTURES7, +]; diff --git a/src/identity/utils/getAvatar.test.tsx b/src/identity/utils/getAvatar.test.tsx index e217cf9b04..ba217e0b9a 100644 --- a/src/identity/utils/getAvatar.test.tsx +++ b/src/identity/utils/getAvatar.test.tsx @@ -125,6 +125,116 @@ describe('getAvatar', () => { expect(getChainPublicClient).toHaveBeenNthCalledWith(2, mainnet); }); + it('should use default base avatar when both mainnet and base mainnet avatar are not available', async () => { + const ensName = 'shrek.base.eth'; + const expectedBaseAvatarUrl = null; + const expectedMainnetAvatarUrl = null; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedBaseAvatarUrl) + .mockResolvedValueOnce(expectedMainnetAvatarUrl); + + const avatarUrl = await getAvatar({ ensName, chain: base }); + + const avatarUrlIsUriData = avatarUrl?.startsWith( + 'data:image/svg+xml;base64', + ); + expect(avatarUrlIsUriData).toBe(true); + 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 use default base avatar when both mainnet and base sepolia avatar are not available', async () => { + const ensName = 'shrek.basetest.eth'; + const expectedBaseAvatarUrl = null; + const expectedMainnetAvatarUrl = null; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedBaseAvatarUrl) + .mockResolvedValueOnce(expectedMainnetAvatarUrl); + + const avatarUrl = await getAvatar({ ensName, chain: baseSepolia }); + + const avatarUrlIsUriData = avatarUrl?.startsWith( + 'data:image/svg+xml;base64', + ); + expect(avatarUrlIsUriData).toBe(true); + 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 never default base avatar for non-basename', async () => { + const ensName = 'ethereummainnetname.eth'; + const expectedBaseAvatarUrl = null; + const expectedMainnetAvatarUrl = null; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedBaseAvatarUrl) + .mockResolvedValueOnce(expectedMainnetAvatarUrl); + + const avatarUrl = await getAvatar({ ensName, chain: base }); + + expect(avatarUrl).toBe(null); + 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 never default base avatar for non-basename', async () => { + const ensName = 'ethereummainnetname.eth'; + const expectedBaseAvatarUrl = null; + const expectedMainnetAvatarUrl = null; + + mockGetEnsAvatar + .mockResolvedValueOnce(expectedBaseAvatarUrl) + .mockResolvedValueOnce(expectedMainnetAvatarUrl); + + const avatarUrl = await getAvatar({ ensName, chain: baseSepolia }); + + expect(avatarUrl).toBe(null); + 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( diff --git a/src/identity/utils/getAvatar.ts b/src/identity/utils/getAvatar.ts index 6e4c239d92..3a2fc7855a 100644 --- a/src/identity/utils/getAvatar.ts +++ b/src/identity/utils/getAvatar.ts @@ -4,7 +4,9 @@ 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'; +import type { BaseName, GetAvatar, GetAvatarReturnType } from '../types'; +import { getBaseDefaultProfilePicture } from './getBaseDefaultProfilePicture'; +import { isBasename } from './isBasename'; /** * An asynchronous function to fetch the Ethereum Name Service (ENS) @@ -18,6 +20,7 @@ export const getAvatar = async ({ const chainIsBase = isBase({ chainId: chain.id }); const chainIsEthereum = isEthereum({ chainId: chain.id }); const chainSupportsUniversalResolver = chainIsEthereum || chainIsBase; + const usernameIsBasename = isBasename(ensName); if (!chainSupportsUniversalResolver) { return Promise.reject( @@ -26,10 +29,12 @@ export const getAvatar = async ({ } let client = getChainPublicClient(chain); + let baseEnsAvatar = null; + // 1. Try basename if (chainIsBase) { try { - const baseEnsAvatar = await client.getEnsAvatar({ + baseEnsAvatar = await client.getEnsAvatar({ name: normalize(ensName), universalResolverAddress: RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id], }); @@ -42,9 +47,21 @@ export const getAvatar = async ({ } } - // Default to mainnet + // 2. Defaults to mainnet client = getChainPublicClient(mainnet); - return await client.getEnsAvatar({ + const mainnetEnsAvatar = await client.getEnsAvatar({ name: normalize(ensName), }); + + if (mainnetEnsAvatar) { + return mainnetEnsAvatar; + } + + // 3. If username is a basename (.base.eth / .basetest.eth), use default basename avatars + if (usernameIsBasename) { + return getBaseDefaultProfilePicture(ensName as BaseName); + } + + // 4. No avatars to display + return null; }; diff --git a/src/identity/utils/getBaseDefaultProfilePicture.test.tsx b/src/identity/utils/getBaseDefaultProfilePicture.test.tsx new file mode 100644 index 0000000000..578c229eca --- /dev/null +++ b/src/identity/utils/getBaseDefaultProfilePicture.test.tsx @@ -0,0 +1,19 @@ +import { getBaseDefaultProfilePicture } from './getBaseDefaultProfilePicture'; + +describe('getBaseDefaultProfilePicture', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return correct resolver data for base mainnet', async () => { + const defaultAvatar = getBaseDefaultProfilePicture('shrek.base.eth'); + const validString = defaultAvatar.startsWith('data:image/svg+xml;base64'); + expect(validString).toBe(true); + }); + + it('should return correct resolver data for base sepolia', async () => { + const defaultAvatar = getBaseDefaultProfilePicture('shrek.basetest.eth'); + const validString = defaultAvatar.startsWith('data:image/svg+xml;base64'); + expect(validString).toBe(true); + }); +}); diff --git a/src/identity/utils/getBaseDefaultProfilePicture.tsx b/src/identity/utils/getBaseDefaultProfilePicture.tsx new file mode 100644 index 0000000000..afaed63552 --- /dev/null +++ b/src/identity/utils/getBaseDefaultProfilePicture.tsx @@ -0,0 +1,15 @@ +import { BASE_DEFAULT_PROFILE_PICTURES } from '../constants'; +import type { BaseName } from '../types'; +import { getBaseDefaultProfilePictureIndex } from './getBaseDefaultProfilePictureIndex'; + +export const getBaseDefaultProfilePicture = (username: BaseName) => { + const profilePictureIndex = getBaseDefaultProfilePictureIndex( + username, + BASE_DEFAULT_PROFILE_PICTURES.length, + ); + const selectedProfilePicture = + BASE_DEFAULT_PROFILE_PICTURES[profilePictureIndex]; + const base64Svg = btoa(selectedProfilePicture); + const dataUri = `data:image/svg+xml;base64,${base64Svg}`; + return dataUri; +}; diff --git a/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx b/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx new file mode 100644 index 0000000000..e21e5b9384 --- /dev/null +++ b/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx @@ -0,0 +1,19 @@ +import { getBaseDefaultProfilePictureIndex } from './getBaseDefaultProfilePictureIndex'; + +describe('getBaseDefaultProfilePictureIndex', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Should always return the same index given a number of options', async () => { + // Note: This seems silly but this tests "proves" the algorithm is deterministic + expect(getBaseDefaultProfilePictureIndex('shrek.base.eth', 7)).toBe(3); + expect(getBaseDefaultProfilePictureIndex('shrek.basetest.eth', 7)).toBe(4); + expect(getBaseDefaultProfilePictureIndex('leo.base.eth', 7)).toBe(0); + expect(getBaseDefaultProfilePictureIndex('leo.basetest.eth', 7)).toBe(3); + expect(getBaseDefaultProfilePictureIndex('zimmania.base.eth', 7)).toBe(5); + expect(getBaseDefaultProfilePictureIndex('zimmania.basetest.eth', 7)).toBe( + 4, + ); + }); +}); diff --git a/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx b/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx new file mode 100644 index 0000000000..6acbe7d4e4 --- /dev/null +++ b/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx @@ -0,0 +1,16 @@ +import { sha256 } from 'viem'; + +// Will return a an index between 0 and optionsLength +export const getBaseDefaultProfilePictureIndex = ( + name: string, + optionsLength: number, +) => { + const nameAsUint8Array = Uint8Array.from( + name.split('').map((letter) => letter.charCodeAt(0)), + ); + const hash = sha256(nameAsUint8Array); + const hashValue = Number.parseInt(hash, 16); + const remainder = hashValue % optionsLength; + const index = remainder; + return index; +}; diff --git a/src/identity/utils/isBasename.test.tsx b/src/identity/utils/isBasename.test.tsx new file mode 100644 index 0000000000..8946b10e1a --- /dev/null +++ b/src/identity/utils/isBasename.test.tsx @@ -0,0 +1,21 @@ +import { isBasename } from './isBasename'; + +describe('isBasename', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Returns true for base mainnet names', async () => { + expect(isBasename('shrek.base.eth')).toBe(true); + }); + + it('Returns true for base mainnet sepolia names', async () => { + expect(isBasename('shrek.basetest.eth')).toBe(true); + }); + + it('Returns false for any other name', async () => { + expect(isBasename('shrek.optimisim.eth')).toBe(false); + expect(isBasename('shrek.eth')).toBe(false); + expect(isBasename('shrek.baaaaaes.eth')).toBe(false); + }); +}); diff --git a/src/identity/utils/isBasename.tsx b/src/identity/utils/isBasename.tsx new file mode 100644 index 0000000000..f749d50f03 --- /dev/null +++ b/src/identity/utils/isBasename.tsx @@ -0,0 +1,10 @@ +export const isBasename = (username: string) => { + if (username.endsWith('.base.eth')) { + return true; + } + + if (username.endsWith('.basetest.eth')) { + return true; + } + return false; +};