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'} - chain-logo + chain-logo {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 @@ + + +
+ {nft.name} +
+ {#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} -
+
{#if !chainId} Select a chain {:else} - {#each nfts as nft (nft.addresses[chainId])} - {@const address = nft.addresses[chainId]} - {@const tokenImage = fetchNFTImage(nft)} + {#each Object.entries(groupNFTByCollection(nfts)) as [address, nftsGroup] (address)} +
+ {#if nftsGroup.length > 0} +
+ + {nftsGroup[0].name} + + {nftsGroup[0].type} +
+
+ {#each nftsGroup as nft} + {@const collectionAddress = nft.addresses[chainId]} + - {#if address === undefined} -
Address for {nft.name} is undefined
- {:else} -
-
+ {/if} +
+ + {/each} {/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; +};