diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index dfab807b4a..14f89698f5 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.41, + "branches": 93.42, "functions": 97.38, "lines": 98.34, - "statements": 98.07 + "statements": 98.08 } diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 0dfd201646..6cf674e691 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -4354,6 +4354,145 @@ describe('SnapController', () => { }); }); + describe('onAssetHistoricalPrice', () => { + it('throws if `onAssetHistoricalPrice` handler returns an invalid 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({ + historicalPrice: { foo: {} }, + }), + ); + + await expect( + snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + handler: HandlerType.OnAssetHistoricalPrice, + request: { + jsonrpc: '2.0', + method: ' ', + params: {}, + id: 1, + }, + }), + ).rejects.toThrow( + `Assertion failed: At path: historicalPrice.intervals -- Expected an object, but received: undefined.`, + ); + + snapController.destroy(); + }); + + it('returns the value when `onAssetHistoricalPrice` 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({ + historicalPrice: { + intervals: { + P1D: [[1737548790, '400']], + }, + updateTime: 1737548790, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + handler: HandlerType.OnAssetHistoricalPrice, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + to: 'swift:0/iso4217:USD', + }, + id: 1, + }, + }), + ).toStrictEqual({ + historicalPrice: { + intervals: { + P1D: [[1737548790, '400']], + }, + updateTime: 1737548790, + }, + }); + + snapController.destroy(); + }); + }); + describe('getRpcRequestHandler', () => { it('handlers populate the "jsonrpc" property if missing', async () => { const rootMessenger = getControllerMessenger(); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index ddd85137d5..49c7cdf5e1 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -104,6 +104,7 @@ import { MAX_FILE_SIZE, OnSettingsPageResponseStruct, isValidUrl, + OnAssetHistoricalPriceResponseStruct, } from '@metamask/snaps-utils'; import type { Json, @@ -3820,6 +3821,9 @@ export class SnapController extends BaseController< case HandlerType.OnAssetsConversion: assertStruct(result, OnAssetsConversionResponseStruct); break; + case HandlerType.OnAssetHistoricalPrice: + assertStruct(result, OnAssetHistoricalPriceResponseStruct); + break; default: break; } diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index 7907537cbd..4dbe385a1f 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,6 +1,6 @@ { - "branches": 80.53, - "functions": 88.96, - "lines": 90.69, - "statements": 89.74 + "branches": 80.66, + "functions": 89.1, + "lines": 90.78, + "statements": 89.72 } 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 efd0935491..c2891b12e9 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1473,6 +1473,60 @@ describe('BaseSnapExecutor', () => { }); }); + it('supports `onAssetHistoricalPrice` export', async () => { + const CODE = ` + module.exports.onAssetHistoricalPrice = () => ({ historicalPrice: { + intervals: { + 'P1D': [ + [1635724800000, "1"], + ] + }, + updateTime: 1635724800000, + } }); + `; + + const executor = new TestSnapExecutor(); + await executor.executeSnap(1, MOCK_SNAP_ID, CODE, []); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'OK', + }); + + await executor.writeCommand({ + jsonrpc: '2.0', + id: 2, + method: 'snapRpc', + params: [ + MOCK_SNAP_ID, + HandlerType.OnAssetHistoricalPrice, + MOCK_ORIGIN, + { + jsonrpc: '2.0', + method: '', + params: { + from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + to: 'swift:0/iso4217:USD', + }, + }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: { + historicalPrice: { + intervals: { + P1D: [[1635724800000, '1']], + }, + updateTime: 1635724800000, + }, + }, + }); + }); + it('supports `onAssetsLookup` export', async () => { const CODE = ` module.exports.onAssetsLookup = () => ({ assets: {} }); diff --git a/packages/snaps-execution-environments/src/common/commands.test.ts b/packages/snaps-execution-environments/src/common/commands.test.ts index 2f10b780ef..068914e495 100644 --- a/packages/snaps-execution-environments/src/common/commands.test.ts +++ b/packages/snaps-execution-environments/src/common/commands.test.ts @@ -41,6 +41,17 @@ describe('getHandlerArguments', () => { ).toThrow('Invalid request params'); }); + it('validates the request params for the OnAssetHistoricalPrice handler', () => { + expect(() => + getHandlerArguments(MOCK_ORIGIN, HandlerType.OnAssetHistoricalPrice, { + id: 1, + jsonrpc: '2.0', + method: 'foo', + params: {}, + }), + ).toThrow('Invalid request params'); + }); + it('throws for invalid handler types', () => { expect(() => // @ts-expect-error Invalid handler type. diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index db7b2f34d0..588c7fe8f4 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -18,6 +18,7 @@ import { assertIsOnAssetsLookupRequestArguments, assertIsOnAssetsConversionRequestArguments, assertIsOnProtocolRequestArguments, + assertIsOnAssetHistoricalPriceRequestArguments, } from './validation'; export type CommandMethodsMapping = { @@ -59,6 +60,13 @@ export function getHandlerArguments( const { signature, signatureOrigin } = request.params; return { signature, signatureOrigin }; } + + case HandlerType.OnAssetHistoricalPrice: { + assertIsOnAssetHistoricalPriceRequestArguments(request.params); + const { from, to } = request.params; + return { from, to }; + } + case HandlerType.OnAssetsLookup: { assertIsOnAssetsLookupRequestArguments(request.params); const { assets } = 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 c3cec65b34..f1ef61c9cb 100644 --- a/packages/snaps-execution-environments/src/common/validation.test.ts +++ b/packages/snaps-execution-environments/src/common/validation.test.ts @@ -1,6 +1,7 @@ import { UserInputEventType } from '@metamask/snaps-sdk'; import { + assertIsOnAssetHistoricalPriceRequestArguments, assertIsOnAssetsConversionRequestArguments, assertIsOnAssetsLookupRequestArguments, assertIsOnNameLookupRequestArguments, @@ -378,3 +379,48 @@ describe('assertIsOnProtocolRequestArguments', () => { }, ); }); + +describe('assertIsOnAssetHistoricalPriceRequestArguments', () => { + it.each([ + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + }, + ])( + 'does not throw for a valid asset historical price request object', + (args) => { + expect(() => + assertIsOnAssetHistoricalPriceRequestArguments(args), + ).not.toThrow(); + }, + ); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'foo', + [], + {}, + { from: [], to: 'swift:0/iso4217:USD' }, + { to: 'swift:0/iso4217:USD', from: 'foo' }, + { from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' }, + { to: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' }, + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + foo: 'bar', + }, + ])( + 'throws if the value is not a valid asset historical price request object', + (value) => { + expect(() => + assertIsOnAssetHistoricalPriceRequestArguments(value as any), + ).toThrow('Invalid request params:'); + }, + ); +}); diff --git a/packages/snaps-execution-environments/src/common/validation.ts b/packages/snaps-execution-environments/src/common/validation.ts index 1e772c558f..9f37ab9665 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -124,6 +124,25 @@ export type RequestArguments = | ExecuteSnapRequestArguments | SnapRpcRequestArguments; +/** + * Asserts that the given value is a valid request arguments object. + * + * @param value - The value to validate. + * @param struct - The struct to validate the value against. + * @throws If the value is not a valid request arguments object. + */ +function assertRequestArguments( + value: unknown, + struct: Struct, +): asserts value is Struct { + assertStruct( + value, + struct, + 'Invalid request params', + rpcErrors.invalidParams, + ); +} + export const OnTransactionRequestArgumentsStruct = object({ // TODO: Improve `transaction` type. transaction: record(string(), JsonStruct), @@ -146,12 +165,7 @@ export type OnTransactionRequestArguments = Infer< export function assertIsOnTransactionRequestArguments( value: unknown, ): asserts value is OnTransactionRequestArguments { - assertStruct( - value, - OnTransactionRequestArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnTransactionRequestArgumentsStruct); } export const OnSignatureRequestArgumentsStruct = object({ @@ -174,12 +188,7 @@ export type OnSignatureRequestArguments = Infer< export function assertIsOnSignatureRequestArguments( value: unknown, ): asserts value is OnSignatureRequestArguments { - assertStruct( - value, - OnSignatureRequestArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnSignatureRequestArgumentsStruct); } const baseNameLookupArgs = { chainId: CaipChainIdStruct }; @@ -217,12 +226,30 @@ export type PossibleLookupRequestArgs = typeof baseNameLookupArgs & { export function assertIsOnNameLookupRequestArguments( value: unknown, ): asserts value is OnNameLookupRequestArguments { - assertStruct( - value, - OnNameLookupRequestArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnNameLookupRequestArgumentsStruct); +} + +export const OnAssetHistoricalPriceRequestArgumentsStruct = object({ + from: CaipAssetTypeStruct, + to: CaipAssetTypeStruct, +}); + +export type OnAssetHistoricalPriceRequestArguments = Infer< + typeof OnAssetHistoricalPriceRequestArgumentsStruct +>; + +/** + * Asserts that the given value is a valid {@link OnAssetHistoricalPriceRequestArguments} + * object. + * + * @param value - The value to validate. + * @throws If the value is not a valid {@link OnAssetHistoricalPriceRequestArguments} + * object. + */ +export function assertIsOnAssetHistoricalPriceRequestArguments( + value: unknown, +): asserts value is OnAssetHistoricalPriceRequestArguments { + assertRequestArguments(value, OnAssetHistoricalPriceRequestArgumentsStruct); } export const OnAssetsLookupRequestArgumentsStruct = object({ @@ -244,12 +271,7 @@ export type OnAssetsLookupRequestArguments = Infer< export function assertIsOnAssetsLookupRequestArguments( value: unknown, ): asserts value is OnAssetsLookupRequestArguments { - assertStruct( - value, - OnAssetsLookupRequestArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnAssetsLookupRequestArgumentsStruct); } export const OnAssetsConversionRequestArgumentsStruct = object({ @@ -280,12 +302,7 @@ export type OnAssetsConversionRequestArguments = Infer< export function assertIsOnAssetsConversionRequestArguments( value: unknown, ): asserts value is OnAssetsConversionRequestArguments { - assertStruct( - value, - OnAssetsConversionRequestArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnAssetsConversionRequestArgumentsStruct); } export const OnUserInputArgumentsStruct = object({ @@ -307,12 +324,7 @@ export type OnUserInputArguments = Infer; export function assertIsOnUserInputRequestArguments( value: unknown, ): asserts value is OnUserInputArguments { - assertStruct( - value, - OnUserInputArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnUserInputArgumentsStruct); } export const OnProtocolRequestArgumentsStruct = object({ @@ -335,12 +347,7 @@ export type OnProtocolRequestArguments = Infer< export function assertIsOnProtocolRequestArguments( value: unknown, ): asserts value is OnProtocolRequestArguments { - assertStruct( - value, - OnProtocolRequestArgumentsStruct, - 'Invalid request params', - rpcErrors.invalidParams, - ); + assertRequestArguments(value, OnProtocolRequestArgumentsStruct); } // TODO: Either fix this lint violation or explain why it's necessary to ignore. diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index c4d435d31e..60c68b1c01 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -126,6 +126,7 @@ export const handlerEndowments: Record = { [HandlerType.OnSettingsPage]: settingsPageEndowmentBuilder.targetName, [HandlerType.OnSignature]: signatureInsightEndowmentBuilder.targetName, [HandlerType.OnUserInput]: null, + [HandlerType.OnAssetHistoricalPrice]: assetsEndowmentBuilder.targetName, [HandlerType.OnAssetsLookup]: assetsEndowmentBuilder.targetName, [HandlerType.OnAssetsConversion]: assetsEndowmentBuilder.targetName, [HandlerType.OnProtocolRequest]: protocolEndowmentBuilder.targetName, diff --git a/packages/snaps-sdk/src/types/handlers/asset-historical-price.ts b/packages/snaps-sdk/src/types/handlers/asset-historical-price.ts new file mode 100644 index 0000000000..f14ab58216 --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/asset-historical-price.ts @@ -0,0 +1,52 @@ +import type { CaipAssetType } from '@metamask/utils'; + +/** + * The historical price value. + * The first element in the array is the timestamp, the second is the price. + */ +export type HistoricalPriceValue = [number, string]; + +/** + * The historical price object. + * The key is the time period as an ISO 8601 duration or the "all" string, the value is an array of historical price values. + */ +export type HistoricalPriceIntervals = { + [key: string]: HistoricalPriceValue[]; +}; + +/** + * The response from the historical price query, containing the historical price about the requested asset pair. + * + * @property historicalPrice - The historical price object + * @property historicalPrice.intervals - The historical price of the asset pair. + * @property historicalPrice.updateTime - The time at which the historical price has been calculated. + * @property historicalPrice.expirationTime - The time at which the historical price expires. + */ +export type OnAssetHistoricalPriceResponse = { + historicalPrice: { + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }; +} | null; + +/** + * The `onAssetHistoricalPrice` handler arguments. + * + * @property from - The CAIP-19 asset type of the asset requested. + * @property to - The CAIP-19 asset type of the asset converted to. + */ +export type OnAssetHistoricalPriceArguments = { + from: CaipAssetType; + to: CaipAssetType; +}; + +/** + * The `onAssetHistoricalPrice` handler. This is called by MetaMask when querying about the historical price of an asset pair on a specific chain. + * + * @returns The historical price of the asset pair. See + * {@link OnAssetHistoricalPriceResponse}. + */ +export type OnAssetHistoricalPriceHandler = ( + args: OnAssetHistoricalPriceArguments, +) => Promise; diff --git a/packages/snaps-sdk/src/types/handlers/index.ts b/packages/snaps-sdk/src/types/handlers/index.ts index d9b44d6913..b2b857fb89 100644 --- a/packages/snaps-sdk/src/types/handlers/index.ts +++ b/packages/snaps-sdk/src/types/handlers/index.ts @@ -1,3 +1,4 @@ +export type * from './asset-historical-price'; export * from './assets-conversion'; export * from './assets-lookup'; export type * from './cronjob'; diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index fe235fd33d..16472380bd 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.75, - "functions": 98.94, - "lines": 98.55, - "statements": 97.03 + "functions": 98.95, + "lines": 98.56, + "statements": 97.05 } diff --git a/packages/snaps-utils/src/handlers/asset-historical-price.test.ts b/packages/snaps-utils/src/handlers/asset-historical-price.test.ts new file mode 100644 index 0000000000..821494caeb --- /dev/null +++ b/packages/snaps-utils/src/handlers/asset-historical-price.test.ts @@ -0,0 +1,233 @@ +import { is } from '@metamask/superstruct'; + +import { + AssetHistoricalPriceStruct, + HistoricalPriceStruct, + OnAssetHistoricalPriceResponseStruct, +} from './asset-historical-price'; + +describe('HistoricalPriceStruct', () => { + it.each([ + { + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + { + all: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + { + all: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + ])('validates "%p"', (value) => { + expect(is(value, HistoricalPriceStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + { + foo: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + ])('does not validate "%p"', (value) => { + expect(is(value, HistoricalPriceStruct)).toBe(false); + }); +}); + +describe('AssetHistoricalPriceStruct', () => { + it.each([ + { + intervals: { + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + { + intervals: { + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + }, + { + intervals: { + all: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + }, + { + intervals: { + all: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + }, + null, + ])('validates an object', (value) => { + expect(is(value, AssetHistoricalPriceStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + undefined, + {}, + [], + { + intervals: { + foo: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + }, + ])('does not validate "%p"', (value) => { + expect(is(value, AssetHistoricalPriceStruct)).toBe(false); + }); +}); + +describe('OnAssetHistoricalPriceResponseStruct', () => { + it.each([ + { + historicalPrice: { + intervals: { + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + { + historicalPrice: { + intervals: { + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + }, + }, + { + historicalPrice: { + intervals: { + all: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + { + historicalPrice: { + intervals: { + all: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + { + historicalPrice: null, + }, + ])('validates "%p"', (value) => { + expect(is(value, OnAssetHistoricalPriceResponseStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + undefined, + {}, + [], + { + historicalPrice: { + historicalPrice: { + foo: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + }, + }, + { + historicalPrice: { + historicalPrice: { + foo: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + expirationTime: 1737542312, + }, + }, + ])('does not validate "%p"', (value) => { + expect(is(value, OnAssetHistoricalPriceResponseStruct)).toBe(false); + }); +}); diff --git a/packages/snaps-utils/src/handlers/asset-historical-price.ts b/packages/snaps-utils/src/handlers/asset-historical-price.ts new file mode 100644 index 0000000000..ae96ed268e --- /dev/null +++ b/packages/snaps-utils/src/handlers/asset-historical-price.ts @@ -0,0 +1,40 @@ +import { nonEmptyRecord } from '@metamask/snaps-sdk'; +import { + array, + literal, + nullable, + number, + object, + optional, + string, + tuple, + union, +} from '@metamask/superstruct'; + +import { ISO8601DurationStruct } from '../time'; + +/** + * A struct representing a historical price. + */ +export const HistoricalPriceStruct = nonEmptyRecord( + union([literal('all'), ISO8601DurationStruct]), + array(tuple([number(), string()])), +); + +/** + * A struct representing an asset's historical price. + */ +export const AssetHistoricalPriceStruct = nullable( + object({ + intervals: HistoricalPriceStruct, + updateTime: number(), + expirationTime: optional(number()), + }), +); + +/** + * A struct representing the response of the `onAssetHistoricalPrice` method. + */ +export const OnAssetHistoricalPriceResponseStruct = object({ + historicalPrice: AssetHistoricalPriceStruct, +}); diff --git a/packages/snaps-utils/src/handlers/exports.test.ts b/packages/snaps-utils/src/handlers/exports.test.ts index cb0a5b617d..3f1d445147 100644 --- a/packages/snaps-utils/src/handlers/exports.test.ts +++ b/packages/snaps-utils/src/handlers/exports.test.ts @@ -28,6 +28,7 @@ describe('SNAP_EXPORT_NAMES', () => { 'onUserInput', 'onAssetsLookup', 'onAssetsConversion', + 'onAssetHistoricalPrice', 'onProtocolRequest', ]); }); diff --git a/packages/snaps-utils/src/handlers/exports.ts b/packages/snaps-utils/src/handlers/exports.ts index bcf4cefe3f..e9e491b68d 100644 --- a/packages/snaps-utils/src/handlers/exports.ts +++ b/packages/snaps-utils/src/handlers/exports.ts @@ -1,4 +1,5 @@ import type { + OnAssetHistoricalPriceHandler, OnAssetsConversionHandler, OnAssetsLookupHandler, OnCronjobHandler, @@ -95,6 +96,15 @@ export const SNAP_EXPORTS = { return typeof snapExport === 'function'; }, }, + [HandlerType.OnAssetHistoricalPrice]: { + type: HandlerType.OnAssetHistoricalPrice, + required: true, + validator: ( + snapExport: unknown, + ): snapExport is OnAssetHistoricalPriceHandler => { + return typeof snapExport === 'function'; + }, + }, [HandlerType.OnAssetsLookup]: { type: HandlerType.OnAssetsLookup, required: true, diff --git a/packages/snaps-utils/src/handlers/index.ts b/packages/snaps-utils/src/handlers/index.ts index 0c164de707..d6df9efa3b 100644 --- a/packages/snaps-utils/src/handlers/index.ts +++ b/packages/snaps-utils/src/handlers/index.ts @@ -1,3 +1,4 @@ +export * from './asset-historical-price'; 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 bd68948828..882893e303 100644 --- a/packages/snaps-utils/src/handlers/types.ts +++ b/packages/snaps-utils/src/handlers/types.ts @@ -42,6 +42,7 @@ export enum HandlerType { OnUserInput = 'onUserInput', OnAssetsLookup = 'onAssetsLookup', OnAssetsConversion = 'onAssetsConversion', + OnAssetHistoricalPrice = 'onAssetHistoricalPrice', OnProtocolRequest = 'onProtocolRequest', } diff --git a/packages/snaps-utils/src/index.executionenv.ts b/packages/snaps-utils/src/index.executionenv.ts index 231173cb3f..65b7b13096 100644 --- a/packages/snaps-utils/src/index.executionenv.ts +++ b/packages/snaps-utils/src/index.executionenv.ts @@ -1,6 +1,7 @@ // Special entrypoint for execution environments for bundle sizing reasons export * from './errors'; -export * from './handlers'; +export * from './handlers/exports'; +export * from './handlers/types'; export * from './iframe'; export * from './logging'; export * from './types';