diff --git a/packages/snaps-controllers/CHANGELOG.md b/packages/snaps-controllers/CHANGELOG.md index dfb4556f8d..1c8a443237 100644 --- a/packages/snaps-controllers/CHANGELOG.md +++ b/packages/snaps-controllers/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add support for `onAssetsMarketData` handler ([#3496](https://github.com/MetaMask/snaps/pull/3496)) + ## [13.1.1] ### Fixed diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 2a031d830d..cc6226eb97 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -4512,8 +4512,10 @@ describe('SnapController', () => { snapController.destroy(); }); + }); - it('returns the value when `onAssetsConversion` returns a valid response with market data', async () => { + describe('onAssetsMarketData', () => { + it('throws if `onAssetsMarketData` handler returns an invalid response', async () => { const rootMessenger = getControllerMessenger(); const messenger = getSnapControllerMessenger(rootMessenger); const snapController = getSnapController( @@ -4552,19 +4554,147 @@ describe('SnapController', () => { 'ExecutionService:handleRpcRequest', async () => Promise.resolve({ - conversionRates: { + marketData: { foo: {} }, + }), + ); + + await expect( + snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: METAMASK_ORIGIN, + handler: HandlerType.OnAssetsMarketData, + request: { + jsonrpc: '2.0', + method: ' ', + params: {}, + id: 1, + }, + }), + ).rejects.toThrow( + `Assertion failed: At path: marketData.foo -- Expected a value of type \`CaipAssetType\`, but received: \`"foo"\`.`, + ); + + snapController.destroy(); + }); + + it('filters out assets that are out of scope for `onAssetsMarketData`', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({ + [SnapEndowments.Assets]: { + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: ['bip122:000000000019d6689c085ae165831e93'], + }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Assets, + }, + }), + ); + + rootMessenger.registerActionHandler( + 'SubjectMetadataController:getSubjectMetadata', + () => MOCK_SNAP_SUBJECT_METADATA, + ); + + rootMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + async () => + Promise.resolve({ + marketData: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + 'eip155:1/slip44:60': { + fungible: true, + }, + }, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: METAMASK_ORIGIN, + handler: HandlerType.OnAssetsMarketData, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + assets: [ + { + asset: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + unit: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + ], + }, + id: 1, + }, + }), + ).toStrictEqual({ marketData: {} }); + + snapController.destroy(); + }); + + it('returns the value when `onAssetsMarketData` returns a valid response', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({ + [SnapEndowments.Assets]: { + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: ['bip122:000000000019d6689c085ae165831e93'], + }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Assets, + }, + }), + ); + + rootMessenger.registerActionHandler( + 'SubjectMetadataController:getSubjectMetadata', + () => MOCK_SNAP_SUBJECT_METADATA, + ); + + rootMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + async () => + Promise.resolve({ + marketData: { 'bip122:000000000019d6689c085ae165831e93/slip44:0': { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { - rate: '400', - conversionTime: 1737548790, - marketData: { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23 }, - }, + fungible: true, + marketCap: '10000', + totalVolume: '100000000', }, }, }, @@ -4575,36 +4705,28 @@ describe('SnapController', () => { await snapController.handleRequest({ snapId: MOCK_SNAP_ID, origin: METAMASK_ORIGIN, - handler: HandlerType.OnAssetsConversion, + handler: HandlerType.OnAssetsMarketData, request: { jsonrpc: '2.0', method: ' ', params: { - conversions: [ + assets: [ { - from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - to: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + asset: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + unit: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', }, ], - includeMarketData: true, }, id: 1, }, }), ).toStrictEqual({ - conversionRates: { + marketData: { 'bip122:000000000019d6689c085ae165831e93/slip44:0': { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { - rate: '400', - conversionTime: 1737548790, - marketData: { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23 }, - }, + fungible: true, + marketCap: '10000', + totalVolume: '100000000', }, }, }, diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index a9ceec5ac4..198c318789 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -55,6 +55,9 @@ import type { OnAssetsConversionArguments, AssetConversion, OnAssetsLookupArguments, + OnAssetsMarketDataArguments, + FungibleAssetMarketData, + OnAssetsMarketDataResponse, } from '@metamask/snaps-sdk'; import { AuxiliaryFileEncoding, @@ -105,6 +108,7 @@ import { isValidUrl, OnAssetHistoricalPriceResponseStruct, OnAssetsConversionResponseStruct, + OnAssetsMarketDataResponseStruct, } from '@metamask/snaps-utils'; import type { Json, @@ -3780,6 +3784,14 @@ export class SnapController extends BaseController< }, result as OnAssetsConversionResponse, ); + + case HandlerType.OnAssetsMarketData: + // We can cast since the request and result have already been validated. + return this.#transformOnAssetsMarketDataResult( + request as { params: OnAssetsMarketDataArguments }, + result as OnAssetsMarketDataResponse, + ); + default: return result; } @@ -3864,6 +3876,38 @@ export class SnapController extends BaseController< return { conversionRates: filteredConversionRates }; } + /** + * Transforms an RPC response coming from the `onAssetsMarketData` handler. + * + * This filters out responses that are out of scope for the Snap based on + * the incoming request. + * + * @param request - The request that returned the result. + * @param request.params - The parameters for the request. + * @param result - The result. + * @param result.marketData - The market data returned by the Snap. + * @returns The transformed result. + */ + #transformOnAssetsMarketDataResult( + { params: requestedParams }: { params: OnAssetsMarketDataArguments }, + { marketData }: OnAssetsMarketDataResponse, + ) { + const { assets: requestedAssets } = requestedParams; + + const filteredMarketData = requestedAssets.reduce< + Record> + >((accumulator, assets) => { + const result = marketData[assets.asset]?.[assets.unit]; + // Only include rates that were actually requested. + if (result) { + accumulator[assets.asset] ??= {}; + accumulator[assets.asset][assets.unit] = result; + } + return accumulator; + }, {}); + return { marketData: filteredMarketData }; + } + /** * Transforms a JSON-RPC request before sending it to the Snap, if required for a given handler. * @@ -3961,6 +4005,9 @@ export class SnapController extends BaseController< case HandlerType.OnAssetHistoricalPrice: assertStruct(result, OnAssetHistoricalPriceResponseStruct); break; + case HandlerType.OnAssetsMarketData: + assertStruct(result, OnAssetsMarketDataResponseStruct); + break; default: break; } diff --git a/packages/snaps-controllers/src/snaps/constants.ts b/packages/snaps-controllers/src/snaps/constants.ts index 791f41290a..412b7811c3 100644 --- a/packages/snaps-controllers/src/snaps/constants.ts +++ b/packages/snaps-controllers/src/snaps/constants.ts @@ -43,5 +43,6 @@ export const CLIENT_ONLY_HANDLERS = Object.freeze([ HandlerType.OnAssetsLookup, HandlerType.OnAssetsConversion, HandlerType.OnAssetHistoricalPrice, + HandlerType.OnAssetsMarketData, HandlerType.OnWebSocketEvent, ]); diff --git a/packages/snaps-execution-environments/CHANGELOG.md b/packages/snaps-execution-environments/CHANGELOG.md index 85deaf9732..d002cb675f 100644 --- a/packages/snaps-execution-environments/CHANGELOG.md +++ b/packages/snaps-execution-environments/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **BREAKING:** Market data is now fetched through `onAssetsMarketData` instead + of `onAssetConversion` + - Previously, `onAssetConversion` could return a `marketData` property, which + contained market data for the asset being converted. This property + has been removed, and `onAssetsMarketData` should be used instead. + ## [9.1.0] ### Added diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts index b81728db3b..90b344fed9 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1608,9 +1608,9 @@ describe('BaseSnapExecutor', () => { }); }); - it('supports `onAssetsConversion` export with the market data flag', async () => { + it('supports `onAssetsMarketData` export', async () => { const CODE = ` - module.exports.onAssetsConversion = () => ({ conversionRates: {} }); + module.exports.onAssetsMarketData = () => ({ marketData: {} }); `; const executor = new TestSnapExecutor(); @@ -1628,19 +1628,18 @@ describe('BaseSnapExecutor', () => { method: 'snapRpc', params: [ MOCK_SNAP_ID, - HandlerType.OnAssetsConversion, + HandlerType.OnAssetsMarketData, MOCK_ORIGIN, { jsonrpc: '2.0', method: '', params: { - conversions: [ + assets: [ { - from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - to: 'eip155:1/slip44:60', + asset: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + unit: 'eip155:1/slip44:60', }, ], - includeMarketData: true, }, }, ], @@ -1649,7 +1648,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ id: 2, jsonrpc: '2.0', - result: { conversionRates: {} }, + result: { marketData: {} }, }); }); diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index bc1c574248..31b9640d7c 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -20,6 +20,7 @@ import { assertIsOnProtocolRequestArguments, assertIsOnAssetHistoricalPriceRequestArguments, assertIsOnWebSocketEventArguments, + assertIsOnAssetsMarketDataRequestArguments, } from './validation'; export type CommandMethodsMapping = { @@ -75,9 +76,16 @@ export function getHandlerArguments( } case HandlerType.OnAssetsConversion: { assertIsOnAssetsConversionRequestArguments(request.params); - const { conversions, includeMarketData } = request.params; - return { conversions, includeMarketData }; + const { conversions } = request.params; + return { conversions }; } + + case HandlerType.OnAssetsMarketData: { + assertIsOnAssetsMarketDataRequestArguments(request.params); + const { assets } = request.params; + return { assets }; + } + case HandlerType.OnNameLookup: { assertIsOnNameLookupRequestArguments(request.params); diff --git a/packages/snaps-execution-environments/src/common/validation.test.ts b/packages/snaps-execution-environments/src/common/validation.test.ts index 94e43427c4..476106533c 100644 --- a/packages/snaps-execution-environments/src/common/validation.test.ts +++ b/packages/snaps-execution-environments/src/common/validation.test.ts @@ -4,6 +4,7 @@ import { assertIsOnAssetHistoricalPriceRequestArguments, assertIsOnAssetsConversionRequestArguments, assertIsOnAssetsLookupRequestArguments, + assertIsOnAssetsMarketDataRequestArguments, assertIsOnNameLookupRequestArguments, assertIsOnProtocolRequestArguments, assertIsOnSignatureRequestArguments, @@ -283,24 +284,6 @@ describe('assertIsOnAssetsConversionRequestArguments', () => { }, ], }, - { - conversions: [ - { - from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - to: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - }, - ], - includeMarketData: true, - }, - { - conversions: [ - { - from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - to: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - }, - ], - includeMarketData: false, - }, ])('does not throw for a valid assets conversion param object', (value) => { expect(() => assertIsOnAssetsConversionRequestArguments(value), @@ -323,8 +306,6 @@ describe('assertIsOnAssetsConversionRequestArguments', () => { { conversions: [{}] }, { conversions: [{ from: 'foo' }] }, { conversions: [{ from: 'foo', to: 'foo' }] }, - { includeMarketData: true }, - { includeMarketData: false }, ])( 'throws if the value is not a valid assets conversion params object', (value) => { @@ -335,6 +316,48 @@ describe('assertIsOnAssetsConversionRequestArguments', () => { ); }); +describe('assertIsOnAssetsMarketDataRequestArguments', () => { + it.each([ + { + assets: [ + { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'swift:0/iso4217:USD', + }, + ], + }, + ])('does not throw for a valid assets market data param object', (value) => { + expect(() => + assertIsOnAssetsMarketDataRequestArguments(value), + ).not.toThrow(); + }); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'foo', + [], + {}, + { assets: [] }, + { assets: ['foo'] }, + { assets: [{}] }, + { assets: [{ asset: 'foo' }] }, + { assets: [{ asset: 'foo', unit: 'foo' }] }, + ])( + 'throws if the value is not a valid assets market data params object', + (value) => { + expect(() => + assertIsOnAssetsMarketDataRequestArguments(value as any), + ).toThrow('Invalid request params:'); + }, + ); +}); + describe('assertIsOnProtocolRequestArguments', () => { it.each([ { diff --git a/packages/snaps-execution-environments/src/common/validation.ts b/packages/snaps-execution-environments/src/common/validation.ts index b3cbdd8bea..b23b8d4b90 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -255,6 +255,37 @@ export function assertIsOnAssetHistoricalPriceRequestArguments( assertRequestArguments(value, OnAssetHistoricalPriceRequestArgumentsStruct); } +export const OnAssetsMarketDataRequestArgumentsStruct = object({ + assets: size( + array( + object({ + asset: CaipAssetTypeStruct, + unit: CaipAssetTypeStruct, + }), + ), + 1, + Infinity, + ), +}); + +export type OnAssetsMarketDataRequestArguments = Infer< + typeof OnAssetsMarketDataRequestArgumentsStruct +>; + +/** + * Asserts that the given value is a valid {@link OnAssetsMarketDataRequestArguments} + * object. + * + * @param value - The value to validate. + * @throws If the value is not a valid {@link OnAssetsMarketDataRequestArguments} + * object. + */ +export function assertIsOnAssetsMarketDataRequestArguments( + value: unknown, +): asserts value is OnAssetsMarketDataRequestArguments { + assertRequestArguments(value, OnAssetsMarketDataRequestArgumentsStruct); +} + export const OnAssetsLookupRequestArgumentsStruct = object({ assets: size(array(CaipAssetTypeStruct), 1, Infinity), }); @@ -288,7 +319,6 @@ export const OnAssetsConversionRequestArgumentsStruct = object({ 1, Infinity, ), - includeMarketData: optional(boolean()), }); export type OnAssetsConversionRequestArguments = Infer< diff --git a/packages/snaps-rpc-methods/CHANGELOG.md b/packages/snaps-rpc-methods/CHANGELOG.md index a0c06fb486..825ddb6fea 100644 --- a/packages/snaps-rpc-methods/CHANGELOG.md +++ b/packages/snaps-rpc-methods/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add support for `onAssetsMarketData` handler ([#3496](https://github.com/MetaMask/snaps/pull/3496)) + ## [13.1.0] ### Added diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index c656c73a9e..843a8b5db2 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -130,6 +130,7 @@ export const handlerEndowments: Record = { [HandlerType.OnAssetHistoricalPrice]: assetsEndowmentBuilder.targetName, [HandlerType.OnAssetsLookup]: assetsEndowmentBuilder.targetName, [HandlerType.OnAssetsConversion]: assetsEndowmentBuilder.targetName, + [HandlerType.OnAssetsMarketData]: assetsEndowmentBuilder.targetName, [HandlerType.OnProtocolRequest]: protocolEndowmentBuilder.targetName, [HandlerType.OnClientRequest]: null, [HandlerType.OnWebSocketEvent]: networkAccessEndowmentBuilder.targetName, diff --git a/packages/snaps-sdk/CHANGELOG.md b/packages/snaps-sdk/CHANGELOG.md index 47c96110d3..6623b82c14 100644 --- a/packages/snaps-sdk/CHANGELOG.md +++ b/packages/snaps-sdk/CHANGELOG.md @@ -7,12 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **BREAKING:** Market data is now fetched through `onAssetsMarketData` instead + of `onAssetConversion` + - Previously, `onAssetConversion` could return a `marketData` property, which + contained market data for the asset being converted. This property + has been removed, and `onAssetsMarketData` should be used instead. + - The `MarketData` type has been replaced with `FungibleAssetMarketData`. + ## [8.1.0] ### Added - Add WebSockets support ([#3450](https://github.com/MetaMask/snaps/pull/3450), [#3459](https://github.com/MetaMask/snaps/pull/3459)) - - This introduces types for the `onWebSocketEvent` handler which receives + - This introduces types for the `onWebSocketEvent` handler which receives events from `WebSocketService`. - Add types for `onStart` handler ([#3455](https://github.com/MetaMask/snaps/pull/3455)) @@ -541,7 +548,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve support for Snap errors without a message ([#2176](https://github.com/MetaMask/snaps/pull/2176)) - You can now add data to an error without having to specify a message. For example: ```ts - throw new MethodNotFoundError({ method: "some method name" }); + throw new MethodNotFoundError({ method: 'some method name' }); ``` - Strip empty `data` from Snap errors ([#2179](https://github.com/MetaMask/snaps/pull/2179)) diff --git a/packages/snaps-sdk/src/types/handlers/assets-conversion.ts b/packages/snaps-sdk/src/types/handlers/assets-conversion.ts index 4d3478d069..0fcee87f7d 100644 --- a/packages/snaps-sdk/src/types/handlers/assets-conversion.ts +++ b/packages/snaps-sdk/src/types/handlers/assets-conversion.ts @@ -1,39 +1,13 @@ import { type CaipAssetType } from '@metamask/utils'; - -/** - * The market data for an asset. - * - * @property marketCap - The market capitalization of the asset. - * @property totalVolume - The total volume of the asset. - * @property circulatingSupply - The circulating supply of the asset. - * @property allTimeHigh - The all-time high price of the asset. - * @property allTimeLow - The all-time low price of the asset. - * @property pricePercentChange - The percentage change in price over different intervals. - * @property pricePercentChange.interval - The time interval for the price change as a ISO 8601 duration - * or the string "all" to represent the all-time change. - */ -export type MarketData = { - marketCap: string; - totalVolume: string; - circulatingSupply: string; - allTimeHigh: string; - allTimeLow: string; - pricePercentChange: { - [interval: string]: number; - }; -}; - /** * The conversion rate between two assets. * * @property rate - The conversion rate between the two assets. - * @property marketData - The market data for the asset, if requested. * @property conversionTime - The time at which the conversion rate was calculated. * @property expirationTime - The time at which the conversion rate expires. */ export type AssetConversion = { rate: string; - marketData?: MarketData; conversionTime: number; expirationTime?: number; }; @@ -46,7 +20,6 @@ export type AssetConversion = { */ export type OnAssetsConversionArguments = { conversions: { from: CaipAssetType; to: CaipAssetType }[]; - includeMarketData?: boolean; }; /** diff --git a/packages/snaps-sdk/src/types/handlers/assets-market-data.ts b/packages/snaps-sdk/src/types/handlers/assets-market-data.ts new file mode 100644 index 0000000000..97e135c84d --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/assets-market-data.ts @@ -0,0 +1,64 @@ +import type { CaipAssetType } from '@metamask/utils'; + +/** + * The market data for a fungible asset. + * + * @property fungible - Indicates that this is a fungible asset. + * This is always `true` for fungible assets. + * @property marketCap - The market capitalization of the asset. + * @property totalVolume - The total volume of the asset. + * @property circulatingSupply - The circulating supply of the asset. + * @property allTimeHigh - The all-time high price of the asset. + * @property allTimeLow - The all-time low price of the asset. + * @property pricePercentChange - The percentage change in price over different intervals. + * @property pricePercentChange.interval - The time interval for the price change as a ISO 8601 duration + * or the string "all" to represent the all-time change. + */ +export type FungibleAssetMarketData = { + fungible: true; + marketCap?: string; + totalVolume?: string; + circulatingSupply?: string; + allTimeHigh?: string; + allTimeLow?: string; + pricePercentChange?: { + [interval: string]: number; + }; +}; + +/** + * The arguments for the `onAssetsMarketData` handler. + * + * @property assets - An array of objects containing the asset and unit types. + * @property assets.asset - The CAIP-19 asset type of the asset. + * @property assets.unit - The CAIP-19 asset type of the unit to use. + */ +export type OnAssetsMarketDataArguments = { + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[]; +}; + +/** + * The `onAssetsMarketData` handler. This is called by MetaMask when querying about market data for a specific asset. + * + * @param args - The arguments for the handler. + * see {@link OnAssetsMarketDataArguments}. + * @returns The market data for the asset. See {@link OnAssetsMarketDataResponse}. + */ +export type OnAssetsMarketDataHandler = ( + args: OnAssetsMarketDataArguments, +) => Promise; + +/** + * The response from the market data query, containing market data for the requested assets. + * + * @property marketData - A nested object with two CAIP-19 keys that contains a {@link FungibleAssetMarketData} object or null between the two keys. + */ +export type OnAssetsMarketDataResponse = { + marketData: Record< + CaipAssetType, + Record + >; +}; diff --git a/packages/snaps-sdk/src/types/handlers/index.ts b/packages/snaps-sdk/src/types/handlers/index.ts index fde6291b77..74eff8227f 100644 --- a/packages/snaps-sdk/src/types/handlers/index.ts +++ b/packages/snaps-sdk/src/types/handlers/index.ts @@ -1,5 +1,6 @@ export type * from './asset-historical-price'; export type * from './assets-conversion'; +export type * from './assets-market-data'; export * from './assets-lookup'; export type * from './client-request'; export type * from './cronjob'; diff --git a/packages/snaps-utils/CHANGELOG.md b/packages/snaps-utils/CHANGELOG.md index d19ea9ccdc..1563f17e6a 100644 --- a/packages/snaps-utils/CHANGELOG.md +++ b/packages/snaps-utils/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **BREAKING:** Market data is now fetched through `onAssetsMarketData` instead + of `onAssetConversion` + - Previously, `onAssetConversion` could return a `marketData` property, which + contained market data for the asset being converted. This property + has been removed, and `onAssetsMarketData` should be used instead. + - The `MarketDataStruct` is now replaced by the `FungibleAssetMarketDataStruct` struct. + ## [10.1.0] ### Added diff --git a/packages/snaps-utils/src/handlers/assets-conversion.test.ts b/packages/snaps-utils/src/handlers/assets-conversion.test.ts index 87897a8ec8..d228b26ea3 100644 --- a/packages/snaps-utils/src/handlers/assets-conversion.test.ts +++ b/packages/snaps-utils/src/handlers/assets-conversion.test.ts @@ -2,87 +2,13 @@ import { is } from '@metamask/superstruct'; import { AssetConversionStruct, - MarketDataStruct, OnAssetsConversionResponseStruct, - PricePercentChangeStruct, } from './assets-conversion'; -describe('PricePercentChangeStruct', () => { - it.each([ - { all: 1.23 }, - { P1Y: 1.23, P1M: 1.23 }, - { all: 1.23, P1Y: 1.23, P1M: 1.23 }, - ])('validates "%p"', (value) => { - expect(is(value, PricePercentChangeStruct)).toBe(true); - }); - - it.each(['foo', 42, null, undefined, {}, [], { all: 'foo' }])( - 'does not validate "%p"', - (value) => { - expect(is(value, PricePercentChangeStruct)).toBe(false); - }, - ); -}); - -describe('MarketDataStruct', () => { - it.each([ - { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23 }, - }, - { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23, P1Y: 1.23 }, - }, - ])('validates "%p"', (value) => { - expect(is(value, MarketDataStruct)).toBe(true); - }); - - it.each(['foo', 42, null, undefined, {}, [], { allTimeHigh: 123 }])( - 'does not validate "%p"', - (value) => { - expect(is(value, MarketDataStruct)).toBe(false); - }, - ); -}); - describe('AssetConversionStruct', () => { it.each([ { rate: '1.23', conversionTime: 1737542312, expirationTime: 1737542312 }, { rate: '1.23', conversionTime: 1737542312 }, - { - rate: '1.23', - conversionTime: 1737542312, - expirationTime: 1737542312, - marketData: { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23 }, - }, - }, - { - rate: '1.23', - conversionTime: 1737542312, - marketData: { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23, P1Y: 1.23 }, - }, - }, ])('validates an object', (value) => { expect(is(value, AssetConversionStruct)).toBe(true); }); @@ -102,26 +28,6 @@ describe('AssetConversionStruct', () => { describe('OnAssetsConversionResponseStruct', () => { it.each([ - { - conversionRates: { - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': - { - 'swift:0/iso4217:USD': { - rate: '1.23', - conversionTime: 1737542312, - expirationTime: 1737542312, - marketData: { - marketCap: '123', - totalVolume: '123', - circulatingSupply: '123', - allTimeHigh: '123', - allTimeLow: '123', - pricePercentChange: { all: 1.23 }, - }, - }, - }, - }, - }, { conversionRates: { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': diff --git a/packages/snaps-utils/src/handlers/assets-conversion.ts b/packages/snaps-utils/src/handlers/assets-conversion.ts index 5469297a72..2b733d1eeb 100644 --- a/packages/snaps-utils/src/handlers/assets-conversion.ts +++ b/packages/snaps-utils/src/handlers/assets-conversion.ts @@ -1,44 +1,18 @@ -import { nonEmptyRecord } from '@metamask/snaps-sdk'; import { - literal, nullable, number, object, optional, record, string, - union, } from '@metamask/superstruct'; import { CaipAssetTypeStruct } from '@metamask/utils'; -import { ISO8601DurationStruct } from '../time'; - -/** - * A struct representing the market data for an asset. - */ -export const PricePercentChangeStruct = nonEmptyRecord( - union([literal('all'), ISO8601DurationStruct]), - number(), -); - -/** - * A struct representing the market data for an asset. - */ -export const MarketDataStruct = object({ - marketCap: string(), - totalVolume: string(), - circulatingSupply: string(), - allTimeHigh: string(), - allTimeLow: string(), - pricePercentChange: optional(PricePercentChangeStruct), -}); - /** * A struct representing the conversion rate between two assets. */ export const AssetConversionStruct = object({ rate: string(), - marketData: optional(MarketDataStruct), conversionTime: number(), expirationTime: optional(number()), }); diff --git a/packages/snaps-utils/src/handlers/assets-market-data.test.ts b/packages/snaps-utils/src/handlers/assets-market-data.test.ts new file mode 100644 index 0000000000..f0cd5c1790 --- /dev/null +++ b/packages/snaps-utils/src/handlers/assets-market-data.test.ts @@ -0,0 +1,110 @@ +import { is } from '@metamask/superstruct'; + +import { + FungibleAssetMarketDataStruct, + OnAssetsMarketDataResponseStruct, + PricePercentChangeStruct, +} from './assets-market-data'; + +describe('PricePercentChangeStruct', () => { + it.each([ + { all: 1.23 }, + { P1Y: 1.23, P1M: 1.23 }, + { all: 1.23, P1Y: 1.23, P1M: 1.23 }, + ])('validates "%p"', (value) => { + expect(is(value, PricePercentChangeStruct)).toBe(true); + }); + + it.each(['foo', 42, null, undefined, {}, [], { all: 'foo' }])( + 'does not validate "%p"', + (value) => { + expect(is(value, PricePercentChangeStruct)).toBe(false); + }, + ); +}); + +describe('FungibleAssetMarketDataStruct', () => { + it.each([ + { + fungible: true, + marketCap: '123', + totalVolume: '123', + circulatingSupply: '123', + allTimeHigh: '123', + allTimeLow: '123', + pricePercentChange: { all: 1.23 }, + }, + { + fungible: true, + marketCap: '123', + totalVolume: '123', + circulatingSupply: '123', + allTimeHigh: '123', + allTimeLow: '123', + pricePercentChange: { all: 1.23, P1Y: 1.23 }, + }, + { + fungible: true, + marketCap: '123', + totalVolume: '123', + circulatingSupply: '123', + allTimeHigh: '123', + pricePercentChange: { all: 1.23, P1Y: 1.23 }, + }, + { fungible: true }, + ])('validates "%p"', (value) => { + expect(is(value, FungibleAssetMarketDataStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + { allTimeHigh: 123 }, + { fungible: false }, + ])('does not validate "%p"', (value) => { + expect(is(value, FungibleAssetMarketDataStruct)).toBe(false); + }); +}); + +describe('OnAssetsMarketDataResponseStruct', () => { + it.each([ + { + marketData: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + 'swift:0/iso4217:USD': { + fungible: true, + marketCap: '123', + totalVolume: '123', + circulatingSupply: '123', + allTimeHigh: '123', + allTimeLow: '123', + pricePercentChange: { all: 1.23 }, + }, + }, + }, + }, + { + marketData: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + 'swift:0/iso4217:USD': null, + }, + }, + }, + { marketData: {} }, + ])('validates "%p"', (value) => { + expect(is(value, OnAssetsMarketDataResponseStruct)).toBe(true); + }); + + it.each(['foo', 42, null, undefined, {}, [], { marketData: 123 }])( + 'does not validate "%p"', + (value) => { + expect(is(value, OnAssetsMarketDataResponseStruct)).toBe(false); + }, + ); +}); diff --git a/packages/snaps-utils/src/handlers/assets-market-data.ts b/packages/snaps-utils/src/handlers/assets-market-data.ts new file mode 100644 index 0000000000..2e568a59b7 --- /dev/null +++ b/packages/snaps-utils/src/handlers/assets-market-data.ts @@ -0,0 +1,45 @@ +import { nonEmptyRecord } from '@metamask/snaps-sdk'; +import { + literal, + nullable, + number, + object, + optional, + record, + string, + union, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +import { ISO8601DurationStruct } from '../time'; + +/** + * A struct representing the market data for an asset. + */ +export const PricePercentChangeStruct = nonEmptyRecord( + union([literal('all'), ISO8601DurationStruct]), + number(), +); + +/** + * A struct representing the market data for an asset. + */ +export const FungibleAssetMarketDataStruct = object({ + fungible: literal(true), + marketCap: optional(string()), + totalVolume: optional(string()), + circulatingSupply: optional(string()), + allTimeHigh: optional(string()), + allTimeLow: optional(string()), + pricePercentChange: optional(PricePercentChangeStruct), +}); + +/** + * A struct representing the response of the `onAssetsMarketData` method. + */ +export const OnAssetsMarketDataResponseStruct = object({ + marketData: record( + CaipAssetTypeStruct, + record(CaipAssetTypeStruct, nullable(FungibleAssetMarketDataStruct)), + ), +}); diff --git a/packages/snaps-utils/src/handlers/exports.test.ts b/packages/snaps-utils/src/handlers/exports.test.ts index a89da358b8..3496300aec 100644 --- a/packages/snaps-utils/src/handlers/exports.test.ts +++ b/packages/snaps-utils/src/handlers/exports.test.ts @@ -30,6 +30,7 @@ describe('SNAP_EXPORT_NAMES', () => { 'onAssetsLookup', 'onAssetsConversion', 'onAssetHistoricalPrice', + 'onAssetsMarketData', 'onProtocolRequest', 'onClientRequest', 'onWebSocketEvent', diff --git a/packages/snaps-utils/src/handlers/exports.ts b/packages/snaps-utils/src/handlers/exports.ts index 82aaf19de1..f817f2436e 100644 --- a/packages/snaps-utils/src/handlers/exports.ts +++ b/packages/snaps-utils/src/handlers/exports.ts @@ -2,6 +2,7 @@ import type { OnAssetHistoricalPriceHandler, OnAssetsConversionHandler, OnAssetsLookupHandler, + OnAssetsMarketDataHandler, OnClientRequestHandler, OnCronjobHandler, OnHomePageHandler, @@ -131,6 +132,15 @@ export const SNAP_EXPORTS = { return typeof snapExport === 'function'; }, }, + [HandlerType.OnAssetsMarketData]: { + type: HandlerType.OnAssetsMarketData, + required: true, + validator: ( + snapExport: unknown, + ): snapExport is OnAssetsMarketDataHandler => { + return typeof snapExport === 'function'; + }, + }, [HandlerType.OnProtocolRequest]: { type: HandlerType.OnProtocolRequest, required: true, diff --git a/packages/snaps-utils/src/handlers/index.ts b/packages/snaps-utils/src/handlers/index.ts index eae90e4406..638126f14f 100644 --- a/packages/snaps-utils/src/handlers/index.ts +++ b/packages/snaps-utils/src/handlers/index.ts @@ -1,5 +1,6 @@ export * from './asset-historical-price'; export * from './assets-conversion'; +export * from './assets-market-data'; export * from './exports'; export * from './home-page'; export * from './name-lookup'; diff --git a/packages/snaps-utils/src/handlers/types.ts b/packages/snaps-utils/src/handlers/types.ts index af3ad495f8..d6690ac0f8 100644 --- a/packages/snaps-utils/src/handlers/types.ts +++ b/packages/snaps-utils/src/handlers/types.ts @@ -44,6 +44,7 @@ export enum HandlerType { OnAssetsLookup = 'onAssetsLookup', OnAssetsConversion = 'onAssetsConversion', OnAssetHistoricalPrice = 'onAssetHistoricalPrice', + OnAssetsMarketData = 'onAssetsMarketData', OnProtocolRequest = 'onProtocolRequest', OnClientRequest = 'onClientRequest', OnWebSocketEvent = 'onWebSocketEvent',