diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 0177f2eac4..5a190fb6aa 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "Qbr3VIystF/tEKyXESJ3ugB8m7n+E0yH8FGCx8od0lA=", + "shasum": "ipeiQKWLZEg5Snm73jZkQ7VeusFnAMPd4ws5zWo7/NI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index 692efc96fe..97f96ef914 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "KiWycLXvfEReGjt8jp3UpL4OZj1o5sDKMO6IXtwOZIQ=", + "shasum": "uPtVkZpOxZzVcTSWX40MuXbED38DLAf9hNiNAR88+10=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 2812bd541a..0152bb35ed 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -4431,6 +4431,106 @@ describe('SnapController', () => { snapController.destroy(); }); + + it('returns the value when `onAssetsConversion` returns a valid response with market data', 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({ + conversionRates: { + '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 }, + }, + }, + }, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + handler: HandlerType.OnAssetsConversion, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + conversions: [ + { + from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + to: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + ], + includeMarketData: true, + }, + id: 1, + }, + }), + ).toStrictEqual({ + conversionRates: { + '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 }, + }, + }, + }, + }, + }); + + snapController.destroy(); + }); }); describe('onAssetHistoricalPrice', () => { diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index c36bd8fb71..649d417461 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -59,7 +59,6 @@ import type { import { AuxiliaryFileEncoding, getErrorMessage, - OnAssetsConversionResponseStruct, OnAssetsLookupResponseStruct, } from '@metamask/snaps-sdk'; import type { @@ -105,6 +104,7 @@ import { OnSettingsPageResponseStruct, isValidUrl, OnAssetHistoricalPriceResponseStruct, + OnAssetsConversionResponseStruct, } from '@metamask/snaps-utils'; import type { Json, 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 be5efb5562..a1f673c48e 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1608,6 +1608,51 @@ describe('BaseSnapExecutor', () => { }); }); + it('supports `onAssetsConversion` export with the market data flag', async () => { + const CODE = ` + module.exports.onAssetsConversion = () => ({ conversionRates: {} }); + `; + + 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.OnAssetsConversion, + MOCK_ORIGIN, + { + jsonrpc: '2.0', + method: '', + params: { + conversions: [ + { + from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + to: 'eip155:1/slip44:60', + }, + ], + includeMarketData: true, + }, + }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: { conversionRates: {} }, + }); + }); + it('supports onSignature export', async () => { const CODE = ` module.exports.onSignature = ({ signature, signatureOrigin }) => diff --git a/packages/snaps-execution-environments/src/common/validation.test.ts b/packages/snaps-execution-environments/src/common/validation.test.ts index f1ef61c9cb..94e43427c4 100644 --- a/packages/snaps-execution-environments/src/common/validation.test.ts +++ b/packages/snaps-execution-environments/src/common/validation.test.ts @@ -283,6 +283,24 @@ 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), @@ -305,6 +323,8 @@ 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) => { diff --git a/packages/snaps-execution-environments/src/common/validation.ts b/packages/snaps-execution-environments/src/common/validation.ts index 9f37ab9665..2f4c7f6f3a 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -9,6 +9,7 @@ import { any, array, assign, + boolean, enums, is, literal, @@ -285,6 +286,7 @@ export const OnAssetsConversionRequestArgumentsStruct = object({ 1, Infinity, ), + includeMarketData: optional(boolean()), }); export type OnAssetsConversionRequestArguments = Infer< diff --git a/packages/snaps-sdk/src/types/handlers/assets-conversion.test.ts b/packages/snaps-sdk/src/types/handlers/assets-conversion.test.ts deleted file mode 100644 index 81eb3e7594..0000000000 --- a/packages/snaps-sdk/src/types/handlers/assets-conversion.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { is } from '@metamask/superstruct'; - -import { AssetConversionStruct } from './assets-conversion'; - -describe('AssetConversionStruct', () => { - it.each([ - { rate: '1.23', conversionTime: 1737542312, expirationTime: 1737542312 }, - { rate: '1.23', conversionTime: 1737542312 }, - ])('validates an object', (value) => { - expect(is(value, AssetConversionStruct)).toBe(true); - }); - - it.each([ - 'foo', - 42, - null, - undefined, - {}, - [], - { rate: 123, conversionTime: 123 }, - ])('does not validate "%p"', (value) => { - expect(is(value, AssetConversionStruct)).toBe(false); - }); -}); diff --git a/packages/snaps-sdk/src/types/handlers/assets-conversion.ts b/packages/snaps-sdk/src/types/handlers/assets-conversion.ts index ca2fda444d..4d3478d069 100644 --- a/packages/snaps-sdk/src/types/handlers/assets-conversion.ts +++ b/packages/snaps-sdk/src/types/handlers/assets-conversion.ts @@ -1,36 +1,59 @@ -import type { Infer } from '@metamask/superstruct'; -import { - number, - object, - string, - optional, - record, - nullable, -} from '@metamask/superstruct'; -import { CaipAssetTypeStruct, type CaipAssetType } from '@metamask/utils'; +import { type CaipAssetType } from '@metamask/utils'; -export const AssetConversionStruct = object({ - rate: string(), - conversionTime: number(), - expirationTime: optional(number()), -}); - -export const OnAssetsConversionResponseStruct = object({ - conversionRates: record( - CaipAssetTypeStruct, - record(CaipAssetTypeStruct, nullable(AssetConversionStruct)), - ), -}); +/** + * 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; + }; +}; -export type AssetConversion = Infer; +/** + * 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; +}; +/** + * The arguments for the `onAssetsConversion` handler. + * + * @property conversions - An array of objects containing the `from` and `to` asset types. + * @property includeMarketData - Whether to include market data in the response. + */ export type OnAssetsConversionArguments = { conversions: { from: CaipAssetType; to: CaipAssetType }[]; + includeMarketData?: boolean; }; /** * The `onAssetsConversion` handler. This is called by MetaMask when querying about asset conversion on specific chains. * + * @param args - The arguments for the handler. + * see {@link OnAssetsConversionArguments}. * @returns The conversion for each asset. See * {@link OnAssetsConversionResponse}. */ diff --git a/packages/snaps-sdk/src/types/handlers/index.ts b/packages/snaps-sdk/src/types/handlers/index.ts index b2b857fb89..470fa182e2 100644 --- a/packages/snaps-sdk/src/types/handlers/index.ts +++ b/packages/snaps-sdk/src/types/handlers/index.ts @@ -1,5 +1,5 @@ export type * from './asset-historical-price'; -export * from './assets-conversion'; +export type * from './assets-conversion'; export * from './assets-lookup'; export type * from './cronjob'; export type * from './home-page'; diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index 16472380bd..3a08980160 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.75, "functions": 98.95, - "lines": 98.56, - "statements": 97.05 + "lines": 98.57, + "statements": 97.07 } diff --git a/packages/snaps-utils/src/handlers/assets-conversion.test.ts b/packages/snaps-utils/src/handlers/assets-conversion.test.ts new file mode 100644 index 0000000000..87897a8ec8 --- /dev/null +++ b/packages/snaps-utils/src/handlers/assets-conversion.test.ts @@ -0,0 +1,170 @@ +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); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + { rate: 123, conversionTime: 123 }, + ])('does not validate "%p"', (value) => { + expect(is(value, AssetConversionStruct)).toBe(false); + }); +}); + +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': + { + 'swift:0/iso4217:USD': { + rate: '1.23', + conversionTime: 1737542312, + expirationTime: 1737542312, + }, + }, + }, + }, + { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + 'swift:0/iso4217:USD': null, + }, + }, + }, + { conversionRates: {} }, + ])('validates "%p"', (value) => { + expect(is(value, OnAssetsConversionResponseStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + { + conversionRates: { + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678': { + 'swift:0/iso4217:USD': { + rate: 123, + conversionTime: 123, + }, + }, + }, + }, + ])('does not validate "%p"', (value) => { + expect(is(value, OnAssetsConversionResponseStruct)).toBe(false); + }); +}); diff --git a/packages/snaps-utils/src/handlers/assets-conversion.ts b/packages/snaps-utils/src/handlers/assets-conversion.ts new file mode 100644 index 0000000000..5469297a72 --- /dev/null +++ b/packages/snaps-utils/src/handlers/assets-conversion.ts @@ -0,0 +1,54 @@ +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()), +}); + +/** + * A struct representing the response of the `onAssetsConversion` method. + */ +export const OnAssetsConversionResponseStruct = object({ + conversionRates: record( + CaipAssetTypeStruct, + record(CaipAssetTypeStruct, nullable(AssetConversionStruct)), + ), +}); diff --git a/packages/snaps-utils/src/handlers/index.ts b/packages/snaps-utils/src/handlers/index.ts index d6df9efa3b..eae90e4406 100644 --- a/packages/snaps-utils/src/handlers/index.ts +++ b/packages/snaps-utils/src/handlers/index.ts @@ -1,4 +1,5 @@ export * from './asset-historical-price'; +export * from './assets-conversion'; export * from './exports'; export * from './home-page'; export * from './name-lookup';