diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 21ce968350..1791d2f4b0 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -5,9 +5,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Add `codefiTokenPricesServiceV2` ([#3600](https://github.com/MetaMask/core/pull/3600)) + - This object can be used for the new `tokenPricesService` argument for TokenRatesController. It uses an internal API to fetch prices for tokens instead of CoinGecko. + ### Changed +- **BREAKING:** TokenRatesController now takes a required argument `tokenPricesService` ([#3600](https://github.com/MetaMask/core/pull/3600)) + - This object is responsible for fetching the prices for tokens held by this controller. +- **BREAKING:** Update signature of `TokenRatesController.updateExchangeRatesByChainId` ([#3600](https://github.com/MetaMask/core/pull/3600)) + - Rename `tokenAddresses` argument to `tokenContractAddresses` + - Change the type of `tokenContractAddresses` from `string[]` to `Hex[]` +- **BREAKING:** Change signature of `TokenRatesController.fetchAndMapExchangeRates` ([#3600](https://github.com/MetaMask/core/pull/3600)) + - This method now takes an object with shape `{ tokenContractAddresses: Hex[]; chainId: Hex; nativeCurrency: string; }` rather than positional arguments - Update TokenListController to fetch prefiltered set of tokens from the API, reducing response data and removing the need for filtering logic ([#2054](https://github.com/MetaMask/core/pull/2054)) +### Removed +- **BREAKING:** Remove `fetchExchangeRate` method from TokenRatesController ([#3600](https://github.com/MetaMask/core/pull/3600)) + - This method (not to be confused with `updateExchangeRate`, which is still present) was only ever intended to be used internally and should not be accessed directly. +- **BREAKING:** Remove `getChainSlug` method from TokenRatesController ([#3600](https://github.com/MetaMask/core/pull/3600)) + - This method was previously used in TokenRatesController to access the CoinGecko API. There is no equivalent. +- **BREAKING:** Remove `CoinGeckoResponse` and `CoinGeckoPlatform` types ([#3600](https://github.com/MetaMask/core/pull/3600)) + - These types were previously used in TokenRatesController to represent data returned from the CoinGecko API. There is no equivalent. + ## [20.0.0] ### Added - **BREAKING**: `TokenRatesControllerState` now has required `contractExchangeRatesByChainId` property which an object keyed by `chainId` and `nativeCurrency` ([#2015](https://github.com/MetaMask/core/pull/2015)) diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 7025fa50be..ac89dd7623 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -1,51 +1,20 @@ import { NetworksTicker, toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; import { advanceTime } from '../../../tests/helpers'; +import type { + AbstractTokenPricesService, + TokenPrice, + TokenPricesByTokenContractAddress, +} from './token-prices-service/abstract-token-prices-service'; import { TokenRatesController } from './TokenRatesController'; -const COINGECKO_API = 'https://api.coingecko.com/api/v3'; -const COINGECKO_ETH_PATH = '/simple/token_price/ethereum'; -const COINGECKO_MATIC_PATH = '/simple/token_price/polygon-pos-network'; -const COINGECKO_ASSETS_PATH = '/asset_platforms'; -const COINGECKO_SUPPORTED_CURRENCIES = '/simple/supported_vs_currencies'; const ADDRESS = '0x01'; - const defaultSelectedAddress = '0x0000000000000000000000000000000000000001'; describe('TokenRatesController', () => { - beforeEach(() => { - nock(COINGECKO_API) - .get(COINGECKO_SUPPORTED_CURRENCIES) - .reply(200, ['eth', 'usd', 'dai']) - .get(COINGECKO_ASSETS_PATH) - .reply(200, [ - { - id: 'binance-smart-chain', - chain_identifier: 56, - name: 'Binance Smart Chain', - shortname: 'BSC', - }, - { - id: 'ethereum', - chain_identifier: 1, - name: 'Ethereum', - shortname: '', - }, - { - id: 'polygon-pos-network', - chain_identifier: 137, - name: 'Polygon', - shortname: 'MATIC', - }, - ]); - - nock('https://min-api.cryptocompare.com') - .get('/data/price?fsym=ETH&tsyms=ETH') - .reply(200, { ETH: 1 }); - }); - afterEach(() => { jest.restoreAllMocks(); }); @@ -70,6 +39,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }); expect(controller.state).toStrictEqual({ contractExchangeRates: {}, @@ -86,6 +56,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }); expect(controller.config).toStrictEqual({ interval: 180000, @@ -110,6 +81,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }); await advanceTime({ clock, duration: 500 }); @@ -145,6 +117,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange, onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allTokens: { @@ -201,6 +174,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange, onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allDetectedTokens, @@ -242,6 +216,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange, onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allTokens: { @@ -287,6 +262,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange, onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allDetectedTokens: { @@ -348,6 +324,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); await controller.start(); const updateExchangeRatesSpy = jest @@ -378,6 +355,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); await controller.start(); const updateExchangeRatesSpy = jest @@ -408,6 +386,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); await controller.start(); jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); @@ -436,6 +415,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); await controller.start(); jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); @@ -464,6 +444,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); await controller.start(); const updateExchangeRatesSpy = jest @@ -496,6 +477,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') @@ -525,6 +507,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') @@ -554,6 +537,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); @@ -581,6 +565,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange, + tokenPricesService: buildMockTokenPricesService(), }); jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); @@ -625,6 +610,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange, onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allTokens: { @@ -667,6 +653,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange, onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allTokens: { @@ -714,6 +701,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange, onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allTokens: { @@ -753,12 +741,9 @@ describe('TokenRatesController', () => { describe('start', () => { it('should poll and update rate in the right interval', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockImplementation(() => { - throw new Error('Network error'); - }); const interval = 100; + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); const controller = new TokenRatesController( { interval, @@ -769,6 +754,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService, }, { allTokens: { @@ -782,24 +768,21 @@ describe('TokenRatesController', () => { ); await controller.start(); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); await advanceTime({ clock, duration: interval }); - expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); await advanceTime({ clock, duration: interval }); - expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); }); }); describe('stop', () => { it('should stop polling', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockImplementation(() => { - throw new Error('Network error'); - }); const interval = 100; + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); const controller = new TokenRatesController( { interval, @@ -810,6 +793,7 @@ describe('TokenRatesController', () => { onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), + tokenPricesService, }, { allTokens: { @@ -823,12 +807,12 @@ describe('TokenRatesController', () => { ); await controller.start(); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); controller.stop(); await advanceTime({ clock, duration: interval }); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); }); }); }); @@ -846,6 +830,8 @@ describe('TokenRatesController', () => { it('should poll on the right interval', async () => { const interval = 100; + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); const controller = new TokenRatesController({ interval, chainId: '0x2', @@ -860,26 +846,27 @@ describe('TokenRatesController', () => { ticker: NetworksTicker.mainnet, }, }), + tokenPricesService, }); - const updateExchangeRatesByChainIdSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); controller.startPollingByNetworkClientId('mainnet', { tokenAddresses: ['0x0'], }); await advanceTime({ clock, duration: 0 }); - expect(updateExchangeRatesByChainIdSpy).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); await advanceTime({ clock, duration: interval }); - expect(updateExchangeRatesByChainIdSpy).toHaveBeenCalledTimes(2); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); await advanceTime({ clock, duration: interval }); - expect(updateExchangeRatesByChainIdSpy).toHaveBeenCalledTimes(3); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); }); it('should update state on poll', async () => { const interval = 100; + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + }); const controller = new TokenRatesController({ interval, chainId: '0x2', @@ -894,11 +881,7 @@ describe('TokenRatesController', () => { ticker: NetworksTicker.mainnet, }, }), - }); - jest.spyOn(controller, 'getChainSlug').mockResolvedValue('ethereum'); - jest.spyOn(controller, 'fetchAndMapExchangeRates').mockResolvedValue({ - '0x02': 0.001, - '0x03': 0.002, + tokenPricesService, }); controller.startPollingByNetworkClientId('mainnet', { @@ -918,6 +901,8 @@ describe('TokenRatesController', () => { it('should stop polling', async () => { const interval = 100; + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); const controller = new TokenRatesController({ interval, chainId: '0x2', @@ -932,43 +917,45 @@ describe('TokenRatesController', () => { ticker: NetworksTicker.mainnet, }, }), + tokenPricesService, }); - const updateExchangeRatesByChainIdSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); const pollingToken = controller.startPollingByNetworkClientId('mainnet', { tokenAddresses: ['0x0'], }); await advanceTime({ clock, duration: 0 }); - expect(updateExchangeRatesByChainIdSpy).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); controller.stopPollingByPollingToken(pollingToken); await advanceTime({ clock, duration: interval }); - expect(updateExchangeRatesByChainIdSpy).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); }); }); describe('updateExchangeRates', () => { it('should not update exchange rates if legacy polling is disabled', async () => { - const controller = new TokenRatesController({ - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn(), - }); - controller.disabled = true; - - const updateExchangeRatesByChainIdSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + const controller = new TokenRatesController( + { + chainId: '0x1', + ticker: NetworksTicker.mainnet, + selectedAddress: defaultSelectedAddress, + onPreferencesStateChange: jest.fn(), + onTokensStateChange: jest.fn(), + onNetworkStateChange: jest.fn(), + getNetworkClientById: jest.fn(), + tokenPricesService, + }, + { + disabled: true, + }, + ); await controller.updateExchangeRates(); - expect(updateExchangeRatesByChainIdSpy).not.toHaveBeenCalled(); + + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); }); it('should update legacy state after updateExchangeRatesByChainId', async () => { @@ -981,6 +968,7 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { allTokens: { @@ -1018,7 +1006,7 @@ describe('TokenRatesController', () => { expect(updateExchangeRatesByChainIdSpy).toHaveBeenCalledWith({ chainId: '0x1', nativeCurrency: NetworksTicker.mainnet, - tokenAddresses: ['0x123', ADDRESS], + tokenContractAddresses: ['0x123', ADDRESS], }); expect(controller.state.contractExchangeRates).toStrictEqual({ @@ -1029,7 +1017,7 @@ describe('TokenRatesController', () => { }); describe('updateExchangeRatesByChainId', () => { - it('should not update state if no tokenAddresses are provided', async () => { + it('should not update state if no token contract addresses are provided', async () => { const controller = new TokenRatesController({ interval: 100, chainId: '0x2', @@ -1039,19 +1027,20 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }); expect(controller.state.contractExchangeRates).toStrictEqual({}); await controller.updateExchangeRatesByChainId({ chainId: '0x1', nativeCurrency: 'ETH', - tokenAddresses: [], + tokenContractAddresses: [], }); expect(controller.state.contractExchangeRatesByChainId).toStrictEqual({}); }); it('should not update state when disabled', async () => { - const tokenAddress = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; + const tokenContractAddress = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; const controller = new TokenRatesController( { interval: 100, @@ -1062,6 +1051,7 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService: buildMockTokenPricesService(), }, { disabled: true }, ); @@ -1070,118 +1060,65 @@ describe('TokenRatesController', () => { await controller.updateExchangeRatesByChainId({ chainId: '0x1', nativeCurrency: 'ETH', - tokenAddresses: [tokenAddress], + tokenContractAddresses: [tokenContractAddress], }); expect(controller.state.contractExchangeRatesByChainId).toStrictEqual({}); }); - it('should update all rates', async () => { - const tokenAddress = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; - nock(COINGECKO_API) - .get( - `${COINGECKO_ETH_PATH}?contract_addresses=${tokenAddress}&vs_currencies=eth`, - ) - .reply(200, { - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359': { eth: 0.00561045 }, - }); - const controller = new TokenRatesController({ - interval: 100, - chainId: '0x2', - ticker: 'ticker', - selectedAddress: '0xdeadbeef', - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn(), - }); - - expect(controller.state.contractExchangeRates).toStrictEqual({}); - await controller.updateExchangeRatesByChainId({ - chainId: '0x1', - nativeCurrency: 'ETH', - tokenAddresses: [tokenAddress], - }); - expect( - Object.keys(controller.state.contractExchangeRatesByChainId['0x1'].ETH), - ).toContain(tokenAddress); - expect( - controller.state.contractExchangeRatesByChainId['0x1'].ETH[ - tokenAddress - ], - ).toBeGreaterThan(0); - }); - - it('should handle balance not found in API', async () => { - const controller = new TokenRatesController({ - chainId: '0x2', - ticker: 'ticker', - selectedAddress: '0xdeadbeef', - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn(), - }); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - jest.spyOn(controller, 'fetchExchangeRate').mockRejectedValue({ - error: 'Not Found', - message: 'Not Found', - }); - - const result = controller.updateExchangeRatesByChainId({ - chainId: '0x1', - nativeCurrency: 'ETH', - tokenAddresses: ['0x0'], - }); - - await expect(result).rejects.not.toThrow(); - }); - - it('should update exchange rates when native currency is not supported by coingecko', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_MATIC_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH - }, - '0x03': { - eth: 0.002, + it('should update exchange rates for the given token addresses to undefined when the given chain ID is not supported by the Price API', async () => { + const controller = new TokenRatesController( + { + chainId: '0x2', + ticker: 'ticker', + selectedAddress: '0xdeadbeef', + onPreferencesStateChange: jest.fn(), + onTokensStateChange: jest.fn(), + onNetworkStateChange: jest.fn(), + getNetworkClientById: jest.fn(), + tokenPricesService: buildMockTokenPricesService({ + validateChainIdSupported(chainId: unknown): chainId is Hex { + return chainId !== '0x9999999999'; + }, + }), + }, + {}, + { + contractExchangeRatesByChainId: { + '0x9999999999': { + MATIC: { + '0x02': 0.01, + '0x03': 0.02, + '0x04': 0.03, + }, + }, }, - }); - - nock('https://min-api.cryptocompare.com') - .get('/data/price?fsym=ETH&tsyms=MATIC') - .reply(200, { MATIC: 0.5 }); // .5 eth to 1 matic - - const controller = new TokenRatesController({ - chainId: '0x2', - ticker: 'ticker', - selectedAddress: '0xdeadbeef', - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn(), - }); + }, + ); await controller.updateExchangeRatesByChainId({ - chainId: toHex(137), + chainId: '0x9999999999', nativeCurrency: 'MATIC', - tokenAddresses: ['0x02', '0x03'], + tokenContractAddresses: ['0x02', '0x03'], }); expect(controller.state.contractExchangeRatesByChainId).toStrictEqual({ - [toHex(137)]: { + '0x9999999999': { MATIC: { - '0x02': 0.0005, // token value in terms of matic = (token value in eth) * (eth value in matic) = .001 * .5 - '0x03': 0.001, + '0x02': undefined, + '0x03': undefined, + '0x04': 0.03, }, }, }); }); - it('should update exchange rates with undefined when chain is not supported by coingecko', async () => { + it('should update exchange rates when native currency is supported by the Price API', async () => { + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + }); const controller = new TokenRatesController({ + interval: 100, chainId: '0x2', ticker: 'ticker', selectedAddress: '0xdeadbeef', @@ -1189,28 +1126,34 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService, }); - jest.spyOn(controller, 'getChainSlug').mockResolvedValue(''); + expect(controller.state.contractExchangeRates).toStrictEqual({}); await controller.updateExchangeRatesByChainId({ - chainId: toHex(137), - nativeCurrency: 'MATIC', - tokenAddresses: ['0x02', '0x03'], + chainId: '0x1', + nativeCurrency: 'ETH', + tokenContractAddresses: ['0xAAA'], }); - expect(controller.state.contractExchangeRatesByChainId).toStrictEqual({ - [toHex(137)]: { - MATIC: { - '0x02': undefined, - '0x03': undefined, + '0x1': { + ETH: { + '0xAAA': 0.001, }, }, }); }); - }); - describe('getChainSlug', () => { - it('returns the chain slug for the chain id', async () => { + it('should update exchange rates when native currency is not supported by the Price API', async () => { + nock('https://min-api.cryptocompare.com') + .get('/data/price?fsym=ETH&tsyms=LOL') + .reply(200, { LOL: 0.5 }); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateCurrencySupported(currency: unknown): currency is string { + return currency !== 'LOL'; + }, + }); const controller = new TokenRatesController({ chainId: '0x2', ticker: 'ticker', @@ -1219,47 +1162,36 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService, }); - expect(await controller.getChainSlug('0x1')).toBe('ethereum'); - expect(await controller.getChainSlug(toHex(56))).toBe( - 'binance-smart-chain', - ); - expect(await controller.getChainSlug(toHex(137))).toBe( - 'polygon-pos-network', - ); - }); - - it('returns null if there is no chain slug for the chain id', async () => { - const controller = new TokenRatesController({ - chainId: '0x2', - ticker: 'ticker', - selectedAddress: '0xdeadbeef', - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn(), + await controller.updateExchangeRatesByChainId({ + chainId: '0x1', + nativeCurrency: 'LOL', + tokenContractAddresses: ['0x02', '0x03'], }); - expect(await controller.getChainSlug('0x2')).toBeNull(); + expect(controller.state.contractExchangeRatesByChainId).toStrictEqual({ + '0x1': { + LOL: { + // token price in LOL = (token price in ETH) * (ETH value in LOL) + '0x02': 0.0005, + '0x03': 0.001, + }, + }, + }); }); }); describe('fetchAndMapExchangeRates', () => { - describe('native currency is supported', () => { + describe('when the native currency is supported', () => { it('returns the exchange rates directly', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, - }, - '0x03': { - eth: 0.002, - }, - }); - + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateCurrencySupported(currency: unknown): currency is string { + return currency === 'ETH'; + }, + }); const controller = new TokenRatesController({ chainId: '0x2', ticker: 'ticker', @@ -1268,12 +1200,15 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService, }); const contractExchangeRates = await controller.fetchAndMapExchangeRates( - 'ETH', - 'ethereum', - ['0x02', '0x03'], + { + nativeCurrency: 'ETH', + chainId: '0x1', + tokenContractAddresses: ['0x02', '0x03'], + }, ); expect(contractExchangeRates).toStrictEqual({ @@ -1283,24 +1218,17 @@ describe('TokenRatesController', () => { }); }); - describe('native currency is not supported', () => { + describe('when the native currency is not supported', () => { it('returns the exchange rates using ETH as a fallback currency', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, - }, - '0x03': { - eth: 0.002, - }, - }); - nock('https://min-api.cryptocompare.com') .get('/data/price?fsym=ETH&tsyms=LOL') .reply(200, { LOL: 0.5 }); - + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateCurrencySupported(currency: unknown): currency is string { + return currency !== 'LOL'; + }, + }); const controller = new TokenRatesController({ chainId: '0x2', ticker: 'ticker', @@ -1309,51 +1237,49 @@ describe('TokenRatesController', () => { onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService, }); const contractExchangeRates = await controller.fetchAndMapExchangeRates( - 'LOL', - 'ethereum', - ['0x02', '0x03'], + { + chainId: '0x1', + nativeCurrency: 'LOL', + tokenContractAddresses: ['0x02', '0x03'], + }, ); expect(contractExchangeRates).toStrictEqual({ + // token price in LOL = (token price in ETH) * (ETH value in LOL) '0x02': 0.0005, '0x03': 0.001, }); }); it('returns the an empty object when market does not exist for pair', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, - }, - '0x03': { - eth: 0.002, - }, - }); + nock('https://min-api.cryptocompare.com') + .get('/data/price?fsym=ETH&tsyms=LOL') + .replyWithError( + new Error('market does not exist for this coin pair'), + ); + const tokenPricesService = buildMockTokenPricesService(); const controller = new TokenRatesController({ chainId: '0x2', - ticker: 'ticker', + ticker: 'ETH', selectedAddress: '0xdeadbeef', onPreferencesStateChange: jest.fn(), onTokensStateChange: jest.fn(), onNetworkStateChange: jest.fn(), getNetworkClientById: jest.fn(), + tokenPricesService, }); - jest - .spyOn(controller, 'fetchExchangeRate') - .mockRejectedValue( - new Error('market does not exist for this coin pair'), - ); + const contractExchangeRates = await controller.fetchAndMapExchangeRates( - 'LOL', - 'ethereum', - ['0x02', '0x03'], + { + chainId: '0x1', + nativeCurrency: 'LOL', + tokenContractAddresses: ['0x02', '0x03'], + }, ); expect(contractExchangeRates).toStrictEqual({}); @@ -1361,3 +1287,61 @@ describe('TokenRatesController', () => { }); }); }); + +/** + * Builds a mock token prices service. + * + * @param overrides - The properties of the token prices service you want to + * provide explicitly. + * @returns The built mock token prices service. + */ +function buildMockTokenPricesService( + overrides: Partial = {}, +): AbstractTokenPricesService { + return { + async fetchTokenPrices() { + return {}; + }, + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return true; + }, + validateCurrencySupported(_currency: unknown): _currency is string { + return true; + }, + ...overrides, + }; +} + +/** + * A version of the token prices service `fetchTokenPrices` method where the + * price of each given token is incremented by one. + * + * @param args - The arguments to this function. + * @param args.tokenContractAddresses - The token contract addresses. + * @param args.currency - The currency. + * @returns The token prices. + */ +async function fetchTokenPricesWithIncreasingPriceForEachToken< + TokenAddress extends Hex, + Currency extends string, +>({ + tokenContractAddresses, + currency, +}: { + tokenContractAddresses: TokenAddress[]; + currency: Currency; +}) { + return tokenContractAddresses.reduce< + Partial> + >((obj, tokenContractAddress, i) => { + const tokenPrice: TokenPrice = { + tokenContractAddress, + value: (i + 1) / 1000, + currency, + }; + return { + ...obj, + [tokenContractAddress]: tokenPrice, + }; + }, {}) as TokenPricesByTokenContractAddress; +} diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index 7d3ab8b589..b7e46d42cd 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -1,10 +1,8 @@ import type { BaseConfig, BaseState } from '@metamask/base-controller'; import { safelyExecute, - handleFetch, toChecksumHexAddress, FALL_BACK_VS_CURRENCY, - toHex, } from '@metamask/controller-utils'; import type { NetworkClientId, @@ -15,37 +13,10 @@ import { PollingControllerV1 } from '@metamask/polling-controller'; import type { PreferencesState } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; -import { fetchExchangeRate as fetchNativeExchangeRate } from './crypto-compare'; +import { fetchExchangeRate as fetchNativeCurrencyExchangeRate } from './crypto-compare'; +import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service'; import type { TokensState } from './TokensController'; -/** - * @type CoinGeckoResponse - * - * CoinGecko API response representation - */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface CoinGeckoResponse { - [address: string]: { - [currency: string]: number; - }; -} -/** - * @type CoinGeckoPlatform - * - * CoinGecko supported platform API representation - */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface CoinGeckoPlatform { - id: string; - chain_identifier: null | number; - name: string; - shortname: string; -} - /** * @type Token * @@ -99,22 +70,6 @@ interface ContractExchangeRates { [address: string]: number | undefined; } -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -interface SupportedChainsCache { - timestamp: number; - data: CoinGeckoPlatform[] | null; -} - -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -interface SupportedVsCurrenciesCache { - timestamp: number; - data: string[]; -} - enum PollState { Active = 'Active', Inactive = 'Inactive', @@ -138,39 +93,41 @@ export interface TokenRatesState extends BaseState { >; } -const CoinGeckoApi = { - BASE_URL: 'https://api.coingecko.com/api/v3', - getTokenPriceURL(chainSlug: string, query: string) { - return `${this.BASE_URL}/simple/token_price/${chainSlug}?${query}`; - }, - getPlatformsURL() { - return `${this.BASE_URL}/asset_platforms`; - }, - getSupportedVsCurrencies() { - return `${this.BASE_URL}/simple/supported_vs_currencies`; - }, -}; - /** - * Finds the chain slug in the data array given a chainId. + * Uses the CryptoCompare API to fetch the exchange rate between one currency + * and another, i.e., the multiplier to apply the amount of one currency in + * order to convert it to another. * - * @param chainId - The current chain ID. - * @param data - A list platforms supported by the CoinGecko API. - * @returns The CoinGecko slug for the given chain ID, or `null` if the slug was not found. + * @param args - The arguments to this function. + * @param args.from - The currency to convert from. + * @param args.to - The currency to convert to. + * @returns The exchange rate between `fromCurrency` to `toCurrency` if one + * exists, or null if one does not. */ -function findChainSlug( - chainId: Hex, - data: CoinGeckoPlatform[] | null, -): string | null { - if (!data) { - return null; +async function getCurrencyConversionRate({ + from, + to, +}: { + from: string; + to: string; +}) { + const includeUSDRate = false; + try { + const result = await fetchNativeCurrencyExchangeRate( + to, + from, + includeUSDRate, + ); + return result.conversionRate; + } catch (error) { + if ( + error instanceof Error && + error.message.includes('market does not exist for this coin pair') + ) { + return null; + } + throw error; } - const chain = - data.find( - ({ chain_identifier }) => - chain_identifier !== null && toHex(chain_identifier) === chainId, - ) ?? null; - return chain?.id || null; } /** @@ -185,18 +142,10 @@ export class TokenRatesController extends PollingControllerV1< private tokenList: Token[] = []; - private supportedChains: SupportedChainsCache = { - timestamp: 0, - data: null, - }; - - private supportedVsCurrencies: SupportedVsCurrenciesCache = { - timestamp: 0, - data: [], - }; - #pollState = PollState.Inactive; + #tokenPricesService: AbstractTokenPricesService; + /** * Name of this controller used during composition */ @@ -217,6 +166,7 @@ export class TokenRatesController extends PollingControllerV1< * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. * @param options.onTokensStateChange - Allows subscribing to token controller state changes. * @param options.onNetworkStateChange - Allows subscribing to network state changes. + * @param options.tokenPricesService - An object in charge of retrieving token prices. * @param config - Initial options used to configure this controller. * @param state - Initial state to set on this controller. */ @@ -231,6 +181,7 @@ export class TokenRatesController extends PollingControllerV1< onPreferencesStateChange, onTokensStateChange, onNetworkStateChange, + tokenPricesService, }: { interval?: number; threshold?: number; @@ -247,6 +198,7 @@ export class TokenRatesController extends PollingControllerV1< onNetworkStateChange: ( listener: (networkState: NetworkState) => void, ) => void; + tokenPricesService: AbstractTokenPricesService; }, config?: Partial, state?: Partial, @@ -270,6 +222,7 @@ export class TokenRatesController extends PollingControllerV1< this.initialize(); this.setIntervalLength(interval); this.getNetworkClientById = getNetworkClientById; + this.#tokenPricesService = tokenPricesService; if (config?.disabled) { this.configure({ disabled: true }, false, false); @@ -365,78 +318,6 @@ export class TokenRatesController extends PollingControllerV1< }, this.config.interval); } - /** - * Fetches a pairs of token address and native currency. - * - * @param chainSlug - The chain string identifier. - * @param vsCurrency - The currency used to generate pairs against the tokens. - * @param tokenAddresses - The addresses for the token contracts. - * @returns The exchange rates for the given pairs. - */ - async fetchExchangeRate( - chainSlug: string, - vsCurrency: string, - tokenAddresses: string[] = this.tokenList.map((token) => token.address), - ): Promise { - const tokenPairs = tokenAddresses.join(','); - const query = `contract_addresses=${tokenPairs}&vs_currencies=${vsCurrency.toLowerCase()}`; - return handleFetch(CoinGeckoApi.getTokenPriceURL(chainSlug, query)); - } - - /** - * Checks if the current native currency is a supported vs currency to use - * to query for token exchange rates. - * - * @param nativeCurrency - The native currency to check. - * @returns A boolean indicating whether it's a supported vsCurrency. - */ - private async checkIsSupportedVsCurrency(nativeCurrency: string) { - const { threshold } = this.config; - const { timestamp, data } = this.supportedVsCurrencies; - - const now = Date.now(); - - if (now - timestamp > threshold) { - const currencies = await handleFetch( - CoinGeckoApi.getSupportedVsCurrencies(), - ); - this.supportedVsCurrencies = { - data: currencies, - timestamp: Date.now(), - }; - return currencies.includes(nativeCurrency.toLowerCase()); - } - - return data.includes(nativeCurrency.toLowerCase()); - } - - /** - * Gets the slug from cached supported platforms CoinGecko API response for the chain ID. - * If cached supported platforms response is stale, fetches and updates it. - * - * @param chainId - The chain ID. - * @returns The CoinGecko slug for the chain ID. - */ - async getChainSlug( - chainId: Hex = this.config.chainId, - ): Promise { - const { threshold } = this.config; - const { data, timestamp } = this.supportedChains; - - const now = Date.now(); - - if (now - timestamp > threshold) { - const platforms = await handleFetch(CoinGeckoApi.getPlatformsURL()); - this.supportedChains = { - data: platforms, - timestamp: Date.now(), - }; - return findChainSlug(chainId, platforms); - } - - return findChainSlug(chainId, data); - } - /** * Updates exchange rates for all tokens. */ @@ -446,11 +327,13 @@ export class TokenRatesController extends PollingControllerV1< } const { chainId, nativeCurrency } = this.config; - const tokenAddresses = this.tokenList.map((token) => token.address); + const tokenContractAddresses = this.tokenList.map( + (token) => token.address, + ) as Hex[]; await this.updateExchangeRatesByChainId({ chainId, nativeCurrency, - tokenAddresses, + tokenContractAddresses, }); this.update({ @@ -465,45 +348,37 @@ export class TokenRatesController extends PollingControllerV1< * @param options - The options to fetch exchange rates. * @param options.chainId - The chain ID. * @param options.nativeCurrency - The ticker for the chain. - * @param options.tokenAddresses - The addresses for the token contracts. + * @param options.tokenContractAddresses - The addresses for the token contracts. */ async updateExchangeRatesByChainId({ chainId, nativeCurrency, - tokenAddresses, + tokenContractAddresses, }: { chainId: Hex; nativeCurrency: string; - tokenAddresses: string[]; + tokenContractAddresses: Hex[]; }) { - if (tokenAddresses.length === 0 || this.disabled) { + if (tokenContractAddresses.length === 0 || this.disabled) { return; } - const chainSlug = await this.getChainSlug(chainId); - let newContractExchangeRates: ContractExchangeRates = {}; - if (!chainSlug) { - tokenAddresses.forEach((tokenAddress) => { - const address = toChecksumHexAddress(tokenAddress); - newContractExchangeRates[address] = undefined; - }); - } else { - newContractExchangeRates = await this.fetchAndMapExchangeRates( - nativeCurrency, - chainSlug, - tokenAddresses, - ); - } + const newContractExchangeRates = await this.fetchAndMapExchangeRates({ + tokenContractAddresses, + chainId, + nativeCurrency, + }); const existingContractExchangeRatesForChainId = this.state.contractExchangeRatesByChainId[chainId] ?? {}; + this.update({ contractExchangeRatesByChainId: { ...this.state.contractExchangeRatesByChainId, [chainId]: { ...existingContractExchangeRatesForChainId, [nativeCurrency]: { - ...(existingContractExchangeRatesForChainId[nativeCurrency] ?? {}), + ...existingContractExchangeRatesForChainId[nativeCurrency], ...newContractExchangeRates, }, }, @@ -512,82 +387,55 @@ export class TokenRatesController extends PollingControllerV1< } /** - * Checks if the active network's native currency is supported by the coingecko API. - * If supported, it fetches and maps contractExchange rates to a format to be consumed by the UI. - * If not supported, it fetches contractExchange rates and maps them from token/fallback-currency - * to token/nativeCurrency. + * Uses the token prices service to retrieve exchange rates for tokens in a + * particular currency. + * + * If the price API does not support the given chain ID, returns an empty + * object. + * + * If the price API does not support the given currency, retrieves exchange + * rates in a known currency instead, then converts those rates using the + * exchange rate between the known currency and desired currency. * - * @param nativeCurrency - The native currency ticker against which to fetch exchange rates - * @param chainSlug - The unique slug used to id the chain by the coingecko api - * should be used to query token exchange rates. - * @param tokenAddresses - The addresses for the token contracts against which to fetch exchange rates. - * @returns An object with conversion rates for each token - * related to the network's native currency. + * @param args - The arguments to this function. + * @param args.tokenContractAddresses - Contract addresses for tokens. + * @param args.chainId - The EIP-155 ID of the chain where the tokens live. + * @param args.nativeCurrency - The native currency in which to request + * exchange rates. + * @returns A map from token contract address to its exchange rate in the + * native currency, or an empty map if no exchange rates can be obtained for + * the chain ID. */ - async fetchAndMapExchangeRates( - nativeCurrency: string, - chainSlug: string, - tokenAddresses: string[] = this.tokenList.map((token) => token.address), - ): Promise { - const contractExchangeRates: ContractExchangeRates = {}; - - // check if native currency is supported as a vs_currency by the API - const nativeCurrencySupported = await this.checkIsSupportedVsCurrency( - nativeCurrency, - ); + async fetchAndMapExchangeRates({ + tokenContractAddresses, + chainId, + nativeCurrency, + }: { + tokenContractAddresses: Hex[]; + chainId: Hex; + nativeCurrency: string; + }): Promise { + if (!this.#tokenPricesService.validateChainIdSupported(chainId)) { + return tokenContractAddresses.reduce((obj, tokenContractAddress) => { + return { + ...obj, + [tokenContractAddress]: undefined, + }; + }, {}); + } - if (nativeCurrencySupported) { - // If it is we can do a simple fetch against the CoinGecko API - const prices = await this.fetchExchangeRate( - chainSlug, + if (this.#tokenPricesService.validateCurrencySupported(nativeCurrency)) { + return await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ + tokenContractAddresses, + chainId, nativeCurrency, - tokenAddresses, - ); - tokenAddresses.forEach((tokenAddress) => { - const price = prices[tokenAddress.toLowerCase()]; - contractExchangeRates[toChecksumHexAddress(tokenAddress)] = price - ? price[nativeCurrency.toLowerCase()] - : 0; }); - } else { - // if native currency is not supported we need to use a fallback vsCurrency, get the exchange rates - // in token/fallback-currency format and convert them to expected token/nativeCurrency format. - let tokenExchangeRates; - let vsCurrencyToNativeCurrencyConversionRate = 0; - try { - [ - tokenExchangeRates, - { conversionRate: vsCurrencyToNativeCurrencyConversionRate }, - ] = await Promise.all([ - this.fetchExchangeRate( - chainSlug, - FALL_BACK_VS_CURRENCY, - tokenAddresses, - ), - fetchNativeExchangeRate(nativeCurrency, FALL_BACK_VS_CURRENCY, false), - ]); - } catch (error) { - if ( - error instanceof Error && - error.message.includes('market does not exist for this coin pair') - ) { - return {}; - } - throw error; - } - - for (const [tokenAddress, conversion] of Object.entries( - tokenExchangeRates, - )) { - const tokenToVsCurrencyConversionRate = - conversion[FALL_BACK_VS_CURRENCY.toLowerCase()]; - contractExchangeRates[toChecksumHexAddress(tokenAddress)] = - tokenToVsCurrencyConversionRate * - vsCurrencyToNativeCurrencyConversionRate; - } } - return contractExchangeRates; + return await this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ + tokenContractAddresses, + nativeCurrency, + }); } /** @@ -600,15 +448,104 @@ export class TokenRatesController extends PollingControllerV1< */ async _executePoll( networkClientId: NetworkClientId, - options: { tokenAddresses: string[] }, + options: { tokenAddresses: Hex[] }, ): Promise { const networkClient = this.getNetworkClientById(networkClientId); await this.updateExchangeRatesByChainId({ chainId: networkClient.configuration.chainId, nativeCurrency: networkClient.configuration.ticker, - tokenAddresses: options.tokenAddresses, + tokenContractAddresses: options.tokenAddresses, }); } + + /** + * Retrieves prices in the given currency for the given tokens on the given + * chain. Ensures that token addresses are checksum addresses. + * + * @param args - The arguments to this function. + * @param args.tokenContractAddresses - Contract addresses for tokens. + * @param args.chainId - The EIP-155 ID of the chain where the tokens live. + * @param args.nativeCurrency - The native currency in which to request + * prices. + * @returns A map of the token addresses (as checksums) to their prices in the + * native currency. + */ + async #fetchAndMapExchangeRatesForSupportedNativeCurrency({ + tokenContractAddresses, + chainId, + nativeCurrency, + }: { + tokenContractAddresses: Hex[]; + chainId: Hex; + nativeCurrency: string; + }): Promise { + const tokenPricesByTokenContractAddress = + await this.#tokenPricesService.fetchTokenPrices({ + tokenContractAddresses, + chainId, + currency: nativeCurrency, + }); + + return Object.entries(tokenPricesByTokenContractAddress).reduce( + (obj, [tokenContractAddress, tokenPrice]) => { + return { + ...obj, + [toChecksumHexAddress(tokenContractAddress)]: tokenPrice.value, + }; + }, + {}, + ); + } + + /** + * If the price API does not support a given native currency, then we need to + * convert it to a fallback currency and feed that currency into the price + * API, then convert the prices to our desired native currency. + * + * @param args - The arguments to this function. + * @param args.tokenContractAddresses - The contract addresses for the tokens you + * want to retrieve prices for. + * @param args.nativeCurrency - The currency you want the prices to be in. + * @returns A map of the token addresses (as checksums) to their prices in the + * native currency. + */ + async #fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ + tokenContractAddresses, + nativeCurrency, + }: { + tokenContractAddresses: Hex[]; + nativeCurrency: string; + }): Promise { + const [ + tokenPricesByTokenContractAddress, + fallbackCurrencyToNativeCurrencyConversionRate, + ] = await Promise.all([ + this.#tokenPricesService.fetchTokenPrices({ + tokenContractAddresses, + currency: FALL_BACK_VS_CURRENCY, + chainId: this.config.chainId, + }), + getCurrencyConversionRate({ + from: FALL_BACK_VS_CURRENCY, + to: nativeCurrency, + }), + ]); + + if (fallbackCurrencyToNativeCurrencyConversionRate === null) { + return {}; + } + + return Object.entries(tokenPricesByTokenContractAddress).reduce( + (obj, [tokenContractAddress, tokenPrice]) => { + return { + ...obj, + [toChecksumHexAddress(tokenContractAddress)]: + tokenPrice.value * fallbackCurrencyToNativeCurrencyConversionRate, + }; + }, + {}, + ); + } } export default TokenRatesController; diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 65f742dedc..1977e0ee2d 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -13,3 +13,4 @@ export { formatIconUrlWithProxy, getFormattedIpfsUrl, } from './assetsUtil'; +export { codefiTokenPricesServiceV2 } from './token-prices-service'; diff --git a/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts new file mode 100644 index 0000000000..9465f5894f --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts @@ -0,0 +1,83 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Represents the price of a token in a currency. + */ +export type TokenPrice< + TokenContractAddress extends Hex, + Currency extends string, +> = { + tokenContractAddress: TokenContractAddress; + value: number; + currency: Currency; +}; + +/** + * A map of token contract address to its price. + */ +export type TokenPricesByTokenContractAddress< + TokenContractAddress extends Hex, + Currency extends string, +> = { + [A in TokenContractAddress]: TokenPrice; +}; + +/** + * An ideal token prices service. All implementations must confirm to this + * interface. + * + * @template ChainId - A type union of valid arguments for the `chainId` + * argument to `fetchTokenPrices`. + * @template TokenContractAddress - A type union of all token contract + * addresses. The reason this type parameter exists is so that we can guarantee + * that same addresses that `fetchTokenPrices` receives are the same addresses + * that shown up in the return value. + * @template Currency - A type union of valid arguments for the `currency` + * argument to `fetchTokenPrices`. + */ +export type AbstractTokenPricesService< + ChainId extends Hex = Hex, + TokenContractAddress extends Hex = Hex, + Currency extends string = string, +> = { + /** + * Retrieves prices in the given currency for the tokens identified by the + * given contract addresses which are expected to live on the given chain. + * + * @param args - The arguments to this function. + * @param args.chainId - An EIP-155 chain ID. + * @param args.tokenContractAddresses - Contract addresses for tokens that + * live on the chain. + * @param args.currency - The desired currency of the token prices. + * @returns The prices for the requested tokens. + */ + fetchTokenPrices({ + chainId, + tokenContractAddresses, + currency, + }: { + chainId: ChainId; + tokenContractAddresses: TokenContractAddress[]; + currency: Currency; + }): Promise< + TokenPricesByTokenContractAddress + >; + + /** + * Type guard for whether the API can return token prices for the given chain + * ID. + * + * @param chainId - The chain ID to check. + * @returns True if the API supports the chain ID, false otherwise. + */ + validateChainIdSupported(chainId: unknown): chainId is ChainId; + + /** + * Type guard for whether the API can return token prices in the given + * currency. + * + * @param currency - The currency to check. + * @returns True if the API supports the currency, false otherwise. + */ + validateCurrencySupported(currency: unknown): currency is Currency; +}; diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts new file mode 100644 index 0000000000..022cb1ea22 --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -0,0 +1,152 @@ +import nock from 'nock'; + +import { + codefiTokenPricesServiceV2, + SUPPORTED_CHAIN_IDS, + SUPPORTED_CURRENCIES, +} from './codefi-v2'; + +describe('codefiTokenPricesServiceV2', () => { + describe('fetchTokenPrices', () => { + it('uses the /spot-prices endpoint of the Codefi Price API to gather prices for the given tokens', async () => { + nock('https://price-api.metafi.codefi.network') + .get('/v2/chains/1/spot-prices') + .query({ + tokenAddresses: '0xAAA,0xBBB,0xCCC', + vsCurrency: 'ETH', + }) + .reply(200, { + '0xaaa': { + eth: 148.17205755299946, + }, + '0xbbb': { + eth: 33689.98134554716, + }, + '0xccc': { + eth: 148.1344197578456, + }, + }); + + const pricedTokensByAddress = + await codefiTokenPricesServiceV2.fetchTokenPrices({ + chainId: '0x1', + tokenContractAddresses: ['0xAAA', '0xBBB', '0xCCC'], + currency: 'ETH', + }); + + expect(pricedTokensByAddress).toStrictEqual({ + '0xAAA': { + tokenContractAddress: '0xAAA', + value: 148.17205755299946, + currency: 'ETH', + }, + '0xBBB': { + tokenContractAddress: '0xBBB', + value: 33689.98134554716, + currency: 'ETH', + }, + '0xCCC': { + tokenContractAddress: '0xCCC', + value: 148.1344197578456, + currency: 'ETH', + }, + }); + }); + + it('throws if one of the token addresses cannot be found in the response data', async () => { + nock('https://price-api.metafi.codefi.network') + .get('/v2/chains/1/spot-prices') + .query({ + tokenAddresses: '0xAAA,0xBBB,0xCCC', + vsCurrency: 'ETH', + }) + .reply(200, { + '0xbbb': { + eth: 33689.98134554716, + }, + '0xccc': { + eth: 148.1344197578456, + }, + }); + + await expect( + codefiTokenPricesServiceV2.fetchTokenPrices({ + chainId: '0x1', + tokenContractAddresses: ['0xAAA', '0xBBB', '0xCCC'], + currency: 'ETH', + }), + ).rejects.toThrow('Could not find price for "0xAAA" in "ETH"'); + }); + + it('throws if the currency cannot be found in the response data', async () => { + nock('https://price-api.metafi.codefi.network') + .get('/v2/chains/1/spot-prices') + .query({ + tokenAddresses: '0xAAA,0xBBB,0xCCC', + vsCurrency: 'ETH', + }) + .reply(200, { + '0xaaa': {}, + '0xbbb': { + eth: 33689.98134554716, + }, + '0xccc': { + eth: 148.1344197578456, + }, + }); + + await expect( + codefiTokenPricesServiceV2.fetchTokenPrices({ + chainId: '0x1', + tokenContractAddresses: ['0xAAA', '0xBBB', '0xCCC'], + currency: 'ETH', + }), + ).rejects.toThrow('Could not find price for "0xAAA" in "ETH"'); + }); + }); + + describe('validateChainIdSupported', () => { + it.each(SUPPORTED_CHAIN_IDS)( + 'returns true if the given chain ID is %s', + (chainId) => { + expect( + codefiTokenPricesServiceV2.validateChainIdSupported(chainId), + ).toBe(true); + }, + ); + + it('returns false if the given chain ID is not one of the supported chain IDs', () => { + expect( + codefiTokenPricesServiceV2.validateChainIdSupported( + '0x999999999999999', + ), + ).toBe(false); + }); + }); + + describe('validateCurrencySupported', () => { + it.each(SUPPORTED_CURRENCIES)( + 'returns true if the given currency is %s', + (currency) => { + expect( + codefiTokenPricesServiceV2.validateCurrencySupported(currency), + ).toBe(true); + }, + ); + + it.each(SUPPORTED_CURRENCIES.map((currency) => currency.toLowerCase()))( + 'returns true if the given currency is %s', + (currency) => { + expect( + codefiTokenPricesServiceV2.validateCurrencySupported(currency), + ).toBe(true); + }, + ); + + it('returns false if the given currency is not one of the supported currencies', () => { + expect(codefiTokenPricesServiceV2.validateCurrencySupported('LOL')).toBe( + false, + ); + }); + }); +}); diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts new file mode 100644 index 0000000000..344e2e6de7 --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -0,0 +1,345 @@ +import { handleFetch } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { hexToNumber } from '@metamask/utils'; + +import type { + AbstractTokenPricesService, + TokenPrice, + TokenPricesByTokenContractAddress, +} from './abstract-token-prices-service'; + +/** + * The shape of the data that the /spot-prices endpoint returns. + */ +type SpotPricesEndpointData< + TokenContractAddress extends Hex, + Currency extends string, +> = Record>; + +/** + * The list of currencies that can be supplied as the `vsCurrency` parameter to + * the `/spot-prices` endpoint, in lowercase form. + */ +export const SUPPORTED_CURRENCIES = [ + // Bitcoin + 'btc', + // Ether + 'eth', + // Litecoin + 'ltc', + // Bitcoin Cash + 'bch', + // Binance Coin + 'bnb', + // EOS + 'eos', + // XRP + 'xrp', + // Lumens + 'xlm', + // Chainlink + 'link', + // Polkadot + 'dot', + // Yearn.finance + 'yfi', + // US Dollar + 'usd', + // United Arab Emirates Dirham + 'aed', + // Argentine Peso + 'ars', + // Australian Dollar + 'aud', + // Bangladeshi Taka + 'bdt', + // Bahraini Dinar + 'bhd', + // Bermudian Dollar + 'bmd', + // Brazil Real + 'brl', + // Canadian Dollar + 'cad', + // Swiss Franc + 'chf', + // Chilean Peso + 'clp', + // Chinese Yuan + 'cny', + // Czech Koruna + 'czk', + // Danish Krone + 'dkk', + // Euro + 'eur', + // British Pound Sterling + 'gbp', + // Hong Kong Dollar + 'hkd', + // Hungarian Forint + 'huf', + // Indonesian Rupiah + 'idr', + // Israeli New Shekel + 'ils', + // Indian Rupee + 'inr', + // Japanese Yen + 'jpy', + // South Korean Won + 'krw', + // Kuwaiti Dinar + 'kwd', + // Sri Lankan Rupee + 'lkr', + // Burmese Kyat + 'mmk', + // Mexican Peso + 'mxn', + // Malaysian Ringgit + 'myr', + // Nigerian Naira + 'ngn', + // Norwegian Krone + 'nok', + // New Zealand Dollar + 'nzd', + // Philippine Peso + 'php', + // Pakistani Rupee + 'pkr', + // Polish Zloty + 'pln', + // Russian Ruble + 'rub', + // Saudi Riyal + 'sar', + // Swedish Krona + 'sek', + // Singapore Dollar + 'sgd', + // Thai Baht + 'thb', + // Turkish Lira + 'try', + // New Taiwan Dollar + 'twd', + // Ukrainian hryvnia + 'uah', + // Venezuelan bolívar fuerte + 'vef', + // Vietnamese đồng + 'vnd', + // South African Rand + 'zar', + // IMF Special Drawing Rights + 'xdr', + // Silver - Troy Ounce + 'xag', + // Gold - Troy Ounce + 'xau', + // Bits + 'bits', + // Satoshi + 'sats', +] as const; + +/** + * A currency that can be supplied as the `vsCurrency` parameter to + * the `/spot-prices` endpoint. Covers both uppercase and lowercase versions. + */ +type SupportedCurrency = + | (typeof SUPPORTED_CURRENCIES)[number] + | Uppercase<(typeof SUPPORTED_CURRENCIES)[number]>; + +/** + * The list of chain IDs that can be supplied in the URL for the `/spot-prices` + * endpoint, but in hexadecimal form (for consistency with how we represent + * chain IDs in other places). + */ +export const SUPPORTED_CHAIN_IDS = [ + // Ethereum Mainnet + '0x1', + // OP Mainnet + '0xa', + // Cronos Mainnet + '0x19', + // BNB Smart Chain Mainnet + '0x38', + // Syscoin Mainnet + '0x39', + // OKXChain Mainnet + '0x42', + // Hoo Smart Chain + '0x46', + // Meter Mainnet + '0x52', + // TomoChain + '0x58', + // Gnosis + '0x64', + // Velas EVM Mainnet + '0x6a', + // Fuse Mainnet + '0x7a', + // Huobi ECO Chain Mainnet + '0x80', + // Polygon Mainnet + '0x89', + // Fantom Opera + '0xfa', + // Boba Network + '0x120', + // KCC Mainnet + '0x141', + // zkSync Era Mainnet + '0x144', + // Theta Mainnet + '0x169', + // Metis Andromeda Mainnet + '0x440', + // Moonbeam + '0x504', + // Moonriver + '0x505', + // Base + '0x2105', + // Shiden + // NOTE: This is the wrong chain ID, this should be 0x150 + '0x2107', + // Smart Bitcoin Cash + '0x2710', + // Arbitrum One + '0xa4b1', + // Celo Mainnet + '0xa4ec', + // Oasis Emerald + '0xa516', + // Avalanche C-Chain + '0xa86a', + // Polis Mainnet + '0x518af', + // Aurora Mainnet + '0x4e454152', + // Harmony Mainnet Shard 0 + '0x63564c40', +] as const; + +/** + * A chain ID that can be supplied in the URL for the `/spot-prices` endpoint, + * but in hexadecimal form (for consistency with how we represent chain IDs in + * other places). + */ +type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]; + +/** + * All requests to V2 of the Price API start with this. + */ +const BASE_URL = 'https://price-api.metafi.codefi.network/v2'; + +/** + * This version of the token prices service uses V2 of the Codefi Price API to + * fetch token prices. + */ +export const codefiTokenPricesServiceV2: AbstractTokenPricesService< + SupportedChainId, + Hex, + SupportedCurrency +> = { + /** + * Retrieves prices in the given currency for the tokens identified by the + * given contract addresses which are expected to live on the given chain. + * + * @param args - The arguments to function. + * @param args.chainId - An EIP-155 chain ID. + * @param args.tokenContractAddresses - Contract addresses for tokens that + * live on the chain. + * @param args.currency - The desired currency of the token prices. + * @returns The prices for the requested tokens. + */ + async fetchTokenPrices({ + chainId, + tokenContractAddresses, + currency, + }: { + chainId: SupportedChainId; + tokenContractAddresses: Hex[]; + currency: SupportedCurrency; + }): Promise> { + const chainIdAsNumber = hexToNumber(chainId); + + const url = new URL(`${BASE_URL}/chains/${chainIdAsNumber}/spot-prices`); + url.searchParams.append('tokenAddresses', tokenContractAddresses.join(',')); + url.searchParams.append('vsCurrency', currency); + + const pricesByCurrencyByTokenContractAddress: SpotPricesEndpointData< + Lowercase, + Lowercase + > = await handleFetch(url); + + return tokenContractAddresses.reduce( + ( + obj: Partial>, + tokenContractAddress, + ) => { + // The Price API lowercases both currency and token addresses, so we have + // to keep track of them and make sure we return the original versions. + const lowercasedTokenContractAddress = + tokenContractAddress.toLowerCase() as Lowercase; + const lowercasedCurrency = + currency.toLowerCase() as Lowercase; + + const price = + pricesByCurrencyByTokenContractAddress[ + lowercasedTokenContractAddress + ]?.[lowercasedCurrency]; + + if (!price) { + throw new Error( + `Could not find price for "${tokenContractAddress}" in "${currency}"`, + ); + } + + const tokenPrice: TokenPrice = { + tokenContractAddress, + value: price, + currency, + }; + return { + ...obj, + [tokenContractAddress]: tokenPrice, + }; + }, + {}, + ) as TokenPricesByTokenContractAddress; + }, + + /** + * Type guard for whether the API can return token prices for the given chain + * ID. + * + * @param chainId - The chain ID to check. + * @returns True if the API supports the chain ID, false otherwise. + */ + validateChainIdSupported(chainId: unknown): chainId is SupportedChainId { + const supportedChainIds: readonly string[] = SUPPORTED_CHAIN_IDS; + return typeof chainId === 'string' && supportedChainIds.includes(chainId); + }, + + /** + * Type guard for whether the API can return token prices in the given + * currency. + * + * @param currency - The currency to check. If a string, can be either + * lowercase or uppercase. + * @returns True if the API supports the currency, false otherwise. + */ + validateCurrencySupported(currency: unknown): currency is SupportedCurrency { + const supportedCurrencies: readonly string[] = SUPPORTED_CURRENCIES; + return ( + typeof currency === 'string' && + supportedCurrencies.includes(currency.toLowerCase()) + ); + }, +}; diff --git a/packages/assets-controllers/src/token-prices-service/index.test.ts b/packages/assets-controllers/src/token-prices-service/index.test.ts new file mode 100644 index 0000000000..90b95c628c --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/index.test.ts @@ -0,0 +1,11 @@ +import * as allExports from '.'; + +describe('token-prices-service', () => { + it('has expected exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + Array [ + "codefiTokenPricesServiceV2", + ] + `); + }); +}); diff --git a/packages/assets-controllers/src/token-prices-service/index.ts b/packages/assets-controllers/src/token-prices-service/index.ts new file mode 100644 index 0000000000..edb6ff1d1f --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/index.ts @@ -0,0 +1,2 @@ +export type { AbstractTokenPricesService } from './abstract-token-prices-service'; +export { codefiTokenPricesServiceV2 } from './codefi-v2'; diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 3d4643079d..a4c0c4acab 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Update `successfulFetch` so that a URL instance can now be passed to it ([#3600](https://github.com/MetaMask/core/pull/3600)) +- Update `handleFetch` so that a URL instance can now be passed to it ([#3600](https://github.com/MetaMask/core/pull/3600)) ## [6.1.0] ### Added diff --git a/packages/controller-utils/src/util.ts b/packages/controller-utils/src/util.ts index 888d9281a6..74023f5019 100644 --- a/packages/controller-utils/src/util.ts +++ b/packages/controller-utils/src/util.ts @@ -312,11 +312,16 @@ export function isSmartContractCode(code: string) { * @param options - Fetch options. * @returns The fetch response. */ -export async function successfulFetch(request: string, options?: RequestInit) { +export async function successfulFetch( + request: URL | RequestInfo, + options?: RequestInit, +) { const response = await fetch(request, options); if (!response.ok) { throw new Error( - `Fetch failed with status '${response.status}' for request '${request}'`, + `Fetch failed with status '${response.status}' for request '${String( + request, + )}'`, ); } return response; @@ -329,7 +334,10 @@ export async function successfulFetch(request: string, options?: RequestInit) { * @param options - The fetch options. * @returns The fetch response JSON data. */ -export async function handleFetch(request: string, options?: RequestInit) { +export async function handleFetch( + request: URL | RequestInfo, + options?: RequestInit, +) { const response = await successfulFetch(request, options); const object = await response.json(); return object;