diff --git a/packages/bridge-ui-v2/.env.example b/packages/bridge-ui-v2/.env.example
index 5e46f175912..76cd1143202 100644
--- a/packages/bridge-ui-v2/.env.example
+++ b/packages/bridge-ui-v2/.env.example
@@ -9,6 +9,7 @@ export PUBLIC_WALLETCONNECT_PROJECT_ID=""
# Enable NFT Bridge ("true" or "false")
export PUBLIC_NFT_BRIDGE_ENABLED=""
+PUBLIC_NFT_BATCH_TRANSFERS_ENABLED=""
# Sentry
export PUBLIC_SENTRY_DSN=https://
@@ -20,4 +21,5 @@ export SENTRY_AUTH_TOKEN=
export CONFIGURED_BRIDGES=
export CONFIGURED_CHAINS=
export CONFIGURED_CUSTOM_TOKEN=
-export CONFIGURED_RELAYER=
\ No newline at end of file
+export CONFIGURED_RELAYER=
+export CONFIGURED_EVENT_INDEXER=
\ No newline at end of file
diff --git a/packages/bridge-ui-v2/config/sample/configuredEventIndexer.example b/packages/bridge-ui-v2/config/sample/configuredEventIndexer.example
new file mode 100644
index 00000000000..50a8b05863a
--- /dev/null
+++ b/packages/bridge-ui-v2/config/sample/configuredEventIndexer.example
@@ -0,0 +1,12 @@
+{
+ "configuredEventIndexer": [
+ {
+ "chainIds": [123456, 654321],
+ "url": "https://some/url.example"
+ },
+ {
+ "chainIds": [1, 11155111],
+ "url": "https://some/other/url.example"
+ }
+ ]
+}
diff --git a/packages/bridge-ui-v2/config/schemas/configuredEventIndexer.schema.json b/packages/bridge-ui-v2/config/schemas/configuredEventIndexer.schema.json
new file mode 100644
index 00000000000..726c22b4c07
--- /dev/null
+++ b/packages/bridge-ui-v2/config/schemas/configuredEventIndexer.schema.json
@@ -0,0 +1,25 @@
+{
+ "$id": "configuredEventIndexer.json",
+ "type": "object",
+ "properties": {
+ "configuredEventIndexer": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "chainIds": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "required": ["chainIds", "url"]
+ }
+ }
+ },
+ "required": ["configuredEventIndexer"]
+}
diff --git a/packages/bridge-ui-v2/scripts/exportJsonToEnv.js b/packages/bridge-ui-v2/scripts/exportJsonToEnv.js
index 0f541563dde..28cbb8fb8e2 100755
--- a/packages/bridge-ui-v2/scripts/exportJsonToEnv.js
+++ b/packages/bridge-ui-v2/scripts/exportJsonToEnv.js
@@ -13,11 +13,12 @@ const bridgesPath = 'config/configuredBridges.json';
const chainsPath = 'config/configuredChains.json';
const tokensPath = 'config/configuredCustomToken.json';
const relayerPath = 'config/configuredRelayer.json';
+const eventIndexerPath = 'config/configuredEventIndexer.json';
// Create a backup of the existing .env file
fs.copyFileSync(envFile, `${envFile}.bak`);
-const jsonFiles = [bridgesPath, chainsPath, tokensPath, relayerPath];
+const jsonFiles = [bridgesPath, chainsPath, tokensPath, relayerPath, eventIndexerPath];
jsonFiles.forEach((jsonFile) => {
if (fs.existsSync(jsonFile)) {
diff --git a/packages/bridge-ui-v2/scripts/vite-plugins/generateEventIndexerConfig.ts b/packages/bridge-ui-v2/scripts/vite-plugins/generateEventIndexerConfig.ts
new file mode 100644
index 00000000000..7815405f5ca
--- /dev/null
+++ b/packages/bridge-ui-v2/scripts/vite-plugins/generateEventIndexerConfig.ts
@@ -0,0 +1,141 @@
+/* eslint-disable no-console */
+import dotenv from 'dotenv';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { Project, SourceFile, VariableDeclarationKind } from 'ts-morph';
+
+import configuredEventIndexerSchema from '../../config/schemas/configuredEventIndexer.schema.json';
+import type { ConfiguredEventIndexer, EventIndexerConfig } from '../../src/libs/eventIndexer/types';
+import { decodeBase64ToJson } from './../utils/decodeBase64ToJson';
+import { formatSourceFile } from './../utils/formatSourceFile';
+import { PluginLogger } from './../utils/PluginLogger';
+import { validateJsonAgainstSchema } from './../utils/validateJson';
+
+dotenv.config();
+
+const pluginName = 'generateEventIndexerConfig';
+const logger = new PluginLogger(pluginName);
+
+const skip = process.env.SKIP_ENV_VALDIATION || false;
+
+const currentDir = path.resolve(new URL(import.meta.url).pathname);
+
+const outputPath = path.join(path.dirname(currentDir), '../../src/generated/eventIndexerConfig.ts');
+
+export function generateEventIndexerConfig() {
+ return {
+ name: pluginName,
+ async buildStart() {
+ logger.info('Plugin initialized.');
+ let configuredEventIndexerConfigFile;
+
+ if (!skip) {
+ if (!process.env.CONFIGURED_EVENT_INDEXER) {
+ throw new Error(
+ 'CONFIGURED_EVENT_INDEXER is not defined in environment. Make sure to run the export step in the documentation.',
+ );
+ }
+
+ // Decode base64 encoded JSON string
+ configuredEventIndexerConfigFile = decodeBase64ToJson(process.env.CONFIGURED_EVENT_INDEXER || '');
+
+ // Valide JSON against schema
+ const isValid = validateJsonAgainstSchema(configuredEventIndexerConfigFile, configuredEventIndexerSchema);
+ if (!isValid) {
+ throw new Error('encoded configuredBridges.json is not valid.');
+ }
+ } else {
+ configuredEventIndexerConfigFile = '';
+ }
+ // Path to where you want to save the generated Typ eScript file
+ const tsFilePath = path.resolve(outputPath);
+
+ const project = new Project();
+ const notification = `// Generated by ${pluginName} on ${new Date().toLocaleString()}`;
+ const warning = `// WARNING: Do not change this file manually as it will be overwritten`;
+
+ let sourceFile = project.createSourceFile(tsFilePath, `${notification}\n${warning}\n`, { overwrite: true });
+
+ // Create the TypeScript content
+ sourceFile = await storeTypesAndEnums(sourceFile);
+ sourceFile = await buildEventIndexerConfig(sourceFile, configuredEventIndexerConfigFile);
+
+ await sourceFile.save();
+
+ const formatted = await formatSourceFile(tsFilePath);
+ console.log('formatted', tsFilePath);
+
+ // Write the formatted code back to the file
+ await fs.writeFile(tsFilePath, formatted);
+ logger.info(`Formatted config file saved to ${tsFilePath}`);
+ },
+ };
+}
+
+async function storeTypesAndEnums(sourceFile: SourceFile) {
+ logger.info(`Storing types...`);
+ // RelayerConfig
+ sourceFile.addImportDeclaration({
+ namedImports: ['EventIndexerConfig'],
+ moduleSpecifier: '$libs/eventIndexer',
+ isTypeOnly: true,
+ });
+
+ logger.info('Types stored.');
+ return sourceFile;
+}
+
+async function buildEventIndexerConfig(
+ sourceFile: SourceFile,
+ configuredEventIndexerConfigFile: ConfiguredEventIndexer,
+) {
+ logger.info('Building event indexer config...');
+
+ const indexer: ConfiguredEventIndexer = configuredEventIndexerConfigFile;
+
+ if (!skip) {
+ if (!indexer.configuredEventIndexer || !Array.isArray(indexer.configuredEventIndexer)) {
+ console.error(
+ 'configuredEventIndexer is not an array. Please check the content of the configuredEventIndexerConfigFile.',
+ );
+ throw new Error();
+ }
+ // Create a constant variable for the configuration
+ const eventIndexerConfigVariable = {
+ declarationKind: VariableDeclarationKind.Const,
+ declarations: [
+ {
+ name: 'configuredEventIndexer',
+ initializer: _formatObjectToTsLiteral(indexer.configuredEventIndexer),
+ type: 'EventIndexerConfig[]',
+ },
+ ],
+ isExported: true,
+ };
+ sourceFile.addVariableStatement(eventIndexerConfigVariable);
+ } else {
+ const emptyEventIndexerConfigVariable = {
+ declarationKind: VariableDeclarationKind.Const,
+ declarations: [
+ {
+ name: 'configuredEventIndexer',
+ initializer: '[]',
+ type: 'EventIndexerConfig[]',
+ },
+ ],
+ isExported: true,
+ };
+ sourceFile.addVariableStatement(emptyEventIndexerConfigVariable);
+ }
+
+ logger.info('EventIndexer config built.');
+ return sourceFile;
+}
+
+const _formatEventIndexerConfigToTsLiteral = (config: EventIndexerConfig): string => {
+ return `{chainIds: [${config.chainIds ? config.chainIds.join(', ') : ''}], url: "${config.url}"}`;
+};
+
+const _formatObjectToTsLiteral = (indexer: EventIndexerConfig[]): string => {
+ return `[${indexer.map(_formatEventIndexerConfigToTsLiteral).join(', ')}]`;
+};
diff --git a/packages/bridge-ui-v2/src/components/Bridge/AddressInput/AddressInput.svelte b/packages/bridge-ui-v2/src/components/Bridge/AddressInput/AddressInput.svelte
index 88d058e2615..048e0b95cfd 100644
--- a/packages/bridge-ui-v2/src/components/Bridge/AddressInput/AddressInput.svelte
+++ b/packages/bridge-ui-v2/src/components/Bridge/AddressInput/AddressInput.svelte
@@ -21,6 +21,7 @@
};
export const clearAddress = () => {
+ state = State.Default;
if (input) input.value = '';
validateEthereumAddress('');
};
diff --git a/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte b/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte
index e27a64774a7..f46bab429c6 100644
--- a/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte
+++ b/packages/bridge-ui-v2/src/components/Bridge/Bridge.svelte
@@ -9,6 +9,7 @@
import { Button } from '$components/Button';
import { Card } from '$components/Card';
import { ChainSelectorWrapper } from '$components/ChainSelector';
+ import { NFTList } from '$components/NFTList';
import { successToast, warningToast } from '$components/NotificationToast';
import { errorToast, infoToast } from '$components/NotificationToast/NotificationToast.svelte';
import { OnAccount } from '$components/OnAccount';
@@ -25,6 +26,7 @@
} from '$libs/bridge';
import { hasBridge } from '$libs/bridge/bridges';
import type { ERC20Bridge } from '$libs/bridge/ERC20Bridge';
+ import { fetchNFTs } from '$libs/bridge/fetchNFTs';
import {
ApproveError,
InsufficientAllowanceError,
@@ -33,9 +35,9 @@
SendMessageError,
} from '$libs/error';
import { bridgeTxService } from '$libs/storage';
- import { ETHToken, getAddress, isDeployedCrossChain, type Token, tokens, TokenType } from '$libs/token';
+ import { ETHToken, getAddress, isDeployedCrossChain, type NFT, tokens, TokenType } from '$libs/token';
import { checkOwnership } from '$libs/token/checkOwnership';
- import { getTokenInfoFromAddress } from '$libs/token/getTokenInfo';
+ import { getTokenWithInfoFromAddress } from '$libs/token/getTokenWithInfoFromAddress';
import { refreshUserBalance } from '$libs/util/balance';
import { getConnectedWallet } from '$libs/util/getConnectedWallet';
import { type Account, account } from '$stores/account';
@@ -65,10 +67,7 @@
let processingFeeComponent: ProcessingFee;
function onNetworkChange(newNetwork: Network, oldNetwork: Network) {
- tick().then(() => {
- // run validations again
- runValidations();
- });
+ updateForm();
if (newNetwork) {
const destChainId = $destinationChain?.id;
@@ -94,6 +93,7 @@
};
function onAccountChange(account: Account) {
+ updateForm();
if (account && account.isConnected && !$selectedToken) {
$selectedToken = ETHToken;
} else if (account && account.isDisconnected) {
@@ -102,6 +102,17 @@
}
}
+ function updateForm() {
+ tick().then(() => {
+ if (manualNFTInput) {
+ // run validations again if we are in manual mode
+ runValidations();
+ } else {
+ resetForm();
+ }
+ });
+ }
+
async function approve() {
if (!$selectedToken || !$network || !$destinationChain) return;
@@ -333,11 +344,19 @@
// Update balance after bridging
if (amountComponent) amountComponent.updateBalance();
if (nftIdInputComponent) nftIdInputComponent.clearIds();
+
$selectedToken = ETHToken;
contractAddress = '';
+ manualNFTInput = false;
+ scanned = false;
+ isOwnerOfAllToken = false;
+ foundNFTs = [];
+ selectedNFT = [];
};
- // NFT Bridge logic
+ /**
+ * NFT Bridge
+ */
let activeStep: NFTSteps = NFTSteps.IMPORT;
const nextStep = () => (activeStep = Math.min(activeStep + 1, NFTSteps.CONFIRM));
@@ -355,29 +374,27 @@
let validating: boolean = false;
let detectedTokenType: TokenType | null = null;
+ let manualNFTInput: boolean = false;
+ let scanning: boolean = false;
+ let scanned: boolean = false;
+
+ let foundNFTs: NFT[] = [];
+ let selectedNFT: NFT[] = [];
+
function onAddressValidation(event: CustomEvent<{ isValidEthereumAddress: boolean; addr: Address }>) {
const { isValidEthereumAddress, addr } = event.detail;
addressInputState = AddressInputState.Validating;
if (isValidEthereumAddress && typeof addr === 'string') {
- getTokenInfoFromAddress(addr)
- .then((details) => {
- if (!details) throw new Error('token details not found');
- if (!$network?.id) throw new Error('network not found');
+ if (!$network?.id) throw new Error('network not found');
+ const srcChainId = $network?.id;
+ getTokenWithInfoFromAddress({ contractAddress: addr, srcChainId: srcChainId, owner: $account?.address })
+ .then((token) => {
+ if (!token) throw new Error('no token with info');
- detectedTokenType = details.type;
addressInputState = AddressInputState.Valid;
- $selectedToken = {
- type: details.type,
- symbol: details.symbol,
- decimals: details.decimals,
- name: details.name,
- logoURI: '',
- addresses: {
- [$network.id]: addr,
- },
- } as Token;
+ $selectedToken = token;
})
.catch((err) => {
console.error(err);
@@ -389,10 +406,26 @@
addressInputState = AddressInputState.Invalid;
}
}
+ const scanForNFTs = async () => {
+ scanning = true;
+ const accountAddress = $account?.address;
+ const srcChainId = $network?.id;
+ if (!accountAddress || !srcChainId) return;
+ const nftsFromAPIs = await fetchNFTs(accountAddress, BigInt(srcChainId));
+ foundNFTs = nftsFromAPIs.nfts;
+ scanning = false;
+ scanned = true;
+ };
// Whenever the user switches bridge types, we should reset the forms
$: $activeBridge && resetForm();
+ $: {
+ const stepKey = NFTSteps[activeStep].toLowerCase();
+ nftStepTitle = $t(`bridge.title.nft.${stepKey}`);
+ nftStepDescription = $t(`bridge.description.nft.${stepKey}`);
+ }
+
$: {
(async () => {
if (addressInputState !== AddressInputState.Valid) return;
@@ -411,18 +444,24 @@
})();
}
- $: canProceed =
- addressInputState === AddressInputState.Valid &&
- nftIdArray.length > 0 &&
- contractAddress &&
- $destinationChain &&
- isOwnerOfAllToken;
+ $: canProceed = manualNFTInput
+ ? addressInputState === AddressInputState.Valid &&
+ nftIdArray.length > 0 &&
+ contractAddress &&
+ $destinationChain &&
+ isOwnerOfAllToken
+ : selectedNFT.length > 0 && $destinationChain && scanned;
+
+ $: canScan = $account?.isConnected && $network?.id && $destinationChain && !scanning;
onDestroy(() => {
resetForm();
});
+
{#if $activeBridge === BridgeTypes.FUNGIBLE}
+
+
{:else if $activeBridge === BridgeTypes.NFT}
@@ -457,58 +500,110 @@
+
{#if activeStep === NFTSteps.IMPORT}
-
-
-
- {#if detectedTokenType === TokenType.ERC721 && contractAddress}
-
- {:else if detectedTokenType === TokenType.ERC1155 && contractAddress}
-
- {/if}
-
-
+
+
+ {#if manualNFTInput}
+
- {#if !isOwnerOfAllToken && nftIdArray?.length > 0 && !validating}
-
+ {#if detectedTokenType === TokenType.ERC721 && contractAddress}
+
+ {:else if detectedTokenType === TokenType.ERC1155 && contractAddress}
+
{/if}
+
+
+
+ {#if !isOwnerOfAllToken && nftIdArray?.length > 0 && !validating}
+
+ {/if}
+
+
+ {#if detectedTokenType === TokenType.ERC1155}
+
+ {/if}
+
+ {:else}
+
+
+
- {#if detectedTokenType === TokenType.ERC1155}
-
- {/if}
+
+ {#if scanned}
+
Your NFTs:
+
+ Don't see your NFTs?
Try adding them manually!
+
+
+ {/if}
+
+
+
+ {/if}
+
+
+
{:else if activeStep === NFTSteps.REVIEW}
Contract: {contractAddress}
-
IDs: {nftIdArray.join(', ')}
+ {#each selectedNFT as nft}
+
Name: {nft.name}
+
Type: {nft.type}
+
ID: {nft.tokenId}
+
URI: {nft.uri}
+
Balance: {nft.balance}
+ {/each}
+
+
+
{:else if activeStep === NFTSteps.CONFIRM}
{/if}
-
+
+
{#if activeStep !== NFTSteps.IMPORT}
-
diff --git a/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte b/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte
new file mode 100644
index 00000000000..90a9d2bc2e7
--- /dev/null
+++ b/packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte
@@ -0,0 +1,116 @@
+
+
+{#if nfts.length > 0}
+
+ {#if multiSelectEnabled}
+
+
+
+ {/if}
+
+
+{/if}
diff --git a/packages/bridge-ui-v2/src/components/NFTList/index.ts b/packages/bridge-ui-v2/src/components/NFTList/index.ts
new file mode 100644
index 00000000000..90148dfb10e
--- /dev/null
+++ b/packages/bridge-ui-v2/src/components/NFTList/index.ts
@@ -0,0 +1 @@
+export { default as NFTList } from './NFTList.svelte';
diff --git a/packages/bridge-ui-v2/src/components/TokenDropdown/AddCustomERC20.svelte b/packages/bridge-ui-v2/src/components/TokenDropdown/AddCustomERC20.svelte
index 1b5690e00d6..86d1ef97433 100644
--- a/packages/bridge-ui-v2/src/components/TokenDropdown/AddCustomERC20.svelte
+++ b/packages/bridge-ui-v2/src/components/TokenDropdown/AddCustomERC20.svelte
@@ -13,9 +13,9 @@
import Erc20 from '$components/Icon/ERC20.svelte';
import { Spinner } from '$components/Spinner';
import { tokenService } from '$libs/storage/services';
- import { type GetCrossChainAddressArgs, type Token, type TokenDetails, TokenType } from '$libs/token';
+ import type { GetCrossChainAddressArgs, Token } from '$libs/token';
import { getCrossChainAddress } from '$libs/token/getCrossChainAddress';
- import { getTokenInfoFromAddress } from '$libs/token/getTokenInfo';
+ import { getTokenWithInfoFromAddress } from '$libs/token/getTokenWithInfoFromAddress';
import { getLogger } from '$libs/util/logger';
import { uid } from '$libs/util/uid';
import { account } from '$stores/account';
@@ -28,14 +28,14 @@
const dialogId = `dialog-${uid()}`;
export let modalOpen = false;
- export let loading = false;
export let loadingTokenDetails = false;
- let tokenDetails: TokenDetails | null;
+ let addressInputComponent: AddressInput;
let tokenError = '';
let tokenAddress: Address | string = '';
let customTokens: Token[] = [];
let customToken: Token | null = null;
+ let customTokenWithDetails: Token | null = null;
let disabled = true;
let isValidEthereumAddress = false;
@@ -59,12 +59,12 @@
// only update the token if we actually have a bridged address
if (bridgedAddress && bridgedAddress !== customToken.addresses[destChain.id]) {
customToken.addresses[destChain.id] = bridgedAddress as Address;
-
tokenService.updateToken(customToken, $account?.address as Address);
}
}
tokenAddress = '';
- tokenDetails = null;
+ customTokenWithDetails = null;
+ resetForm();
};
const closeModal = () => {
@@ -74,9 +74,10 @@
const resetForm = () => {
customToken = null;
- tokenDetails = null;
+ customTokenWithDetails = null;
tokenError = '';
isValidEthereumAddress = false;
+ if (addressInputComponent) addressInputComponent.clearAddress();
};
const remove = async (token: Token) => {
@@ -99,45 +100,41 @@
};
const onAddressChange = async (tokenAddress: string) => {
+ if (!tokenAddress) return;
+ loadingTokenDetails = true;
log('Fetching token details for address "%s"…', tokenAddress);
tokenError = 'unchecked';
+ const { chain: srcChain } = getNetwork();
+ if (!srcChain) return;
try {
- const tokenInfo = await getTokenInfoFromAddress(tokenAddress as Address);
- if (!tokenInfo) return;
+ const token = await getTokenWithInfoFromAddress({
+ contractAddress: tokenAddress as Address,
+ srcChainId: srcChain.id,
+ });
+ if (!token) return;
const balance = await readContract({
address: tokenAddress as Address,
abi: erc20ABI,
functionName: 'balanceOf',
args: [$account?.address as Address],
});
-
- log({ balance });
-
- tokenDetails = { ...tokenInfo, balance };
+ customTokenWithDetails = { ...token, balance };
const { chain } = getNetwork();
-
if (!chain) throw new Error('Chain not found');
-
- if ($account.address && chain) {
- customToken = {
- name: tokenDetails.name,
- addresses: {
- [chain?.id]: tokenDetails.address,
- },
- decimals: tokenDetails.decimals,
- symbol: tokenDetails.symbol,
- logoURI: '',
- type: TokenType.ERC20,
- imported: true,
- } as Token;
- tokenError = '';
- }
+ customToken = customTokenWithDetails;
+ tokenError = '';
} catch (error) {
tokenError = 'Error fetching token details';
log('Failed to fetch token: ', error);
}
+ loadingTokenDetails = false;
};
+ $: formattedBalance =
+ customTokenWithDetails?.balance && customTokenWithDetails?.decimals
+ ? formatUnits(customTokenWithDetails.balance, customTokenWithDetails.decimals)
+ : 0;
+
$: if (isValidEthereumAddress) {
onAddressChange(tokenAddress);
} else {
@@ -170,35 +167,34 @@
{$t('token_dropdown.custom_token.description')}
-
- {#if tokenDetails}
-
- {$t('common.name')}: {tokenDetails.symbol}
- {$t('common.balance')}: {formatUnits(tokenDetails.balance, tokenDetails.decimals)}
-
- {:else if tokenError !== '' && tokenAddress !== '' && isValidEthereumAddress}
-
- {:else if loadingTokenDetails}
-
- {:else}
-
- {/if}
+
+
+ {#if customTokenWithDetails}
+
{$t('common.name')}: {customTokenWithDetails.symbol}
+
{$t('common.balance')}: {formattedBalance}
+ {:else if tokenError !== '' && tokenAddress !== '' && isValidEthereumAddress && !loadingTokenDetails}
+
+ {:else if loadingTokenDetails}
+
+ {:else}
+
+ {/if}
+
- {#if loading}
-
- {:else}
-
- {/if}
+
{#if customTokens.length > 0}
@@ -217,8 +213,8 @@
{/each}
{/if}
-
-
-
+
+
+
diff --git a/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts b/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts
new file mode 100644
index 00000000000..15d9573a9c4
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts
@@ -0,0 +1,62 @@
+import type { Address } from 'viem';
+
+import type { ChainID } from '$libs/chain';
+import { eventIndexerApiServices } from '$libs/eventIndexer/initEventIndexer';
+import { type NFT, TokenType } from '$libs/token';
+import { getTokenWithInfoFromAddress } from '$libs/token/getTokenWithInfoFromAddress';
+import { getLogger } from '$libs/util/logger';
+
+const log = getLogger('bridge:fetchNFTs');
+
+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}`;
+ if (!nftMap.has(uniqueKey)) {
+ nftMap.set(uniqueKey, nft);
+ }
+ });
+ });
+ return Array.from(nftMap.values());
+}
+
+export async function fetchNFTs(userAddress: Address, chainID: ChainID): Promise<{ nfts: NFT[]; error: Error | null }> {
+ let error: Error | null = null;
+
+ // Fetch from all indexers
+ const indexerPromises: Promise[] = eventIndexerApiServices.map(async (eventIndexerApiService) => {
+ const { items: result } = await eventIndexerApiService.getAllNftsByAddressFromAPI(userAddress, chainID, {
+ page: 0,
+ size: 100,
+ });
+
+ 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({
+ contractAddress: nft.contractAddress,
+ srcChainId: Number(chainID),
+ owner: userAddress,
+ tokenId: Number(nft.tokenID),
+ type,
+ })) as NFT;
+ });
+ return await Promise.all(nftsPromises);
+ });
+
+ let nftArrays: NFT[][] = [];
+ try {
+ nftArrays = await Promise.all(indexerPromises);
+ } catch (e) {
+ log('error fetching nfts from indexer services', e);
+ error = e as Error;
+ }
+
+ // Deduplicate based on address and chainID
+ const deduplicatedNfts = deduplicateNFTs(nftArrays);
+
+ log(`found ${deduplicatedNfts.length} unique NFTs from all indexers`, deduplicatedNfts);
+
+ return { nfts: deduplicatedNfts, error };
+}
diff --git a/packages/bridge-ui-v2/src/libs/bridge/fetchTransactions.ts b/packages/bridge-ui-v2/src/libs/bridge/fetchTransactions.ts
index eaa4941d25d..523955b0855 100644
--- a/packages/bridge-ui-v2/src/libs/bridge/fetchTransactions.ts
+++ b/packages/bridge-ui-v2/src/libs/bridge/fetchTransactions.ts
@@ -20,7 +20,7 @@ export async function fetchTransactions(userAddress: Address) {
page: 0,
size: 100,
});
- log(`fetched ${txs.length} transactions from relayer`, txs);
+ log(`fetched ${txs?.length ?? 0} transactions from relayer`, txs);
return txs;
});
@@ -37,7 +37,7 @@ export async function fetchTransactions(userAddress: Address) {
// Flatten the arrays into a single array
const relayerTxs: BridgeTransaction[] = relayerTxsArrays.reduce((acc, txs) => acc.concat(txs), []);
- log(`fetched ${relayerTxs.length} transactions from all relayers`, relayerTxs);
+ log(`fetched ${relayerTxs?.length ?? 0} transactions from all relayers`, relayerTxs);
const { mergedTransactions, outdatedLocalTransactions } = mergeAndCaptureOutdatedTransactions(localTxs, relayerTxs);
if (outdatedLocalTransactions.length > 0) {
diff --git a/packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.test.ts b/packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.test.ts
new file mode 100644
index 00000000000..d7ca437f58d
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.test.ts
@@ -0,0 +1,42 @@
+import axios from 'axios';
+import { zeroAddress } from 'viem';
+
+import { EventIndexerAPIService } from './EventIndexerAPIService';
+
+vi.mock('axios');
+
+describe('EventIndexerAPIService', () => {
+ it('should fetch NFTs by address', async () => {
+ const mockData = { data: 'mockData' };
+ vi.mocked(axios.get).mockResolvedValue({ status: 200, data: mockData });
+
+ const service = new EventIndexerAPIService('https://api.example.com');
+ const result = await service.getNftsByAddress({ address: zeroAddress, chainID: 1n });
+
+ expect(result).toEqual(mockData);
+ expect(axios.get).toHaveBeenCalledWith('https://api.example.com/nftsByAddress', expect.any(Object));
+ });
+
+ it('should throw an error on API failure', async () => {
+ vi.mocked(axios.get).mockResolvedValue({ status: 500 });
+ const service = new EventIndexerAPIService('https://api.example.com');
+ await expect(service.getNftsByAddress({ address: zeroAddress, chainID: 1n })).rejects.toThrow(
+ 'could not fetch transactions from API',
+ );
+ expect(axios.get).toHaveBeenCalledWith('https://api.example.com/nftsByAddress', expect.any(Object));
+ });
+
+ it('should fetch all NFTs by address', async () => {
+ const mockData = { data: 'mockData' };
+ const mockGetNftsByAddress = vi.fn().mockImplementation(() => Promise.resolve(mockData));
+ const service = new EventIndexerAPIService('https://api.example.com');
+ service.getNftsByAddress = mockGetNftsByAddress;
+
+ const result = await service.getAllNftsByAddressFromAPI(zeroAddress, 1n, {
+ size: 1,
+ page: 2,
+ });
+
+ expect(result).toEqual(mockData);
+ });
+});
diff --git a/packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.ts b/packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.ts
new file mode 100644
index 00000000000..e55f66e402d
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.ts
@@ -0,0 +1,61 @@
+import axios from 'axios';
+import type { Address } from 'viem';
+
+import type { ChainID } from '$libs/chain';
+import { getLogger } from '$libs/util/logger';
+
+import type { EventIndexerAPI, EventIndexerAPIRequestParams, EventIndexerAPIResponse, PaginationParams } from './types';
+
+const log = getLogger('EventIndexerAPIService');
+
+export class EventIndexerAPIService implements EventIndexerAPI {
+ private readonly baseUrl: string;
+
+ constructor(baseUrl: string) {
+ log('eventIndexer service instantiated');
+
+ this.baseUrl = baseUrl.replace(/\/$/, '');
+ }
+
+ async getNftsByAddress(params: EventIndexerAPIRequestParams): Promise {
+ const requestURL = `${this.baseUrl}/nftsByAddress`;
+
+ try {
+ log('Fetching from API with params', params);
+
+ const response = await axios.get(requestURL, {
+ params,
+ timeout: 5000, // todo: discuss and move to config
+ });
+
+ if (!response || response.status >= 400) throw response;
+
+ log('Events form API', response.data);
+
+ return response.data;
+ } catch (error) {
+ console.error(error);
+ log('Failed to fetch from API', error);
+ throw new Error('could not fetch transactions from API', {
+ cause: error,
+ });
+ }
+ }
+
+ async getAllNftsByAddressFromAPI(
+ address: Address,
+ chainID: ChainID,
+ paginationParams: PaginationParams,
+ ): Promise {
+ const params = {
+ address,
+ chainID,
+ ...paginationParams,
+ };
+ const response = await this.getNftsByAddress(params);
+
+ // todo: filter and cleanup?
+
+ return response;
+ }
+}
diff --git a/packages/bridge-ui-v2/src/libs/eventIndexer/index.ts b/packages/bridge-ui-v2/src/libs/eventIndexer/index.ts
new file mode 100644
index 00000000000..3913b4cc82a
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/eventIndexer/index.ts
@@ -0,0 +1,3 @@
+// export { default as EventIndexerAPIService } from './EventIndexerAPIService';
+
+export * from './types';
diff --git a/packages/bridge-ui-v2/src/libs/eventIndexer/initEventIndexer.ts b/packages/bridge-ui-v2/src/libs/eventIndexer/initEventIndexer.ts
new file mode 100644
index 00000000000..e803bf7ca12
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/eventIndexer/initEventIndexer.ts
@@ -0,0 +1,6 @@
+import { configuredEventIndexer } from '../../generated/eventIndexerConfig';
+import { EventIndexerAPIService } from './EventIndexerAPIService';
+
+export const eventIndexerApiServices: EventIndexerAPIService[] = configuredEventIndexer.map(
+ (eventIndexerConfig: { url: string }) => new EventIndexerAPIService(eventIndexerConfig.url),
+);
diff --git a/packages/bridge-ui-v2/src/libs/eventIndexer/types.ts b/packages/bridge-ui-v2/src/libs/eventIndexer/types.ts
new file mode 100644
index 00000000000..0839268d191
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/eventIndexer/types.ts
@@ -0,0 +1,57 @@
+import type { Address } from '@wagmi/core';
+
+import type { ChainID } from '$libs/chain';
+import type { Token } from '$libs/token';
+
+export type GetAllByAddressResponse = {
+ nfts: Token[];
+ paginationInfo: PaginationInfo;
+};
+
+export type PaginationParams = {
+ size: number;
+ page: number;
+};
+
+export interface EventIndexerAPI {
+ getNftsByAddress(params: EventIndexerAPIRequestParams): Promise;
+}
+
+export type EventIndexerAPIResponseNFT = {
+ id: number;
+ tokenID: string;
+ contractAddress: Address;
+ contractType: string;
+ address: Address;
+ chainID: number;
+ amount: number;
+};
+
+export type EventIndexerAPIRequestParams = {
+ address: Address;
+ chainID?: ChainID;
+};
+
+export type PaginationInfo = {
+ page: number;
+ size: number;
+ max_page: number;
+ total_pages: number;
+ total: number;
+ last: boolean;
+ first: boolean;
+};
+
+export type EventIndexerAPIResponse = PaginationInfo & {
+ items: EventIndexerAPIResponseNFT[];
+ visible: number;
+};
+
+export type EventIndexerConfig = {
+ chainIds: number[];
+ url: string;
+};
+
+export type ConfiguredEventIndexer = {
+ configuredEventIndexer: EventIndexerConfig[];
+};
diff --git a/packages/bridge-ui-v2/src/libs/storage/CustomTokenService.ts b/packages/bridge-ui-v2/src/libs/storage/CustomTokenService.ts
index d9e73adaa2e..3bb4afee06e 100644
--- a/packages/bridge-ui-v2/src/libs/storage/CustomTokenService.ts
+++ b/packages/bridge-ui-v2/src/libs/storage/CustomTokenService.ts
@@ -34,7 +34,10 @@ export class CustomTokenService implements TokenService {
tokens.push(token);
}
- this.storage.setItem(`${STORAGE_PREFIX}-${address.toLowerCase()}`, JSON.stringify(tokens));
+ this.storage.setItem(
+ `${STORAGE_PREFIX}-${address.toLowerCase()}`,
+ JSON.stringify(tokens, (_, value) => (typeof value === 'bigint' ? Number(value) : value)),
+ );
this.storageChangeNotifier.dispatchEvent(new CustomEvent('storageChange', { detail: tokens }));
return tokens;
diff --git a/packages/bridge-ui-v2/src/libs/token/fetchNFTImage.ts b/packages/bridge-ui-v2/src/libs/token/fetchNFTImage.ts
new file mode 100644
index 00000000000..4ded911edad
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/token/fetchNFTImage.ts
@@ -0,0 +1,10 @@
+import { getLogger } from '$libs/util/logger';
+
+import type { Token } from './types';
+
+const log = getLogger('libs:token:fetchNFTImage');
+
+export const fetchNFTImage = (token: Token) => {
+ log('fetching image for', token);
+ return null;
+};
diff --git a/packages/bridge-ui-v2/src/libs/token/getTokenInfo.test.ts b/packages/bridge-ui-v2/src/libs/token/getTokenInfo.test.ts
deleted file mode 100644
index 17735551887..00000000000
--- a/packages/bridge-ui-v2/src/libs/token/getTokenInfo.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { fetchToken, type FetchTokenResult, readContract } from '@wagmi/core';
-import { type Address, zeroAddress } from 'viem';
-
-import { UnknownTokenTypeError } from '$libs/error';
-
-import { detectContractType } from './detectContractType';
-import { getTokenInfoFromAddress } from './getTokenInfo';
-import { TokenType } from './types';
-
-vi.mock('@wagmi/core');
-
-vi.mock('./errors', () => {
- return {
- UnknownTypeError: vi.fn().mockImplementation(() => {
- return { message: 'Mocked UnknownTypeError' };
- }),
- };
-});
-
-vi.mock('./detectContractType', () => {
- const actual = vi.importActual('./detectContractType');
- return {
- ...actual,
- detectContractType: vi.fn(),
- };
-});
-
-describe('getTokenInfoFromAddress', () => {
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- it('should return correct token details for ERC20 tokens', async () => {
- // Given
- const address: Address = zeroAddress;
- vi.mocked(detectContractType).mockResolvedValue(TokenType.ERC20);
- vi.mocked(fetchToken).mockResolvedValue({
- name: 'MockToken',
- symbol: 'MTK',
- decimals: 18,
- } as FetchTokenResult);
-
- // When
- const result = await getTokenInfoFromAddress(address);
-
- // Then
- expect(result).toEqual({
- address,
- name: 'MockToken',
- symbol: 'MTK',
- decimals: 18,
- type: TokenType.ERC20,
- });
- });
-
- it('should return correct token details for ERC721 tokens', async () => {
- // Given
- const address: Address = zeroAddress;
- vi.mocked(detectContractType).mockResolvedValue(TokenType.ERC721);
- vi.mocked(readContract).mockResolvedValueOnce('MockNFT').mockResolvedValueOnce('MNFT');
-
- // When
- const result = await getTokenInfoFromAddress(address);
-
- // Then
- expect(result).toEqual({
- address,
- name: 'MockNFT',
- symbol: 'MNFT',
- decimals: 0,
- type: TokenType.ERC721,
- });
- });
-
- it('should return null for unknown token types', async () => {
- // Given
- const address: Address = zeroAddress;
- vi.mocked(detectContractType).mockRejectedValue(new UnknownTokenTypeError());
-
- // When
- const result = await getTokenInfoFromAddress(address);
-
- // Then
- expect(result).toBeNull();
- });
-});
diff --git a/packages/bridge-ui-v2/src/libs/token/getTokenInfo.ts b/packages/bridge-ui-v2/src/libs/token/getTokenInfo.ts
deleted file mode 100644
index c0e2ede1acb..00000000000
--- a/packages/bridge-ui-v2/src/libs/token/getTokenInfo.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { erc721ABI, fetchToken, readContract } from '@wagmi/core';
-import type { Address } from 'viem';
-
-import { detectContractType } from './detectContractType';
-import { type TokenDetails, TokenType } from './types';
-
-export const getTokenInfoFromAddress = async (address: Address) => {
- try {
- const tokenType = await detectContractType(address);
- const details: TokenDetails = {} as TokenDetails;
- if (tokenType === TokenType.ERC20) {
- const token = await fetchToken({
- address,
- });
- details.type = tokenType;
- details.address = address;
- details.name = token.name;
- details.symbol = token.symbol;
- details.decimals = token.decimals;
- return details;
- } else if (tokenType === TokenType.ERC1155) {
- // todo: via URI?
- details.type = tokenType;
- return details;
- } else if (tokenType === TokenType.ERC721) {
- const name = await readContract({
- address,
- abi: erc721ABI,
- functionName: 'name',
- });
-
- const symbol = await readContract({
- address,
- abi: erc721ABI,
- functionName: 'symbol',
- });
-
- details.type = tokenType;
- details.address = address;
- details.name = name;
- details.symbol = symbol;
- details.decimals = 0;
- return details;
- }
- return null;
- } catch (err) {
- return null;
- }
-};
diff --git a/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.test.ts b/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.test.ts
new file mode 100644
index 00000000000..8c84e456976
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.test.ts
@@ -0,0 +1,162 @@
+import { fetchToken, type FetchTokenResult, readContract } from '@wagmi/core';
+import { type Address, zeroAddress } from 'viem';
+
+import { UnknownTokenTypeError } from '$libs/error';
+
+import { detectContractType } from './detectContractType';
+import { getTokenWithInfoFromAddress } from './getTokenWithInfoFromAddress';
+import { TokenType } from './types';
+
+vi.mock('@wagmi/core');
+
+vi.mock('./errors', () => {
+ return {
+ UnknownTypeError: vi.fn().mockImplementation(() => {
+ return { message: 'Mocked UnknownTypeError' };
+ }),
+ };
+});
+
+vi.mock('./detectContractType', () => {
+ const actual = vi.importActual('./detectContractType');
+ return {
+ ...actual,
+ detectContractType: vi.fn(),
+ };
+});
+
+describe('getTokenWithInfoFromAddress', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('ERC20', () => {
+ it('should return correct token details for ERC20 tokens', async () => {
+ // Given
+ const address: Address = zeroAddress;
+ vi.mocked(detectContractType).mockResolvedValue(TokenType.ERC20);
+ vi.mocked(fetchToken).mockResolvedValue({
+ name: 'MockToken',
+ symbol: 'MTK',
+ decimals: 18,
+ } as FetchTokenResult);
+
+ // When
+ const result = await getTokenWithInfoFromAddress({ contractAddress: address, srcChainId: 1 });
+
+ // Then
+ expect(result).toEqual({
+ addresses: {
+ 1: address,
+ },
+ name: 'MockToken',
+ symbol: 'MTK',
+ decimals: 18,
+ type: TokenType.ERC20,
+ });
+
+ expect(fetchToken).toHaveBeenCalledOnce();
+ expect(fetchToken).toHaveBeenCalledWith({
+ address,
+ chainId: 1,
+ });
+ });
+ });
+
+ describe('ERC721', () => {
+ it('should return correct token details for ERC721 tokens', async () => {
+ // Given
+ const address: Address = zeroAddress;
+ vi.mocked(detectContractType).mockResolvedValue(TokenType.ERC721);
+ vi.mocked(readContract)
+ .mockResolvedValueOnce('Mock721')
+ .mockResolvedValueOnce('MNFT')
+ .mockResolvedValueOnce('some/uri/123');
+
+ // When
+ const result = await getTokenWithInfoFromAddress({ contractAddress: address, srcChainId: 1, tokenId: 123 });
+
+ // Then
+ expect(result).toEqual({
+ addresses: {
+ 1: address,
+ },
+ uri: 'some/uri/123',
+ tokenId: 123,
+ name: 'Mock721',
+ symbol: 'MNFT',
+ type: TokenType.ERC721,
+ });
+ expect(readContract).toHaveBeenCalledTimes(3);
+ });
+ });
+ describe('ERC1155', () => {
+ it('should return correct token details for ERC1155 tokens', async () => {
+ // Given
+ const address: Address = zeroAddress;
+ vi.mocked(detectContractType).mockResolvedValue(TokenType.ERC1155);
+ vi.mocked(readContract)
+ .mockResolvedValueOnce('Mock1155')
+ .mockResolvedValueOnce('some/uri/123')
+ .mockResolvedValueOnce(1337n);
+ // When
+ const result = await getTokenWithInfoFromAddress({
+ contractAddress: address,
+ srcChainId: 1,
+ tokenId: 123,
+ owner: zeroAddress,
+ });
+
+ // Then
+ expect(result).toEqual({
+ addresses: {
+ 1: address,
+ },
+ uri: 'some/uri/123',
+ tokenId: 123,
+ name: 'Mock1155',
+ balance: 1337n,
+ type: TokenType.ERC1155,
+ });
+ });
+
+ it('should return correct token details for ERC1155 tokens with no owner passed', async () => {
+ // Given
+ const address: Address = zeroAddress;
+ vi.mocked(detectContractType).mockResolvedValue(TokenType.ERC1155);
+ vi.mocked(readContract)
+ .mockResolvedValueOnce('Mock1155')
+ .mockResolvedValueOnce('some/uri/123')
+ .mockResolvedValueOnce(1337n);
+ // When
+ const result = await getTokenWithInfoFromAddress({ contractAddress: address, srcChainId: 1, tokenId: 123 });
+
+ // Then
+ expect(result).toEqual({
+ addresses: {
+ 1: address,
+ },
+ uri: 'some/uri/123',
+ tokenId: 123,
+ name: 'Mock1155',
+ balance: 0,
+ type: TokenType.ERC1155,
+ });
+ });
+ });
+
+ it('should throw for unknown token types', async () => {
+ // Given
+ const address: Address = zeroAddress;
+ vi.mocked(detectContractType).mockRejectedValue(new UnknownTokenTypeError());
+
+ // When
+ try {
+ await getTokenWithInfoFromAddress({ contractAddress: address, srcChainId: 1 });
+ expect.fail('should have thrown');
+ } catch (error) {
+ expect(readContract).not.toHaveBeenCalled();
+ expect(fetchToken).not.toHaveBeenCalled();
+ }
+ });
+});
diff --git a/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts b/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts
new file mode 100644
index 00000000000..0c7fc1fb003
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts
@@ -0,0 +1,132 @@
+import { erc721ABI, fetchToken, readContract } from '@wagmi/core';
+import type { Address } from 'viem';
+
+import { erc1155ABI } from '$abi';
+import { getLogger } from '$libs/util/logger';
+import { safeReadContract } from '$libs/util/safeReadContract';
+
+import { detectContractType } from './detectContractType';
+import { type NFT, type Token, TokenType } from './types';
+
+const log = getLogger('libs:token:getTokenInfo');
+
+type GetTokenWithInfoFromAddressParams = {
+ contractAddress: Address;
+ srcChainId: number;
+ owner?: Address;
+ tokenId?: number;
+ type?: TokenType;
+};
+
+export const getTokenWithInfoFromAddress = async ({
+ contractAddress,
+ srcChainId,
+ owner,
+ tokenId,
+ type,
+}: GetTokenWithInfoFromAddressParams): Promise => {
+ 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;
+ } 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;
+ } else if (tokenType === TokenType.ERC721) {
+ const name = await readContract({
+ address: contractAddress,
+ abi: erc721ABI,
+ functionName: 'name',
+ chainId: srcChainId,
+ });
+
+ const symbol = await readContract({
+ address: contractAddress,
+ abi: erc721ABI,
+ functionName: 'symbol',
+ chainId: srcChainId,
+ });
+
+ let uri;
+
+ if (tokenId) {
+ uri = await safeReadContract({
+ address: contractAddress,
+ abi: erc721ABI,
+ functionName: 'tokenURI',
+ args: [BigInt(tokenId)],
+ chainId: srcChainId,
+ });
+ }
+
+ const token = {
+ type: tokenType,
+ addresses: {
+ [srcChainId]: contractAddress,
+ },
+ name,
+ symbol,
+ tokenId: tokenId ?? 0,
+ uri: uri ? uri.toString() : undefined,
+ } as NFT;
+
+ return token;
+ } else {
+ throw new Error('Unsupported token type');
+ }
+ } catch (err) {
+ log('Error getting token info', err);
+ throw new Error('Error getting token info');
+ }
+};
diff --git a/packages/bridge-ui-v2/src/libs/token/types.ts b/packages/bridge-ui-v2/src/libs/token/types.ts
index e0d95fa40de..b5c21cf9062 100644
--- a/packages/bridge-ui-v2/src/libs/token/types.ts
+++ b/packages/bridge-ui-v2/src/libs/token/types.ts
@@ -14,23 +14,20 @@ export enum TokenType {
}
export type Token = {
+ type: TokenType;
name: string;
- addresses: Record;
symbol: string;
+ addresses: Record;
decimals: number;
- type: TokenType;
logoURI?: string;
imported?: boolean;
mintable?: boolean;
+ balance?: bigint;
+ uri?: string;
};
-export type TokenDetails = {
- name: string;
- address: Address;
- symbol: string;
- balance: bigint;
- decimals: number;
- type: TokenType;
+export type NFT = Token & {
+ tokenId: number;
};
export type GetCrossChainAddressArgs = {
diff --git a/packages/bridge-ui-v2/src/libs/util/safeReadContract.test.ts b/packages/bridge-ui-v2/src/libs/util/safeReadContract.test.ts
new file mode 100644
index 00000000000..85df57956ca
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/util/safeReadContract.test.ts
@@ -0,0 +1,35 @@
+import { readContract } from '@wagmi/core';
+import { zeroAddress } from 'viem';
+
+import { safeReadContract } from './safeReadContract';
+
+vi.mock('@wagmi/core');
+
+describe('safeReadContract', () => {
+ it('should return contract data on success', async () => {
+ const mockData = { data: 'mockData' };
+ vi.mocked(readContract).mockResolvedValue(mockData);
+
+ const result = await safeReadContract({
+ address: zeroAddress,
+ abi: [],
+ functionName: 'functionName',
+ chainId: 1,
+ });
+
+ expect(result).toEqual(mockData);
+ });
+
+ it('should return null on failure', async () => {
+ vi.mocked(readContract).mockRejectedValue(new Error('mockError'));
+
+ const result = await safeReadContract({
+ address: zeroAddress,
+ abi: [],
+ functionName: 'functionName',
+ chainId: 1,
+ });
+
+ expect(result).toBeNull();
+ });
+});
diff --git a/packages/bridge-ui-v2/src/libs/util/safeReadContract.ts b/packages/bridge-ui-v2/src/libs/util/safeReadContract.ts
new file mode 100644
index 00000000000..c1b84591f08
--- /dev/null
+++ b/packages/bridge-ui-v2/src/libs/util/safeReadContract.ts
@@ -0,0 +1,27 @@
+import { readContract } from '@wagmi/core';
+import type { Abi, Address } from 'viem';
+
+import { getLogger } from './logger';
+
+const log = getLogger('libs:util:safeReadContract');
+
+type ReadContractParams = {
+ address: Address;
+ abi: Abi;
+ functionName: string;
+ args?: unknown[];
+ chainId: number;
+};
+
+/*
+ * Safely read a contract, returning null if it fails
+ * useful when trying to access a non standard, non mandatory function
+ */
+export async function safeReadContract(params: ReadContractParams): Promise {
+ try {
+ return await readContract(params);
+ } catch (error) {
+ log(`Failed to read contract: ${error}`);
+ return null;
+ }
+}
diff --git a/packages/bridge-ui-v2/vite.config.ts b/packages/bridge-ui-v2/vite.config.ts
index 4297df2a02c..61c58cb1204 100644
--- a/packages/bridge-ui-v2/vite.config.ts
+++ b/packages/bridge-ui-v2/vite.config.ts
@@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/dist/config';
import { generateBridgeConfig } from './scripts/vite-plugins/generateBridgeConfig';
import { generateChainConfig } from './scripts/vite-plugins/generateChainConfig';
import { generateCustomTokenConfig } from './scripts/vite-plugins/generateCustomTokenConfig';
+import { generateEventIndexerConfig } from './scripts/vite-plugins/generateEventIndexerConfig';
import { generateRelayerConfig } from './scripts/vite-plugins/generateRelayerConfig';
export default defineConfig({
@@ -20,6 +21,7 @@ export default defineConfig({
generateChainConfig(),
generateRelayerConfig(),
generateCustomTokenConfig(),
+ generateEventIndexerConfig(),
],
test: {
environment: 'jsdom',