From a5882d29282b3b69aca0a32981295b8c1431cfea Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Mon, 9 Dec 2024 15:00:42 +0200 Subject: [PATCH] TW-1387 Implement sending EVM tokens (#1219) * basic layout * select token for tezos * select token modal logic * dont show tags * fix select asset modal opening after page reload * fix e2e ts * form refactor * resolving evm address from domain * max amount calculation * fix logic for erc20 tokens * recipient account select main logic * address copying fixed * recipient input / select acc state connection + truncation added * tezos form validation fixes * evm form validation + react-hook-form v7 used * add active state for filter network option * scroll to selected network * add filter network search * loader added * always show converted amount * show floating assetSymbol in input * track other networks addresses * show tezos error toast on form submit * maxAmount calculation fixes * fix pipeline * confirm modal base + segmented control * confirmation modal layout finished * Evm / tezos component separation Header, DetailsTab * fix after-merge conflicts * fix network icon * more after-merge fixes * some more fixes * some more fixes * infoIcon * iconBase memo * viem update * send evm transaction * send evm transaction / add networks support * fix various ui bugs * fix fee options calculations * fix some more bugs * fix import cycle * custom transaction params inputs + error handling * fix ts-prune * major refactoring and bug fixes * tezos fee options calculation + ui fixes * send tezos operations without old confirmation page * added loading button + proper form reset * edit gas fee and storageLimit * show default evm form values * some ui fixes * non zero validation * error tab * fix ts-prune * fix fee calculation with custom gas limit * refactor * refactor + minor ui fixes * show default gas fee and storage limit + non zero gas fee validation * storage limit handling * fix ts-prune * fix some after-merge issues * show tezos raw transaction * renaming * raw transaction json view * tezos submit errors handling * fix some after-merge issues * fix some after-merge issues * fix ts-prune * refactor * more refactor * apply suggestion * fix audit * minor bug fixes * after merge fixes * fix minor bugs * some more after-merge fixes * some more after-merge fixes * fix info icon size * TW-1387 Implement sending ERC20 tokens * TW-1387 Implement sending ERC721 and ERC1155 tokens * TW-1387 Fix pipeline errors * TW-1387 Major refactoring * TW-1387 Fix displaying transaction hex * TW-1387 Refactoring according to comments * TW-1387 Refactoring according to comments * TW-1387 Improve balances update in networks that are not covered by Covalent * TW-1387 Refactor recent changes * TW-1387 Fix updating balances on chains that Covalent does not support * TW-1387 Remove redundant 'break' statement * TW-1387 Refactor listening to new EVM blocks and transfers events * TW-1387 Do without passing chain id to listeners * TW-1387 Minor refactoring * TW-1387 Fix updating balances to zero if they come from Covalent --------- Co-authored-by: lendihop --- .../use-account-tokens-listing-logic.ts | 37 +- ...-evm-chain-account-tokens-listing-logic.ts | 23 +- .../use-tezos-account-tokens-listing-logic.ts | 11 +- ...ezos-chain-account-tokens-listing-logic.ts | 21 +- .../CollectiblePage/CollectiblePageImage.tsx | 8 +- .../CollectiblePage/PropertiesItems.tsx | 8 +- .../Collectibles/CollectiblePage/index.tsx | 9 + .../components/CollectibleItem.tsx | 15 +- .../components/AddTokenModal/AddTokenForm.tsx | 23 +- .../Tokens/components/EvmChainTokensTab.tsx | 13 +- .../Tokens/components/EvmTokensTab.tsx | 12 +- .../Tokens/components/ListItem.tsx | 6 +- .../Tokens/components/MultiChainTokensTab.tsx | 25 +- .../Tokens/components/TezosChainTokensTab.tsx | 13 +- .../Tokens/components/TezosTokensTab.tsx | 14 +- .../pages/Send/build-basic-evm-send-params.ts | 64 +++ src/app/pages/Send/form/EvmForm.tsx | 14 +- src/app/pages/Send/form/SelectAssetButton.tsx | 14 +- .../Send/hooks/use-evm-estimation-data.ts | 66 ++- .../Send/modals/ConfirmSend/EvmContent.tsx | 85 +++- .../modals/ConfirmSend/components/Header.tsx | 4 +- .../pages/Send/modals/ConfirmSend/context.ts | 4 +- .../ConfirmSend/hooks/use-evm-fee-options.ts | 83 +++- .../Send/modals/ConfirmSend/tabs/Fee.tsx | 7 +- .../pages/Send/modals/ConfirmSend/types.ts | 28 +- .../Send/modals/SelectAsset/EvmAssetsList.tsx | 23 +- .../modals/SelectAsset/EvmChainAssetsList.tsx | 22 +- .../SelectAsset/MultiChainAssetsList.tsx | 40 +- .../SelectAsset/TezosChainAssetsList.tsx | 4 +- src/app/store/evm/balances/actions.ts | 19 + src/app/store/evm/balances/epics.ts | 41 ++ src/app/store/evm/balances/reducers.ts | 19 +- src/app/store/evm/balances/utils.ts | 26 ++ src/app/store/root-state.epics.ts | 4 +- src/app/templates/AssetIcon.tsx | 2 +- src/app/templates/Balance.tsx | 4 +- .../add-network-modal/use-add-network.ts | 2 + src/lib/abi/erc1155.ts | 420 ++++++++++++++++++ src/lib/abi/erc20.ts | 22 + src/lib/abi/erc721.ts | 22 + src/lib/assets/hooks/tokens.ts | 2 +- src/lib/balances/hooks.ts | 87 ++-- src/lib/evm/on-chain/balance.ts | 9 +- .../evm-http-rpc-listener.ts | 73 +++ .../evm-new-block-listener.ts | 15 + .../evm-transfer-subscriptions/index.ts | 50 +++ .../evm-transfer-subscriptions/listener.ts | 17 + .../listeners-delegate.ts | 13 + .../erc1155-transfer-events-listener.ts | 49 ++ .../erc20-transfer-events-listener.ts | 22 + .../erc721-transfer-events-listener.ts | 22 + .../evm-single-transfer-events-listener.ts | 28 ++ .../evm-transfer-events-listener.ts | 91 ++++ .../make-get-transfer-events-listener.ts | 10 + src/lib/evm/on-chain/metadata.ts | 48 +- src/lib/evm/on-chain/utils/common.utils.ts | 4 +- .../utils/evm-rpc-requests-executor.ts | 89 ++++ src/lib/fixed-times.ts | 2 + src/lib/images-uri.ts | 19 +- src/lib/metadata/index.ts | 4 +- src/lib/metadata/types.ts | 4 + src/lib/metadata/utils.ts | 7 +- src/lib/types.d.ts | 2 + src/lib/utils/evm.utils.ts | 2 +- src/lib/utils/queue-of-unique.ts | 29 ++ src/temple/evm/index.ts | 4 +- src/temple/evm/types.ts | 101 ++++- src/temple/evm/utils.ts | 112 ++++- src/temple/misc.ts | 8 +- 69 files changed, 1858 insertions(+), 342 deletions(-) create mode 100644 src/app/pages/Send/build-basic-evm-send-params.ts create mode 100644 src/app/store/evm/balances/epics.ts create mode 100644 src/lib/abi/erc1155.ts create mode 100644 src/lib/abi/erc20.ts create mode 100644 src/lib/abi/erc721.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/evm-http-rpc-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/evm-new-block-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/index.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/listeners-delegate.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc1155-transfer-events-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc20-transfer-events-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc721-transfer-events-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-single-transfer-events-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-transfer-events-listener.ts create mode 100644 src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/make-get-transfer-events-listener.ts create mode 100644 src/lib/evm/on-chain/utils/evm-rpc-requests-executor.ts create mode 100644 src/lib/utils/queue-of-unique.ts diff --git a/src/app/hooks/listing-logic/use-account-tokens-listing-logic.ts b/src/app/hooks/listing-logic/use-account-tokens-listing-logic.ts index a4f097639d..e717a374c0 100644 --- a/src/app/hooks/listing-logic/use-account-tokens-listing-logic.ts +++ b/src/app/hooks/listing-logic/use-account-tokens-listing-logic.ts @@ -61,23 +61,26 @@ export const useAccountTokensForListing = ( [enabledEvmChains, enabledTezChains] ); - const enabledChainsSlugsSorted = useMemoWithCompare(() => { - const enabledChainsSlugs = [ - ...gasChainsSlugs, - ...tezTokens - .filter(({ status }) => status === 'enabled') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)), - ...evmTokens - .filter(({ status }) => status === 'enabled') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.EVM, chainId, slug)) - ]; - - const enabledChainsSlugsFiltered = filterZeroBalances - ? enabledChainsSlugs.filter(isNonZeroBalance) - : enabledChainsSlugs; - - return enabledChainsSlugsFiltered.sort(tokensSortPredicate); - }, [gasChainsSlugs, tezTokens, evmTokens, filterZeroBalances, isNonZeroBalance, tokensSortPredicate]); + const enabledChainsSlugsFiltered = useMemo(() => { + const enabledChainsSlugs = gasChainsSlugs + .concat( + tezTokens + .filter(({ status }) => status === 'enabled') + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)) + ) + .concat( + evmTokens + .filter(({ status }) => status === 'enabled') + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.EVM, chainId, slug)) + ); + + return filterZeroBalances ? enabledChainsSlugs.filter(isNonZeroBalance) : enabledChainsSlugs; + }, [evmTokens, filterZeroBalances, gasChainsSlugs, isNonZeroBalance, tezTokens]); + + const enabledChainsSlugsSorted = useMemoWithCompare( + () => enabledChainsSlugsFiltered.sort(tokensSortPredicate), + [enabledChainsSlugsFiltered, tokensSortPredicate] + ); return { enabledChainsSlugsSorted, diff --git a/src/app/hooks/listing-logic/use-evm-chain-account-tokens-listing-logic.ts b/src/app/hooks/listing-logic/use-evm-chain-account-tokens-listing-logic.ts index 60a4cda190..54c1d2b720 100644 --- a/src/app/hooks/listing-logic/use-evm-chain-account-tokens-listing-logic.ts +++ b/src/app/hooks/listing-logic/use-evm-chain-account-tokens-listing-logic.ts @@ -40,16 +40,19 @@ export const useEvmChainAccountTokensForListing = ( [balances] ); - const enabledSlugsSorted = useMemoWithCompare(() => { - const enabledSlugs = [ - EVM_TOKEN_SLUG, - ...tokens.filter(({ status }) => status === 'enabled').map(({ slug }) => slug) - ]; - - const enabledSlugsFiltered = filterZeroBalances ? enabledSlugs.filter(isNonZeroBalance) : enabledSlugs; - - return enabledSlugsFiltered.sort(tokensSortPredicate); - }, [tokens, isNonZeroBalance, tokensSortPredicate, filterZeroBalances]); + const enabledSlugsFiltered = useMemo(() => { + const gasTokensSlugs: string[] = [EVM_TOKEN_SLUG]; + const enabledSlugs = gasTokensSlugs.concat( + tokens.filter(({ status }) => status === 'enabled').map(({ slug }) => slug) + ); + + return filterZeroBalances ? enabledSlugs.filter(isNonZeroBalance) : enabledSlugs; + }, [filterZeroBalances, isNonZeroBalance, tokens]); + + const enabledSlugsSorted = useMemoWithCompare( + () => enabledSlugsFiltered.sort(tokensSortPredicate), + [enabledSlugsFiltered, tokensSortPredicate] + ); return { enabledSlugsSorted, diff --git a/src/app/hooks/listing-logic/use-tezos-account-tokens-listing-logic.ts b/src/app/hooks/listing-logic/use-tezos-account-tokens-listing-logic.ts index fac6acdb27..e09df7cbbb 100644 --- a/src/app/hooks/listing-logic/use-tezos-account-tokens-listing-logic.ts +++ b/src/app/hooks/listing-logic/use-tezos-account-tokens-listing-logic.ts @@ -53,13 +53,16 @@ export const useTezosAccountTokensForListing = (publicKeyHash: string, filterZer [balancesRecord, publicKeyHash] ); - const enabledChainsSlugsSorted = useMemoWithCompare(() => { + const enabledSlugsFiltered = useMemo(() => { const enabledSlugs = gasSlugs.concat(enabledStoredChainSlugs); - const enabledSlugsFiltered = filterZeroBalances ? enabledSlugs.filter(isNonZeroBalance) : enabledSlugs; + return filterZeroBalances ? enabledSlugs.filter(isNonZeroBalance) : enabledSlugs; + }, [enabledStoredChainSlugs, filterZeroBalances, gasSlugs, isNonZeroBalance]); - return enabledSlugsFiltered.sort(tokensSortPredicate); - }, [enabledStoredChainSlugs, isNonZeroBalance, tokensSortPredicate, gasSlugs, filterZeroBalances]); + const enabledChainsSlugsSorted = useMemoWithCompare( + () => enabledSlugsFiltered.sort(tokensSortPredicate), + [enabledSlugsFiltered, tokensSortPredicate] + ); return { enabledChainsSlugsSorted, diff --git a/src/app/hooks/listing-logic/use-tezos-chain-account-tokens-listing-logic.ts b/src/app/hooks/listing-logic/use-tezos-chain-account-tokens-listing-logic.ts index 1446458150..229ec83946 100644 --- a/src/app/hooks/listing-logic/use-tezos-chain-account-tokens-listing-logic.ts +++ b/src/app/hooks/listing-logic/use-tezos-chain-account-tokens-listing-logic.ts @@ -45,14 +45,19 @@ export const useTezosChainAccountTokensForListing = (publicKeyHash: string, chai [isNonZeroBalance, leadingAssetsSlugs, filterZeroBalances] ); - const nonLeadingTokenSlugsFilteredSorted = useMemoWithCompare(() => { - const nonLeadingSlugs = [ - TEZ_TOKEN_SLUG, - ...tokens.filter(({ status }) => status === 'enabled').map(({ slug }) => slug) - ]; - - return (filterZeroBalances ? nonLeadingSlugs.filter(isNonZeroBalance) : nonLeadingSlugs).sort(tokensSortPredicate); - }, [tokens, isNonZeroBalance, tokensSortPredicate, filterZeroBalances]); + const nonLeadingTokensSlugsFiltered = useMemo(() => { + const gasSlugs: string[] = [TEZ_TOKEN_SLUG]; + const nonLeadingSlugs = gasSlugs.concat( + tokens.filter(({ status }) => status === 'enabled').map(({ slug }) => slug) + ); + + return filterZeroBalances ? nonLeadingSlugs.filter(isNonZeroBalance) : nonLeadingSlugs; + }, [tokens, isNonZeroBalance, filterZeroBalances]); + + const nonLeadingTokenSlugsFilteredSorted = useMemoWithCompare( + () => nonLeadingTokensSlugsFiltered.sort(tokensSortPredicate), + [nonLeadingTokensSlugsFiltered, tokensSortPredicate] + ); const enabledTokenSlugsSorted = useMemo( () => Array.from(new Set(leadingAssetsFiltered.concat(nonLeadingTokenSlugsFilteredSorted))), diff --git a/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx b/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx index 533f1047a0..606f0bae96 100644 --- a/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx +++ b/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx @@ -1,9 +1,11 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { isString } from 'lodash'; + import { Model3DViewer } from 'app/atoms/Model3DViewer'; import { useCollectiblesListOptionsSelector } from 'app/store/assets-filter-options/selectors'; import { TezosAssetImageStacked } from 'app/templates/AssetImage'; -import { isSvgDataUriInUtf8Encoding, buildObjktCollectibleArtifactUri } from 'lib/images-uri'; +import { isSvgDataUriInUtf8Encoding, buildObjktCollectibleArtifactUri, buildHttpLinkFromUri } from 'lib/images-uri'; import { TokenMetadata } from 'lib/metadata'; import { EvmCollectibleMetadata } from 'lib/metadata/types'; import { ImageStacked } from 'lib/ui/ImageStacked'; @@ -103,7 +105,9 @@ interface EvmCollectiblePageImageProps { } export const EvmCollectiblePageImage = memo(({ metadata, className }) => { - const sources = useMemo(() => (metadata.image ? [metadata.image] : []), [metadata.image]); + const { image } = metadata; + + const sources = useMemo(() => [buildHttpLinkFromUri(image)].filter(isString), [image]); return ( (({ accountPkh, evmChainId, assetSlug, metadata }) => { - const rawBalance = useRawEvmAssetBalanceSelector(accountPkh, evmChainId, assetSlug); + const chain = useEvmChainByChainId(evmChainId); + const { value: balance } = useEvmAssetBalance(assetSlug, accountPkh, chain!); if (!metadata) return null; @@ -117,7 +119,7 @@ export const EvmPropertiesItems = memo(({ accountPkh, e <>
Owned
- {rawBalance ?? '-'} + {balance?.toFixed() ?? '-'}
diff --git a/src/app/pages/Collectibles/CollectiblePage/index.tsx b/src/app/pages/Collectibles/CollectiblePage/index.tsx index 8c8f51b518..b5b7bc1e75 100644 --- a/src/app/pages/Collectibles/CollectiblePage/index.tsx +++ b/src/app/pages/Collectibles/CollectiblePage/index.tsx @@ -87,6 +87,11 @@ const EvmCollectiblePage = memo(({ evmChainId, assetSlu return tab ?? tabs[0]!; }, [tabs, tabNameInUrl]); + const onSendButtonClick = useCallback( + () => navigate(buildSendPagePath(TempleChainKind.EVM, String(evmChainId), assetSlug)), + [evmChainId, assetSlug] + ); + return ( (({ evmChainId, assetSlu
)} + + + +
diff --git a/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx b/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx index 051a12ec44..4e886df325 100644 --- a/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx +++ b/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx @@ -10,7 +10,6 @@ import { ReactComponent as DeleteIcon } from 'app/icons/base/delete.svg'; import { dispatch } from 'app/store'; import { setEvmCollectibleStatusAction } from 'app/store/evm/assets/actions'; import { useStoredEvmCollectibleSelector } from 'app/store/evm/assets/selectors'; -import { useRawEvmAssetBalanceSelector } from 'app/store/evm/balances/selectors'; import { useEvmCollectibleMetadataSelector } from 'app/store/evm/collectibles-metadata/selectors'; import { setTezosCollectibleStatusAction } from 'app/store/tezos/assets/actions'; import { useStoredTezosCollectibleSelector } from 'app/store/tezos/assets/selectors'; @@ -24,11 +23,13 @@ import { DeleteAssetModal } from 'app/templates/remove-asset-modal/delete-asset- import { setAnotherSelector, setTestID } from 'lib/analytics'; import { objktCurrencies } from 'lib/apis/objkt'; import { getAssetStatus } from 'lib/assets/hooks/utils'; +import { useEvmAssetBalance } from 'lib/balances/hooks'; import { T } from 'lib/i18n'; import { getTokenName } from 'lib/metadata'; import { getCollectibleName, getCollectionName } from 'lib/metadata/utils'; import { atomsToTokens } from 'lib/temple/helpers'; import { useBooleanState } from 'lib/ui/hooks'; +import { ZERO } from 'lib/utils/numbers'; import { Link } from 'lib/woozie'; import { useEvmChainByChainId, useTezosChainByChainId } from 'temple/front/chains'; import { TempleChainKind } from 'temple/types'; @@ -269,12 +270,13 @@ interface EvmCollectibleItemProps { export const EvmCollectibleItem = memo( ({ assetSlug, evmChainId, accountPkh, showDetails = false, manageActive = false, hideWithoutMeta }) => { const metadata = useEvmCollectibleMetadataSelector(evmChainId, assetSlug); - const balanceAtomic = useRawEvmAssetBalanceSelector(accountPkh, evmChainId, assetSlug); - const balance = balanceAtomic ?? '0'; + const chain = useEvmChainByChainId(evmChainId); + const { value: balance = ZERO } = useEvmAssetBalance(assetSlug, accountPkh, chain!); + const balanceBeforeTruncate = balance.toString(); const storedToken = useStoredEvmCollectibleSelector(accountPkh, evmChainId, assetSlug); - const checked = getAssetStatus(balance, storedToken?.status) === 'enabled'; + const checked = getAssetStatus(balanceBeforeTruncate, storedToken?.status) === 'enabled'; const [deleteModalOpened, setDeleteModalOpened, setDeleteModalClosed] = useBooleanState(false); @@ -304,7 +306,10 @@ export const EvmCollectibleItem = memo( [checked, assetSlug, evmChainId, accountPkh] ); - const truncatedBalance = useMemo(() => (balance.length > 6 ? `${balance.slice(0, 6)}...` : balance), [balance]); + const truncatedBalance = useMemo( + () => (balanceBeforeTruncate.length > 6 ? `${balanceBeforeTruncate.slice(0, 6)}...` : balanceBeforeTruncate), + [balanceBeforeTruncate] + ); const network = useEvmChainByChainId(evmChainId); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx index 8b2185ee36..fcaa51e28b 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx @@ -34,7 +34,7 @@ import { t, T } from 'lib/i18n'; import { isCollectible, TokenMetadata } from 'lib/metadata'; import { fetchOneTokenMetadata } from 'lib/metadata/fetch'; import { TokenMetadataNotFoundError } from 'lib/metadata/on-chain'; -import { EvmTokenMetadata } from 'lib/metadata/types'; +import { EvmCollectibleMetadata, EvmTokenMetadata } from 'lib/metadata/types'; import { loadContract } from 'lib/temple/contract'; import { useSafeState, useUpdatableRef } from 'lib/ui/hooks'; import { navigate } from 'lib/woozie'; @@ -69,11 +69,14 @@ interface RequiredTokenMetadataResponse extends TokenMetadataResponse { symbol: string; } -interface RequiredEvmTokenMetadata extends EvmTokenMetadata { +interface RequiredMetadata { name: string; symbol: string; } +type RequiredEvmTokenMetadata = EvmTokenMetadata & RequiredMetadata; +type RequiredEvmCollectibleMetadata = EvmCollectibleMetadata & RequiredMetadata; + interface FormData { address: string; id?: string; @@ -125,7 +128,7 @@ export const AddTokenForm = memo( const attemptRef = useRef(0); const tezMetadataRef = useRef(); - const evmMetadataRef = useRef(); + const evmMetadataRef = useRef(); const loadMetadataPure = useCallback(async () => { if (!formValid) return; @@ -173,10 +176,10 @@ export const AddTokenForm = memo( ? fetchEvmCollectibleMetadataFromChain : fetchEvmTokenMetadataFromChain)(selectedNetwork, tokenSlug); - if (!metadata || !metadata.name || !metadata.symbol) + if (!metadata || !hasRequiredMetadata(metadata)) throw new TokenMetadataNotFoundError('Failed to load token metadata'); - evmMetadataRef.current = metadata as RequiredEvmTokenMetadata; + evmMetadataRef.current = metadata; stateToSet = { bottomSectionVisible: true }; } @@ -282,14 +285,16 @@ export const AddTokenForm = memo( dispatch( putEvmCollectiblesMetadataAction({ chainId: selectedNetwork.chainId, - records: { [tokenSlug]: { ...evmMetadataRef.current, tokenId: id } } + records: { + [tokenSlug]: { ...(evmMetadataRef.current as RequiredEvmCollectibleMetadata), tokenId: id } + } }) ); else dispatch( putEvmTokensMetadataAction({ chainId: selectedNetwork.chainId, - records: { [tokenSlug]: evmMetadataRef.current } + records: { [tokenSlug]: evmMetadataRef.current as RequiredEvmTokenMetadata } }) ); } @@ -419,6 +424,10 @@ export const AddTokenForm = memo( } ); +const hasRequiredMetadata = ( + metadata: EvmTokenMetadata | EvmCollectibleMetadata +): metadata is RequiredEvmCollectibleMetadata | RequiredEvmTokenMetadata => Boolean(metadata.name && metadata.symbol); + const errorHandler = (err: any, contractAddress: string) => { if (err instanceof ContractNotFoundError) { toastError(t('referredByTokenContractNotFound', contractAddress)); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/EvmChainTokensTab.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/EvmChainTokensTab.tsx index ac5d2d2acc..b8f6039dd7 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/EvmChainTokensTab.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/EvmChainTokensTab.tsx @@ -67,13 +67,14 @@ const TabContentWithManageActive: FC = ({ publicKeyHash, networ hideZeroBalance ); + const storedSlugs = useMemo( + () => tokens.filter(({ status }) => status !== 'removed').map(({ slug }) => slug), + [tokens] + ); + const allStoredSlugsSorted = useMemoWithCompare( - () => - tokens - .filter(({ status }) => status !== 'removed') - .map(({ slug }) => slug) - .sort(tokensSortPredicate), - [tokens, tokensSortPredicate] + () => storedSlugs.sort(tokensSortPredicate), + [storedSlugs, tokensSortPredicate] ); const allSlugsSorted = usePreservedOrderSlugsToManage(enabledSlugsSorted, allStoredSlugsSorted); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/EvmTokensTab.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/EvmTokensTab.tsx index 94595960d7..f948a681fe 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/EvmTokensTab.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/EvmTokensTab.tsx @@ -60,13 +60,17 @@ const TabContentWithManageActive: FC = ({ publicKeyHash }) => { hideZeroBalance ); - const allChainsSlugsSorted = useMemoWithCompare( + const allChainsSlugs = useMemo( () => tokens .filter(({ status }) => status !== 'removed') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.EVM, chainId, slug)) - .sort(tokensSortPredicate), - [tokens, tokensSortPredicate] + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.EVM, chainId, slug)), + [tokens] + ); + + const allChainsSlugsSorted = useMemoWithCompare( + () => allChainsSlugs.sort(tokensSortPredicate), + [allChainsSlugs, tokensSortPredicate] ); const allSlugsSorted = usePreservedOrderSlugsToManage(enabledChainSlugsSorted, allChainsSlugsSorted); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx index 4c0c3a61f7..0e1c77ed5d 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/ListItem.tsx @@ -9,7 +9,7 @@ import { setEvmTokenStatusAction } from 'app/store/evm/assets/actions'; import { useStoredEvmTokenSelector } from 'app/store/evm/assets/selectors'; import { setTezosTokenStatusAction } from 'app/store/tezos/assets/actions'; import { useStoredTezosTokenSelector } from 'app/store/tezos/assets/selectors'; -import { EvmTokenIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; +import { EvmAssetIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; import { DeleteAssetModal } from 'app/templates/remove-asset-modal/delete-asset-modal'; import { setAnotherSelector } from 'lib/analytics'; import { EVM_TOKEN_SLUG, TEZ_TOKEN_SLUG } from 'lib/assets/defaults'; @@ -237,7 +237,7 @@ export const EvmListItem = memo( return ( <>
- +
@@ -274,7 +274,7 @@ export const EvmListItem = memo( testIDProperties={{ key: assetSlug }} {...setAnotherSelector('name', assetName)} > - +
diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/MultiChainTokensTab.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/MultiChainTokensTab.tsx index 357d4e679c..e8fc3e67c7 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/MultiChainTokensTab.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/MultiChainTokensTab.tsx @@ -68,18 +68,23 @@ const TabContentWithManageActive: FC = ({ accountTezAddress, accountEvmAd hideZeroBalance ); - const otherChainSlugsSorted = useMemoWithCompare(() => { - const tokensChainsSlugs = [ - ...tezTokens + const tokensChainsSlugs = useMemo( + () => + tezTokens .filter(({ status }) => status !== 'removed') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)), - ...evmTokens - .filter(({ status }) => status !== 'removed') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.EVM, chainId, slug)) - ]; + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)) + .concat( + evmTokens + .filter(({ status }) => status !== 'removed') + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.EVM, chainId, slug)) + ), + [tezTokens, evmTokens] + ); - return tokensChainsSlugs.sort(tokensSortPredicate); - }, [tokensSortPredicate, tezTokens, evmTokens]); + const otherChainSlugsSorted = useMemoWithCompare( + () => tokensChainsSlugs.sort(tokensSortPredicate), + [tokensChainsSlugs, tokensSortPredicate] + ); const allSlugsSorted = usePreservedOrderSlugsToManage(enabledChainsSlugsSorted, otherChainSlugsSorted); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/TezosChainTokensTab.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/TezosChainTokensTab.tsx index 951001546d..4627e35a74 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/TezosChainTokensTab.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/TezosChainTokensTab.tsx @@ -66,13 +66,14 @@ const TabContentWithManageActive: FC = ({ publicKeyHash, networ chainId ); + const allTokensSlugs = useMemo( + () => tokens.filter(({ status }) => status !== 'removed').map(({ slug }) => slug), + [tokens] + ); + const allTokensSlugsSorted = useMemoWithCompare( - () => - tokens - .filter(({ status }) => status !== 'removed') - .map(({ slug }) => slug) - .sort(tokensSortPredicate), - [tokens, tokensSortPredicate] + () => allTokensSlugs.sort(tokensSortPredicate), + [allTokensSlugs, tokensSortPredicate] ); const allSlugsSorted = usePreservedOrderSlugsToManage(enabledTokenSlugsSorted, allTokensSlugsSorted); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/TezosTokensTab.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/TezosTokensTab.tsx index 7ae7dea12a..24155df42f 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/TezosTokensTab.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/TezosTokensTab.tsx @@ -61,16 +61,20 @@ const TabContentWithManageActive: FC = ({ publicKeyHash }) => { hideZeroBalance ); - const allChainsSlugsSorted = useMemoWithCompare( + const allTezosTokensSlugs = useMemo( () => tokens .filter(({ status }) => status !== 'removed') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)) - .sort(tokensSortPredicate), - [tokens, tokensSortPredicate] + .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)), + [tokens] ); - const allSlugsSorted = usePreservedOrderSlugsToManage(enabledChainsSlugsSorted, allChainsSlugsSorted); + const allTezosTokensSlugsSorted = useMemoWithCompare( + () => allTezosTokensSlugs.sort(tokensSortPredicate), + [allTezosTokensSlugs, tokensSortPredicate] + ); + + const allSlugsSorted = usePreservedOrderSlugsToManage(enabledChainsSlugsSorted, allTezosTokensSlugsSorted); return ( { + let value = BigInt(0); + let data: HexString | undefined; + + const atomicAmount = amount ? parseUnits(amount, assetMetadata.decimals ?? 0) : BigInt(1); + switch (assetMetadata.standard) { + case EvmAssetStandard.NATIVE: + value = amount ? parseEther(amount) : BigInt(1); + break; + case EvmAssetStandard.ERC20: + data = encodeFunctionData({ + abi: erc20Abi, + args: [receiver, atomicAmount], + functionName: 'transfer' + }); + break; + case EvmAssetStandard.ERC721: + data = encodeFunctionData({ + abi: erc721Abi, + args: [sender, receiver, BigInt(assetMetadata.tokenId)], + functionName: 'safeTransferFrom' + }); + break; + case EvmAssetStandard.ERC1155: + data = encodeFunctionData({ + abi: erc1155Abi, + args: [sender, receiver, BigInt(assetMetadata.tokenId), atomicAmount, '0x'], + functionName: 'safeTransferFrom' + }); + break; + default: + throw new Error('Unsupported EVM token standard'); + } + + const to = isEvmNativeTokenSlug(assetMetadata.address) ? receiver : assetMetadata?.address ?? receiver; + + return { to, value, data }; +}; diff --git a/src/app/pages/Send/form/EvmForm.tsx b/src/app/pages/Send/form/EvmForm.tsx index 286f483078..da6115456c 100644 --- a/src/app/pages/Send/form/EvmForm.tsx +++ b/src/app/pages/Send/form/EvmForm.tsx @@ -6,10 +6,11 @@ import { FormProvider, useForm } from 'react-hook-form-v7'; import { formatEther, isAddress } from 'viem'; import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { useEvmCollectibleMetadataSelector } from 'app/store/evm/collectibles-metadata/selectors'; import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; import { useFormAnalytics } from 'lib/analytics'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; -import { useEvmTokenBalance } from 'lib/balances/hooks'; +import { useEvmAssetBalance } from 'lib/balances/hooks'; import { useAssetFiatCurrencyPrice } from 'lib/fiat-currency'; import { t, toLocalFixed } from 'lib/i18n'; import { getAssetSymbol } from 'lib/metadata'; @@ -41,8 +42,11 @@ export const EvmForm: FC = ({ chainId, assetSlug, onSelectAssetClick, onR if (!account || !network) throw new DeadEndBoundaryError(); - const storedMetadata = useEvmTokenMetadataSelector(network.chainId, assetSlug); - const assetMetadata = isEvmNativeTokenSlug(assetSlug) ? network?.currency : storedMetadata; + const storedTokenMetadata = useEvmTokenMetadataSelector(network.chainId, assetSlug); + const storedCollectibleMetadata = useEvmCollectibleMetadataSelector(network.chainId, assetSlug); + const assetMetadata = isEvmNativeTokenSlug(assetSlug) + ? network?.currency + : storedTokenMetadata ?? storedCollectibleMetadata; if (!assetMetadata) throw new Error('Metadata not found'); @@ -53,8 +57,8 @@ export const EvmForm: FC = ({ chainId, assetSlug, onSelectAssetClick, onR const formAnalytics = useFormAnalytics('SendForm'); - const { value: balance = ZERO } = useEvmTokenBalance(assetSlug, accountPkh, network); - const { value: ethBalance = ZERO } = useEvmTokenBalance(EVM_TOKEN_SLUG, accountPkh, network); + const { value: balance = ZERO } = useEvmAssetBalance(assetSlug, accountPkh, network); + const { value: ethBalance = ZERO } = useEvmAssetBalance(EVM_TOKEN_SLUG, accountPkh, network); const [shouldUseFiat, setShouldUseFiat] = useSafeState(false); diff --git a/src/app/pages/Send/form/SelectAssetButton.tsx b/src/app/pages/Send/form/SelectAssetButton.tsx index bc04a78786..e60519746f 100644 --- a/src/app/pages/Send/form/SelectAssetButton.tsx +++ b/src/app/pages/Send/form/SelectAssetButton.tsx @@ -5,13 +5,11 @@ import clsx from 'clsx'; import { Button, IconBase } from 'app/atoms'; import Money from 'app/atoms/Money'; import { ReactComponent as CompactDown } from 'app/icons/base/compact_down.svg'; -import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; -import { EvmTokenIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; +import { EvmAssetIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; import { EvmBalance, TezosBalance } from 'app/templates/Balance'; import { setAnotherSelector, setTestID, TestIDProperty } from 'lib/analytics'; import { T } from 'lib/i18n'; -import { getAssetSymbol, useTezosAssetMetadata } from 'lib/metadata'; -import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; +import { getAssetSymbol, useEvmAssetMetadata, useTezosAssetMetadata } from 'lib/metadata'; import { EvmChain, OneOfChains, TezosChain } from 'temple/front'; import { TempleChainKind } from 'temple/types'; @@ -86,18 +84,16 @@ interface EvmContentProps { } const EvmContent = memo(({ network, accountPkh, assetSlug }) => { - const tokenMetadata = useEvmTokenMetadataSelector(network.chainId, assetSlug); - - const metadata = isEvmNativeTokenSlug(assetSlug) ? network.currency : tokenMetadata; + const assetMetadata = useEvmAssetMetadata(assetSlug, network.chainId); return ( <> - + {balance => (
- {getAssetSymbol(metadata)} + {getAssetSymbol(assetMetadata)} {balance} diff --git a/src/app/pages/Send/hooks/use-evm-estimation-data.ts b/src/app/pages/Send/hooks/use-evm-estimation-data.ts index 8b0a301c89..1aedfc2247 100644 --- a/src/app/pages/Send/hooks/use-evm-estimation-data.ts +++ b/src/app/pages/Send/hooks/use-evm-estimation-data.ts @@ -1,26 +1,37 @@ import { useCallback } from 'react'; import BigNumber from 'bignumber.js'; -import { pick } from 'lodash'; -import { parseEther } from 'viem'; +import { FeeValuesEIP1559, FeeValuesLegacy, TransactionRequest } from 'viem'; import { toastError } from 'app/toaster'; import { isNativeTokenAddress } from 'lib/apis/temple/endpoints/evm/api.utils'; +import { useEvmAssetMetadata } from 'lib/metadata'; import { useTypedSWR } from 'lib/swr'; import { getReadOnlyEvmForNetwork } from 'temple/evm'; import { EvmChain } from 'temple/front'; +import { buildBasicEvmSendParams } from '../build-basic-evm-send-params'; + import { checkZeroBalance } from './utils'; -export interface EvmEstimationData { +interface EvmEstimationDataBase { estimatedFee: bigint; + data: HexString; + type: NonNullable; gas: bigint; - maxFeePerGas: bigint; - maxPriorityFeePerGas: bigint; - data: string; nonce: number; } +interface LegacyEvmEstimationData extends EvmEstimationDataBase, FeeValuesLegacy { + type: 'legacy' | 'eip2930'; +} + +interface Eip1559EvmEstimationData extends EvmEstimationDataBase, FeeValuesEIP1559 { + type: 'eip1559' | 'eip7702'; +} + +export type EvmEstimationData = LegacyEvmEstimationData | Eip1559EvmEstimationData; + export const useEvmEstimationData = ( to: HexString, assetSlug: string, @@ -31,8 +42,14 @@ export const useEvmEstimationData = ( toFilled?: boolean, amount?: string ) => { + const assetMetadata = useEvmAssetMetadata(assetSlug, network.chainId); + const estimate = useCallback(async (): Promise => { try { + if (!assetMetadata) { + throw new Error('Asset metadata not found'); + } + const isNativeToken = isNativeTokenAddress(network.chainId, assetSlug); checkZeroBalance(balance, ethBalance, isNativeToken); @@ -40,23 +57,42 @@ export const useEvmEstimationData = ( const publicClient = getReadOnlyEvmForNetwork(network); const transaction = await publicClient.prepareTransactionRequest({ - to, - account: accountPkh, - value: amount ? parseEther(amount) : BigInt(1) + ...buildBasicEvmSendParams(accountPkh, to, assetMetadata, amount), + account: accountPkh }); - return { - estimatedFee: transaction.gas * transaction.maxFeePerGas, - data: transaction.data || '0x', - ...pick(transaction, ['gas', 'maxFeePerGas', 'maxPriorityFeePerGas', 'nonce']) - }; + switch (transaction.type) { + case 'legacy': + case 'eip2930': + return { + estimatedFee: transaction.gas * transaction.gasPrice, + data: transaction.data || '0x', + type: transaction.type, + gas: transaction.gas, + gasPrice: transaction.gasPrice, + nonce: transaction.nonce + }; + case 'eip1559': + case 'eip7702': + return { + estimatedFee: transaction.gas * transaction.maxFeePerGas, + data: transaction.data || '0x', + type: transaction.type, + gas: transaction.gas, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + nonce: transaction.nonce + }; + default: + throw new Error('Unsupported transaction type'); + } } catch (err: any) { console.warn(err); toastError(err.details || err.message); return undefined; } - }, [network, assetSlug, balance, ethBalance, to, accountPkh, amount]); + }, [network, assetSlug, balance, ethBalance, accountPkh, to, amount, assetMetadata]); return useTypedSWR( toFilled ? ['evm-estimation-data', network.chainId, assetSlug, accountPkh, to, amount] : null, diff --git a/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx b/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx index db3619b6fe..ebdc052181 100644 --- a/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx +++ b/src/app/pages/Send/modals/ConfirmSend/EvmContent.tsx @@ -1,18 +1,22 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { omit } from 'lodash'; +import { omit, transform } from 'lodash'; import { FormProvider, useForm } from 'react-hook-form-v7'; import { useDebounce } from 'use-debounce'; -import { formatEther, parseEther, serializeTransaction } from 'viem'; +import { FeeValuesEIP1559, FeeValuesLegacy, formatEther, parseEther, serializeTransaction } from 'viem'; import { CLOSE_ANIMATION_TIMEOUT } from 'app/atoms/PageModal'; import { EvmReviewData } from 'app/pages/Send/form/interfaces'; import { useEvmEstimationData } from 'app/pages/Send/hooks/use-evm-estimation-data'; import { toastError, toastSuccess } from 'app/toaster'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; -import { useEvmTokenBalance } from 'lib/balances/hooks'; +import { useEvmAssetBalance } from 'lib/balances/hooks'; +import { useEvmAssetMetadata } from 'lib/metadata'; import { useTempleClient } from 'lib/temple/front'; import { ZERO } from 'lib/utils/numbers'; +import { EvmTxParams } from 'temple/evm/types'; + +import { buildBasicEvmSendParams } from '../../build-basic-evm-send-params'; import { BaseContent, Tab } from './BaseContent'; import { DEFAULT_INPUT_DEBOUNCE } from './contants'; @@ -32,8 +36,9 @@ export const EvmContent: FC = ({ data, onClose }) => { const { sendEvmTransaction } = useTempleClient(); - const { value: balance = ZERO } = useEvmTokenBalance(assetSlug, accountPkh, network); - const { value: ethBalance = ZERO } = useEvmTokenBalance(EVM_TOKEN_SLUG, accountPkh, network); + const { value: balance = ZERO } = useEvmAssetBalance(assetSlug, accountPkh, network); + const { value: ethBalance = ZERO } = useEvmAssetBalance(EVM_TOKEN_SLUG, accountPkh, network); + const assetMetadata = useEvmAssetMetadata(assetSlug, network.chainId); const form = useForm({ mode: 'onChange' }); const { watch, formState, setValue } = form; @@ -70,29 +75,55 @@ export const EvmContent: FC = ({ data, onClose }) => { if (gasPriceValue && selectedFeeOption) setSelectedFeeOption(null); }, [gasPriceValue, selectedFeeOption]); + const getFeesPerGas = useCallback( + (rawGasPrice: string): FeeValuesLegacy | FeeValuesEIP1559 | null => { + if (!feeOptions) { + return null; + } + + const parsedGasPrice = rawGasPrice ? parseEther(rawGasPrice, 'gwei') : null; + const feeOptionValues = feeOptions.gasPrice[selectedFeeOption ?? 'mid']; + + return transform( + feeOptionValues, + (acc, value, key) => { + if (typeof value === 'bigint' && parsedGasPrice) { + // @ts-expect-error + acc[key] = parsedGasPrice; + } + + return acc; + }, + { ...feeOptionValues } + ); + }, + [feeOptions, selectedFeeOption] + ); + const rawTransaction = useMemo(() => { - if (!estimationData || !feeOptions) return null; + const feesPerGas = getFeesPerGas(debouncedGasPrice); - const parsedGasPrice = debouncedGasPrice ? parseEther(debouncedGasPrice, 'gwei') : null; + if (!estimationData || !feesPerGas || !assetMetadata) return null; + + const basicParams = buildBasicEvmSendParams(accountPkh, to as HexString, assetMetadata, amount); return serializeTransaction({ chainId: network.chainId, gas: debouncedGasLimit ? BigInt(debouncedGasLimit) : estimationData.gas, nonce: debouncedNonce ? Number(debouncedNonce) : estimationData.nonce, - to: to as HexString, - value: parseEther(amount), - ...(selectedFeeOption ? feeOptions.gasPrice[selectedFeeOption] : feeOptions.gasPrice.mid), - ...(parsedGasPrice ? { maxFeePerGas: parsedGasPrice, maxPriorityFeePerGas: parsedGasPrice } : {}) + ...basicParams, + ...feesPerGas }); }, [ + accountPkh, amount, + assetMetadata, debouncedGasLimit, debouncedGasPrice, debouncedNonce, estimationData, - feeOptions, + getFeesPerGas, network.chainId, - selectedFeeOption, to ]); @@ -124,24 +155,34 @@ export const EvmContent: FC = ({ data, onClose }) => { async ({ gasPrice, gasLimit, nonce }: EvmTxParamsFormData) => { if (formState.isSubmitting) return; - if (!estimationData || !feeOptions) { + const feesPerGas = getFeesPerGas(gasPrice); + + if (!assetMetadata) { + throw new Error('Asset metadata not found.'); + } + + if (!estimationData || !feesPerGas) { toastError('Failed to estimate transaction.'); return; } try { - const parsedGasPrice = gasPrice ? parseEther(gasPrice, 'gwei') : null; + const { value, to: txDestination } = buildBasicEvmSendParams( + accountPkh, + to as HexString, + assetMetadata, + amount + ); const txHash = await sendEvmTransaction(accountPkh, network, { - to: to as HexString, - value: parseEther(amount), + to: txDestination, + value, ...omit(estimationData, 'estimatedFee'), - ...(selectedFeeOption ? feeOptions.gasPrice[selectedFeeOption] : feeOptions.gasPrice.mid), - ...(parsedGasPrice ? { maxFeePerGas: parsedGasPrice, maxPriorityFeePerGas: parsedGasPrice } : {}), + ...feesPerGas, ...(gasLimit ? { gas: BigInt(gasLimit) } : {}), ...(nonce ? { nonce: Number(nonce) } : {}) - }); + } as EvmTxParams); onConfirm(); onClose(); @@ -157,13 +198,13 @@ export const EvmContent: FC = ({ data, onClose }) => { [ accountPkh, amount, + assetMetadata, estimationData, - feeOptions, formState.isSubmitting, + getFeesPerGas, network, onClose, onConfirm, - selectedFeeOption, sendEvmTransaction, to ] diff --git a/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx b/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx index 3db06581b1..e530c67fcc 100644 --- a/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx +++ b/src/app/pages/Send/modals/ConfirmSend/components/Header.tsx @@ -2,7 +2,7 @@ import React, { memo } from 'react'; import BigNumber from 'bignumber.js'; -import { EvmTokenIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; +import { EvmAssetIconWithNetwork, TezosTokenIconWithNetwork } from 'app/templates/AssetIcon'; import InFiat from 'app/templates/InFiat'; import { OneOfChains } from 'temple/front'; import { TempleChainKind } from 'temple/types'; @@ -19,7 +19,7 @@ export const Header = memo(({ network, assetSlug, amount }) => { return (
{isEvm ? ( - + ) : ( )} diff --git a/src/app/pages/Send/modals/ConfirmSend/context.ts b/src/app/pages/Send/modals/ConfirmSend/context.ts index bdbffe1f93..f2e2f28e11 100644 --- a/src/app/pages/Send/modals/ConfirmSend/context.ts +++ b/src/app/pages/Send/modals/ConfirmSend/context.ts @@ -7,9 +7,9 @@ import { TezosEstimationData } from 'app/pages/Send/hooks/use-tezos-estimation-d import { EvmFeeOptions } from './types'; -interface ExtendedEvmEstimationData extends EvmEstimationData { +type ExtendedEvmEstimationData = EvmEstimationData & { feeOptions: EvmFeeOptions; -} +}; export const [EvmEstimationDataProvider, useEvmEstimationDataState] = constate(() => { const [data, setData] = useState(null); diff --git a/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts b/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts index 49604a9c70..cf20c47db7 100644 --- a/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts +++ b/src/app/pages/Send/modals/ConfirmSend/hooks/use-evm-fee-options.ts @@ -1,36 +1,73 @@ -import { formatEther } from 'viem'; +import { pick, transform } from 'lodash'; +import { FeeValues, formatEther } from 'viem'; import { EvmEstimationData } from 'app/pages/Send/hooks/use-evm-estimation-data'; import { useMemoWithCompare } from 'lib/ui/hooks'; import { getGasPriceStep } from 'temple/evm/utils'; -import { DisplayedFeeOptions, EvmFeeOptions } from '../types'; +import { EvmFeeOptions, FeeOptionLabel } from '../types'; -export const useEvmFeeOptions = (customGasLimit: string, estimationData?: EvmEstimationData): EvmFeeOptions | null => - useMemoWithCompare(() => { - if (!estimationData) return null; +const generateOptions = ( + type: U, + estimatedValues: T, + gas: bigint, + getDisplayedGasPrice: (fees: T) => bigint +) => { + const stepsQuotients = { slow: -1, mid: 0, fast: 1 }; + + const gasPrice = transform, Record>( + stepsQuotients, + (optionsAcc, stepsQuotient, key) => { + optionsAcc[key] = transform( + estimatedValues, + (optionAcc, value, key) => { + if (typeof value === 'bigint') { + const step = getGasPriceStep(value); + optionAcc[key] = (value + step * BigInt(stepsQuotient)) as typeof value; + } + + return optionAcc; + }, + { ...estimatedValues } + ); - const { maxFeePerGas, gas: estimatedGasLimit, maxPriorityFeePerGas } = estimationData; + return optionsAcc; + }, + { slow: estimatedValues, mid: estimatedValues, fast: estimatedValues } + ); - const gas = customGasLimit ? BigInt(customGasLimit) : estimatedGasLimit; + const displayed = transform, Record>( + gasPrice, + (acc, fees, key) => { + acc[key] = formatEther(gas * getDisplayedGasPrice(fees)); - const maxFeeStep = getGasPriceStep(maxFeePerGas); - const maxPriorityFeeStep = getGasPriceStep(maxPriorityFeePerGas); + return acc; + }, + { slow: '', mid: '', fast: '' } + ); - const gasPrice = { - slow: { - maxFeePerGas: maxFeePerGas - maxFeeStep, - maxPriorityFeePerGas: maxPriorityFeePerGas - maxPriorityFeeStep - }, - mid: { maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas }, - fast: { maxFeePerGas: maxFeePerGas + maxFeeStep, maxPriorityFeePerGas: maxPriorityFeePerGas + maxPriorityFeeStep } - }; + return { type, displayed, gasPrice }; +}; + +export const useEvmFeeOptions = (customGasLimit: string, estimationData?: EvmEstimationData): EvmFeeOptions | null => + useMemoWithCompare(() => { + if (!estimationData) return null; - const displayed: DisplayedFeeOptions = { - slow: formatEther(gas * gasPrice.slow.maxFeePerGas), - mid: formatEther(gas * gasPrice.mid.maxFeePerGas), - fast: formatEther(gas * gasPrice.fast.maxFeePerGas) - }; + const gas = customGasLimit ? BigInt(customGasLimit) : estimationData.gas; - return { displayed, gasPrice }; + switch (estimationData.type) { + case 'legacy': + case 'eip2930': + return generateOptions('legacy' as const, pick(estimationData, 'gasPrice'), gas, fees => fees.gasPrice); + case 'eip1559': + case 'eip7702': + return generateOptions( + 'eip1559' as const, + pick(estimationData, 'maxFeePerGas', 'maxPriorityFeePerGas'), + gas, + fees => fees.maxFeePerGas + ); + default: + throw new Error('Unsupported transaction type'); + } }, [estimationData, customGasLimit]); diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx index 3dc66daaef..3177d2c480 100644 --- a/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/Fee.tsx @@ -57,7 +57,12 @@ const EvmContent: FC = ({ selectedOption, onOptionSelect }) => { const gasPriceFallback = useMemo(() => { if (!data || !selectedOption) return ''; - return formatEther(data.feeOptions.gasPrice[selectedOption].maxFeePerGas, 'gwei'); + return formatEther( + data.feeOptions.type === 'legacy' + ? data.feeOptions.gasPrice[selectedOption].gasPrice + : data.feeOptions.gasPrice[selectedOption].maxFeePerGas, + 'gwei' + ); }, [data, selectedOption]); return ( diff --git a/src/app/pages/Send/modals/ConfirmSend/types.ts b/src/app/pages/Send/modals/ConfirmSend/types.ts index a8605aad4e..e22f8f636f 100644 --- a/src/app/pages/Send/modals/ConfirmSend/types.ts +++ b/src/app/pages/Send/modals/ConfirmSend/types.ts @@ -1,3 +1,5 @@ +import { FeeValues, FeeValuesEIP1559, FeeValuesLegacy } from 'viem'; + export interface EvmTxParamsFormData { gasPrice: string; gasLimit: string; @@ -23,16 +25,22 @@ export interface DisplayedFeeOptions { fast: string; } -interface EvmFeeOption { - maxFeePerGas: bigint; - maxPriorityFeePerGas: bigint; -} +type EvmFeeOptionType = 'legacy' | 'eip1559'; -export interface EvmFeeOptions { +interface EvmFeeOptionsBase { + type: EvmFeeOptionType; displayed: DisplayedFeeOptions; - gasPrice: { - slow: EvmFeeOption; - mid: EvmFeeOption; - fast: EvmFeeOption; - }; + gasPrice: Record; +} + +interface EvmLegacyFeeOptions extends EvmFeeOptionsBase { + type: 'legacy'; + gasPrice: Record; } + +interface EvmEip1559FeeOptions extends EvmFeeOptionsBase { + type: 'eip1559'; + gasPrice: Record; +} + +export type EvmFeeOptions = EvmLegacyFeeOptions | EvmEip1559FeeOptions; diff --git a/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx index 94d0b909de..484cc52042 100644 --- a/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx +++ b/src/app/pages/Send/modals/SelectAsset/EvmAssetsList.tsx @@ -5,6 +5,7 @@ import { getSlugWithChainId } from 'app/hooks/listing-logic/utils'; import { EvmListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { useEnabledEvmAccountTokenSlugs } from 'lib/assets/hooks/tokens'; import { searchEvmTokensWithNoMeta } from 'lib/assets/search.utils'; import { useEvmAccountTokensSortPredicate } from 'lib/assets/use-sorting'; import { parseChainAssetSlug, toChainAssetSlug } from 'lib/assets/utils'; @@ -21,16 +22,20 @@ interface Props { export const EvmAssetsList = memo(({ publicKeyHash, searchValue, onAssetSelect }) => { const enabledChains = useEnabledEvmChains(); const tokensSortPredicate = useEvmAccountTokensSortPredicate(publicKeyHash); + const tokensSlugs = useEnabledEvmAccountTokenSlugs(publicKeyHash); - const gasSlugs = useMemo( - () => enabledChains.map(chain => toChainAssetSlug(TempleChainKind.EVM, chain.chainId, EVM_TOKEN_SLUG)), - [enabledChains] + const enabledEvmAssetsSlugs = useMemo( + () => + enabledChains + .map(chain => toChainAssetSlug(TempleChainKind.EVM, chain.chainId, EVM_TOKEN_SLUG)) + .concat(tokensSlugs), + [enabledChains, tokensSlugs] ); - // TODO: Show all tokens - const enabledChainSlugsSorted = useMemoWithCompare(() => { - return gasSlugs.sort(tokensSortPredicate); - }, [gasSlugs, tokensSortPredicate]); + const enabledEvmAssetsSlugsSorted = useMemoWithCompare( + () => enabledEvmAssetsSlugs.sort(tokensSortPredicate), + [enabledEvmAssetsSlugs, tokensSortPredicate] + ); const allEvmChains = useAllEvmChains(); const metadata = useEvmTokensMetadataRecordSelector(); @@ -42,8 +47,8 @@ export const EvmAssetsList = memo(({ publicKeyHash, searchValue, onAssetS ); const searchedSlugs = useMemo( - () => searchEvmTokensWithNoMeta(searchValue, enabledChainSlugsSorted, getMetadata, getSlugWithChainId), - [enabledChainSlugsSorted, getMetadata, searchValue] + () => searchEvmTokensWithNoMeta(searchValue, enabledEvmAssetsSlugsSorted, getMetadata, getSlugWithChainId), + [enabledEvmAssetsSlugsSorted, getMetadata, searchValue] ); return ( diff --git a/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx index 76e01057dd..e4650a92ce 100644 --- a/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx +++ b/src/app/pages/Send/modals/SelectAsset/EvmChainAssetsList.tsx @@ -5,7 +5,9 @@ import { DeadEndBoundaryError } from 'app/ErrorBoundary'; import { EvmListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { useEnabledEvmChainAccountTokenSlugs } from 'lib/assets/hooks'; import { searchEvmChainTokensWithNoMeta } from 'lib/assets/search.utils'; +import { useEvmAccountTokensSortPredicate } from 'lib/assets/use-sorting'; import { toChainAssetSlug } from 'lib/assets/utils'; import { useMemoWithCompare } from 'lib/ui/hooks'; import { useEvmChainByChainId } from 'temple/front/chains'; @@ -20,13 +22,21 @@ interface Props { export const EvmChainAssetsList = memo(({ chainId, publicKeyHash, searchValue, onAssetSelect }) => { const chain = useEvmChainByChainId(chainId); + const tokensSlugs = useEnabledEvmChainAccountTokenSlugs(publicKeyHash, chainId); + const tokensSortPredicate = useEvmAccountTokensSortPredicate(publicKeyHash); if (!chain) throw new DeadEndBoundaryError(); - // TODO: Show all tokens for current chain - const assetsSlugs = useMemoWithCompare(() => { - return [EVM_TOKEN_SLUG]; - }, []); + const enabledEvmChainAssetsSlugs = useMemo(() => { + const gasTokensSlugs: string[] = [EVM_TOKEN_SLUG]; + + return gasTokensSlugs.concat(tokensSlugs); + }, [tokensSlugs]); + + const enabledEvmChainAssetsSlugsSorted = useMemoWithCompare( + () => enabledEvmChainAssetsSlugs.sort(tokensSortPredicate), + [enabledEvmChainAssetsSlugs, tokensSortPredicate] + ); const metadata = useEvmTokensMetadataRecordSelector(); @@ -36,8 +46,8 @@ export const EvmChainAssetsList = memo(({ chainId, publicKeyHash, searchV ); const searchedSlugs = useMemo( - () => searchEvmChainTokensWithNoMeta(searchValue, assetsSlugs, getMetadata, s => s), - [assetsSlugs, getMetadata, searchValue] + () => searchEvmChainTokensWithNoMeta(searchValue, enabledEvmChainAssetsSlugsSorted, getMetadata, s => s), + [enabledEvmChainAssetsSlugsSorted, getMetadata, searchValue] ); return ( diff --git a/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx index a50efd586d..ed9d8d472b 100644 --- a/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx +++ b/src/app/pages/Send/modals/SelectAsset/MultiChainAssetsList.tsx @@ -5,7 +5,7 @@ import { getSlugFromChainSlug } from 'app/hooks/listing-logic/utils'; import { EvmListItem, TezosListItem } from 'app/pages/Home/OtherComponents/Tokens/components/ListItem'; import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; import { EVM_TOKEN_SLUG, TEZ_TOKEN_SLUG } from 'lib/assets/defaults'; -import { useTezosAccountTokens } from 'lib/assets/hooks/tokens'; +import { useEnabledEvmAccountTokenSlugs, useEnabledTezosAccountTokenSlugs } from 'lib/assets/hooks/tokens'; import { searchAssetsWithNoMeta } from 'lib/assets/search.utils'; import { useAccountTokensSortPredicate } from 'lib/assets/use-sorting'; import { parseChainAssetSlug, toChainAssetSlug } from 'lib/assets/utils'; @@ -23,32 +23,30 @@ interface Props { export const MultiChainAssetsList = memo( ({ accountTezAddress, accountEvmAddress, searchValue, onAssetSelect }) => { - const tezTokens = useTezosAccountTokens(accountTezAddress); + const tezTokensSlugs = useEnabledTezosAccountTokenSlugs(accountTezAddress); + const evmTokensSlugs = useEnabledEvmAccountTokenSlugs(accountEvmAddress); const enabledTezChains = useEnabledTezosChains(); const enabledEvmChains = useEnabledEvmChains(); const tokensSortPredicate = useAccountTokensSortPredicate(accountTezAddress, accountEvmAddress); - const gasChainsSlugs = useMemo( - () => [ - ...enabledTezChains.map(chain => toChainAssetSlug(TempleChainKind.Tezos, chain.chainId, TEZ_TOKEN_SLUG)), - ...enabledEvmChains.map(chain => toChainAssetSlug(TempleChainKind.EVM, chain.chainId, EVM_TOKEN_SLUG)) - ], - [enabledEvmChains, enabledTezChains] + const enabledAssetsSlugs = useMemo( + () => + enabledTezChains + .map(chain => toChainAssetSlug(TempleChainKind.Tezos, chain.chainId, TEZ_TOKEN_SLUG)) + .concat( + enabledEvmChains.map(chain => toChainAssetSlug(TempleChainKind.EVM, chain.chainId, EVM_TOKEN_SLUG)), + tezTokensSlugs, + evmTokensSlugs + ), + [enabledTezChains, enabledEvmChains, tezTokensSlugs, evmTokensSlugs] ); - // TODO: Show all tokens - const enabledChainsSlugsSorted = useMemoWithCompare(() => { - const enabledChainsSlugs = [ - ...gasChainsSlugs, - ...tezTokens - .filter(({ status }) => status === 'enabled') - .map(({ chainId, slug }) => toChainAssetSlug(TempleChainKind.Tezos, chainId, slug)) - ]; - - return enabledChainsSlugs.sort(tokensSortPredicate); - }, [tezTokens, tokensSortPredicate, gasChainsSlugs]); + const enabledAssetsSlugsSorted = useMemoWithCompare( + () => enabledAssetsSlugs.sort(tokensSortPredicate), + [enabledAssetsSlugs, tokensSortPredicate] + ); const tezosChains = useAllTezosChains(); const evmChains = useAllEvmChains(); @@ -66,13 +64,13 @@ export const MultiChainAssetsList = memo( () => searchAssetsWithNoMeta( searchValue, - enabledChainsSlugsSorted, + enabledAssetsSlugsSorted, getTezMetadata, getEvmMetadata, slug => slug, getSlugFromChainSlug ), - [enabledChainsSlugsSorted, getEvmMetadata, getTezMetadata, searchValue] + [enabledAssetsSlugsSorted, getEvmMetadata, getTezMetadata, searchValue] ); return ( diff --git a/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx b/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx index 1219d7a3d3..9962cbbf9d 100644 --- a/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx +++ b/src/app/pages/Send/modals/SelectAsset/TezosChainAssetsList.tsx @@ -30,9 +30,9 @@ export const TezosChainAssetsList = memo(({ chainId, publicKeyHash, searc const tokensSortPredicate = useTezosChainAccountTokensSortPredicate(publicKeyHash, chainId); const assetsSlugs = useMemoWithCompare(() => { - const sortedSlugs = Array.from(tokensSlugs).sort(tokensSortPredicate); + const gasTokensSlugs: string[] = [TEZ_TOKEN_SLUG]; - return [TEZ_TOKEN_SLUG, ...sortedSlugs]; + return gasTokensSlugs.concat(Array.from(tokensSlugs).sort(tokensSortPredicate)); }, [tokensSortPredicate, tokensSlugs]); const getAssetMetadata = useGetChainTokenOrGasMetadata(chainId); diff --git a/src/app/store/evm/balances/actions.ts b/src/app/store/evm/balances/actions.ts index 29912cae82..457df5b629 100644 --- a/src/app/store/evm/balances/actions.ts +++ b/src/app/store/evm/balances/actions.ts @@ -1,6 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; +import BigNumber from 'bignumber.js'; import { BalancesResponse } from 'lib/apis/temple/endpoints/evm/api.interfaces'; +import { EvmAssetStandard } from 'lib/evm/types'; +import { createActions } from 'lib/store'; +import { EvmNetworkEssentials } from 'temple/networks'; interface processLoadedEvmTokensBalancesActionPayload { publicKeyHash: HexString; @@ -8,6 +12,21 @@ interface processLoadedEvmTokensBalancesActionPayload { data: BalancesResponse; } +export interface LoadOnChainBalancePayload { + network: EvmNetworkEssentials; + assetSlug: string; + account: HexString; + assetStandard?: EvmAssetStandard; +} + +interface LoadOnChainBalanceSuccessPayload extends Omit { + balance: BigNumber; +} + export const processLoadedEvmAssetsBalancesAction = createAction( 'evm/balances/PROCESS_LOADED_ASSETS_BALANCES_ACTION' ); + +export const loadEvmBalanceOnChainActions = createActions( + 'evm/balances/LOAD_BALANCE_ON_CHAIN' +); diff --git a/src/app/store/evm/balances/epics.ts b/src/app/store/evm/balances/epics.ts new file mode 100644 index 0000000000..df8d3ad95c --- /dev/null +++ b/src/app/store/evm/balances/epics.ts @@ -0,0 +1,41 @@ +import { Epic, combineEpics } from 'redux-observable'; +import { EMPTY, catchError, forkJoin, from, merge, mergeMap, of, switchMap } from 'rxjs'; +import { ofType } from 'ts-action-operators'; + +import { RequestAlreadyPendingError } from 'lib/evm/on-chain/utils/evm-rpc-requests-executor'; + +import { setEvmBalancesLoadingState } from '../actions'; + +import { loadEvmBalanceOnChainActions } from './actions'; +import { evmOnChainBalancesRequestsExecutor } from './utils'; + +const loadEvmBalanceOnChainEpic: Epic = action$ => + action$.pipe( + ofType(loadEvmBalanceOnChainActions.submit), + mergeMap(({ payload }) => { + const { network, assetSlug, account } = payload; + + return from(evmOnChainBalancesRequestsExecutor.executeRequest(payload)).pipe( + switchMap(balance => + forkJoin([Promise.resolve(balance), evmOnChainBalancesRequestsExecutor.queueIsEmpty(network.chainId)]) + ), + switchMap(([balance, queueIsEmpty]) => { + const updateBalanceObservable = of( + loadEvmBalanceOnChainActions.success({ network, assetSlug, account, balance }) + ); + + return queueIsEmpty + ? merge( + updateBalanceObservable, + of(setEvmBalancesLoadingState({ chainId: network.chainId, isLoading: false })) + ) + : updateBalanceObservable; + }), + catchError(error => + error instanceof RequestAlreadyPendingError ? EMPTY : of(loadEvmBalanceOnChainActions.fail(error.message)) + ) + ); + }) + ); + +export const evmBalancesEpics = combineEpics(loadEvmBalanceOnChainEpic); diff --git a/src/app/store/evm/balances/reducers.ts b/src/app/store/evm/balances/reducers.ts index ff53f8f7f3..3a9ffd5dc2 100644 --- a/src/app/store/evm/balances/reducers.ts +++ b/src/app/store/evm/balances/reducers.ts @@ -1,20 +1,23 @@ import { createReducer } from '@reduxjs/toolkit'; -import { processLoadedEvmAssetsBalancesAction } from './actions'; +import { loadEvmBalanceOnChainActions, processLoadedEvmAssetsBalancesAction } from './actions'; import { EvmBalancesInitialState, EvmBalancesStateInterface } from './state'; import { getTokenSlugBalanceRecord } from './utils'; export const evmBalancesReducer = createReducer(EvmBalancesInitialState, builder => { builder.addCase(processLoadedEvmAssetsBalancesAction, ({ balancesAtomic }, { payload }) => { const { publicKeyHash, chainId, data } = payload; - if (!balancesAtomic[publicKeyHash]) balancesAtomic[publicKeyHash] = {}; - const accountBalances = balancesAtomic[publicKeyHash]; - accountBalances[chainId] = Object.assign( - {}, - accountBalances[chainId] ?? {}, - getTokenSlugBalanceRecord(data.items, chainId) - ); + balancesAtomic[publicKeyHash][chainId] = getTokenSlugBalanceRecord(data.items, chainId); + }); + + builder.addCase(loadEvmBalanceOnChainActions.success, ({ balancesAtomic }, { payload }) => { + const { network, assetSlug, account, balance } = payload; + const { chainId } = network; + if (!balancesAtomic[account]) balancesAtomic[account] = {}; + const accountBalances = balancesAtomic[account]; + + accountBalances[chainId] = Object.assign({}, accountBalances[chainId] ?? {}, { [assetSlug]: balance.toFixed() }); }); }); diff --git a/src/app/store/evm/balances/utils.ts b/src/app/store/evm/balances/utils.ts index e1fc95b93e..df12ffc509 100644 --- a/src/app/store/evm/balances/utils.ts +++ b/src/app/store/evm/balances/utils.ts @@ -1,11 +1,15 @@ +import { BigNumber } from 'bignumber.js'; import { getAddress } from 'viem'; import { BalanceItem } from 'lib/apis/temple/endpoints/evm/api.interfaces'; import { isNativeTokenAddress } from 'lib/apis/temple/endpoints/evm/api.utils'; import { toTokenSlug } from 'lib/assets'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; +import { fetchEvmRawBalance } from 'lib/evm/on-chain/balance'; +import { EvmRpcRequestsExecutor, ExecutionQueueCallbacks } from 'lib/evm/on-chain/utils/evm-rpc-requests-executor'; import { isPositiveCollectibleBalance, isPositiveTokenBalance } from 'lib/utils/evm.utils'; +import { LoadOnChainBalancePayload } from './actions'; import { AssetSlugBalanceRecord } from './state'; export const getTokenSlugBalanceRecord = (data: BalanceItem[], chainId: number) => @@ -32,3 +36,25 @@ export const getTokenSlugBalanceRecord = (data: BalanceItem[], chainId: number) return acc; }, {}); + +class EvmOnChainBalancesRequestsExecutor extends EvmRpcRequestsExecutor< + LoadOnChainBalancePayload & ExecutionQueueCallbacks, + BigNumber, + number +> { + protected getQueueKey(payload: LoadOnChainBalancePayload) { + return payload.network.chainId; + } + + protected requestsAreSame(a: LoadOnChainBalancePayload, b: LoadOnChainBalancePayload) { + return a.network.chainId === b.network.chainId && a.assetSlug === b.assetSlug && a.account === b.account; + } + + protected async getResult(payload: LoadOnChainBalancePayload) { + const { network, assetSlug, account, assetStandard } = payload; + + return fetchEvmRawBalance(network, assetSlug, account, assetStandard); + } +} + +export const evmOnChainBalancesRequestsExecutor = new EvmOnChainBalancesRequestsExecutor(); diff --git a/src/app/store/root-state.epics.ts b/src/app/store/root-state.epics.ts index 1665aad096..8314fa741f 100644 --- a/src/app/store/root-state.epics.ts +++ b/src/app/store/root-state.epics.ts @@ -8,6 +8,7 @@ import { abTestingEpics } from './ab-testing/epics'; import { advertisingEpics } from './advertising/epics'; import { buyWithCreditCardEpics } from './buy-with-credit-card/epics'; import { currencyEpics } from './currency/epics'; +import { evmBalancesEpics } from './evm/balances/epics'; import { partnersPromotionEpics } from './partners-promotion/epics'; import type { RootState } from './root-state.type'; import { swapEpics } from './swap/epics'; @@ -29,7 +30,8 @@ const allEpics = combineEpics( collectiblesMetadataEpics, abTestingEpics, buyWithCreditCardEpics, - collectiblesEpics + collectiblesEpics, + evmBalancesEpics ); export const epicMiddleware = createEpicMiddleware(); diff --git a/src/app/templates/AssetIcon.tsx b/src/app/templates/AssetIcon.tsx index 474dda164c..18f2993659 100644 --- a/src/app/templates/AssetIcon.tsx +++ b/src/app/templates/AssetIcon.tsx @@ -72,7 +72,7 @@ const EvmAssetIconPlaceholder: EvmAssetImageProps['Fallback'] = memo(({ metadata ) ); -export const EvmTokenIconWithNetwork = memo(({ evmChainId, className, style, ...props }) => { +export const EvmAssetIconWithNetwork = memo(({ evmChainId, className, style, ...props }) => { const network = useEvmChainByChainId(evmChainId); return ( diff --git a/src/app/templates/Balance.tsx b/src/app/templates/Balance.tsx index 0e1b9bf6f8..7edd8523b0 100644 --- a/src/app/templates/Balance.tsx +++ b/src/app/templates/Balance.tsx @@ -5,7 +5,7 @@ import CSSTransition from 'react-transition-group/CSSTransition'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; import { useTezosAssetBalance } from 'lib/balances'; -import { useEvmTokenBalance } from 'lib/balances/hooks'; +import { useEvmAssetBalance } from 'lib/balances/hooks'; import { EvmNetworkEssentials, TezosNetworkEssentials } from 'temple/networks'; interface TezosBalanceProps { @@ -45,7 +45,7 @@ interface EvmBalanceProps { } export const EvmBalance: FC = ({ network, address, children, assetSlug = EVM_TOKEN_SLUG }) => { - const { value: balance } = useEvmTokenBalance(assetSlug, address, network); + const { value: balance } = useEvmAssetBalance(assetSlug, address, network); const exists = balance !== undefined; const childNode = children(balance == null ? new BigNumber(0) : balance); diff --git a/src/app/templates/NetworksSettings/add-network-modal/use-add-network.ts b/src/app/templates/NetworksSettings/add-network-modal/use-add-network.ts index 8877bfeda1..39a56bb797 100644 --- a/src/app/templates/NetworksSettings/add-network-modal/use-add-network.ts +++ b/src/app/templates/NetworksSettings/add-network-modal/use-add-network.ts @@ -5,6 +5,7 @@ import { nanoid } from 'nanoid'; import { ArtificialError } from 'app/defaults'; import { toastError } from 'app/toaster'; +import { EvmAssetStandard } from 'lib/evm/types'; import { t } from 'lib/i18n'; import { COLORS } from 'lib/ui/colors'; import { loadEvmChainId } from 'temple/evm'; @@ -85,6 +86,7 @@ export const useAddNetwork = ( [Number(chainId)]: { ...commonChainSpecs, currency: { + standard: EvmAssetStandard.NATIVE, symbol, name: currencyName, decimals: currencyDecimals diff --git a/src/lib/abi/erc1155.ts b/src/lib/abi/erc1155.ts new file mode 100644 index 0000000000..988af30a70 --- /dev/null +++ b/src/lib/abi/erc1155.ts @@ -0,0 +1,420 @@ +export const erc1155TransferBatchEvent = { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'operator', + type: 'address' + }, + { + indexed: true, + internalType: 'address', + name: 'from', + type: 'address' + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address' + }, + { + indexed: false, + internalType: 'uint256[]', + name: 'ids', + type: 'uint256[]' + }, + { + indexed: false, + internalType: 'uint256[]', + name: 'values', + type: 'uint256[]' + } + ], + name: 'TransferBatch', + type: 'event' +} as const; + +export const erc1155TransferSingleEvent = { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'operator', + type: 'address' + }, + { + indexed: true, + internalType: 'address', + name: 'from', + type: 'address' + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address' + }, + { + indexed: false, + internalType: 'uint256', + name: 'id', + type: 'uint256' + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256' + } + ], + name: 'TransferSingle', + type: 'event' +} as const; + +export const erc1155Abi = [ + { + inputs: [ + { + internalType: 'address', + name: 'sender', + type: 'address' + }, + { + internalType: 'uint256', + name: 'balance', + type: 'uint256' + }, + { + internalType: 'uint256', + name: 'needed', + type: 'uint256' + }, + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256' + } + ], + name: 'ERC1155InsufficientBalance', + type: 'error' + }, + { + inputs: [ + { + internalType: 'address', + name: 'approver', + type: 'address' + } + ], + name: 'ERC1155InvalidApprover', + type: 'error' + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'idsLength', + type: 'uint256' + }, + { + internalType: 'uint256', + name: 'valuesLength', + type: 'uint256' + } + ], + name: 'ERC1155InvalidArrayLength', + type: 'error' + }, + { + inputs: [ + { + internalType: 'address', + name: 'operator', + type: 'address' + } + ], + name: 'ERC1155InvalidOperator', + type: 'error' + }, + { + inputs: [ + { + internalType: 'address', + name: 'receiver', + type: 'address' + } + ], + name: 'ERC1155InvalidReceiver', + type: 'error' + }, + { + inputs: [ + { + internalType: 'address', + name: 'sender', + type: 'address' + } + ], + name: 'ERC1155InvalidSender', + type: 'error' + }, + { + inputs: [ + { + internalType: 'address', + name: 'operator', + type: 'address' + }, + { + internalType: 'address', + name: 'owner', + type: 'address' + } + ], + name: 'ERC1155MissingApprovalForAll', + type: 'error' + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'account', + type: 'address' + }, + { + indexed: true, + internalType: 'address', + name: 'operator', + type: 'address' + }, + { + indexed: false, + internalType: 'bool', + name: 'approved', + type: 'bool' + } + ], + name: 'ApprovalForAll', + type: 'event' + }, + erc1155TransferBatchEvent, + erc1155TransferSingleEvent, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'string', + name: 'value', + type: 'string' + }, + { + indexed: true, + internalType: 'uint256', + name: 'id', + type: 'uint256' + } + ], + name: 'URI', + type: 'event' + }, + { + inputs: [ + { + internalType: 'address', + name: 'account', + type: 'address' + }, + { + internalType: 'uint256', + name: 'id', + type: 'uint256' + } + ], + name: 'balanceOf', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256' + } + ], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [ + { + internalType: 'address[]', + name: 'accounts', + type: 'address[]' + }, + { + internalType: 'uint256[]', + name: 'ids', + type: 'uint256[]' + } + ], + name: 'balanceOfBatch', + outputs: [ + { + internalType: 'uint256[]', + name: '', + type: 'uint256[]' + } + ], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [ + { + internalType: 'address', + name: 'account', + type: 'address' + }, + { + internalType: 'address', + name: 'operator', + type: 'address' + } + ], + name: 'isApprovedForAll', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool' + } + ], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [ + { + internalType: 'address', + name: 'from', + type: 'address' + }, + { + internalType: 'address', + name: 'to', + type: 'address' + }, + { + internalType: 'uint256[]', + name: 'ids', + type: 'uint256[]' + }, + { + internalType: 'uint256[]', + name: 'values', + type: 'uint256[]' + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes' + } + ], + name: 'safeBatchTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { + internalType: 'address', + name: 'from', + type: 'address' + }, + { + internalType: 'address', + name: 'to', + type: 'address' + }, + { + internalType: 'uint256', + name: 'id', + type: 'uint256' + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256' + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes' + } + ], + name: 'safeTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { + internalType: 'address', + name: 'operator', + type: 'address' + }, + { + internalType: 'bool', + name: 'approved', + type: 'bool' + } + ], + name: 'setApprovalForAll', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { + internalType: 'bytes4', + name: 'interfaceId', + type: 'bytes4' + } + ], + name: 'supportsInterface', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool' + } + ], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256' + } + ], + name: 'uri', + outputs: [ + { + internalType: 'string', + name: '', + type: 'string' + } + ], + stateMutability: 'view', + type: 'function' + } +] as const; diff --git a/src/lib/abi/erc20.ts b/src/lib/abi/erc20.ts new file mode 100644 index 0000000000..544a65784f --- /dev/null +++ b/src/lib/abi/erc20.ts @@ -0,0 +1,22 @@ +export const erc20TransferEvent = { + anonymous: false, + type: 'event', + name: 'Transfer', + inputs: [ + { + indexed: true, + name: 'from', + type: 'address' + }, + { + indexed: true, + name: 'to', + type: 'address' + }, + { + indexed: false, + name: 'value', + type: 'uint256' + } + ] +} as const; diff --git a/src/lib/abi/erc721.ts b/src/lib/abi/erc721.ts new file mode 100644 index 0000000000..94ca4fba22 --- /dev/null +++ b/src/lib/abi/erc721.ts @@ -0,0 +1,22 @@ +export const erc721TransferEvent = { + anonymous: false, + type: 'event', + name: 'Transfer', + inputs: [ + { + indexed: true, + name: 'from', + type: 'address' + }, + { + indexed: true, + name: 'to', + type: 'address' + }, + { + indexed: true, + name: 'tokenId', + type: 'uint256' + } + ] +} as const; diff --git a/src/lib/assets/hooks/tokens.ts b/src/lib/assets/hooks/tokens.ts index 76c6580486..42347320bd 100644 --- a/src/lib/assets/hooks/tokens.ts +++ b/src/lib/assets/hooks/tokens.ts @@ -38,7 +38,7 @@ export const useEnabledAccountChainTokenSlugs = (accountTezAddress: string, acco return useMemo(() => [...tezTokens, ...evmTokens], [tezTokens, evmTokens]); }; -const useEnabledTezosAccountTokenSlugs = (publicKeyHash: string) => { +export const useEnabledTezosAccountTokenSlugs = (publicKeyHash: string) => { const tokens = useTezosAccountTokens(publicKeyHash); return useMemoWithCompare( diff --git a/src/lib/balances/hooks.ts b/src/lib/balances/hooks.ts index 086ac3ea9a..57ec87a6ad 100644 --- a/src/lib/balances/hooks.ts +++ b/src/lib/balances/hooks.ts @@ -1,18 +1,18 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { emptyFn, isDefined } from '@rnw-community/shared'; import BigNumber from 'bignumber.js'; +import { useDispatch } from 'react-redux'; +import { setEvmBalancesLoadingState } from 'app/store/evm/actions'; +import { loadEvmBalanceOnChainActions } from 'app/store/evm/balances/actions'; import { useRawEvmAccountBalancesSelector, useRawEvmChainAccountBalancesSelector, useRawEvmAssetBalanceSelector } from 'app/store/evm/balances/selectors'; import { useEvmBalancesLoadingStateSelector } from 'app/store/evm/selectors'; -import { - useEvmTokenMetadataSelector, - useEvmTokensMetadataRecordSelector -} from 'app/store/evm/tokens-metadata/selectors'; +import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; import { useAllAccountBalancesSelector, useAllAccountBalancesEntitySelector, @@ -22,17 +22,21 @@ import { getKeyForBalancesRecord } from 'app/store/tezos/balances/utils'; import { isSupportedChainId } from 'lib/apis/temple/endpoints/evm/api.utils'; import { isKnownChainId } from 'lib/apis/tzkt'; import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; -import { fetchEvmRawBalance as fetchEvmRawBalanceFromBlockchain } from 'lib/evm/on-chain/balance'; +import { createEvmTransfersListener } from 'lib/evm/on-chain/evm-transfer-subscriptions'; import { EvmAssetStandard } from 'lib/evm/types'; import { EVM_BALANCES_SYNC_INTERVAL } from 'lib/fixed-times'; -import { useTezosAssetMetadata, useGetChainTokenOrGasMetadata, useGetTokenOrGasMetadata } from 'lib/metadata'; +import { + useTezosAssetMetadata, + useGetChainTokenOrGasMetadata, + useGetTokenOrGasMetadata, + useEvmAssetMetadata +} from 'lib/metadata'; import { EvmTokenMetadata } from 'lib/metadata/types'; +import { isEvmCollectible } from 'lib/metadata/utils'; import { useTypedSWR } from 'lib/swr'; import { atomsToTokens } from 'lib/temple/helpers'; import { useInterval } from 'lib/ui/hooks'; -import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; import { useAccountAddressForEvm, useAccountAddressForTezos, useAllEvmChains, useOnTezosBlock } from 'temple/front'; -import { useEvmChainByChainId } from 'temple/front/chains'; import { EvmNetworkEssentials, TezosNetworkEssentials } from 'temple/networks'; import { getReadOnlyTezos } from 'temple/tezos'; @@ -210,65 +214,58 @@ function useEvmAssetRawBalance( error?: unknown; refresh: EmptyFn; } { + const dispatch = useDispatch(); const currentAccountAddress = useAccountAddressForEvm(); - const { chainId, rpcBaseURL } = network; + const { chainId } = network; const storedBalance = useRawEvmAssetBalanceSelector(address, network.chainId, assetSlug); const storedLoadingState = useEvmBalancesLoadingStateSelector(chainId); const storedError = isDefined(storedLoadingState?.error); - const usingStore = useMemo( + const usingOffchainAPI = useMemo( () => address === currentAccountAddress && isSupportedChainId(chainId) && !storedError, [storedError, address, currentAccountAddress, chainId] ); - const onChainBalanceSwrRes = useTypedSWR( - ['evm-asset-raw-balance', rpcBaseURL, assetSlug, address], - () => { - if (usingStore) return; - - return fetchEvmRawBalanceFromBlockchain(network, assetSlug, address, assetStandard).then(res => res.toString()); - }, - { - revalidateOnFocus: false, - dedupingInterval: 20000 - } + const evmTransfersListener = useMemo( + () => + assetStandard && !usingOffchainAPI + ? createEvmTransfersListener(network.rpcBaseURL, address, assetSlug, assetStandard) + : undefined, + [address, assetSlug, assetStandard, network.rpcBaseURL, usingOffchainAPI] ); - const refreshBalanceOnChain = useCallback(() => void onChainBalanceSwrRes.mutate(), [onChainBalanceSwrRes.mutate]); + const refreshBalanceOnChain = useCallback(() => { + dispatch(setEvmBalancesLoadingState({ chainId, isLoading: true })); + dispatch(loadEvmBalanceOnChainActions.submit({ network, assetSlug, account: address, assetStandard })); + }, [dispatch, chainId, network, assetSlug, address, assetStandard]); useInterval( () => { - if (usingStore) return; + if (usingOffchainAPI) return; refreshBalanceOnChain(); }, - [usingStore, refreshBalanceOnChain], + [usingOffchainAPI, refreshBalanceOnChain], EVM_BALANCES_SYNC_INTERVAL, - false + true ); - if (usingStore) - return { - value: storedBalance, - isSyncing: storedLoadingState?.isLoading ?? false, - refresh: emptyFn - }; + useEffect( + () => evmTransfersListener?.subscribe(refreshBalanceOnChain), + [address, assetSlug, chainId, evmTransfersListener, refreshBalanceOnChain, usingOffchainAPI] + ); return { - value: onChainBalanceSwrRes.data, - isSyncing: onChainBalanceSwrRes.isValidating, - error: onChainBalanceSwrRes.error, + value: storedBalance, + isSyncing: storedLoadingState?.isLoading ?? false, refresh: refreshBalanceOnChain }; } -export function useEvmTokenBalance(assetSlug: string, address: HexString, network: EvmNetworkEssentials) { - const chain = useEvmChainByChainId(network.chainId); - const tokenMetadata = useEvmTokenMetadataSelector(network.chainId, assetSlug); - - const metadata = isEvmNativeTokenSlug(assetSlug) ? chain?.currency : tokenMetadata; +export function useEvmAssetBalance(assetSlug: string, address: HexString, network: EvmNetworkEssentials) { + const metadata = useEvmAssetMetadata(assetSlug, network.chainId); const { value: rawValue, @@ -278,9 +275,17 @@ export function useEvmTokenBalance(assetSlug: string, address: HexString, networ } = useEvmAssetRawBalance(assetSlug, address, network, metadata?.standard); const value = useMemo( - () => (rawValue && metadata?.decimals ? atomsToTokens(new BigNumber(rawValue), metadata.decimals) : undefined), + () => (rawValue && metadata ? atomsToTokens(new BigNumber(rawValue), metadata.decimals ?? 0) : undefined), [rawValue, metadata] ); return { rawValue, value, isSyncing, error, refresh, metadata }; } + +export function useEvmTokenBalance(assetSlug: string, address: HexString, network: EvmNetworkEssentials) { + const { metadata, ...restProps } = useEvmAssetBalance(assetSlug, address, network); + + return !metadata || isEvmCollectible(metadata) + ? { ...restProps, metadata: undefined, value: undefined } + : { metadata, ...restProps }; +} diff --git a/src/lib/evm/on-chain/balance.ts b/src/lib/evm/on-chain/balance.ts index 738deeb11b..363e61c4c7 100644 --- a/src/lib/evm/on-chain/balance.ts +++ b/src/lib/evm/on-chain/balance.ts @@ -1,6 +1,7 @@ import BigNumber from 'bignumber.js'; -import { parseAbi } from 'viem'; +import { erc20Abi, erc721Abi } from 'viem'; +import { erc1155Abi } from 'lib/abi/erc1155'; import { fromAssetSlug } from 'lib/assets'; import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; import { ONE, ZERO } from 'lib/utils/numbers'; @@ -39,7 +40,7 @@ export const fetchEvmRawBalance = async ( if (standard === EvmAssetStandard.ERC1155) { const fetchedErc1155Balance = await publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function balanceOf(address account, uint256 id) view returns (uint256)']), + abi: erc1155Abi, functionName: 'balanceOf', args: [account, tokenId] }); @@ -50,7 +51,7 @@ export const fetchEvmRawBalance = async ( if (standard === EvmAssetStandard.ERC721) { const ownerAddress = await publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function ownerOf(uint256 tokenId) view returns (address owner)']), + abi: erc721Abi, functionName: 'ownerOf', args: [tokenId] }); @@ -60,7 +61,7 @@ export const fetchEvmRawBalance = async ( const fetchedBalance = await publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function balanceOf(address owner) view returns (uint256)']), + abi: erc20Abi, functionName: 'balanceOf', args: [account] }); diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/evm-http-rpc-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/evm-http-rpc-listener.ts new file mode 100644 index 0000000000..b82998ca99 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/evm-http-rpc-listener.ts @@ -0,0 +1,73 @@ +import { HttpTransport, PublicClient } from 'viem'; + +import { delay } from 'lib/utils'; +import { getReadOnlyEvm } from 'temple/evm'; + +import { Listener, ListenerCallback } from './listener'; + +export abstract class EvmHttpRpcListener extends Listener { + protected rpcClient: PublicClient; + protected isActive = false; + protected cancelSubscription: EmptyFn | null = null; + + constructor(httpRpcUrl: string) { + super(); + this.rpcClient = getReadOnlyEvm(httpRpcUrl); + } + + protected abstract subscribeToRpcEvents(): Promise; + + protected startListening() { + this.subscribeToRpcEvents() + .then(cancelSubscription => { + if (this.isActive) { + this.cancelSubscription = cancelSubscription; + } else { + cancelSubscription(); + } + }) + .catch(e => console.error(e)); + } + + protected stopListening() { + if (this.cancelSubscription) { + this.cancelSubscription(); + this.cancelSubscription = null; + } + } + + protected activate() { + this.isActive = true; + this.startListening(); + } + + protected deactivate() { + this.isActive = false; + this.stopListening(); + } + + subscribe(listener: ListenerCallback) { + if (this.callbacks.length === 0) { + this.activate(); + } + super.subscribe(listener); + } + + unsubscribe(listener: ListenerCallback) { + super.unsubscribe(listener); + if (this.callbacks.length === 0) { + this.deactivate(); + } + } + + protected async onError(error: unknown) { + if (!this.isActive) { + return; + } + + console.error(error); + this.stopListening(); + await delay(1000); + this.startListening(); + } +} diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/evm-new-block-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/evm-new-block-listener.ts new file mode 100644 index 0000000000..ff76770b9d --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/evm-new-block-listener.ts @@ -0,0 +1,15 @@ +import memoizee from 'memoizee'; + +import { EvmHttpRpcListener } from './evm-http-rpc-listener'; + +/** Do not construct directly; use `getEvmNewBlockListener` instead */ +export class EvmNewBlockListener extends EvmHttpRpcListener { + protected async subscribeToRpcEvents() { + return this.rpcClient.watchBlockNumber({ + onBlockNumber: () => this.emit(), + onError: e => this.onError(e) + }); + } +} + +export const getEvmNewBlockListener = memoizee((httpRpcUrl: string) => new EvmNewBlockListener(httpRpcUrl)); diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/index.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/index.ts new file mode 100644 index 0000000000..0c43c953e7 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/index.ts @@ -0,0 +1,50 @@ +import memoizee from 'memoizee'; + +import { EvmAssetStandard } from 'lib/evm/types'; + +import { EvmNewBlockListener, getEvmNewBlockListener } from './evm-new-block-listener'; +import { getERC1155TransferEventsListener } from './transfer-events-listeners/erc1155-transfer-events-listener'; +import { getERC20TransferEventsListener } from './transfer-events-listeners/erc20-transfer-events-listener'; +import { getERC721TransferEventsListener } from './transfer-events-listeners/erc721-transfer-events-listener'; + +const transferListenerGetters = { + [EvmAssetStandard.NATIVE]: getEvmNewBlockListener, + [EvmAssetStandard.ERC20]: getERC20TransferEventsListener, + [EvmAssetStandard.ERC721]: getERC721TransferEventsListener, + [EvmAssetStandard.ERC1155]: getERC1155TransferEventsListener +}; + +class EvmAssetTransfersListener { + private listener: ReturnType<(typeof transferListenerGetters)[EvmAssetStandard]>; + + constructor(rpcUrl: string, account: HexString, private assetSlug: string, assetStandard: EvmAssetStandard) { + if (assetStandard === EvmAssetStandard.NATIVE) { + this.listener = getEvmNewBlockListener(rpcUrl); + } else { + this.listener = transferListenerGetters[assetStandard](rpcUrl, account); + } + } + + /** Returns a function that cancels the subscription */ + subscribe(callback: EmptyFn) { + let unsubscribe: EmptyFn; + const listener = this.listener; + if (listener instanceof EvmNewBlockListener) { + unsubscribe = () => listener.unsubscribe(callback); + listener.subscribe(callback); + } else { + const wrappedCallback = (assetSlug: string) => + void (assetSlug.toLowerCase() === this.assetSlug.toLowerCase() && callback()); + unsubscribe = () => listener.unsubscribe(wrappedCallback); + listener.subscribe(wrappedCallback); + } + + return unsubscribe; + } +} + +export const createEvmTransfersListener = memoizee( + (rpcUrl: string, account: HexString, assetSlug: string, assetStandard: EvmAssetStandard) => + new EvmAssetTransfersListener(rpcUrl, account, assetSlug, assetStandard), + { length: 4 } +); diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/listener.ts new file mode 100644 index 0000000000..eb00241239 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/listener.ts @@ -0,0 +1,17 @@ +export type ListenerCallback = (...args: Args) => void; + +export abstract class Listener { + protected callbacks: ListenerCallback[] = []; + + subscribe(callback: ListenerCallback) { + this.callbacks.push(callback); + } + + unsubscribe(callback: ListenerCallback) { + this.callbacks = this.callbacks.filter(cb => cb !== callback); + } + + protected emit(...args: Args) { + this.callbacks.forEach(callback => callback(...args)); + } +} diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/listeners-delegate.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/listeners-delegate.ts new file mode 100644 index 0000000000..b8222e6b77 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/listeners-delegate.ts @@ -0,0 +1,13 @@ +import { Listener, ListenerCallback } from './listener'; + +export abstract class ListenersDelegate { + constructor(protected listeners: Listener[]) {} + + subscribe(callback: ListenerCallback) { + this.listeners.forEach(listener => listener.subscribe(callback)); + } + + unsubscribe(callback: ListenerCallback) { + this.listeners.forEach(listener => listener.unsubscribe(callback)); + } +} diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc1155-transfer-events-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc1155-transfer-events-listener.ts new file mode 100644 index 0000000000..a602c22348 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc1155-transfer-events-listener.ts @@ -0,0 +1,49 @@ +import { isDefined } from '@rnw-community/shared'; +import { Log } from 'viem'; + +import { erc1155TransferBatchEvent, erc1155TransferSingleEvent } from 'lib/abi/erc1155'; +import { toTokenSlug } from 'lib/assets'; + +import { ListenersDelegate } from '../listeners-delegate'; + +import { EvmSingleTransferEventsListener } from './evm-single-transfer-events-listener'; +import { EvmTransferEventsListener } from './evm-transfer-events-listener'; +import { makeGetTransferEventsListener } from './make-get-transfer-events-listener'; + +class ERC1155SingleTransferEventsListener extends EvmSingleTransferEventsListener { + protected getTokenId(log: Log) { + return log.args.id; + } + + protected getAmount(log: Log) { + return log.args.value; + } +} + +class ERC1155BatchTransferEventsListener extends EvmTransferEventsListener { + protected getAssetsSlugs(log: Log) { + const { address, args } = log; + const { ids, values, from, to } = args; + + if ((from !== this.account && to !== this.account) || !ids || !values) { + return []; + } + + return ids + .map((rawTokenId, i) => { + return values[i] ? toTokenSlug(address, rawTokenId.toString()) : undefined; + }) + .filter(isDefined); + } +} + +class ERC1155TransferEventsListener extends ListenersDelegate<[string]> { + constructor(httpRpcUrl: string, account: HexString) { + super([ + new ERC1155SingleTransferEventsListener(httpRpcUrl, account, erc1155TransferSingleEvent), + new ERC1155BatchTransferEventsListener(httpRpcUrl, account, erc1155TransferBatchEvent) + ]); + } +} + +export const getERC1155TransferEventsListener = makeGetTransferEventsListener(ERC1155TransferEventsListener); diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc20-transfer-events-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc20-transfer-events-listener.ts new file mode 100644 index 0000000000..b620660622 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc20-transfer-events-listener.ts @@ -0,0 +1,22 @@ +import { Log } from 'viem'; + +import { erc20TransferEvent } from 'lib/abi/erc20'; + +import { EvmSingleTransferEventsListener } from './evm-single-transfer-events-listener'; +import { makeGetTransferEventsListener } from './make-get-transfer-events-listener'; + +class ERC20SingleTransferEventsListener extends EvmSingleTransferEventsListener { + constructor(httpRpcUrl: string, account: HexString) { + super(httpRpcUrl, account, erc20TransferEvent); + } + + protected getTokenId() { + return BigInt(0); + } + + protected getAmount(log: Log) { + return log.args.value; + } +} + +export const getERC20TransferEventsListener = makeGetTransferEventsListener(ERC20SingleTransferEventsListener); diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc721-transfer-events-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc721-transfer-events-listener.ts new file mode 100644 index 0000000000..81b4b72b68 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/erc721-transfer-events-listener.ts @@ -0,0 +1,22 @@ +import { Log } from 'viem'; + +import { erc721TransferEvent } from 'lib/abi/erc721'; + +import { EvmSingleTransferEventsListener } from './evm-single-transfer-events-listener'; +import { makeGetTransferEventsListener } from './make-get-transfer-events-listener'; + +class ERC721SingleTransferEventsListener extends EvmSingleTransferEventsListener { + constructor(httpRpcUrl: string, account: HexString) { + super(httpRpcUrl, account, erc721TransferEvent); + } + + protected getTokenId(log: Log) { + return log.args.tokenId; + } + + protected getAmount() { + return BigInt(1); + } +} + +export const getERC721TransferEventsListener = makeGetTransferEventsListener(ERC721SingleTransferEventsListener); diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-single-transfer-events-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-single-transfer-events-listener.ts new file mode 100644 index 0000000000..c85c693f4b --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-single-transfer-events-listener.ts @@ -0,0 +1,28 @@ +import { Log } from 'viem'; + +import { erc1155TransferBatchEvent } from 'lib/abi/erc1155'; +import { toTokenSlug } from 'lib/assets'; + +import { EvmTransferEventsListener, TransferEvent } from './evm-transfer-events-listener'; + +type SingleTransferEvent = Exclude; + +type TransferSingleLog = Log; + +export abstract class EvmSingleTransferEventsListener< + T extends SingleTransferEvent +> extends EvmTransferEventsListener { + protected abstract getTokenId(log: TransferSingleLog): bigint | undefined; + protected abstract getAmount(log: TransferSingleLog): bigint | undefined; + + protected getAssetsSlugs(log: TransferSingleLog) { + const { address, args } = log; + const { from, to } = args as Exclude['args'], readonly unknown[]>; + const tokenId = this.getTokenId(log); + const amount = this.getAmount(log); + + return (from === this.account || to === this.account) && amount && tokenId !== undefined + ? [toTokenSlug(address, tokenId.toString())] + : []; + } +} diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-transfer-events-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-transfer-events-listener.ts new file mode 100644 index 0000000000..4e00107b09 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/evm-transfer-events-listener.ts @@ -0,0 +1,91 @@ +import { isEqual, noop } from 'lodash'; +import { Log, OneOf, WatchEventParameters } from 'viem'; + +import { erc1155TransferBatchEvent, erc1155TransferSingleEvent } from 'lib/abi/erc1155'; +import { erc20TransferEvent } from 'lib/abi/erc20'; +import { erc721TransferEvent } from 'lib/abi/erc721'; +import { getReadOnlyEvm } from 'temple/evm'; + +import { + EvmRpcRequestsExecutor, + ExecutionQueueCallbacks, + RequestAlreadyPendingError +} from '../../utils/evm-rpc-requests-executor'; +import { EvmHttpRpcListener } from '../evm-http-rpc-listener'; + +export type TransferEvent = OneOf< + | typeof erc20TransferEvent + | typeof erc721TransferEvent + | typeof erc1155TransferBatchEvent + | typeof erc1155TransferSingleEvent +>; + +interface TransfersSubscriptionPayload { + rpcUrl: string; + args: WatchEventParameters; +} + +class EvmTransferEventsSubscriptionExecutor extends EvmRpcRequestsExecutor< + TransfersSubscriptionPayload & ExecutionQueueCallbacks, + EmptyFn, + string +> { + protected getQueueKey(payload: TransfersSubscriptionPayload) { + return payload.rpcUrl; + } + + protected requestsAreSame(a: TransfersSubscriptionPayload, b: TransfersSubscriptionPayload) { + return isEqual(a, b); + } + + protected async getResult(payload: TransfersSubscriptionPayload) { + const client = getReadOnlyEvm(payload.rpcUrl); + + return client.watchEvent(payload.args); + } +} + +const evmTransferEventsSubscriptionExecutor = new EvmTransferEventsSubscriptionExecutor(); + +export abstract class EvmTransferEventsListener extends EvmHttpRpcListener<[string]> { + constructor(protected httpRpcUrl: string, protected account: HexString, protected event: T) { + super(httpRpcUrl); + } + + protected abstract getAssetsSlugs(log: Log): string[]; + + protected handleLog(log: Log) { + const assetsSlugs = this.getAssetsSlugs(log); + assetsSlugs.forEach(slug => this.emit(slug)); + } + + protected async subscribeToRpcEvents() { + const eventsArgs = [{ from: this.account }, { to: this.account }]; + + const internalCancelSubscriptionFns = await Promise.all( + eventsArgs.map(args => + evmTransferEventsSubscriptionExecutor + .executeRequest({ + rpcUrl: this.httpRpcUrl, + args: { + onLogs: logs => logs.forEach(log => this.handleLog(log as Log)), + event: this.event, + args, + onError: e => this.onError(e) + } + }) + .catch(error => { + if (!(error instanceof RequestAlreadyPendingError)) { + console.error(error); + } + + return noop; + }) + ) + ); + + return () => { + internalCancelSubscriptionFns.forEach(fn => fn()); + }; + } +} diff --git a/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/make-get-transfer-events-listener.ts b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/make-get-transfer-events-listener.ts new file mode 100644 index 0000000000..4560dad780 --- /dev/null +++ b/src/lib/evm/on-chain/evm-transfer-subscriptions/transfer-events-listeners/make-get-transfer-events-listener.ts @@ -0,0 +1,10 @@ +import memoizee from 'memoizee'; + +interface ListenerClassConstructor { + new (httpRpcUrl: string, account: HexString): T; +} + +export const makeGetTransferEventsListener = (constructor: ListenerClassConstructor) => + memoizee((httpRpcUrl: string, account: HexString) => new constructor(httpRpcUrl, account), { + length: 2 + }); diff --git a/src/lib/evm/on-chain/metadata.ts b/src/lib/evm/on-chain/metadata.ts index 5a4e0afdf9..1c7a0dcb98 100644 --- a/src/lib/evm/on-chain/metadata.ts +++ b/src/lib/evm/on-chain/metadata.ts @@ -1,9 +1,11 @@ import axios from 'axios'; -import { parseAbi, PublicClient } from 'viem'; +import { pickBy } from 'lodash'; +import { erc20Abi, erc721Abi, parseAbi, PublicClient } from 'viem'; +import { erc1155Abi } from 'lib/abi/erc1155'; import { NftCollectionAttribute } from 'lib/apis/temple/endpoints/evm/api.interfaces'; import { fromAssetSlug } from 'lib/assets'; -import { buildMetadataLinkFromUri } from 'lib/images-uri'; +import { buildHttpLinkFromUri } from 'lib/images-uri'; import { EvmCollectibleMetadata, EvmTokenMetadata } from 'lib/metadata/types'; import { getReadOnlyEvm } from 'temple/evm'; import { EvmNetworkEssentials } from 'temple/networks'; @@ -138,22 +140,28 @@ const getERC1155Metadata = async (publicClient: PublicClient, contractAddress: H if (!metadataUri) throw new Error(); - const collectibleMetadata = await getCollectiblePropertiesFromUri(metadataUri); + const actualMetadataUri = metadataUri.replace('{id}', tokenId.toString().padStart(64, '0')); + const collectibleMetadata = await getCollectiblePropertiesFromUri(actualMetadataUri); const metadata: EvmCollectibleMetadata = { address: contractAddress, tokenId: tokenId.toString(), standard: EvmAssetStandard.ERC1155, - name: getValue(results[0]), - symbol: getValue(results[1]), - metadataUri, + // ERC1155 specification does not include `symbol` or `name` view methods, see + // https://eips.ethereum.org/EIPS/eip-1155#metadata-choices but let's assign their values if a contract has them + name: collectibleMetadata.collectibleName ?? getValue(results[0]), + symbol: getValue(results[1]) ?? collectibleMetadata.collectibleName, + metadataUri: actualMetadataUri, ...collectibleMetadata }; return metadata; }; -const getCommonPromises = (publicClient: PublicClient, contractAddress: HexString): Promise[] => [ +const getCommonPromises = ( + publicClient: PublicClient, + contractAddress: HexString +): [Promise, Promise] => [ publicClient.readContract({ address: contractAddress, abi: parseAbi(['function name() public view returns (string)']), @@ -171,7 +179,7 @@ const getERC20Properties = async (publicClient: PublicClient, contractAddress: H ...getCommonPromises(publicClient, contractAddress), publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function decimals() public view returns (uint8)']), + abi: erc20Abi, functionName: 'decimals' }) ]); @@ -181,7 +189,7 @@ const getERC721Properties = async (publicClient: PublicClient, contractAddress: ...getCommonPromises(publicClient, contractAddress), publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function tokenURI(uint256 _tokenId) external view returns (string)']), + abi: erc721Abi, functionName: 'tokenURI', args: [tokenId] }) @@ -192,7 +200,7 @@ const getERC1155Properties = async (publicClient: PublicClient, contractAddress: ...getCommonPromises(publicClient, contractAddress), publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function uri(uint256 _id) external view returns (string memory)']), + abi: erc1155Abi, functionName: 'uri', args: [tokenId] }) @@ -205,12 +213,12 @@ const handleFetchedMetadata = (fetchedMetadata: T[], assetSlugs: string[]) => return { ...acc, [slug]: metadata }; }, {}); -const getValue = (result: PromiseSettledResult) => - result.status === 'fulfilled' ? (result.value as T) : undefined; +const getValue = (result: PromiseSettledResult) => (result.status === 'fulfilled' ? result.value : undefined); interface CollectibleMetadata { image?: string; name?: string; + decimals?: number; description?: string; attributes?: NftCollectionAttribute[]; external_url?: string; @@ -225,7 +233,7 @@ const getCollectiblePropertiesFromUri = async ( 'image' | 'collectibleName' | 'description' | 'attributes' | 'externalUrl' | 'animationUrl' > > => { - const uri = buildMetadataLinkFromUri(metadataUri); + const uri = buildHttpLinkFromUri(metadataUri); if (!uri) throw new Error(); @@ -233,14 +241,20 @@ const getCollectiblePropertiesFromUri = async ( if (typeof data !== 'object' || !data.image || !data.name || !data.description) throw new Error(); - const { name, description, image, attributes, external_url: externalUrl, animation_url: animationUrl } = data; + const { + name, + description, + decimals, + image, + attributes, + external_url: externalUrl, + animation_url: animationUrl + } = data; return { image, collectibleName: name, description, - ...(attributes && { attributes }), - ...(externalUrl && { externalUrl }), - ...(animationUrl && { animationUrl }) + ...pickBy({ attributes, externalUrl, animationUrl, decimals }, value => value !== undefined && value !== '') }; }; diff --git a/src/lib/evm/on-chain/utils/common.utils.ts b/src/lib/evm/on-chain/utils/common.utils.ts index 48d3d564bb..9f7c222db6 100644 --- a/src/lib/evm/on-chain/utils/common.utils.ts +++ b/src/lib/evm/on-chain/utils/common.utils.ts @@ -1,4 +1,4 @@ -import { parseAbi } from 'viem'; +import { erc20Abi, parseAbi } from 'viem'; import { fromAssetSlug } from 'lib/assets'; import { getReadOnlyEvm } from 'temple/evm'; @@ -38,7 +38,7 @@ export const detectEvmTokenStandard = async ( try { await publicClient.readContract({ address: contractAddress, - abi: parseAbi(['function totalSupply() public view returns (uint256)']), + abi: erc20Abi, functionName: 'totalSupply' }); diff --git a/src/lib/evm/on-chain/utils/evm-rpc-requests-executor.ts b/src/lib/evm/on-chain/utils/evm-rpc-requests-executor.ts new file mode 100644 index 0000000000..c5940856c2 --- /dev/null +++ b/src/lib/evm/on-chain/utils/evm-rpc-requests-executor.ts @@ -0,0 +1,89 @@ +import { isDefined } from '@rnw-community/shared'; +import { Mutex } from 'async-mutex'; +import { omit } from 'lodash'; + +import { EVM_RPC_REQUESTS_INTERVAL } from 'lib/fixed-times'; +import { QueueOfUnique } from 'lib/utils/queue-of-unique'; + +export class RequestAlreadyPendingError extends Error {} + +export interface ExecutionQueueCallbacks { + onSuccess: SyncFn; + onError: SyncFn; +} + +type Payload, R> = Omit; + +export abstract class EvmRpcRequestsExecutor, R, K extends string | number> { + private requestsQueues = new Map>(); + private mapMutex = new Mutex(); + private requestInterval: NodeJS.Timer; + + constructor() { + this.executeNextRequests = this.executeNextRequests.bind(this); + this.requestInterval = setInterval(() => this.executeNextRequests(), EVM_RPC_REQUESTS_INTERVAL); + } + + protected abstract getQueueKey(payload: Payload): K; + protected abstract requestsAreSame(a: Payload, b: Payload): boolean; + protected abstract getResult(payload: Payload): Promise; + + async queueIsEmpty(key: K) { + return this.mapMutex.runExclusive(async () => { + const queue = this.requestsQueues.get(key); + return !queue || (await queue.length()) === 0; + }); + } + + async executeRequest(payload: Payload) { + const chainId = this.getQueueKey(payload); + + return new Promise(async (resolve, reject) => { + const queue = await this.mapMutex.runExclusive(async () => { + let result = this.requestsQueues.get(chainId); + if (!result) { + result = new QueueOfUnique((a, b) => + this.requestsAreSame(omit(a, 'onSuccess', 'onError'), omit(b, 'onSuccess', 'onError')) + ); + this.requestsQueues.set(chainId, result); + } + + return result; + }); + + const hasBeenPushed = await queue.push({ + ...payload, + onSuccess: resolve, + onError: reject + } as T); + + if (!hasBeenPushed) { + reject(new RequestAlreadyPendingError()); + } + }); + } + + finalize() { + clearInterval(this.requestInterval); + } + + private async executeNextRequests() { + const requests = await this.mapMutex.runExclusive(() => { + const requestsPromises: Promise[] = []; + this.requestsQueues.forEach(queue => requestsPromises.push(queue.pop())); + + return Promise.all(requestsPromises).then(requests => requests.filter(isDefined)); + }); + + return Promise.all( + requests.map(async ({ onSuccess, onError, ...payload }) => { + try { + const result = await this.getResult(payload); + onSuccess(result); + } catch (err: any) { + onError(err); + } + }) + ); + } +} diff --git a/src/lib/fixed-times.ts b/src/lib/fixed-times.ts index 415135c601..f53170edc7 100644 --- a/src/lib/fixed-times.ts +++ b/src/lib/fixed-times.ts @@ -17,3 +17,5 @@ export const USER_ACTION_TIMEOUT = 30_000; export const DEFAULT_WALLET_AUTOLOCK_TIME = LONG_INTERVAL; export const COLLECTIBLES_DETAILS_SYNC_INTERVAL = LONG_INTERVAL; + +export const EVM_RPC_REQUESTS_INTERVAL = 70; diff --git a/src/lib/images-uri.ts b/src/lib/images-uri.ts index acc91e8e00..a15de3ce75 100644 --- a/src/lib/images-uri.ts +++ b/src/lib/images-uri.ts @@ -13,7 +13,7 @@ const COMPRESSES_TOKEN_ICON_SIZE = 80; const COMPRESSES_COLLECTIBLE_ICON_SIZE = 250; const IPFS_PROTOCOL = 'ipfs://'; -const IPFS_GATE = 'https://cloudflare-ipfs.com/ipfs'; +const IPFS_GATE = 'https://ipfs.io/ipfs'; const MEDIA_HOST = 'https://static.tcinfra.net/media'; const DEFAULT_MEDIA_SIZE: TcInfraMediaSize = 'small'; const OBJKT_MEDIA_HOST = 'https://assets.objkt.media/file/assets-003'; @@ -258,10 +258,21 @@ export const buildEvmTokenIconSources = (metadata: EvmAssetMetadataBase, chainId return mainFallback ? [getCompressedImageUrl(mainFallback, COMPRESSES_TOKEN_ICON_SIZE)] : []; }; -export const buildEvmCollectibleIconSources = (metadata: EvmCollectibleMetadata) => - metadata.image ? [getCompressedImageUrl(metadata.image, COMPRESSES_COLLECTIBLE_ICON_SIZE), metadata.image] : []; +export const buildEvmCollectibleIconSources = (metadata: EvmCollectibleMetadata) => { + const originalUrl = metadata.image; -export const buildMetadataLinkFromUri = (uri?: string) => { + return originalUrl + ? [ + getCompressedImageUrl( + buildIpfsMediaUriByInfo({ uri: originalUrl, ipfs: getIpfsItemInfo(originalUrl) }) ?? originalUrl, + COMPRESSES_COLLECTIBLE_ICON_SIZE + ), + originalUrl + ] + : []; +}; + +export const buildHttpLinkFromUri = (uri?: string) => { if (!uri) return undefined; if (uri.startsWith(IPFS_PROTOCOL)) { diff --git a/src/lib/metadata/index.ts b/src/lib/metadata/index.ts index 80e916b0fb..cf102cecab 100644 --- a/src/lib/metadata/index.ts +++ b/src/lib/metadata/index.ts @@ -35,7 +35,7 @@ import { isTezosDcpChainId } from 'temple/networks'; import { TEZOS_METADATA, FILM_METADATA } from './defaults'; import { AssetMetadataBase, - EvmAssetMetadataBase, + EvmAssetMetadata, EvmCollectibleMetadata, EvmNativeTokenMetadata, EvmTokenMetadata, @@ -56,7 +56,7 @@ export const useTezosAssetMetadata = (slug: string, tezosChainId: string): Asset return isTezAsset(slug) ? getTezosGasMetadata(tezosChainId) : tokenMetadata || collectibleMetadata; }; -export const useEvmAssetMetadata = (slug: string, evmChainId: number): EvmAssetMetadataBase | undefined => { +export const useEvmAssetMetadata = (slug: string, evmChainId: number): EvmAssetMetadata | undefined => { const network = useEvmChainByChainId(evmChainId); const tokenMetadata = useEvmTokenMetadataSelector(evmChainId, slug); const collectibleMetadata = useEvmCollectibleMetadataSelector(evmChainId, slug); diff --git a/src/lib/metadata/types.ts b/src/lib/metadata/types.ts index 7aa061db4c..2ee150986f 100644 --- a/src/lib/metadata/types.ts +++ b/src/lib/metadata/types.ts @@ -52,6 +52,8 @@ export interface EvmNativeTokenMetadata extends Required { } export interface EvmCollectibleMetadata extends EvmAssetMetadataBase { + standard?: EvmAssetStandard.ERC721 | EvmAssetStandard.ERC1155; + address: HexString; tokenId: string; metadataUri?: string; image?: string; @@ -62,3 +64,5 @@ export interface EvmCollectibleMetadata extends EvmAssetMetadataBase { animationUrl?: string; originalOwner?: string; } + +export type EvmAssetMetadata = EvmTokenMetadata | EvmNativeTokenMetadata | EvmCollectibleMetadata; diff --git a/src/lib/metadata/utils.ts b/src/lib/metadata/utils.ts index 4cd54e115c..76eef857d3 100644 --- a/src/lib/metadata/utils.ts +++ b/src/lib/metadata/utils.ts @@ -9,12 +9,12 @@ import { TezosTokenStandardsEnum, EvmTokenMetadata, EvmCollectibleMetadata, + EvmAssetMetadata, EvmAssetMetadataBase } from './types'; -export function getAssetSymbol(metadata: EvmAssetMetadataBase | AssetMetadataBase | nullish, short = false) { +export function getAssetSymbol(metadata: AssetMetadataBase | EvmAssetMetadataBase | nullish, short = false) { if (!metadata?.symbol) return '???'; - if (!short) return metadata.symbol; return metadata.symbol === 'tez' ? TEZOS_SYMBOL : metadata.symbol.substring(0, 5); @@ -36,6 +36,9 @@ export function getCollectionName(metadata: EvmCollectibleMetadata | nullish) { export const isCollectible = (metadata: StringRecord) => 'artifactUri' in metadata && isString(metadata.artifactUri); +export const isEvmCollectible = (metadata: EvmAssetMetadata): metadata is EvmCollectibleMetadata => + 'tokenId' in metadata; + /** * @deprecated // Assertion here is not safe! */ diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 57ffc8e7f1..c2e09b3368 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -32,3 +32,5 @@ type PropsWithChildren

= P & { children: import('react').ReactNode type PropsWithClassName

= P & { className?: string }; type Arguments = T extends (...args: infer U) => any ? U : never; + +type Replace = Exclude extends never ? S2 : S1 extends T ? Exclude | S2 : T; diff --git a/src/lib/utils/evm.utils.ts b/src/lib/utils/evm.utils.ts index e4a30fcf8e..6d044cf73d 100644 --- a/src/lib/utils/evm.utils.ts +++ b/src/lib/utils/evm.utils.ts @@ -14,4 +14,4 @@ export const isProperCollectibleMetadata = ( ): data is NonNullableField => Boolean(data.token_id && data.token_url && data.external_data); -export const isEvmNativeTokenSlug = (slug: string) => slug === EVM_TOKEN_SLUG; +export const isEvmNativeTokenSlug = (slug: string): slug is typeof EVM_TOKEN_SLUG => slug === EVM_TOKEN_SLUG; diff --git a/src/lib/utils/queue-of-unique.ts b/src/lib/utils/queue-of-unique.ts new file mode 100644 index 0000000000..a7b42b4b0a --- /dev/null +++ b/src/lib/utils/queue-of-unique.ts @@ -0,0 +1,29 @@ +import { Mutex } from 'async-mutex'; +import { isEqual } from 'lodash'; + +export class QueueOfUnique { + private data: T[] = []; + private mutex = new Mutex(); + + constructor(private equalityFn: (a: T, b: T) => boolean = isEqual) {} + + length() { + return this.mutex.runExclusive(() => this.data.length); + } + + pop() { + return this.mutex.runExclusive(() => this.data.shift()); + } + + push(element: T) { + return this.mutex.runExclusive(() => { + if (this.data.some(e => this.equalityFn(e, element))) { + return false; + } + + this.data.push(element); + + return true; + }); + } +} diff --git a/src/temple/evm/index.ts b/src/temple/evm/index.ts index 9ff2875f16..38c4e4740a 100644 --- a/src/temple/evm/index.ts +++ b/src/temple/evm/index.ts @@ -1,5 +1,5 @@ import memoizee from 'memoizee'; -import { Transport, Chain, createPublicClient, http, PublicClient, HttpTransportConfig } from 'viem'; +import { Transport, Chain, createPublicClient, http, PublicClient, HttpTransportConfig, HttpTransport } from 'viem'; import { rejectOnTimeout } from 'lib/utils'; import { EvmChain } from 'temple/front'; @@ -16,7 +16,7 @@ const READ_ONLY_CLIENT_TRANSPORT_CONFIG: HttpTransportConfig = { }; export const getReadOnlyEvm = memoizee( - (rpcUrl: string): PublicClient => + (rpcUrl: string): PublicClient => createPublicClient({ transport: http(rpcUrl, READ_ONLY_CLIENT_TRANSPORT_CONFIG) }), diff --git a/src/temple/evm/types.ts b/src/temple/evm/types.ts index 6dad8e76fa..957d62583f 100644 --- a/src/temple/evm/types.ts +++ b/src/temple/evm/types.ts @@ -1,15 +1,90 @@ -export interface EvmTxParams { - to: HexString; - value: bigint; - gas: bigint; - maxFeePerGas: bigint; - maxPriorityFeePerGas: bigint; - nonce?: number; -} +import { + AccessList, + BlobSidecar, + ExactPartial, + FeeValuesEIP1559, + FeeValuesEIP4844, + FeeValuesLegacy, + OneOf, + PrivateKeyAccount, + RequiredBy, + SendTransactionParameters, + SendTransactionRequest, + TransactionRequestBase +} from 'viem'; +import { Signature } from 'viem/_types/types/misc'; + +import { EvmNativeTokenMetadata } from 'lib/metadata/types'; -export interface SerializableEvmTxParams extends Pick { - value: string; - gas: string; - maxFeePerGas: string; - maxPriorityFeePerGas: string; +interface RestoredChain { + id: number; + name: string; + nativeCurrency: EvmNativeTokenMetadata; + rpcUrls: { + default: { + http: string[]; + }; + }; } + +export type EvmTxParams = SendTransactionParameters< + RestoredChain, + PrivateKeyAccount, + undefined, + SendTransactionRequest +>; + +export type WithSerializedBigint>> = { + [K in keyof T]: Replace; +}; + +type SerializableFeeValuesLegacy = WithSerializedBigint; + +type SerializableFeeValuesEIP1559 = WithSerializedBigint; + +type SerializableFeeValuesEIP4844 = WithSerializedBigint; + +type SerializableEvmTxParamsBase = WithSerializedBigint>; + +type SerializableEvmTxParamsLegacy = SerializableEvmTxParamsBase<'legacy'> & ExactPartial; + +type SerializableEvmTxParamsEIP2930 = SerializableEvmTxParamsBase<'eip2930'> & + ExactPartial & { + accessList?: AccessList | undefined; + }; + +type SerializableEvmTxParamsEIP1559 = SerializableEvmTxParamsBase<'eip1559'> & + ExactPartial & { + accessList?: AccessList | undefined; + }; + +type SerializableEvmTxParamsEIP4844 = RequiredBy, 'to'> & + RequiredBy, 'maxFeePerBlobGas'> & { + accessList?: AccessList | undefined; + blobs: readonly HexString[]; + blobVersionedHashes?: readonly HexString[] | undefined; + // TODO: Add Kzg initialization params + sidecars?: readonly BlobSidecar[]; + }; + +type SerializableSignature = WithSerializedBigint; + +type SerializableAuthorization = { + contractAddress: HexString; + chainId: number; + nonce: number; +} & ExactPartial; + +type SerializableEvmTxParamsEIP7702 = SerializableEvmTxParamsBase<'eip7702'> & + ExactPartial & { + accessList?: AccessList | undefined; + authorizationList?: SerializableAuthorization[] | undefined; + }; + +export type SerializableEvmTxParams = OneOf< + | SerializableEvmTxParamsLegacy + | SerializableEvmTxParamsEIP2930 + | SerializableEvmTxParamsEIP1559 + | SerializableEvmTxParamsEIP4844 + | SerializableEvmTxParamsEIP7702 +>; diff --git a/src/temple/evm/utils.ts b/src/temple/evm/utils.ts index e6ce119602..1b6132ac93 100644 --- a/src/temple/evm/utils.ts +++ b/src/temple/evm/utils.ts @@ -1,30 +1,104 @@ import memoizee from 'memoizee'; import * as ViemChains from 'viem/chains'; -import { EvmTxParams, SerializableEvmTxParams } from './types'; +import { EvmTxParams, SerializableEvmTxParams, WithSerializedBigint } from './types'; export const getViemChainsList = memoizee(() => Object.values(ViemChains)); -export function toSerializableEvmTxParams(txParams: EvmTxParams): SerializableEvmTxParams { - return { - to: txParams.to, - value: txParams.value.toString(), - gas: txParams.gas.toString(), - maxFeePerGas: txParams.maxFeePerGas.toString(), - maxPriorityFeePerGas: txParams.maxPriorityFeePerGas.toString(), - nonce: txParams.nonce - }; +function serializeBigints>(input: T) { + const result = {} as WithSerializedBigint; + for (const key in input) { + const value = input[key]; + // @ts-expect-error + result[key] = typeof value === 'bigint' ? value.toString() : value; + } + + return result; +} + +type DeserializedBigints> = { + [K in keyof T]: Replace; +}; + +function toBigintRecord>(input: T): DeserializedBigints { + const result = {} as DeserializedBigints; + for (const key in input) { + const value = input[key]; + // @ts-expect-error + result[key] = typeof value === 'string' ? BigInt(value) : value; + } + + return result; +} + +export function toSerializableEvmTxParams(params: EvmTxParams): SerializableEvmTxParams { + switch (params.type) { + case 'legacy': + case 'eip2930': + case 'eip1559': + return serializeBigints(params); + // EIP4844 type is left for the case a dApp needs it + case 'eip4844': + const serializedBlobs = params.blobs.map( + (blob): HexString => (typeof blob === 'string' ? blob : `0x${Buffer.from(blob).toString('hex')}`) + ); + return { + ...serializeBigints(params), + blobs: serializedBlobs + }; + case 'eip7702': + const serializedAutorizationList = params.authorizationList?.map(auth => serializeBigints(auth)); + return { + ...serializeBigints(params), + authorizationList: serializedAutorizationList + }; + default: + throw new Error(`Unsupported EVM transaction type: ${params.type}`); + } } -export function fromSerializableEvmTxParams(txParams: SerializableEvmTxParams): EvmTxParams { - return { - to: txParams.to, - value: BigInt(txParams.value), - gas: BigInt(txParams.gas), - maxFeePerGas: BigInt(txParams.maxFeePerGas), - maxPriorityFeePerGas: BigInt(txParams.maxPriorityFeePerGas), - nonce: txParams.nonce - }; +export function fromSerializableEvmTxParams(params: SerializableEvmTxParams): EvmTxParams { + if (params.type === 'legacy' || params.type === 'eip2930') { + const { gas, value, gasPrice, ...restLegacy } = params; + + return { + ...restLegacy, + ...toBigintRecord({ gas, value, gasPrice }) + }; + } + + if (params.type === 'eip1559') { + const { gas, value, maxFeePerGas, maxPriorityFeePerGas, ...restProps } = params; + + return { + ...restProps, + ...toBigintRecord({ gas, value, maxFeePerGas, maxPriorityFeePerGas }) + }; + } + + if (params.type === 'eip4844') { + const { gas, value, maxFeePerGas, maxPriorityFeePerGas, maxFeePerBlobGas, ...restProps } = params; + + return { + ...restProps, + ...toBigintRecord({ gas, value, maxFeePerGas, maxPriorityFeePerGas, maxFeePerBlobGas }) + }; + } + + if (params.type === 'eip7702') { + const { gas, value, maxFeePerGas, maxPriorityFeePerGas, authorizationList, ...restProps } = params; + + return { + ...restProps, + ...toBigintRecord({ gas, value, maxFeePerGas, maxPriorityFeePerGas }), + authorizationList: authorizationList?.map(({ v, ...restAuthProps }) => ({ + ...restAuthProps, + ...toBigintRecord({ v }) + })) + }; + } + + throw new Error(`Unsupported EVM transaction type: ${params.type}`); } export function getGasPriceStep(averageGasPrice: bigint) { diff --git a/src/temple/misc.ts b/src/temple/misc.ts index ea48af28e0..6e4fdc9531 100644 --- a/src/temple/misc.ts +++ b/src/temple/misc.ts @@ -1,5 +1,3 @@ -/** TODO: Optimize via either: - * - Set to Math.max(TEZOS_DEFAULT_CHAINS.length, EVM_DEFAULT_CHAINS.length) - * - Set `{ max: 10, maxAge: number }` - */ -export const MAX_MEMOIZED_TOOLKITS = 4; +import { EVM_DEFAULT_NETWORKS, TEZOS_DEFAULT_NETWORKS } from './networks'; + +export const MAX_MEMOIZED_TOOLKITS = Math.max(TEZOS_DEFAULT_NETWORKS.length, EVM_DEFAULT_NETWORKS.length) * 2;