diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7da370ce3fe..6a16614f5c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -93,6 +93,7 @@ /packages/rate-limit-controller @MetaMask/core-platform /packages/react-data-query @MetaMask/core-platform /packages/profile-metrics-controller @MetaMask/core-platform +/packages/price-api @MetaMask/core-platform ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth diff --git a/README.md b/README.md index 352fec4e6ec..97ce25024be 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/phishing-controller`](packages/phishing-controller) - [`@metamask/polling-controller`](packages/polling-controller) - [`@metamask/preferences-controller`](packages/preferences-controller) +- [`@metamask/price-api`](packages/price-api) - [`@metamask/profile-metrics-controller`](packages/profile-metrics-controller) - [`@metamask/profile-sync-controller`](packages/profile-sync-controller) - [`@metamask/ramps-controller`](packages/ramps-controller) @@ -166,6 +167,7 @@ linkStyle default opacity:0.5 phishing_controller(["@metamask/phishing-controller"]); polling_controller(["@metamask/polling-controller"]); preferences_controller(["@metamask/preferences-controller"]); + price_api(["@metamask/price-api"]); profile_metrics_controller(["@metamask/profile-metrics-controller"]); profile_sync_controller(["@metamask/profile-sync-controller"]); ramps_controller(["@metamask/ramps-controller"]); @@ -427,6 +429,9 @@ linkStyle default opacity:0.5 polling_controller --> messenger; preferences_controller --> base_controller; preferences_controller --> messenger; + price_api --> base_data_service; + price_api --> controller_utils; + price_api --> messenger; profile_metrics_controller --> accounts_controller; profile_metrics_controller --> base_controller; profile_metrics_controller --> controller_utils; diff --git a/packages/price-api/CHANGELOG.md b/packages/price-api/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/price-api/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/price-api/LICENSE b/packages/price-api/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/price-api/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/price-api/README.md b/packages/price-api/README.md new file mode 100644 index 00000000000..f3d472cbfd4 --- /dev/null +++ b/packages/price-api/README.md @@ -0,0 +1,15 @@ +# `@metamask/price-api` + +Wraps the Price API. + +## Installation + +`yarn add @metamask/price-api` + +or + +`npm install @metamask/price-api` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/price-api/jest.config.js b/packages/price-api/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/price-api/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/price-api/package.json b/packages/price-api/package.json new file mode 100644 index 00000000000..b1eef9db443 --- /dev/null +++ b/packages/price-api/package.json @@ -0,0 +1,78 @@ +{ + "name": "@metamask/price-api", + "version": "0.0.0", + "description": "Wraps the Price API", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/price-api#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/price-api", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/price-api", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-data-service": "^0.1.1", + "@metamask/controller-utils": "^11.20.0", + "@metamask/messenger": "^1.1.1", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/price-api/src/index.ts b/packages/price-api/src/index.ts new file mode 100644 index 00000000000..84dfba8aa63 --- /dev/null +++ b/packages/price-api/src/index.ts @@ -0,0 +1,10 @@ +export type { + PriceApiServiceActions, + PriceApiServiceCacheUpdatedEvent, + PriceApiServiceEvents, + PriceApiServiceGranularCacheUpdatedEvent, + PriceApiServiceInvalidateQueriesAction, + PriceApiServiceMessenger, +} from './price-api-service/price-api-service'; +export type { PriceApiServiceFetchHistoricalPricesV3Action } from './price-api-service/price-api-service-method-action-types'; +export { PriceApiService } from './price-api-service/price-api-service'; diff --git a/packages/price-api/src/price-api-service/price-api-service-method-action-types.ts b/packages/price-api/src/price-api-service/price-api-service-method-action-types.ts new file mode 100644 index 00000000000..f80dd4286af --- /dev/null +++ b/packages/price-api/src/price-api-service/price-api-service-method-action-types.ts @@ -0,0 +1,32 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { PriceApiService } from './price-api-service'; + +/** + * Get historical prices by CAIP-19 asset ID (v3 endpoint). + * + * @param args - The arguments to this function. + * @param args.params - Essential request parameters. Usually `{ chainId, + * assetType }` where `chainId` is the CAIP-2 chain ID and `assetType` is the + * asset type portion of CAIP-19. May also be `null` to "disable" the query. + * @param args.options - Optional request parameters. + * @param args.options.currency - The currency for prices. + * @param args.options.timePeriod - The time period. + * @param args.options.from - Start timestamp. + * @param args.options.to - End timestamp. + * @param args.options.interval - Data interval. + * @returns The historical prices response. + */ +export type PriceApiServiceFetchHistoricalPricesV3Action = { + type: `PriceApiService:fetchHistoricalPricesV3`; + handler: PriceApiService['fetchHistoricalPricesV3']; +}; + +/** + * Union of all PriceApiService action types. + */ +export type PriceApiServiceMethodActions = + PriceApiServiceFetchHistoricalPricesV3Action; diff --git a/packages/price-api/src/price-api-service/price-api-service.ts b/packages/price-api/src/price-api-service/price-api-service.ts new file mode 100644 index 00000000000..9dbdfca4af5 --- /dev/null +++ b/packages/price-api/src/price-api-service/price-api-service.ts @@ -0,0 +1,375 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, + QueryKey, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + is, + number, + optional, + tuple, + type, +} from '@metamask/superstruct'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import type { PriceApiServiceMethodActions } from './price-api-service-method-action-types'; + +// === GENERAL === + +/** + * The name of the {@link PriceApiService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'PriceApiService'; + +// === MESSENGER === + +/** + * All of the methods within {@link PriceApiService} that are exposed via + * the messenger. + */ +const MESSENGER_EXPOSED_METHODS = ['fetchHistoricalPricesV3'] as const; + +/** + * Invalidates cached queries for {@link PriceApiService}. + */ +export type PriceApiServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link PriceApiService} exposes to other consumers. + */ +export type PriceApiServiceActions = + | PriceApiServiceMethodActions + | PriceApiServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link PriceApiService} calls. + */ +type AllowedActions = never; + +/** + * Published when {@link PriceApiService}'s cache is updated. + */ +export type PriceApiServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; + +/** + * Published when a key within {@link PriceApiService}'s cache is + * updated. + */ +export type PriceApiServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link PriceApiService} exposes to other consumers. + */ +export type PriceApiServiceEvents = + | PriceApiServiceCacheUpdatedEvent + | PriceApiServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link PriceApiService} subscribes + * to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link PriceApiService}. + */ +export type PriceApiServiceMessenger = Messenger< + typeof serviceName, + PriceApiServiceActions | AllowedActions, + PriceApiServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Struct to validate what the API endpoint returns. + */ +const HistoricalPricesV3ResponseStruct = type({ + prices: array(tuple([number(), number()])), + marketCaps: optional(array(tuple([number(), number()]))), + totalVolumes: optional(array(tuple([number(), number()]))), +}); + +/** + * What the API endpoint returns. + */ +type HistoricalPricesV3Response = Infer< + typeof HistoricalPricesV3ResponseStruct +>; + +/** + * The base URL of the API that the service represents. + */ +const BASE_URL = 'https://price.api.cx.metamask.io'; + +/** + * Supported currencies for the Price API. + */ +export type SupportedCurrency = + // Crypto + | 'btc' + | 'eth' + | 'ltc' + | 'bch' + | 'bnb' + | 'eos' + | 'xrp' + | 'xlm' + | 'link' + | 'dot' + | 'yfi' + // Fiat + | 'usd' + | 'aed' + | 'ars' + | 'aud' + | 'bdt' + | 'bhd' + | 'bmd' + | 'brl' + | 'cad' + | 'chf' + | 'clp' + | 'cny' + | 'czk' + | 'dkk' + | 'eur' + | 'gbp' + | 'gel' + | 'hkd' + | 'huf' + | 'idr' + | 'ils' + | 'inr' + | 'jpy' + | 'krw' + | 'kwd' + | 'lkr' + | 'mmk' + | 'mxn' + | 'myr' + | 'ngn' + | 'nok' + | 'nzd' + | 'php' + | 'pkr' + | 'pln' + | 'rub' + | 'sar' + | 'sek' + | 'sgd' + | 'thb' + | 'try' + | 'twd' + | 'uah' + | 'vef' + | 'vnd' + | 'zar'; + +/** + * This service class wraps the Price API. + * + * @example + * + * ``` ts + * // === Setup === + * + * import type { MessengerActions, MessengerEvents } from '@metamask/messenger'; + * import { Messenger } from '@metamask/messenger'; + * import type { + * PriceApiServiceMessenger, + * } from '@metamask/price-api'; + * + * const rootMessenger = new Messenger< + * 'Root', + * PriceApiServiceActions + * PriceApiServiceEvents + * >({ namespace: 'Root' }); + * const priceApiServiceMessenger = new Messenger< + * 'PriceApiService', + * MessengerActions, + * MessengerEvents, + * typeof rootMessenger, + * >({ + * namespace: 'PriceApiService', + * parent: rootMessenger, + * }); + * // Instantiate the service to register its actions on the messenger + * new PriceApiService({ + * messenger: priceApiServiceMessenger, + * }); + * + * // ... Later ... + * + * // Fetch the past week's prices on Mainnet for ETH, in USD + * const historicalPrices = await rootMessenger.call( + * 'PriceApiService:fetchHistoricalPricesV3', + * params: { + * chainId: 'eip155:1', + * assetType: 'slip44:60', + * }, + * options: { + * currency: 'usd', + * timePeriod: '7d' + * }, + * ); + * + * // The same thing, only using React Query + * + * import { useQuery } from '@metamask/react-data-query'; + * + * const { data, isFetching } = useQuery({ + * queryKey: [ + * 'PriceApiService:fetchHistoricalPricesV3' as const, + * { + * params: { + * chainId: 'eip155:1', + * assetType: 'slip44:60', + * }, + * options: { + * currency: 'usd', + * timePeriod: '7d', + * }, + * }, + * ], + * }); + * ``` + */ +export class PriceApiService extends BaseDataService< + typeof serviceName, + PriceApiServiceMessenger +> { + /** + * Constructs a new PriceApiService object. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.queryClientConfig - Configuration for the underlying TanStack + * Query client. + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + queryClientConfig = {}, + policyOptions = {}, + }: { + messenger: PriceApiServiceMessenger; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Get historical prices by CAIP-19 asset ID (v3 endpoint). + * + * @param args - The arguments to this function. + * @param args.params - Essential request parameters. Usually `{ chainId, + * assetType }` where `chainId` is the CAIP-2 chain ID and `assetType` is the + * asset type portion of CAIP-19. May also be `null` to "disable" the query. + * @param args.options - Optional request parameters. + * @param args.options.currency - The currency for prices. + * @param args.options.timePeriod - The time period. + * @param args.options.from - Start timestamp. + * @param args.options.to - End timestamp. + * @param args.options.interval - Data interval. + * @returns The historical prices response. + */ + async fetchHistoricalPricesV3({ + params = {}, + options: { currency, timePeriod, from, to, interval } = {}, + }: { + params: { + chainId?: string; + assetType?: string; + } | null; + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + interval?: '5m' | 'hourly' | 'daily'; + }; + }): Promise { + let url: URL; + if (params) { + url = new URL( + `/v3/historical-prices/${params.chainId}/${params.assetType}`, + BASE_URL, + ); + if (currency) { + url.searchParams.append('vsCurrency', currency); + } + if (timePeriod) { + url.searchParams.append('timePeriod', timePeriod); + } + if (from) { + url.searchParams.append('from', from.toString()); + } + if (to) { + url.searchParams.append('to', to.toString()); + } + if (interval) { + url.searchParams.append('to', interval); + } + } + + const queryKey: QueryKey = [`${this.name}:fetchHistoricalPricesV3`]; + if (params) { + queryKey.push(params); + } else { + queryKey.push('disabled'); + } + const jsonResponse = await this.fetchQuery({ + queryKey, + queryFn: async ({ signal }) => { + if (url) { + const response = await fetch(url, { signal }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Price API failed with status '${response.status}'`, + ); + } + + return response.json(); + } + + return { prices: [] }; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + + if (!is(jsonResponse, HistoricalPricesV3ResponseStruct)) { + throw new Error('Malformed response received from Price API'); + } + + return jsonResponse; + } +} diff --git a/packages/price-api/tsconfig.build.json b/packages/price-api/tsconfig.build.json new file mode 100644 index 00000000000..02d3bf93d6f --- /dev/null +++ b/packages/price-api/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/price-api/tsconfig.json b/packages/price-api/tsconfig.json new file mode 100644 index 00000000000..e514ad1c607 --- /dev/null +++ b/packages/price-api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/price-api/typedoc.json b/packages/price-api/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/price-api/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index b2648bc9e8d..0a524954c2a 100644 --- a/teams.json +++ b/teams.json @@ -75,5 +75,6 @@ "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform", "metamask/config-registry-controller": "team-networks", - "metamask/money-account-controller": "team-accounts-framework" + "metamask/money-account-controller": "team-accounts-framework", + "metamask/price-api": "team-core-platform" } diff --git a/tsconfig.build.json b/tsconfig.build.json index b02f3a882f5..7f0b707e040 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -187,6 +187,9 @@ { "path": "./packages/preferences-controller/tsconfig.build.json" }, + { + "path": "./packages/price-api/tsconfig.build.json" + }, { "path": "./packages/profile-metrics-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 7eb3aadc457..9888aa45d83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -179,6 +179,9 @@ { "path": "./packages/preferences-controller" }, + { + "path": "./packages/price-api" + }, { "path": "./packages/profile-metrics-controller" }, diff --git a/yarn.lock b/yarn.lock index 4aed557b33a..e16e4db399e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4901,6 +4901,29 @@ __metadata: languageName: unknown linkType: soft +"@metamask/price-api@workspace:packages/price-api": + version: 0.0.0-use.local + resolution: "@metamask/price-api@workspace:packages/price-api" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-data-service": "npm:^0.1.1" + "@metamask/controller-utils": "npm:^11.20.0" + "@metamask/messenger": "npm:^1.1.1" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller": version: 0.0.0-use.local resolution: "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller"