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(bridge-ui): base64 NFT data #16645

Merged
merged 3 commits into from
Apr 4, 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
17 changes: 12 additions & 5 deletions packages/bridge-ui/src/libs/token/fetchNFTImageUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { get } from 'svelte/store';

import { destNetwork } from '$components/Bridge/state';
import { fetchNFTMetadata } from '$libs/token/fetchNFTMetadata';
import { decodeBase64ToJson } from '$libs/util/decodeBase64ToJson';
import { getLogger } from '$libs/util/logger';
import { resolveIPFSUri } from '$libs/util/resolveIPFSUri';
import { addMetadataToCache, isMetadataCached } from '$stores/metadata';
Expand Down Expand Up @@ -62,12 +63,18 @@ const fetchImageUrl = async (url: string): Promise<string> => {
return url;
} else {
log('fetchImageUrl failed to load image');
const newUrl = await resolveIPFSUri(url);
if (newUrl) {
const gatewayImageLoaded = await testImageLoad(newUrl);
if (gatewayImageLoaded) {
return newUrl;
if (url.startsWith('ipfs://')) {
const newUrl = await resolveIPFSUri(url);
if (newUrl) {
const gatewayImageLoaded = await testImageLoad(newUrl);
if (gatewayImageLoaded) {
return newUrl;
}
}
} else if (url.startsWith('data:image/svg+xml;base64,')) {
const base64 = url.replace('data:image/svg+xml;base64,', '');
const decodedImage = decodeBase64ToJson(base64);
return decodedImage;
}
}
throw new Error(`No image found for ${url}`);
Expand Down
283 changes: 283 additions & 0 deletions packages/bridge-ui/src/libs/token/fetchNFTMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import axios from 'axios';
import { type Address, type Chain, zeroAddress } from 'viem';

import { destNetwork } from '$components/Bridge/state';
import { FetchMetadataError } from '$libs/error';
import { L1_CHAIN_ID, L2_CHAIN_ID, MOCK_ERC721, MOCK_ERC721_BASE64, MOCK_METADATA, MOCK_METADATA_BASE64 } from '$mocks';
import { getMetadataFromCache, isMetadataCached } from '$stores/metadata';
import { connectedSourceChain } from '$stores/network';
import type { TokenInfo } from '$stores/tokenInfo';

import { fetchNFTMetadata } from './fetchNFTMetadata';
import { getTokenAddresses } from './getTokenAddresses';
import { getTokenWithInfoFromAddress } from './getTokenWithInfoFromAddress';

vi.mock('../../generated/customTokenConfig', () => {
const mockERC20 = {
name: 'MockERC20',
addresses: { '1': zeroAddress },
symbol: 'MTF',
decimals: 18,
type: 'ERC20',
};
return {
customToken: [mockERC20],
};
});

vi.mock('./getTokenAddresses');

describe('fetchNFTMetadata()', () => {
it('should return null if srcChainId or destChainId is not defined', async () => {
const result = await fetchNFTMetadata(MOCK_ERC721);
expect(result).toBe(null);
});

it('should return null if tokenInfo or tokenInfo.canonical.address is not defined', async () => {
// Given
connectedSourceChain.set({ id: L1_CHAIN_ID } as Chain);
destNetwork.set({ id: L2_CHAIN_ID } as Chain);

vi.mock('$stores/metadata', () => ({
isMetadataCached: vi.fn(),
getMetadataFromCache: vi.fn(),
metadataCache: {
update: vi.fn(),
},
}));

const mockTokenInfo = {
canonical: null,
bridged: {
chainId: L2_CHAIN_ID,
address: MOCK_ERC721.addresses.L2_CHAIN_ID as Address,
},
} satisfies TokenInfo;

vi.mocked(isMetadataCached).mockReturnValue(true);
vi.mocked(getMetadataFromCache).mockReturnValue(MOCK_METADATA);

vi.mocked(getTokenAddresses).mockResolvedValue(mockTokenInfo);
// When
const result = await fetchNFTMetadata(MOCK_ERC721);

// Then
expect(result).toBe(null);
});

describe('when metadata is cached', () => {
beforeAll(() => {
connectedSourceChain.set({ id: L1_CHAIN_ID } as Chain);
destNetwork.set({ id: L2_CHAIN_ID } as Chain);

vi.mock('$stores/metadata', () => ({
isMetadataCached: vi.fn(),
getMetadataFromCache: vi.fn(),
metadataCache: {
update: vi.fn(),
},
}));
});

afterAll(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
vi.resetModules();
});

it('should return metadata if metadata is cached', async () => {
// Given
const mockTokenInfo = {
canonical: {
chainId: L1_CHAIN_ID,
address: MOCK_ERC721.addresses.L1_CHAIN_ID as Address,
},
bridged: {
chainId: L2_CHAIN_ID,
address: MOCK_ERC721.addresses.L2_CHAIN_ID as Address,
},
} satisfies TokenInfo;

vi.mocked(isMetadataCached).mockReturnValue(true);
vi.mocked(getMetadataFromCache).mockReturnValue(MOCK_METADATA);
vi.mocked(getTokenAddresses).mockResolvedValue(mockTokenInfo);

// When
const result = await fetchNFTMetadata(MOCK_ERC721);

// Then
expect(result).toBe(MOCK_METADATA);
});
});

describe('when metadata is not cached', () => {
beforeAll(() => {
vi.mock('$stores/metadata', () => ({
isMetadataCached: vi.fn(),
getMetadataFromCache: vi.fn(),
metadataCache: {
update: vi.fn(),
},
}));
connectedSourceChain.set({ id: L1_CHAIN_ID } as Chain);
destNetwork.set({ id: L2_CHAIN_ID } as Chain);
});

afterAll(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
vi.resetModules();
});

it('should return metadata if uri contains data:application/json;base64', async () => {
// Given
vi.mock('axios');
const MOCK_NFT = {
...MOCK_ERC721_BASE64,
};

const mockTokenInfo = {
canonical: {
chainId: L1_CHAIN_ID,
address: MOCK_ERC721.addresses.L1_CHAIN_ID as Address,
},
bridged: {
chainId: L2_CHAIN_ID,
address: MOCK_ERC721.addresses.L2_CHAIN_ID as Address,
},
} satisfies TokenInfo;

vi.mocked(getTokenAddresses).mockResolvedValue(mockTokenInfo);
vi.mocked(isMetadataCached).mockReturnValue(false);
vi.mocked(axios.get).mockResolvedValue({ status: 200, data: MOCK_METADATA_BASE64 });

// When
const result = await fetchNFTMetadata(MOCK_NFT);

// Then
expect(result).toStrictEqual(MOCK_METADATA_BASE64);
});

it('should return metadata if uri contains ipfs:// and ipfs contains image', async () => {
// Given
vi.mock('axios');

const MOCK_NFT = {
...MOCK_ERC721,
uri: 'ipfs://someuri',
};

const mockTokenInfo = {
canonical: {
chainId: L1_CHAIN_ID,
address: MOCK_ERC721.addresses.L1_CHAIN_ID as Address,
},
bridged: {
chainId: L2_CHAIN_ID,
address: MOCK_ERC721.addresses.L2_CHAIN_ID as Address,
},
} satisfies TokenInfo;

vi.mocked(getTokenAddresses).mockResolvedValue(mockTokenInfo);
vi.mocked(isMetadataCached).mockReturnValue(false);
vi.mocked(axios.get).mockResolvedValue({ status: 200, data: MOCK_METADATA });

// When
const result = await fetchNFTMetadata(MOCK_NFT);

// Then
expect(result).toBe(MOCK_METADATA);
});

describe('when uri is not found', () => {
describe('fetchCrossChainNFTMetadata', () => {
beforeAll(() => {
vi.mock('axios');

vi.mock('./fetchNFTMetadata', async (importOriginal) => {
const actual = await importOriginal<typeof import('./fetchNFTMetadata')>();
return {
...actual,
crossChainFetchNFTMetadata: vi.fn().mockResolvedValue(MOCK_METADATA),
};
});

vi.mock('./getTokenWithInfoFromAddress');
});

afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
vi.resetModules();
});

it('should return metadata if canonical token has valid metadata ', async () => {
// Given
const MOCK_BRIDGED_NFT = {
...MOCK_ERC721,
uri: '',
};

const MOCK_CANONICAL_NFT = {
...MOCK_ERC721,
uri: 'ipfs://someUri',
};

const mockTokenInfo = {
canonical: {
chainId: L1_CHAIN_ID,
address: MOCK_ERC721.addresses.L1_CHAIN_ID as Address,
},
bridged: {
chainId: L2_CHAIN_ID,
address: MOCK_ERC721.addresses.L2_CHAIN_ID as Address,
},
} satisfies TokenInfo;

vi.mocked(getTokenAddresses).mockResolvedValue(mockTokenInfo).mockResolvedValue(mockTokenInfo);
vi.mocked(isMetadataCached).mockReturnValue(false);

vi.mocked(getTokenWithInfoFromAddress).mockResolvedValue(MOCK_CANONICAL_NFT);

// When
const result = await fetchNFTMetadata(MOCK_BRIDGED_NFT);

// Then
expect(result).toBe(MOCK_METADATA);
});

it('should throw FetchMetadataError if no uri is found crosschain either', async () => {
// Given
const MOCK_BRIDGED_NFT = {
...MOCK_ERC721,
uri: '',
};

const MOCK_CANONICAL_NFT = {
...MOCK_ERC721,
uri: '', // No uri on canonical either
};

const mockTokenInfo = {
canonical: {
chainId: L1_CHAIN_ID,
address: MOCK_ERC721.addresses.L1_CHAIN_ID as Address,
},
bridged: {
chainId: L2_CHAIN_ID,
address: MOCK_ERC721.addresses.L2_CHAIN_ID as Address,
},
} satisfies TokenInfo;

vi.mocked(getTokenAddresses).mockResolvedValue(mockTokenInfo).mockResolvedValue(mockTokenInfo);
vi.mocked(isMetadataCached).mockReturnValue(false);

vi.mocked(getTokenWithInfoFromAddress).mockResolvedValue(MOCK_CANONICAL_NFT);

// Then
await expect(fetchNFTMetadata(MOCK_BRIDGED_NFT)).rejects.toBeInstanceOf(FetchMetadataError);
});
});
});
});
});
24 changes: 24 additions & 0 deletions packages/bridge-ui/src/libs/token/fetchNFTMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { get } from 'svelte/store';
import { destNetwork } from '$components/Bridge/state';
import { ipfsConfig } from '$config';
import { FetchMetadataError, NoMetadataFoundError, WrongChainError } from '$libs/error';
import { decodeBase64ToJson } from '$libs/util/decodeBase64ToJson';
import { getLogger } from '$libs/util/logger';
import { resolveIPFSUri } from '$libs/util/resolveIPFSUri';
import { getMetadataFromCache, isMetadataCached, metadataCache } from '$stores/metadata';
Expand Down Expand Up @@ -42,6 +43,29 @@ export async function fetchNFTMetadata(token: NFT): Promise<NFTMetadata | null>
// https://eips.ethereum.org/EIPS/eip-681
// TODO: implement EIP-681, for now we treat it as invalid URI
uri = '';
} else if (uri && uri.startsWith('data:application/json;base64')) {
// we have a base64 encoded json
const base64 = uri.replace('data:application/json;base64,', '');
const decodedData = decodeBase64ToJson(base64);
const metadata: NFTMetadata = {
...decodedData,
image: decodedData.image,
name: decodedData.name,
description: decodedData.description,
external_url: decodedData.external_url,
};
if (decodedData.image) {
// Update cache
metadataCache.update((cache) => {
const key = tokenInfo.canonical?.address;

if (key) {
cache.set(key, metadata);
}
return cache;
});
return metadata;
}
}
if (!uri || uri === '') {
const crossChainMetadata = await crossChainFetchNFTMetadata(token);
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge-ui/src/libs/token/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type NFT = Token & {
// Based on https://docs.opensea.io/docs/metadata-standards
export type NFTMetadata = {
description: string;
external_url: string;
external_url?: string;
image: string;
name: string;
//todo: more metadata?
Expand Down
10 changes: 10 additions & 0 deletions packages/bridge-ui/src/libs/util/decodeBase64ToJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Buffer } from 'buffer';

export const decodeBase64ToJson = (base64: string) => {
try {
const decodedString = Buffer.from(base64, 'base64').toString('utf-8');
return JSON.parse(decodedString);
} catch (error) {
throw new Error('Failed to decode and parse JSON from base64: ' + (error as Error).message);
}
};
Loading
Loading