Skip to content

Commit

Permalink
feat: Use default basename avatar when possible (#1002)
Browse files Browse the repository at this point in the history
  • Loading branch information
kirkas authored Aug 7, 2024
1 parent d155adc commit 9fbb7ec
Show file tree
Hide file tree
Showing 11 changed files with 269 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/warm-rice-study.md
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions src/identity/components/Avatar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
19 changes: 19 additions & 0 deletions src/identity/constants.ts

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions src/identity/utils/getAvatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 21 additions & 4 deletions src/identity/utils/getAvatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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],
});
Expand All @@ -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;
};
19 changes: 19 additions & 0 deletions src/identity/utils/getBaseDefaultProfilePicture.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions src/identity/utils/getBaseDefaultProfilePicture.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
19 changes: 19 additions & 0 deletions src/identity/utils/getBaseDefaultProfilePictureIndex.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
16 changes: 16 additions & 0 deletions src/identity/utils/getBaseDefaultProfilePictureIndex.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
21 changes: 21 additions & 0 deletions src/identity/utils/isBasename.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions src/identity/utils/isBasename.tsx
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit 9fbb7ec

Please sign in to comment.