From 03a7326ec88cc46f1ee6599f476ea7773a3ffb92 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 2 Sep 2026 18:31:12 +0100 Subject: [PATCH] feat(stellar-wallet-snap)!: remove asset handler entry points Remove the onAssetsLookup, onAssetsConversion, onAssetHistoricalPrice, and onAssetsMarketData entry points, along with the now-unused AssetsHandler module and the endowment:assets permission. Closes WPN-2014 --- eslint-suppressions.json | 14 +- packages/stellar-wallet-snap/.env.example | 6 - packages/stellar-wallet-snap/CHANGELOG.md | 5 + packages/stellar-wallet-snap/snap.config.ts | 4 - .../stellar-wallet-snap/snap.manifest.json | 5 +- .../stellar-wallet-snap/src/api/asset.test.ts | 20 - packages/stellar-wallet-snap/src/api/asset.ts | 14 - packages/stellar-wallet-snap/src/config.ts | 8 - packages/stellar-wallet-snap/src/context.ts | 9 - .../src/handlers/asset/api.test.ts | 30 - .../src/handlers/asset/api.ts | 17 - .../src/handlers/asset/assets.test.ts | 164 ----- .../src/handlers/asset/assets.ts | 106 ---- packages/stellar-wallet-snap/src/index.ts | 18 - .../src/services/price/PriceService.test.ts | 587 +----------------- .../src/services/price/PriceService.ts | 485 +-------------- .../price/__mocks__/price.fixtures.ts | 59 -- .../src/services/price/api.ts | 8 - .../price/price-api/PriceApiClient.test.ts | 145 ----- .../price/price-api/PriceApiClient.ts | 98 +-- .../src/services/price/price-api/api.test.ts | 179 ------ .../src/services/price/price-api/api.ts | 51 -- .../src/utils/currency.test.ts | 37 -- .../stellar-wallet-snap/src/utils/currency.ts | 30 - 24 files changed, 14 insertions(+), 2085 deletions(-) delete mode 100644 packages/stellar-wallet-snap/src/handlers/asset/api.test.ts delete mode 100644 packages/stellar-wallet-snap/src/handlers/asset/api.ts delete mode 100644 packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts delete mode 100644 packages/stellar-wallet-snap/src/handlers/asset/assets.ts delete mode 100644 packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts delete mode 100644 packages/stellar-wallet-snap/src/services/price/api.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b008be1d3..102b4c98e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1232,7 +1232,7 @@ "count": 1 }, "no-restricted-globals": { - "count": 27 + "count": 25 } }, "packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts": { @@ -1240,11 +1240,6 @@ "count": 1 } }, - "packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, "packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -1438,12 +1433,7 @@ }, "packages/stellar-wallet-snap/src/services/price/PriceService.test.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 4 - } - }, - "packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 + "count": 2 } }, "packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts": { diff --git a/packages/stellar-wallet-snap/.env.example b/packages/stellar-wallet-snap/.env.example index a4563154d..88e5b55b7 100644 --- a/packages/stellar-wallet-snap/.env.example +++ b/packages/stellar-wallet-snap/.env.example @@ -53,12 +53,6 @@ SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io # Cache TTL Milliseconds for spot prices #STELLAR_SPOT_PRICES_TTL_MILLISECONDS= -# Cache TTL Milliseconds for fiat exchange rates -#STELLAR_FIAT_EXCHANGE_RATES_TTL_MILLISECONDS= - -# Cache TTL Milliseconds for historical prices -#STELLAR_HISTORICAL_PRICES_TTL_MILLISECONDS= - # Inclusion fee multiplier (applied to Stellar network base fee per operation) #STELLAR_BASE_FEE_MULTIPLIER= diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 33a2673d9..ec8d99354 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING** Bump `@metamask/keyring-snap-sdk` from `^9.2.1` to `^10.0.0` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) - **BREAKING** Bump `@metamask/snaps-sdk` from `^11.2.0` to `^12.0.1` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) +### Removed + +- **BREAKING** Remove the `onAssetsLookup`, `onAssetsConversion`, `onAssetHistoricalPrice`, and `onAssetsMarketData` asset handler entry points, along with the now-unused `AssetsHandler` module and the `endowment:assets` permission ([#262](https://github.com/MetaMask/internal-snaps/pull/262)) +- Remove now-unused price APIs: `PriceService` `getFiatExchangeRates`, `getHistoricalPrices`, `getHistoricalPriceWithAllTimePeriods`, `getMultipleTokenConversions`, and `getMultipleTokensMarketData` methods; `PriceApiClient` fiat-exchange-rates and historical-prices endpoints and their structs; the `isFiat` and `getFiatTicker` utils; the `FiatCaipAssetStruct` struct; and the `fiatExchangeRates` and `historicalPrices` cache TTL settings ([#262](https://github.com/MetaMask/internal-snaps/pull/262)) + ## [0.1.0] ### Added diff --git a/packages/stellar-wallet-snap/snap.config.ts b/packages/stellar-wallet-snap/snap.config.ts index f33acfc21..df28285fa 100644 --- a/packages/stellar-wallet-snap/snap.config.ts +++ b/packages/stellar-wallet-snap/snap.config.ts @@ -33,10 +33,6 @@ const config: SnapConfig = { PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL ?? '', - STELLAR_FIAT_EXCHANGE_RATES_TTL_MILLISECONDS: - process.env.STELLAR_FIAT_EXCHANGE_RATES_TTL_MILLISECONDS ?? '', - STELLAR_HISTORICAL_PRICES_TTL_MILLISECONDS: - process.env.STELLAR_HISTORICAL_PRICES_TTL_MILLISECONDS ?? '', STELLAR_SPOT_PRICES_TTL_MILLISECONDS: process.env.STELLAR_SPOT_PRICES_TTL_MILLISECONDS ?? '', STELLAR_BASE_FEE_TTL_MILLISECONDS: diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 18ff6434a..2500da2c2 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "eLUwFyKvzK6j3RV0am3G59i2B74yVEf2bU+wnMMcWeE=", + "shasum": "CUvTqT+aiM+mylyW1uwVjk/bS6Pit/uWYTGQnslRi5k=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -59,9 +59,6 @@ } } ] - }, - "endowment:assets": { - "scopes": ["stellar:pubnet"] } }, "platformVersion": "12.0.1", diff --git a/packages/stellar-wallet-snap/src/api/asset.test.ts b/packages/stellar-wallet-snap/src/api/asset.test.ts index 4e39056fb..a52162d1b 100644 --- a/packages/stellar-wallet-snap/src/api/asset.test.ts +++ b/packages/stellar-wallet-snap/src/api/asset.test.ts @@ -1,7 +1,6 @@ import { assert, StructError } from '@metamask/superstruct'; import { - FiatCaipAssetStruct, KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct, KnownCaip19Slip44IdStruct, @@ -41,25 +40,6 @@ describe('KnownCaip19Sep41AssetStruct', () => { }); }); -describe('FiatCaipAssetStruct', () => { - it.each(['swift:0/iso4217:USD', 'swift:0/iso4217:eur'])( - 'accepts a valid fiat CAIP-19 asset id', - (assetId) => { - expect(() => assert(assetId, FiatCaipAssetStruct)).not.toThrow(); - }, - ); - - it.each([ - 'stellar:pubnet/slip44:148', - 'eip155:1/swift:0/iso4217:USD', - 'swift:0/iso4217:US', - 'swift:0/iso4217:USDC', - 'eip155:1/notswift:0/iso4217:USD', - ])('rejects a non-fiat CAIP-19 asset id', (assetId) => { - expect(() => assert(assetId, FiatCaipAssetStruct)).toThrow(StructError); - }); -}); - describe('KnownCaip19Slip44IdStruct', () => { it('accepts a valid CAIP-19 asset', () => { expect(() => diff --git a/packages/stellar-wallet-snap/src/api/asset.ts b/packages/stellar-wallet-snap/src/api/asset.ts index d51c69dfa..06daf7fee 100644 --- a/packages/stellar-wallet-snap/src/api/asset.ts +++ b/packages/stellar-wallet-snap/src/api/asset.ts @@ -1,6 +1,5 @@ import type { Infer } from '@metamask/superstruct'; import { union } from '@metamask/superstruct'; -import type { CaipAssetType } from '@metamask/utils'; import { definePattern } from '@metamask/utils'; import { STELLAR_COIN_TYPE } from '../constants'; @@ -48,16 +47,6 @@ export const KnownCaip19Sep41AssetStruct = /^stellar:(?:pubnet|testnet)\/sep41:C[A-Z2-7]{55}$/u, ); -/** - * Fiat asset id in SWIFT / ISO 4217 form only: `swift:0/iso4217:{code}` (3-letter code). - * - * @see https://github.com/MetaMask/core/blob/main/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts#L44 - */ -export const FiatCaipAssetStruct = definePattern( - 'FiatCaipAsset', - /^swift:0\/iso4217:[A-Za-z]{3}$/u, -); - /** Validation struct for a known Stellar CAIP-19 asset id or slip44 id. */ export const KnownCaip19AssetIdOrSlip44IdStruct = union([ KnownCaip19Sep41AssetStruct, @@ -65,9 +54,6 @@ export const KnownCaip19AssetIdOrSlip44IdStruct = union([ KnownCaip19Slip44IdStruct, ]); -/** Fiat CAIP-19 asset id (SWIFT / ISO 4217). */ -export type FiatCaipAssetId = Infer; - /** CAIP-19 Sep41 asset ID */ export type KnownCaip19Sep41AssetId = Infer; diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index ec73ce07f..260a8a0af 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -163,10 +163,6 @@ const ConfigStruct = object({ // 1 hour spotPrices: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), // 1 hour - fiatExchangeRates: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), - // 1 hour - historicalPrices: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), - // 1 hour baseFee: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), // 10 minutes (Horizon account payload; aligns with on-chain account cache usage) loadOnChainAccount: parseIntegerStruct(1000, 10 * 60 * 1000 * 1), @@ -258,10 +254,6 @@ export const AppConfig = create( cache: { ttlMilliseconds: { spotPrices: process.env.STELLAR_SPOT_PRICES_TTL_MILLISECONDS, - fiatExchangeRates: - process.env.STELLAR_FIAT_EXCHANGE_RATES_TTL_MILLISECONDS, - historicalPrices: - process.env.STELLAR_HISTORICAL_PRICES_TTL_MILLISECONDS, baseFee: process.env.STELLAR_BASE_FEE_TTL_MILLISECONDS, loadOnChainAccount: process.env.STELLAR_LOAD_ON_CHAIN_ACCOUNT_TTL_MILLISECONDS, diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index ab234e5ea..d29ec967b 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -3,7 +3,6 @@ import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; import { KeyringHandler, CronjobHandler, UserInputHandler } from './handlers'; import { AccountResolver } from './handlers/accountResolver'; -import { AssetsHandler } from './handlers/asset/assets'; import type { IClientRequestHandler } from './handlers/clientRequest'; import { ChangeTrustOptHandler, @@ -247,13 +246,6 @@ const cronjobHandler = new CronjobHandler({ handlers: cronjobMethodHandlers, }); -/** ------------------------------ Asset Handler ------------------------------ */ -const assetsHandler = new AssetsHandler({ - logger, - assetMetadataService, - priceService, -}); - /** ------------------------------ Client Request Handlers ------------------------------ */ const changeTrustOptHandler = new ChangeTrustOptHandler({ logger, @@ -320,7 +312,6 @@ const clientRequestHandler = new ClientRequestHandler({ export { clientRequestHandler, cronjobHandler, - assetsHandler, keyringHandler, userInputHandler, signTransactionHandler, diff --git a/packages/stellar-wallet-snap/src/handlers/asset/api.test.ts b/packages/stellar-wallet-snap/src/handlers/asset/api.test.ts deleted file mode 100644 index 027921c30..000000000 --- a/packages/stellar-wallet-snap/src/handlers/asset/api.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { assert, StructError } from '@metamask/superstruct'; - -import { - NATIVE, - USDC_CLASSIC, - USDC_SEP41, -} from '../../services/asset-metadata/__mocks__/assets.fixtures'; -import { OnAssetsLookupRequestStruct } from './api'; - -describe('OnAssetsLookupRequestStruct', () => { - it.each([ - { assets: [USDC_CLASSIC] }, - { assets: [USDC_SEP41] }, - { assets: [NATIVE] }, - { assets: [USDC_CLASSIC, USDC_SEP41, NATIVE] }, - ])('accepts valid assets request', (request) => { - expect(() => assert(request, OnAssetsLookupRequestStruct)).not.toThrow(); - }); - - it.each([ - { assets: ['invalid-asset-id'] }, - { assets: ['eip155:1/erc20:0x0000000000000000000000000000000000000000'] }, - { assets: 'stellar:pubnet/slip44:148' }, - {}, - ])('rejects invalid assets request', (assetId) => { - expect(() => assert(assetId, OnAssetsLookupRequestStruct)).toThrow( - StructError, - ); - }); -}); diff --git a/packages/stellar-wallet-snap/src/handlers/asset/api.ts b/packages/stellar-wallet-snap/src/handlers/asset/api.ts deleted file mode 100644 index f0b72c15a..000000000 --- a/packages/stellar-wallet-snap/src/handlers/asset/api.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { array, object, union } from '@metamask/superstruct'; - -import { - KnownCaip19ClassicAssetStruct, - KnownCaip19Sep41AssetStruct, - KnownCaip19Slip44IdStruct, -} from '../../api'; - -export const OnAssetsLookupAssetStruct = union([ - KnownCaip19ClassicAssetStruct, - KnownCaip19Slip44IdStruct, - KnownCaip19Sep41AssetStruct, -]); - -export const OnAssetsLookupRequestStruct = object({ - assets: array(OnAssetsLookupAssetStruct), -}); diff --git a/packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts b/packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts deleted file mode 100644 index 6396fe10e..000000000 --- a/packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - CaipAssetType, - FungibleAssetMarketData, -} from '@metamask/snaps-sdk'; - -import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import { - createMockAssetMetadataService, - generateMockKeyringAssetMetadata, - USDC_CLASSIC, -} from '../../services/asset-metadata/__mocks__/assets.fixtures'; -import { createMockPriceService } from '../../services/price/__mocks__/price.fixtures'; -import { logger } from '../../utils/logger'; -import { AssetsHandler } from './assets'; - -jest.mock('../../utils/logger'); - -describe('AssetsHandler', () => { - const setupHandlers = () => { - const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = - createMockAssetMetadataService(); - - const { - service: priceService, - getSpotPricesSpy, - getFiatExchangeRatesSpy, - getHistoricalPricesSpy, - getMultipleTokensMarketDataSpy, - getMultipleTokenConversionsSpy, - getHistoricalPriceWithAllTimePeriodsSpy, - } = createMockPriceService(); - - const handler = new AssetsHandler({ - logger, - assetMetadataService, - priceService, - }); - - return { - handler, - getAssetsMetadataByAssetIdsSpy, - getSpotPricesSpy, - getFiatExchangeRatesSpy, - getHistoricalPricesSpy, - getMultipleTokensMarketDataSpy, - getMultipleTokenConversionsSpy, - getHistoricalPriceWithAllTimePeriodsSpy, - }; - }; - - describe('onAssetsLookup', () => { - it('calls asset metadata once per chain and returns merged metadata', async () => { - const { handler, getAssetsMetadataByAssetIdsSpy } = setupHandlers(); - const expectedResponse = generateMockKeyringAssetMetadata(); - getAssetsMetadataByAssetIdsSpy.mockResolvedValue(expectedResponse); - - const assets = Object.keys( - expectedResponse, - ) as KnownCaip19AssetIdOrSlip44Id[]; - - const result = await handler.onAssetsLookup({ assets }); - - expect(getAssetsMetadataByAssetIdsSpy).toHaveBeenCalledTimes(1); - expect(getAssetsMetadataByAssetIdsSpy).toHaveBeenCalledWith(assets); - - expect(result).toMatchObject({ - assets: expectedResponse, - }); - }); - }); - - describe('onAssetsMarketData', () => { - it('calls price service', async () => { - const { handler, getMultipleTokensMarketDataSpy } = setupHandlers(); - const expectedResponse: Record< - CaipAssetType, - Record - > = { - [USDC_CLASSIC]: { - [USDC_CLASSIC]: { - fungible: true, - }, - }, - }; - getMultipleTokensMarketDataSpy.mockResolvedValue(expectedResponse); - - const result = await handler.onAssetsMarketData({ - assets: [ - { - asset: USDC_CLASSIC, - unit: 'swift:0/iso4217:USD', - }, - ], - }); - - expect(getMultipleTokensMarketDataSpy).toHaveBeenCalledWith([ - { - asset: USDC_CLASSIC, - unit: 'swift:0/iso4217:USD', - }, - ]); - expect(result).toMatchObject({ - marketData: expectedResponse, - }); - }); - }); - - describe('onAssetsConversion', () => { - it('calls price service', async () => { - const { handler, getMultipleTokenConversionsSpy } = setupHandlers(); - const expectedResponse = { - [USDC_CLASSIC]: { - [USDC_CLASSIC]: { - rate: '1', - conversionTime: Date.now(), - }, - }, - }; - getMultipleTokenConversionsSpy.mockResolvedValue(expectedResponse); - - const result = await handler.onAssetsConversion({ - conversions: [ - { - from: USDC_CLASSIC, - to: 'swift:0/iso4217:USD', - }, - ], - }); - - expect(getMultipleTokenConversionsSpy).toHaveBeenCalledWith([ - { - from: USDC_CLASSIC, - to: 'swift:0/iso4217:USD', - }, - ]); - expect(result).toMatchObject({ - conversionRates: expectedResponse, - }); - }); - }); - - describe('onAssetHistoricalPrice', () => { - it('calls price service', async () => { - const { handler, getHistoricalPriceWithAllTimePeriodsSpy } = - setupHandlers(); - - const result = await handler.onAssetHistoricalPrice({ - from: USDC_CLASSIC, - to: 'swift:0/iso4217:USD', - }); - - expect(getHistoricalPriceWithAllTimePeriodsSpy).toHaveBeenCalledWith( - USDC_CLASSIC, - 'swift:0/iso4217:USD', - ); - - expect(result).toMatchObject({ - historicalPrice: { - intervals: {}, - }, - }); - }); - }); -}); diff --git a/packages/stellar-wallet-snap/src/handlers/asset/assets.ts b/packages/stellar-wallet-snap/src/handlers/asset/assets.ts deleted file mode 100644 index 098e0c0f5..000000000 --- a/packages/stellar-wallet-snap/src/handlers/asset/assets.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Logger } from '@metamask/snap-networks-utils'; -import type { - OnAssetHistoricalPriceArguments, - OnAssetHistoricalPriceResponse, - OnAssetsConversionArguments, - OnAssetsConversionResponse, - OnAssetsLookupArguments, - OnAssetsLookupResponse, - OnAssetsMarketDataArguments, - OnAssetsMarketDataResponse, -} from '@metamask/snaps-sdk'; -import { assert } from '@metamask/superstruct'; - -import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; -import type { PriceService } from '../../services/price/PriceService'; -import { withCatchAndThrowSnapError } from '../../utils/errors'; -import { OnAssetsLookupRequestStruct } from './api'; - -export class AssetsHandler { - readonly #logger: Logger; - - readonly #assetMetadataService: AssetMetadataService; - - readonly #priceService: PriceService; - - constructor({ - logger, - assetMetadataService, - priceService, - }: { - logger: Logger; - assetMetadataService: AssetMetadataService; - priceService: PriceService; - }) { - this.#logger = logger.withPrefix('[🪙 AssetsHandler]'); - this.#assetMetadataService = assetMetadataService; - this.#priceService = priceService; - } - - async onAssetHistoricalPrice( - params: OnAssetHistoricalPriceArguments, - ): Promise { - return await withCatchAndThrowSnapError(async () => { - this.#logger.debug('[📈 onAssetHistoricalPrice]', params); - - const { from, to } = params; - - const historicalPrice = - await this.#priceService.getHistoricalPriceWithAllTimePeriods(from, to); - - return { - historicalPrice, - }; - }); - } - - async onAssetsConversion( - params: OnAssetsConversionArguments, - ): Promise { - return await withCatchAndThrowSnapError(async () => { - this.#logger.debug('[📈 onAssetsConversion]', params); - - const { conversions } = params; - - const conversionRates = - await this.#priceService.getMultipleTokenConversions(conversions); - - return { - conversionRates, - }; - }); - } - - async onAssetsLookup( - params: OnAssetsLookupArguments, - ): Promise { - return await withCatchAndThrowSnapError(async () => { - this.#logger.debug('[🔍 onAssetsLookup]', params); - // Ensure we only support Stellar assets here. - assert(params, OnAssetsLookupRequestStruct); - - const assetMetadata = - await this.#assetMetadataService.getAssetsMetadataByAssetIds( - params.assets, - ); - - return { - assets: assetMetadata, - }; - }); - } - - async onAssetsMarketData( - params: OnAssetsMarketDataArguments, - ): Promise { - return await withCatchAndThrowSnapError(async () => { - this.#logger.debug('[🔍 onAssetsMarketData]', params); - - const marketData = await this.#priceService.getMultipleTokensMarketData( - params.assets, - ); - - return { marketData }; - }); - } -} diff --git a/packages/stellar-wallet-snap/src/index.ts b/packages/stellar-wallet-snap/src/index.ts index b504f0433..233362a0c 100644 --- a/packages/stellar-wallet-snap/src/index.ts +++ b/packages/stellar-wallet-snap/src/index.ts @@ -1,10 +1,6 @@ import type { OnUserInputHandler, OnKeyringRequestHandler, - OnAssetsConversionHandler, - OnAssetHistoricalPriceHandler, - OnAssetsLookupHandler, - OnAssetsMarketDataHandler, OnClientRequestHandler, OnCronjobHandler, } from '@metamask/snaps-sdk'; @@ -12,24 +8,10 @@ import type { import { keyringHandler, userInputHandler, - assetsHandler, clientRequestHandler, cronjobHandler, } from './context'; -export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( - args, -) => assetsHandler.onAssetHistoricalPrice(args); - -export const onAssetsConversion: OnAssetsConversionHandler = async (args) => - assetsHandler.onAssetsConversion(args); - -export const onAssetsLookup: OnAssetsLookupHandler = async (args) => - assetsHandler.onAssetsLookup(args); - -export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => - assetsHandler.onAssetsMarketData(args); - export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, diff --git a/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index 8098be648..8e740b836 100644 --- a/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -1,51 +1,21 @@ -import { serialize } from '@metamask/snap-networks-utils'; import type { CaipAssetType } from '@metamask/utils'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { AppConfig } from '../../config'; import { logger } from '../../utils'; import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; -import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; -import type { - FiatExchangeRatesResponse, - GetHistoricalPricesResponse, - SpotPrice, -} from './price-api/api'; +import type { SpotPrice } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; -import { HISTORICAL_PRICE_TIME_PERIODS, PriceService } from './PriceService'; -import type { HistoricalPriceTimePeriod } from './PriceService'; +import { PriceService } from './PriceService'; jest.mock('../../utils/logger'); const stellarClassicUsdc = 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const satisfies KnownCaip19AssetIdOrSlip44Id; -const fiatUsdCaip = 'swift:0/iso4217:USD' as CaipAssetType; - -const fiatEurCaip = 'swift:0/iso4217:EUR' as CaipAssetType; - const stellarTestnetMockAsset = 'stellar:testnet/asset:MOCK-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const satisfies KnownCaip19AssetIdOrSlip44Id; -const fiatExchangeRatesBody = { - usd: { - name: 'US Dollar', - ticker: 'usd' as const, - value: 1, - currencyType: 'fiat' as const, - }, -} as FiatExchangeRatesResponse; - -const fiatExchangeRatesUsdEur: FiatExchangeRatesResponse = { - ...fiatExchangeRatesBody, - eur: { - name: 'Euro', - ticker: 'eur' as const, - value: 2, - currencyType: 'fiat' as const, - }, -}; - const minimalSpot = (id: string, price: number): SpotPrice => ({ id, price, @@ -56,16 +26,6 @@ const SPOT_PRICES_CACHE_KEY_PREFIX = 'PriceService:getSpotPrices' as const; const cacheKeySpotPrice = (assetId: CaipAssetType, vsCurrency: string) => `${SPOT_PRICES_CACHE_KEY_PREFIX}:${assetId}:${vsCurrency}`; -const cacheKeyFiatExchangeRates = () => 'PriceService:getFiatExchangeRates:'; - -const cacheKeyHistoricalPrices = (params: { - assetType: KnownCaip19AssetIdOrSlip44Id; - timePeriod: string; - from: number; - to: number; - vsCurrency: string; -}) => `PriceService:getHistoricalPrices:${JSON.stringify(serialize(params))}`; - describe('PriceService', () => { const setupTest = () => { const getSpotPricesSpy = jest.spyOn( @@ -75,26 +35,8 @@ describe('PriceService', () => { getSpotPricesSpy.mockReset(); getSpotPricesSpy.mockResolvedValue({ [stellarClassicUsdc]: null }); - const getFiatExchangeRatesSpy = jest.spyOn( - PriceApiClient.prototype, - 'getFiatExchangeRates', - ); - getFiatExchangeRatesSpy.mockReset(); - getFiatExchangeRatesSpy.mockResolvedValue(fiatExchangeRatesBody); - - const getHistoricalPricesSpy = jest.spyOn( - PriceApiClient.prototype, - 'getHistoricalPrices', - ); - getHistoricalPricesSpy.mockReset(); - getHistoricalPricesSpy.mockResolvedValue( - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - ); - return { getSpotPricesSpy, - getFiatExchangeRatesSpy, - getHistoricalPricesSpy, }; }; @@ -314,529 +256,4 @@ describe('PriceService', () => { ); }); }); - - describe('getFiatExchangeRates', () => { - it('calls PriceApiClient and stores result in cache', async () => { - const { getFiatExchangeRatesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - expect(await service.getFiatExchangeRates()).toStrictEqual( - fiatExchangeRatesBody, - ); - - expect(getFiatExchangeRatesSpy).toHaveBeenCalledTimes(1); - const key = cacheKeyFiatExchangeRates(); - expect(cache.set).toHaveBeenCalledWith( - key, - fiatExchangeRatesBody, - AppConfig.cache.ttlMilliseconds.fiatExchangeRates, - ); - }); - - it('returns cached fiat rates without calling PriceApiClient', async () => { - const { getFiatExchangeRatesSpy } = setupTest(); - const { cache, store } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - const key = cacheKeyFiatExchangeRates(); - - store.set(key, fiatExchangeRatesBody); - - expect(await service.getFiatExchangeRates()).toStrictEqual( - fiatExchangeRatesBody, - ); - - expect(getFiatExchangeRatesSpy).not.toHaveBeenCalled(); - }); - - it('calls PriceApiClient when refreshCache is true', async () => { - const { getFiatExchangeRatesSpy } = setupTest(); - const { cache, store } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - store.set(cacheKeyFiatExchangeRates(), fiatExchangeRatesBody); - - await service.getFiatExchangeRates(true); - - expect(getFiatExchangeRatesSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('getHistoricalPrices', () => { - const historicalParams = { - assetType: stellarClassicUsdc, - timePeriod: '7d', - from: 1, - to: 2, - vsCurrency: 'usd' as const, - }; - - const historicalRequestPayload = { - assetType: stellarClassicUsdc, - timePeriod: '7d', - from: 1, - to: 2, - vsCurrency: 'usd', - }; - - it('calls PriceApiClient and stores result in cache', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - expect(await service.getHistoricalPrices(historicalParams)).toStrictEqual( - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - ); - - expect(getHistoricalPricesSpy).toHaveBeenCalledWith( - historicalRequestPayload, - ); - const key = cacheKeyHistoricalPrices(historicalRequestPayload); - expect(cache.set).toHaveBeenCalledWith( - key, - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - AppConfig.cache.ttlMilliseconds.historicalPrices, - ); - }); - - it('returns cached historical prices without calling PriceApiClient', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache, store } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - const key = cacheKeyHistoricalPrices(historicalRequestPayload); - - store.set(key, GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - - expect(await service.getHistoricalPrices(historicalParams)).toStrictEqual( - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - ); - - expect(getHistoricalPricesSpy).not.toHaveBeenCalled(); - }); - - it('calls PriceApiClient when refreshCache is true', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache, store } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - const key = cacheKeyHistoricalPrices(historicalRequestPayload); - - store.set(key, GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - - await service.getHistoricalPrices(historicalParams, true); - - expect(getHistoricalPricesSpy).toHaveBeenCalledTimes(1); - }); - - it('defaults vsCurrency and forwards zero from and to', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - expect( - await service.getHistoricalPrices({ - assetType: stellarClassicUsdc, - from: 0, - to: 0, - }), - ).toStrictEqual(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - - expect(getHistoricalPricesSpy).toHaveBeenCalledWith({ - assetType: stellarClassicUsdc, - from: 0, - to: 0, - vsCurrency: 'usd', - }); - }); - }); - - describe('getHistoricalPriceWithAllTimePeriods', () => { - it('requests each configured time period with vsCurrency from quote asset', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getHistoricalPricesSpy.mockResolvedValue({ - prices: [[1_700_000_000_000, 0.12]], - marketCaps: [], - totalVolumes: [], - }); - - await service.getHistoricalPriceWithAllTimePeriods( - stellarClassicUsdc, - fiatUsdCaip, - ); - - expect(getHistoricalPricesSpy).toHaveBeenCalledTimes( - HISTORICAL_PRICE_TIME_PERIODS.length, - ); - - HISTORICAL_PRICE_TIME_PERIODS.forEach((timePeriod) => { - expect(getHistoricalPricesSpy).toHaveBeenCalledWith({ - assetType: stellarClassicUsdc, - timePeriod, - vsCurrency: 'usd', - }); - }); - }); - - it('returns intervals keyed by ISO 8601 durations with stringified prices', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - const historicalByPeriod = { - '1d': { prices: [[1, 0.5]], marketCaps: [], totalVolumes: [] }, - '7d': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, - '1m': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, - '3m': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, - '1y': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, - '1000y': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, - } satisfies Record< - HistoricalPriceTimePeriod, - GetHistoricalPricesResponse - >; - - getHistoricalPricesSpy.mockImplementation(async (params) => { - const period = params.timePeriod as keyof typeof historicalByPeriod; - return historicalByPeriod[period]; - }); - - const { intervals } = await service.getHistoricalPriceWithAllTimePeriods( - stellarClassicUsdc, - fiatUsdCaip, - ); - - const expectedIntervalKeys = new Set( - HISTORICAL_PRICE_TIME_PERIODS.map( - (period) => `P${period.toUpperCase()}`, - ), - ); - expect(new Set(Object.keys(intervals))).toStrictEqual( - expectedIntervalKeys, - ); - expect(intervals.P1D).toStrictEqual([[1, '0.5']]); - expect(intervals.P7D).toStrictEqual([[1, '2']]); - }); - - it('sets updateTime and expirationTime using historical prices cache TTL', async () => { - const { getHistoricalPricesSpy } = setupTest(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-06-01T12:00:00.000Z')); - - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getHistoricalPricesSpy.mockResolvedValue({ - prices: [], - marketCaps: [], - totalVolumes: [], - }); - - const now = Date.now(); - const result = await service.getHistoricalPriceWithAllTimePeriods( - stellarClassicUsdc, - fiatUsdCaip, - ); - - expect(result.updateTime).toBe(now); - expect(result.expirationTime).toBe( - now + AppConfig.cache.ttlMilliseconds.historicalPrices, - ); - - jest.useRealTimers(); - }); - - it('uses empty price series for a period when the historical request fails', async () => { - const { getHistoricalPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - const successResponse: GetHistoricalPricesResponse = { - prices: [[10, 1]], - marketCaps: [], - totalVolumes: [], - }; - - const historicalHandlers: Record< - HistoricalPriceTimePeriod, - () => Promise - > = { - '1d': async () => successResponse, - '7d': async () => successResponse, - '1m': async () => successResponse, - '3m': async () => Promise.reject(new Error('network')), - '1y': async () => successResponse, - '1000y': async () => successResponse, - }; - - getHistoricalPricesSpy.mockImplementation(async (params) => { - const period = params.timePeriod as keyof typeof historicalHandlers; - return historicalHandlers[period](); - }); - - const { intervals } = await service.getHistoricalPriceWithAllTimePeriods( - stellarClassicUsdc, - fiatUsdCaip, - ); - - expect(intervals.P3M).toStrictEqual([]); - expect(intervals.P1D).toStrictEqual([[10, '1']]); - }); - }); - - describe('getMultipleTokenConversions', () => { - it('returns empty record when conversions list is empty', async () => { - setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - expect(await service.getMultipleTokenConversions([])).toStrictEqual({}); - }); - - it('derives crypto to crypto rate from USD spot prices', async () => { - const { getSpotPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: minimalSpot('usdc', 2), - [stellarTestnetMockAsset]: minimalSpot('mock', 0.5), - }); - - const result = await service.getMultipleTokenConversions([ - { from: stellarClassicUsdc, to: stellarTestnetMockAsset }, - ]); - - expect( - result[stellarClassicUsdc]?.[stellarTestnetMockAsset], - ).toMatchObject({ - rate: '4', - }); - expect(getSpotPricesSpy).toHaveBeenCalledWith( - [stellarClassicUsdc, stellarTestnetMockAsset], - 'usd', - ); - }); - - it('returns null when a crypto leg has no usable USD price', async () => { - const { getSpotPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: minimalSpot('usdc', 1), - }); - - const result = await service.getMultipleTokenConversions([ - { from: stellarClassicUsdc, to: stellarTestnetMockAsset }, - ]); - - expect(result[stellarClassicUsdc]?.[stellarTestnetMockAsset]).toBeNull(); - }); - - it('derives fiat to fiat rate using inverted exchange rate values', async () => { - const { getSpotPricesSpy, getFiatExchangeRatesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getFiatExchangeRatesSpy.mockResolvedValue(fiatExchangeRatesUsdEur); - getSpotPricesSpy.mockResolvedValue({}); - - const result = await service.getMultipleTokenConversions([ - { from: fiatUsdCaip, to: fiatEurCaip }, - ]); - - // Fiat USD leg: 1 / usd.value = 1/1. Fiat EUR leg: 1 / eur.value = 1/2. - // USD→EUR amount multiplier: fromUsdRate / toUsdRate = 1 / 0.5 = 2. - expect(result[fiatUsdCaip]?.[fiatEurCaip]).toMatchObject({ - rate: '2', - }); - }); - - it('sets expirationTime from the shorter spot or fiat cache TTL', async () => { - const { getSpotPricesSpy } = setupTest(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-15T00:00:00.000Z')); - - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: minimalSpot('usdc', 1), - [stellarTestnetMockAsset]: minimalSpot('mock', 1), - }); - - const now = Date.now(); - const ttl = Math.min( - AppConfig.cache.ttlMilliseconds.spotPrices, - AppConfig.cache.ttlMilliseconds.fiatExchangeRates, - ); - - const conversions = await service.getMultipleTokenConversions([ - { from: stellarClassicUsdc, to: stellarTestnetMockAsset }, - ]); - const row = conversions[stellarClassicUsdc]?.[stellarTestnetMockAsset]; - - expect(row).toMatchObject({ - conversionTime: now, - expirationTime: now + ttl, - }); - - jest.useRealTimers(); - }); - }); - - describe('getMultipleTokensMarketData', () => { - it('returns empty record when assets list is empty', async () => { - setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - expect(await service.getMultipleTokensMarketData([])).toStrictEqual({}); - }); - - it('omits rows when the base asset has no spot entry', async () => { - const { getSpotPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({}); - - const result = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatUsdCaip }, - ]); - - expect(result).toStrictEqual({}); - }); - - it('omits rows when the unit has no usable conversion rate', async () => { - const { getSpotPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: minimalSpot('usdc', 1), - }); - - const result = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatEurCaip }, - ]); - - expect(result).toStrictEqual({}); - }); - - it('scales USD monetary fields to the quote unit without converting circulating supply', async () => { - const { getSpotPricesSpy, getFiatExchangeRatesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getFiatExchangeRatesSpy.mockResolvedValue(fiatExchangeRatesUsdEur); - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: { - id: 'usdc', - price: 1, - marketCap: 1000, - totalVolume: 200, - circulatingSupply: 500, - allTimeHigh: 2, - allTimeLow: 0.5, - }, - }); - - const result = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatEurCaip }, - ]); - - expect(result[stellarClassicUsdc]?.[fiatEurCaip]).toMatchObject({ - fungible: true, - marketCap: '2000', - totalVolume: '400', - circulatingSupply: '500', - allTimeHigh: '4', - allTimeLow: '1', - }); - }); - - it('includes pricePercentChange when spot returns percent fields', async () => { - const { getSpotPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: { - id: 'usdc', - price: 1, - pricePercentChange1d: 1.5, - pricePercentChange7d: -2, - }, - }); - - const result = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatUsdCaip }, - ]); - - expect( - result[stellarClassicUsdc]?.[fiatUsdCaip]?.pricePercentChange, - ).toStrictEqual({ - P1D: 1.5, - P7D: -2, - }); - }); - - it('uses string zero for circulating supply when spot omits, nulls, or sends zero', async () => { - const { getSpotPricesSpy } = setupTest(); - const { cache } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: { - id: 'usdc', - price: 1, - marketCap: 100, - }, - }); - - const omitted = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatUsdCaip }, - ]); - - expect( - omitted[stellarClassicUsdc]?.[fiatUsdCaip]?.circulatingSupply, - ).toBe('0'); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: { - id: 'usdc', - price: 1, - marketCap: 100, - circulatingSupply: null, - }, - }); - - const nulled = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatUsdCaip }, - ]); - - expect(nulled[stellarClassicUsdc]?.[fiatUsdCaip]?.circulatingSupply).toBe( - '0', - ); - - getSpotPricesSpy.mockResolvedValue({ - [stellarClassicUsdc]: { - id: 'usdc', - price: 1, - marketCap: 1, - circulatingSupply: 0, - }, - }); - - const explicitZero = await service.getMultipleTokensMarketData([ - { asset: stellarClassicUsdc, unit: fiatUsdCaip }, - ]); - - expect( - explicitZero[stellarClassicUsdc]?.[fiatUsdCaip]?.circulatingSupply, - ).toBe('0'); - }); - }); }); diff --git a/packages/stellar-wallet-snap/src/services/price/PriceService.ts b/packages/stellar-wallet-snap/src/services/price/PriceService.ts index efb1bde9d..f2bae0024 100644 --- a/packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -1,49 +1,18 @@ import type { Logger, Serializable } from '@metamask/snap-networks-utils'; -import type { - AssetConversion, - FungibleAssetMarketData, - HistoricalPriceIntervals, -} from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; -import { pick } from 'lodash'; import { AppConfig } from '../../config'; -import { getFiatTicker, isFiat, trackError } from '../../utils'; +import { trackError } from '../../utils'; import type { ICache } from '../cache'; -import { useCache } from '../cache'; -import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; import type { - FiatExchangeRatesResponse, - GetHistoricalPricesParams, - GetHistoricalPricesResponse, SpotPrice, SpotPricesResponse, - Ticker, VsCurrencyParam, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; /** - * Time window tokens passed to the Price API for multichain historical snapshots. - * Single source of truth for {@link PriceService.getHistoricalPriceWithAllTimePeriods}. - */ -export const HISTORICAL_PRICE_TIME_PERIODS = [ - '1d', - '7d', - '1m', - '3m', - '1y', - '1000y', -] as const; - -export type HistoricalPriceTimePeriod = - (typeof HISTORICAL_PRICE_TIME_PERIODS)[number]; - -/** - * Fetches and caches price data from the MetaMask Price API: spot quotes, fiat - * exchange rates, historical intervals, cross-asset conversions, and market metrics. + * Fetches and caches spot price data from the MetaMask Price API. */ export class PriceService { readonly #priceApiClient: PriceApiClient; @@ -176,456 +145,6 @@ export class PriceService { }; } - /** - * Gets exchange rates from the Price API (same payload shape as the fiat-rates - * endpoint: tickers keyed to name, value, and currency type). - * Results are cached for `AppConfig.cache.ttlMilliseconds.fiatExchangeRates`. - * - * @param refreshCache - When true, bypasses the cache for this call. - * @returns A promise that resolves to rates keyed by ticker (fiat, crypto, and - * commodity symbols). - */ - async getFiatExchangeRates( - refreshCache: boolean = false, - ): Promise { - return useCache( - this.#priceApiClient.getFiatExchangeRates.bind(this.#priceApiClient), - this.#cache, - { - functionName: 'PriceService:getFiatExchangeRates', - ttlMilliseconds: AppConfig.cache.ttlMilliseconds.fiatExchangeRates, - refreshCache, - }, - )(); - } - - /** - * Gets historical OHLC-style series for a single asset from the Price API. - * Results are cached for `AppConfig.cache.ttlMilliseconds.historicalPrices`. - * - * @param params - Request parameters. - * @param params.assetType - CAIP asset type to chart. - * @param params.timePeriod - Optional window such as `7d` (mutually exclusive - * with `from`/`to` in typical API usage). - * @param params.from - Optional range start (unix ms). - * @param params.to - Optional range end (unix ms). - * @param params.vsCurrency - Quote currency; defaults to `usd` when omitted. - * @param refreshCache - When true, bypasses the cache for this call. - * @returns A promise that resolves to price, market cap, and volume series for the - * requested range. - */ - async getHistoricalPrices( - params: GetHistoricalPricesParams, - refreshCache: boolean = false, - ): Promise { - const { assetType, timePeriod, from, to, vsCurrency = 'usd' } = params; - - return useCache( - this.#priceApiClient.getHistoricalPrices.bind(this.#priceApiClient), - this.#cache, - { - functionName: 'PriceService:getHistoricalPrices', - ttlMilliseconds: AppConfig.cache.ttlMilliseconds.historicalPrices, - refreshCache, - }, - )({ - assetType, - ...(timePeriod !== undefined && { timePeriod }), - ...(from !== undefined && { from }), - ...(to !== undefined && { to }), - vsCurrency, - }); - } - - /** - * Loads historical prices for `from` in each configured calendar period, - * quoted in the asset reference parsed from `to` (used as `vsCurrency`). - * Failed periods return empty series via - * {@link GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT} so other periods still succeed. - * - * @param from - Base CAIP asset type. - * @param to - Quote asset; its CAIP `assetReference` becomes the vs ticker (lowercase). - * @returns A promise that resolves to an object with `intervals` (ISO 8601 duration keys, - * for example `P7D`, mapped to `[timestamp, price]` pairs with string prices), `updateTime`, - * and optional `expirationTime` so the caller can decide when to refresh cached data. - * @see https://github.com/MetaMask/core/blob/main/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts#L556 - */ - async getHistoricalPriceWithAllTimePeriods( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; - }> { - const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); - - // For each time period, call the Price API to fetch the historical prices - const promises = HISTORICAL_PRICE_TIME_PERIODS.map(async (timePeriod) => { - try { - const response = await this.getHistoricalPrices( - { - assetType: from, - timePeriod, - // It is possible that the toTicker is not a valid vsCurrency, - // but we can safely cast it to VsCurrencyParam because the Price API will throw an error if it is not a valid value - vsCurrency: toTicker as VsCurrencyParam, - }, - // Refresh the cache to ensure we get the latest data - true, - ); - // Wrap the response in an object with the time period and the response for easier reducing - return { - timePeriod, - response, - }; - } catch (error) { - await trackError(error); - // Gracefully handle individual errors to avoid breaking the entire operation - this.#logger.warn( - `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, - error, - ); - - return { - timePeriod, - response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - }; - } - }); - - const wrappedHistoricalPrices = await Promise.all(promises); - - // Format the response into the expected intervals format - const intervals = wrappedHistoricalPrices.reduce( - (acc, { timePeriod, response }) => { - const iso8601Interval = `P${timePeriod.toUpperCase()}`; - acc[iso8601Interval] = response.prices.map((price) => [ - price[0], - price[1].toString(), - ]); - return acc; - }, - {}, - ); - - // TODO: replace with more accurate expiration time for the result based on the data itself. - const now = Date.now(); - - const result = { - intervals, - updateTime: now, - expirationTime: now + AppConfig.cache.ttlMilliseconds.historicalPrices, - }; - - return result; - } - - /** - * Computes pairwise conversion rates between assets (fiat or crypto CAIP IDs). - * Uses {@link getFiatExchangeRates} and {@link getSpotPrices} (vs USD), - * then divides USD-equivalent values to obtain each `from`→`to` rate. - * That USD bridge is an approximation when both legs are not USD-quoted spot. - * Each {@link AssetConversion}'s `expirationTime` uses the shorter of the spot and - * fiat-exchange-rate cache TTLs. - * - * @param conversions - Pairs of `from` and `to` CAIP asset types. - * @returns A promise that resolves to a nested record `from` → `to` → - * {@link AssetConversion} or `null` when either leg has no usable rate. - */ - async getMultipleTokenConversions( - conversions: { from: CaipAssetType; to: CaipAssetType }[], - ): Promise< - Record> - > { - if (conversions.length === 0) { - return {}; - } - - /** - * `from` and `to` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = conversions.flatMap((conversion) => [ - conversion.from, - conversion.to, - ]); - - // Expired time is not being used by the caller, - // so we should use the cached results. - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); - - /** - * Now that we have the data, convert the `from`s to `to`s. - * - * We need to handle the following cases: - * 1. `from` and `to` are both fiat - * 2. `from` and `to` are both crypto - * 3. `from` is fiat and `to` is crypto - * 4. `from` is crypto and `to` is fiat - * - * We also need to keep in mind that although `cryptoPrices` are indexed - * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. - * To convert fiat currency symbols to CAIP 19 IDs, we can use the - * `this.#fiatSymbolToCaip19Id` method. - */ - const result: Record< - CaipAssetType, - Record - > = {}; - - conversions.forEach((conversion) => { - const { from, to } = conversion; - - result[from] ??= {}; - - const fromUsdRate = this.#calculateConversionRate({ - asset: from, - fiatExchangeRates, - cryptoPrices, - }); - - const toUsdRate = this.#calculateConversionRate({ - asset: to, - fiatExchangeRates, - cryptoPrices, - }); - - if (fromUsdRate.isZero() || toUsdRate.isZero()) { - result[from][to] = null; - return; - } - - const rate = fromUsdRate.dividedBy(toUsdRate).toString(); - - const now = Date.now(); - - // Caller is not using the expiration time, - // so we can just use the minimum of the two fixed TTLs as placeholder. - const expirationTime = Math.min( - AppConfig.cache.ttlMilliseconds.spotPrices, - AppConfig.cache.ttlMilliseconds.fiatExchangeRates, - ); - - result[from][to] = { - rate, - conversionTime: now, - expirationTime: now + expirationTime, - }; - }); - - return result; - } - - /** - * Returns fungible market metrics for each crypto `asset`, with monetary fields - * expressed in the given `unit` (fiat or crypto) using the same USD bridge as - * {@link getMultipleTokenConversions}. - * - * @param assets - Rows with `asset` (must have spot data) and pricing `unit`. - * @returns A promise that resolves to a nested record `asset` → `unit` → - * {@link FungibleAssetMarketData}. Assets without spot prices or with a zero - * `unit` USD rate are omitted from the result. - */ - async getMultipleTokensMarketData( - assets: { - asset: CaipAssetType; - unit: CaipAssetType; - }[], - ): Promise< - Record> - > { - if (assets.length === 0) { - return {}; - } - - /** - * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); - - const result: Record< - CaipAssetType, - Record - > = {}; - - assets.forEach((asset) => { - const { asset: assetType, unit } = asset; - - // Skip if we don't have price data for the asset - if (!cryptoPrices[assetType]) { - return; - } - - const unitUsdRate = this.#calculateConversionRate({ - asset: unit, - fiatExchangeRates, - cryptoPrices, - }); - - if (unitUsdRate.isZero()) { - return; - } - - // Initialize the nested structure for the asset if it doesn't exist - result[assetType] ??= {}; - - // Store the market data with the unit as the key - result[assetType][unit] = this.#computeMarketData( - cryptoPrices[assetType], - unitUsdRate, - ); - }); - - return result; - } - - /** - * Converts USD-denominated spot metrics into the display `unit` by dividing - * each monetary field by the USD value of one `unit` (see {@link #calculateConversionRate}). - * Percent change fields are copied unchanged. - * - * @param spotPrice - Spot payload for the base asset (from the Price API, vs USD). - * @param rate - Non-zero USD price of one unit of the quote asset. - * @returns Market data scaled to the quote `unit`; empty strings where converted - * monetary inputs are nullish. Circulating supply is not currency-converted; when - * the spot payload omits or nulls it, the value is `'0'` by design (same as numeric zero). - */ - #computeMarketData( - spotPrice: SpotPrice, - rate: BigNumber, - ): FungibleAssetMarketData { - const marketDataInUsd = pick(spotPrice, [ - 'marketCap', - 'totalVolume', - 'circulatingSupply', - 'allTimeHigh', - 'allTimeLow', - 'pricePercentChange1h', - 'pricePercentChange1d', - 'pricePercentChange7d', - 'pricePercentChange14d', - 'pricePercentChange30d', - 'pricePercentChange200d', - 'pricePercentChange1y', - ]); - - // Variations in percent don't need to be converted, they are independent of the currency - const pricePercentChange = { - ...this.#includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), - ...this.#includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), - ...this.#includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), - ...this.#includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), - ...this.#includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), - ...this.#includeIfDefined( - 'P200D', - marketDataInUsd.pricePercentChange200d, - ), - ...this.#includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), - }; - - const marketDataInToCurrency = { - fungible: true, - marketCap: this.#toCurrencySafe(marketDataInUsd.marketCap, rate), - totalVolume: this.#toCurrencySafe(marketDataInUsd.totalVolume, rate), - // Circulating supply counts tokens in circulation (not a fiat amount); do not divide by `rate`. - // By design, missing or null from the API is treated as zero (`'0'`), matching other snaps. - circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), - allTimeHigh: this.#toCurrencySafe(marketDataInUsd.allTimeHigh, rate), - allTimeLow: this.#toCurrencySafe(marketDataInUsd.allTimeLow, rate), - // Add pricePercentChange field only if it has values - ...(Object.keys(pricePercentChange).length > 0 - ? { pricePercentChange } - : {}), - } as FungibleAssetMarketData; - - return marketDataInToCurrency; - } - - /** - * Loads exchange rates and USD spot prices needed for conversion and market views. - * Shared by {@link getMultipleTokenConversions} and {@link getMultipleTokensMarketData}. - * - * @param allAssets - Every `from`/`to` or `asset`/`unit` CAIP id involved (duplicates allowed). - * @param refreshCache - When true, bypasses the cache for this call. - * @returns A promise that resolves to the full fiat rate table plus a partial spot - * map for non-fiat ids only (fiat entries are not requested from spot pricing). - */ - async #fetchPriceData( - allAssets: CaipAssetType[], - refreshCache: boolean = false, - ): Promise<{ - fiatExchangeRates: FiatExchangeRatesResponse; - cryptoPrices: Partial; - }> { - const assetIds = allAssets.filter((asset) => !isFiat(asset)); - - const [fiatExchangeRates, cryptoPrices] = await Promise.all([ - this.getFiatExchangeRates(refreshCache), - this.getSpotPrices( - { - assetIds, - vsCurrency: 'usd', - }, - refreshCache, - ), - ]); - - return { fiatExchangeRates, cryptoPrices }; - } - - #calculateConversionRate({ - asset, - fiatExchangeRates, - cryptoPrices, - }: { - asset: CaipAssetType; - fiatExchangeRates: FiatExchangeRatesResponse; - cryptoPrices: Partial; - }): BigNumber { - if (isFiat(asset)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const ticker = getFiatTicker(asset) as Ticker; - const fiatExchangeRate = fiatExchangeRates[ticker]?.value; - - // if it is falsy, return 0 - if (!fiatExchangeRate) { - return new BigNumber(0); - } - - return new BigNumber(1).dividedBy(fiatExchangeRate); - } - return new BigNumber(cryptoPrices[asset]?.price ?? 0); - } - - #includeIfDefined( - key: string, - value: number | null | undefined, - ): Record { - return value === null || value === undefined ? {} : { [key]: value }; - } - - readonly #toCurrencySafe = ( - value: number | null | undefined, - rate: BigNumber, - ): string => { - return value === null || value === undefined - ? '' - : new BigNumber(value).dividedBy(rate).toString(); - }; - async #getSpotPrices( assetIds: CaipAssetType[], vsCurrency: VsCurrencyParam | string = 'usd', diff --git a/packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts b/packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts deleted file mode 100644 index 3bc4a9d92..000000000 --- a/packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { logger } from '../../../utils/logger'; -import { createMemoryCache } from '../../cache/__mocks__/cache.fixtures'; -import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from '../api'; -import type { FiatExchangeRatesResponse } from '../price-api/api'; -import { PriceApiClient } from '../price-api/PriceApiClient'; -import { PriceService } from '../PriceService'; - -const fiatExchangeRatesBody = { - usd: { - name: 'US Dollar', - ticker: 'usd' as const, - value: 1, - currencyType: 'fiat' as const, - }, -} as FiatExchangeRatesResponse; - -export const createMockPriceService = () => { - const { cache, store } = createMemoryCache(); - const service = new PriceService({ cache, logger }); - - const getSpotPricesSpy = jest - .spyOn(PriceApiClient.prototype, 'getSpotPrices') - .mockResolvedValue({}); - - const getFiatExchangeRatesSpy = jest - .spyOn(PriceApiClient.prototype, 'getFiatExchangeRates') - .mockResolvedValue(fiatExchangeRatesBody); - - const getHistoricalPricesSpy = jest - .spyOn(PriceApiClient.prototype, 'getHistoricalPrices') - .mockResolvedValue(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - - const getMultipleTokensMarketDataSpy = jest.spyOn( - PriceService.prototype, - 'getMultipleTokensMarketData', - ); - - const getMultipleTokenConversionsSpy = jest.spyOn( - PriceService.prototype, - 'getMultipleTokenConversions', - ); - - const getHistoricalPriceWithAllTimePeriodsSpy = jest.spyOn( - PriceService.prototype, - 'getHistoricalPriceWithAllTimePeriods', - ); - - return { - service, - cache, - store, - getSpotPricesSpy, - getFiatExchangeRatesSpy, - getHistoricalPricesSpy, - getMultipleTokensMarketDataSpy, - getMultipleTokenConversionsSpy, - getHistoricalPriceWithAllTimePeriodsSpy, - }; -}; diff --git a/packages/stellar-wallet-snap/src/services/price/api.ts b/packages/stellar-wallet-snap/src/services/price/api.ts deleted file mode 100644 index c2d12ecf1..000000000 --- a/packages/stellar-wallet-snap/src/services/price/api.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { GetHistoricalPricesResponse } from './price-api'; - -export const GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT: GetHistoricalPricesResponse = - { - prices: [], - marketCaps: [], - totalVolumes: [], - }; diff --git a/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts b/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts index df92d0d9e..8126fad7d 100644 --- a/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts +++ b/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts @@ -5,7 +5,6 @@ import { HttpResponseException, InvalidHttpResponseException, } from '../../../utils/errors'; -import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from '../api'; import { PriceApiClient } from './PriceApiClient'; jest.mock('../../../utils/logger'); @@ -45,63 +44,6 @@ describe('PriceApiClient', () => { const createClient = () => new PriceApiClient({ baseUrl }, mockFetch); - describe('getFiatExchangeRates', () => { - it('requests fiat exchange rates endpoint and returns parsed body', async () => { - const body = { - usd: { - name: 'US Dollar', - ticker: 'usd' as const, - value: 1, - currencyType: 'fiat' as const, - }, - }; - mockFetch.mockResolvedValueOnce(jsonResponse(body)); - - const client = createClient(); - expect(await client.getFiatExchangeRates()).toStrictEqual(body); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch.mock.calls[0]?.[0]).toBe( - buildUrl({ - baseUrl, - path: '/v1/exchange-rates/fiat', - }), - ); - }); - - it('throws HttpResponseException when response is not ok', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse({}, { ok: false, status: 502 }), - ); - - const client = createClient(); - await expect(client.getFiatExchangeRates()).rejects.toThrow( - HttpResponseException, - ); - }); - - it('throws InvalidHttpResponseException when response body fails validation', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ invalid: true })); - - const client = createClient(); - await expect(client.getFiatExchangeRates()).rejects.toThrow( - InvalidHttpResponseException, - ); - }); - - it('throws HttpException when fetch rejects', async () => { - const networkError = Object.assign(new Error('network down'), { - cause: { code: 'ECONNREFUSED' }, - }); - mockFetch.mockRejectedValueOnce(networkError); - - const client = createClient(); - await expect(client.getFiatExchangeRates()).rejects.toThrow( - HttpException, - ); - }); - }); - describe('getSpotPrices', () => { it('requests spot prices with default vsCurrency and includeMarketData', async () => { const spotBody = { @@ -224,91 +166,4 @@ describe('PriceApiClient', () => { ); }); }); - - describe('getHistoricalPrices', () => { - it('requests historical prices with path and query params', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT), - ); - - const client = createClient(); - expect( - await client.getHistoricalPrices({ - assetType: stellarClassicUsdc, - timePeriod: '7d', - from: 1, - to: 2, - vsCurrency: 'usd', - }), - ).toStrictEqual(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - - expect(mockFetch.mock.calls[0]?.[0]).toBe( - buildUrl({ - baseUrl, - path: '/v3/historical-prices/{assetType}', - pathParams: { assetType: stellarClassicUsdc }, - queryParams: { - timePeriod: '7d', - from: '1', - to: '2', - vsCurrency: 'usd', - }, - encodePathParams: false, - }), - ); - }); - - it('includes from and to in query when both are zero', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT), - ); - - const client = createClient(); - expect( - await client.getHistoricalPrices({ - assetType: stellarClassicUsdc, - from: 0, - to: 0, - }), - ).toStrictEqual(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - - expect(mockFetch.mock.calls[0]?.[0]).toBe( - buildUrl({ - baseUrl, - path: '/v3/historical-prices/{assetType}', - pathParams: { assetType: stellarClassicUsdc }, - queryParams: { - from: '0', - to: '0', - }, - encodePathParams: false, - }), - ); - }); - - it('throws HttpResponseException when response is not ok', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse({}, { ok: false, status: 502 }), - ); - - const client = createClient(); - await expect( - client.getHistoricalPrices({ - assetType: stellarClassicUsdc, - timePeriod: '7d', - }), - ).rejects.toThrow(HttpResponseException); - }); - - it('throws InvalidHttpResponseException when response body fails validation', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ invalid: true })); - - const client = createClient(); - await expect( - client.getHistoricalPrices({ - assetType: stellarClassicUsdc, - }), - ).rejects.toThrow(InvalidHttpResponseException); - }); - }); }); diff --git a/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts b/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts index 09c1361c0..476b100b1 100644 --- a/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts +++ b/packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts @@ -13,20 +13,8 @@ import { InvalidHttpResponseException, normalizeHttpException, } from '../../../utils/errors'; -import type { - FiatExchangeRatesResponse, - GetHistoricalPricesParams, - GetHistoricalPricesResponse, - SpotPricesResponse, - VsCurrencyParam, -} from './api'; -import { - FiatExchangeRatesResponseStruct, - GetHistoricalPricesParamsStruct, - GetHistoricalPricesResponseStruct, - GetSpotPricesParamsStruct, - GetSpotPricesResponseStruct, -} from './api'; +import type { SpotPricesResponse, VsCurrencyParam } from './api'; +import { GetSpotPricesParamsStruct, GetSpotPricesResponseStruct } from './api'; import { PriceApiException } from './exceptions'; export class PriceApiClient { @@ -48,31 +36,6 @@ export class PriceApiClient { this.#baseUrl = baseUrl; } - async getFiatExchangeRates(): Promise { - try { - const url = buildUrl({ - baseUrl: this.#baseUrl, - path: '/v1/exchange-rates/fiat', - }); - - const response = await this.#fetch(url); - - if (!response.ok) { - throw new HttpResponseException(response.status); - } - - const data = await response.json(); - assertHttpResponse(data, FiatExchangeRatesResponseStruct); - - return data; - } catch (error: unknown) { - return this.#throwError({ - error, - fallbackError: 'Error fetching fiat exchange rates', - }); - } - } - async getSpotPrices( assetIds: CaipAssetType[], vsCurrency: VsCurrencyParam | string = 'usd', @@ -114,63 +77,6 @@ export class PriceApiClient { } } - /** - * Business logic for `getHistoricalPrices`. - * - * @param params - The parameters for the request. - * @param params.assetType - The asset type of the token. - * @param params.timePeriod - The time period for the historical prices. - * @param params.from - The start date for the historical prices. - * @param params.to - The end date for the historical prices. - * @param params.vsCurrency - The currency to convert the prices to. - * @returns The historical prices for the token. - * @throws {HttpResponseException} When the HTTP response status is not successful. - * @throws {InvalidHttpResponseException} When the response body is invalid. - * @throws {PriceApiException} When the request fails for another reason. - */ - async getHistoricalPrices( - params: GetHistoricalPricesParams, - ): Promise { - try { - assertHttpRequestParams(params, GetHistoricalPricesParamsStruct); - - const url = buildUrl({ - baseUrl: this.#baseUrl, - path: '/v3/historical-prices/{assetType}', - pathParams: { - assetType: params.assetType, - }, - queryParams: { - ...(params.timePeriod !== undefined && { - timePeriod: params.timePeriod, - }), - ...(params.from !== undefined && { from: params.from.toString() }), - ...(params.to !== undefined && { to: params.to.toString() }), - ...(params.vsCurrency !== undefined && { - vsCurrency: params.vsCurrency, - }), - }, - encodePathParams: false, - }); - - const response = await this.#fetch(url); - - if (!response.ok) { - throw new HttpResponseException(response.status); - } - - const historicalPrices = await response.json(); - assertHttpResponse(historicalPrices, GetHistoricalPricesResponseStruct); - - return historicalPrices; - } catch (error: unknown) { - return this.#throwError({ - error, - fallbackError: 'Error fetching historical prices', - }); - } - } - #throwError({ error, exceptionClasses, diff --git a/packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts b/packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts index 7448eef13..58e1a72cd 100644 --- a/packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts +++ b/packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts @@ -1,12 +1,7 @@ import { assert, StructError } from '@metamask/superstruct'; import { cloneDeep, set } from 'lodash'; -import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from '../api'; import { - ExchangeRateStruct, - FiatExchangeRatesResponseStruct, - GetHistoricalPricesParamsStruct, - GetHistoricalPricesResponseStruct, GetSpotPricesParamsStruct, GetSpotPricesResponseStruct, SpotPriceStruct, @@ -29,90 +24,6 @@ const validSpotPrices: SpotPricesResponse = { }; describe('price-api structs', () => { - describe('ExchangeRateStruct', () => { - it('accepts a fiat exchange rate row', () => { - expect(() => - assert( - { - name: 'US Dollar', - ticker: 'usd', - value: 1, - currencyType: 'fiat', - }, - ExchangeRateStruct, - ), - ).not.toThrow(); - }); - - it('rejects negative value', () => { - expect(() => - assert( - { - name: 'US Dollar', - ticker: 'usd', - value: -1, - currencyType: 'fiat', - }, - ExchangeRateStruct, - ), - ).toThrow(StructError); - }); - - it('rejects unknown ticker', () => { - expect(() => - assert( - { - name: 'X', - ticker: 'not-a-ticker', - value: 1, - currencyType: 'crypto', - }, - ExchangeRateStruct, - ), - ).toThrow(StructError); - }); - }); - - describe('FiatExchangeRatesResponseStruct', () => { - it('accepts a record keyed by ticker', () => { - expect(() => - assert( - { - usd: { - name: 'US Dollar', - ticker: 'usd', - value: 1, - currencyType: 'fiat', - }, - btc: { - name: 'Bitcoin', - ticker: 'btc', - value: 50000, - currencyType: 'crypto', - }, - }, - FiatExchangeRatesResponseStruct, - ), - ).not.toThrow(); - }); - - it('rejects invalid top-level key', () => { - expect(() => - assert( - { - notATicker: { - name: 'X', - ticker: 'usd', - value: 1, - currencyType: 'fiat', - }, - }, - FiatExchangeRatesResponseStruct, - ), - ).toThrow(StructError); - }); - }); - describe('SpotPriceStruct', () => { it('accepts minimal spot price fields', () => { expect(() => @@ -193,94 +104,4 @@ describe('price-api structs', () => { ).toThrow(StructError); }); }); - - describe('GetHistoricalPricesParamsStruct', () => { - it('accepts full params', () => { - expect(() => - assert( - { - assetType: stellarClassicUsdc, - timePeriod: '7d', - from: 0, - to: 1, - vsCurrency: 'usd', - }, - GetHistoricalPricesParamsStruct, - ), - ).not.toThrow(); - }); - - it('accepts only required assetType', () => { - expect(() => - assert( - { assetType: stellarClassicUsdc }, - GetHistoricalPricesParamsStruct, - ), - ).not.toThrow(); - }); - - it('rejects invalid timePeriod pattern', () => { - expect(() => - assert( - { - assetType: stellarClassicUsdc, - timePeriod: '0d', - }, - GetHistoricalPricesParamsStruct, - ), - ).toThrow(StructError); - }); - - it('rejects negative from timestamp', () => { - expect(() => - assert( - { - assetType: stellarClassicUsdc, - from: -1, - }, - GetHistoricalPricesParamsStruct, - ), - ).toThrow(StructError); - }); - }); - - describe('GetHistoricalPricesResponseStruct', () => { - it('accepts tuple series arrays', () => { - expect(() => - assert( - { - prices: [ - [1_700_000_000_000, 0.12], - [1_700_006_400_000, 0.13], - ], - marketCaps: [[1_700_000_000_000, 1e9]], - totalVolumes: [[1_700_000_000_000, 5e6]], - }, - GetHistoricalPricesResponseStruct, - ), - ).not.toThrow(); - }); - - it('accepts empty series', () => { - expect(() => - assert( - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - GetHistoricalPricesResponseStruct, - ), - ).not.toThrow(); - }); - - it('rejects malformed price point', () => { - expect(() => - assert( - { - prices: [[1, 2, 3]], - marketCaps: [], - totalVolumes: [], - }, - GetHistoricalPricesResponseStruct, - ), - ).toThrow(StructError); - }); - }); }); diff --git a/packages/stellar-wallet-snap/src/services/price/price-api/api.ts b/packages/stellar-wallet-snap/src/services/price/price-api/api.ts index b2528e45c..e63372fca 100644 --- a/packages/stellar-wallet-snap/src/services/price/price-api/api.ts +++ b/packages/stellar-wallet-snap/src/services/price/price-api/api.ts @@ -8,10 +8,8 @@ import { number, object, optional, - pattern, record, string, - tuple, type, union, } from '@metamask/superstruct'; @@ -117,33 +115,6 @@ export const TickerStruct = union([ export type Ticker = Infer; -/** - * Struct for validating exchange rate data from the API. - * Includes bounds validation to prevent malicious data injection. - */ -export const ExchangeRateStruct = type({ - name: string(), - ticker: TickerStruct, - value: min(number(), 0), - currencyType: enums(['fiat', 'crypto', 'commodity']), -}); - -export type ExchangeRate = Infer; - -/** - * Struct for validating the fiat exchange rates response. - * Maps ticker symbols to their exchange rate data. - * Despite the endpoint name, the response includes all exchange rates (crypto, fiat, commodity). - */ -export const FiatExchangeRatesResponseStruct = record( - TickerStruct, - ExchangeRateStruct, -); - -export type FiatExchangeRatesResponse = Infer< - typeof FiatExchangeRatesResponseStruct ->; - /** * The structure of the spot price response from the Price API as described in * [this file](https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/types/price.ts#L46-L71). @@ -226,25 +197,3 @@ export const GetSpotPricesParamsStruct = object({ }); export type GetSpotPricesParams = Infer; - -export const GetHistoricalPricesParamsStruct = object({ - assetType: CaipAssetTypeStruct, - timePeriod: optional(pattern(string(), /^[1-9][0-9]*[dmy]$/u)), // Supports days, months, years - from: optional(min(number(), 0)), - to: optional(min(number(), 0)), - vsCurrency: optional(VsCurrencyParamStruct), -}); - -export type GetHistoricalPricesParams = Infer< - typeof GetHistoricalPricesParamsStruct ->; - -export const GetHistoricalPricesResponseStruct = type({ - prices: array(tuple([number(), number()])), - marketCaps: array(tuple([number(), number()])), - totalVolumes: array(tuple([number(), number()])), -}); - -export type GetHistoricalPricesResponse = Infer< - typeof GetHistoricalPricesResponseStruct ->; diff --git a/packages/stellar-wallet-snap/src/utils/currency.test.ts b/packages/stellar-wallet-snap/src/utils/currency.test.ts index e003043b8..361bb6169 100644 --- a/packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -3,8 +3,6 @@ import { BigNumber } from 'bignumber.js'; import { toDisplayBalance, formatFiat, - getFiatTicker, - isFiat, normalizeAmount, tokenToFiat, toSmallestUnit, @@ -111,38 +109,3 @@ describe('tokenToFiat', () => { expect(tokenToFiat('0.5', '4')).toBe('2'); }); }); - -describe('isFiat', () => { - it('returns true for swift ISO4217 ids', () => { - expect(isFiat('swift:0/iso4217:USD')).toBe(true); - }); - - it('returns false for chain-prefixed fiat ids', () => { - expect(isFiat('eip155:1/swift:0/iso4217:USD')).toBe(false); - }); - - it('returns false for stellar asset ids', () => { - expect( - isFiat( - 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - ), - ).toBe(false); - }); - - it('returns false when ISO4217 segment is not exactly three letters', () => { - expect(isFiat('swift:0/iso4217:US')).toBe(false); - expect(isFiat('swift:0/iso4217:USDC')).toBe(false); - }); -}); - -describe('getFiatTicker', () => { - it('throws when asset id is not fiat', () => { - expect(() => getFiatTicker('stellar:pubnet/slip44:148')).toThrow( - 'Passed assetId is not a fiat asset', - ); - }); - - it('returns lowercase asset reference from parser', () => { - expect(getFiatTicker('swift:0/iso4217:EUR')).toBe('eur'); - }); -}); diff --git a/packages/stellar-wallet-snap/src/utils/currency.ts b/packages/stellar-wallet-snap/src/utils/currency.ts index 4262d0b47..d70d69b30 100644 --- a/packages/stellar-wallet-snap/src/utils/currency.ts +++ b/packages/stellar-wallet-snap/src/utils/currency.ts @@ -1,9 +1,5 @@ -import { is } from '@metamask/superstruct'; -import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { FiatCaipAssetStruct } from '../api/asset'; import { STELLAR_DECIMAL_PLACES } from '../constants'; /** @@ -134,29 +130,3 @@ export function tokenToFiat( const bigAmount = new BigNumber(tokenAmount); return bigAmount.multipliedBy(new BigNumber(rateConversion)).toString(); } - -/** - * Checks if a CAIP-19 asset type is a fiat asset. - * - * @param assetId - The CAIP-19 asset type. - * @returns True if the asset is a fiat asset, false otherwise. - */ -export function isFiat(assetId: CaipAssetType): boolean { - return is(assetId, FiatCaipAssetStruct); -} - -/** - * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset ID. - * - * @param assetId - The CAIP-19 asset ID. - * @returns The fiat ticker. - */ -export function getFiatTicker(assetId: CaipAssetType): string { - if (!isFiat(assetId)) { - throw new Error('Passed assetId is not a fiat asset'); - } - - const fiatTicker = parseCaipAssetType(assetId).assetReference.toLowerCase(); - - return fiatTicker; -}