diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ae7f8179a39..bc4c7642384 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -187,13 +187,7 @@ }, "packages/assets-controllers/src/CurrencyRateController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 3 - }, - "no-negated-condition": { "count": 1 - }, - "no-restricted-syntax": { - "count": 3 } }, "packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 6c6bca3fc57..566e98bc19f 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/polling-controller` from `^16.0.0` to `^16.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) - Update Plasma (0x2611) mapping to eip155:9745/erc20:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for XPL ([#7601](https://github.com/MetaMask/core/pull/7601)) - `TokensController.watchAsset` now supports optional origin/page metadata and safely falls back for empty origins to avoid rejected approvals ([#7612](https://github.com/MetaMask/core/pull/7612)) +- Refactor `CurrencyRateController` exchange rate fetching with improved fallback logic ([#7606](https://github.com/MetaMask/core/pull/7606)) + - Improved partial success handling: when some currencies succeed via the Price API and others fail, only failed currencies trigger the fallback mechanism + - Extracted helper methods for better code organization and testability ## [95.1.0] diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index 1a5cfea2e80..7a05b7601e3 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -780,12 +780,13 @@ describe('CurrencyRateController', () => { await controller.updateExchangeRate(nativeCurrencies); - const conversionDate = getStubbedDate() / 1000; + // With new fallback logic, ETH with value: 0 triggers fallback attempt + // When fallback also fails (no NetworkController:getState handler), null state is returned expect(controller.state).toStrictEqual({ currentCurrency: 'xyz', currencyRates: { ETH: { - conversionDate, + conversionDate: null, conversionRate: null, usdConversionRate: null, }, @@ -1468,7 +1469,7 @@ describe('CurrencyRateController', () => { controller.destroy(); }); - it('should skip currencies not found in network configurations (lines 252-257)', async () => { + it('should set null state for currencies not found in network configurations (lines 252-257)', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); const messenger = getCurrencyRateControllerMessengerWithNetworkState({ @@ -1499,7 +1500,6 @@ describe('CurrencyRateController', () => { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', chainId: assets[0].chainId, - assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1554,7 +1554,12 @@ describe('CurrencyRateController', () => { conversionRate: 2500.5, usdConversionRate: null, }, - // BNB should not be included as it's not in network configurations + // BNB has null state because it couldn't be found in network configurations + BNB: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, }, }); @@ -1634,6 +1639,463 @@ describe('CurrencyRateController', () => { }); }); + describe('partial success with fallback', () => { + it('should fallback only for currencies that failed in Price API response (partial success)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns ETH but not POL (partial success) + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + usd: 1 / 2500, + }, + // POL is missing - should trigger fallback + }); + + const fetchTokenPricesSpy = jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockResolvedValue([ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + price: 0.75, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + // Should only call fetchTokenPrices for POL (ETH succeeded) + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ + assets: [ + { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010', + }, + ], + currency: 'usd', + }); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: 2500, + }, + POL: { + conversionDate, + conversionRate: 0.75, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('should not call fallback when all currencies succeed from Price API', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns both ETH and POL (full success) + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + usd: 1 / 2500, + }, + pol: { + name: 'Polygon', + ticker: 'pol', + value: 1 / 0.8, + currencyType: 'crypto', + usd: 1 / 1, + }, + }); + + const fetchTokenPricesSpy = jest.spyOn( + tokenPricesService, + 'fetchTokenPrices', + ); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + // Should NOT call fetchTokenPrices since all currencies succeeded + expect(fetchTokenPricesSpy).not.toHaveBeenCalled(); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: 2500, + }, + POL: { + conversionDate, + conversionRate: 0.8, + usdConversionRate: 1, + }, + }, + }); + + controller.destroy(); + }); + + it('should preserve successful Price API rates even when fallback fails', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns ETH but not POL + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + usd: 1 / 2500, + }, + }); + + // Fallback also fails for POL + jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockRejectedValue(new Error('Token prices service failed')); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + // ETH should still have valid data from Price API + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: 2500, + }, + // POL should have null values since both approaches failed + POL: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle multiple partial failures with mixed fallback results', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x38': { + chainId: '0x38', + nativeCurrency: 'BNB', + name: 'BSC', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns only ETH + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + }, + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Fallback succeeds for POL but fails for BNB + jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x89')) { + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + price: 0.75, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + throw new Error('Token prices service failed for BNB'); + }); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL', 'BNB']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + // ETH from Price API + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: null, + }, + // POL from fallback + POL: { + conversionDate, + conversionRate: 0.75, + usdConversionRate: null, + }, + // BNB failed both approaches + BNB: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle Price API returning rate with no value (undefined rate)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns ETH but with value: 0 (falsy) + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0, // Falsy value should trigger fallback + currencyType: 'crypto', + }, + }); + + jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue([ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + price: 1800, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 1800, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const tokenPricesService = buildMockTokenPricesService(); diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index feeb95ced2c..d6266083c4c 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -103,6 +103,18 @@ type CurrencyRatePollingInput = { const boundedPrecisionNumber = (value: number, precision = 9): number => Number(value.toFixed(precision)); +/** + * Controller that passively polls on a set interval for an exchange rate from the current network + * asset to the user's preferred currency. + */ +/** Result from attempting to fetch rates from an API */ +type FetchRatesResult = { + /** Successfully fetched rates */ + rates: CurrencyRateState['currencyRates']; + /** Currencies that failed and need fallback or null state */ + failedCurrencies: Record; +}; + /** * Controller that passively polls on a set interval for an exchange rate from the current network * asset to the user's preferred currency. @@ -112,11 +124,11 @@ export class CurrencyRateController extends StaticIntervalPollingController { - private readonly mutex = new Mutex(); + readonly #mutex = new Mutex(); - private readonly includeUsdRate; + readonly #includeUsdRate: boolean; - private readonly useExternalServices: () => boolean; + readonly #useExternalServices: () => boolean; readonly #tokenPricesService: AbstractTokenPricesService; @@ -152,8 +164,8 @@ export class CurrencyRateController extends StaticIntervalPollingController { + const releaseLock = await this.#mutex.acquire(); const nativeCurrencies = Object.keys(this.state.currencyRates); try { this.update(() => { @@ -181,57 +193,79 @@ export class CurrencyRateController extends StaticIntervalPollingController, - ): Promise { - const { currentCurrency } = this.state; + currentCurrency: string, + ): Promise { + const rates: CurrencyRateState['currencyRates'] = {}; + let failedCurrencies: Record = {}; try { - const priceApiExchangeRatesResponse = - await this.#tokenPricesService.fetchExchangeRates({ - baseCurrency: currentCurrency, - includeUsdRate: this.includeUsdRate, - cryptocurrencies: [ - ...new Set(Object.values(nativeCurrenciesToFetch)), - ], - }); - - const ratesPriceApi = Object.entries(nativeCurrenciesToFetch).reduce< - CurrencyRateState['currencyRates'] - >((acc, [nativeCurrency, fetchedCurrency]) => { - const rate = - priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; + const response = await this.#tokenPricesService.fetchExchangeRates({ + baseCurrency: currentCurrency, + includeUsdRate: this.#includeUsdRate, + cryptocurrencies: [...new Set(Object.values(nativeCurrenciesToFetch))], + }); - acc[nativeCurrency] = { - conversionDate: rate !== undefined ? Date.now() / 1000 : null, - conversionRate: rate?.value - ? boundedPrecisionNumber(1 / rate.value) - : null, - usdConversionRate: rate?.usd - ? boundedPrecisionNumber(1 / rate.usd) - : null, - }; - return acc; - }, {}); - return ratesPriceApi; + Object.entries(nativeCurrenciesToFetch).forEach( + ([nativeCurrency, fetchedCurrency]) => { + const rate = response[fetchedCurrency.toLowerCase()]; + + if (rate?.value) { + rates[nativeCurrency] = { + conversionDate: Date.now() / 1000, + conversionRate: boundedPrecisionNumber(1 / rate.value), + usdConversionRate: rate?.usd + ? boundedPrecisionNumber(1 / rate.usd) + : null, + }; + } else { + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + }, + ); } catch (error) { console.error('Failed to fetch exchange rates.', error); + failedCurrencies = { ...nativeCurrenciesToFetch }; } - // fallback using spot price from token prices service + return { rates, failedCurrencies }; + } + + /** + * Fetches exchange rates from the token prices service as a fallback. + * This method is designed to never throw - all errors are handled internally + * and result in currencies being marked as failed. + * + * @param currenciesToFetch - Map of native currencies that need fallback fetching. + * @param currentCurrency - The current fiat currency to get rates for. + * @returns Object containing successful rates and currencies that failed. + */ + async #fetchRatesFromTokenPricesService( + currenciesToFetch: Record, + currentCurrency: string, + ): Promise { try { - // Step 1: Get all network configurations to find matching chainIds for native currencies + const rates: CurrencyRateState['currencyRates'] = {}; + const failedCurrencies: Record = {}; + const networkControllerState = this.messenger.call( 'NetworkController:getState', ); const networkConfigurations = networkControllerState.networkConfigurationsByChainId; - // Step 2: Build a map of nativeCurrency -> chainId(s) - const currencyToChainIds = Object.entries(nativeCurrenciesToFetch).reduce< + // Build a map of nativeCurrency -> chainId for currencies to fetch + const currencyToChainIds = Object.entries(currenciesToFetch).reduce< Record >((acc, [nativeCurrency, fetchedCurrency]) => { - // Find the first chainId that has this native currency const matchingEntry = ( Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] ).find( @@ -241,21 +275,18 @@ export class CurrencyRateController extends StaticIntervalPollingController { const nativeTokenAddress = getNativeTokenAddress(chainId); - // Pass empty array as fetchTokenPrices automatically includes the native token address const tokenPrices = await this.#tokenPricesService.fetchTokenPrices({ assets: [{ chainId, tokenAddress: nativeTokenAddress }], currency: currentCurrency, @@ -273,61 +304,111 @@ export class CurrencyRateController extends StaticIntervalPollingController { - const [nativeCurrency, { chainId }] = currencyToChainIdsEntries[index]; - if (result.status === 'fulfilled') { - return result.value; + + ratesResults.forEach((result, index) => { + const [nativeCurrency, { fetchedCurrency, chainId }] = + currencyToChainIdsEntries[index]; + + if (result.status === 'fulfilled' && result.value.conversionRate) { + rates[nativeCurrency] = { + conversionDate: result.value.conversionDate, + conversionRate: result.value.conversionRate, + usdConversionRate: result.value.usdConversionRate, + }; + } else { + if (result.status === 'rejected') { + console.error( + `Failed to fetch token price for ${nativeCurrency} on chain ${chainId}`, + result.reason, + ); + } + failedCurrencies[nativeCurrency] = fetchedCurrency; } - console.error( - `Failed to fetch token price for ${nativeCurrency} on chain ${chainId}`, - result.reason, - ); - return { - nativeCurrency, - conversionDate: null, - conversionRate: null, - usdConversionRate: null, - }; }); - // Step 4: Convert to the expected format - const ratesFromTokenPricesService = ratesFromTokenPrices.reduce< - CurrencyRateState['currencyRates'] - >((acc, rate) => { - acc[rate.nativeCurrency] = { - conversionDate: rate.conversionDate, - conversionRate: rate.conversionRate - ? boundedPrecisionNumber(rate.conversionRate) - : null, - usdConversionRate: rate.usdConversionRate - ? boundedPrecisionNumber(rate.usdConversionRate) - : null, - }; - return acc; - }, {}); - - return ratesFromTokenPricesService; + return { rates, failedCurrencies }; } catch (error) { console.error( 'Failed to fetch exchange rates from token prices service.', error, ); - // Return null state for all requested currencies - return Object.keys(nativeCurrenciesToFetch).reduce< - CurrencyRateState['currencyRates'] - >((acc, nativeCurrency) => { + // Return all currencies as failed + return { rates: {}, failedCurrencies: { ...currenciesToFetch } }; + } + } + + /** + * Creates null rate entries for currencies that couldn't be fetched. + * + * @param currencies - Array of currency symbols to create null entries for. + * @returns Null rate entries for all provided currencies. + */ + #createNullRatesForCurrencies( + currencies: string[], + ): CurrencyRateState['currencyRates'] { + return currencies.reduce( + (acc, nativeCurrency) => { acc[nativeCurrency] = { conversionDate: null, conversionRate: null, usdConversionRate: null, }; return acc; - }, {}); + }, + {}, + ); + } + + /** + * Fetches exchange rates with fallback logic. + * First tries the Price API, then falls back to token prices service for any failed currencies. + * + * @param nativeCurrenciesToFetch - Map of native currency to the currency symbol to fetch. + * @returns Exchange rates for all requested currencies. + */ + async #fetchExchangeRatesWithFallback( + nativeCurrenciesToFetch: Record, + ): Promise { + const { currentCurrency } = this.state; + + // Step 1: Try the Price API exchange rates first + const { + rates: ratesPriceApi, + failedCurrencies: failedCurrenciesFromPriceApi, + } = await this.#fetchRatesFromPriceApi( + nativeCurrenciesToFetch, + currentCurrency, + ); + + // Step 2: If all currencies succeeded, return early + if (Object.keys(failedCurrenciesFromPriceApi).length === 0) { + return ratesPriceApi; } + + // Step 3: Fallback using token prices service for failed currencies + const { + rates: ratesFromFallback, + failedCurrencies: failedCurrenciesFromFallback, + } = await this.#fetchRatesFromTokenPricesService( + failedCurrenciesFromPriceApi, + currentCurrency, + ); + + // Step 4: Create null rates for currencies that failed both approaches + const nullRates = this.#createNullRatesForCurrencies( + Object.keys(failedCurrenciesFromFallback), + ); + + // Step 5: Merge all results - Price API rates take priority, then fallback, then null rates + return { + ...nullRates, + ...ratesFromFallback, + ...ratesPriceApi, + }; } /** @@ -338,11 +419,11 @@ export class CurrencyRateController extends StaticIntervalPollingController { - if (!this.useExternalServices()) { + if (!this.#useExternalServices()) { return; } - const releaseLock = await this.mutex.acquire(); + const releaseLock = await this.#mutex.acquire(); try { // For preloaded testnets (Goerli, Sepolia) we want to fetch exchange rate for real ETH. // Map each native currency to the symbol we want to fetch for it. @@ -383,7 +464,7 @@ export class CurrencyRateController extends StaticIntervalPollingController