From 460637aaec98f0dfad7f1f4db5ec9c1de89ea5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 7 Aug 2024 16:40:30 -0400 Subject: [PATCH 1/3] use default basename avatar when possible --- src/identity/components/Avatar.stories.tsx | 14 +++ src/identity/constants.ts | 19 ++++ src/identity/utils/getAvatar.test.tsx | 107 ++++++++++++++++++ src/identity/utils/getAvatar.ts | 25 +++- .../getBaseDefaultProfilePicture.test.tsx | 20 ++++ .../utils/getBaseDefaultProfilePicture.tsx | 15 +++ ...getBaseDefaultProfilePictureIndex.test.tsx | 18 +++ .../getBaseDefaultProfilePictureIndex.tsx | 16 +++ src/identity/utils/isBasename.test.tsx | 22 ++++ src/identity/utils/isBasename.tsx | 5 + 10 files changed, 257 insertions(+), 4 deletions(-) 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/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..7701492b3a 100644 --- a/src/identity/utils/getAvatar.test.tsx +++ b/src/identity/utils/getAvatar.test.tsx @@ -125,6 +125,113 @@ 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..2bb001eb9a 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..2ed73377b9 --- /dev/null +++ b/src/identity/utils/getBaseDefaultProfilePicture.test.tsx @@ -0,0 +1,20 @@ + +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..affe13842b --- /dev/null +++ b/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx @@ -0,0 +1,18 @@ + +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..1d7121e6e8 --- /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; +}; \ No newline at end of file diff --git a/src/identity/utils/isBasename.test.tsx b/src/identity/utils/isBasename.test.tsx new file mode 100644 index 0000000000..4929729acd --- /dev/null +++ b/src/identity/utils/isBasename.test.tsx @@ -0,0 +1,22 @@ + +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..92c4375152 --- /dev/null +++ b/src/identity/utils/isBasename.tsx @@ -0,0 +1,5 @@ +export const isBasename = (username: string) => { + if (username.endsWith('.base.eth')) return true; + if (username.endsWith('.basetest.eth')) return true; + return false; +}; From 1b87f9994f692d3022bb4d5a5b67fd383eb214d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 7 Aug 2024 16:43:28 -0400 Subject: [PATCH 2/3] changelog --- .changeset/warm-rice-study.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/warm-rice-study.md 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 From aad7f2e20b391f28aa7cd8c43e09e7ad303984dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 7 Aug 2024 16:45:00 -0400 Subject: [PATCH 3/3] ci build --- src/identity/utils/getAvatar.test.tsx | 9 ++++++--- src/identity/utils/getAvatar.ts | 2 +- src/identity/utils/getBaseDefaultProfilePicture.test.tsx | 1 - .../utils/getBaseDefaultProfilePictureIndex.test.tsx | 5 +++-- src/identity/utils/getBaseDefaultProfilePictureIndex.tsx | 4 ++-- src/identity/utils/isBasename.test.tsx | 1 - src/identity/utils/isBasename.tsx | 9 +++++++-- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/identity/utils/getAvatar.test.tsx b/src/identity/utils/getAvatar.test.tsx index 7701492b3a..ba217e0b9a 100644 --- a/src/identity/utils/getAvatar.test.tsx +++ b/src/identity/utils/getAvatar.test.tsx @@ -125,7 +125,6 @@ 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; @@ -137,7 +136,9 @@ describe('getAvatar', () => { const avatarUrl = await getAvatar({ ensName, chain: base }); - const avatarUrlIsUriData = avatarUrl?.startsWith('data:image/svg+xml;base64') + const avatarUrlIsUriData = avatarUrl?.startsWith( + 'data:image/svg+xml;base64', + ); expect(avatarUrlIsUriData).toBe(true); expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { name: ensName, @@ -164,7 +165,9 @@ describe('getAvatar', () => { const avatarUrl = await getAvatar({ ensName, chain: baseSepolia }); - const avatarUrlIsUriData = avatarUrl?.startsWith('data:image/svg+xml;base64') + const avatarUrlIsUriData = avatarUrl?.startsWith( + 'data:image/svg+xml;base64', + ); expect(avatarUrlIsUriData).toBe(true); expect(mockGetEnsAvatar).toHaveBeenNthCalledWith(1, { name: ensName, diff --git a/src/identity/utils/getAvatar.ts b/src/identity/utils/getAvatar.ts index 2bb001eb9a..3a2fc7855a 100644 --- a/src/identity/utils/getAvatar.ts +++ b/src/identity/utils/getAvatar.ts @@ -53,7 +53,7 @@ export const getAvatar = async ({ name: normalize(ensName), }); - if(mainnetEnsAvatar) { + if (mainnetEnsAvatar) { return mainnetEnsAvatar; } diff --git a/src/identity/utils/getBaseDefaultProfilePicture.test.tsx b/src/identity/utils/getBaseDefaultProfilePicture.test.tsx index 2ed73377b9..578c229eca 100644 --- a/src/identity/utils/getBaseDefaultProfilePicture.test.tsx +++ b/src/identity/utils/getBaseDefaultProfilePicture.test.tsx @@ -1,4 +1,3 @@ - import { getBaseDefaultProfilePicture } from './getBaseDefaultProfilePicture'; describe('getBaseDefaultProfilePicture', () => { diff --git a/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx b/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx index affe13842b..e21e5b9384 100644 --- a/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx +++ b/src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx @@ -1,4 +1,3 @@ - import { getBaseDefaultProfilePictureIndex } from './getBaseDefaultProfilePictureIndex'; describe('getBaseDefaultProfilePictureIndex', () => { @@ -13,6 +12,8 @@ describe('getBaseDefaultProfilePictureIndex', () => { 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); + expect(getBaseDefaultProfilePictureIndex('zimmania.basetest.eth', 7)).toBe( + 4, + ); }); }); diff --git a/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx b/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx index 1d7121e6e8..6acbe7d4e4 100644 --- a/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx +++ b/src/identity/utils/getBaseDefaultProfilePictureIndex.tsx @@ -1,4 +1,4 @@ -import { sha256 } from "viem"; +import { sha256 } from 'viem'; // Will return a an index between 0 and optionsLength export const getBaseDefaultProfilePictureIndex = ( @@ -13,4 +13,4 @@ export const getBaseDefaultProfilePictureIndex = ( const remainder = hashValue % optionsLength; const index = remainder; return index; -}; \ No newline at end of file +}; diff --git a/src/identity/utils/isBasename.test.tsx b/src/identity/utils/isBasename.test.tsx index 4929729acd..8946b10e1a 100644 --- a/src/identity/utils/isBasename.test.tsx +++ b/src/identity/utils/isBasename.test.tsx @@ -1,4 +1,3 @@ - import { isBasename } from './isBasename'; describe('isBasename', () => { diff --git a/src/identity/utils/isBasename.tsx b/src/identity/utils/isBasename.tsx index 92c4375152..f749d50f03 100644 --- a/src/identity/utils/isBasename.tsx +++ b/src/identity/utils/isBasename.tsx @@ -1,5 +1,10 @@ export const isBasename = (username: string) => { - if (username.endsWith('.base.eth')) return true; - if (username.endsWith('.basetest.eth')) return true; + if (username.endsWith('.base.eth')) { + return true; + } + + if (username.endsWith('.basetest.eth')) { + return true; + } return false; };