Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT]: Use default basename avatar when possible #1002

Merged
merged 3 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the comments

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