From 331f845a84d4bc25827c2025d785ffb1402d7619 Mon Sep 17 00:00:00 2001 From: Korbinian Date: Tue, 10 Oct 2023 17:18:29 +0200 Subject: [PATCH] feat(bridge-ui-v2): dynamic import of NFT data via API (#14928) --- packages/bridge-ui-v2/.env.example | 4 +- .../sample/configuredEventIndexer.example | 12 + .../configuredEventIndexer.schema.json | 25 ++ .../bridge-ui-v2/scripts/exportJsonToEnv.js | 3 +- .../generateEventIndexerConfig.ts | 141 ++++++++++++ .../Bridge/AddressInput/AddressInput.svelte | 1 + .../src/components/Bridge/Bridge.svelte | 214 +++++++++++++----- .../src/components/NFTList/NFTList.svelte | 116 ++++++++++ .../src/components/NFTList/index.ts | 1 + .../TokenDropdown/AddCustomERC20.svelte | 114 +++++----- .../bridge-ui-v2/src/libs/bridge/fetchNFTs.ts | 62 +++++ .../src/libs/bridge/fetchTransactions.ts | 4 +- .../EventIndexerAPIService.test.ts | 42 ++++ .../eventIndexer/EventIndexerAPIService.ts | 61 +++++ .../src/libs/eventIndexer/index.ts | 3 + .../src/libs/eventIndexer/initEventIndexer.ts | 6 + .../src/libs/eventIndexer/types.ts | 57 +++++ .../src/libs/storage/CustomTokenService.ts | 5 +- .../src/libs/token/fetchNFTImage.ts | 10 + .../src/libs/token/getTokenInfo.test.ts | 86 ------- .../src/libs/token/getTokenInfo.ts | 49 ---- .../token/getTokenWithInfoFromAddress.test.ts | 162 +++++++++++++ .../libs/token/getTokenWithInfoFromAddress.ts | 132 +++++++++++ packages/bridge-ui-v2/src/libs/token/types.ts | 15 +- .../src/libs/util/safeReadContract.test.ts | 35 +++ .../src/libs/util/safeReadContract.ts | 27 +++ packages/bridge-ui-v2/vite.config.ts | 2 + 27 files changed, 1121 insertions(+), 268 deletions(-) create mode 100644 packages/bridge-ui-v2/config/sample/configuredEventIndexer.example create mode 100644 packages/bridge-ui-v2/config/schemas/configuredEventIndexer.schema.json create mode 100644 packages/bridge-ui-v2/scripts/vite-plugins/generateEventIndexerConfig.ts create mode 100644 packages/bridge-ui-v2/src/components/NFTList/NFTList.svelte create mode 100644 packages/bridge-ui-v2/src/components/NFTList/index.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/fetchNFTs.ts create mode 100644 packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.test.ts create mode 100644 packages/bridge-ui-v2/src/libs/eventIndexer/EventIndexerAPIService.ts create mode 100644 packages/bridge-ui-v2/src/libs/eventIndexer/index.ts create mode 100644 packages/bridge-ui-v2/src/libs/eventIndexer/initEventIndexer.ts create mode 100644 packages/bridge-ui-v2/src/libs/eventIndexer/types.ts create mode 100644 packages/bridge-ui-v2/src/libs/token/fetchNFTImage.ts delete mode 100644 packages/bridge-ui-v2/src/libs/token/getTokenInfo.test.ts delete mode 100644 packages/bridge-ui-v2/src/libs/token/getTokenInfo.ts create mode 100644 packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.test.ts create mode 100644 packages/bridge-ui-v2/src/libs/token/getTokenWithInfoFromAddress.ts create mode 100644 packages/bridge-ui-v2/src/libs/util/safeReadContract.test.ts create mode 100644 packages/bridge-ui-v2/src/libs/util/safeReadContract.ts 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}
@@ -444,6 +483,10 @@
+ + {: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} - {/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',