diff --git a/src/apis/token-service.test.ts b/src/apis/token-service.test.ts new file mode 100644 index 00000000000..069704b5fd6 --- /dev/null +++ b/src/apis/token-service.test.ts @@ -0,0 +1,179 @@ +import nock from 'nock'; +import { NetworksChainId } from '../network/NetworkController'; +import { + fetchTokenList, + syncTokens, + fetchTokenMetadata, +} from './token-service'; + +const TOKEN_END_POINT_API = 'https://token-api.airswap-prod.codefi.network'; + +const sampleTokenList = [ + { + address: '0xbbbbca6a901c926f240b89eacb641d8aec7aeafd', + symbol: 'LRC', + decimals: 18, + occurances: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurances: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + { + address: '0x408e41876cccdc0f92210600ef50372656052a38', + symbol: 'REN', + decimals: 18, + occurances: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurances: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurances: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, +]; + +const sampleToken = { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurances: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', +}; + +describe('FetchtokenList', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.enableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should call the tokens api and return the list of tokens', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleTokenList) + .persist(); + + const tokens = await fetchTokenList(NetworksChainId.mainnet); + + expect(tokens).toStrictEqual(sampleTokenList); + }); + it('should call the api to sync tokens and returns nothing', async () => { + nock(TOKEN_END_POINT_API) + .get(`/sync/${NetworksChainId.mainnet}`) + .reply(200) + .persist(); + + expect(await syncTokens(NetworksChainId.mainnet)).toBeUndefined(); + }); + it('should call the api to return the token metadata for eth address provided', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${NetworksChainId.mainnet}?address=0x514910771af9ca656af840dff83e8264ecf986ca`, + ) + .reply(200, sampleToken) + .persist(); + + const token = await fetchTokenMetadata( + NetworksChainId.mainnet, + '0x514910771af9ca656af840dff83e8264ecf986ca', + ); + + expect(token).toStrictEqual(sampleToken); + }); +}); diff --git a/src/apis/token-service.ts b/src/apis/token-service.ts new file mode 100644 index 00000000000..c5c7a2d5c9e --- /dev/null +++ b/src/apis/token-service.ts @@ -0,0 +1,72 @@ +import { timeoutFetch } from '../util'; + +const END_POINT = 'https://token-api.airswap-prod.codefi.network'; + +function syncTokensURL(chainId: string) { + return `${END_POINT}/sync/${chainId}`; +} +function getTokensURL(chainId: string) { + return `${END_POINT}/tokens/${chainId}`; +} +function getTokenMetadataURL(chainId: string, tokenAddress: string) { + return `${END_POINT}/tokens/${chainId}?address=${tokenAddress}`; +} + +/** + * Fetches the list of token metadata for a given network chainId + * + * @returns - Promise resolving token List + */ +export async function fetchTokenList(chainId: string): Promise { + const tokenURL = getTokensURL(chainId); + const fetchOptions: RequestInit = { + referrer: tokenURL, + referrerPolicy: 'no-referrer-when-downgrade', + method: 'GET', + mode: 'cors', + }; + fetchOptions.headers = new window.Headers(); + fetchOptions.headers.set('Content-Type', 'application/json'); + const tokenResponse = await timeoutFetch(tokenURL, fetchOptions); + return await tokenResponse.json(); +} + +/** + * Forces a sync of token metadata for a given network chainId. + * Syncing happens every 1 hour in the background, this api can + * be used to force a sync from our side + */ +export async function syncTokens(chainId: string): Promise { + const syncURL = syncTokensURL(chainId); + const fetchOptions: RequestInit = { + referrer: syncURL, + referrerPolicy: 'no-referrer-when-downgrade', + method: 'GET', + mode: 'cors', + }; + fetchOptions.headers = new window.Headers(); + fetchOptions.headers.set('Content-Type', 'application/json'); + await timeoutFetch(syncURL, fetchOptions); +} + +/** + * Fetch metadata for the token address provided for a given network chainId + * + * @return Promise resolving token metadata for the tokenAddress provided + */ +export async function fetchTokenMetadata( + chainId: string, + tokenAddress: string, +): Promise { + const tokenMetadataURL = getTokenMetadataURL(chainId, tokenAddress); + const fetchOptions: RequestInit = { + referrer: tokenMetadataURL, + referrerPolicy: 'no-referrer-when-downgrade', + method: 'GET', + mode: 'cors', + }; + fetchOptions.headers = new window.Headers(); + fetchOptions.headers.set('Content-Type', 'application/json'); + const tokenResponse = await timeoutFetch(tokenMetadataURL, fetchOptions); + return await tokenResponse.json(); +} diff --git a/src/assets/AssetsDetectionController.test.ts b/src/assets/AssetsDetectionController.test.ts index a0d0eefcfa6..f08505161a1 100644 --- a/src/assets/AssetsDetectionController.test.ts +++ b/src/assets/AssetsDetectionController.test.ts @@ -1,15 +1,20 @@ import { createSandbox, SinonStub, stub } from 'sinon'; import nock from 'nock'; import { BN } from 'ethereumjs-util'; -import contractMap from '@metamask/contract-metadata'; import { NetworkController, NetworksChainId, } from '../network/NetworkController'; import { PreferencesController } from '../user/PreferencesController'; +import { ControllerMessenger } from '../ControllerMessenger'; import { AssetsController } from './AssetsController'; import { AssetsContractController } from './AssetsContractController'; import { AssetsDetectionController } from './AssetsDetectionController'; +import { + TokenListController, + GetTokenListState, + TokenListStateChange, +} from './TokenListController'; const DEFAULT_INTERVAL = 180000; const MAINNET = 'mainnet'; @@ -17,12 +22,89 @@ const ROPSTEN = 'ropsten'; const TOKENS = [{ address: '0xfoO', symbol: 'bar', decimals: 2 }]; const OPEN_SEA_HOST = 'https://api.opensea.io'; const OPEN_SEA_PATH = '/api/v1'; +const TOKEN_END_POINT_API = 'https://token-api.airswap-prod.codefi.network'; +const sampleTokenList = [ + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, + { + address: '0x6810e776880c02933d47db1b9fc05908e5386b96', + symbol: 'GNO', + name: 'Gnosis', + decimals: 18, + occurrences: 10, + aggregators: [ + 'paraswap', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, +]; +function getTokenListMessenger() { + const controllerMessenger = new ControllerMessenger< + GetTokenListState, + TokenListStateChange + >(); + const messenger = controllerMessenger.getRestricted< + 'TokenListController', + never, + never + >({ + name: 'TokenListController', + }); + return messenger; +} describe('AssetsDetectionController', () => { let assetsDetection: AssetsDetectionController; let preferences: PreferencesController; let network: NetworkController; let assets: AssetsController; + let tokenList: TokenListController; let assetsContract: AssetsContractController; let getBalancesInSingleCall: SinonStub< Parameters, @@ -30,7 +112,7 @@ describe('AssetsDetectionController', () => { >; const sandbox = createSandbox(); - beforeEach(() => { + beforeEach(async () => { preferences = new PreferencesController(); network = new NetworkController(); assetsContract = new AssetsContractController(); @@ -43,6 +125,17 @@ describe('AssetsDetectionController', () => { assetsContract, ), }); + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleTokenList) + .persist(); + const messenger = getTokenListMessenger(); + tokenList = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + }); + await tokenList.start(); getBalancesInSingleCall = sandbox.stub(); assetsDetection = new AssetsDetectionController({ onAssetsStateChange: (listener) => assets.subscribe(listener), @@ -53,6 +146,7 @@ describe('AssetsDetectionController', () => { addTokens: assets.addTokens.bind(assets), addCollectible: assets.addCollectible.bind(assets), getAssetsState: () => assets.state, + getTokenListState: () => tokenList.state, }); nock(OPEN_SEA_HOST) @@ -153,6 +247,7 @@ describe('AssetsDetectionController', () => { afterEach(() => { nock.cleanAll(); sandbox.reset(); + tokenList.destroy(); }); it('should set default config', () => { @@ -187,6 +282,7 @@ describe('AssetsDetectionController', () => { addTokens: assets.addTokens.bind(assets), addCollectible: assets.addCollectible.bind(assets), getAssetsState: () => assets.state, + getTokenListState: () => tokenList.state, }, { interval: 10 }, ); @@ -232,6 +328,7 @@ describe('AssetsDetectionController', () => { addTokens: assets.addTokens.bind(assets), addCollectible: assets.addCollectible.bind(assets), getAssetsState: () => assets.state, + getTokenListState: () => tokenList.state, }, { interval: 10, networkType: ROPSTEN }, ); @@ -447,23 +544,22 @@ describe('AssetsDetectionController', () => { it('should detect tokens correctly', async () => { assetsDetection.configure({ networkType: MAINNET, selectedAddress: '0x1' }); getBalancesInSingleCall.resolves({ - '0x6810e776880C02933D47DB1b9fc05908e5386b96': new BN(1), + '0x6810e776880c02933d47db1b9fc05908e5386b96': new BN(1), }); await assetsDetection.detectTokens(); expect(assets.state.tokens).toStrictEqual([ { address: '0x6810e776880C02933D47DB1b9fc05908e5386b96', + symbol: 'GNO', decimals: 18, image: undefined, - symbol: 'GNO', }, ]); }); - it('should update the tokens list when new tokens are detected', async () => { assetsDetection.configure({ networkType: MAINNET, selectedAddress: '0x1' }); getBalancesInSingleCall.resolves({ - '0x6810e776880C02933D47DB1b9fc05908e5386b96': new BN(1), + '0x6810e776880c02933d47db1b9fc05908e5386b96': new BN(1), }); await assetsDetection.detectTokens(); expect(assets.state.tokens).toStrictEqual([ @@ -475,7 +571,7 @@ describe('AssetsDetectionController', () => { }, ]); getBalancesInSingleCall.resolves({ - '0x514910771AF9Ca656af840dff83E8264EcF986CA': new BN(1), + '0x514910771af9ca656af840dff83e8264ecf986ca': new BN(1), }); await assetsDetection.detectTokens(); expect(assets.state.tokens).toStrictEqual([ @@ -487,9 +583,9 @@ describe('AssetsDetectionController', () => { }, { address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', decimals: 18, image: undefined, - symbol: 'LINK', }, ]); }); @@ -497,39 +593,37 @@ describe('AssetsDetectionController', () => { it('should call getBalancesInSingle with token address that is not present on the asset state', async () => { assetsDetection.configure({ networkType: MAINNET, selectedAddress: '0x1' }); getBalancesInSingleCall.resolves({ - '0x6810e776880C02933D47DB1b9fc05908e5386b96': new BN(1), + '0x6810e776880c02933d47db1b9fc05908e5386b96': new BN(1), }); - const tokensToDetect: string[] = []; - for (const address in contractMap) { - const contract = contractMap[address]; - if (contract.erc20) { - tokensToDetect.push(address); - } - } + const tokensToDetect: string[] = Object.keys(tokenList.state.tokenList); await assetsDetection.detectTokens(); - expect(getBalancesInSingleCall.calledWith('0x1', tokensToDetect)).toBe( - true, - ); + expect( + getBalancesInSingleCall + .getCall(0) + .calledWithExactly('0x1', tokensToDetect), + ).toBe(true); getBalancesInSingleCall.resolves({ - '0x514910771AF9Ca656af840dff83E8264EcF986CA': new BN(1), + '0x514910771af9ca656af840dff83e8264ecf986ca': new BN(1), }); const updatedTokensToDetect = tokensToDetect.filter( - (address) => address !== '0x6810e776880C02933D47DB1b9fc05908e5386b96', + (address) => address !== '0x6810e776880c02933d47db1b9fc05908e5386b96', ); await assetsDetection.detectTokens(); expect( - getBalancesInSingleCall.calledWith('0x1', updatedTokensToDetect), + getBalancesInSingleCall + .getCall(1) + .calledWithExactly('0x1', updatedTokensToDetect), ).toBe(true); }); it('should not autodetect tokens that exist in the ignoreList', async () => { assetsDetection.configure({ networkType: MAINNET, selectedAddress: '0x1' }); getBalancesInSingleCall.resolves({ - '0x6810e776880C02933D47DB1b9fc05908e5386b96': new BN(1), + '0x514910771af9ca656af840dff83e8264ecf986ca': new BN(1), }); await assetsDetection.detectTokens(); - assets.removeAndIgnoreToken('0x6810e776880C02933D47DB1b9fc05908e5386b96'); + assets.removeAndIgnoreToken('0x514910771af9ca656af840dff83e8264ecf986ca'); await assetsDetection.detectTokens(); expect(assets.state.tokens).toStrictEqual([]); }); @@ -537,7 +631,7 @@ describe('AssetsDetectionController', () => { it('should not detect tokens if there is no selectedAddress set', async () => { assetsDetection.configure({ networkType: MAINNET }); getBalancesInSingleCall.resolves({ - '0x6810e776880C02933D47DB1b9fc05908e5386b96': new BN(1), + '0x514910771af9ca656af840dff83e8264ecf986ca': new BN(1), }); await assetsDetection.detectTokens(); expect(assets.state.tokens).toStrictEqual([]); diff --git a/src/assets/AssetsDetectionController.ts b/src/assets/AssetsDetectionController.ts index d2605a33c39..20f9f4a2c0e 100644 --- a/src/assets/AssetsDetectionController.ts +++ b/src/assets/AssetsDetectionController.ts @@ -1,4 +1,3 @@ -import contractMap from '@metamask/contract-metadata'; import BaseController, { BaseConfig, BaseState } from '../BaseController'; import type { NetworkState, NetworkType } from '../network/NetworkController'; import type { PreferencesState } from '../user/PreferencesController'; @@ -11,6 +10,7 @@ import type { } from './AssetsController'; import type { AssetsContractController } from './AssetsContractController'; import { Token } from './TokenRatesController'; +import { TokenListState } from './TokenListController'; const DEFAULT_INTERVAL = 180000; @@ -182,6 +182,8 @@ export class AssetsDetectionController extends BaseController< private getAssetsState: () => AssetsState; + private getTokenListState: () => TokenListState; + /** * Creates a AssetsDetectionController instance * @@ -207,6 +209,7 @@ export class AssetsDetectionController extends BaseController< addTokens, addCollectible, getAssetsState, + getTokenListState, }: { onAssetsStateChange: ( listener: (assetsState: AssetsState) => void, @@ -222,6 +225,7 @@ export class AssetsDetectionController extends BaseController< addTokens: AssetsController['addTokens']; addCollectible: AssetsController['addCollectible']; getAssetsState: () => AssetsState; + getTokenListState: () => TokenListState; }, config?: Partial, state?: Partial, @@ -235,6 +239,7 @@ export class AssetsDetectionController extends BaseController< }; this.initialize(); this.getAssetsState = getAssetsState; + this.getTokenListState = getTokenListState; this.addTokens = addTokens; onAssetsStateChange(({ tokens }) => { this.configure({ tokens }); @@ -302,12 +307,12 @@ export class AssetsDetectionController extends BaseController< return; } const tokensAddresses = this.config.tokens.map( - /* istanbul ignore next*/ (token) => token.address, + /* istanbul ignore next*/ (token) => token.address.toLowerCase(), ); + const { tokenList } = this.getTokenListState(); const tokensToDetect: string[] = []; - for (const address in contractMap) { - const contract = contractMap[address]; - if (contract.erc20 && !tokensAddresses.includes(address)) { + for (const address in tokenList) { + if (!tokensAddresses.includes(address)) { tokensToDetect.push(address); } } @@ -335,8 +340,8 @@ export class AssetsDetectionController extends BaseController< if (!ignored) { tokensToAdd.push({ address: tokenAddress, - decimals: contractMap[tokenAddress].decimals, - symbol: contractMap[tokenAddress].symbol, + decimals: tokenList[tokenAddress].decimals, + symbol: tokenList[tokenAddress].symbol, }); } } diff --git a/src/assets/TokenListController.test.ts b/src/assets/TokenListController.test.ts new file mode 100644 index 00000000000..98da8a819e0 --- /dev/null +++ b/src/assets/TokenListController.test.ts @@ -0,0 +1,948 @@ +import { stub } from 'sinon'; +import nock from 'nock'; +import { ControllerMessenger } from '../ControllerMessenger'; +import NetworkController, { + NetworksChainId, +} from '../network/NetworkController'; +import { + TokenListController, + TokenListStateChange, + GetTokenListState, +} from './TokenListController'; + +const name = 'TokenListController'; +const TOKEN_END_POINT_API = 'https://token-api.airswap-prod.codefi.network'; +const timestamp = Date.now(); + +const sampleMainnetTokenList = [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, +]; +const sampleWithDuplicateSymbols = [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, +]; +const sampleWithLessThan2Occurences = [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 2, + aggregators: ['paraswap', 'pmm'], + name: 'Synthetix', + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 1, + aggregators: ['paraswap'], + name: 'Bancor', + }, +]; +const sampleBinanceTokenList = [ + { + address: '0x7083609fce4d1d8dc0c979aab8c869ea2c873402', + symbol: 'DOT', + decimals: 18, + name: 'PolkadotBEP2', + aggregators: ['binanceDex', 'oneInch', 'pancake', 'swipe', 'venus'], + occurrences: 5, + }, + { + address: '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3', + symbol: 'DAI', + decimals: 18, + name: 'DaiBEP2', + aggregators: ['binanceDex', 'oneInch', 'pancake', 'swipe', 'venus'], + occurrences: 5, + }, +]; +const sampleSingleChainState = { + tokenList: { + '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c': { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, + }, + tokensChainsCache: { + '1': { + timestamp, + data: sampleMainnetTokenList, + }, + }, +}; + +const sampleTwoChainState = { + tokenList: { + '0x7083609fce4d1d8dc0c979aab8c869ea2c873402': { + address: '0x7083609fce4d1d8dc0c979aab8c869ea2c873402', + symbol: 'DOT', + decimals: 18, + name: 'PolkadotBEP2', + aggregators: ['binanceDex', 'oneInch', 'pancake', 'swipe', 'venus'], + occurrences: 5, + }, + '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3': { + address: '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3', + symbol: 'DAI', + decimals: 18, + name: 'DaiBEP2', + aggregators: ['binanceDex', 'oneInch', 'pancake', 'swipe', 'venus'], + occurrences: 5, + }, + }, + tokensChainsCache: { + '1': { + timestamp, + data: sampleMainnetTokenList, + }, + '56': { + timestamp: timestamp + 150, + data: sampleBinanceTokenList, + }, + }, +}; + +const sampleTokenMetaData = { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', +}; + +const existingState = { + tokenList: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + }, + tokensChainsCache: { + '1': { + timestamp, + data: sampleMainnetTokenList, + }, + }, +}; + +const outdatedExistingState = { + tokenList: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + }, + tokensChainsCache: { + '1': { + timestamp, + data: sampleMainnetTokenList, + }, + }, +}; + +const expiredCacheExistingState = { + tokenList: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 9, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + ], + name: 'Chainlink', + }, + }, + tokensChainsCache: { + '1': { + timestamp: timestamp - 1800000, + data: [ + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + ], + }, + }, +}; + +function getRestrictedMessenger() { + const controllerMessenger = new ControllerMessenger< + GetTokenListState, + TokenListStateChange + >(); + const messenger = controllerMessenger.getRestricted< + 'TokenListController', + never, + never + >({ + name, + }); + return messenger; +} + +describe('TokenListController', () => { + let network: NetworkController; + beforeEach(() => { + network = new NetworkController(); + }); + afterEach(() => { + nock.cleanAll(); + }); + + it('should set default state', async () => { + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + }); + + expect(controller.state).toStrictEqual({ + tokenList: {}, + tokensChainsCache: {}, + }); + + controller.destroy(); + }); + + it('should initialize with initial state', () => { + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + state: existingState, + }); + expect(controller.state).toStrictEqual({ + tokenList: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + }, + tokensChainsCache: { + '1': { + timestamp, + data: sampleMainnetTokenList, + }, + }, + }); + + controller.destroy(); + }); + + it('should not poll before being started', async () => { + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + interval: 100, + messenger, + }); + + await new Promise((resolve) => setTimeout(() => resolve(), 150)); + expect(controller.state.tokenList).toStrictEqual({}); + + controller.destroy(); + }); + + it('should poll and update rate in the right interval', async () => { + const tokenListMock = stub(TokenListController.prototype, 'fetchTokenList'); + + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + interval: 100, + messenger, + }); + await controller.start(); + + await new Promise((resolve) => setTimeout(() => resolve(), 1)); + expect(tokenListMock.called).toBe(true); + expect(tokenListMock.calledTwice).toBe(false); + await new Promise((resolve) => setTimeout(() => resolve(), 150)); + expect(tokenListMock.calledTwice).toBe(true); + + controller.destroy(); + tokenListMock.restore(); + }); + + it('should not poll after being stopped', async () => { + const tokenListMock = stub(TokenListController.prototype, 'fetchTokenList'); + + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + interval: 100, + messenger, + }); + await controller.start(); + controller.stop(); + + // called once upon initial start + expect(tokenListMock.called).toBe(true); + expect(tokenListMock.calledTwice).toBe(false); + + await new Promise((resolve) => setTimeout(() => resolve(), 150)); + expect(tokenListMock.calledTwice).toBe(false); + + controller.destroy(); + tokenListMock.restore(); + }); + + it('should poll correctly after being started, stopped, and started again', async () => { + const tokenListMock = stub(TokenListController.prototype, 'fetchTokenList'); + + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + interval: 100, + messenger, + }); + await controller.start(); + controller.stop(); + + // called once upon initial start + expect(tokenListMock.called).toBe(true); + expect(tokenListMock.calledTwice).toBe(false); + + await controller.start(); + + await new Promise((resolve) => setTimeout(() => resolve(), 1)); + expect(tokenListMock.calledTwice).toBe(true); + await new Promise((resolve) => setTimeout(() => resolve(), 150)); + expect(tokenListMock.calledThrice).toBe(true); + controller.destroy(); + tokenListMock.restore(); + }); + + it('should update token list from api', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleMainnetTokenList) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + }); + await controller.start(); + expect(controller.state.tokenList).toStrictEqual( + sampleSingleChainState.tokenList, + ); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].data, + ).toStrictEqual( + sampleSingleChainState.tokensChainsCache[NetworksChainId.mainnet].data, + ); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].timestamp, + ).toBeGreaterThanOrEqual( + sampleSingleChainState.tokensChainsCache[NetworksChainId.mainnet] + .timestamp, + ); + controller.destroy(); + }); + + it('should update the cache before threshold time if the current data is undefined', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .once() + .reply(200, undefined); + + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleMainnetTokenList) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + interval: 100, + }); + await controller.start(); + expect(controller.state.tokenList).toStrictEqual({}); + expect(controller.state.tokensChainsCache.data).toBeUndefined(); + await new Promise((resolve) => setTimeout(() => resolve(), 150)); + expect(controller.state.tokenList).toStrictEqual( + sampleSingleChainState.tokenList, + ); + expect(controller.state.tokensChainsCache['1'].data).toStrictEqual( + sampleSingleChainState.tokensChainsCache['1'].data, + ); + controller.destroy(); + }); + + it('should update token list from cache before reaching the threshold time', async () => { + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + state: existingState, + }); + expect(controller.state).toStrictEqual(existingState); + await controller.start(); + expect(controller.state).toStrictEqual(sampleSingleChainState); + controller.destroy(); + }); + + it('should update token list after removing data with duplicate symbols', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleWithDuplicateSymbols) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + }); + await controller.start(); + expect(controller.state.tokenList).toStrictEqual({ + '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c': { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, + }); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].data, + ).toStrictEqual(sampleWithDuplicateSymbols); + controller.destroy(); + }); + + it('should update token list after removing data less than 2 occurrences', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleWithLessThan2Occurences) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + }); + await controller.start(); + expect(controller.state.tokenList).toStrictEqual({ + '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 2, + aggregators: ['paraswap', 'pmm'], + name: 'Synthetix', + }, + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + }); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].data, + ).toStrictEqual(sampleWithLessThan2Occurences); + controller.destroy(); + }); + + it('should update token list when the token property changes', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleMainnetTokenList) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + state: outdatedExistingState, + }); + expect(controller.state).toStrictEqual(outdatedExistingState); + await controller.start(); + expect(controller.state).toStrictEqual(sampleSingleChainState); + controller.destroy(); + }); + + it('should update the cache when the timestamp expires', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleMainnetTokenList) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + state: expiredCacheExistingState, + }); + expect(controller.state).toStrictEqual(expiredCacheExistingState); + await controller.start(); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].timestamp, + ).toBeGreaterThan( + sampleSingleChainState.tokensChainsCache[NetworksChainId.mainnet] + .timestamp, + ); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].data, + ).toStrictEqual( + sampleSingleChainState.tokensChainsCache[NetworksChainId.mainnet].data, + ); + controller.destroy(); + }); + + it('should update token list when the chainId change', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleMainnetTokenList) + .get(`/tokens/56`) + .reply(200, sampleBinanceTokenList) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + state: existingState, + interval: 100, + }); + expect(controller.state).toStrictEqual(existingState); + await controller.start(); + expect(controller.state).toStrictEqual(sampleSingleChainState); + network.update({ + provider: { + type: 'rpc', + chainId: '56', + }, + }); + await new Promise((resolve) => setTimeout(() => resolve(), 10)); + expect(controller.state.tokenList).toStrictEqual( + sampleTwoChainState.tokenList, + ); + expect( + controller.state.tokensChainsCache[NetworksChainId.mainnet].data, + ).toStrictEqual( + sampleTwoChainState.tokensChainsCache[NetworksChainId.mainnet].data, + ); + expect(controller.state.tokensChainsCache['56'].data).toStrictEqual( + sampleTwoChainState.tokensChainsCache['56'].data, + ); + + controller.destroy(); + }); + + it('should call syncTokens to update the token list in the backend and clears the cache for the next fetch', async () => { + const tokenListBeforeSync = [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + ]; + nock(TOKEN_END_POINT_API) + .get(`/sync/${NetworksChainId.mainnet}`) + .reply(200) + .persist(); + + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .once() + .reply(200, tokenListBeforeSync); + + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .reply(200, sampleMainnetTokenList) + .persist(); + + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + interval: 200, + }); + await controller.start(); + expect(controller.state.tokenList).toStrictEqual({ + '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + }); + expect(await controller.syncTokens()).toBeUndefined(); + expect(controller.state.tokensChainsCache['1']).toStrictEqual({ + timestamp: 0, + data: [], + }); + await new Promise((resolve) => setTimeout(() => resolve(), 300)); + expect(controller.state.tokenList).toStrictEqual( + sampleSingleChainState.tokenList, + ); + controller.destroy(); + }); + + it('should return the metadata for a tokenAddress provided', async () => { + nock(TOKEN_END_POINT_API) + .get(`/tokens/${NetworksChainId.mainnet}`) + .query({ address: '0x514910771af9ca656af840dff83e8264ecf986ca' }) + .reply(200, sampleTokenMetaData) + .persist(); + const messenger = getRestrictedMessenger(); + const controller = new TokenListController({ + chainId: NetworksChainId.mainnet, + onNetworkStateChange: (listener) => network.subscribe(listener), + messenger, + }); + const tokenMeta = await controller.fetchTokenMetadata( + '0x514910771af9ca656af840dff83e8264ecf986ca', + ); + expect(tokenMeta).toStrictEqual(sampleTokenMetaData); + + controller.destroy(); + }); +}); diff --git a/src/assets/TokenListController.ts b/src/assets/TokenListController.ts new file mode 100644 index 00000000000..f625329a19d --- /dev/null +++ b/src/assets/TokenListController.ts @@ -0,0 +1,288 @@ +import type { Patch } from 'immer'; +import { Mutex } from 'async-mutex'; +import { BaseController } from '../BaseControllerV2'; +import type { RestrictedControllerMessenger } from '../ControllerMessenger'; +import { safelyExecute } from '../util'; +import { + fetchTokenList, + syncTokens, + fetchTokenMetadata, +} from '../apis/token-service'; +import { NetworkState } from '../network/NetworkController'; + +const DEFAULT_INTERVAL = 60 * 60 * 1000; +const DEFAULT_THRESHOLD = 60 * 30 * 1000; + +const name = 'TokenListController'; + +interface DataCache { + timestamp: number; + data: Token[]; +} +interface TokensChainsCache { + [chainSlug: string]: DataCache; +} + +type Token = { + name: string; + address: string; + decimals: number; + symbol: string; + occurrences: number; + aggregators: string[]; +}; + +type TokenMap = { + [address: string]: Token; +}; + +export type TokenListState = { + tokenList: TokenMap; + tokensChainsCache: TokensChainsCache; +}; + +export type TokenListStateChange = { + type: `${typeof name}:stateChange`; + payload: [TokenListState, Patch[]]; +}; + +export type GetTokenListState = { + type: `${typeof name}:getState`; + handler: () => TokenListState; +}; + +const metadata = { + tokenList: { persist: true, anonymous: true }, + tokensChainsCache: { persist: true, anonymous: true }, +}; + +const defaultState: TokenListState = { + tokenList: {}, + tokensChainsCache: {}, +}; + +/** + * Controller that passively polls on a set interval for the list of tokens from metaswaps api + */ +export class TokenListController extends BaseController< + typeof name, + TokenListState +> { + private mutex = new Mutex(); + + private intervalId?: NodeJS.Timeout; + + private intervalDelay: number; + + private cacheRefreshThreshold: number; + + private chainId: string; + + /** + * Creates a TokenListController instance + * + * @param options - Constructor options + * @param options.interval - The polling interval, in milliseconds + * @param options.messenger - A reference to the messaging system + * @param options.state - Initial state to set on this controller + */ + constructor({ + chainId, + onNetworkStateChange, + interval = DEFAULT_INTERVAL, + cacheRefreshThreshold = DEFAULT_THRESHOLD, + messenger, + state, + }: { + chainId: string; + onNetworkStateChange: ( + listener: (networkState: NetworkState) => void, + ) => void; + interval?: number; + cacheRefreshThreshold?: number; + messenger: RestrictedControllerMessenger< + typeof name, + GetTokenListState, + TokenListStateChange, + never, + never + >; + state?: Partial; + }) { + super({ + name, + metadata, + messenger, + state: { ...defaultState, ...state }, + }); + this.intervalDelay = interval; + this.cacheRefreshThreshold = cacheRefreshThreshold; + this.chainId = chainId; + onNetworkStateChange(async (networkState) => { + this.chainId = networkState.provider.chainId; + await safelyExecute(() => this.fetchTokenList()); + }); + } + + /** + * Start polling for the token list + */ + async start() { + await this.startPolling(); + } + + /** + * Stop polling for the token list + */ + stop() { + this.stopPolling(); + } + + /** + * Prepare to discard this controller. + * + * This stops any active polling. + */ + destroy() { + super.destroy(); + this.stopPolling(); + } + + private stopPolling() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + } + + /** + * Starts a new polling interval + */ + private async startPolling(): Promise { + await safelyExecute(() => this.fetchTokenList()); + this.intervalId = setInterval(async () => { + await safelyExecute(() => this.fetchTokenList()); + }, this.intervalDelay); + } + + /** + * Fetching token list from the Token Service API + */ + async fetchTokenList(): Promise { + const releaseLock = await this.mutex.acquire(); + try { + const tokensFromAPI: Token[] = await safelyExecute(() => + this.fetchFromCache(), + ); + const { tokensChainsCache } = this.state; + const tokenList: TokenMap = {}; + + // filtering out tokens with less than 2 occurences + const filteredTokenList = tokensFromAPI.filter( + (token) => token.occurrences >= 2, + ); + // removing the tokens with symbol conflicts + const symbolsList = filteredTokenList.map((token) => token.symbol); + const duplicateSymbols = [ + ...new Set( + symbolsList.filter( + (symbol, index) => symbolsList.indexOf(symbol) !== index, + ), + ), + ]; + const uniqueTokenList = filteredTokenList.filter( + (token) => !duplicateSymbols.includes(token.symbol), + ); + for (const token of uniqueTokenList) { + tokenList[token.address] = token; + } + this.update(() => { + return { + tokenList, + tokensChainsCache, + }; + }); + } finally { + releaseLock(); + } + } + + /** + * Checks if the Cache timestamp is valid, + * if yes data in cache will be returned + * otherwise a call to the API service will be made. + * @returns Promise that resolves into a TokenList + */ + async fetchFromCache(): Promise { + const { tokensChainsCache, ...tokensData }: TokenListState = this.state; + const dataCache = tokensChainsCache[this.chainId]; + const now = Date.now(); + if ( + dataCache?.data && + now - dataCache?.timestamp < this.cacheRefreshThreshold + ) { + return dataCache.data; + } + const tokenList: Token[] = await safelyExecute(() => + fetchTokenList(this.chainId), + ); + const updatedTokensChainsCache = { + ...tokensChainsCache, + [this.chainId]: { + timestamp: Date.now(), + data: tokenList, + }, + }; + this.update(() => { + return { + ...tokensData, + tokensChainsCache: updatedTokensChainsCache, + }; + }); + return tokenList; + } + + /** + * Calls the API to sync the tokens in the token service + */ + async syncTokens(): Promise { + const releaseLock = await this.mutex.acquire(); + try { + await safelyExecute(() => syncTokens(this.chainId)); + const { tokenList, tokensChainsCache } = this.state; + const updatedTokensChainsCache = { + ...tokensChainsCache, + [this.chainId]: { + timestamp: 0, + data: [], + }, + }; + this.update(() => { + return { + tokenList, + tokensChainsCache: updatedTokensChainsCache, + }; + }); + } finally { + releaseLock(); + } + } + + /** + * Fetch metadata for a token whose address is send to the API + * @param tokenAddress + * @returns Promise that resolvesto Token Metadata + */ + async fetchTokenMetadata(tokenAddress: string): Promise { + const releaseLock = await this.mutex.acquire(); + try { + const token = await safelyExecute(() => + fetchTokenMetadata(this.chainId, tokenAddress), + ); + return token; + } finally { + releaseLock(); + } + } +} + +export default TokenListController; diff --git a/src/index.ts b/src/index.ts index 600be9bd1e8..587918200f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,4 +32,5 @@ export * from './transaction/TransactionController'; export * from './message-manager/PersonalMessageManager'; export * from './message-manager/TypedMessageManager'; export * from './notification/NotificationController'; +export * from './assets/TokenListController'; export { util };