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;
+};