diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index a16cd0d14f3..d87a78b6fa3 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -86,9 +86,6 @@ "packages/assets-controllers/src/multicall.test.ts": { "@typescript-eslint/prefer-promise-reject-errors": 2 }, - "packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts": { - "jsdoc/require-returns": 1 - }, "packages/assets-controllers/src/token-prices-service/codefi-v2.ts": { "jsdoc/tag-lines": 2 }, diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 16c6073711b..33801c48c93 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Added constructor argument `tokenPricesService` in `currencyRateController` ([#6863](https://github.com/MetaMask/core/pull/6863)) + +- Added `fetchExchangeRates` function to fetch exchange rates from price-api ([#6863](https://github.com/MetaMask/core/pull/6863)) + +### Changed + +- `CurrencyRateController` now fetches exchange rates from price-api and fallback to cryptoCompare ([#6863](https://github.com/MetaMask/core/pull/6863)) + ## [81.0.1] ### Fixed diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index ce7e559b7a5..273d443b300 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -5,6 +5,7 @@ import { NetworksTicker, } from '@metamask/controller-utils'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; @@ -14,9 +15,37 @@ import type { GetCurrencyRateState, } from './CurrencyRateController'; import { CurrencyRateController } from './CurrencyRateController'; +import type { AbstractTokenPricesService } from './token-prices-service'; const name = 'CurrencyRateController' as const; +/** + * 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 {}; + }, + async fetchExchangeRates() { + return {}; + }, + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return true; + }, + validateCurrencySupported(_currency: unknown): _currency is string { + return true; + }, + ...overrides, + }; +} + /** * Constructs a restricted messenger. * @@ -78,7 +107,11 @@ describe('CurrencyRateController', () => { it('should set default state', () => { const messenger = getRestrictedMessenger(); - const controller = new CurrencyRateController({ messenger }); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + }); expect(controller.state).toStrictEqual({ currentCurrency: 'usd', @@ -96,10 +129,12 @@ describe('CurrencyRateController', () => { it('should initialize with initial state', () => { const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const existingState = { currentCurrency: 'rep' }; const controller = new CurrencyRateController({ messenger, state: existingState, + tokenPricesService, }); expect(controller.state).toStrictEqual({ @@ -119,10 +154,12 @@ describe('CurrencyRateController', () => { it('should not poll before being started', async () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ interval: 100, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, + tokenPricesService, }); await advanceTime({ clock, duration: 200 }); @@ -151,35 +188,52 @@ describe('CurrencyRateController', () => { }, }); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ interval: 100, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency }, + tokenPricesService, }); controller.startPolling({ nativeCurrencies: ['ETH'] }); await advanceTime({ clock, duration: 0 }); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: 10, - conversionRate: 1, - usdConversionRate: 11, + conversionRate: 4149.76, + usdConversionRate: null, }, }); await advanceTime({ clock, duration: 99 }); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); await advanceTime({ clock, duration: 1 }); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(2); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(2); expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: 20, - conversionRate: 2, - usdConversionRate: 22, + conversionRate: 4149.76, + usdConversionRate: null, }, }); @@ -188,11 +242,25 @@ describe('CurrencyRateController', () => { it('should not poll after being stopped', async () => { const fetchMultiExchangeRateStub = jest.fn(); + const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ interval: 100, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, + tokenPricesService, }); controller.startPolling({ nativeCurrencies: ['ETH'] }); @@ -202,11 +270,13 @@ describe('CurrencyRateController', () => { controller.stopAllPolling(); // called once upon initial start - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); await advanceTime({ clock, duration: 150, stepSize: 50 }); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); controller.destroy(); }); @@ -215,10 +285,23 @@ describe('CurrencyRateController', () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ interval: 100, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, + tokenPricesService, }); controller.startPolling({ nativeCurrencies: ['ETH'] }); await advanceTime({ clock, duration: 0 }); @@ -226,19 +309,22 @@ describe('CurrencyRateController', () => { controller.stopAllPolling(); // called once upon initial start - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); controller.startPolling({ nativeCurrencies: ['ETH'] }); await advanceTime({ clock, duration: 0 }); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(2); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(2); await advanceTime({ clock, duration: 100 }); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(3); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(3); }); - it('should update exchange rate', async () => { + it('should update exchange rate from price api', async () => { const currentCurrency = 'cad'; jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); @@ -246,11 +332,24 @@ describe('CurrencyRateController', () => { .fn() .mockResolvedValue({ eth: { [currentCurrency]: 10, usd: 111 } }); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 111, + }, + }); const controller = new CurrencyRateController({ interval: 10, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency }, + tokenPricesService, }); expect(controller.state.currencyRates).toStrictEqual({ @@ -263,11 +362,14 @@ describe('CurrencyRateController', () => { await controller.updateExchangeRate(['ETH']); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: getStubbedDate() / 1000, - conversionRate: 10, - usdConversionRate: 111, + conversionRate: 4149.76, + usdConversionRate: 0.01, }, }); @@ -297,11 +399,26 @@ describe('CurrencyRateController', () => { }, }; }); + const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 0.001, + }, + }); const controller = new CurrencyRateController({ fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency }, + tokenPricesService, }); expect(controller.state.currencyRates).toStrictEqual({ @@ -314,6 +431,9 @@ describe('CurrencyRateController', () => { await controller.updateExchangeRate(['SepoliaETH']); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: 0, @@ -322,8 +442,8 @@ describe('CurrencyRateController', () => { }, SepoliaETH: { conversionDate: getStubbedDate() / 1000, - conversionRate: 10, - usdConversionRate: 110, + conversionRate: 4149.76, + usdConversionRate: 1000, }, }); @@ -338,6 +458,23 @@ describe('CurrencyRateController', () => { btc: { [currentCurrency]: 10, usd: 11 }, }); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 0.0055, + }, + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.00010377048177666853, + currencyType: 'crypto', + usd: 0.0022, + }, + }); const controller = new CurrencyRateController({ interval: 10, fetchMultiExchangeRate: fetchMultiExchangeRateStub, @@ -356,6 +493,7 @@ describe('CurrencyRateController', () => { }, }, }, + tokenPricesService, }); await controller.setCurrentCurrency(currentCurrency); @@ -378,54 +516,83 @@ describe('CurrencyRateController', () => { currencyRates: { ETH: { conversionDate: getStubbedDate() / 1000, - conversionRate: 10, - usdConversionRate: 11, + conversionRate: 4149.76, + usdConversionRate: 181.82, }, BTC: { conversionDate: getStubbedDate() / 1000, - conversionRate: 10, - usdConversionRate: 11, + conversionRate: 9636.65, + usdConversionRate: 454.55, }, }, }); controller.destroy(); }); - it('should add usd rate to state when includeUsdRate is configured true', async () => { const fetchMultiExchangeRateStub = jest.fn().mockResolvedValue({}); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 0.0055, + }, + }); const controller = new CurrencyRateController({ includeUsdRate: true, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); await controller.updateExchangeRate(['SepoliaETH']); - - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); - expect(fetchMultiExchangeRateStub.mock.calls).toMatchObject([ - ['xyz', ['ETH'], true], + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy.mock.calls).toMatchObject([ + [ + { + baseCurrency: 'xyz', + includeUsdRate: true, + cryptocurrencies: ['ETH'], + }, + ], ]); controller.destroy(); }); - it('should default to fetching exchange rate from crypto-compare', async () => { + it('should default to fetching exchange rate from price api', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); - const cryptoCompareHost = 'https://min-api.cryptocompare.com'; - nock(cryptoCompareHost) - .get('/data/pricemulti?fsyms=ETH&tsyms=xyz') - .reply(200, { ETH: { XYZ: 2000.42 } }) - .persist(); + const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000.42, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH']); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual({ currentCurrency: 'xyz', currencyRates: { @@ -440,7 +607,7 @@ describe('CurrencyRateController', () => { controller.destroy(); }); - it('should throw unexpected errors', async () => { + it('should throw unexpected errors when both price api and crypto-compare fail', async () => { const cryptoCompareHost = 'https://min-api.cryptocompare.com'; nock(cryptoCompareHost) .get('/data/pricemulti?fsyms=ETH&tsyms=xyz') @@ -451,15 +618,22 @@ describe('CurrencyRateController', () => { .persist(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); await expect(controller.updateExchangeRate(['ETH'])).rejects.toThrow( 'this method has been deprecated', ); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + controller.destroy(); }); @@ -481,7 +655,15 @@ describe('CurrencyRateController', () => { }, }; const messenger = getRestrictedMessenger(); - const controller = new CurrencyRateController({ messenger, state }); + const tokenPricesService = buildMockTokenPricesService(); + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); + const controller = new CurrencyRateController({ + messenger, + state, + tokenPricesService, + }); // Error should still be thrown await expect(controller.updateExchangeRate(['ETH'])).rejects.toThrow( @@ -494,7 +676,69 @@ describe('CurrencyRateController', () => { controller.destroy(); }); - it('fetches exchange rates for multiple native currencies', async () => { + it('fetches exchange rates for multiple native currencies from price api', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 4000.42, + currencyType: 'crypto', + }, + pol: { + name: 'Polkadot', + ticker: 'pol', + value: 1 / 0.3, + currencyType: 'crypto', + }, + bnb: { + name: 'BNB', + ticker: 'bnb', + value: 1 / 500.1, + currencyType: 'crypto', + }, + }); + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL', 'BNB']); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + BNB: { + conversionDate, + conversionRate: 500.1, + usdConversionRate: null, + }, + ETH: { + conversionDate, + conversionRate: 4000.42, + usdConversionRate: null, + }, + POL: { + conversionDate, + conversionRate: 0.3, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('fallback to crypto compare when price api fails and fetches exchange rates for multiple native currencies', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); const cryptoCompareHost = 'https://min-api.cryptocompare.com'; nock(cryptoCompareHost) @@ -506,9 +750,14 @@ describe('CurrencyRateController', () => { }) .persist(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH', 'POL', 'BNB']); @@ -538,7 +787,7 @@ describe('CurrencyRateController', () => { controller.destroy(); }); - it('skips updating empty or undefined native currencies', async () => { + it('skips updating empty or undefined native currencies when calling crypto compare', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); const cryptoCompareHost = 'https://min-api.cryptocompare.com'; nock(cryptoCompareHost) @@ -549,9 +798,56 @@ describe('CurrencyRateController', () => { .persist(); const messenger = getRestrictedMessenger(); + + const tokenPricesService = buildMockTokenPricesService(); + + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, + }); + + const nativeCurrencies = ['ETH', undefined, '']; + + await controller.updateExchangeRate(nativeCurrencies); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 1000, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('skips updating empty or undefined native currencies when calling price api', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getRestrictedMessenger(); + + const tokenPricesService = buildMockTokenPricesService(); + + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1000, + currencyType: 'crypto', + }, + }); + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, }); const nativeCurrencies = ['ETH', undefined, '']; @@ -577,11 +873,13 @@ describe('CurrencyRateController', () => { it('should not fetch exchange rates when useExternalServices is false', async () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ useExternalServices: () => false, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'usd' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH']); @@ -601,12 +899,14 @@ describe('CurrencyRateController', () => { it('should not poll when useExternalServices is false', async () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ useExternalServices: () => false, interval: 100, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'usd' }, + tokenPricesService, }); controller.startPolling({ nativeCurrencies: ['ETH'] }); @@ -624,11 +924,13 @@ describe('CurrencyRateController', () => { it('should not fetch exchange rates when useExternalServices is false even with multiple currencies', async () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ useExternalServices: () => false, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'eur' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH', 'BTC', 'BNB']); @@ -648,11 +950,13 @@ describe('CurrencyRateController', () => { it('should not fetch exchange rates when useExternalServices is false even with testnet currencies', async () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ useExternalServices: () => false, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'cad' }, + tokenPricesService, }); await controller.updateExchangeRate(['SepoliaETH', 'GoerliETH']); @@ -672,12 +976,14 @@ describe('CurrencyRateController', () => { it('should not fetch exchange rates when useExternalServices is false even with includeUsdRate true', async () => { const fetchMultiExchangeRateStub = jest.fn(); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ useExternalServices: () => false, includeUsdRate: true, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'jpy' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH']); @@ -700,21 +1006,35 @@ describe('CurrencyRateController', () => { .fn() .mockResolvedValue({ eth: { usd: 2000, eur: 1800 } }); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1800, + currencyType: 'crypto', + usd: 1 / 2000, + }, + }); const controller = new CurrencyRateController({ useExternalServices: () => true, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'eur' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH']); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledWith( - 'eur', - ['ETH'], - false, - ); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledWith({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies: ['ETH'], + }); expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: getStubbedDate() / 1000, @@ -732,20 +1052,35 @@ describe('CurrencyRateController', () => { .fn() .mockResolvedValue({ eth: { usd: 2000, gbp: 1600 } }); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1600, + currencyType: 'crypto', + usd: 1 / 2000, + }, + }); const controller = new CurrencyRateController({ fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'gbp' }, + tokenPricesService, }); await controller.updateExchangeRate(['ETH']); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(1); - expect(fetchMultiExchangeRateStub).toHaveBeenCalledWith( - 'gbp', - ['ETH'], - false, - ); + expect(fetchMultiExchangeRateStub).toHaveBeenCalledTimes(0); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledWith({ + baseCurrency: 'gbp', + includeUsdRate: false, + cryptocurrencies: ['ETH'], + }); expect(controller.state.currencyRates).toStrictEqual({ ETH: { conversionDate: getStubbedDate() / 1000, @@ -762,11 +1097,13 @@ describe('CurrencyRateController', () => { .fn() .mockRejectedValue(new Error('API Error')); const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ useExternalServices: () => false, fetchMultiExchangeRate: fetchMultiExchangeRateStub, messenger, state: { currentCurrency: 'usd' }, + tokenPricesService, }); // Should not throw an error @@ -780,8 +1117,10 @@ describe('CurrencyRateController', () => { describe('metadata', () => { it('includes expected state in debug snapshots', () => { + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ messenger: getRestrictedMessenger(), + tokenPricesService, }); expect( @@ -805,8 +1144,10 @@ describe('CurrencyRateController', () => { }); it('includes expected state in state logs', () => { + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ messenger: getRestrictedMessenger(), + tokenPricesService, }); expect( @@ -830,8 +1171,10 @@ describe('CurrencyRateController', () => { }); it('persists expected state', () => { + const tokenPricesService = buildMockTokenPricesService(); const controller = new CurrencyRateController({ messenger: getRestrictedMessenger(), + tokenPricesService, }); expect( @@ -857,6 +1200,7 @@ describe('CurrencyRateController', () => { it('exposes expected state to UI', () => { const controller = new CurrencyRateController({ messenger: getRestrictedMessenger(), + tokenPricesService: buildMockTokenPricesService(), }); expect( diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index 9827eb2ea67..9c2a6f771f1 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -12,6 +12,7 @@ import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { Mutex } from 'async-mutex'; import { fetchMultiExchangeRate as defaultFetchMultiExchangeRate } from './crypto-compare-service'; +import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service'; /** * @type CurrencyRateState @@ -107,6 +108,8 @@ export class CurrencyRateController extends StaticIntervalPollingController boolean; + readonly #tokenPricesService: AbstractTokenPricesService; + /** * Creates a CurrencyRateController instance. * @@ -117,6 +120,7 @@ export class CurrencyRateController extends StaticIntervalPollingController; useExternalServices?: () => boolean; fetchMultiExchangeRate?: typeof defaultFetchMultiExchangeRate; + tokenPricesService: AbstractTokenPricesService; }) { super({ name, @@ -143,6 +149,7 @@ export class CurrencyRateController extends StaticIntervalPollingController, + ): Promise { + const { currentCurrency } = this.state; + + try { + const priceApiExchangeRatesResponse = + await this.#tokenPricesService.fetchExchangeRates({ + baseCurrency: currentCurrency, + includeUsdRate: this.includeUsdRate, + cryptocurrencies: [ + ...new Set(Object.values(nativeCurrenciesToFetch)), + ], + }); + + const ratesPriceApi = Object.entries(nativeCurrenciesToFetch).reduce( + (acc, [nativeCurrency, fetchedCurrency]) => { + const rate = + priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; + + acc[nativeCurrency] = { + conversionDate: rate !== undefined ? Date.now() / 1000 : null, + conversionRate: rate?.value + ? Number((1 / rate?.value).toFixed(2)) + : null, + usdConversionRate: rate?.usd + ? Number((1 / rate?.usd).toFixed(2)) + : null, + }; + return acc; + }, + {} as CurrencyRateState['currencyRates'], + ); + return ratesPriceApi; + } catch (error) { + console.error('Failed to fetch exchange rates.', error); + } + + // fallback to crypto compare + + try { + const fetchExchangeRateResponse = await this.fetchMultiExchangeRate( + currentCurrency, + [...new Set(Object.values(nativeCurrenciesToFetch))], + this.includeUsdRate, + ); + + const rates = Object.entries(nativeCurrenciesToFetch).reduce( + (acc, [nativeCurrency, fetchedCurrency]) => { + const rate = fetchExchangeRateResponse[fetchedCurrency.toLowerCase()]; + acc[nativeCurrency] = { + conversionDate: rate !== undefined ? Date.now() / 1000 : null, + conversionRate: rate?.[currentCurrency.toLowerCase()] ?? null, + usdConversionRate: rate?.usd ?? null, + }; + return acc; + }, + {} as CurrencyRateState['currencyRates'], + ); + + return rates; + } catch (error) { + console.error('Failed to fetch exchange rates.', error); + throw error; + } + } + /** * Updates the exchange rate for the current currency and native currency pairs. * @@ -182,8 +256,6 @@ export class CurrencyRateController extends StaticIntervalPollingController, ); - const fetchExchangeRateResponse = await this.fetchMultiExchangeRate( - currentCurrency, - [...new Set(Object.values(nativeCurrenciesToFetch))], - this.includeUsdRate, - ); - - const rates = Object.entries(nativeCurrenciesToFetch).reduce( - (acc, [nativeCurrency, fetchedCurrency]) => { - const rate = fetchExchangeRateResponse[fetchedCurrency.toLowerCase()]; - acc[nativeCurrency] = { - conversionDate: rate !== undefined ? Date.now() / 1000 : null, - conversionRate: rate?.[currentCurrency.toLowerCase()] ?? null, - usdConversionRate: rate?.usd ?? null, - }; - return acc; - }, - {} as CurrencyRateState['currencyRates'], + const rates = await this.#fetchExchangeRatesWithFallback( + nativeCurrenciesToFetch, ); this.update((state) => { diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 5db4713b5fd..da985dadfad 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -2973,6 +2973,9 @@ function buildMockTokenPricesService( async fetchTokenPrices() { return {}; }, + async fetchExchangeRates() { + return {}; + }, validateChainIdSupported(_chainId: unknown): _chainId is Hex { return true; }, diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts index 2148acd60c2..8d2ac0b1eb4 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts @@ -130,6 +130,9 @@ function buildMockTokenPricesService( overrides: Partial = {}, ): AbstractTokenPricesService { return { + async fetchExchangeRates() { + return {}; + }, async fetchTokenPrices() { return {}; }, diff --git a/packages/assets-controllers/src/assetsUtil.test.ts b/packages/assets-controllers/src/assetsUtil.test.ts index 17dc36ebf93..f93298b44cb 100644 --- a/packages/assets-controllers/src/assetsUtil.test.ts +++ b/packages/assets-controllers/src/assetsUtil.test.ts @@ -783,5 +783,8 @@ function createMockPriceService(): AbstractTokenPricesService { async fetchTokenPrices() { return {}; }, + async fetchExchangeRates() { + return {}; + }, }; } 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 index 7e705a33ab6..ddc7a3e159b 100644 --- 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 @@ -27,6 +27,17 @@ export type TokenPrice = { totalVolume: number; }; +/** + * Represents an exchange rate. + */ +export type ExchangeRate = { + name: string; + ticker: string; + value: number; + currencyType: string; + usd?: number; +}; + /** * A map of token address to its price. */ @@ -37,6 +48,13 @@ export type TokenPricesByTokenAddress< [A in TokenAddress]: TokenPrice; }; +/** + * A map of currency to its exchange rate. + */ +export type ExchangeRatesByCurrency = { + [C in Currency]: ExchangeRate; +}; + /** * An ideal token prices service. All implementations must confirm to this * interface. @@ -75,6 +93,25 @@ export type AbstractTokenPricesService< currency: Currency; }): Promise>>; + /** + * Retrieves exchange rates in the given currency. + * + * @param args - The arguments to this function. + * @param args.baseCurrency - The desired currency of the token prices. + * @param args.includeUsdRate - Whether to include the USD rate in the response. + * @param args.cryptocurrencies - The cryptocurrencies to get exchange rates for. + * @returns The exchange rates in the requested base currency. + */ + fetchExchangeRates({ + baseCurrency, + includeUsdRate, + cryptocurrencies, + }: { + baseCurrency: Currency; + includeUsdRate: boolean; + cryptocurrencies: string[]; + }): Promise>; + /** * Type guard for whether the API can return token prices for the given chain * ID. 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 index 8f644e7e02e..964dbe669bc 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -1271,6 +1271,489 @@ describe('CodefiTokenPricesServiceV2', () => { }); }); + describe('fetchExchangeRates', () => { + const exchangeRatesMockResponseUsd = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.000008880690393396647, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + ltc: { + name: 'Litecoin', + ticker: 'ltc', + value: 0.01021289164000047, + currencyType: 'crypto', + }, + }; + + const exchangeRatesMockResponseEur = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.000010377048177666853, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.0002845697921761581, + currencyType: 'crypto', + }, + ltc: { + name: 'Litecoin', + ticker: 'ltc', + value: 0.011983861448641322, + currencyType: 'crypto', + }, + }; + + const cryptocurrencies = ['ETH']; + + describe('when includeUsdRate is true and baseCurrency is not USD', () => { + it('throws when all calls to price fail', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .replyWithError('Failed to fetch'); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .replyWithError('Failed to fetch'); + await expect(() => + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }), + ).rejects.toThrow('Failed to fetch'); + }); + it('throws an error if none of the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + await expect( + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['not-supported'], + }), + ).rejects.toThrow( + 'None of the cryptocurrencies are supported by price api', + ); + }); + + it('returns result when some of the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['not-supported', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseEur.eth, + usd: 0.000240977533824818, + }, + }); + }); + + it('returns successfully usd values when all the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseEur.eth, + usd: 0.000240977533824818, + }, + btc: { + ...exchangeRatesMockResponseEur.btc, + usd: 0.000008880690393396647, + }, + }); + }); + + it('does not return usd values when one call to price fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .replyWithError('Failed to fetch'); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseEur.eth, + }, + btc: { + ...exchangeRatesMockResponseEur.btc, + }, + }); + }); + }); + + describe('when includeUsdRate is true and baseCurrency is equal to USD', () => { + it('throws when the call to price fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .replyWithError('Failed to fetch') + .persist(); + + await expect(() => + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('returns successfully usd values when all the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseUsd.eth, + usd: exchangeRatesMockResponseUsd.eth.value, + }, + btc: { + ...exchangeRatesMockResponseUsd.btc, + usd: exchangeRatesMockResponseUsd.btc.value, + }, + }); + }); + + it('returns successfully usd values when some of the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: true, + cryptocurrencies: ['not-supported', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseUsd.eth, + usd: exchangeRatesMockResponseUsd.eth.value, + }, + }); + }); + }); + + describe('when includeUsdRate is false and baseCurrency is not USD', () => { + it('does not include usd in the returned result', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies: ['eth'], + }); + + expect(result).toStrictEqual({ + eth: exchangeRatesMockResponseEur.eth, + }); + }); + }); + + describe('when includeUsdRate is false and baseCurrency is USD', () => { + it('includes usd in the returned result', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: false, + cryptocurrencies: ['eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseUsd.eth, + usd: exchangeRatesMockResponseUsd.eth.value, + }, + }); + }); + }); + + it('throws if the request fails consistently', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .replyWithError('Failed to fetch'); + + await expect( + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('throws if the initial request and all retries fail', async () => { + const retries = 3; + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .times(1 + retries) + .replyWithError('Failed to fetch'); + + await expect( + new CodefiTokenPricesServiceV2({ retries }).fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('succeeds if the last retry succeeds', async () => { + const retries = 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .times(retries) + .replyWithError('Failed to fetch'); + // Interceptor for successful request + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + const exchangeRates = await new CodefiTokenPricesServiceV2({ + retries, + }).fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }); + + expect(exchangeRates).toStrictEqual({ + eth: exchangeRatesMockResponseEur.eth, + }); + }); + + describe('before circuit break', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ now: Date.now() }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .delay(degradedThreshold * 2) + .reply(200, exchangeRatesMockResponseEur); + const onDegradedHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + degradedThreshold, + onDegraded: onDegradedHandler, + retries, + }); + + await fetchExchangeRatesWithFakeTimers({ + clock, + fetchExchangeRates: () => + service.fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }), + retries, + }); + + expect(onDegradedHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('after circuit break', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ now: Date.now() }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('calls onBreak handler upon break', async () => { + const retries = 3; + // Max consencutive failures is set to match number of calls in three update attempts (including retries) + const maximumConsecutiveFailures = (1 + retries) * 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .times(maximumConsecutiveFailures) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + const onBreakHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + retries, + maximumConsecutiveFailures, + onBreak: onBreakHandler, + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + const fetchExchangeRates = () => + service.fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }); + expect(onBreakHandler).not.toHaveBeenCalled(); + + // Initial three calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries).keys()) { + // eslint-disable-next-line no-loop-func + await expect(() => + fetchExchangeRatesWithFakeTimers({ + clock, + fetchExchangeRates, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreakHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('validateChainIdSupported', () => { it.each(SUPPORTED_CHAIN_IDS)( 'returns true if the given chain ID is %s', @@ -1344,6 +1827,7 @@ describe('CodefiTokenPricesServiceV2', () => { * @param args.clock - The fake timers clock to advance. * @param args.fetchTokenPrices - The "fetchTokenPrices" function to call. * @param args.retries - The number of retries the fetch call is configured to make. + * @returns The result of the fetch call. */ async function fetchTokenPricesWithFakeTimers({ clock, @@ -1368,3 +1852,43 @@ async function fetchTokenPricesWithFakeTimers({ return await pendingUpdate; } + +/** + * Calls the 'fetchExchangeRates' function while advancing the clock, allowing + * the function to resolve. + * + * Fetching rates is challenging in an environment with fake timers + * because we're using a library that automatically retries failed requests, + * which uses `setTimeout` internally. We have to advance the clock after the + * update call starts but before awaiting the result, otherwise it never + * resolves. + * + * @param args - Arguments + * @param args.clock - The fake timers clock to advance. + * @param args.fetchExchangeRates - The "fetchExchangeRates" function to call. + * @param args.retries - The number of retries the fetch call is configured to make. + * @returns The result of the fetch call. + */ +async function fetchExchangeRatesWithFakeTimers({ + clock, + fetchExchangeRates, + retries, +}: { + clock: sinon.SinonFakeTimers; + fetchExchangeRates: () => Promise; + retries: number; +}) { + const pendingUpdate = fetchExchangeRates(); + pendingUpdate.catch(() => { + // suppress Unhandled Promise error + }); + + // Advance timer enough to exceed max possible retry delay for initial call, and all + // subsequent retries + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries + 1).keys()) { + await clock.tickAsync(defaultMaxRetryDelay); + } + + return await pendingUpdate; +} diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts index cf93d67401f..1b96e9e1aac 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -12,6 +12,7 @@ import { hexToNumber } from '@metamask/utils'; import type { AbstractTokenPricesService, + ExchangeRatesByCurrency, TokenPrice, TokenPricesByTokenAddress, } from './abstract-token-prices-service'; @@ -275,6 +276,8 @@ type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]; */ const BASE_URL = 'https://price.api.cx.metamask.io/v2'; +const BASE_URL_V1 = 'https://price.api.cx.metamask.io/v1'; + /** * The shape of the data that the /spot-prices endpoint returns. */ @@ -529,6 +532,108 @@ export class CodefiTokenPricesServiceV2 ) as Partial>; } + /** + * Retrieves exchange rates in the given base currency. + * + * @param args - The arguments to this function. + * @param args.baseCurrency - The desired base currency of the exchange rates. + * @param args.includeUsdRate - Whether to include the USD rate in the response. + * @param args.cryptocurrencies - The cryptocurrencies to get exchange rates for. + * @returns The exchange rates for the requested base currency. + */ + async fetchExchangeRates({ + baseCurrency, + includeUsdRate, + cryptocurrencies, + }: { + baseCurrency: SupportedCurrency; + includeUsdRate: boolean; + cryptocurrencies: string[]; + }): Promise> { + const url = new URL(`${BASE_URL_V1}/exchange-rates`); + url.searchParams.append('baseCurrency', baseCurrency); + + const urlUsd = new URL(`${BASE_URL_V1}/exchange-rates`); + urlUsd.searchParams.append('baseCurrency', 'usd'); + + const [exchangeRatesResult, exchangeRatesResultUsd] = + await Promise.allSettled([ + this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ), + ...(includeUsdRate && baseCurrency.toLowerCase() !== 'usd' + ? [ + this.#policy.execute(() => + handleFetch(urlUsd, { + headers: { 'Cache-Control': 'no-cache' }, + }), + ), + ] + : []), + ]); + + // Handle resolved/rejected + const exchangeRates = + exchangeRatesResult.status === 'fulfilled' + ? exchangeRatesResult.value + : {}; + const exchangeRatesUsd = + exchangeRatesResultUsd?.status === 'fulfilled' + ? exchangeRatesResultUsd.value + : {}; + + if (exchangeRatesResult.status === 'rejected') { + throw new Error('Failed to fetch'); + } + + const filteredExchangeRates = cryptocurrencies.reduce((acc, key) => { + if (exchangeRates[key.toLowerCase() as SupportedCurrency]) { + acc[key.toLowerCase() as SupportedCurrency] = + exchangeRates[key.toLowerCase() as SupportedCurrency]; + } + return acc; + }, {} as ExchangeRatesByCurrency); + + if (Object.keys(filteredExchangeRates).length === 0) { + throw new Error( + 'None of the cryptocurrencies are supported by price api', + ); + } + + const filteredUsdExchangeRates = cryptocurrencies.reduce((acc, key) => { + if (exchangeRatesUsd[key.toLowerCase() as SupportedCurrency]) { + acc[key.toLowerCase() as SupportedCurrency] = + exchangeRatesUsd[key.toLowerCase() as SupportedCurrency]; + } + return acc; + }, {} as ExchangeRatesByCurrency); + + if (baseCurrency.toLowerCase() === 'usd') { + Object.keys(filteredExchangeRates).forEach((key) => { + filteredExchangeRates[key as SupportedCurrency] = { + ...filteredExchangeRates[key as SupportedCurrency], + usd: filteredExchangeRates[key as SupportedCurrency]?.value, + }; + }); + return filteredExchangeRates; + } + if (!includeUsdRate) { + return filteredExchangeRates; + } + + const merged = Object.keys(filteredExchangeRates).reduce((acc, key) => { + acc[key as SupportedCurrency] = { + ...filteredExchangeRates[key as SupportedCurrency], + ...(filteredUsdExchangeRates[key as SupportedCurrency]?.value + ? { usd: filteredUsdExchangeRates[key as SupportedCurrency]?.value } + : {}), + }; + return acc; + }, {} as ExchangeRatesByCurrency); + + return merged; + } + /** * Type guard for whether the API can return token prices for the given chain * ID.