From 87390767a913ada8e0af9c96507027e8a1bc43dc Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 12 Jan 2026 21:49:37 +0000 Subject: [PATCH 1/8] Cursor: Apply local changes for cloud agent --- .../src/CurrencyRateController.ts | 94 ++++++++++++------- 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index feeb95ced2c..eac10d5d800 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -186,6 +186,10 @@ export class CurrencyRateController extends StaticIntervalPollingController { const { currentCurrency } = this.state; + // Step 1: Try the Price API exchange rates first + const ratesPriceApi: CurrencyRateState['currencyRates'] = {}; + let failedCurrencies: Record = {}; + try { const priceApiExchangeRatesResponse = await this.#tokenPricesService.fetchExchangeRates({ @@ -196,39 +200,51 @@ export class CurrencyRateController extends StaticIntervalPollingController((acc, [nativeCurrency, fetchedCurrency]) => { - const rate = - priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; - - 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; + // Process the response and identify which currencies succeeded vs failed + Object.entries(nativeCurrenciesToFetch).forEach( + ([nativeCurrency, fetchedCurrency]) => { + const rate = + priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; + + if (rate?.value) { + // Successfully got a rate + ratesPriceApi[nativeCurrency] = { + conversionDate: Date.now() / 1000, + conversionRate: boundedPrecisionNumber(1 / rate.value), + usdConversionRate: rate?.usd + ? boundedPrecisionNumber(1 / rate.usd) + : null, + }; + } else { + // Failed to get a rate - mark for fallback + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + }, + ); } catch (error) { console.error('Failed to fetch exchange rates.', error); + // All currencies failed - they all need fallback + failedCurrencies = { ...nativeCurrenciesToFetch }; } - // fallback using spot price from token prices service + // Step 2: If all currencies succeeded, return early + if (Object.keys(failedCurrencies).length === 0) { + return ratesPriceApi; + } + + // Step 3: Fallback using spot price from token prices service for failed currencies + let ratesFromFallback: CurrencyRateState['currencyRates'] = {}; + try { - // Step 1: Get all network configurations to find matching chainIds for native currencies + // Get all network configurations to find matching chainIds for native currencies 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(s) for failed currencies only + const currencyToChainIds = Object.entries(failedCurrencies).reduce< Record >((acc, [nativeCurrency, fetchedCurrency]) => { // Find the first chainId that has this native currency @@ -250,7 +266,7 @@ export class CurrencyRateController extends StaticIntervalPollingController { @@ -277,6 +293,7 @@ export class CurrencyRateController extends StaticIntervalPollingController { const [nativeCurrency, { chainId }] = currencyToChainIdsEntries[index]; if (result.status === 'fulfilled') { @@ -294,8 +311,8 @@ export class CurrencyRateController extends StaticIntervalPollingController((acc, rate) => { acc[rate.nativeCurrency] = { @@ -309,25 +326,34 @@ export class CurrencyRateController extends StaticIntervalPollingController((acc, nativeCurrency) => { + } + + // Step 4: For any currencies that failed both approaches, set null state + const nullRatesForRemainingFailed = Object.keys(failedCurrencies).reduce< + CurrencyRateState['currencyRates'] + >((acc, nativeCurrency) => { + // Only add null state if not already handled by fallback + if (!ratesFromFallback[nativeCurrency]) { acc[nativeCurrency] = { conversionDate: null, conversionRate: null, usdConversionRate: null, }; - return acc; - }, {}); - } + } + return acc; + }, {}); + + // Step 5: Merge all results - Price API rates + Fallback rates + Null rates for remaining failures + return { + ...nullRatesForRemainingFailed, + ...ratesFromFallback, + ...ratesPriceApi, + }; } /** From bebd9be696ed89f002f211a5b58a7aa4b1d69c2f Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 12 Jan 2026 22:17:03 +0000 Subject: [PATCH 2/8] refactor(CurrencyRateController): clean up fallback logic and add tests - Refactor #fetchExchangeRatesWithFallback into smaller helper methods: - #fetchRatesFromPriceApi: handles primary Price API call - #fetchRatesFromTokenPricesService: handles fallback fetching - #createNullRatesForCurrencies: creates null entries for failed currencies - Rename private members to use hash syntax (fixing lint errors) - Add partial success handling: when some currencies succeed and others fail, only failed currencies trigger fallback - Add comprehensive tests for partial success scenarios - Update existing tests to match new expected behavior --- .../src/CurrencyRateController.test.ts | 472 +++++++++++++++++- .../src/CurrencyRateController.ts | 312 +++++++----- 2 files changed, 647 insertions(+), 137 deletions(-) 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 eac10d5d800..cc3045ad8a3 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 the primary Price API */ +type PriceApiResult = { + /** Successfully fetched rates */ + rates: CurrencyRateState['currencyRates']; + /** Currencies that failed and need fallback */ + 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,34 +193,33 @@ export class CurrencyRateController extends StaticIntervalPollingController, - ): Promise { - const { currentCurrency } = this.state; - - // Step 1: Try the Price API exchange rates first - const ratesPriceApi: CurrencyRateState['currencyRates'] = {}; + 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 response = await this.#tokenPricesService.fetchExchangeRates({ + baseCurrency: currentCurrency, + includeUsdRate: this.#includeUsdRate, + cryptocurrencies: [...new Set(Object.values(nativeCurrenciesToFetch))], + }); - // Process the response and identify which currencies succeeded vs failed Object.entries(nativeCurrenciesToFetch).forEach( ([nativeCurrency, fetchedCurrency]) => { - const rate = - priceApiExchangeRatesResponse[fetchedCurrency.toLowerCase()]; + const rate = response[fetchedCurrency.toLowerCase()]; if (rate?.value) { - // Successfully got a rate - ratesPriceApi[nativeCurrency] = { + rates[nativeCurrency] = { conversionDate: Date.now() / 1000, conversionRate: boundedPrecisionNumber(1 / rate.value), usdConversionRate: rate?.usd @@ -216,116 +227,162 @@ export class CurrencyRateController extends StaticIntervalPollingController, + currentCurrency: string, + ): Promise { + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + const networkConfigurations = + networkControllerState.networkConfigurationsByChainId; + + // Build a map of nativeCurrency -> chainId for failed currencies + const currencyToChainIds = Object.entries(failedCurrencies).reduce< + Record + >((acc, [nativeCurrency, fetchedCurrency]) => { + const matchingEntry = ( + Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] + ).find( + ([, config]) => + config.nativeCurrency.toUpperCase() === fetchedCurrency.toUpperCase(), ); - const networkConfigurations = - networkControllerState.networkConfigurationsByChainId; - - // Build a map of nativeCurrency -> chainId(s) for failed currencies only - const currencyToChainIds = Object.entries(failedCurrencies).reduce< - Record - >((acc, [nativeCurrency, fetchedCurrency]) => { - // Find the first chainId that has this native currency - const matchingEntry = ( - Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] - ).find( - ([, config]) => - config.nativeCurrency.toUpperCase() === - fetchedCurrency.toUpperCase(), + + if (matchingEntry) { + acc[nativeCurrency] = { fetchedCurrency, chainId: matchingEntry[0] }; + } + return acc; + }, {}); + + const currencyToChainIdsEntries = Object.entries(currencyToChainIds); + const ratesResults = await Promise.allSettled( + currencyToChainIdsEntries.map(async ([nativeCurrency, { chainId }]) => { + const nativeTokenAddress = getNativeTokenAddress(chainId); + const tokenPrices = await this.#tokenPricesService.fetchTokenPrices({ + assets: [{ chainId, tokenAddress: nativeTokenAddress }], + currency: currentCurrency, + }); + + const tokenPrice = tokenPrices.find( + (item) => + item.tokenAddress.toLowerCase() === + nativeTokenAddress.toLowerCase(), ); - if (matchingEntry) { + return { + nativeCurrency, + conversionDate: tokenPrice ? Date.now() / 1000 : null, + conversionRate: tokenPrice?.price + ? boundedPrecisionNumber(tokenPrice.price) + : null, + usdConversionRate: null, + }; + }), + ); + + return ratesResults.reduce( + (acc, result, index) => { + const [nativeCurrency, { chainId }] = currencyToChainIdsEntries[index]; + + if (result.status === 'fulfilled') { + acc[nativeCurrency] = { + conversionDate: result.value.conversionDate, + conversionRate: result.value.conversionRate, + usdConversionRate: result.value.usdConversionRate, + }; + } else { + console.error( + `Failed to fetch token price for ${nativeCurrency} on chain ${chainId}`, + result.reason, + ); acc[nativeCurrency] = { - fetchedCurrency, - chainId: matchingEntry[0], + conversionDate: null, + conversionRate: null, + usdConversionRate: null, }; } + return acc; + }, + {}, + ); + } + /** + * Creates null rate entries for currencies that couldn't be fetched. + * + * @param currencies - Array of currency symbols to create null entries for. + * @param existingRates - Rates that were already successfully fetched (to avoid overwriting). + * @returns Null rate entries for currencies not in existingRates. + */ + #createNullRatesForCurrencies( + currencies: string[], + existingRates: CurrencyRateState['currencyRates'], + ): CurrencyRateState['currencyRates'] { + return currencies.reduce( + (acc, nativeCurrency) => { + if (!existingRates[nativeCurrency]) { + acc[nativeCurrency] = { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }; + } return acc; - }, {}); + }, + {}, + ); + } - // Fetch token prices for each chainId - const currencyToChainIdsEntries = Object.entries(currencyToChainIds); - const ratesResults = await Promise.allSettled( - currencyToChainIdsEntries.map(async ([nativeCurrency, { chainId }]) => { - 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, - }); - - const tokenPrice = tokenPrices.find( - (item) => - item.tokenAddress.toLowerCase() === - nativeTokenAddress.toLowerCase(), - ); + /** + * 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; - return { - nativeCurrency, - conversionDate: tokenPrice ? Date.now() / 1000 : null, - conversionRate: tokenPrice?.price - ? boundedPrecisionNumber(tokenPrice.price) - : null, - usdConversionRate: null, // Token prices service doesn't provide USD rate in this context - }; - }), + // Step 1: Try the Price API exchange rates first + const { rates: ratesPriceApi, failedCurrencies } = + await this.#fetchRatesFromPriceApi( + nativeCurrenciesToFetch, + currentCurrency, ); - const ratesFromTokenPrices = ratesResults.map((result, index) => { - const [nativeCurrency, { chainId }] = currencyToChainIdsEntries[index]; - if (result.status === 'fulfilled') { - return result.value; - } - console.error( - `Failed to fetch token price for ${nativeCurrency} on chain ${chainId}`, - result.reason, - ); - return { - nativeCurrency, - conversionDate: null, - conversionRate: null, - usdConversionRate: null, - }; - }); + // Step 2: If all currencies succeeded, return early + if (Object.keys(failedCurrencies).length === 0) { + return ratesPriceApi; + } - // Convert to the expected format - ratesFromFallback = 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; - }, {}); + // Step 3: Fallback using token prices service for failed currencies + let ratesFromFallback: CurrencyRateState['currencyRates'] = {}; + try { + ratesFromFallback = await this.#fetchRatesFromTokenPricesService( + failedCurrencies, + currentCurrency, + ); } catch (error) { console.error( 'Failed to fetch exchange rates from token prices service.', @@ -333,24 +390,15 @@ export class CurrencyRateController extends StaticIntervalPollingController((acc, nativeCurrency) => { - // Only add null state if not already handled by fallback - if (!ratesFromFallback[nativeCurrency]) { - acc[nativeCurrency] = { - conversionDate: null, - conversionRate: null, - usdConversionRate: null, - }; - } - return acc; - }, {}); + // Step 4: Create null rates for any currencies that failed both approaches + const nullRates = this.#createNullRatesForCurrencies( + Object.keys(failedCurrencies), + ratesFromFallback, + ); - // Step 5: Merge all results - Price API rates + Fallback rates + Null rates for remaining failures + // Step 5: Merge all results - Price API rates take priority, then fallback, then null rates return { - ...nullRatesForRemainingFailed, + ...nullRates, ...ratesFromFallback, ...ratesPriceApi, }; @@ -364,11 +412,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. @@ -409,7 +457,7 @@ export class CurrencyRateController extends StaticIntervalPollingController Date: Mon, 12 Jan 2026 22:27:30 +0000 Subject: [PATCH 3/8] chore: prune unused eslint suppressions for CurrencyRateController Removed unused suppressions after refactoring: - no-negated-condition: no longer needed - no-restricted-syntax: no longer needed - @typescript-eslint/explicit-function-return-type: reduced count from 3 to 1 --- eslint-suppressions.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 9fafda276e0..5621c1e1fed 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": { From 236f510549521041f422b0370ecd8e33e00736f4 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 12 Jan 2026 22:38:43 +0000 Subject: [PATCH 4/8] refactor: improve #fetchRatesFromTokenPricesService to return rates and failedCurrencies - Updated #fetchRatesFromTokenPricesService to return { rates, failedCurrencies } similar to #fetchRatesFromPriceApi for consistency - Removed null currency creation from fallback method - failures now add to failedCurrencies instead of creating null rates inline - Step 4 now takes the final list of failed currencies from step 3 to build null rates, simplifying the logic - Simplified #createNullRatesForCurrencies to only take currencies array - Renamed PriceApiResult type to FetchRatesResult for broader applicability --- .../src/CurrencyRateController.ts | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index cc3045ad8a3..b489454ef79 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -107,11 +107,11 @@ const boundedPrecisionNumber = (value: number, precision = 9): number => * 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 the primary Price API */ -type PriceApiResult = { +/** Result from attempting to fetch rates from an API */ +type FetchRatesResult = { /** Successfully fetched rates */ rates: CurrencyRateState['currencyRates']; - /** Currencies that failed and need fallback */ + /** Currencies that failed and need fallback or null state */ failedCurrencies: Record; }; @@ -203,7 +203,7 @@ export class CurrencyRateController extends StaticIntervalPollingController, currentCurrency: string, - ): Promise { + ): Promise { const rates: CurrencyRateState['currencyRates'] = {}; let failedCurrencies: Record = {}; @@ -242,22 +242,25 @@ export class CurrencyRateController extends StaticIntervalPollingController, + currenciesToFetch: Record, currentCurrency: string, - ): Promise { + ): Promise { + const rates: CurrencyRateState['currencyRates'] = {}; + const failedCurrencies: Record = {}; + const networkControllerState = this.messenger.call( 'NetworkController:getState', ); const networkConfigurations = networkControllerState.networkConfigurationsByChainId; - // Build a map of nativeCurrency -> chainId for failed currencies - const currencyToChainIds = Object.entries(failedCurrencies).reduce< + // Build a map of nativeCurrency -> chainId for currencies to fetch + const currencyToChainIds = Object.entries(currenciesToFetch).reduce< Record >((acc, [nativeCurrency, fetchedCurrency]) => { const matchingEntry = ( @@ -269,6 +272,9 @@ export class CurrencyRateController extends StaticIntervalPollingController( - (acc, result, index) => { - const [nativeCurrency, { chainId }] = currencyToChainIdsEntries[index]; - - if (result.status === 'fulfilled') { - acc[nativeCurrency] = { - conversionDate: result.value.conversionDate, - conversionRate: result.value.conversionRate, - usdConversionRate: result.value.usdConversionRate, - }; - } else { + 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, ); - acc[nativeCurrency] = { - conversionDate: null, - conversionRate: null, - usdConversionRate: null, - }; } - return acc; - }, - {}, - ); + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + }); + + return { rates, failedCurrencies }; } /** * Creates null rate entries for currencies that couldn't be fetched. * * @param currencies - Array of currency symbols to create null entries for. - * @param existingRates - Rates that were already successfully fetched (to avoid overwriting). - * @returns Null rate entries for currencies not in existingRates. + * @returns Null rate entries for all provided currencies. */ #createNullRatesForCurrencies( currencies: string[], - existingRates: CurrencyRateState['currencyRates'], ): CurrencyRateState['currencyRates'] { return currencies.reduce( (acc, nativeCurrency) => { - if (!existingRates[nativeCurrency]) { - acc[nativeCurrency] = { - conversionDate: null, - conversionRate: null, - usdConversionRate: null, - }; - } + acc[nativeCurrency] = { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }; return acc; }, {}, @@ -365,24 +364,32 @@ export class CurrencyRateController extends StaticIntervalPollingController = { + ...failedCurrenciesFromPriceApi, + }; + try { - ratesFromFallback = await this.#fetchRatesFromTokenPricesService( - failedCurrencies, + const fallbackResult = await this.#fetchRatesFromTokenPricesService( + failedCurrenciesFromPriceApi, currentCurrency, ); + ratesFromFallback = fallbackResult.rates; + failedCurrenciesFromFallback = fallbackResult.failedCurrencies; } catch (error) { console.error( 'Failed to fetch exchange rates from token prices service.', @@ -390,10 +397,9 @@ export class CurrencyRateController extends StaticIntervalPollingController Date: Mon, 12 Jan 2026 22:44:24 +0000 Subject: [PATCH 5/8] refactor: move try-catch inside #fetchRatesFromTokenPricesService - Wrapped the entire method body in try-catch so it never throws - On any unexpected error, returns all currencies as failed - Updated JSDoc to document that the method is designed to never throw - This allows #fetchExchangeRatesWithFallback to use const instead of let and removes the external try-catch, simplifying the orchestration logic --- .../src/CurrencyRateController.ts | 175 +++++++++--------- 1 file changed, 88 insertions(+), 87 deletions(-) diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index b489454ef79..d6266083c4c 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -241,6 +241,8 @@ export class CurrencyRateController extends StaticIntervalPollingController, currentCurrency: string, ): Promise { - const rates: CurrencyRateState['currencyRates'] = {}; - const failedCurrencies: Record = {}; + try { + const rates: CurrencyRateState['currencyRates'] = {}; + const failedCurrencies: Record = {}; - const networkControllerState = this.messenger.call( - 'NetworkController:getState', - ); - const networkConfigurations = - networkControllerState.networkConfigurationsByChainId; - - // Build a map of nativeCurrency -> chainId for currencies to fetch - const currencyToChainIds = Object.entries(currenciesToFetch).reduce< - Record - >((acc, [nativeCurrency, fetchedCurrency]) => { - const matchingEntry = ( - Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] - ).find( - ([, config]) => - config.nativeCurrency.toUpperCase() === fetchedCurrency.toUpperCase(), + const networkControllerState = this.messenger.call( + 'NetworkController:getState', ); - - if (matchingEntry) { - acc[nativeCurrency] = { fetchedCurrency, chainId: matchingEntry[0] }; - } else { - // No matching network configuration - mark as failed - failedCurrencies[nativeCurrency] = fetchedCurrency; - } - return acc; - }, {}); - - const currencyToChainIdsEntries = Object.entries(currencyToChainIds); - const ratesResults = await Promise.allSettled( - currencyToChainIdsEntries.map(async ([nativeCurrency, { chainId }]) => { - const nativeTokenAddress = getNativeTokenAddress(chainId); - const tokenPrices = await this.#tokenPricesService.fetchTokenPrices({ - assets: [{ chainId, tokenAddress: nativeTokenAddress }], - currency: currentCurrency, - }); - - const tokenPrice = tokenPrices.find( - (item) => - item.tokenAddress.toLowerCase() === - nativeTokenAddress.toLowerCase(), + const networkConfigurations = + networkControllerState.networkConfigurationsByChainId; + + // Build a map of nativeCurrency -> chainId for currencies to fetch + const currencyToChainIds = Object.entries(currenciesToFetch).reduce< + Record + >((acc, [nativeCurrency, fetchedCurrency]) => { + const matchingEntry = ( + Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] + ).find( + ([, config]) => + config.nativeCurrency.toUpperCase() === + fetchedCurrency.toUpperCase(), ); - return { - nativeCurrency, - conversionDate: tokenPrice ? Date.now() / 1000 : null, - conversionRate: tokenPrice?.price - ? boundedPrecisionNumber(tokenPrice.price) - : null, - usdConversionRate: null, - }; - }), - ); - - ratesResults.forEach((result, index) => { - const [nativeCurrency, { fetchedCurrency, chainId }] = - currencyToChainIdsEntries[index]; + if (matchingEntry) { + acc[nativeCurrency] = { fetchedCurrency, chainId: matchingEntry[0] }; + } else { + // No matching network configuration - mark as failed + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + return acc; + }, {}); - 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, + const currencyToChainIdsEntries = Object.entries(currencyToChainIds); + const ratesResults = await Promise.allSettled( + currencyToChainIdsEntries.map(async ([nativeCurrency, { chainId }]) => { + const nativeTokenAddress = getNativeTokenAddress(chainId); + const tokenPrices = await this.#tokenPricesService.fetchTokenPrices({ + assets: [{ chainId, tokenAddress: nativeTokenAddress }], + currency: currentCurrency, + }); + + const tokenPrice = tokenPrices.find( + (item) => + item.tokenAddress.toLowerCase() === + nativeTokenAddress.toLowerCase(), ); + + return { + nativeCurrency, + conversionDate: tokenPrice ? Date.now() / 1000 : null, + conversionRate: tokenPrice?.price + ? boundedPrecisionNumber(tokenPrice.price) + : null, + usdConversionRate: null, + }; + }), + ); + + 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; } - failedCurrencies[nativeCurrency] = fetchedCurrency; - } - }); + }); - return { rates, failedCurrencies }; + return { rates, failedCurrencies }; + } catch (error) { + console.error( + 'Failed to fetch exchange rates from token prices service.', + error, + ); + // Return all currencies as failed + return { rates: {}, failedCurrencies: { ...currenciesToFetch } }; + } } /** @@ -378,24 +390,13 @@ export class CurrencyRateController extends StaticIntervalPollingController = { - ...failedCurrenciesFromPriceApi, - }; - - try { - const fallbackResult = await this.#fetchRatesFromTokenPricesService( - failedCurrenciesFromPriceApi, - currentCurrency, - ); - ratesFromFallback = fallbackResult.rates; - failedCurrenciesFromFallback = fallbackResult.failedCurrencies; - } catch (error) { - console.error( - 'Failed to fetch exchange rates from token prices service.', - error, - ); - } + 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( From 1561366cc251d1169bb0d4dcb13e45bef662850a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 22:47:34 +0000 Subject: [PATCH 6/8] Checkpoint before follow-up message Co-authored-by: prithpal.sooriya --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5621c1e1fed..7888ee08012 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2095,4 +2095,4 @@ "count": 1 } } -} +} \ No newline at end of file From 2a0fd88cb770fa07c664f28d52c2809b44de2975 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 12 Jan 2026 22:50:21 +0000 Subject: [PATCH 7/8] docs: add changelog entry for CurrencyRateController refactoring --- packages/assets-controllers/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index a32d81ce035..4018b63901a 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/transaction-controller` from `^62.8.0` to `^62.9.0` ([#7602](https://github.com/MetaMask/core/pull/7602)) +- 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] From f9ff877c335cfcfd1e37388dee2e7e38017a5eea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 22:53:46 +0000 Subject: [PATCH 8/8] style: add missing trailing newline to eslint-suppressions.json --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7888ee08012..5621c1e1fed 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2095,4 +2095,4 @@ "count": 1 } } -} \ No newline at end of file +}