diff --git a/package.json b/package.json index 40b52cce59e..47c010c266a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "661.0.0", + "version": "662.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f278971d00a..d549f89647b 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [87.1.0] + +### Added + +- Added `getTrendingTokens` function to fetch trending tokens for specific chains ([#7054]) (https://github.com/MetaMask/core/pull/7054) +- Added new types `SortTrendingBy` and `TrendingAsset` ([#7054]) (https://github.com/MetaMask/core/pull/7054) + ## [87.0.0] ### Added @@ -2239,7 +2246,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@87.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@87.1.0...HEAD +[87.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@87.0.0...@metamask/assets-controllers@87.1.0 [87.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@86.0.0...@metamask/assets-controllers@87.0.0 [86.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@85.0.0...@metamask/assets-controllers@86.0.0 [85.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@84.0.0...@metamask/assets-controllers@85.0.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index d7cdcfb7e87..4d88a1fb73d 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "87.0.0", + "version": "87.1.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 3b9cde2216f..f119072a564 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -142,7 +142,7 @@ export { SUPPORTED_CHAIN_IDS, getNativeTokenAddress, } from './token-prices-service'; -export { searchTokens } from './token-service'; +export { searchTokens, getTrendingTokens } from './token-service'; export { RatesController, Cryptocurrency } from './RatesController'; export type { RatesControllerState, @@ -231,3 +231,4 @@ export type { } from './selectors/token-selectors'; export { selectAssetsBySelectedAccountGroup } from './selectors/token-selectors'; export { createFormatters } from './utils/formatters'; +export type { SortTrendingBy, TrendingAsset } from './token-service'; diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 78b8ebff6fa..b611a76ff38 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -2,9 +2,11 @@ import { toHex } from '@metamask/controller-utils'; import type { CaipChainId } from '@metamask/utils'; import nock from 'nock'; +import type { SortTrendingBy } from './token-service'; import { fetchTokenListByChainId, fetchTokenMetadata, + getTrendingTokens, searchTokens, TOKEN_END_POINT_API, TOKEN_METADATA_NO_SUPPORT_ERROR, @@ -709,4 +711,96 @@ describe('Token service', () => { }); }); }); + + describe('getTrendingTokens', () => { + const sampleTrendingTokens = [ + { + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USDC', + symbol: 'USDC', + decimals: 6, + price: '1.00294333595976', + aggregatedUsdVolume: 455616484.38, + marketCap: 75877371441.07, + }, + { + assetId: 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + price: '3406.01599421582', + aggregatedUsdVolume: 358982988.74, + marketCap: 7610628690.4, + }, + ]; + it('returns empty array if no chains are provided', async () => { + const result = await getTrendingTokens({ chainIds: [] }); + expect(result).toStrictEqual([]); + }); + + it('returns empty array if api returns non-array response', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}`, + ) + .reply(200, { error: 'Invalid response' }) + .persist(); + + const result = await getTrendingTokens({ chainIds: [sampleCaipChainId] }); + expect(result).toStrictEqual([]); + }); + + it('returns empty array if the fetch fails', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}`, + ) + .reply(500) + .persist(); + + const result = await getTrendingTokens({ chainIds: [sampleCaipChainId] }); + expect(result).toStrictEqual([]); + }); + + it('returns the list of trending tokens if the fetch succeeds', async () => { + const testChainId = 'eip155:1'; + const sortBy: SortTrendingBy = 'm5_trending'; + const testMinLiquidity = 1000000; + const testMinVolume24hUsd = 1000000; + const testMaxVolume24hUsd = 1000000; + const testMinMarketCap = 1000000; + const testMaxMarketCap = 1000000; + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sortBy=${sortBy}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + sortBy, + minLiquidity: testMinLiquidity, + minVolume24hUsd: testMinVolume24hUsd, + maxVolume24hUsd: testMaxVolume24hUsd, + minMarketCap: testMinMarketCap, + maxMarketCap: testMaxMarketCap, + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + + it('returns the list of trending tokens if the fetch succeeds with no query params', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get(`/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}`) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + }); }); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 24fc7228e81..48118574dd3 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -20,8 +20,7 @@ export const TOKEN_METADATA_NO_SUPPORT_ERROR = */ function getTokensURL(chainId: Hex) { const occurrenceFloor = chainId === ChainId['linea-mainnet'] ? 1 : 3; - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `${TOKEN_END_POINT_API}/tokens/${convertHexToDecimal( chainId, )}?occurrenceFloor=${occurrenceFloor}&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false`; @@ -35,13 +34,20 @@ function getTokensURL(chainId: Hex) { * @returns The token metadata URL. */ function getTokenMetadataURL(chainId: Hex, tokenAddress: string) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return `${TOKEN_END_POINT_API}/token/${convertHexToDecimal( chainId, )}?address=${tokenAddress}`; } +/** + * The sort by field for trending tokens. + */ +export type SortTrendingBy = + | 'm5_trending' + | 'h1_trending' + | 'h6_trending' + | 'h24_trending'; + /** * Get the token search URL for the given networks and search query. * @@ -58,6 +64,43 @@ function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) { return `${TOKEN_END_POINT_API}/tokens/search?chainIds=${encodedChainIds}&query=${encodedQuery}&limit=${limit}`; } +/** + * Get the trending tokens URL for the given networks and search query. + * + * @param options - Options for getting trending tokens. + * @param options.chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). + * @param options.sortBy - The sort by field. + * @param options.minLiquidity - The minimum liquidity. + * @param options.minVolume24hUsd - The minimum volume 24h in USD. + * @param options.maxVolume24hUsd - The maximum volume 24h in USD. + * @param options.minMarketCap - The minimum market cap. + * @param options.maxMarketCap - The maximum market cap. + * @returns The trending tokens URL. + */ +function getTrendingTokensURL(options: { + chainIds: CaipChainId[]; + sortBy?: SortTrendingBy; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; +}): string { + const encodedChainIds = options.chainIds + .map((id) => encodeURIComponent(id)) + .join(','); + // Add the rest of query params if they are defined + const queryParams = new URLSearchParams(); + const { chainIds, ...rest } = options; + Object.entries(rest).forEach(([key, value]) => { + if (value !== undefined) { + queryParams.append(key, String(value)); + } + }); + + return `${TOKEN_END_POINT_API}/v3/tokens/trending?chainIds=${encodedChainIds}${queryParams.toString() ? `&${queryParams.toString()}` : ''}`; +} + const tenSecondsInMilliseconds = 10_000; // Token list averages 1.6 MB in size @@ -134,6 +177,91 @@ export async function searchTokens( } } +/** + * The trending asset type. + */ +export type TrendingAsset = { + assetId: string; + name: string; + symbol: string; + decimals: number; + price: string; + aggregatedUsdVolume: number; + marketCap: number; + priceChangePct?: { + m5?: string; + m15?: string; + m30?: string; + h1?: string; + h6?: string; + h24?: string; + }; + labels?: string[]; +}; + +/** + * Get the trending tokens for the given chains. + * + * @param options - Options for getting trending tokens. + * @param options.chainIds - The chains to get the trending tokens for. + * @param options.sortBy - The sort by field. + * @param options.minLiquidity - The minimum liquidity. + * @param options.minVolume24hUsd - The minimum volume 24h in USD. + * @param options.maxVolume24hUsd - The maximum volume 24h in USD. + * @param options.minMarketCap - The minimum market cap. + * @param options.maxMarketCap - The maximum market cap. + * @returns The trending tokens. + * @throws Will throw if the request fails. + */ +export async function getTrendingTokens({ + chainIds, + sortBy, + minLiquidity, + minVolume24hUsd, + maxVolume24hUsd, + minMarketCap, + maxMarketCap, +}: { + chainIds: CaipChainId[]; + sortBy?: SortTrendingBy; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; +}): Promise { + if (chainIds.length === 0) { + console.error('No chains provided'); + return []; + } + + const trendingTokensURL = getTrendingTokensURL({ + chainIds, + sortBy, + minLiquidity, + minVolume24hUsd, + maxVolume24hUsd, + minMarketCap, + maxMarketCap, + }); + + try { + const result = await handleFetch(trendingTokensURL); + + // Validate that the API returned an array + if (Array.isArray(result)) { + return result; + } + + // Handle non-expected responses + console.error('Trending tokens API returned non-array response:', result); + return []; + } catch (error) { + console.error('Trending tokens request failed:', error); + return []; + } +} + /** * Fetch metadata for the token address provided for a given network. This request is cancellable * using the abort signal passed in. @@ -145,8 +273,6 @@ export async function searchTokens( * @param options.timeout - The fetch timeout. * @returns The token metadata, or `undefined` if the request was either aborted or failed. */ -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention export async function fetchTokenMetadata( chainId: Hex, tokenAddress: string, @@ -208,8 +334,6 @@ async function parseJsonResponse(apiResponse: Response): Promise { const responseObj = await apiResponse.json(); // api may return errors as json without setting an error http status code if (responseObj?.error) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`TokenService Error: ${responseObj.error}`); } return responseObj; diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 05165ae44ed..92631dd1afd 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -68,7 +68,7 @@ }, "devDependencies": { "@metamask/accounts-controller": "^34.0.0", - "@metamask/assets-controllers": "^87.0.0", + "@metamask/assets-controllers": "^87.1.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^5.0.1", "@metamask/network-controller": "^25.0.0", diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 2a6b8f932e8..c4c6092f50a 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -62,7 +62,7 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/assets-controllers": "^87.0.0", + "@metamask/assets-controllers": "^87.1.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/bridge-controller": "^59.0.0", "@metamask/bridge-status-controller": "^59.0.0", diff --git a/yarn.lock b/yarn.lock index 7e92dd477f9..3046040af5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2761,7 +2761,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^87.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^87.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2953,7 +2953,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^34.0.0" - "@metamask/assets-controllers": "npm:^87.0.0" + "@metamask/assets-controllers": "npm:^87.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.15.0" @@ -5219,7 +5219,7 @@ __metadata: dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^87.0.0" + "@metamask/assets-controllers": "npm:^87.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bridge-controller": "npm:^59.0.0"