diff --git a/app/scripts/controllers/detect-tokens.js b/app/scripts/controllers/detect-tokens.js index c9ea77f2d..1777d28ca 100644 --- a/app/scripts/controllers/detect-tokens.js +++ b/app/scripts/controllers/detect-tokens.js @@ -1,10 +1,9 @@ import Web3 from 'web3'; -import contracts from '@metamask/contract-metadata'; import { warn } from 'loglevel'; import SINGLE_CALL_BALANCES_ABI from 'single-call-balance-checker-abi'; -import { MAINNET_CHAIN_ID } from '../../../shared/constants/network'; import { SINGLE_CALL_BALANCES_ADDRESS } from '../constants/contracts'; import { MINUTE } from '../../../shared/constants/time'; +import { isEqualCaseInsensitive } from '../../../ui/helpers/utils/util'; // By default, poll every 3 minutes const DEFAULT_INTERVAL = MINUTE * 3; @@ -24,57 +23,13 @@ export default class DetectTokensController { preferences, network, keyringMemStore, + tokenList, } = {}) { this.preferences = preferences; this.interval = interval; this.network = network; this.keyringMemStore = keyringMemStore; - } - - /** - * For each token in @metamask/contract-metadata, find check selectedAddress balance. - */ - async detectNewTokens() { - if (!this.isActive) { - return; - } - if (this._network.store.getState().provider.chainId !== MAINNET_CHAIN_ID) { - return; - } - - const tokensToDetect = []; - this.web3.setProvider(this._network._provider); - for (const contractAddress in contracts) { - if ( - contracts[contractAddress].erc20 && - !this.tokenAddresses.includes(contractAddress.toLowerCase()) && - !this.hiddenTokens.includes(contractAddress.toLowerCase()) - ) { - tokensToDetect.push(contractAddress); - } - } - - let result; - try { - result = await this._getTokenBalances(tokensToDetect); - } catch (error) { - warn( - `MetaMask - DetectTokensController single call balance fetch failed`, - error, - ); - return; - } - - tokensToDetect.forEach((tokenAddress, index) => { - const balance = result[index]; - if (balance && !balance.isZero()) { - this._preferences.addToken( - tokenAddress, - contracts[tokenAddress].symbol, - contracts[tokenAddress].decimals, - ); - } - }); + this.tokenList = tokenList; } async _getTokenBalances(tokens) { @@ -91,6 +46,63 @@ export default class DetectTokensController { }); } + /** + * For each token in the tokenlist provided by the TokenListController, check selectedAddress balance. + */ + async detectNewTokens() { + if (!this.isActive) { + return; + } + + const { tokenList } = this._tokenList.state; + if (Object.keys(tokenList).length === 0) { + return; + } + + const tokensToDetect = []; + this.web3.setProvider(this._network._provider); + for (const tokenAddress in tokenList) { + if ( + !this.tokenAddresses.find((address) => + isEqualCaseInsensitive(address, tokenAddress), + ) && + !this.hiddenTokens.find((address) => + isEqualCaseInsensitive(address, tokenAddress), + ) + ) { + tokensToDetect.push(tokenAddress); + } + } + const sliceOfTokensToDetect = [ + tokensToDetect.slice(0, 1000), + tokensToDetect.slice(1000, tokensToDetect.length - 1), + ]; + for (const tokensSlice of sliceOfTokensToDetect) { + let result; + try { + result = await this._getTokenBalances(tokensSlice); + } catch (error) { + warn( + `MetaMask - DetectTokensController single call balance fetch failed`, + error, + ); + return; + } + await Promise.all( + tokensSlice.map(async (tokenAddress, index) => { + const balance = result[index]; + if (balance && !balance.isZero()) { + await this._preferences.addToken( + tokenAddress, + tokenList[tokenAddress].symbol, + tokenList[tokenAddress].decimals, + ); + } + }), + ); + } + } + /** * Restart token detection polling period and call detectNewTokens * in case of address change or user session initialization. @@ -138,9 +150,13 @@ export default class DetectTokensController { }); this.hiddenTokens = hiddenTokens; }); - preferences.store.subscribe(({ selectedAddress }) => { - if (this.selectedAddress !== selectedAddress) { + preferences.store.subscribe(({ selectedAddress, useTokenDetection }) => { + if ( + this.selectedAddress !== selectedAddress || + this.useTokenDetection !== useTokenDetection + ) { this.selectedAddress = selectedAddress; + this.useTokenDetection = useTokenDetection; this.restartTokenDetection(); } }); @@ -176,6 +192,16 @@ export default class DetectTokensController { }); } + /** + * @type {Object} + */ + set tokenList(tokenList) { + if (!tokenList) { + return; + } + this._tokenList = tokenList; + } + /** * Internal isActive state * @type {Object} diff --git a/app/scripts/controllers/detect-tokens.test.js b/app/scripts/controllers/detect-tokens.test.js index 7bd788271..65f9af298 100644 --- a/app/scripts/controllers/detect-tokens.test.js +++ b/app/scripts/controllers/detect-tokens.test.js @@ -1,15 +1,19 @@ import { strict as assert } from 'assert'; import sinon from 'sinon'; +import nock from 'nock'; import { ObservableStore } from '@metamask/obs-store'; -import contracts from '@metamask/contract-metadata'; import BigNumber from 'bignumber.js'; - +import { + ControllerMessenger, + TokenListController, +} from '@metamask/controllers'; import { MAINNET, ROPSTEN } from '../../../shared/constants/network'; import DetectTokensController from './detect-tokens'; import NetworkController from './network'; import PreferencesController from './preferences'; describe('DetectTokensController', function () { + let tokenListController; const sandbox = sinon.createSandbox(); let keyringMemStore, network, preferences, provider; @@ -36,6 +40,87 @@ describe('DetectTokensController', function () { sandbox .stub(preferences, '_detectIsERC721') .returns(Promise.resolve(false)); + nock('https://token-api.metaswap.codefi.network') + .get(`/tokens/1`) + .reply(200, [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + iconUrl: 'https://airswap-token-images.s3.amazonaws.com/SNX.png', + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + iconUrl: 'https://s3.amazonaws.com/airswap-token-images/LINK.png', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + iconUrl: 'https://s3.amazonaws.com/airswap-token-images/BNT.png', + }, + ]) + .get(`/tokens/3`) + .reply(200, { error: 'ChainId 3 is not supported' }) + .persist(); + const tokenListMessenger = new ControllerMessenger().getRestricted({ + name: 'TokenListController', + }); + tokenListController = new TokenListController({ + chainId: '1', + useStaticTokenList: false, + onNetworkStateChange: sinon.spy(), + onPreferencesStateChange: sinon.spy(), + messenger: tokenListMessenger, + }); + await tokenListController.start(); }); after(function () { @@ -56,6 +141,7 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = true; @@ -75,10 +161,22 @@ describe('DetectTokensController', function () { it('should not check tokens while on test network', async function () { sandbox.useFakeTimers(); network.setProviderType(ROPSTEN); + const tokenListMessengerRopsten = new ControllerMessenger().getRestricted({ + name: 'TokenListController', + }); + tokenListController = new TokenListController({ + chainId: '3', + useStaticTokenList: false, + onNetworkStateChange: sinon.spy(), + onPreferencesStateChange: sinon.spy(), + messenger: tokenListMessengerRopsten, + }); + await tokenListController.start(); const controller = new DetectTokensController({ preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = true; @@ -96,17 +194,16 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = true; - const contractAddresses = Object.keys(contracts); - const erc20ContractAddresses = contractAddresses.filter( - (contractAddress) => contracts[contractAddress].erc20 === true, - ); + const { tokenList } = tokenListController.state; + const erc20ContractAddresses = Object.keys(tokenList); const existingTokenAddress = erc20ContractAddresses[0]; - const existingToken = contracts[existingTokenAddress]; + const existingToken = tokenList[existingTokenAddress]; await preferences.addToken( existingTokenAddress, existingToken.symbol, @@ -144,17 +241,16 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = true; - const contractAddresses = Object.keys(contracts); - const erc20ContractAddresses = contractAddresses.filter( - (contractAddress) => contracts[contractAddress].erc20 === true, - ); + const { tokenList } = tokenListController.state; + const erc20ContractAddresses = Object.keys(tokenList); const existingTokenAddress = erc20ContractAddresses[0]; - const existingToken = contracts[existingTokenAddress]; + const existingToken = tokenList[existingTokenAddress]; await preferences.addToken( existingTokenAddress, existingToken.symbol, @@ -162,16 +258,16 @@ describe('DetectTokensController', function () { ); const tokenAddressToAdd = erc20ContractAddresses[1]; - const tokenToAdd = contracts[tokenAddressToAdd]; + const tokenToAdd = tokenList[tokenAddressToAdd]; - const contractAddresssesToDetect = contractAddresses.filter( + const contractAddressesToDetect = erc20ContractAddresses.filter( (address) => address !== existingTokenAddress, ); - const indexOfTokenToAdd = contractAddresssesToDetect.indexOf( + const indexOfTokenToAdd = contractAddressesToDetect.indexOf( tokenAddressToAdd, ); - const balances = new Array(contractAddresssesToDetect.length); + const balances = new Array(contractAddressesToDetect.length); balances[indexOfTokenToAdd] = new BigNumber(10); sandbox @@ -203,17 +299,16 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = true; - const contractAddresses = Object.keys(contracts); - const erc20ContractAddresses = contractAddresses.filter( - (contractAddress) => contracts[contractAddress].erc20 === true, - ); + const { tokenList } = tokenListController.state; + const erc20ContractAddresses = Object.keys(tokenList); const existingTokenAddress = erc20ContractAddresses[0]; - const existingToken = contracts[existingTokenAddress]; + const existingToken = tokenList[existingTokenAddress]; await preferences.addToken( existingTokenAddress, existingToken.symbol, @@ -221,16 +316,16 @@ describe('DetectTokensController', function () { ); const tokenAddressToAdd = erc20ContractAddresses[1]; - const tokenToAdd = contracts[tokenAddressToAdd]; + const tokenToAdd = tokenList[tokenAddressToAdd]; - const contractAddresssesToDetect = contractAddresses.filter( + const contractAddressesToDetect = erc20ContractAddresses.filter( (address) => address !== existingTokenAddress, ); - const indexOfTokenToAdd = contractAddresssesToDetect.indexOf( + const indexOfTokenToAdd = contractAddressesToDetect.indexOf( tokenAddressToAdd, ); - const balances = new Array(contractAddresssesToDetect.length); + const balances = new Array(contractAddressesToDetect.length); balances[indexOfTokenToAdd] = new BigNumber(10); sandbox @@ -261,6 +356,7 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = true; @@ -277,6 +373,7 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.selectedAddress = '0x0'; @@ -292,6 +389,7 @@ describe('DetectTokensController', function () { preferences, network, keyringMemStore, + tokenList: tokenListController, }); controller.isOpen = true; controller.isUnlocked = false; diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 11b38a478..b7b981ec3 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -51,7 +51,10 @@ export default class PreferencesController { useNonceField: false, usePhishDetect: true, dismissSeedBackUpReminder: false, - useStaticTokenList: false, + + // set to true means the dynamic list from the API is being used + // set to false will be using the static list from contract-metadata + useTokenDetection: true, // WARNING: Do not use feature flags for security-sensitive things. // Feature flag toggling is available in the global namespace @@ -140,13 +143,13 @@ export default class PreferencesController { } /** - * Setter for the `useStaticTokenList` property + * Setter for the `useTokenDetection` property * * @param {boolean} val - Whether or not the user prefers to use the static token list or dynamic token list from the API * */ - setUseStaticTokenList(val) { - this.store.updateState({ useStaticTokenList: val }); + setUseTokenDetection(val) { + this.store.updateState({ useTokenDetection: val }); } /** diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index f088199ed..0abf8c202 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -869,22 +869,22 @@ describe('preferences controller', function () { ); }); }); - describe('setUseStaticTokenList', function () { - it('should default to false', function () { + describe('setUseTokenDetection', function () { + it('should default to true', function () { const state = preferencesController.store.getState(); - assert.equal(state.useStaticTokenList, false); + assert.equal(state.useTokenDetection, true); }); - it('should set the useStaticTokenList property in state', function () { + it('should set the useTokenDetection property in state', function () { assert.equal( - preferencesController.store.getState().useStaticTokenList, - false, - ); - preferencesController.setUseStaticTokenList(true); - assert.equal( - preferencesController.store.getState().useStaticTokenList, + preferencesController.store.getState().useTokenDetection, true, ); + preferencesController.setUseTokenDetection(false); + assert.equal( + preferencesController.store.getState().useTokenDetection, + false, + ); }); }); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 5c4bb09cf..0ee57bbf8 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -16,7 +16,6 @@ import TrezorKeyring from 'eth-trezor-keyring'; import LedgerBridgeKeyring from '@metamask/eth-ledger-bridge-keyring'; import EthQuery from 'eth-query'; import nanoid from 'nanoid'; -import contractMap from '@metamask/contract-metadata'; import { AddressBookController, ApprovalController, @@ -238,8 +237,8 @@ export default class MetamaskController extends EventEmitter { }); this.tokenListController = new TokenListController({ chainId: hexToDecimal(this.networkController.getCurrentChainId()), - useStaticTokenList: this.preferencesController.store.getState() - .useStaticTokenList, + useStaticTokenList: !this.preferencesController.store.getState() + .useTokenDetection, onNetworkStateChange: (cb) => this.networkController.store.subscribe((networkState) => { const modifiedNetworkState = { @@ -251,11 +250,17 @@ export default class MetamaskController extends EventEmitter { }; return cb(modifiedNetworkState); }), - onPreferencesStateChange: this.preferencesController.store.subscribe.bind( - this.preferencesController.store, - ), + onPreferencesStateChange: (cb) => + this.preferencesController.store.subscribe((preferencesState) => { + const modifiedPreferencesState = { + ...preferencesState, + useStaticTokenList: !this.preferencesController.store.getState() + .useTokenDetection, + }; + return cb(modifiedPreferencesState); + }), messenger: tokenListMessenger, - state: initState.tokenListController, + state: initState.TokenListController, }); this.phishingController = new PhishingController(); @@ -372,6 +377,7 @@ export default class MetamaskController extends EventEmitter { preferences: this.preferencesController, network: this.networkController, keyringMemStore: this.keyringController.memStore, + tokenList: this.tokenListController, }); this.addressBookController = new AddressBookController( @@ -775,8 +781,8 @@ export default class MetamaskController extends EventEmitter { setUseBlockie: this.setUseBlockie.bind(this), setUseNonceField: this.setUseNonceField.bind(this), setUsePhishDetect: this.setUsePhishDetect.bind(this), - setUseStaticTokenList: nodeify( - this.preferencesController.setUseStaticTokenList, + setUseTokenDetection: nodeify( + this.preferencesController.setUseTokenDetection, this.preferencesController, ), setIpfsGateway: this.setIpfsGateway.bind(this), @@ -1297,28 +1303,8 @@ export default class MetamaskController extends EventEmitter { tokens, } = this.preferencesController.store.getState(); - // Filter ERC20 tokens - const filteredAccountTokens = {}; - Object.keys(accountTokens).forEach((address) => { - const checksummedAddress = toChecksumHexAddress(address); - filteredAccountTokens[checksummedAddress] = {}; - Object.keys(accountTokens[address]).forEach((chainId) => { - filteredAccountTokens[checksummedAddress][chainId] = - chainId === MAINNET_CHAIN_ID - ? accountTokens[address][chainId].filter( - ({ address: tokenAddress }) => { - const checksumAddress = toChecksumHexAddress(tokenAddress); - return contractMap[checksumAddress] - ? contractMap[checksumAddress].erc20 - : true; - }, - ) - : accountTokens[address][chainId]; - }); - }); - const preferences = { - accountTokens: filteredAccountTokens, + accountTokens, currentLocale, frequentRpcList, identities, diff --git a/test/data/fetch-mocks.json b/test/data/fetch-mocks.json index 9299b4602..f9b2885f2 100644 --- a/test/data/fetch-mocks.json +++ b/test/data/fetch-mocks.json @@ -25,5 +25,49 @@ "fallback_to_v1": false } } + }, + "tokenList": { + "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0": { + "address": "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + "symbol": "MATIC", + "decimals": 18, + "name": "Polygon", + "iconUrl": "https://raw.githubusercontent.com/MetaMask/eth-contract-metadata/master/images/matic-network-logo.svg", + "aggregators": [ + "airswapLight", + "bancor", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0x0d8775f648430679a709e98d2b0cb6250d2887ef": { + "address": "0x0d8775f648430679a709e98d2b0cb6250d2887ef", + "symbol": "BAT", + "decimals": 18, + "name": "Basic Attention Tok", + "iconUrl": "https://s3.amazonaws.com/airswap-token-images/BAT.png", + "aggregators": [ + "airswapLight", + "bancor", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + } } } diff --git a/test/data/mock-state.json b/test/data/mock-state.json index caaa378d8..8b6171339 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -154,6 +154,53 @@ "editingTransactionId": null, "toNickname": "" }, + "useTokenDetection": true, + "tokenList": { + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": { + "address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "symbol": "WBTC", + "decimals": 8, + "name": "Wrapped Bitcoin", + "iconUrl": "https://s3.amazonaws.com/airswap-token-images/WBTC.png", + "aggregators": [ + "airswapLight", + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 12 + }, + "0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e": { + "address": "0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e", + "symbol": "YFI", + "decimals": 18, + "name": "yearn.finance", + "iconUrl": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e/logo.png", + "aggregators": [ + "airswapLight", + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 12 + } + }, "currentNetworkTxList": [ { "id": 3387511061307736, diff --git a/test/e2e/fixtures/imported-account/state.json b/test/e2e/fixtures/imported-account/state.json index 407bbbd83..aa2fc309b 100644 --- a/test/e2e/fixtures/imported-account/state.json +++ b/test/e2e/fixtures/imported-account/state.json @@ -88,6 +88,361 @@ } ] }, + "TokenListController": { + "tokenList": { + "0xbbbbca6a901c926f240b89eacb641d8aec7aeafd": { + "address": "0xbbbbca6a901c926f240b89eacb641d8aec7aeafd", + "symbol": "LRC", + "decimals": 18, + "name": "Loopring", + "iconUrl": "https://airswap-token-images.s3.amazonaws.com/LRC.png", + "aggregators": [ + "airswapLight", + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 12 + }, + "0x04fa0d235c4abf4bcf4787af4cf447de572ef828": { + "address": "0x04fa0d235c4abf4bcf4787af4cf447de572ef828", + "symbol": "UMA", + "decimals": 18, + "name": "UMA", + "iconUrl": "https://assets.coingecko.com/coins/images/10951/thumb/UMA.png?1586307916", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0x6b3595068778dd592e39a122f4f5a5cf09c90fe2": { + "address": "0x6b3595068778dd592e39a122f4f5a5cf09c90fe2", + "symbol": "SUSHI", + "decimals": 18, + "name": "SushiSwap", + "iconUrl": "https://assets.coingecko.com/coins/images/12271/thumb/512x512_Logo_no_chop.png?1606986688", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0xd533a949740bb3306d119cc777fa900ba034cd52": { + "address": "0xd533a949740bb3306d119cc777fa900ba034cd52", + "symbol": "CRV", + "decimals": 18, + "name": "Curve DAO Token", + "iconUrl": "https://assets.coingecko.com/coins/images/12124/thumb/Curve.png?1597369484", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0xc00e94cb662c3520282e6f5717214004a7f26888": { + "address": "0xc00e94cb662c3520282e6f5717214004a7f26888", + "symbol": "COMP", + "decimals": 18, + "name": "Compound", + "iconUrl": "https://assets.coingecko.com/coins/images/10775/thumb/COMP.png?1592625425", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0xba100000625a3754423978a60c9317c58a424e3d": { + "address": "0xba100000625a3754423978a60c9317c58a424e3d", + "symbol": "BAL", + "decimals": 18, + "name": "Balancer", + "iconUrl": "https://assets.coingecko.com/coins/images/11683/thumb/Balancer.png?1592792958", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0": { + "address": "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + "symbol": "MATIC", + "decimals": 18, + "name": "Polygon", + "iconUrl": "https://raw.githubusercontent.com/MetaMask/eth-contract-metadata/master/images/matic-network-logo.svg", + "aggregators": [ + "airswapLight", + "bancor", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + "0x0d8775f648430679a709e98d2b0cb6250d2887ef": { + "address": "0x0d8775f648430679a709e98d2b0cb6250d2887ef", + "symbol": "BAT", + "decimals": 18, + "name": "Basic Attention Tok", + "iconUrl": "https://s3.amazonaws.com/airswap-token-images/BAT.png", + "aggregators": [ + "airswapLight", + "bancor", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + } + }, + "tokensChainsCache": { + "1": { + "timestamp": 1628769574961, + "data": [ + { + "address": "0xbbbbca6a901c926f240b89eacb641d8aec7aeafd", + "symbol": "LRC", + "decimals": 18, + "name": "Loopring", + "iconUrl": "https://airswap-token-images.s3.amazonaws.com/LRC.png", + "aggregators": [ + "airswapLight", + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 12 + }, + { + "address": "0x04fa0d235c4abf4bcf4787af4cf447de572ef828", + "symbol": "UMA", + "decimals": 18, + "name": "UMA", + "iconUrl": "https://assets.coingecko.com/coins/images/10951/thumb/UMA.png?1586307916", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + { + "address": "0x6b3595068778dd592e39a122f4f5a5cf09c90fe2", + "symbol": "SUSHI", + "decimals": 18, + "name": "SushiSwap", + "iconUrl": "https://assets.coingecko.com/coins/images/12271/thumb/512x512_Logo_no_chop.png?1606986688", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + { + "address": "0xd533a949740bb3306d119cc777fa900ba034cd52", + "symbol": "CRV", + "decimals": 18, + "name": "Curve DAO Token", + "iconUrl": "https://assets.coingecko.com/coins/images/12124/thumb/Curve.png?1597369484", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + { + "address": "0xc00e94cb662c3520282e6f5717214004a7f26888", + "symbol": "COMP", + "decimals": 18, + "name": "Compound", + "iconUrl": "https://assets.coingecko.com/coins/images/10775/thumb/COMP.png?1592625425", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + { + "address": "0xba100000625a3754423978a60c9317c58a424e3d", + "symbol": "BAL", + "decimals": 18, + "name": "Balancer", + "iconUrl": "https://assets.coingecko.com/coins/images/11683/thumb/Balancer.png?1592792958", + "aggregators": [ + "bancor", + "cmc", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + { + "address": "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + "symbol": "MATIC", + "decimals": 18, + "name": "Polygon", + "iconUrl": "https://raw.githubusercontent.com/MetaMask/eth-contract-metadata/master/images/matic-network-logo.svg", + "aggregators": [ + "airswapLight", + "bancor", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + }, + { + "address": "0x0d8775f648430679a709e98d2b0cb6250d2887ef", + "symbol": "BAT", + "decimals": 18, + "name": "Basic Attention Tok", + "iconUrl": "https://s3.amazonaws.com/airswap-token-images/BAT.png", + "aggregators": [ + "airswapLight", + "bancor", + "coinGecko", + "kleros", + "oneInch", + "paraswap", + "pmm", + "totle", + "zapper", + "zerion", + "zeroEx" + ], + "occurrences": 11 + } + ] + }, + "3": { + "timestamp": 1628769543620 + }, + "1337": { + "timestamp": 1628769513476 + } + } + }, "PreferencesController": { "accountTokens": { "0x5cfe73b6021e818b776b421b1c4db2474086a7e1": { @@ -123,7 +478,8 @@ "tokens": [], "useBlockie": false, "useNonceField": false, - "usePhishDetect": true + "usePhishDetect": true, + "useTokenDetection": true }, "config": {}, "firstTimeInfo": { diff --git a/test/e2e/tests/permissions.spec.js b/test/e2e/tests/permissions.spec.js index 503066626..50fefb3a5 100644 --- a/test/e2e/tests/permissions.spec.js +++ b/test/e2e/tests/permissions.spec.js @@ -1,5 +1,5 @@ const { strict: assert } = require('assert'); -const { withFixtures, xxLargeDelayMs } = require('../helpers'); +const { withFixtures, xxLargeDelayMs, xLargeDelayMs } = require('../helpers'); describe('Permissions', function () { it('sets permissions and connect to Dapp', async function () { @@ -62,7 +62,7 @@ describe('Permissions', function () { text: 'Connected sites', tag: 'h2', }); - + await driver.delay(xLargeDelayMs); const domains = await driver.findClickableElements( '.connected-sites-list__domain-name', ); diff --git a/test/e2e/webdriver/index.js b/test/e2e/webdriver/index.js index 075718631..eb552f34d 100644 --- a/test/e2e/webdriver/index.js +++ b/test/e2e/webdriver/index.js @@ -52,6 +52,12 @@ async function setupFetchMocking(driver) { if (url.match(/featureFlags$/u)) { return { json: async () => clone(mockResponses.swaps.featureFlags) }; } + } else if ( + url.match(/^https:\/\/(token-api\.airswap-prod\.codefi\.network)/u) + ) { + if (url.match(/tokens\/1337$/u)) { + return { json: async () => clone(mockResponses.tokenList) }; + } } return window.origFetch(...args); }; diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index f57ae13ae..19dbcf48b 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -222,6 +222,76 @@ export const createSwapsMockStore = () => { swapsFeatureIsLive: false, useNewSwapsApi: false, }, + useTokenDetection: true, + tokenList: { + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'UNI', + decimals: 18, + name: 'Uniswap', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + name: 'Chainlink', + iconUrl: 'https://s3.amazonaws.com/airswap-token-images/LINK.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2': { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + symbol: 'SUSHI', + decimals: 18, + name: 'SushiSwap', + iconUrl: + 'https://assets.coingecko.com/coins/images/12271/thumb/512x512_Logo_no_chop.png?1606986688', + aggregators: [ + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 11, + }, + }, }, appState: { modal: { diff --git a/ui/components/ui/identicon/identicon.component.js b/ui/components/ui/identicon/identicon.component.js index dd6eb5beb..b78f8e372 100644 --- a/ui/components/ui/identicon/identicon.component.js +++ b/ui/components/ui/identicon/identicon.component.js @@ -1,7 +1,6 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import contractMap from '@metamask/contract-metadata'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import Jazzicon from '../jazzicon'; @@ -23,6 +22,8 @@ export default class Identicon extends PureComponent { useBlockie: PropTypes.bool, alt: PropTypes.string, imageBorder: PropTypes.bool, + useTokenDetection: PropTypes.bool, + tokenList: PropTypes.object, }; static defaultProps = { @@ -33,6 +34,7 @@ export default class Identicon extends PureComponent { image: undefined, useBlockie: false, alt: '', + tokenList: {}, }; renderImage() { @@ -51,8 +53,14 @@ export default class Identicon extends PureComponent { } renderJazzicon() { - const { address, className, diameter, alt } = this.props; - + const { + address, + className, + diameter, + alt, + useTokenDetection, + tokenList, + } = this.props; return ( ); } @@ -78,16 +88,25 @@ export default class Identicon extends PureComponent { } render() { - const { address, image, useBlockie, addBorder, diameter } = this.props; - + const { + address, + image, + useBlockie, + addBorder, + diameter, + useTokenDetection, + tokenList, + } = this.props; if (image) { return this.renderImage(); } if (address) { - const checksummedAddress = toChecksumHexAddress(address); - - if (checksummedAddress && contractMap[checksummedAddress]?.logo) { + // token from dynamic api list is fetched when useTokenDetection is true + const tokenAddress = useTokenDetection + ? address + : toChecksumHexAddress(address); + if (tokenAddress && tokenList[tokenAddress]?.iconUrl) { return this.renderJazzicon(); } diff --git a/ui/components/ui/identicon/identicon.container.js b/ui/components/ui/identicon/identicon.container.js index 2ed017abb..b000eecfe 100644 --- a/ui/components/ui/identicon/identicon.container.js +++ b/ui/components/ui/identicon/identicon.container.js @@ -3,11 +3,13 @@ import Identicon from './identicon.component'; const mapStateToProps = (state) => { const { - metamask: { useBlockie }, + metamask: { useBlockie, useTokenDetection, tokenList }, } = state; return { useBlockie, + useTokenDetection, + tokenList, }; }; diff --git a/ui/components/ui/jazzicon/jazzicon.component.js b/ui/components/ui/jazzicon/jazzicon.component.js index b0a35cb6f..f5261f832 100644 --- a/ui/components/ui/jazzicon/jazzicon.component.js +++ b/ui/components/ui/jazzicon/jazzicon.component.js @@ -15,6 +15,8 @@ export default class Jazzicon extends PureComponent { className: PropTypes.string, diameter: PropTypes.number, style: PropTypes.object, + useTokenDetection: PropTypes.bool, + tokenList: PropTypes.object, }; static defaultProps = { @@ -46,8 +48,13 @@ export default class Jazzicon extends PureComponent { } appendJazzicon() { - const { address, diameter } = this.props; - const image = iconFactory.iconForAddress(address, diameter); + const { address, diameter, useTokenDetection, tokenList } = this.props; + const image = iconFactory.iconForAddress( + address, + diameter, + useTokenDetection, + tokenList, + ); this.container.current.appendChild(image); } diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 6ee102b69..e3008b577 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -1,8 +1,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import abi from 'human-standard-token-abi'; -import contractMap from '@metamask/contract-metadata'; import BigNumber from 'bignumber.js'; -import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; +import { addHexPrefix } from 'ethereumjs-util'; import { debounce } from 'lodash'; import { conversionGreaterThan, @@ -39,6 +38,8 @@ import { getTargetAccount, getIsNonStandardEthChain, checkNetworkAndAccountSupports1559, + getUseTokenDetection, + getTokenList, } from '../../selectors'; import { disconnectGasFeeEstimatePoller, @@ -71,6 +72,7 @@ import { isDefaultMetaMaskChain, isOriginContractAddress, isValidDomainName, + isEqualCaseInsensitive, } from '../../helpers/utils/util'; import { getGasEstimateType, @@ -517,6 +519,8 @@ export const initializeSendState = createAsyncThunk( gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)), gasEstimatePollToken, eip1559support, + useTokenDetection: getUseTokenDetection(state), + tokenAddressList: Object.keys(getTokenList(state)), }; }, ); @@ -986,7 +990,7 @@ const slice = createSlice({ recipient.warning = null; } else { const isSendingToken = asset.type === ASSET_TYPES.TOKEN; - const { chainId, tokens } = action.payload; + const { chainId, tokens, tokenAddressList } = action.payload; if ( isBurnAddress(recipient.userInput) || (!isValidHexAddress(recipient.userInput, { @@ -1005,11 +1009,12 @@ const slice = createSlice({ } else { recipient.error = null; } - if ( isSendingToken && isValidHexAddress(recipient.userInput) && - (toChecksumAddress(recipient.userInput) in contractMap || + (tokenAddressList.find((address) => + isEqualCaseInsensitive(address, recipient.userInput), + ) || checkExistingAddresses(recipient.userInput, tokens)) ) { recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; @@ -1210,6 +1215,8 @@ const slice = createSlice({ payload: { chainId: action.payload.chainId, tokens: action.payload.tokens, + useTokenDetection: action.payload.useTokenDetection, + tokenAddressList: action.payload.tokenAddressList, }, }); } @@ -1395,7 +1402,14 @@ export function updateRecipientUserInput(userInput) { const state = getState(); const chainId = getCurrentChainId(state); const tokens = getTokens(state); - debouncedValidateRecipientUserInput(dispatch, { chainId, tokens }); + const useTokenDetection = getUseTokenDetection(state); + const tokenAddressList = Object.keys(getTokenList(state)); + debouncedValidateRecipientUserInput(dispatch, { + chainId, + tokens, + useTokenDetection, + tokenAddressList, + }); }; } diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index 69dbe8893..807a936b9 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -628,6 +628,8 @@ describe('Send Slice', () => { payload: { chainId: '', tokens: [], + useTokenDetection: true, + tokenAddressList: [], }, }; @@ -649,6 +651,8 @@ describe('Send Slice', () => { payload: { chainId: '0x55', tokens: [], + useTokenDetection: true, + tokenAddressList: [], }, }; @@ -671,6 +675,8 @@ describe('Send Slice', () => { payload: { chainId: '', tokens: [], + useTokenDetection: true, + tokenAddressList: [], }, }; @@ -698,6 +704,8 @@ describe('Send Slice', () => { payload: { chainId: '0x4', tokens: [], + useTokenDetection: true, + tokenAddressList: ['0x514910771af9ca656af840dff83e8264ecf986ca'], }, }; @@ -1111,6 +1119,32 @@ describe('Send Slice', () => { provider: { chainId: '0x4', }, + useTokenDetection: true, + tokenList: { + 0x514910771af9ca656af840dff83e8264ecf986ca: { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + name: 'Chainlink', + iconUrl: + 'https://s3.amazonaws.com/airswap-token-images/LINK.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + }, }, send: initialState, gas: { @@ -1484,6 +1518,31 @@ describe('Send Slice', () => { chainId: '', }, tokens: [], + useTokenDetection: true, + tokenList: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + name: 'Chainlink', + iconUrl: 'https://s3.amazonaws.com/airswap-token-images/LINK.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + }, }, }; @@ -1512,6 +1571,8 @@ describe('Send Slice', () => { expect(store.getActions()[1].payload).toStrictEqual({ chainId: '', tokens: [], + useTokenDetection: true, + tokenAddressList: ['0x514910771af9ca656af840dff83e8264ecf986ca'], }); }); }); @@ -1736,6 +1797,32 @@ describe('Send Slice', () => { chainId: '', }, tokens: [], + useTokenDetection: true, + tokenList: { + 0x514910771af9ca656af840dff83e8264ecf986ca: { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + name: 'Chainlink', + iconUrl: + 'https://s3.amazonaws.com/airswap-token-images/LINK.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + }, }, send: { asset: { diff --git a/ui/helpers/utils/icon-factory.js b/ui/helpers/utils/icon-factory.js index 5189dcfce..2da53dbc9 100644 --- a/ui/helpers/utils/icon-factory.js +++ b/ui/helpers/utils/icon-factory.js @@ -1,4 +1,3 @@ -import contractMap from '@metamask/contract-metadata'; import { isValidHexAddress, toChecksumHexAddress, @@ -18,11 +17,18 @@ function IconFactory(jazzicon) { this.cache = {}; } -IconFactory.prototype.iconForAddress = function (address, diameter) { - const addr = toChecksumHexAddress(address); - - if (iconExistsFor(addr)) { - return imageElFor(addr); +IconFactory.prototype.iconForAddress = function ( + address, + diameter, + useTokenDetection, + tokenList, +) { + // When useTokenDetection flag is true the tokenList contains tokens with non-checksum address from the dynamic token service api, + // When useTokenDetection flag is false the tokenList contains tokens with checksum addresses from contract-metadata. + // So the flag indicates whether the address of tokens currently on the tokenList is checksum or not. + const addr = useTokenDetection ? address : toChecksumHexAddress(address); + if (iconExistsFor(addr, tokenList)) { + return imageElFor(addr, useTokenDetection, tokenList); } return this.generateIdenticonSvg(address, diameter); @@ -49,18 +55,22 @@ IconFactory.prototype.generateNewIdenticon = function (address, diameter) { // util -function iconExistsFor(address) { +function iconExistsFor(address, tokenList) { return ( - contractMap[address] && + tokenList[address] && isValidHexAddress(address, { allowNonPrefixed: false }) && - contractMap[address].logo + tokenList[address].iconUrl ); } -function imageElFor(address) { - const contract = contractMap[address]; - const fileName = contract.logo; - const path = `images/contract/${fileName}`; +function imageElFor(address, useTokenDetection, tokenList) { + const tokenMetadata = tokenList[address]; + const fileName = tokenMetadata?.iconUrl; + // token from dynamic api list is fetched when useTokenDetection is true + // In the static list, the iconUrl will be holding only a filename for the image, + // the corresponding images will be available in the `images/contract/` location when the contract-metadata package was added to the extension + // so that it can be accessed using the filename in iconUrl. + const path = useTokenDetection ? fileName : `images/contract/${fileName}`; const img = document.createElement('img'); img.src = path; img.style.width = '100%'; diff --git a/ui/helpers/utils/token-util.js b/ui/helpers/utils/token-util.js index 46e3617c8..68431dde5 100644 --- a/ui/helpers/utils/token-util.js +++ b/ui/helpers/utils/token-util.js @@ -1,6 +1,5 @@ import log from 'loglevel'; import BigNumber from 'bignumber.js'; -import contractMap from '@metamask/contract-metadata'; import { conversionUtil, multiplyCurrencies, @@ -8,13 +7,6 @@ import { import * as util from './util'; import { formatCurrency } from './confirm-tx.util'; -const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { - return { - ...acc, - [base.toLowerCase()]: contractMap[base], - }; -}, {}); - const DEFAULT_SYMBOL = ''; async function getSymbolFromContract(tokenAddress) { @@ -48,15 +40,21 @@ async function getDecimalsFromContract(tokenAddress) { } } -function getContractMetadata(tokenAddress) { - return tokenAddress && casedContractMap[tokenAddress.toLowerCase()]; +function getTokenMetadata(tokenAddress, tokenList) { + const casedTokenList = Object.keys(tokenList).reduce((acc, base) => { + return { + ...acc, + [base.toLowerCase()]: tokenList[base], + }; + }, {}); + return tokenAddress && casedTokenList[tokenAddress.toLowerCase()]; } -async function getSymbol(tokenAddress) { +async function getSymbol(tokenAddress, tokenList) { let symbol = await getSymbolFromContract(tokenAddress); if (!symbol) { - const contractMetadataInfo = getContractMetadata(tokenAddress); + const contractMetadataInfo = getTokenMetadata(tokenAddress, tokenList); if (contractMetadataInfo) { symbol = contractMetadataInfo.symbol; @@ -66,11 +64,11 @@ async function getSymbol(tokenAddress) { return symbol; } -async function getDecimals(tokenAddress) { +async function getDecimals(tokenAddress, tokenList) { let decimals = await getDecimalsFromContract(tokenAddress); if (!decimals || decimals === '0') { - const contractMetadataInfo = getContractMetadata(tokenAddress); + const contractMetadataInfo = getTokenMetadata(tokenAddress, tokenList); if (contractMetadataInfo) { decimals = contractMetadataInfo.decimals?.toString(); @@ -80,23 +78,12 @@ async function getDecimals(tokenAddress) { return decimals; } -export async function getSymbolAndDecimals(tokenAddress, existingTokens = []) { - const existingToken = existingTokens.find( - ({ address }) => tokenAddress === address, - ); - - if (existingToken) { - return { - symbol: existingToken.symbol, - decimals: existingToken.decimals, - }; - } - +export async function getSymbolAndDecimals(tokenAddress, tokenList) { let symbol, decimals; try { - symbol = await getSymbol(tokenAddress); - decimals = await getDecimals(tokenAddress); + symbol = await getSymbol(tokenAddress, tokenList); + decimals = await getDecimals(tokenAddress, tokenList); } catch (error) { log.warn( `symbol() and decimal() calls for token at address ${tokenAddress} resulted in error:`, @@ -113,12 +100,12 @@ export async function getSymbolAndDecimals(tokenAddress, existingTokens = []) { export function tokenInfoGetter() { const tokens = {}; - return async (address) => { + return async (address, tokenList) => { if (tokens[address]) { return tokens[address]; } - tokens[address] = await getSymbolAndDecimals(address); + tokens[address] = await getSymbolAndDecimals(address, tokenList); return tokens[address]; }; diff --git a/ui/helpers/utils/util.js b/ui/helpers/utils/util.js index 83c94a944..0cd76837e 100644 --- a/ui/helpers/utils/util.js +++ b/ui/helpers/utils/util.js @@ -56,6 +56,12 @@ export function isDefaultMetaMaskChain(chainId) { return false; } +// Both inputs should be strings. This method is currently used to compare tokenAddress hex strings. +export function isEqualCaseInsensitive(value1, value2) { + if (typeof value1 !== 'string' || typeof value2 !== 'string') return false; + return value1.toLowerCase() === value2.toLowerCase(); +} + export function valuesFor(obj) { if (!obj) { return []; diff --git a/ui/hooks/useTokensToSearch.js b/ui/hooks/useTokensToSearch.js index dd3d30f54..3375a8cf9 100644 --- a/ui/hooks/useTokensToSearch.js +++ b/ui/hooks/useTokensToSearch.js @@ -9,6 +9,8 @@ import { getCurrentCurrency, getSwapsDefaultToken, getCurrentChainId, + getUseTokenDetection, + getTokenList, } from '../selectors'; import { getConversionRate } from '../ducks/metamask/metamask'; @@ -17,7 +19,7 @@ import { isSwapsDefaultTokenSymbol } from '../../shared/modules/swaps.utils'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { useEqualityCheck } from './useEqualityCheck'; -const tokenList = shuffle( +const shuffledContractMap = shuffle( Object.entries(contractMap) .map(([address, tokenData]) => ({ ...tokenData, @@ -32,9 +34,14 @@ export function getRenderableTokenData( conversionRate, currentCurrency, chainId, + tokenList, + useTokenDetection, ) { const { symbol, name, address, iconUrl, string, balance, decimals } = token; - + // token from dynamic api list is fetched when useTokenDetection is true + const tokenAddress = useTokenDetection + ? address + : toChecksumHexAddress(address); const formattedFiat = getTokenFiatAmount( isSwapsDefaultTokenSymbol(symbol, chainId) @@ -59,12 +66,12 @@ export function getRenderableTokenData( ) || ''; const usedIconUrl = iconUrl || - (contractMap[toChecksumHexAddress(address)] && - `images/contract/${contractMap[toChecksumHexAddress(address)].logo}`); + (tokenList[tokenAddress] && + `images/contract/${tokenList[tokenAddress].iconUrl}`); return { ...token, primaryLabel: symbol, - secondaryLabel: name || contractMap[toChecksumHexAddress(address)]?.name, + secondaryLabel: name || tokenList[tokenAddress]?.name, rightPrimaryLabel: string && `${new BigNumber(string).round(6).toString()} ${symbol}`, rightSecondaryLabel: formattedFiat, @@ -72,18 +79,27 @@ export function getRenderableTokenData( identiconAddress: usedIconUrl ? null : address, balance, decimals, - name: name || contractMap[toChecksumHexAddress(address)]?.name, + name: name || tokenList[tokenAddress]?.name, rawFiat, }; } -export function useTokensToSearch({ usersTokens = [], topTokens = {} }) { +export function useTokensToSearch({ + usersTokens = [], + topTokens = {}, + shuffledTokensList, +}) { const chainId = useSelector(getCurrentChainId); const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const conversionRate = useSelector(getConversionRate); const currentCurrency = useSelector(getCurrentCurrency); const defaultSwapsToken = useSelector(getSwapsDefaultToken); - + const tokenList = useSelector(getTokenList); + const useTokenDetection = useSelector(getUseTokenDetection); + // token from dynamic api list is fetched when useTokenDetection is true + const shuffledTokenList = useTokenDetection + ? shuffledTokensList + : shuffledContractMap; const memoizedTopTokens = useEqualityCheck(topTokens); const memoizedUsersToken = useEqualityCheck(usersTokens); @@ -93,6 +109,8 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) { conversionRate, currentCurrency, chainId, + tokenList, + useTokenDetection, ); const memoizedDefaultToken = useEqualityCheck(defaultToken); @@ -102,7 +120,7 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) { ? swapsTokens : [ memoizedDefaultToken, - ...tokenList.filter( + ...shuffledTokenList.filter( (token) => token.symbol !== memoizedDefaultToken.symbol, ), ]; @@ -132,6 +150,8 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) { conversionRate, currentCurrency, chainId, + tokenList, + useTokenDetection, ); if ( isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) || @@ -166,5 +186,7 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) { currentCurrency, memoizedTopTokens, chainId, + tokenList, + useTokenDetection, ]); } diff --git a/ui/pages/add-token/add-token.component.js b/ui/pages/add-token/add-token.component.js index a2cf0a702..eb56130a0 100644 --- a/ui/pages/add-token/add-token.component.js +++ b/ui/pages/add-token/add-token.component.js @@ -40,6 +40,11 @@ class AddToken extends Component { mostRecentOverviewPage: PropTypes.string.isRequired, chainId: PropTypes.string, rpcPrefs: PropTypes.object, + tokenList: PropTypes.object, + }; + + static defaultProps = { + tokenList: {}, }; state = { @@ -140,7 +145,10 @@ class AddToken extends Component { return; } - const { setPendingTokens, history } = this.props; + const { setPendingTokens, history, tokenList } = this.props; + const tokenAddressList = Object.keys(tokenList).map((address) => + address.toLowerCase(), + ); const { customAddress: address, customSymbol: symbol, @@ -154,12 +162,16 @@ class AddToken extends Component { decimals, }; - setPendingTokens({ customToken, selectedTokens }); + setPendingTokens({ customToken, selectedTokens, tokenAddressList }); history.push(CONFIRM_ADD_TOKEN_ROUTE); } async attemptToAutoFillTokenParams(address) { - const { symbol = '', decimals } = await this.tokenInfoGetter(address); + const { tokenList } = this.props; + const { symbol = '', decimals } = await this.tokenInfoGetter( + address, + tokenList, + ); const symbolAutoFilled = Boolean(symbol); const decimalAutoFilled = Boolean(decimals); @@ -358,8 +370,8 @@ class AddToken extends Component { } renderSearchToken() { + const { tokenList } = this.props; const { tokenSelectorError, selectedTokens, searchResults } = this.state; - return (
{ showSearchTab: getIsMainnet(state) || process.env.IN_TEST === 'true', chainId, rpcPrefs: getRpcPrefsForCurrentProvider(state), + tokenList: getTokenList(state), }; }; diff --git a/ui/pages/add-token/add-token.test.js b/ui/pages/add-token/add-token.test.js index 4b36e7f39..d3ebafbec 100644 --- a/ui/pages/add-token/add-token.test.js +++ b/ui/pages/add-token/add-token.test.js @@ -26,6 +26,7 @@ describe('Add Token', () => { identities: {}, mostRecentOverviewPage: '/', showSearchTab: true, + tokenList: {}, }; describe('Add Token', () => { diff --git a/ui/pages/add-token/token-list/token-list.component.js b/ui/pages/add-token/token-list/token-list.component.js index 819be4173..4d6827a99 100644 --- a/ui/pages/add-token/token-list/token-list.component.js +++ b/ui/pages/add-token/token-list/token-list.component.js @@ -14,6 +14,7 @@ export default class TokenList extends Component { results: PropTypes.array, selectedTokens: PropTypes.object, onToggleToken: PropTypes.func, + useTokenDetection: PropTypes.bool, }; render() { @@ -22,6 +23,7 @@ export default class TokenList extends Component { selectedTokens = {}, onToggleToken, tokens = [], + useTokenDetection, } = this.props; return results.length === 0 ? ( @@ -35,13 +37,17 @@ export default class TokenList extends Component { {Array(6) .fill(undefined) .map((_, i) => { - const { logo, symbol, name, address } = results[i] || {}; + const { iconUrl, symbol, name, address } = results[i] || {}; + // token from dynamic api list is fetched when useTokenDetection is true + const iconPath = useTokenDetection + ? iconUrl + : `images/contract/${iconUrl}`; const tokenAlreadyAdded = checkExistingAddresses(address, tokens); const onClick = () => !tokenAlreadyAdded && onToggleToken(results[i]); return ( - Boolean(logo || symbol || name) && ( + Boolean(iconUrl || symbol || name) && (
diff --git a/ui/pages/add-token/token-list/token-list.container.js b/ui/pages/add-token/token-list/token-list.container.js index 4896067f7..565bd4262 100644 --- a/ui/pages/add-token/token-list/token-list.container.js +++ b/ui/pages/add-token/token-list/token-list.container.js @@ -2,9 +2,10 @@ import { connect } from 'react-redux'; import TokenList from './token-list.component'; const mapStateToProps = ({ metamask }) => { - const { tokens } = metamask; + const { tokens, useTokenDetection } = metamask; return { tokens, + useTokenDetection, }; }; diff --git a/ui/pages/add-token/token-search/token-search.component.js b/ui/pages/add-token/token-search/token-search.component.js index 76bfdab09..6a6a2c0dc 100644 --- a/ui/pages/add-token/token-search/token-search.component.js +++ b/ui/pages/add-token/token-search/token-search.component.js @@ -1,26 +1,9 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import contractMap from '@metamask/contract-metadata'; import Fuse from 'fuse.js'; import InputAdornment from '@material-ui/core/InputAdornment'; import TextField from '../../../components/ui/text-field'; - -const contractList = Object.entries(contractMap) - .map(([address, tokenData]) => ({ ...tokenData, address })) - .filter((tokenData) => Boolean(tokenData.erc20)); - -const fuse = new Fuse(contractList, { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}); +import { isEqualCaseInsensitive } from '../../../helpers/utils/util'; export default class TokenSearch extends Component { static contextTypes = { @@ -34,17 +17,40 @@ export default class TokenSearch extends Component { static propTypes = { onSearch: PropTypes.func, error: PropTypes.string, + tokenList: PropTypes.object, }; state = { searchQuery: '', }; + constructor(props) { + super(props); + const { tokenList } = this.props; + this.tokenList = Object.values(tokenList); + this.tokenSearchFuse = new Fuse(this.tokenList, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'symbol', weight: 0.5 }, + ], + }); + } + handleSearch(searchQuery) { this.setState({ searchQuery }); - const fuseSearchResult = fuse.search(searchQuery); - const addressSearchResult = contractList.filter((token) => { - return token.address.toLowerCase() === searchQuery.toLowerCase(); + const fuseSearchResult = this.tokenSearchFuse.search(searchQuery); + const addressSearchResult = this.tokenList.filter((token) => { + return ( + token.address && + searchQuery && + isEqualCaseInsensitive(token.address, searchQuery) + ); }); const results = [...addressSearchResult, ...fuseSearchResult]; this.props.onSearch({ searchQuery, results }); diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js index 251edb1ca..f407ceb57 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -1,7 +1,6 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; -import contractMap from '@metamask/contract-metadata'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { @@ -30,6 +29,8 @@ import { checkNetworkAndAccountSupports1559, getPreferences, getAccountType, + getUseTokenDetection, + getTokenList, } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { @@ -47,13 +48,6 @@ import { import { getGasLoadingAnimationIsShowing } from '../../ducks/app/app'; import ConfirmTransactionBase from './confirm-transaction-base.component'; -const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { - return { - ...acc, - [base.toLowerCase()]: contractMap[base], - }; -}, {}); - let customNonceValue = ''; const customNonceMerge = (txData) => customNonceValue @@ -109,9 +103,19 @@ const mapStateToProps = (state, ownProps) => { const { name: fromName } = identities[fromAddress]; const toAddress = propsToAddress || txParamsToAddress; + const tokenList = getTokenList(state); + const useTokenDetection = getUseTokenDetection(state); + const casedTokenList = useTokenDetection + ? tokenList + : Object.keys(tokenList).reduce((acc, base) => { + return { + ...acc, + [base.toLowerCase()]: tokenList[base], + }; + }, {}); const toName = identities[toAddress]?.name || - casedContractMap[toAddress]?.name || + casedTokenList[toAddress]?.name || shortenAddress(toChecksumHexAddress(toAddress)); const checksummedAddress = toChecksumHexAddress(toAddress); diff --git a/ui/pages/swaps/build-quote/build-quote.js b/ui/pages/swaps/build-quote/build-quote.js index 73ad6c1ee..66bfbeb5a 100644 --- a/ui/pages/swaps/build-quote/build-quote.js +++ b/ui/pages/swaps/build-quote/build-quote.js @@ -36,6 +36,8 @@ import { getCurrentCurrency, getCurrentChainId, getRpcPrefsForCurrentProvider, + getUseTokenDetection, + getTokenList, } from '../../../selectors'; import { @@ -83,6 +85,7 @@ export default function BuildQuote({ selectedAccountAddress, isFeatureFlagLoaded, tokenFromError, + shuffledTokensList, }) { const t = useContext(I18nContext); const dispatch = useDispatch(); @@ -105,6 +108,8 @@ export default function BuildQuote({ const defaultSwapsToken = useSelector(getSwapsDefaultToken); const chainId = useSelector(getCurrentChainId); const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); + const tokenList = useSelector(getTokenList); + const useTokenDetection = useSelector(getUseTokenDetection); const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const conversionRate = useSelector(getConversionRate); @@ -138,11 +143,14 @@ export default function BuildQuote({ conversionRate, currentCurrency, chainId, + tokenList, + useTokenDetection, ); const tokensToSearch = useTokensToSearch({ usersTokens: memoizedUsersTokens, topTokens: topAssets, + shuffledTokensList, }); const selectedToToken = tokensToSearch.find(({ address }) => address === toToken?.address) || @@ -611,4 +619,5 @@ BuildQuote.propTypes = { selectedAccountAddress: PropTypes.string, isFeatureFlagLoaded: PropTypes.bool.isRequired, tokenFromError: PropTypes.string, + shuffledTokensList: PropTypes.array, }; diff --git a/ui/pages/swaps/build-quote/build-quote.test.js b/ui/pages/swaps/build-quote/build-quote.test.js index c1a1d8042..1fc09368e 100644 --- a/ui/pages/swaps/build-quote/build-quote.test.js +++ b/ui/pages/swaps/build-quote/build-quote.test.js @@ -19,6 +19,7 @@ const createProps = (customProps = {}) => { maxSlippage: 15, selectedAccountAddress: 'selectedAccountAddress', isFeatureFlagLoaded: false, + shuffledTokensList: [], ...customProps, }; }; diff --git a/ui/pages/swaps/index.js b/ui/pages/swaps/index.js index 309a35a7f..477469e3c 100644 --- a/ui/pages/swaps/index.js +++ b/ui/pages/swaps/index.js @@ -8,6 +8,7 @@ import { Redirect, } from 'react-router-dom'; import BigNumber from 'bignumber.js'; +import { shuffle } from 'lodash'; import { I18nContext } from '../../contexts/i18n'; import { getSelectedAccount, @@ -15,6 +16,7 @@ import { getIsSwapsChain, isHardwareWallet, getHardwareWalletType, + getTokenList, } from '../../selectors/selectors'; import { getQuotes, @@ -119,6 +121,8 @@ export default function Swap() { checkNetworkAndAccountSupports1559, ); const fromToken = useSelector(getFromToken); + const tokenList = useSelector(getTokenList); + const listTokenValues = shuffle(Object.values(tokenList)); if (networkAndAccountSupports1559) { // This will pre-load gas fees before going to the View Quote page. @@ -336,6 +340,7 @@ export default function Swap() { maxSlippage={maxSlippage} isFeatureFlagLoaded={isFeatureFlagLoaded} tokenFromError={tokenFromError} + shuffledTokensList={listTokenValues} /> ); }} diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 48e1ab2f5..08e5a89d7 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -599,3 +599,21 @@ export function getShowRecoveryPhraseReminder(state) { return currentTime - recoveryPhraseReminderLastShown >= frequency; } + +/** + * To get the useTokenDetection flag which determines whether a static or dynamic token list is used + * @param {*} state + * @returns Boolean + */ +export function getUseTokenDetection(state) { + return Boolean(state.metamask.useTokenDetection); +} + +/** + * To retrieve the tokenList produced by TokenListcontroller + * @param {*} state + * @returns {Object} + */ +export function getTokenList(state) { + return state.metamask.tokenList; +} diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index b83697ab3..75a43ea49 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -129,4 +129,60 @@ describe('Selectors', () => { const totalUnapprovedCount = selectors.getTotalUnapprovedCount(mockState); expect(totalUnapprovedCount).toStrictEqual(1); }); + + it('#getUseTokenDetection', () => { + const useTokenDetection = selectors.getUseTokenDetection(mockState); + expect(useTokenDetection).toStrictEqual(true); + }); + + it('#getTokenList', () => { + const tokenList = selectors.getTokenList(mockState); + expect(tokenList).toStrictEqual({ + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599': { + address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + symbol: 'WBTC', + decimals: 8, + name: 'Wrapped Bitcoin', + iconUrl: 'https://s3.amazonaws.com/airswap-token-images/WBTC.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e': { + address: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + symbol: 'YFI', + decimals: 18, + name: 'yearn.finance', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e/logo.png', + aggregators: [ + 'airswapLight', + 'bancor', + 'cmc', + 'coinGecko', + 'kleros', + 'oneInch', + 'paraswap', + 'pmm', + 'totle', + 'zapper', + 'zerion', + 'zeroEx', + ], + occurrences: 12, + }, + }); + }); }); diff --git a/ui/store/actions.js b/ui/store/actions.js index 314f857b9..16f70edf1 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -9,6 +9,7 @@ import { } from '../helpers/utils/i18n-helper'; import { getMethodDataAsync } from '../helpers/utils/transactions.util'; import { getSymbolAndDecimals } from '../helpers/utils/token-util'; +import { isEqualCaseInsensitive } from '../helpers/utils/util'; import switchDirection from '../helpers/utils/switch-direction'; import { ENVIRONMENT_TYPE_NOTIFICATION, @@ -21,11 +22,11 @@ import { getMetaMaskAccounts, getPermittedAccountsForCurrentTab, getSelectedAddress, + getTokenList, } from '../selectors'; import { computeEstimatedGasLimit, resetSendState } from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'; -import { LISTED_CONTRACT_ADDRESSES } from '../../shared/constants/tokens'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import * as actionConstants from './actionConstants'; @@ -2039,11 +2040,11 @@ export function setUsePhishDetect(val) { }; } -export function setUseStaticTokenList(val) { +export function setUseTokenDetection(val) { return (dispatch) => { dispatch(showLoadingIndication()); - log.debug(`background.setUseStaticTokenList`); - background.setUseStaticTokenList(val, (err) => { + log.debug(`background.setUseTokenDetection`); + background.setUseTokenDetection(val, (err) => { dispatch(hideLoadingIndication()); if (err) { dispatch(displayWarning(err.message)); @@ -2100,7 +2101,11 @@ export function setCurrentLocale(locale, messages) { } export function setPendingTokens(pendingTokens) { - const { customToken = {}, selectedTokens = {} } = pendingTokens; + const { + customToken = {}, + selectedTokens = {}, + tokenAddressList = [], + } = pendingTokens; const { address, symbol, decimals } = customToken; const tokens = address && symbol && decimals >= 0 <= 36 @@ -2114,8 +2119,8 @@ export function setPendingTokens(pendingTokens) { : selectedTokens; Object.keys(tokens).forEach((tokenAddress) => { - tokens[tokenAddress].unlisted = !LISTED_CONTRACT_ADDRESSES.includes( - tokenAddress.toLowerCase(), + tokens[tokenAddress].unlisted = !tokenAddressList.find((addr) => + isEqualCaseInsensitive(addr, tokenAddress), ); }); @@ -2522,6 +2527,7 @@ export function loadingTokenParamsFinished() { export function getTokenParams(tokenAddress) { return (dispatch, getState) => { + const tokenList = getTokenList(getState()); const existingTokens = getState().metamask.tokens; const existingToken = existingTokens.find( ({ address }) => tokenAddress === address, @@ -2537,10 +2543,12 @@ export function getTokenParams(tokenAddress) { dispatch(loadingTokenParamsStarted()); log.debug(`loadingTokenParams`); - return getSymbolAndDecimals(tokenAddress).then(({ symbol, decimals }) => { - dispatch(addToken(tokenAddress, symbol, Number(decimals))); - dispatch(loadingTokenParamsFinished()); - }); + return getSymbolAndDecimals(tokenAddress, tokenList).then( + ({ symbol, decimals }) => { + dispatch(addToken(tokenAddress, symbol, Number(decimals))); + dispatch(loadingTokenParamsFinished()); + }, + ); }; }