diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 37e782f977..61d9fb2146 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": "B0senywfM+w5lQ+iMvK+bVcKJ6VeLDj7HiUVYR5Cuag=", + "shasum": "PU8/QaQOlO6/ShRIM+jofaiQFUAprfuUX9RV6G5xRJo=", "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 96083306bf..4fc06d52a3 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": "PS0U7SHYXWpFhO8QMtArHKU1rFzMkwtTLFlc3/g1HQ4=", + "shasum": "5vUCvHpbE8BnQv9R8QorYcvyKPZk0s+Fuh/MFUZ7LH4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 7a462547c5..fffcd4a239 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 92.96, - "functions": 96.56, - "lines": 98.05, - "statements": 97.77 + "branches": 93.06, + "functions": 96.59, + "lines": 98.08, + "statements": 97.8 } diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index aaa463bfed..9bc0b487ca 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -3898,6 +3898,459 @@ describe('SnapController', () => { snapController.destroy(); }); + describe('onAssetsLookup', () => { + it('throws if `onAssetsLookup` 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({ + assets: { foo: {} }, + }), + ); + + await expect( + snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: 'foo.com', + handler: HandlerType.OnAssetsLookup, + request: { + jsonrpc: '2.0', + method: ' ', + params: {}, + id: 1, + }, + }), + ).rejects.toThrow( + `Assertion failed: At path: assets.foo -- Expected a string matching`, + ); + + snapController.destroy(); + }); + + it('filters out assets that are out of scope for `onAssetsLookup`', 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({ + assets: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://metamask.io/sol.svg', + units: [ + { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }, + ], + }, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: 'foo.com', + handler: HandlerType.OnAssetsLookup, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + id: 1, + }, + }), + ).toStrictEqual({ assets: {} }); + + snapController.destroy(); + }); + + it('returns the value when `onAssetsLookup` 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({ + assets: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + name: 'Bitcoin', + symbol: 'BTC', + fungible: true, + iconUrl: 'https://metamask.io/btc.svg', + units: [ + { + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + }, + ], + }, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: 'foo.com', + handler: HandlerType.OnAssetsLookup, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + id: 1, + }, + }), + ).toStrictEqual({ + assets: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + name: 'Bitcoin', + symbol: 'BTC', + fungible: true, + iconUrl: 'https://metamask.io/btc.svg', + units: [ + { + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + }, + ], + }, + }, + }); + + snapController.destroy(); + }); + }); + + describe('onAssetsConversion', () => { + it('throws if `onAssetsConversion` 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({ + conversionRates: { foo: {} }, + }), + ); + + await expect( + snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: 'foo.com', + handler: HandlerType.OnAssetsConversion, + request: { + jsonrpc: '2.0', + method: ' ', + params: {}, + id: 1, + }, + }), + ).rejects.toThrow( + `Assertion failed: At path: conversionRates.foo -- Expected a string matching`, + ); + + snapController.destroy(); + }); + + it('filters out assets that are out of scope for `onAssetsConversion`', 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': { + 'eip155:1/slip44:60': { + rate: '33', + conversionTime: 1737548790, + }, + }, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: 'foo.com', + handler: HandlerType.OnAssetsConversion, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + conversions: [ + { + from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + to: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + ], + }, + id: 1, + }, + }), + ).toStrictEqual({ conversionRates: {} }); + + snapController.destroy(); + }); + + it('returns the value when `onAssetsConversion` 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({ + conversionRates: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '400', + conversionTime: 1737548790, + }, + }, + }, + }), + ); + + expect( + await snapController.handleRequest({ + snapId: MOCK_SNAP_ID, + origin: 'foo.com', + handler: HandlerType.OnAssetsConversion, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + conversions: [ + { + from: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + to: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + ], + }, + id: 1, + }, + }), + ).toStrictEqual({ + conversionRates: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '400', + conversionTime: 1737548790, + }, + }, + }, + }); + + snapController.destroy(); + }); + }); + describe('getRpcRequestHandler', () => { it('handlers populate the "jsonrpc" property if missing', async () => { const rootMessenger = getControllerMessenger(); @@ -5655,7 +6108,7 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: {}, }), ).rejects.toThrow( - 'A snap must request at least one of the following permissions: endowment:rpc, endowment:transaction-insight, endowment:cronjob, endowment:name-lookup, endowment:lifecycle-hooks, endowment:keyring, endowment:page-home, endowment:page-settings, endowment:signature-insight.', + 'A snap must request at least one of the following permissions: endowment:rpc, endowment:transaction-insight, endowment:cronjob, endowment:name-lookup, endowment:lifecycle-hooks, endowment:keyring, endowment:page-home, endowment:page-settings, endowment:signature-insight, endowment:assets.', ); controller.destroy(); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 655c1bcdec..f93d78d01c 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -41,6 +41,7 @@ import { getRpcCaveatOrigins, processSnapPermissions, getEncryptionEntropy, + getChainIdsCaveat, } from '@metamask/snaps-rpc-methods'; import type { RequestSnapsParams, @@ -48,8 +49,19 @@ import type { SnapId, ComponentOrElement, ContentType, + OnAssetsLookupResponse, + FungibleAssetMetadata, + OnAssetsConversionResponse, + OnAssetsConversionArguments, + AssetConversion, + OnAssetsLookupArguments, +} from '@metamask/snaps-sdk'; +import { + AuxiliaryFileEncoding, + getErrorMessage, + OnAssetsConversionResponseStruct, + OnAssetsLookupResponseStruct, } from '@metamask/snaps-sdk'; -import { AuxiliaryFileEncoding, getErrorMessage } from '@metamask/snaps-sdk'; import type { FetchedSnapFiles, InitialConnections, @@ -92,7 +104,12 @@ import { MAX_FILE_SIZE, OnSettingsPageResponseStruct, } from '@metamask/snaps-utils'; -import type { Json, NonEmptyArray, SemVerRange } from '@metamask/utils'; +import type { + Json, + NonEmptyArray, + SemVerRange, + CaipAssetType, +} from '@metamask/utils'; import { assert, assertIsJsonRpcRequest, @@ -3508,6 +3525,7 @@ export class SnapController extends BaseController< const transformedResult = await this.#transformSnapRpcRequestResult( snapId, handlerType, + request, result, ); @@ -3569,12 +3587,14 @@ export class SnapController extends BaseController< * * @param snapId - The snap ID of the snap that produced the result. * @param handlerType - The handler type that produced the result. + * @param request - The request that returned the result. * @param result - The result. * @returns The transformed result if applicable, otherwise the original result. */ async #transformSnapRpcRequestResult( snapId: SnapId, handlerType: HandlerType, + request: Record, result: unknown, ) { switch (handlerType) { @@ -3597,11 +3617,106 @@ export class SnapController extends BaseController< } return result; } + case HandlerType.OnAssetsLookup: + // We can cast since the request and result have already been validated. + return this.#transformOnAssetsLookupResult( + snapId, + request as { params: OnAssetsLookupArguments }, + result as OnAssetsLookupResponse, + ); + + case HandlerType.OnAssetsConversion: + // We can cast since the request and result have already been validated. + return this.#transformOnAssetsConversionResult( + request as { + params: OnAssetsConversionArguments; + }, + result as OnAssetsConversionResponse, + ); default: return result; } } + /** + * Transform an RPC response coming from the `onAssetsLookup` handler. + * + * This filters out responses that are out of scope for the Snap based on + * its permissions and the incoming request. + * + * @param snapId - The snap ID of the snap that produced the result. + * @param request - The request that returned the result. + * @param request.params - The parameters for the request. + * @param result - The result. + * @param result.assets - The assets returned by the Snap. + * @returns The transformed result. + */ + #transformOnAssetsLookupResult( + snapId: SnapId, + { params: requestedParams }: { params: OnAssetsLookupArguments }, + { assets }: OnAssetsLookupResponse, + ) { + const permissions = this.messagingSystem.call( + 'PermissionController:getPermissions', + snapId, + ); + // We know the permissions are guaranteed to be set here. + assert(permissions); + + const permission = permissions[SnapEndowments.Assets]; + const scopes = getChainIdsCaveat(permission); + assert(scopes); + + const { assets: requestedAssets } = requestedParams; + + const filteredAssets = Object.keys(assets).reduce< + Record + >((accumulator, assetType) => { + const castAssetType = assetType as CaipAssetType; + const isValid = + scopes.some((scope) => castAssetType.startsWith(scope)) && + requestedAssets.includes(castAssetType); + // Filter out unrequested assets and assets for scopes the Snap hasn't registered for. + if (isValid) { + accumulator[castAssetType] = assets[castAssetType]; + } + return accumulator; + }, {}); + return { assets: filteredAssets }; + } + + /** + * Transform an RPC response coming from the `onAssetsConversion` 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.conversionRates - The conversion rates returned by the Snap. + * @returns The transformed result. + */ + #transformOnAssetsConversionResult( + { params: requestedParams }: { params: OnAssetsConversionArguments }, + { conversionRates }: OnAssetsConversionResponse, + ) { + const { conversions: requestedConversions } = requestedParams; + + const filteredConversionRates = requestedConversions.reduce< + Record> + >((accumulator, conversion) => { + const rate = conversionRates[conversion.from]?.[conversion.to]; + // Only include rates that were actually requested. + if (rate) { + accumulator[conversion.from] ??= {}; + accumulator[conversion.from][conversion.to] = rate; + } + return accumulator; + }, {}); + return { conversionRates: filteredConversionRates }; + } + /** * Assert that the returned result of a Snap RPC call is the expected shape. * @@ -3654,6 +3769,12 @@ export class SnapController extends BaseController< case HandlerType.OnNameLookup: assertStruct(result, OnNameLookupResponseStruct); break; + case HandlerType.OnAssetsLookup: + assertStruct(result, OnAssetsLookupResponseStruct); + break; + case HandlerType.OnAssetsConversion: + assertStruct(result, OnAssetsConversionResponseStruct); + break; default: break; } diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index 926fe3031b..f8b47f1792 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,6 +1,6 @@ { - "branches": 80.68, - "functions": 89.33, - "lines": 90.68, - "statements": 90.08 + "branches": 80.95, + "functions": 89.47, + "lines": 90.84, + "statements": 90 } diff --git a/packages/snaps-execution-environments/lavamoat/browserify/iframe/policy.json b/packages/snaps-execution-environments/lavamoat/browserify/iframe/policy.json index 5f53e91c6f..e386897747 100644 --- a/packages/snaps-execution-environments/lavamoat/browserify/iframe/policy.json +++ b/packages/snaps-execution-environments/lavamoat/browserify/iframe/policy.json @@ -79,6 +79,7 @@ }, "@metamask/snaps-sdk": { "globals": { + "URL": true, "fetch": true }, "packages": { diff --git a/packages/snaps-execution-environments/lavamoat/browserify/node-process/policy.json b/packages/snaps-execution-environments/lavamoat/browserify/node-process/policy.json index 1c4b1c0425..57d9fbbc20 100644 --- a/packages/snaps-execution-environments/lavamoat/browserify/node-process/policy.json +++ b/packages/snaps-execution-environments/lavamoat/browserify/node-process/policy.json @@ -89,6 +89,7 @@ }, "@metamask/snaps-sdk": { "globals": { + "URL": true, "fetch": true }, "packages": { diff --git a/packages/snaps-execution-environments/lavamoat/browserify/node-thread/policy.json b/packages/snaps-execution-environments/lavamoat/browserify/node-thread/policy.json index 1c4b1c0425..57d9fbbc20 100644 --- a/packages/snaps-execution-environments/lavamoat/browserify/node-thread/policy.json +++ b/packages/snaps-execution-environments/lavamoat/browserify/node-thread/policy.json @@ -89,6 +89,7 @@ }, "@metamask/snaps-sdk": { "globals": { + "URL": true, "fetch": true }, "packages": { diff --git a/packages/snaps-execution-environments/lavamoat/browserify/webview/policy.json b/packages/snaps-execution-environments/lavamoat/browserify/webview/policy.json index 7417631724..df5877fd7f 100644 --- a/packages/snaps-execution-environments/lavamoat/browserify/webview/policy.json +++ b/packages/snaps-execution-environments/lavamoat/browserify/webview/policy.json @@ -24,6 +24,7 @@ }, "@metamask/snaps-sdk": { "globals": { + "URL": true, "fetch": true }, "packages": { diff --git a/packages/snaps-execution-environments/lavamoat/browserify/worker-executor/policy.json b/packages/snaps-execution-environments/lavamoat/browserify/worker-executor/policy.json index 5f53e91c6f..e386897747 100644 --- a/packages/snaps-execution-environments/lavamoat/browserify/worker-executor/policy.json +++ b/packages/snaps-execution-environments/lavamoat/browserify/worker-executor/policy.json @@ -79,6 +79,7 @@ }, "@metamask/snaps-sdk": { "globals": { + "URL": true, "fetch": true }, "packages": { diff --git a/packages/snaps-execution-environments/lavamoat/browserify/worker-pool/policy.json b/packages/snaps-execution-environments/lavamoat/browserify/worker-pool/policy.json index 7417631724..df5877fd7f 100644 --- a/packages/snaps-execution-environments/lavamoat/browserify/worker-pool/policy.json +++ b/packages/snaps-execution-environments/lavamoat/browserify/worker-pool/policy.json @@ -24,6 +24,7 @@ }, "@metamask/snaps-sdk": { "globals": { + "URL": true, "fetch": true }, "packages": { 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 9b0ece4e9a..d803c3ce66 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1474,6 +1474,89 @@ describe('BaseSnapExecutor', () => { }); }); + it('supports `onAssetsLookup` export', async () => { + const CODE = ` + module.exports.onAssetsLookup = () => ({ assets: {} }); + `; + + 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.OnAssetsLookup, + MOCK_ORIGIN, + { + jsonrpc: '2.0', + method: '', + params: { + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: { assets: {} }, + }); + }); + + it('supports `onAssetsConversion` export', 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', + }, + ], + }, + }, + ], + }); + + 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/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index b03a32d23d..8dbfac295f 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -15,6 +15,8 @@ import { assertIsOnSignatureRequestArguments, assertIsOnNameLookupRequestArguments, assertIsOnUserInputRequestArguments, + assertIsOnAssetsLookupRequestArguments, + assertIsOnAssetsConversionRequestArguments, } from './validation'; export type CommandMethodsMapping = { @@ -56,6 +58,16 @@ export function getHandlerArguments( const { signature, signatureOrigin } = request.params; return { signature, signatureOrigin }; } + case HandlerType.OnAssetsLookup: { + assertIsOnAssetsLookupRequestArguments(request.params); + const { assets } = request.params; + return { assets }; + } + case HandlerType.OnAssetsConversion: { + assertIsOnAssetsConversionRequestArguments(request.params); + const { conversions } = request.params; + return { conversions }; + } 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 87a6b8ab5e..eef0294d2a 100644 --- a/packages/snaps-execution-environments/src/common/validation.test.ts +++ b/packages/snaps-execution-environments/src/common/validation.test.ts @@ -1,6 +1,8 @@ import { UserInputEventType } from '@metamask/snaps-sdk'; import { + assertIsOnAssetsConversionRequestArguments, + assertIsOnAssetsLookupRequestArguments, assertIsOnNameLookupRequestArguments, assertIsOnSignatureRequestArguments, assertIsOnTransactionRequestArguments, @@ -232,3 +234,81 @@ describe('assertIsOnUserInputRequestArguments', () => { ); }); }); + +describe('assertIsOnAssetsLookupRequestArguments', () => { + it.each([ + { assets: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'] }, + { + assets: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + ], + }, + ])('does not throw for a valid assets lookup param object', (value) => { + expect(() => assertIsOnAssetsLookupRequestArguments(value)).not.toThrow(); + }); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'foo', + [], + {}, + { assets: [] }, + { assets: ['foo'] }, + ])( + 'throws if the value is not a valid assets lookup params object', + (value) => { + expect(() => + assertIsOnAssetsLookupRequestArguments(value as any), + ).toThrow('Invalid request params:'); + }, + ); +}); + +describe('assertIsOnAssetsConversionRequestArguments', () => { + it.each([ + { + conversions: [ + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + ], + }, + ])('does not throw for a valid assets conversion param object', (value) => { + expect(() => + assertIsOnAssetsConversionRequestArguments(value), + ).not.toThrow(); + }); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'foo', + [], + {}, + { conversions: [] }, + { conversions: ['foo'] }, + { conversions: [{}] }, + { conversions: [{ from: 'foo' }] }, + { conversions: [{ from: 'foo', to: 'foo' }] }, + ])( + 'throws if the value is not a valid assets conversion params object', + (value) => { + expect(() => + assertIsOnAssetsConversionRequestArguments(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 166c9df8c7..22a5e68033 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -16,6 +16,7 @@ import { object, optional, record, + size, string, tuple, union, @@ -23,6 +24,7 @@ import { import type { Json, JsonRpcSuccess } from '@metamask/utils'; import { assertStruct, + CaipAssetTypeStruct, JsonRpcIdStruct, JsonRpcParamsStruct, JsonRpcSuccessStruct, @@ -216,6 +218,69 @@ export function assertIsOnNameLookupRequestArguments( ); } +export const OnAssetsLookupRequestArgumentsStruct = object({ + assets: size(array(CaipAssetTypeStruct), 1, Infinity), +}); + +export type OnAssetsLookupRequestArguments = Infer< + typeof OnAssetsLookupRequestArgumentsStruct +>; + +/** + * Asserts that the given value is a valid {@link OnAssetsLookupRequestArguments} + * object. + * + * @param value - The value to validate. + * @throws If the value is not a valid {@link OnAssetsLookupRequestArguments} + * object. + */ +export function assertIsOnAssetsLookupRequestArguments( + value: unknown, +): asserts value is OnAssetsLookupRequestArguments { + assertStruct( + value, + OnAssetsLookupRequestArgumentsStruct, + 'Invalid request params', + rpcErrors.invalidParams, + ); +} + +export const OnAssetsConversionRequestArgumentsStruct = object({ + conversions: size( + array( + object({ + from: CaipAssetTypeStruct, + to: CaipAssetTypeStruct, + }), + ), + 1, + Infinity, + ), +}); + +export type OnAssetsConversionRequestArguments = Infer< + typeof OnAssetsConversionRequestArgumentsStruct +>; + +/** + * Asserts that the given value is a valid {@link OnAssetsConversionRequestArguments} + * object. + * + * @param value - The value to validate. + * @throws If the value is not a valid {@link OnNameLookupRequestArguments} + * object. + */ +export function assertIsOnAssetsConversionRequestArguments( + value: unknown, +): asserts value is OnAssetsConversionRequestArguments { + assertStruct( + value, + OnAssetsConversionRequestArgumentsStruct, + 'Invalid request params', + rpcErrors.invalidParams, + ); +} + export const OnUserInputArgumentsStruct = object({ id: string(), event: UserInputEventStruct, diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 0b93d167c8..d7bfe3cbd4 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 94.89, - functions: 98.05, - lines: 98.67, - statements: 98.34, + branches: 94.93, + functions: 98.08, + lines: 98.69, + statements: 98.36, }, }, }); diff --git a/packages/snaps-rpc-methods/src/endowments/assets.test.ts b/packages/snaps-rpc-methods/src/endowments/assets.test.ts new file mode 100644 index 0000000000..223ad031a4 --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/assets.test.ts @@ -0,0 +1,40 @@ +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +import { assetsEndowmentBuilder, getAssetsCaveatMapper } from './assets'; +import { SnapEndowments } from './enum'; + +describe('endowment:assets', () => { + describe('specificationBuilder', () => { + it('builds the expected permission specification', () => { + const specification = assetsEndowmentBuilder.specificationBuilder({}); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: SnapEndowments.Assets, + endowmentGetter: expect.any(Function), + allowedCaveats: [SnapCaveatType.ChainIds], + subjectTypes: [SubjectType.Snap], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeNull(); + }); + }); + + describe('getAssetsCaveatMapper', () => { + it('maps a value to a caveat', () => { + expect( + getAssetsCaveatMapper({ + scopes: ['bip122:000000000019d6689c085ae165831e93'], + }), + ).toStrictEqual({ + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: ['bip122:000000000019d6689c085ae165831e93'], + }, + ], + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/endowments/assets.ts b/packages/snaps-rpc-methods/src/endowments/assets.ts new file mode 100644 index 0000000000..392912127e --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/assets.ts @@ -0,0 +1,74 @@ +import type { + PermissionSpecificationBuilder, + EndowmentGetterParams, + ValidPermissionSpecification, + PermissionConstraint, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; +import type { Json, NonEmptyArray } from '@metamask/utils'; +import { assert, isObject } from '@metamask/utils'; + +import { createGenericPermissionValidator } from './caveats'; +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.Assets; + +type AssetsEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof permissionName; + endowmentGetter: (_options?: any) => null; + allowedCaveats: Readonly> | null; +}>; + +/** + * `endowment:assets` returns nothing; it is intended to be used as a flag to determine whether the Snap can run asset queries. + * + * @param _builderOptions - Optional specification builder options. + * @returns The specification for the assets endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + AssetsEndowmentSpecification +> = (_builderOptions?: any) => { + return { + permissionType: PermissionType.Endowment, + targetName: permissionName, + allowedCaveats: [SnapCaveatType.ChainIds], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, + subjectTypes: [SubjectType.Snap], + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.ChainIds }, + { type: SnapCaveatType.MaxRequestTime, optional: true }, + ]), + }; +}; + +export const assetsEndowmentBuilder = Object.freeze({ + targetName: permissionName, + specificationBuilder, +} as const); + +/** + * Map a raw value from the `initialPermissions` to a caveat specification. + * Note that this function does not do any validation, that's handled by the + * PermissionsController when the permission is requested. + * + * @param value - The raw value from the `initialPermissions`. + * @returns The caveat specification. + */ +export function getAssetsCaveatMapper( + value: Json, +): Pick { + assert(isObject(value) && value.scopes); + + return { + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: value.scopes, + }, + ], + }; +} diff --git a/packages/snaps-rpc-methods/src/endowments/enum.ts b/packages/snaps-rpc-methods/src/endowments/enum.ts index cdd13a6746..96f4179fc6 100644 --- a/packages/snaps-rpc-methods/src/endowments/enum.ts +++ b/packages/snaps-rpc-methods/src/endowments/enum.ts @@ -11,4 +11,5 @@ export enum SnapEndowments { Keyring = 'endowment:keyring', HomePage = 'endowment:page-home', SettingsPage = 'endowment:page-settings', + Assets = 'endowment:assets', } diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index 7d82ac72d3..e0767cedd7 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -2,6 +2,7 @@ import type { PermissionConstraint } from '@metamask/permission-controller'; import { HandlerType } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; +import { assetsEndowmentBuilder, getAssetsCaveatMapper } from './assets'; import { createMaxRequestTimeMapper, getMaxRequestTimeCaveatMapper, @@ -60,6 +61,7 @@ export const endowmentPermissionBuilders = { [homePageEndowmentBuilder.targetName]: homePageEndowmentBuilder, [signatureInsightEndowmentBuilder.targetName]: signatureInsightEndowmentBuilder, + [assetsEndowmentBuilder.targetName]: assetsEndowmentBuilder, } as const; export const endowmentCaveatSpecifications = { @@ -96,6 +98,9 @@ export const endowmentCaveatMappers: Record< [lifecycleHooksEndowmentBuilder.targetName]: getMaxRequestTimeCaveatMapper, [homePageEndowmentBuilder.targetName]: getMaxRequestTimeCaveatMapper, [settingsPageEndowmentBuilder.targetName]: getMaxRequestTimeCaveatMapper, + [assetsEndowmentBuilder.targetName]: createMaxRequestTimeMapper( + getAssetsCaveatMapper, + ), }; // We allow null because a permitted handler does not have an endowment @@ -111,6 +116,8 @@ export const handlerEndowments: Record = { [HandlerType.OnSettingsPage]: settingsPageEndowmentBuilder.targetName, [HandlerType.OnSignature]: signatureInsightEndowmentBuilder.targetName, [HandlerType.OnUserInput]: null, + [HandlerType.OnAssetsLookup]: assetsEndowmentBuilder.targetName, + [HandlerType.OnAssetsConversion]: assetsEndowmentBuilder.targetName, }; export * from './enum'; diff --git a/packages/snaps-rpc-methods/src/permissions.test.ts b/packages/snaps-rpc-methods/src/permissions.test.ts index de38781944..9b9486d2e4 100644 --- a/packages/snaps-rpc-methods/src/permissions.test.ts +++ b/packages/snaps-rpc-methods/src/permissions.test.ts @@ -8,6 +8,18 @@ describe('buildSnapEndowmentSpecifications', () => { const specifications = buildSnapEndowmentSpecifications([]); expect(specifications).toMatchInlineSnapshot(` { + "endowment:assets": { + "allowedCaveats": [ + "chainIds", + ], + "endowmentGetter": [Function], + "permissionType": "Endowment", + "subjectTypes": [ + "snap", + ], + "targetName": "endowment:assets", + "validator": [Function], + }, "endowment:cronjob": { "allowedCaveats": [ "snapCronjob", diff --git a/packages/snaps-sdk/src/types/handlers/assets-conversion.test.ts b/packages/snaps-sdk/src/types/handlers/assets-conversion.test.ts new file mode 100644 index 0000000000..81eb3e7594 --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/assets-conversion.test.ts @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000000..7d5139057f --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/assets-conversion.ts @@ -0,0 +1,50 @@ +import type { Infer } from '@metamask/superstruct'; +import { + number, + object, + string, + optional, + record, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct, 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, AssetConversionStruct), + ), +}); + +export type AssetConversion = Infer; + +export type OnAssetsConversionArguments = { + conversions: { from: CaipAssetType; to: CaipAssetType }[]; +}; + +/** + * The `onAssetsConversion` handler. This is called by MetaMask when querying about asset conversion on specific chains. + * + * @returns The conversion for each asset. See + * {@link OnAssetsConversionResponse}. + */ +export type OnAssetsConversionHandler = ( + args: OnAssetsConversionArguments, +) => Promise; + +/** + * The response from the conversion query, containing rates about each requested asset pair. + * + * @property conversionRates - A nested object with two CAIP-19 keys that contains a conversion rate between the two keys. + */ +export type OnAssetsConversionResponse = { + conversionRates: Record< + CaipAssetType, + Record + >; +}; diff --git a/packages/snaps-sdk/src/types/handlers/assets-lookup.test.ts b/packages/snaps-sdk/src/types/handlers/assets-lookup.test.ts new file mode 100644 index 0000000000..b1dec9559c --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/assets-lookup.test.ts @@ -0,0 +1,83 @@ +import { is } from '@metamask/superstruct'; + +import { FungibleAssetMetadataStruct } from './assets-lookup'; + +const BTC_ICON_BASE64 = + 'PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMjAiIGN5PSIyMC4wMDAyIiByPSIxOC44ODg5IiBmaWxsPSJ1cmwoI3BhaW50MF9saW5lYXJfNjlfODQxKSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI0LjgxNTIgMTIuMTcxNkMyNy40MzQyIDEzLjEzMjUgMjkuMzIwNiAxNC41NTYxIDI4Ljg4ODIgMTcuMjg3OUMyOC41NjA5IDE5LjI3NjUgMjcuNTE4OCAyMC4yNDg1IDI2LjExMDYgMjAuNTkxNUMyNy45ODQ1IDIxLjY0MDQgMjguODg2NSAyMy4yMzI1IDI3LjkyMTYgMjYuMDI1MkMyNi43MjE3IDI5LjUyNzggMjQuMDM1OSAyOS44NDU1IDIwLjQ3ODQgMjkuMTY3TDE5LjU2MjUgMzIuODYzNkwxNy40OTU1IDMyLjM1MTNMMTguNDExMyAyOC42NTQ4QzE4LjE4NjQgMjguNTk0OSAxNy45NDk0IDI4LjUzOTcgMTcuNzA2MyAyOC40ODMxQzE3LjM4MTUgMjguNDA3NSAxNy4wNDU4IDI4LjMyOTMgMTYuNzEzNiAyOC4yMzM5TDE1Ljc5NzcgMzEuOTMwN0wxMy43MzQ1IDMxLjQxOTNMMTQuNjUwMyAyNy43MjI2TDEwLjU0MDMgMjYuNjAzMkwxMS41NjE5IDIzLjk4OTRDMTEuNTYxOSAyMy45ODk0IDEzLjExMzIgMjQuNDE2MSAxMy4wODkxIDI0LjM4OTNDMTMuNjY0NSAyNC41MjkyIDEzLjk0NTkgMjQuMTI3MyAxNC4wNjExIDIzLjg0NThMMTUuNTI3OCAxNy45MTk3TDE2LjU5NTEgMTMuNzA3N0MxNi42NDEzIDEzLjI1MjQgMTYuNDk4NyAxMi42NTY4IDE1LjY1OCAxMi40MzAyQzE1LjcxNTIgMTIuMzk2NiAxNC4xNDQ1IDEyLjA1NTEgMTQuMTQ0NSAxMi4wNTUxTDE0Ljc1NjYgOS41Nzc5N0wxOC45OTI2IDEwLjYyNzhMMTkuODg5NyA3LjAwNjg0TDIyLjAyMzcgNy41MzU3M0wyMS4xMjY2IDExLjE1NjdDMjEuNTQxNSAxMS4yNDY5IDIxLjk0NyAxMS4zNTE4IDIyLjM1NjggMTEuNDU3OEwyMi4zNTcgMTEuNDU3OEMyMi40OTE1IDExLjQ5MjYgMjIuNjI2NSAxMS41Mjc1IDIyLjc2MjQgMTEuNTYyMUwyMy42NTk1IDcuOTQxMTJMMjUuNzM1OSA4LjQ1NTcxTDI0LjgxNTIgMTIuMTcxNlpNMTkuMTUyNSAxNy45OTRDMTkuMTg0OCAxOC4wMDM2IDE5LjIxOTQgMTguMDE0IDE5LjI1NjEgMTguMDI1QzIwLjQ5NyAxOC4zOTggMjQuMTc2NiAxOS41MDM3IDI0Ljc5NjQgMTcuMDQxN0MyNS4zNzM1IDE0LjcwMTQgMjIuMTg1NyAxMy45ODY2IDIwLjcwNDUgMTMuNjU0NEMyMC41Mjk2IDEzLjYxNTIgMjAuMzc4NCAxMy41ODEzIDIwLjI2MDEgMTMuNTUwN0wxOS4xNTI1IDE3Ljk5NFpNMTcuNTE5NiAyNS4yOTM5QzE3LjQ1NDQgMjUuMjc0NCAxNy4zOTQzIDI1LjI1NjcgMTcuMzM5OCAyNS4yNDA2TDE4LjQ0NzQgMjAuNzk3NEMxOC41NzgzIDIwLjgzMTQgMTguNzQzOCAyMC44NzAzIDE4LjkzNTIgMjAuOTE1MkMyMC42ODEzIDIxLjMyNTUgMjQuNTgxMyAyMi4yNDIgMjMuOTc1MSAyNC41OTU0QzIzLjM4NjggMjcuMDM5IDE5LjA0ODQgMjUuNzQ4NyAxNy41MTk2IDI1LjI5MzlaIiBmaWxsPSJ3aGl0ZSIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY5Xzg0MSIgeDE9IjIwIiB5MT0iMS4xMTEzMyIgeDI9IjIwIiB5Mj0iMzguODg5MSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjRkZCNjBBIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0Y1ODMwMCIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM+Cjwvc3ZnPgo='; + +describe('FungibleAssetMetadataStruct', () => { + it.each([ + { + name: 'Bitcoin', + symbol: 'BTC', + fungible: true, + iconUrl: `data:image/svg+xml;base64,${BTC_ICON_BASE64}`, + units: [ + { + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + }, + ], + }, + { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://metamask.io/sol.svg', + units: [ + { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }, + ], + }, + ])('validates an object', (value) => { + expect(is(value, FungibleAssetMetadataStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + { + name: 'Bitcoin', + symbol: 'BTC', + fungible: true, + iconUrl: 'https://metamask.io/btc.svg', + units: [], + }, + { + name: 'Bitcoin', + symbol: 'BTC', + fungible: true, + iconUrl: 'http://metamask.io/btc.svg', + units: [ + { + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + }, + ], + }, + { + name: 'Bitcoin', + symbol: 'BTC', + fungible: true, + iconUrl: 'data:image/png;base64,', + units: [ + { + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + }, + ], + }, + ])('does not validate "%p"', (value) => { + expect(is(value, FungibleAssetMetadataStruct)).toBe(false); + }); +}); diff --git a/packages/snaps-sdk/src/types/handlers/assets-lookup.ts b/packages/snaps-sdk/src/types/handlers/assets-lookup.ts new file mode 100644 index 0000000000..62aca05f4b --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/assets-lookup.ts @@ -0,0 +1,75 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + size, + literal, + number, + object, + refine, + string, + record, +} from '@metamask/superstruct'; +import { + assert, + CaipAssetTypeStruct, + type CaipAssetType, +} from '@metamask/utils'; + +export const FungibleAssetUnitStruct = object({ + name: string(), + symbol: string(), + decimals: number(), +}); + +export type FungibleAssetUnit = Infer; + +export const AssetIconUrlStruct = refine(string(), 'Asset URL', (value) => { + try { + const url = new URL(value); + // For now, we require asset URLs to either be base64 SVGs or remote HTTPS URLs + assert( + url.protocol === 'https:' || + value.startsWith('data:image/svg+xml;base64,'), + ); + return true; + } catch { + return 'Invalid URL'; + } +}); + +export const FungibleAssetMetadataStruct = object({ + name: string(), + symbol: string(), + fungible: literal(true), + iconUrl: AssetIconUrlStruct, + units: size(array(FungibleAssetUnitStruct), 1, Infinity), +}); + +export const OnAssetsLookupResponseStruct = object({ + assets: record(CaipAssetTypeStruct, FungibleAssetMetadataStruct), +}); + +export type FungibleAssetMetadata = Infer; + +export type OnAssetsLookupArguments = { + assets: CaipAssetType[]; +}; + +/** + * The `onAssetsLookup` handler. This is called by MetaMask when querying about specific assets on specific chains. + * + * @returns The metadata about each asset. See + * {@link OnAssetsLookupResponse}. + */ +export type OnAssetsLookupHandler = ( + args: OnAssetsLookupArguments, +) => Promise; + +/** + * The response from the query, containing metadata about each requested asset. + * + * @property assets - An object containing a mapping between the CAIP-19 key and a metadata object. + */ +export type OnAssetsLookupResponse = { + assets: Record; +}; diff --git a/packages/snaps-sdk/src/types/handlers/index.ts b/packages/snaps-sdk/src/types/handlers/index.ts index ab6a86d833..33fd11ac75 100644 --- a/packages/snaps-sdk/src/types/handlers/index.ts +++ b/packages/snaps-sdk/src/types/handlers/index.ts @@ -1,3 +1,5 @@ +export * from './assets-conversion'; +export * from './assets-lookup'; export * from './cronjob'; export * from './home-page'; export * from './keyring'; diff --git a/packages/snaps-simulation/src/methods/specifications.test.ts b/packages/snaps-simulation/src/methods/specifications.test.ts index 2690485057..6b23e2789b 100644 --- a/packages/snaps-simulation/src/methods/specifications.test.ts +++ b/packages/snaps-simulation/src/methods/specifications.test.ts @@ -49,6 +49,18 @@ describe('getPermissionSpecifications', () => { }), ).toMatchInlineSnapshot(` { + "endowment:assets": { + "allowedCaveats": [ + "chainIds", + ], + "endowmentGetter": [Function], + "permissionType": "Endowment", + "subjectTypes": [ + "snap", + ], + "targetName": "endowment:assets", + "validator": [Function], + }, "endowment:cronjob": { "allowedCaveats": [ "snapCronjob", diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index a9248b7438..701516c7b8 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.74, - "functions": 98.93, + "functions": 98.94, "lines": 99.46, - "statements": 96.31 + "statements": 96.32 } diff --git a/packages/snaps-utils/src/handler-types.ts b/packages/snaps-utils/src/handler-types.ts index 230486a95e..392969898b 100644 --- a/packages/snaps-utils/src/handler-types.ts +++ b/packages/snaps-utils/src/handler-types.ts @@ -10,6 +10,8 @@ export enum HandlerType { OnHomePage = 'onHomePage', OnSettingsPage = 'onSettingsPage', OnUserInput = 'onUserInput', + OnAssetsLookup = 'onAssetsLookup', + OnAssetsConversion = 'onAssetsConversion', } export type SnapHandler = { diff --git a/packages/snaps-utils/src/handlers.ts b/packages/snaps-utils/src/handlers.ts index a20a48b842..f2de2b6e2a 100644 --- a/packages/snaps-utils/src/handlers.ts +++ b/packages/snaps-utils/src/handlers.ts @@ -111,6 +111,20 @@ export const SNAP_EXPORTS = { return typeof snapExport === 'function'; }, }, + [HandlerType.OnAssetsLookup]: { + type: HandlerType.OnAssetsLookup, + required: true, + validator: (snapExport: unknown): snapExport is OnUserInputHandler => { + return typeof snapExport === 'function'; + }, + }, + [HandlerType.OnAssetsConversion]: { + type: HandlerType.OnAssetsConversion, + required: true, + validator: (snapExport: unknown): snapExport is OnUserInputHandler => { + return typeof snapExport === 'function'; + }, + }, } as const; export const OnTransactionSeverityResponseStruct = object({ diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index ad14afc916..becb3073b0 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -188,6 +188,12 @@ export const EmptyObjectStruct = object({}) as unknown as Struct< /* eslint-disable @typescript-eslint/naming-convention */ export const PermissionsStruct: Describe = type({ + 'endowment:assets': optional( + mergeStructs( + HandlerCaveatsStruct, + object({ scopes: size(array(ChainIdsStruct), 1, Infinity) }), + ), + ), 'endowment:cronjob': optional( mergeStructs( HandlerCaveatsStruct,