Skip to content

Commit

Permalink
feat(bridge-ui): base64 NFT data (#16645)
Browse files Browse the repository at this point in the history
  • Loading branch information
KorbinianK committed Apr 4, 2024
1 parent 3854467 commit 4516d0a
Show file tree
Hide file tree
Showing 6 changed files with 351 additions and 10 deletions.
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);
}
};

0 comments on commit 4516d0a

Please sign in to comment.