From feb9df06da458e955800ff68bac6f20e19e70632 Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Tue, 27 Jul 2021 13:45:24 -0400 Subject: [PATCH] Swaps: Add custom token flow - search by address and get it imported to your wallet (#2729) --- app/components/Base/Alert.js | 6 +- app/components/Base/ModalDragger.js | 12 +- app/components/UI/Swaps/QuotesView.js | 9 +- .../UI/Swaps/components/TokenImportModal.js | 115 ++++++++ .../UI/Swaps/components/TokenSelectModal.js | 261 ++++++++++++++++-- app/components/UI/Swaps/index.js | 3 +- app/components/UI/Swaps/utils/index.js | 16 +- .../UI/Swaps/utils/useFetchTokenMetadata.js | 64 +++++ .../__snapshots__/index.test.js.snap | 1 + app/util/analytics.js | 6 +- locales/languages/en.json | 12 + 11 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 app/components/UI/Swaps/components/TokenImportModal.js create mode 100644 app/components/UI/Swaps/utils/useFetchTokenMetadata.js diff --git a/app/components/Base/Alert.js b/app/components/Base/Alert.js index 8e17314616f..2c638bcbc64 100644 --- a/app/components/Base/Alert.js +++ b/app/components/Base/Alert.js @@ -27,9 +27,9 @@ const styles = StyleSheet.create({ backgroundColor: colors.red000, borderColor: colors.red }, - textInfo: { color: colors.blue }, - textWarning: { color: colors.yellow700 }, - textError: { color: colors.red }, + textInfo: { color: colors.blue, flexShrink: 1 }, + textWarning: { color: colors.yellow700, flexShrink: 1 }, + textError: { color: colors.red, flexShrink: 1 }, textIconStyle: { marginRight: 12 }, iconWrapper: { alignItems: 'center' diff --git a/app/components/Base/ModalDragger.js b/app/components/Base/ModalDragger.js index 23767102f33..bbd1bb8c984 100644 --- a/app/components/Base/ModalDragger.js +++ b/app/components/Base/ModalDragger.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { StyleSheet, View } from 'react-native'; import { colors } from '../../styles/common'; import Device from '../../util/Device'; @@ -12,6 +13,9 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, borderColor: colors.grey100 }, + borderless: { + borderColor: colors.transparent + }, dragger: { width: 48, height: 5, @@ -21,12 +25,16 @@ const styles = StyleSheet.create({ } }); -function ModalDragger() { +function ModalDragger({ borderless }) { return ( - + ); } +ModalDragger.propTypes = { + borderless: PropTypes.bool +}; + export default ModalDragger; diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js index 98e7a384b63..65df8ac5091 100644 --- a/app/components/UI/Swaps/QuotesView.js +++ b/app/components/UI/Swaps/QuotesView.js @@ -289,14 +289,17 @@ function SwapsQuotesView({ const navigation = useNavigation(); const route = useRoute(); /* Get params from navigation */ - const { sourceTokenAddress, destinationTokenAddress, sourceAmount, slippage } = useMemo( + + const { sourceTokenAddress, destinationTokenAddress, sourceAmount, slippage, tokens } = useMemo( () => getQuotesNavigationsParams(route), [route] ); /* Get tokens from the tokens list */ - const sourceToken = swapsTokens?.find(token => toLowerCaseEquals(token.address, sourceTokenAddress)); - const destinationToken = swapsTokens?.find(token => toLowerCaseEquals(token.address, destinationTokenAddress)); + const sourceToken = [...swapsTokens, ...tokens].find(token => toLowerCaseEquals(token.address, sourceTokenAddress)); + const destinationToken = [...swapsTokens, ...tokens].find(token => + toLowerCaseEquals(token.address, destinationTokenAddress) + ); const hasConversionRate = Boolean(destinationToken) && diff --git a/app/components/UI/Swaps/components/TokenImportModal.js b/app/components/UI/Swaps/components/TokenImportModal.js new file mode 100644 index 00000000000..d0637144247 --- /dev/null +++ b/app/components/UI/Swaps/components/TokenImportModal.js @@ -0,0 +1,115 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, SafeAreaView, View } from 'react-native'; +import Modal from 'react-native-modal'; +import FAIcon from 'react-native-vector-icons/FontAwesome5'; +import { colors } from '../../../../styles/common'; + +import ModalDragger from '../../../Base/ModalDragger'; +import Text from '../../../Base/Text'; +import Alert from '../../../Base/Alert'; +import TokenIcon from './TokenIcon'; +import StyledButton from '../../StyledButton'; +import { strings } from '../../../../../locales/i18n'; + +const styles = StyleSheet.create({ + modal: { + margin: 0, + justifyContent: 'flex-end' + }, + modalView: { + backgroundColor: colors.white, + borderTopLeftRadius: 10, + borderTopRightRadius: 10 + }, + content: { + marginVertical: 14, + paddingHorizontal: 30, + alignItems: 'center' + }, + alertIcon: { + paddingTop: 4, + paddingRight: 8 + }, + title: { + fontSize: 24, + marginVertical: 14 + }, + tokenTitle: { + fontSize: 18, + textAlign: 'center', + marginVertical: 14 + }, + tokenAddress: { + backgroundColor: colors.grey000, + width: '100%', + borderRadius: 20, + marginVertical: 6, + paddingHorizontal: 8, + paddingVertical: 4 + }, + cta: { + marginTop: 10, + width: '100%' + } +}); + +function TokenImportModal({ isVisible, dismiss, token, onPressImport }) { + return ( + + + + + ( + + )} + > + {textStyle => {strings('swaps.add_warning')}} + + + {strings('swaps.import_token')} + + + + {token.name ? `${token.name} (${token.symbol})` : token.symbol} + + + {strings('swaps.contract')} + + + + {token.address} + + + + {strings('swaps.Import')} + + + + + ); +} + +TokenImportModal.propTypes = { + isVisible: PropTypes.bool, + dismiss: PropTypes.func, + token: PropTypes.shape({ + address: PropTypes.string, + name: PropTypes.string, + symbol: PropTypes.string, + decimals: PropTypes.number, + iconUrl: PropTypes.string + }), + onPressImport: PropTypes.func +}; +export default TokenImportModal; diff --git a/app/components/UI/Swaps/components/TokenSelectModal.js b/app/components/UI/Swaps/components/TokenSelectModal.js index 95e07448597..b3f4eca7a19 100644 --- a/app/components/UI/Swaps/components/TokenSelectModal.js +++ b/app/components/UI/Swaps/components/TokenSelectModal.js @@ -1,11 +1,23 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { StyleSheet, TextInput, SafeAreaView, TouchableOpacity, View, TouchableWithoutFeedback } from 'react-native'; +import { + StyleSheet, + TextInput, + SafeAreaView, + TouchableOpacity, + View, + TouchableWithoutFeedback, + ActivityIndicator, + InteractionManager +} from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; +import { useNavigation } from '@react-navigation/native'; import Modal from 'react-native-modal'; import Icon from 'react-native-vector-icons/Ionicons'; +import FAIcon from 'react-native-vector-icons/FontAwesome5'; import Fuse from 'fuse.js'; import { connect } from 'react-redux'; +import { isValidAddress } from 'ethereumjs-util'; import Device from '../../../../util/Device'; import { balanceToFiat, hexToBN, renderFromTokenMinimalUnit, renderFromWei, weiToFiat } from '../../../../util/number'; @@ -18,6 +30,13 @@ import Text from '../../../Base/Text'; import ListItem from '../../../Base/ListItem'; import ModalDragger from '../../../Base/ModalDragger'; import TokenIcon from './TokenIcon'; +import Alert from '../../../Base/Alert'; +import useBlockExplorer from '../utils/useBlockExplorer'; +import useFetchTokenMetadata from '../utils/useFetchTokenMetadata'; +import useModalHandler from '../../../Base/hooks/useModalHandler'; +import TokenImportModal from './TokenImportModal'; +import Analytics from '../../../../core/Analytics'; +import { ANALYTICS_EVENT_OPTS } from '../../../../util/analytics'; const styles = StyleSheet.create({ modal: { @@ -62,6 +81,32 @@ const styles = StyleSheet.create({ emptyList: { marginVertical: 10, marginHorizontal: 30 + }, + importButton: { + paddingVertical: 6, + paddingHorizontal: 10, + backgroundColor: colors.blue, + borderRadius: 100 + }, + importButtonText: { + color: colors.white + }, + loadingIndicator: { + margin: 10 + }, + loadingTokenView: { + marginVertical: 10, + marginHorizontal: 30, + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row' + }, + footer: { + padding: 30 + }, + footerIcon: { + paddingTop: 4, + paddingRight: 8 } }); @@ -80,22 +125,32 @@ function TokenSelectModal({ currentCurrency, conversionRate, tokenExchangeRates, + chainId, + provider, + frequentRpcList, balances }) { + const navigation = useNavigation(); const searchInput = useRef(null); const list = useRef(); const [searchString, setSearchString] = useState(''); + const explorer = useBlockExplorer(provider, frequentRpcList); + const [isTokenImportVisible, , showTokenImportModal, hideTokenImportModal] = useModalHandler(false); - const filteredTokens = useMemo(() => tokens?.filter(token => !excludeAddresses.includes(token.address)), [ - tokens, + const excludedAddresses = useMemo(() => excludeAddresses.filter(Boolean).map(address => address.toLowerCase()), [ excludeAddresses ]); + + const filteredTokens = useMemo( + () => tokens?.filter(token => !excludedAddresses.includes(token.address?.toLowerCase())), + [tokens, excludedAddresses] + ); const filteredInitialTokens = useMemo( () => initialTokens?.length > 0 - ? initialTokens.filter(token => !excludeAddresses.includes(token.address)) + ? initialTokens.filter(token => !excludedAddresses.includes(token.address?.toLowerCase())) : filteredTokens, - [excludeAddresses, filteredTokens, initialTokens] + [excludedAddresses, filteredTokens, initialTokens] ); const tokenFuse = useMemo( () => @@ -118,6 +173,19 @@ function TokenSelectModal({ [searchString, tokenFuse, filteredInitialTokens] ); + const shouldFetchToken = useMemo( + () => + tokenSearchResults.length === 0 && + isValidAddress(searchString) && + !excludedAddresses.includes(searchString?.toLowerCase()), + [excludedAddresses, searchString, tokenSearchResults.length] + ); + + const [loadingTokenMetadata, tokenMetadata] = useFetchTokenMetadata( + shouldFetchToken ? searchString : null, + chainId + ); + const renderItem = useCallback( ({ item }) => { const itemAddress = safeToChecksumAddress(item.address); @@ -158,6 +226,69 @@ function TokenSelectModal({ const handleSearchPress = () => searchInput?.current?.focus(); + const handleShowImportToken = useCallback(() => { + searchInput?.current?.blur(); + showTokenImportModal(); + }, [showTokenImportModal]); + + const handlePressImportToken = useCallback( + item => { + const { address, symbol } = item; + InteractionManager.runAfterInteractions(() => { + Analytics.trackEventWithParameters( + ANALYTICS_EVENT_OPTS.CUSTOM_TOKEN_IMPORTED, + { address, symbol, chain_id: chainId }, + true + ); + }); + hideTokenImportModal(); + onItemPress(item); + }, + [chainId, hideTokenImportModal, onItemPress] + ); + + const handleBlockExplorerPress = useCallback(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: shouldFetchToken ? explorer.token(searchString) : explorer.token('').replace('token/', 'tokens/'), + title: strings(shouldFetchToken ? 'swaps.verify' : 'swaps.find_token_address') + } + }); + dismiss(); + }, [dismiss, explorer, navigation, searchString, shouldFetchToken]); + + const renderFooter = useMemo( + () => ( + + ( + + )} + > + {textStyle => ( + + + {strings('swaps.cant_find_token')} + + {` ${strings('swaps.manually_pasting')}`} + {explorer.isValid && ( + + {` ${strings('swaps.token_address_can_be_found')} `} + + {explorer.name} + + . + + )} + + )} + + + ), + [explorer.isValid, explorer.name, handleBlockExplorerPress] + ); + const renderEmptyList = useMemo( () => ( @@ -172,6 +303,11 @@ function TokenSelectModal({ if (list.current) list.current.scrollToOffset({ animated: false, y: 0 }); }, []); + const handleClearSearch = useCallback(() => { + setSearchString(''); + searchInput?.current?.focus(); + }, [setSearchString]); + return ( + {searchString.length > 0 && ( + + + + )} - item.address} - ListEmptyComponent={renderEmptyList} - /> + {shouldFetchToken ? ( + + {loadingTokenMetadata ? ( + + + {strings('swaps.gathering_token_details')} + + ) : tokenMetadata.error ? ( + + {strings('swaps.error_gathering_token_details')} + + ) : tokenMetadata.valid ? ( + + + + + + + + {tokenMetadata.metadata.symbol} + {tokenMetadata.metadata.name && {tokenMetadata.metadata.name}} + + + + + {strings('swaps.Import')} + + + + + + handlePressImportToken(tokenMetadata.metadata)} + /> + + ) : ( + + + {strings('swaps.invalid_token_contract_address')} + {explorer.isValid && ( + + {` ${strings('swaps.please_verify_on_explorer')} `} + + {explorer.name} + + . + + )} + + + )} + + ) : ( + item.address} + ListEmptyComponent={renderEmptyList} + ListFooterComponent={renderFooter} + ListFooterComponentStyle={[styles.resultRow, styles.footer]} + /> + )} ); @@ -248,7 +460,19 @@ TokenSelectModal.propTypes = { /** * An object containing token exchange rates in the format address => exchangeRate */ - tokenExchangeRates: PropTypes.object + tokenExchangeRates: PropTypes.object, + /** + * Chain Id + */ + chainId: PropTypes.string, + /** + * Current Network provider + */ + provider: PropTypes.object, + /** + * Frequent RPC list from PreferencesController + */ + frequentRpcList: PropTypes.array }; const mapStateToProps = state => ({ @@ -257,7 +481,10 @@ const mapStateToProps = state => ({ currentCurrency: state.engine.backgroundState.CurrencyRateController.currentCurrency, selectedAddress: state.engine.backgroundState.PreferencesController.selectedAddress, balances: state.engine.backgroundState.TokenBalancesController.contractBalances, - tokenExchangeRates: state.engine.backgroundState.TokenRatesController.contractExchangeRates + tokenExchangeRates: state.engine.backgroundState.TokenRatesController.contractExchangeRates, + chainId: state.engine.backgroundState.NetworkController.provider.chainId, + provider: state.engine.backgroundState.NetworkController.provider, + frequentRpcList: state.engine.backgroundState.PreferencesController.frequentRpcList }); export default connect(mapStateToProps)(TokenSelectModal); diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index e3344c68813..4a40ae84734 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -351,7 +351,8 @@ function SwapsAmountView({ sourceToken?.address, destinationToken?.address, toTokenMinimalUnit(amount, sourceToken?.decimals).toString(10), - slippage + slippage, + [sourceToken, destinationToken] ) ); }, [ diff --git a/app/components/UI/Swaps/utils/index.js b/app/components/UI/Swaps/utils/index.js index cd744d0f6fe..aa599d2b85b 100644 --- a/app/components/UI/Swaps/utils/index.js +++ b/app/components/UI/Swaps/utils/index.js @@ -37,14 +37,22 @@ export function isDynamicToken(token) { * @param {string} destinationTokenAddress Token contract address used as swaps result * @param {string} sourceAmount Amount in minimal token units of sourceTokenAddress to be swapped * @param {string|number} slippage Max slippage + * @param {array} tokens Tokens selected for trade * @return {object} Object containing sourceTokenAddress, destinationTokenAddress, sourceAmount and slippage */ -export function setQuotesNavigationsParams(sourceTokenAddress, destinationTokenAddress, sourceAmount, slippage) { +export function setQuotesNavigationsParams( + sourceTokenAddress, + destinationTokenAddress, + sourceAmount, + slippage, + tokens = [] +) { return { sourceTokenAddress, destinationTokenAddress, sourceAmount, - slippage + slippage, + tokens }; } @@ -57,12 +65,14 @@ export function getQuotesNavigationsParams(route) { const sourceTokenAddress = route.params?.sourceTokenAddress ?? ''; const destinationTokenAddress = route.params?.destinationTokenAddress ?? ''; const sourceAmount = route.params?.sourceAmount; + const tokens = route.params?.tokens; return { sourceTokenAddress, destinationTokenAddress, sourceAmount, - slippage + slippage, + tokens }; } diff --git a/app/components/UI/Swaps/utils/useFetchTokenMetadata.js b/app/components/UI/Swaps/utils/useFetchTokenMetadata.js new file mode 100644 index 00000000000..d3290be5242 --- /dev/null +++ b/app/components/UI/Swaps/utils/useFetchTokenMetadata.js @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; + +const defaultTokenMetadata = { + valid: null, + error: false, + metadata: null +}; + +// TODO: change this with a multi chain endpoint in the future +const SWAPS_TOKEN_API = { + '1': 'https://api.metaswap.codefi.network', + '1337': 'https://metaswap-api.airswap-dev.codefi.network', + '56': 'https://bsc-api.metaswap.codefi.network' +}; + +function useFetchTokenMetadata(address, chainId) { + const [isLoading, setIsLoading] = useState(false); + const [tokenMetadata, setTokenMetadata] = useState(defaultTokenMetadata); + + useEffect(() => { + if (!address) { + return; + } + + let cancelTokenSource; + async function fetchTokenMetadata() { + try { + cancelTokenSource = axios.CancelToken.source(); + setTokenMetadata(defaultTokenMetadata); + setIsLoading(true); + const { data } = await axios.request({ + url: '/token', + baseURL: SWAPS_TOKEN_API[chainId], + params: { + address + }, + cancelToken: cancelTokenSource.token + }); + setTokenMetadata({ error: false, valid: true, metadata: data }); + } catch (error) { + // Address is not an ERC20 + if (error?.response?.status === 422) { + setTokenMetadata({ error: false, valid: false, metadata: null }); + } else { + setTokenMetadata({ ...defaultTokenMetadata, error: true }); + } + } finally { + setIsLoading(false); + } + } + fetchTokenMetadata(); + + return () => { + cancelTokenSource?.cancel(); + setIsLoading(false); + setTokenMetadata(defaultTokenMetadata); + }; + }, [address, chainId]); + + return [isLoading, tokenMetadata]; +} + +export default useFetchTokenMetadata; diff --git a/app/components/Views/SendFlow/ErrorMessage/__snapshots__/index.test.js.snap b/app/components/Views/SendFlow/ErrorMessage/__snapshots__/index.test.js.snap index 621c6cfde6f..3f8a3fba37b 100644 --- a/app/components/Views/SendFlow/ErrorMessage/__snapshots__/index.test.js.snap +++ b/app/components/Views/SendFlow/ErrorMessage/__snapshots__/index.test.js.snap @@ -40,6 +40,7 @@ exports[`ErrorMessage should render correctly 1`] = ` style={ Object { "color": "#D73A49", + "flexShrink": 1, } } underline={false} diff --git a/app/util/analytics.js b/app/util/analytics.js index 45eeb043ffa..340dbb1b95e 100644 --- a/app/util/analytics.js +++ b/app/util/analytics.js @@ -175,7 +175,8 @@ const CATEGORIES = { QUOTES_TIMED_OUT: 'Quotes Timed Out', NO_QUOTES_AVAILABLE: 'No Quotes Available', GAS_FEES_CHANGED: 'Gas Fees Changed', - EDIT_SPEND_LIMIT_OPENED: 'Edit Spend Limit Opened' + EDIT_SPEND_LIMIT_OPENED: 'Edit Spend Limit Opened', + TOKEN_IMPORTED: 'Custom Token Imported' }; export const ANALYTICS_EVENT_OPTS = { @@ -486,5 +487,6 @@ export const ANALYTICS_EVENT_OPTS = { QUOTES_TIMED_OUT: generateOpt(CATEGORIES.QUOTES_TIMED_OUT, ACTIONS.QUOTE, NAMES.SWAPS), NO_QUOTES_AVAILABLE: generateOpt(CATEGORIES.NO_QUOTES_AVAILABLE, ACTIONS.QUOTE, NAMES.SWAPS), GAS_FEES_CHANGED: generateOpt(CATEGORIES.GAS_FEES_CHANGED, ACTIONS.QUOTE, NAMES.SWAPS), - EDIT_SPEND_LIMIT_OPENED: generateOpt(CATEGORIES.EDIT_SPEND_LIMIT_OPENED, ACTIONS.QUOTE, NAMES.SWAPS) + EDIT_SPEND_LIMIT_OPENED: generateOpt(CATEGORIES.EDIT_SPEND_LIMIT_OPENED, ACTIONS.QUOTE, NAMES.SWAPS), + CUSTOM_TOKEN_IMPORTED: generateOpt(CATEGORIES.TOKEN_IMPORTED, ACTIONS.SWAP, NAMES.SWAPS) }; diff --git a/locales/languages/en.json b/locales/languages/en.json index a5972132e6f..6e662089eed 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1448,6 +1448,18 @@ "select_a_token": "Select a token", "search_token": "Search for a token", "no_tokens_result": "No tokens match “{{searchString}}”", + "find_token_address": "Find token address", + "cant_find_token": "Can’t find a token?", + "manually_pasting": "You can manually add any token by pasting its address.", + "token_address_can_be_found": "Token contract addresses can be found on", + "gathering_token_details": "Gathering token details...", + "error_gathering_token_details": "Oops, there was an error gathering token details.", + "Import": "Import", + "invalid_token_contract_address": "Invalid token contract address.", + "please_verify_on_explorer": "Please verify on", + "add_warning": "Anyone can create a token, including creating fake versions of existing tokens that claim to represent projects.", + "import_token": "Import token?", + "contract": "Contract:", "available_to_swap": "{{asset}} available to swap.", "use_max": "Use max", "not_enough": "Not enough {{symbol}} to complete this swap",