diff --git a/packages/bridge-ui/src/libs/token/fetchNFTImageUrl.ts b/packages/bridge-ui/src/libs/token/fetchNFTImageUrl.ts index 17bd76ec2b..67abcbea83 100644 --- a/packages/bridge-ui/src/libs/token/fetchNFTImageUrl.ts +++ b/packages/bridge-ui/src/libs/token/fetchNFTImageUrl.ts @@ -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'; @@ -62,12 +63,18 @@ const fetchImageUrl = async (url: string): Promise => { 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}`); diff --git a/packages/bridge-ui/src/libs/token/fetchNFTMetadata.test.ts b/packages/bridge-ui/src/libs/token/fetchNFTMetadata.test.ts new file mode 100644 index 0000000000..1d22ca682d --- /dev/null +++ b/packages/bridge-ui/src/libs/token/fetchNFTMetadata.test.ts @@ -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(); + 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); + }); + }); + }); + }); +}); diff --git a/packages/bridge-ui/src/libs/token/fetchNFTMetadata.ts b/packages/bridge-ui/src/libs/token/fetchNFTMetadata.ts index 42de787725..5fc80732db 100644 --- a/packages/bridge-ui/src/libs/token/fetchNFTMetadata.ts +++ b/packages/bridge-ui/src/libs/token/fetchNFTMetadata.ts @@ -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'; @@ -42,6 +43,29 @@ export async function fetchNFTMetadata(token: NFT): Promise // 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); diff --git a/packages/bridge-ui/src/libs/token/types.ts b/packages/bridge-ui/src/libs/token/types.ts index e4ea3db541..da71e4b15e 100644 --- a/packages/bridge-ui/src/libs/token/types.ts +++ b/packages/bridge-ui/src/libs/token/types.ts @@ -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? diff --git a/packages/bridge-ui/src/libs/util/decodeBase64ToJson.ts b/packages/bridge-ui/src/libs/util/decodeBase64ToJson.ts new file mode 100644 index 0000000000..d4d9812405 --- /dev/null +++ b/packages/bridge-ui/src/libs/util/decodeBase64ToJson.ts @@ -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); + } +}; diff --git a/packages/bridge-ui/src/tests/mocks/tokens.ts b/packages/bridge-ui/src/tests/mocks/tokens.ts index 2172bcd4e1..56ee8958d8 100644 --- a/packages/bridge-ui/src/tests/mocks/tokens.ts +++ b/packages/bridge-ui/src/tests/mocks/tokens.ts @@ -1,10 +1,15 @@ import { type NFT, type NFTMetadata, TokenType } from '$libs/token/types'; +const base64Image = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIwIiBoZWlnaHQ9IjMyMCIgdmlld0JveD0iMCAwIDMyMCAzMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZDVkN2UxIiAvPjxyZWN0IHdpZHRoPSIxNDAiIGhlaWdodD0iMTAiIHg9IjkwIiB5PSIyMTAiIGZpbGw9IiNmZmZkZjIiIC8+PC9zdmc+'; +export const base64Metadata = + 'eyJuYW1lIjoiTW9jayBOYW1lIiwgImRlc2NyaXB0aW9uIjoibW9jayBkZXNjcmlwdGlvbiIsICJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUIzYVdSMGFEMGlNekl3SWlCb1pXbG5hSFE5SWpNeU1DSWdkbWxsZDBKdmVEMGlNQ0F3SURNeU1DQXpNakFpSUhodGJHNXpQU0pvZEhSd09pOHZkM2QzTG5jekxtOXlaeTh5TURBd0wzTjJaeUlnYzJoaGNHVXRjbVZ1WkdWeWFXNW5QU0pqY21semNFVmtaMlZ6SWo0OGNtVmpkQ0IzYVdSMGFEMGlNVEF3SlNJZ2FHVnBaMmgwUFNJeE1EQWxJaUJtYVd4c1BTSWpaRFZrTjJVeElpQXZQanh5WldOMElIZHBaSFJvUFNJeE5EQWlJR2hsYVdkb2REMGlNVEFpSUhnOUlqa3dJaUI1UFNJeU1UQWlJR1pwYkd3OUlpTm1abVprWmpJaUlDOCtQQzl6ZG1jKyJ9'; + export const MOCK_METADATA = { - name: 'name', - description: 'description', - image: 'image', - external_url: 'external_url', + name: 'Mock Name', + description: 'mock description', + image: 'image/mock.png', + external_url: 'mock/external_url', } satisfies NFTMetadata; export const MOCK_ERC721 = { @@ -16,3 +21,15 @@ export const MOCK_ERC721 = { metadata: MOCK_METADATA, tokenId: 42, } satisfies NFT; + +export const MOCK_ERC721_BASE64 = { + ...MOCK_ERC721, + uri: base64Metadata, +}; + +export const MOCK_METADATA_BASE64 = { + name: 'Mock Name', + description: 'mock description', + image: base64Image, + external_url: undefined, +} satisfies NFTMetadata;