diff --git a/packages/bridge-ui-v2/.gitignore b/packages/bridge-ui-v2/.gitignore
index 3be284d6842..fa4bf0d56d6 100644
--- a/packages/bridge-ui-v2/.gitignore
+++ b/packages/bridge-ui-v2/.gitignore
@@ -2,6 +2,7 @@
node_modules
/build
/.svelte-kit
+.svelte-kit/
/package
.env
.env.*
diff --git a/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte b/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte
index f46bab429c6..00ce1931bbb 100644
--- a/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte
+++ b/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte
@@ -9,6 +9,10 @@
import { Button } from '$components/Button';
import { Card } from '$components/Card';
import { ChainSelectorWrapper } from '$components/ChainSelector';
+ import ChainSelector from '$components/ChainSelector/ChainSelector.svelte';
+ import { Icon } from '$components/Icon';
+ import IconFlipper from '$components/Icon/IconFlipper.svelte';
+ import { NFTCard } from '$components/NFTCard';
import { NFTList } from '$components/NFTList';
import { successToast, warningToast } from '$components/NotificationToast';
import { errorToast, infoToast } from '$components/NotificationToast/NotificationToast.svelte';
@@ -40,6 +44,7 @@
import { getTokenWithInfoFromAddress } from '$libs/token/getTokenWithInfoFromAddress';
import { refreshUserBalance } from '$libs/util/balance';
import { getConnectedWallet } from '$libs/util/getConnectedWallet';
+ import { shortenAddress } from '$libs/util/shortenAddress';
import { type Account, account } from '$stores/account';
import { type Network, network } from '$stores/network';
import { pendingTransactions } from '$stores/pendingTransactions';
@@ -364,6 +369,7 @@
let nftStepTitle: string;
let nftStepDescription: string;
+ let nextStepButtonText: string;
let nftIdArray: number[];
let enteredIds: string = '';
let contractAddress: Address | '';
@@ -381,6 +387,12 @@
let foundNFTs: NFT[] = [];
let selectedNFT: NFT[] = [];
+ enum NFTView {
+ CARDS,
+ LIST,
+ }
+ let nftView: NFTView = NFTView.CARDS;
+
function onAddressValidation(event: CustomEvent<{ isValidEthereumAddress: boolean; addr: Address }>) {
const { isValidEthereumAddress, addr } = event.detail;
addressInputState = AddressInputState.Validating;
@@ -406,6 +418,7 @@
addressInputState = AddressInputState.Invalid;
}
}
+
const scanForNFTs = async () => {
scanning = true;
const accountAddress = $account?.address;
@@ -417,8 +430,27 @@
scanned = true;
};
+ const changeNFTView = () => {
+ if (nftView === NFTView.CARDS) {
+ nftView = NFTView.LIST;
+ } else {
+ nftView = NFTView.CARDS;
+ }
+ };
+
+ const searchNFTs = () => {
+ // TODO: implement
+ };
+
// Whenever the user switches bridge types, we should reset the forms
- $: $activeBridge && resetForm();
+ $: $activeBridge && (resetForm(), (activeStep = NFTSteps.IMPORT));
+
+ $: {
+ const stepKey = NFTSteps[activeStep].toLowerCase();
+ nftStepTitle = $t(`bridge.title.nft.${stepKey}`);
+ nftStepDescription = $t(`bridge.description.nft.${stepKey}`);
+ nextStepButtonText = activeStep === NFTSteps.CONFIRM ? $t('common.confirm') : $t('common.continue');
+ }
$: {
const stepKey = NFTSteps[activeStep].toLowerCase();
@@ -545,31 +577,86 @@
Automatic NFT Input
-->
-
+ {#if !scanned}
+
+ {:else}
+
+ {/if}
-
+
{#if scanned}
-
Your NFTs:
+
{$t('bridge.nft.step.import.scan_screen.title')}
+
+
+
- Don't see your NFTs?
Try adding them manually!
+
{/if}
-
-
{/if}
+
+
+ {:else if activeStep === NFTSteps.REVIEW}
+
+
+
{$t('common.destination')}
+
+
+
+
+
{$t('common.contract_address')}
+
+
+
+
+
{$t('bridge.nft.step.review.token_id')}
+
+
+ {#each selectedNFT as nft}
+ - {nft.tokenId}
+ {/each}
+
+
+
+
+
@@ -577,22 +664,39 @@
-
- {:else if activeStep === NFTSteps.REVIEW}
-
-
-
Contract: {contractAddress}
+
+
+
+ {$t('bridge.nft.step.review.your_tokens')}
+
+
+
+
+
+
+
+
+ {#if nftView === NFTView.LIST}
+
+ {:else if nftView === NFTView.CARDS}
+
{#each selectedNFT as nft}
-
Name: {nft.name}
-
Type: {nft.type}
-
ID: {nft.tokenId}
-
URI: {nft.uri}
-
Balance: {nft.balance}
+
{/each}
-
-
-
+ {/if}
{:else if activeStep === NFTSteps.CONFIRM}
@@ -607,15 +711,16 @@
{#if activeStep !== NFTSteps.IMPORT}
+ type="neutral"
+ class="px-[28px] py-[14px] rounded-full w-auto flex-1 bg-transparent !border border-primary-brand hover:border-primary-interactive-hover"
+ on:click={previousStep}>
+ {$t('common.edit')}
{/if}
+ on:click={nextStep}>{nextStepButtonText}
diff --git a/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte b/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte
index 99847b7d37f..d3ae0434ed9 100644
--- a/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte
+++ b/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte
@@ -49,6 +49,8 @@
'flex justify-start content-center',
);
+ let iconSize = small ? 'w-5 h-5' : 'w-7 h-7';
+
let switchingNetwork = false;
let buttonId = `button-${uid()}`;
let dialogId = `dialog-${uid()}`;
@@ -134,7 +136,7 @@
{#if value}
{@const icon = chainConfig[Number(value.id)]?.icon || 'Unknown Chain'}
-
+
{truncateString(value.name, 8)}
{/if}
diff --git a/packages/bridge-ui-v2/src/components/Icon/Icon.svelte b/packages/bridge-ui-v2/src/components/Icon/Icon.svelte
index b59acea71dd..adcce39a11c 100644
--- a/packages/bridge-ui-v2/src/components/Icon/Icon.svelte
+++ b/packages/bridge-ui-v2/src/components/Icon/Icon.svelte
@@ -27,7 +27,10 @@
| 'trash'
| 'adjustments'
| 'sun'
- | 'moon';
+ | 'moon'
+ | 'list'
+ | 'magnifier'
+ | 'cards';
+
+
+
+
+
+
diff --git a/packages/bridge-ui-v2/src/components/Icon/index.ts b/packages/bridge-ui-v2/src/components/Icon/index.ts
index 06a1af8f9b9..88bb9c70a1b 100644
--- a/packages/bridge-ui-v2/src/components/Icon/index.ts
+++ b/packages/bridge-ui-v2/src/components/Icon/index.ts
@@ -2,4 +2,5 @@ export { default as BllIcon } from './BLL.svelte';
export { default as EthIcon } from './ETH.svelte';
export { default as HorseIcon } from './HORSE.svelte';
export { default as Icon, type IconType } from './Icon.svelte';
+export { default as IconFlipper } from './IconFlipper.svelte';
export { default as TTKOIcon } from './TTKO.svelte';
diff --git a/packages/bridge-ui-v2/src/components/NFTCard/NFTCard.svelte b/packages/bridge-ui-v2/src/components/NFTCard/NFTCard.svelte
new file mode 100644
index 00000000000..3d063b94da2
--- /dev/null
+++ b/packages/bridge-ui-v2/src/components/NFTCard/NFTCard.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {#if nft.name}{$t('common.collection')}:
+ {nft.name}{/if}
+ {#if nft.symbol}
+ {nft.symbol}
+ {/if}
+
+ {#if nft.metadata?.name}
+ {$t('common.name')}:
+ {nft.metadata?.name}
+ {/if}
+
+
+ {$t('common.id')}: {nft.tokenId}
+
+ {$t('common.address')}: {truncateString(address, 13)}
+
+
+
+
diff --git a/packages/bridge-ui-v2/src/components/NFTCard/index.ts b/packages/bridge-ui-v2/src/components/NFTCard/index.ts
new file mode 100644
index 00000000000..3fe76b67a53
--- /dev/null
+++ b/packages/bridge-ui-v2/src/components/NFTCard/index.ts
@@ -0,0 +1 @@
+export { default as NFTCard } from './NFTCard.svelte';
diff --git a/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte b/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte
index 90a9d2bc2e7..1c1e7aa100f 100644
--- a/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte
+++ b/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte
@@ -1,13 +1,16 @@
{#if nfts.length > 0}
- {#if multiSelectEnabled}
+ {#if multiSelectEnabled && !viewOnly}
{/if}
-
+
diff --git a/packages/bridge-ui-v2/src/components/NFTList/NFTListItem.svelte b/packages/bridge-ui-v2/src/components/NFTList/NFTListItem.svelte
new file mode 100644
index 00000000000..c1c388e5b34
--- /dev/null
+++ b/packages/bridge-ui-v2/src/components/NFTList/NFTListItem.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/packages/bridge-ui-v2/src/i18n/en.json b/packages/bridge-ui-v2/src/i18n/en.json
index 407594b0f85..d6f4d64c5ce 100644
--- a/packages/bridge-ui-v2/src/i18n/en.json
+++ b/packages/bridge-ui-v2/src/i18n/en.json
@@ -26,6 +26,25 @@
"confirm": "Confirm your NFT details."
}
},
+ "nft": {
+ "step": {
+ "import": {
+ "title": "Import NFT",
+ "description": "Import your NFT from the source chain.",
+ "scan_screen": {
+ "title": "Scan for NFT",
+ "description": "Don't see your NFTs? Try adding them manually!"
+ }
+ },
+ "review": {
+ "title": "Import NFT",
+ "description": "Review your NFT details.",
+ "token_id": "Token ID",
+ "your_tokens": "Your tokens on"
+ },
+ "confirm": "Confirm"
+ }
+ },
"actions": {
"approve": {
"tx": "Transaction sent to approve {token} tokens. Click
here to see it in the explorer.",
@@ -35,7 +54,10 @@
"bridge": {
"tx": "Transaction sent to bridge {token} tokens. Click
here to see it in the explorer.",
"success": "Transaction completed!. Your funds are getting ready to be claimed on {network}"
- }
+ },
+ "nft_scan": "Scan for NFT",
+ "nft_scan_again": "Scan again",
+ "nft_manual": "Add manually"
},
"errors": {
"send_erc20_error": "Failed to send ERC20 token",
@@ -278,12 +300,18 @@
},
"common": {
"edit": "Edit",
+ "continue": "Continue",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"loading": "Loading",
"ok": "Ok",
"name": "Name",
- "balance": "Balance"
+ "balance": "Balance",
+ "destination": "Destination",
+ "contract_address": "Contract address",
+ "id": "ID",
+ "collection": "Collection",
+ "address": "Address"
}
}
diff --git a/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts b/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts
index 15d9573a9c4..4bf947f8387 100644
--- a/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts
+++ b/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts
@@ -12,7 +12,8 @@ function deduplicateNFTs(nftArrays: NFT[][]): NFT[] {
const nftMap: Map
= new Map();
nftArrays.flat().forEach((nft) => {
Object.entries(nft.addresses).forEach(([chainID, address]) => {
- const uniqueKey = `${address}-${chainID}`;
+ const uniqueKey = `${address}-${chainID}-${nft.tokenId}`;
+
if (!nftMap.has(uniqueKey)) {
nftMap.set(uniqueKey, nft);
}
@@ -33,16 +34,21 @@ export async function fetchNFTs(userAddress: Address, chainID: ChainID): Promise
const nftsPromises: Promise[] = result.map(async (nft) => {
const type: TokenType = TokenType[nft.contractType as keyof typeof TokenType];
- //TODO: tokenID should not be cast to number, but the ABI only allows for numbers, so it would fail either way if it wasn't a number
- return (await getTokenWithInfoFromAddress({
+ return getTokenWithInfoFromAddress({
contractAddress: nft.contractAddress,
srcChainId: Number(chainID),
owner: userAddress,
tokenId: Number(nft.tokenID),
type,
- })) as NFT;
+ }) as Promise;
});
- return await Promise.all(nftsPromises);
+
+ const nftsSettled = await Promise.allSettled(nftsPromises);
+ const nfts = nftsSettled
+ .filter((result) => result.status === 'fulfilled')
+ .map((result) => (result as PromiseFulfilledResult).value);
+
+ return nfts;
});
let nftArrays: NFT[][] = [];
diff --git a/packages/bridge-ui-v2/src/libs/token/fetch1155Images.ts b/packages/bridge-ui-v2/src/libs/token/fetch1155Images.ts
deleted file mode 100644
index aa7fa7e47bd..00000000000
--- a/packages/bridge-ui-v2/src/libs/token/fetch1155Images.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { readContract } from '@wagmi/core';
-
-export async function fetchERC1155Images(contractAddress: string, tokenIds: number[]) {
- const ERC1155_ABI = [
- {
- constant: true,
- inputs: [{ name: 'id', type: 'uint256' }],
- name: 'uri',
- outputs: [{ name: '', type: 'string' }],
- payable: false,
- stateMutability: 'view',
- type: 'function',
- },
- ];
-
- type ImageEntry = { [id: string]: string };
- type ImagesArray = ImageEntry[];
- const result: { images: ImagesArray; errors: number[] } = {
- images: [],
- errors: [],
- };
-
- for (const id of tokenIds) {
- try {
- const uri = await readContract({
- address: contractAddress as `0x${string}`,
- abi: ERC1155_ABI,
- functionName: 'uri',
- args: [id],
- });
- const url = uri as string;
-
- // Replace placeholder with actual id
- // const resolvedUrl = url.replace("{id}", id.toString());
-
- // Todo: temporary fix for pinata gateway
- const baseUrlToRemove = 'https://gateway.pinata.cloud';
-
- const metadata = await fetch(url.replace(baseUrlToRemove, '')).then(async (res) => await res.json());
- result.images.push({ [id]: metadata.image.replace(baseUrlToRemove, '') });
- } catch (error) {
- result.errors.push(id);
- console.error(error);
- }
- }
- return result;
-}
diff --git a/packages/bridge-ui-v2/src/libs/token/fetchErc721Images.ts b/packages/bridge-ui-v2/src/libs/token/fetchErc721Images.ts
deleted file mode 100644
index b7b321338f0..00000000000
--- a/packages/bridge-ui-v2/src/libs/token/fetchErc721Images.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { readContract } from '@wagmi/core';
-
-export async function fetchERC721Images(contractAddress: string, tokenIds: number[]) {
- const ERC721_ABI = [
- {
- constant: true,
- inputs: [{ name: 'tokenId', type: 'uint256' }],
- name: 'tokenURI',
- outputs: [{ name: '', type: 'string' }],
- payable: false,
- stateMutability: 'view',
- type: 'function',
- },
- ];
-
- type ImageEntry = { [id: string]: string };
- type ImagesArray = ImageEntry[];
- const result: { images: ImagesArray; errors: number[] } = {
- images: [],
- errors: [],
- };
-
- for (const id of tokenIds) {
- try {
- const uri = await readContract({
- address: contractAddress as `0x${string}`,
- abi: ERC721_ABI,
- functionName: 'tokenURI',
- args: [id],
- });
- const url = uri as string;
-
- // Todo: temporary fix for pinata gateway
- const baseUrlToRemove = 'https://gateway.pinata.cloud';
-
- const metadata = await fetch(url.replace(baseUrlToRemove, '')).then(async (res) => await res.json());
- result.images.push({ [id]: metadata.image.replace(baseUrlToRemove, '') });
- } catch (error) {
- result.errors.push(id);
- console.error(error);
- }
- }
- return result;
-}
diff --git a/packages/bridge-ui-v2/src/libs/token/fetchNFTImageUrl.ts b/packages/bridge-ui-v2/src/libs/token/fetchNFTImageUrl.ts
new file mode 100644
index 00000000000..7ffc5ce0dd0
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/token/fetchNFTImageUrl.ts
@@ -0,0 +1,16 @@
+import { parseNFTMetadata } from '$libs/util/parseNFTMetadata';
+import { safeParseUrl } from '$libs/util/safeParseUrl';
+
+import type { NFT } from './types';
+
+const PLACEHOLDER_IMAGE_URL = '/chains/taiko.svg';
+
+export const fetchNFTImageUrl = async (token: NFT) => {
+ try {
+ const metadata = await parseNFTMetadata(token);
+ if (!metadata?.image || metadata?.image === '') return PLACEHOLDER_IMAGE_URL;
+ return safeParseUrl(metadata?.image);
+ } catch (error) {
+ return PLACEHOLDER_IMAGE_URL;
+ }
+};
diff --git a/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts b/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts
index 0c7fc1fb003..1a40642d31f 100644
--- a/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts
+++ b/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts
@@ -3,6 +3,7 @@ import type { Address } from 'viem';
import { erc1155ABI } from '$abi';
import { getLogger } from '$libs/util/logger';
+import { parseNFTMetadata } from '$libs/util/parseNFTMetadata';
import { safeReadContract } from '$libs/util/safeReadContract';
import { detectContractType } from './detectContractType';
@@ -28,61 +29,9 @@ export const getTokenWithInfoFromAddress = async ({
try {
const tokenType: TokenType = type ?? (await detectContractType(contractAddress));
if (tokenType === TokenType.ERC20) {
- const fetchResult = await fetchToken({
- address: contractAddress,
- chainId: srcChainId,
- });
-
- const token = {
- type: tokenType,
- name: fetchResult.name,
- symbol: fetchResult.symbol,
- addresses: {
- [srcChainId]: contractAddress,
- },
- decimals: fetchResult.decimals,
- } as Token;
-
- return token;
+ return getERC20Info(contractAddress, srcChainId, tokenType);
} else if (tokenType === TokenType.ERC1155) {
- const name = await safeReadContract({
- address: contractAddress,
- abi: erc1155ABI,
- functionName: 'name',
- chainId: srcChainId,
- });
-
- const uri = await safeReadContract({
- address: contractAddress,
- abi: erc1155ABI,
- functionName: 'uri',
- chainId: srcChainId,
- });
-
- let balance;
- if (tokenId && owner) {
- balance = await readContract({
- address: contractAddress,
- abi: erc1155ABI,
- functionName: 'balanceOf',
- args: [owner, BigInt(tokenId)],
- chainId: srcChainId,
- });
- }
-
- const token = {
- type: tokenType,
- name: name ? name : 'No name specified',
- uri: uri ? uri.toString() : undefined,
- addresses: {
- [srcChainId]: contractAddress,
- },
- tokenId,
- balance: balance ? balance : 0,
- } as NFT;
- // todo: fetch more details via URI?
-
- return token;
+ return getERC1155Info(contractAddress, srcChainId, owner, tokenId, tokenType);
} else if (tokenType === TokenType.ERC721) {
const name = await readContract({
address: contractAddress,
@@ -130,3 +79,85 @@ export const getTokenWithInfoFromAddress = async ({
throw new Error('Error getting token info');
}
};
+
+const getERC20Info = async (contractAddress: Address, srcChainId: number, type: TokenType) => {
+ const fetchResult = await fetchToken({
+ address: contractAddress,
+ chainId: srcChainId,
+ });
+
+ const token = {
+ type,
+ name: fetchResult.name,
+ symbol: fetchResult.symbol,
+ addresses: {
+ [srcChainId]: contractAddress,
+ },
+ decimals: fetchResult.decimals,
+ } as Token;
+ return token;
+};
+
+const getERC1155Info = async (
+ contractAddress: Address,
+ srcChainId: number,
+ owner: Address | undefined,
+ tokenId: number | undefined,
+ type: TokenType,
+) => {
+ let name = await safeReadContract({
+ address: contractAddress,
+ abi: erc1155ABI,
+ functionName: 'name',
+ chainId: srcChainId,
+ });
+
+ let uri = await safeReadContract({
+ address: contractAddress,
+ abi: erc1155ABI,
+ functionName: 'uri',
+ chainId: srcChainId,
+ });
+ if (tokenId && !uri)
+ uri = await safeReadContract({
+ address: contractAddress,
+ abi: erc1155ABI,
+ functionName: 'uri',
+ args: [BigInt(tokenId)],
+ chainId: srcChainId,
+ });
+
+ let balance;
+ if (tokenId && owner) {
+ balance = await readContract({
+ address: contractAddress,
+ abi: erc1155ABI,
+ functionName: 'balanceOf',
+ args: [owner, BigInt(tokenId)],
+ chainId: srcChainId,
+ });
+ }
+
+ let token: NFT;
+ try {
+ token = {
+ type,
+ name: name ? name : 'No collection name',
+ uri: uri ? uri.toString() : undefined,
+ addresses: {
+ [srcChainId]: contractAddress,
+ },
+ tokenId,
+ balance: balance ? balance : 0,
+ } as NFT;
+ const metadata = await parseNFTMetadata(token);
+ if (metadata?.name !== '') name = metadata?.name;
+ // todo: more metadata?
+ log('logging', token.name, metadata);
+ token.metadata = metadata || undefined;
+ return token;
+ } catch (error) {
+ log(`error fetching metadata for ${contractAddress} id: ${tokenId}`, error);
+ }
+ throw new Error('Error getting token info');
+};
diff --git a/packages/bridge-ui-v2/src/libs/token/index.ts b/packages/bridge-ui-v2/src/libs/token/index.ts
index 8d9541eb01d..41b684704f9 100644
--- a/packages/bridge-ui-v2/src/libs/token/index.ts
+++ b/packages/bridge-ui-v2/src/libs/token/index.ts
@@ -1,7 +1,5 @@
export { checkMintable } from './checkMintable';
export { detectContractType } from './detectContractType';
-export { fetchERC1155Images } from './fetch1155Images';
-export { fetchERC721Images } from './fetchErc721Images';
export { getAddress } from './getAddress';
export { getBalance } from './getBalance';
export { isDeployedCrossChain } from './isDeployedCrossChain';
diff --git a/packages/bridge-ui-v2/src/libs/token/types.ts b/packages/bridge-ui-v2/src/libs/token/types.ts
index b5c21cf9062..29d251b183b 100644
--- a/packages/bridge-ui-v2/src/libs/token/types.ts
+++ b/packages/bridge-ui-v2/src/libs/token/types.ts
@@ -23,11 +23,21 @@ export type Token = {
imported?: boolean;
mintable?: boolean;
balance?: bigint;
- uri?: string;
};
export type NFT = Token & {
tokenId: number;
+ uri?: string;
+ metadata?: NFTMetadata;
+};
+
+// Based on https://docs.opensea.io/docs/metadata-standards
+export type NFTMetadata = {
+ description: string;
+ external_url: string;
+ image: string;
+ name: string;
+ //todo: more metadata?
};
export type GetCrossChainAddressArgs = {
diff --git a/packages/bridge-ui-v2/src/libs/util/extractIPFSCidFromUrl.ts b/packages/bridge-ui-v2/src/libs/util/extractIPFSCidFromUrl.ts
new file mode 100644
index 00000000000..5db28a4a9d6
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/util/extractIPFSCidFromUrl.ts
@@ -0,0 +1,8 @@
+export const extractIPFSCidFromUrl = (url: string): { cid: string | null; remainder: string | null } => {
+ // Regular expression to match a typical IPFS CID v0 or v1
+ // CID v0: QmP6oEEnsDr55gKqr1BQzjJwnsoscxFSksrsQ1YiMvG1Y91
+ // CID v1: bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi
+ const regex = /\/(Qm[a-zA-Z0-9]{44}|b[a-z]{8}[a-zA-Z0-9]{39})([^/]*)/;
+ const match = url.match(regex);
+ return match ? { cid: match[1], remainder: match[2] } : { cid: null, remainder: null };
+};
diff --git a/packages/bridge-ui-v2/src/libs/util/groupNFTByCollection.ts b/packages/bridge-ui-v2/src/libs/util/groupNFTByCollection.ts
new file mode 100644
index 00000000000..bf873aeab6c
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/util/groupNFTByCollection.ts
@@ -0,0 +1,13 @@
+import type { NFT } from '../token/types';
+
+export function groupNFTByCollection(nfts: NFT[]): Record {
+ const grouped: Record = {};
+ nfts.forEach((nft) => {
+ const addressKey = Object.values(nft.addresses).join('-');
+ if (!grouped[addressKey]) {
+ grouped[addressKey] = [];
+ }
+ grouped[addressKey].push(nft);
+ });
+ return grouped;
+}
diff --git a/packages/bridge-ui-v2/src/libs/util/parseNFTMetadata.ts b/packages/bridge-ui-v2/src/libs/util/parseNFTMetadata.ts
new file mode 100644
index 00000000000..61314c9b9b7
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/util/parseNFTMetadata.ts
@@ -0,0 +1,62 @@
+import axios, { AxiosError } from 'axios';
+
+import { type NFT, type NFTMetadata, TokenType } from '$libs/token';
+import { safeParseUrl } from '$libs/util/safeParseUrl';
+
+import { extractIPFSCidFromUrl } from './extractIPFSCidFromUrl';
+import { getLogger } from './logger';
+
+const log = getLogger('libs:token:parseNFTMetadata');
+
+export const parseNFTMetadata = async (token: NFT): Promise => {
+ if (token.type !== TokenType.ERC721 && token.type !== TokenType.ERC1155) throw new Error('Not a NFT');
+
+ if (!token.uri) throw new Error('No token URI found');
+ if (token.uri.includes('{id}')) {
+ token.uri = token.uri.replace('{id}', token.tokenId.toString());
+ }
+ const url = safeParseUrl(token.uri);
+ if (!url) throw new Error('No metadata found');
+
+ let json;
+ try {
+ json = await axios.get(url);
+ } catch (err) {
+ const error = err as AxiosError;
+ log(`error fetching metadata for ${token.name} id: ${token.tokenId}`, error);
+ //todo: handle different error scenarios?
+ json = await retry(url, token.tokenId);
+ }
+ if (!json || !json.data) throw new Error('No metadata found');
+
+ const metadata = {
+ description: json.data.description || '',
+ external_url: json.data.external_url || '',
+ image: json.data.image || '',
+ name: json.data.name || '',
+ };
+
+ log(`fetched metadata for ${token.name} id: ${token.tokenId}`, metadata);
+ return metadata;
+};
+
+const retry = async (url: string, tokenId: number) => {
+ const { cid, remainder } = extractIPFSCidFromUrl(url);
+ let gateway;
+ if (cid && !remainder) {
+ gateway = `https://ipfs.io/ipfs/${cid}`;
+ } else if (cid && remainder === tokenId.toString()) {
+ gateway = `https://ipfs.io/ipfs/${cid}/${remainder}.json`;
+ } else {
+ log(`no valid CID found in ${url}`);
+ return null;
+ }
+
+ try {
+ log(`retrying with ${gateway}`);
+ return await axios.get(gateway);
+ } catch (error) {
+ log('retrying failed', error);
+ return null;
+ }
+};
diff --git a/packages/bridge-ui-v2/src/libs/util/safeParseUrl.ts b/packages/bridge-ui-v2/src/libs/util/safeParseUrl.ts
new file mode 100644
index 00000000000..e96699db8a0
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/util/safeParseUrl.ts
@@ -0,0 +1,7 @@
+export const safeParseUrl = (uri: string) => {
+ if (uri && uri.startsWith('ipfs://')) {
+ // todo: multiple configurable ipfs gateways as fallback
+ return `https://ipfs.io/ipfs/${uri.slice(7)}`;
+ }
+ return uri;
+};