From 1ac10948baedc952aa349af392eea02796ee0971 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 10 Jul 2024 15:46:15 +1000 Subject: [PATCH 1/3] feat: custom token imports (#7285) * chore: update search copy * wip: changing context * chore: request token data from alchemy * chore: use asset groups * chore: use tanstack queries * chore: import click tracer * chore: show import button * chore: use makeAsset * chore: upsert asset on import * chore: add token import feature flag * chore: wire up warning ack * refactor: abstract CustomAssetAcknowledgement * chore: add token box in ack * chore: style improvements * chore: allow full-width warning content * fix: query must not return undefined * feat: add address copy button * chore: pre-review tidy-up * chore: fix translation str typo * chore: use getQueryFn * chore: use getCustomTokenQueryKey helper * fix: use mergeQueryOutputs helper * chore: review fixes * chore: support bsc asset namespace & better naming * fix: warning content breakpoint --- .env.base | 1 + .env.dev | 1 + .env.develop | 1 + package.json | 2 +- react-app-rewired/headers/csps/alchemy.ts | 2 + src/assets/translations/en/main.json | 8 +- .../Acknowledgement/Acknowledgement.tsx | 2 +- .../TradeAssetSearch/TradeAssetSearch.tsx | 20 +- .../GroupedAssetList/GroupedAssetList.tsx | 7 +- .../components/GroupedAssetRow.tsx | 195 +++++++++++++----- .../components/SearchTermAssetList.tsx | 69 ++++++- .../hooks/CustomAssetAcknowledgement.tsx | 177 ++++++++++++++++ .../hooks/useGetCustomTokensQuery.tsx | 68 ++++++ src/config.ts | 1 + .../market-service/market-service-manager.ts | 2 + src/react-queries/helpers.ts | 2 +- .../preferencesSlice/preferencesSlice.ts | 2 + src/test/mocks/store.ts | 1 + yarn.lock | 10 +- 19 files changed, 492 insertions(+), 79 deletions(-) create mode 100644 src/components/TradeAssetSearch/hooks/CustomAssetAcknowledgement.tsx create mode 100644 src/components/TradeAssetSearch/hooks/useGetCustomTokensQuery.tsx diff --git a/.env.base b/.env.base index 472b84cd433..18d15977be9 100644 --- a/.env.base +++ b/.env.base @@ -38,6 +38,7 @@ REACT_APP_FEATURE_ACCOUNT_MANAGEMENT_LEDGER=true REACT_APP_FEATURE_RFOX=true REACT_APP_FEATURE_RFOX_REWARDS_TX_HISTORY=false REACT_APP_FEATURE_ARBITRUM_BRIDGE=false +REACT_APP_FEATURE_CUSTOM_TOKEN_IMPORT=false # absolute URL prefix REACT_APP_ABSOLUTE_URL_PREFIX=https://app.shapeshift.com diff --git a/.env.dev b/.env.dev index f24ee8a1a47..c037e1fa913 100644 --- a/.env.dev +++ b/.env.dev @@ -2,6 +2,7 @@ REACT_APP_FEATURE_RFOX_REWARDS_TX_HISTORY=true REACT_APP_FEATURE_ARBITRUM_BRIDGE=true REACT_APP_FEATURE_COWSWAP_ARBITRUM=true +REACT_APP_FEATURE_CUSTOM_TOKEN_IMPORT=true # logging REACT_APP_REDUX_WINDOW=false diff --git a/.env.develop b/.env.develop index 6e4fd9af050..beac65438ef 100644 --- a/.env.develop +++ b/.env.develop @@ -3,6 +3,7 @@ REACT_APP_FEATURE_RFOX_REWARDS_TX_HISTORY=true REACT_APP_FEATURE_CHATWOOT=true REACT_APP_FEATURE_ARBITRUM_BRIDGE=true REACT_APP_FEATURE_COWSWAP_ARBITRUM=true +REACT_APP_FEATURE_CUSTOM_TOKEN_IMPORT=true # mixpanel REACT_APP_MIXPANEL_TOKEN=1c1369f6ea23a6404bac41b42817cc4b diff --git a/package.json b/package.json index f560f5c63e1..27d9b6bc789 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,7 @@ "react-swipeable-views-utils": "^0.14.0", "react-table": "^7.8.0", "react-virtualized-auto-sizer": "^1.0.5", - "react-virtuoso": "^4.7.1", + "react-virtuoso": "^4.7.11", "react-window": "^1.8.7", "redux-persist": "^6.0.0", "reselect": "^4.1.6", diff --git a/react-app-rewired/headers/csps/alchemy.ts b/react-app-rewired/headers/csps/alchemy.ts index f059de77326..3f286ffe9b5 100644 --- a/react-app-rewired/headers/csps/alchemy.ts +++ b/react-app-rewired/headers/csps/alchemy.ts @@ -14,5 +14,7 @@ export const csp: Csp = { 'https://base-mainnet.g.alchemy.com/nft/v3/', // Mercle IPNS gateway for NFT resolution 'https://backend.mercle.xyz/ipns/', + // Custom token metadata resolution + 'https://*.g.alchemy.com/v2/', ], } diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index b53198166c0..c1df51af822 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -109,6 +109,7 @@ "chartUnavailable": "Balance chart data is temporarily unavailable for %{unavailableAssetNames}.", "search": "Search", "searchAsset": "Search for asset", + "searchNameOrAddress": "Search by name or address", "enterAmount": "Enter Amount", "enterAddress": "Enter Address", "pageNotFound": "The page you're looking for cannot be found", @@ -150,6 +151,7 @@ "prices": "Prices", "symmetric": "Symmetric", "asymmetric": "Asymmetric (%{assetSymbol})", + "import": "Import", "carousel": { "next": "Next", "prev": "Previous", @@ -2372,11 +2374,15 @@ "highSlippageDeposit": "This deposit has high slippage (%{slippagePercentage}%). Proceed with caution.", "highSlippageBorrow": "This borrow has high slippage (%{slippagePercentage}%). Proceed with caution.", "highSlippageTrade": "This trade is impacted by price movement (%{slippagePercentage}%). Proceed with caution.", - "unsafeTrade": "This trade may be unsafe or below the recommended minimum size, proceed with caution." + "unsafeTrade": "This trade may be unsafe or below the recommended minimum size, proceed with caution.", + "customToken": "Anyone can create tokens, including fake ones, so caution is needed. Some tokens may not work with ShapeShift. By importing this token, you accept the risks. This token is not traded on major U.S. exchanges or frequently swapped on ShapeShift. Always research before trading." }, "streamingAcknowledgement": { "description": "This is a streaming swap, while you get a better price it can take up to %{estimatedTimeHuman} to complete." }, + "customTokenAcknowledgement": { + "understand": "I understand the risks" + }, "watchlist": { "empty": { "title": "Start building your watchlist", diff --git a/src/components/Acknowledgement/Acknowledgement.tsx b/src/components/Acknowledgement/Acknowledgement.tsx index 05cbf450335..258c6cf27a1 100644 --- a/src/components/Acknowledgement/Acknowledgement.tsx +++ b/src/components/Acknowledgement/Acknowledgement.tsx @@ -179,7 +179,7 @@ export const Acknowledgement = ({ /> = ({ const translate = useTranslate() const history = useHistory() const [activeChainId, setActiveChainId] = useState('All') + const [assetToImport, setAssetToImport] = useState(undefined) + const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false) const portfolioAssetsSortedByBalance = useAppSelector( selectPortfolioFungibleAssetsSortedByBalance, @@ -84,7 +87,7 @@ export const TradeAssetSearch: FC = ({ () => ({ ...register('search'), type: 'text', - placeholder: translate('common.searchAsset'), + placeholder: translate('common.searchNameOrAddress'), pl: 10, variant: 'filled', borderWidth: 0, @@ -170,8 +173,18 @@ export const TradeAssetSearch: FC = ({ }) }, [handleAssetClick, isPopularAssetIdsLoading, quickAccessAssets]) + const handleImportIntent = useCallback((asset: Asset) => { + setAssetToImport(asset) + setShouldShowWarningAcknowledgement(true) + }, []) + return ( - <> + = ({ activeChainId={activeChainId} searchString={searchString} onAssetClick={handleAssetClick} + onImportClick={handleImportIntent} isLoading={isPopularAssetIdsLoading} allowWalletUnsupportedAssets={allowWalletUnsupportedAssets} /> @@ -220,6 +234,6 @@ export const TradeAssetSearch: FC = ({ onAssetClick={handleAssetClick} /> )} - + ) } diff --git a/src/components/TradeAssetSearch/components/GroupedAssetList/GroupedAssetList.tsx b/src/components/TradeAssetSearch/components/GroupedAssetList/GroupedAssetList.tsx index 49d6ce6aada..f956f638b3e 100644 --- a/src/components/TradeAssetSearch/components/GroupedAssetList/GroupedAssetList.tsx +++ b/src/components/TradeAssetSearch/components/GroupedAssetList/GroupedAssetList.tsx @@ -21,6 +21,7 @@ export type GroupedAssetListProps = { groupCounts: number[] groupIsLoading: boolean[] onAssetClick: (asset: Asset) => void + onImportClick?: (asset: Asset) => void hideZeroBalanceAmounts: boolean } @@ -30,6 +31,7 @@ export const GroupedAssetList = ({ groupCounts, groupIsLoading, onAssetClick, + onImportClick, hideZeroBalanceAmounts, }: GroupedAssetListProps) => { const renderGroupContent = useCallback( @@ -68,13 +70,14 @@ export const GroupedAssetList = ({ return ( ) }, - [assets, hideZeroBalanceAmounts, onAssetClick], + [assets, hideZeroBalanceAmounts, onAssetClick, onImportClick], ) return ( diff --git a/src/components/TradeAssetSearch/components/GroupedAssetList/components/GroupedAssetRow.tsx b/src/components/TradeAssetSearch/components/GroupedAssetList/components/GroupedAssetRow.tsx index 6eb59bae659..86cd66a0b7c 100644 --- a/src/components/TradeAssetSearch/components/GroupedAssetList/components/GroupedAssetRow.tsx +++ b/src/components/TradeAssetSearch/components/GroupedAssetList/components/GroupedAssetRow.tsx @@ -1,8 +1,11 @@ import { Box, Button, Flex, Text, useColorModeValue } from '@chakra-ui/react' +import { fromAssetId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import { useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' import { AssetIcon } from 'components/AssetIcon' +import { InlineCopyButton } from 'components/InlineCopyButton' import { useWallet } from 'hooks/useWallet/useWallet' import { bnOrZero } from 'lib/bignumber/bignumber' import { firstNonZeroDecimal } from 'lib/math' @@ -22,21 +25,27 @@ export type GroupedAssetRowProps = { assets: Asset[] hideZeroBalanceAmounts: boolean index: number - onClick: (asset: Asset) => void + onAssetClick: (asset: Asset) => void + onImportClick?: (asset: Asset) => void } export const GroupedAssetRow = ({ index, - onClick, + onAssetClick, assets, hideZeroBalanceAmounts, + onImportClick, }: GroupedAssetRowProps) => { const color = useColorModeValue('text.subtle', 'whiteAlpha.500') + const backgroundColor = useColorModeValue('gray.50', 'background.button.secondary.base') + const translate = useTranslate() const { state: { isConnected, isDemoWallet, wallet }, } = useWallet() const asset: Asset | undefined = assets[index] const assetId = asset?.assetId + // If the asset isn't in the store we are rendering a custom token + const isAssetInStore = useAppSelector(s => s.assets.ids.some(a => a === assetId)) const filter = useMemo(() => ({ assetId }), [assetId]) const isSupported = assetId && wallet && isAssetSupportedByWallet(assetId, wallet) const cryptoPrecisionBalance = useAppSelector(s => @@ -44,64 +53,140 @@ export const GroupedAssetRow = ({ ) const userCurrencyBalance = useAppSelector(s => selectPortfolioUserCurrencyBalanceByAssetId(s, filter)) ?? '0' - const handleClick = useCallback(() => onClick(asset), [asset, onClick]) - if (!asset) return null + const handleAssetClick = useCallback(() => { + onAssetClick(asset) + }, [asset, onAssetClick]) + + const handleImportClick = useCallback(() => { + if (onImportClick) { + onImportClick(asset) + } + }, [asset, onImportClick]) const hideAssetBalance = !!(hideZeroBalanceAmounts && bnOrZero(cryptoPrecisionBalance).isZero()) - return ( - + ) + }, [ + asset, + color, + cryptoPrecisionBalance, + handleAssetClick, + hideAssetBalance, + isConnected, + isDemoWallet, + isSupported, + userCurrencyBalance, + ]) + + const CustomAssetRow: JSX.Element | null = useMemo(() => { + if (!asset) return null + return ( + - )} - - ) + + ) + }, [asset, assetId, backgroundColor, color, handleImportClick, isSupported, translate]) + + return isAssetInStore ? KnownAssetRow : CustomAssetRow } diff --git a/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx b/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx index a6900a5a941..908211b2a59 100644 --- a/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx +++ b/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx @@ -1,13 +1,18 @@ -import type { ChainId } from '@shapeshiftoss/caip' +import { ASSET_NAMESPACE, bscChainId, type ChainId, toAssetId } from '@shapeshiftoss/caip' +import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { Asset } from '@shapeshiftoss/types' +import { makeAsset, type MinimalAsset } from '@shapeshiftoss/utils' import { useMemo } from 'react' +import { isSome } from 'lib/utils' import { selectAssetsSortedByName, selectWalletConnectedChainIds, } from 'state/slices/common-selectors' +import { selectAssets } from 'state/slices/selectors' import { useAppSelector } from 'state/store' import { filterAssetsBySearchTerm } from '../helpers/filterAssetsBySearchTerm/filterAssetsBySearchTerm' +import { useGetCustomTokensQuery } from '../hooks/useGetCustomTokensQuery' import { GroupedAssetList } from './GroupedAssetList/GroupedAssetList' export type SearchTermAssetListProps = { @@ -16,21 +21,29 @@ export type SearchTermAssetListProps = { searchString: string allowWalletUnsupportedAssets: boolean | undefined onAssetClick: (asset: Asset) => void + onImportClick: (asset: Asset) => void } export const SearchTermAssetList = ({ - isLoading, + isLoading: isAssetListLoading, activeChainId, searchString, allowWalletUnsupportedAssets, onAssetClick, + onImportClick, }: SearchTermAssetListProps) => { const assets = useAppSelector(selectAssetsSortedByName) - const groupIsLoading = useMemo(() => { - return [Boolean(isLoading)] - }, [isLoading]) - + const assetsById = useAppSelector(selectAssets) const walletConnectedChainIds = useAppSelector(selectWalletConnectedChainIds) + const chainIds = useMemo( + () => (activeChainId === 'All' ? walletConnectedChainIds : [activeChainId]), + [activeChainId, walletConnectedChainIds], + ) + const walletSupportedEvmChainIds = useMemo(() => chainIds.filter(isEvmChainId), [chainIds]) + const { data: customTokens, isLoading: isLoadingCustomTokens } = useGetCustomTokensQuery({ + contractAddress: searchString, + chainIds: walletSupportedEvmChainIds, + }) const assetsForChain = useMemo(() => { if (activeChainId === 'All') { @@ -44,16 +57,51 @@ export const SearchTermAssetList = ({ return assets.filter(asset => asset.chainId === activeChainId) }, [activeChainId, allowWalletUnsupportedAssets, assets, walletConnectedChainIds]) + const customAssets: Asset[] = useMemo( + () => + customTokens + ? customTokens + .filter(isSome) + .map(metaData => { + const { name, symbol, decimals, logo } = metaData + // If we can't get all the information we need to create an Asset, don't allow the custom token + if (!name || !symbol || !decimals) return null + const assetId = toAssetId({ + chainId: metaData.chainId, + assetNamespace: + metaData.chainId === bscChainId ? ASSET_NAMESPACE.bep20 : ASSET_NAMESPACE.erc20, + assetReference: metaData.contractAddress, + }) + const minimalAsset: MinimalAsset = { + assetId, + name, + symbol, + precision: decimals, + icon: logo ?? undefined, + } + return makeAsset(assetsById, minimalAsset) + }) + .filter(isSome) + : [], + [assetsById, customTokens], + ) + + // We only want to show custom assets that aren't already in the asset list const searchTermAssets = useMemo(() => { - return filterAssetsBySearchTerm(searchString, assetsForChain) - }, [searchString, assetsForChain]) + const filteredAssets = filterAssetsBySearchTerm(searchString, assetsForChain) + const existingAssetIds = new Set(filteredAssets.map(asset => asset.assetId)) + const uniqueCustomAssets = customAssets.filter(asset => !existingAssetIds.has(asset.assetId)) + + return filteredAssets.concat(uniqueCustomAssets) + }, [assetsForChain, customAssets, searchString]) - const { groups, groupCounts } = useMemo(() => { + const { groups, groupCounts, groupIsLoading } = useMemo(() => { return { groups: ['modals.assetSearch.searchResults'], groupCounts: [searchTermAssets.length], + groupIsLoading: [isLoadingCustomTokens || isAssetListLoading], } - }, [searchTermAssets.length]) + }, [isAssetListLoading, isLoadingCustomTokens, searchTermAssets.length]) return ( ) } diff --git a/src/components/TradeAssetSearch/hooks/CustomAssetAcknowledgement.tsx b/src/components/TradeAssetSearch/hooks/CustomAssetAcknowledgement.tsx new file mode 100644 index 00000000000..4db13f87216 --- /dev/null +++ b/src/components/TradeAssetSearch/hooks/CustomAssetAcknowledgement.tsx @@ -0,0 +1,177 @@ +import { ExternalLinkIcon } from '@chakra-ui/icons' +import { + Box, + Center, + Checkbox, + Flex, + Link, + type ResponsiveValue, + Text, + useBreakpointValue, + useColorModeValue, +} from '@chakra-ui/react' +import { fromAssetId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' +import type * as CSS from 'csstype' +import { type PropsWithChildren, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { WarningAcknowledgement } from 'components/Acknowledgement/Acknowledgement' +import { AssetIcon } from 'components/AssetIcon' +import { InlineCopyButton } from 'components/InlineCopyButton' +import { useToggle } from 'hooks/useToggle/useToggle' +import { middleEllipsis } from 'lib/utils' +import { assets as assetsSlice } from 'state/slices/assetsSlice/assetsSlice' +import { marketData as marketDataSlice } from 'state/slices/marketDataSlice/marketDataSlice' +import { useAppDispatch } from 'state/store' + +const externalLinkIcon = + +type CustomAssetAcknowledgementProps = { + asset: Asset | undefined + handleAssetClick: (asset: Asset) => void + shouldShowWarningAcknowledgement: boolean + setShouldShowWarningAcknowledgement: (shouldShow: boolean) => void +} & PropsWithChildren + +const extractAndCapitalizeDomain = (url: string): string => { + try { + const urlObj = new URL(url) + const hostParts = urlObj.hostname.split('.') + const domain = hostParts[hostParts.length - 2] + + if (domain === undefined) { + return '' + } + + return domain.charAt(0).toUpperCase() + domain.slice(1) + } catch (error) { + console.error('Invalid URL:', error) + return '' + } +} + +export const CustomAssetAcknowledgement: React.FC = ({ + children, + asset, + handleAssetClick, + shouldShowWarningAcknowledgement, + setShouldShowWarningAcknowledgement, +}) => { + const translate = useTranslate() + const dispatch = useAppDispatch() + const color = useColorModeValue('text.subtle', 'whiteAlpha.500') + + const [hasAcknowledged, toggleHasAcknowledged] = useToggle(false) + + const onImportClick = useCallback(() => { + if (!asset) return + + // Add asset to the store + dispatch(assetsSlice.actions.upsertAsset(asset)) + + // Add market data to the store + dispatch( + marketDataSlice.actions.setCryptoMarketData({ + [asset.assetId]: { price: '0', marketCap: '0', volume: '0', changePercent24Hr: 0 }, + }), + ) + + // Once the custom asset is in the store, proceed as if it was a normal asset + handleAssetClick(asset) + }, [dispatch, handleAssetClick, asset]) + + const checkboxTextColor = useColorModeValue('gray.800', 'gray.50') + const backgroundColor = useColorModeValue('gray.100', 'darkNeutralAlpha.700') + const flexDirection: ResponsiveValue | undefined = useBreakpointValue( + { + base: 'column', + sm: 'row', + }, + ) + + const CustomAssetRow: JSX.Element | null = useMemo(() => { + if (!asset) return null + return ( + + + + + + {asset.name} + + + {asset.symbol} + + + + {middleEllipsis(fromAssetId(asset.assetId).assetReference)} + + + + + + + + + {extractAndCapitalizeDomain(asset.explorerAddressLink)} + {externalLinkIcon} + + + + ) + }, [asset, backgroundColor, color, flexDirection]) + + const Content: JSX.Element = useMemo( + () => ( +
+ + + {translate('customTokenAcknowledgement.understand')} + + + {CustomAssetRow} +
+ ), + [CustomAssetRow, checkboxTextColor, toggleHasAcknowledged, translate], + ) + + return ( + + {children} + + ) +} diff --git a/src/components/TradeAssetSearch/hooks/useGetCustomTokensQuery.tsx b/src/components/TradeAssetSearch/hooks/useGetCustomTokensQuery.tsx new file mode 100644 index 00000000000..0412f8f4c3d --- /dev/null +++ b/src/components/TradeAssetSearch/hooks/useGetCustomTokensQuery.tsx @@ -0,0 +1,68 @@ +import { type ChainId } from '@shapeshiftoss/caip' +import { useQueries, type UseQueryResult } from '@tanstack/react-query' +import type { TokenMetadataResponse } from 'alchemy-sdk' +import { useCallback, useMemo } from 'react' +import { mergeQueryOutputs } from 'react-queries/helpers' +import { isAddress } from 'viem' +import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' +import { getAlchemyInstanceByChainId } from 'lib/alchemySdkInstance' + +type TokenMetadata = TokenMetadataResponse & { + chainId: ChainId + contractAddress: string + price: string +} + +type UseGetCustomTokensQueryProps = { + contractAddress: string + chainIds: ChainId[] +} + +type CustomTokenQueryKey = ['customTokens', string, ChainId] + +const getCustomTokenQueryKey = (contractAddress: string, chainId: ChainId): CustomTokenQueryKey => [ + 'customTokens', + contractAddress, + chainId, +] + +export const useGetCustomTokensQuery = ({ + contractAddress, + chainIds, +}: UseGetCustomTokensQueryProps): UseQueryResult<(TokenMetadata | null)[], Error[]> => { + const customTokenImportEnabled = useFeatureFlag('CustomTokenImport') + + const getTokenMetadata = useCallback( + async (chainId: ChainId) => { + const alchemy = getAlchemyInstanceByChainId(chainId) + const tokenMetadataResponse = await alchemy.core.getTokenMetadata(contractAddress) + // TODO: get price from somewhere + return { ...tokenMetadataResponse, chainId, contractAddress, price: '0' } + }, + [contractAddress], + ) + + const isValidEvmAddress = useMemo( + () => isAddress(contractAddress, { strict: false }), + [contractAddress], + ) + + const getQueryFn = useCallback( + (chainId: ChainId) => () => getTokenMetadata(chainId), + [getTokenMetadata], + ) + + const customTokenQueries = useQueries({ + queries: isValidEvmAddress + ? chainIds.map(chainId => ({ + queryKey: getCustomTokenQueryKey(contractAddress, chainId), + queryFn: getQueryFn(chainId), + enabled: customTokenImportEnabled, + staleTime: Infinity, + })) + : [], + combine: queries => mergeQueryOutputs(queries, results => results), + }) + + return customTokenQueries +} diff --git a/src/config.ts b/src/config.ts index 06b38db9fdb..bc8a1cc8db5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -94,6 +94,7 @@ const validators = { REACT_APP_FEATURE_SAVERS_VAULTS: bool({ default: false }), REACT_APP_FEATURE_SAVERS_VAULTS_DEPOSIT: bool({ default: false }), REACT_APP_FEATURE_SAVERS_VAULTS_WITHDRAW: bool({ default: false }), + REACT_APP_FEATURE_CUSTOM_TOKEN_IMPORT: bool({ default: false }), // A flag encapsulating all WalletConnect to dApps - v1 and v2 REACT_APP_FEATURE_WALLET_CONNECT_TO_DAPPS: bool({ default: false }), REACT_APP_FEATURE_WALLET_CONNECT_TO_DAPPS_V2: bool({ default: false }), diff --git a/src/lib/market-service/market-service-manager.ts b/src/lib/market-service/market-service-manager.ts index 61b9acfba19..a0c249ea53c 100644 --- a/src/lib/market-service/market-service-manager.ts +++ b/src/lib/market-service/market-service-manager.ts @@ -52,6 +52,8 @@ export class MarketServiceManager { // new YearnVaultMarketCapService({ yearnSdk }), // new YearnTokenMarketCapService({ yearnSdk }), new FoxyMarketService({ providerUrls, provider }), + // TODO: Zerion market provider + // TODO: Debank market provider ] this.assetService = new AssetService() diff --git a/src/react-queries/helpers.ts b/src/react-queries/helpers.ts index 3a5dab44807..c272814a8eb 100644 --- a/src/react-queries/helpers.ts +++ b/src/react-queries/helpers.ts @@ -34,7 +34,7 @@ export const mergeQueryOutputs = =16 || >=17 || >= 18" react-dom: ">=16 || >=17 || >= 18" - checksum: c864095bd875825c2ce25b69f165fe29ad7bea2d54c31b407b3b99347f6b68311a6928566369f45641cd2f5a6e8540fbafca87d808c0495c12196cb1665145d5 + checksum: 3e9b56e8bd2ae88b04563ff3ce221c95db493741ff89667475e390a8abfb202a9b0692cbe9edd822316a27c902c835ce0e287b036df6644909317c7a6c1462fc languageName: node linkType: hard From b2c6293e8df12ec7cf37d514ace6bd917d4ca2fa Mon Sep 17 00:00:00 2001 From: firebomb1 <88804546+firebomb1@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:42:48 +0200 Subject: [PATCH 2/3] fix: rfox address change fr translation (#7350) --- src/assets/translations/fr/main.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 099cf7eee53..de0bfee45fc 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -2412,7 +2412,7 @@ "rewards": "Récompenses", "claims": "Réclamations", "chainNotSupportedByWallet": "Chaîne non prise en charge par le porte-monnaie", - "changeAddress": "Adr. de change", + "changeAddress": "Changer d'adresse", "sameAddressNotAllowed": "Même adresse non autorisée", "stakingDetails": "Détails de mise", "stakeAmount": "Montant à miser", From 1a36c1f8887e2aa1e34a261298bcbade7fe1dd15 Mon Sep 17 00:00:00 2001 From: woody <125113430+woodenfurniture@users.noreply.github.com> Date: Thu, 11 Jul 2024 07:08:12 +1000 Subject: [PATCH 3/3] fix: revert detect smart contracts for every evm chains (#7356) This reverts commit 3e70f295e65bc9eedfd5fa714b53e4ecae2d1972. --- .../components/TradeInput/TradeInput.tsx | 4 +-- .../Deposit/components/Confirm.tsx | 2 +- .../Withdraw/components/Confirm.tsx | 2 +- .../useIsSmartContractAddress.ts | 33 +++++++++---------- src/lib/address/utils.ts | 10 ++---- src/lib/ethersProviderSingleton.ts | 4 ++- src/lib/fees/utils.test.ts | 3 +- src/lib/fees/utils.ts | 7 ++-- .../Pool/components/Borrow/BorrowInput.tsx | 2 +- .../Pool/components/Repay/RepayInput.tsx | 2 +- .../AddLiquidity/AddLiquidityInput.tsx | 2 +- .../swapper/helpers/validateTradeQuote.ts | 10 ++---- .../resolvers/uniV2/index.ts | 2 +- 13 files changed, 35 insertions(+), 48 deletions(-) diff --git a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx index 1e79bd27f1c..9cf7e025d62 100644 --- a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx @@ -273,10 +273,10 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { const receiveAddress = manualReceiveAddress ?? walletReceiveAddress const { data: _isSmartContractSellAddress, isLoading: isSellAddressByteCodeLoading } = - useIsSmartContractAddress(userAddress, sellAsset.chainId) + useIsSmartContractAddress(userAddress) const { data: _isSmartContractReceiveAddress, isLoading: isReceiveAddressByteCodeLoading } = - useIsSmartContractAddress(receiveAddress ?? '', buyAsset.chainId) + useIsSmartContractAddress(receiveAddress ?? '') const disableSmartContractSwap = useMemo(() => { // Swappers other than THORChain shouldn't be affected by this limitation diff --git a/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Confirm.tsx b/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Confirm.tsx index c515342a8e7..c347dce8ec7 100644 --- a/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Confirm.tsx +++ b/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Confirm.tsx @@ -334,7 +334,7 @@ export const Confirm: React.FC = ({ accountId, onNext }) => { ) const { data: _isSmartContractAddress, isLoading: isAddressByteCodeLoading } = - useIsSmartContractAddress(fromAddress ?? '', chainId) + useIsSmartContractAddress(fromAddress ?? '') const disableSmartContractDeposit = useMemo(() => { // This is either a smart contract address, or the bytecode is still loading - disable confirm diff --git a/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Withdraw/components/Confirm.tsx b/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Withdraw/components/Confirm.tsx index eda7a31cc98..98e7c16a168 100644 --- a/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Withdraw/components/Confirm.tsx +++ b/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Withdraw/components/Confirm.tsx @@ -451,7 +451,7 @@ export const Confirm: React.FC = ({ accountId, onNext }) => { ) const { data: _isSmartContractAddress, isLoading: isAddressByteCodeLoading } = - useIsSmartContractAddress(userAddress, chainId) + useIsSmartContractAddress(userAddress) const disableSmartContractWithdraw = useMemo(() => { // This is either a smart contract address, or the bytecode is still loading - disable confirm diff --git a/src/hooks/useIsSmartContractAddress/useIsSmartContractAddress.ts b/src/hooks/useIsSmartContractAddress/useIsSmartContractAddress.ts index 73b097944ce..cd515c0acb0 100644 --- a/src/hooks/useIsSmartContractAddress/useIsSmartContractAddress.ts +++ b/src/hooks/useIsSmartContractAddress/useIsSmartContractAddress.ts @@ -1,26 +1,25 @@ -import type { ChainId } from '@shapeshiftoss/caip' -import { isEvmChainId } from '@shapeshiftoss/chain-adapters' -import { skipToken, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { isSmartContractAddress } from 'lib/address/utils' -export const useIsSmartContractAddress = (address: string, chainId: ChainId) => { +export const useIsSmartContractAddress = (address: string) => { // Lowercase the address to ensure proper caching const userAddress = useMemo(() => address.toLowerCase(), [address]) - const query = useQuery({ - queryKey: [ - 'isSmartContractAddress', - { - userAddress, - chainId, - }, - ], - queryFn: - isEvmChainId(chainId) && Boolean(userAddress.length) - ? () => isSmartContractAddress(userAddress, chainId) - : skipToken, - }) + const queryParams = useMemo(() => { + return { + queryKey: [ + 'isSmartContractAddress', + { + userAddress, + }, + ], + queryFn: () => isSmartContractAddress(userAddress), + enabled: Boolean(userAddress.length), + } + }, [userAddress]) + + const query = useQuery(queryParams) return query } diff --git a/src/lib/address/utils.ts b/src/lib/address/utils.ts index 40cc648f93d..527e1ba1192 100644 --- a/src/lib/address/utils.ts +++ b/src/lib/address/utils.ts @@ -1,16 +1,10 @@ -import type { ChainId } from '@shapeshiftoss/caip' -import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import { isAddress } from 'viem' import { getEthersProvider } from 'lib/ethersProviderSingleton' export const isEthAddress = (address: string): boolean => /^0x[0-9A-Fa-f]{40}$/.test(address) -export const isSmartContractAddress = async ( - address: string, - chainId: ChainId, -): Promise => { +export const isSmartContractAddress = async (address: string): Promise => { if (!isAddress(address)) return false - if (!isEvmChainId(chainId)) return false - const bytecode = await getEthersProvider(chainId).getCode(address) + const bytecode = await getEthersProvider().getCode(address) return bytecode !== '0x' } diff --git a/src/lib/ethersProviderSingleton.ts b/src/lib/ethersProviderSingleton.ts index 16995f5b9eb..5e65bb76738 100644 --- a/src/lib/ethersProviderSingleton.ts +++ b/src/lib/ethersProviderSingleton.ts @@ -35,7 +35,9 @@ export const rpcUrlByChainId = (chainId: EvmChainId): string => { const ethersProviders: Map = new Map() const ethersV5Providers: Map = new Map() -export const getEthersProvider = (chainId: EvmChainId): JsonRpcProvider => { +export const getEthersProvider = ( + chainId: EvmChainId = KnownChainIds.EthereumMainnet, +): JsonRpcProvider => { if (!ethersProviders.has(chainId)) { const provider = new JsonRpcProvider(rpcUrlByChainId(chainId), undefined, { staticNetwork: true, diff --git a/src/lib/fees/utils.test.ts b/src/lib/fees/utils.test.ts index edb28844c1a..c9ea5a2cb0c 100644 --- a/src/lib/fees/utils.test.ts +++ b/src/lib/fees/utils.test.ts @@ -1,4 +1,3 @@ -import { KnownChainIds } from '@shapeshiftoss/types' import type { Block } from 'ethers' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getEthersProvider } from 'lib/ethersProviderSingleton' @@ -7,7 +6,7 @@ import { findClosestFoxDiscountDelayBlockNumber } from './utils' vi.unmock('ethers') -const getBlockSpy = vi.spyOn(getEthersProvider(KnownChainIds.EthereumMainnet), 'getBlock') +const getBlockSpy = vi.spyOn(getEthersProvider(), 'getBlock') describe('findClosestFoxDiscountDelayBlockNumber', () => { beforeEach(() => { diff --git a/src/lib/fees/utils.ts b/src/lib/fees/utils.ts index af728938bb5..e7343d8fff2 100644 --- a/src/lib/fees/utils.ts +++ b/src/lib/fees/utils.ts @@ -1,4 +1,3 @@ -import { KnownChainIds } from '@shapeshiftoss/types' import { getEthersProvider } from 'lib/ethersProviderSingleton' export const AVERAGE_BLOCK_TIME_BLOCKS = 1000 @@ -6,14 +5,14 @@ export const AVERAGE_BLOCK_TIME_BLOCKS = 1000 export const findClosestFoxDiscountDelayBlockNumber = async ( delayHours: number, ): Promise => { - const latestBlock = await getEthersProvider(KnownChainIds.EthereumMainnet).getBlock('latest') + const latestBlock = await getEthersProvider().getBlock('latest') if (!latestBlock) throw new Error('Could not get latest block') // No-op - if delay is zero, we don't need to perform any logic to find the closest FOX discounts delay block number // Since the block we're interested in is the current one if (delayHours === 0) return latestBlock.number - const historicalBlock = await getEthersProvider(KnownChainIds.EthereumMainnet).getBlock( + const historicalBlock = await getEthersProvider().getBlock( latestBlock.number - AVERAGE_BLOCK_TIME_BLOCKS, ) if (!historicalBlock) @@ -28,7 +27,7 @@ export const findClosestFoxDiscountDelayBlockNumber = async ( let blockNumber = latestBlock.number - targetBlocksToMove while (true) { - const block = await getEthersProvider(KnownChainIds.EthereumMainnet).getBlock(blockNumber) + const block = await getEthersProvider().getBlock(blockNumber) if (!block) throw new Error(`Could not get block ${blockNumber}`) const timeDifference = targetTimestamp - block.timestamp diff --git a/src/pages/Lending/Pool/components/Borrow/BorrowInput.tsx b/src/pages/Lending/Pool/components/Borrow/BorrowInput.tsx index d714b6b66df..89e59cc2cfb 100644 --- a/src/pages/Lending/Pool/components/Borrow/BorrowInput.tsx +++ b/src/pages/Lending/Pool/components/Borrow/BorrowInput.tsx @@ -358,7 +358,7 @@ export const BorrowInput = ({ }, [collateralAccountId]) const { data: _isSmartContractAddress, isLoading: isAddressByteCodeLoading } = - useIsSmartContractAddress(userAddress, borrowAsset?.chainId ?? '') + useIsSmartContractAddress(userAddress) const disableSmartContractDeposit = useMemo(() => { // This is either a smart contract address, or the bytecode is still loading - disable confirm diff --git a/src/pages/Lending/Pool/components/Repay/RepayInput.tsx b/src/pages/Lending/Pool/components/Repay/RepayInput.tsx index 9132a606867..f7cd2487d5f 100644 --- a/src/pages/Lending/Pool/components/Repay/RepayInput.tsx +++ b/src/pages/Lending/Pool/components/Repay/RepayInput.tsx @@ -461,7 +461,7 @@ export const RepayInput = ({ ]) const { data: _isSmartContractAddress, isLoading: isAddressByteCodeLoading } = - useIsSmartContractAddress(userAddress, repaymentAsset?.chainId ?? '') + useIsSmartContractAddress(userAddress) const disableSmartContractRepayment = useMemo(() => { // This is either a smart contract address, or the bytecode is still loading - disable confirm diff --git a/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx b/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx index 8aef2f11fbc..41f08bdfbad 100644 --- a/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx +++ b/src/pages/ThorChainLP/components/AddLiquidity/AddLiquidityInput.tsx @@ -242,7 +242,7 @@ export const AddLiquidityInput: React.FC = ({ }) const { data: isSmartContractAccountAddress, isLoading: isSmartContractAccountAddressLoading } = - useIsSmartContractAddress(poolAssetAccountAddress ?? '', poolAsset?.chainId ?? '') + useIsSmartContractAddress(poolAssetAccountAddress ?? '') const accountIdsByAssetId = useAppSelector(selectPortfolioAccountIdsByAssetId) diff --git a/src/state/apis/swapper/helpers/validateTradeQuote.ts b/src/state/apis/swapper/helpers/validateTradeQuote.ts index cc96fc0658a..e9e9df2546f 100644 --- a/src/state/apis/swapper/helpers/validateTradeQuote.ts +++ b/src/state/apis/swapper/helpers/validateTradeQuote.ts @@ -238,14 +238,8 @@ export const validateTradeQuote = async ( if (swapperName !== SwapperName.Thorchain) return false // This is either a smart contract address, or the bytecode is still loading - disable confirm - const _isSmartContractSellAddress = await isSmartContractAddress( - sendAddress, - firstHop.sellAsset.chainId, - ) - const _isSmartContractReceiveAddress = await isSmartContractAddress( - quote.receiveAddress, - firstHop.buyAsset.chainId, - ) + const _isSmartContractSellAddress = await isSmartContractAddress(sendAddress) + const _isSmartContractReceiveAddress = await isSmartContractAddress(quote.receiveAddress) // For long-tails, the *destination* address cannot be a smart contract // https://dev.thorchain.org/aggregators/aggregator-overview.html#admonition-warning // This doesn't apply to regular THOR swaps however, which docs have no mention of *destination* having to be an EOA diff --git a/src/state/slices/opportunitiesSlice/resolvers/uniV2/index.ts b/src/state/slices/opportunitiesSlice/resolvers/uniV2/index.ts index ab740c6c36b..ccddb5f3bc2 100644 --- a/src/state/slices/opportunitiesSlice/resolvers/uniV2/index.ts +++ b/src/state/slices/opportunitiesSlice/resolvers/uniV2/index.ts @@ -37,7 +37,7 @@ import { calculateAPRFromToken0 } from './utils' let _blockNumber: number | null = null const getBlockNumber = async () => { - const ethersProvider = getEthersProvider(KnownChainIds.EthereumMainnet) + const ethersProvider = getEthersProvider() if (_blockNumber) return _blockNumber const blockNumber = await ethersProvider.getBlockNumber() _blockNumber = blockNumber