Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Migrate TokenRatesController to use multichain v3 Price API endpoint with SLIP44 support for native tokens ([#7033](https://github.com/MetaMask/core/pull/7033))

## [86.0.0]

### Changed
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controllers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@metamask/metamask-eth-abis": "^3.1.1",
"@metamask/polling-controller": "^15.0.0",
"@metamask/rpc-errors": "^7.0.2",
"@metamask/slip44": "^4.3.0",
"@metamask/snaps-sdk": "^9.0.0",
"@metamask/snaps-utils": "^11.0.0",
"@metamask/utils": "^11.8.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ function buildMockTokenPricesService(
async fetchExchangeRates() {
return {};
},
async fetchMultichainTokenPrices() {
return {};
},
validateChainIdSupported(_chainId: unknown): _chainId is Hex {
return true;
},
Expand Down
178 changes: 98 additions & 80 deletions packages/assets-controllers/src/TokenRatesController.test.ts

Large diffs are not rendered by default.

182 changes: 123 additions & 59 deletions packages/assets-controllers/src/TokenRatesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ import type {
NetworkControllerStateChangeEvent,
} from '@metamask/network-controller';
import { StaticIntervalPollingController } from '@metamask/polling-controller';
import { createDeferredPromise, type Hex } from '@metamask/utils';
import type { Hex } from '@metamask/utils';
import { isEqual } from 'lodash';

import { reduceInBatchesSerially, TOKEN_PRICES_BATCH_SIZE } from './assetsUtil';
import { fetchExchangeRate as fetchNativeCurrencyExchangeRate } from './crypto-compare-service';
import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service';
import { getNativeTokenAddress } from './token-prices-service/codefi-v2';
import type { SupportedCurrency } from './token-prices-service/codefi-v2';
import type {
TokensControllerGetStateAction,
TokensControllerStateChangeEvent,
Expand Down Expand Up @@ -242,7 +243,6 @@ export class TokenRatesController extends StaticIntervalPollingController<TokenR

readonly #tokenPricesService: AbstractTokenPricesService;

#inProcessExchangeRateUpdates: Record<`${Hex}:${string}`, Promise<void>> = {};

#disabled: boolean;

Expand Down Expand Up @@ -522,73 +522,137 @@ export class TokenRatesController extends StaticIntervalPollingController<TokenR
return;
}

// Create a promise for each chainId to fetch exchange rates.
const updatePromises = chainIdAndNativeCurrency.map(
async ({ chainId, nativeCurrency }) => {
const tokenAddresses = this.#getTokenAddresses(chainId);
// Build a unique key based on chainId, nativeCurrency, and the number of token addresses.
const updateKey: `${Hex}:${string}` = `${chainId}:${nativeCurrency}:${tokenAddresses.length}`;

if (updateKey in this.#inProcessExchangeRateUpdates) {
// Await any ongoing update to avoid redundant work.
await this.#inProcessExchangeRateUpdates[updateKey];
return null;
}
// Use multichain approach only - no fallback
const chainAndTokenRequests = chainIdAndNativeCurrency.map(
({ chainId, nativeCurrency }) => ({
chainId,
tokenAddresses: this.#getTokenAddresses(chainId),
nativeCurrency,
}),
);

// Create a deferred promise to track this update.
const {
promise: inProgressUpdate,
resolve: updateSucceeded,
reject: updateFailed,
} = createDeferredPromise({ suppressUnhandledRejection: true });
this.#inProcessExchangeRateUpdates[updateKey] = inProgressUpdate;

try {
const contractInformations = await this.#fetchAndMapExchangeRates({
tokenAddresses,
chainId,
nativeCurrency,
});
const multichainResults = await this.#fetchMultichainExchangeRates({
chainAndTokenRequests,
});

// Each promise returns an object with the market data for the chain.
const marketData = {
[chainId]: {
...(contractInformations ?? {}),
},
// Update with multichain results
if (Object.keys(multichainResults).length > 0) {
this.update((state) => {
for (const [chainId, contractExchangeRates] of Object.entries(
multichainResults,
)) {
const chainIdHex = chainId as Hex;
if (!state.marketData[chainIdHex]) {
state.marketData[chainIdHex] = {};
}
state.marketData[chainIdHex] = {
...state.marketData[chainIdHex],
...contractExchangeRates,
};
}
});
}
}

updateSucceeded();
return marketData;
} catch (error: unknown) {
updateFailed(error);
throw error;
} finally {
// Cleanup the tracking for this update.
delete this.#inProcessExchangeRateUpdates[updateKey];
/**
* Uses the multichain token prices service to retrieve exchange rates for tokens
* across multiple chains in a single request. This is more efficient than making
* multiple single-chain requests.
*
* @param args - The arguments to this function.
* @param args.chainAndTokenRequests - Array of objects containing chainId, tokenAddresses, and nativeCurrency.
* @returns A map from chain ID to token addresses to their prices.
*/
async #fetchMultichainExchangeRates({
chainAndTokenRequests,
}: {
chainAndTokenRequests: {
chainId: Hex;
tokenAddresses: Hex[];
nativeCurrency: string;
}[];
}): Promise<Record<Hex, ContractMarketData>> {
// fetchMultichainTokenPrices is now required, no need to check

// Helper function to parse CAIP asset IDs back to chain ID and token address
const parseCaipAssetId = (caipAssetId: string): { chainId: Hex; tokenAddress: Hex } | null => {
try {
// Handle SLIP44 format: eip155:1/slip44:60
if (caipAssetId.includes('/slip44:')) {
const [namespaceChain, slip44Part] = caipAssetId.split('/slip44:');
const [namespace, chainIdStr] = namespaceChain.split(':');
if (namespace === 'eip155' && chainIdStr) {
const chainId = `0x${parseInt(chainIdStr, 10).toString(16)}` as Hex;
const nativeTokenAddress = getNativeTokenAddress(chainId);
return { chainId, tokenAddress: nativeTokenAddress };
}
} else {
// Handle regular format: eip155:1:0x...
const [namespace, chainIdStr, tokenAddress] = caipAssetId.split(':');
if (namespace === 'eip155' && chainIdStr && tokenAddress) {
const chainId = `0x${parseInt(chainIdStr, 10).toString(16)}` as Hex;
return { chainId, tokenAddress: tokenAddress as Hex };
}
}
},
);
} catch (error) {
console.error(`Failed to parse CAIP asset ID: ${caipAssetId}`, error);
}
return null;
};

// Group requests by currency to batch them efficiently
const requestsByCurrency: Record<
string,
{ chainId: Hex; tokenAddresses: Hex[] }[]
> = {};
const chainToCurrency: Record<Hex, string> = {};

// Wait for all update promises to settle.
const results = await Promise.allSettled(updatePromises);
for (const request of chainAndTokenRequests) {
const { chainId, tokenAddresses, nativeCurrency } = request;
chainToCurrency[chainId] = nativeCurrency;

// Merge all successful market data updates into one object.
const combinedMarketData = results.reduce((acc, result) => {
if (result.status === 'fulfilled' && result.value) {
acc = { ...acc, ...result.value };
if (!requestsByCurrency[nativeCurrency]) {
requestsByCurrency[nativeCurrency] = [];
}
return acc;
}, {});
requestsByCurrency[nativeCurrency].push({ chainId, tokenAddresses });
}

// Call this.update only once with the combined market data to reduce the number of state changes and re-renders
if (Object.keys(combinedMarketData).length > 0) {
this.update((state) => {
state.marketData = {
...state.marketData,
...combinedMarketData,
};
});
const results: Record<Hex, ContractMarketData> = {};

// Process each currency group
for (const [currency, requests] of Object.entries(requestsByCurrency)) {
try {
const caipPrices = await this.#tokenPricesService.fetchMultichainTokenPrices({
tokenRequests: requests,
currency: currency as SupportedCurrency,
});

// Convert CAIP results back to chain-based structure
for (const request of requests) {
const { chainId } = request;
results[chainId] = {};
}

// Map CAIP asset IDs back to chain + token structure
for (const [caipAssetId, tokenPrice] of Object.entries(caipPrices)) {
const parsedAsset = parseCaipAssetId(caipAssetId);
if (parsedAsset) {
const { chainId, tokenAddress } = parsedAsset;
if (results[chainId]) {
results[chainId][tokenAddress] = tokenPrice;
}
}
}
} catch (error) {
console.error(
`Failed to fetch multichain prices for currency ${currency}:`,
error,
);
throw error;
}
}

return results;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ function buildMockTokenPricesService(
async fetchTokenPrices() {
return {};
},
async fetchMultichainTokenPrices() {
return {};
},
validateChainIdSupported(_chainId: unknown): _chainId is Hex {
return true;
},
Expand Down
3 changes: 3 additions & 0 deletions packages/assets-controllers/src/assetsUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,5 +786,8 @@ function createMockPriceService(): AbstractTokenPricesService {
async fetchExchangeRates() {
return {};
},
async fetchMultichainTokenPrices() {
return {};
},
};
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import type { ServicePolicy } from '@metamask/controller-utils';
import type { Hex } from '@metamask/utils';

/**
* Represents a CAIP asset ID for multichain token identification
*/
export type CaipAssetId = string;

/**
* Represents token information for multichain requests
*/
export type MultichainTokenRequest = {
chainId: Hex;
tokenAddresses: Hex[];
};

/**
* A map of CAIP asset ID to its price for multichain responses
*/
export type TokenPricesByCaipAssetId<Currency extends string> = {
[caipAssetId: CaipAssetId]: TokenPrice<Hex, Currency>;
};

/**
* Represents the price of a token in a currency.
*/
Expand Down Expand Up @@ -129,4 +149,21 @@ export type AbstractTokenPricesService<
* @returns True if the API supports the currency, false otherwise.
*/
validateCurrencySupported(currency: unknown): currency is Currency;

/**
* Retrieves prices for multiple tokens across multiple chains in a single request.
* This is more efficient than making multiple single-chain requests.
*
* @param args - The arguments to this function.
* @param args.tokenRequests - Array of chain IDs and their token addresses.
* @param args.currency - The desired currency of the token prices.
* @returns A map of CAIP asset IDs to their prices.
*/
fetchMultichainTokenPrices({
tokenRequests,
currency,
}: {
tokenRequests: MultichainTokenRequest[];
currency: Currency;
}): Promise<TokenPricesByCaipAssetId<Currency>>;
};
Loading
Loading