From 841ae5baa9682daaf7360c0df5209452e47099b7 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 15 Nov 2022 14:03:25 +0100 Subject: [PATCH] BREAKING: Remove `wallet` global in favor of `snaps` and `ethereum` (#939) * Initial implementation of new global APIs * Fix a bunch of tests and simplify * More tests * Add EIP1193 permission * Fix tests * Use new APIs in examples * Fix tests following rebase * Update coverage * Fix some PR comments * Abstract createService function * Remove leftover comment * Refactor based on PR comments * Rename snap global to snaps Co-authored-by: Maarten Zuidhoorn --- packages/examples/.eslintrc.js | 3 +- .../examples/bls-signer/snap.manifest.json | 2 +- .../examples/examples/bls-signer/src/index.js | 6 +- .../examples/browserify/snap.manifest.json | 2 +- .../examples/examples/browserify/src/snap.ts | 4 +- .../examples/ethers-js/snap.manifest.json | 6 +- .../examples/examples/ethers-js/src/index.js | 8 +- .../examples/notifications/snap.manifest.json | 2 +- .../examples/notifications/src/index.js | 4 +- .../examples/rollup/snap.manifest.json | 2 +- packages/examples/examples/rollup/src/snap.ts | 4 +- .../examples/typescript/snap.manifest.json | 2 +- .../examples/examples/typescript/src/index.ts | 2 +- .../examples/webpack/snap.manifest.json | 2 +- .../examples/examples/webpack/src/snap.ts | 4 +- packages/multichain-provider/package.json | 2 +- .../src/MultiChainProvider.test.ts | 4 +- .../src/MultiChainProvider.ts | 4 +- .../services/AbstractExecutionService.test.ts | 54 +-- .../iframe/IframeExecutionService.test.ts | 139 ++---- .../node/NodeProcessExecutionService.test.ts | 134 +----- .../node/NodeThreadExecutionService.test.ts | 138 +----- .../src/snaps/SnapController.test.ts | 114 +++-- .../src/snaps/endowments/enum.ts | 1 + .../endowments/ethereum-provider.test.ts | 19 + .../src/snaps/endowments/ethereum-provider.ts | 46 ++ .../src/snaps/endowments/index.ts | 3 + .../src/test-utils/execution-environment.ts | 7 +- .../snaps-controllers/src/test-utils/index.ts | 1 + .../src/test-utils/service.ts | 62 +++ .../jest.config.js | 8 +- .../snaps-execution-environments/package.json | 1 + .../src/common/BaseSnapExecutor.test.ts | 422 +++++++++--------- .../src/common/BaseSnapExecutor.ts | 61 ++- .../src/common/endowments/index.test.ts | 130 +++--- .../src/common/endowments/index.ts | 14 +- .../src/iframe/IFrameSnapExecutor.test.ts | 32 +- .../ChildProcessSnapExecutor.test.ts | 32 +- .../node-thread/ThreadSnapExecutor.test.ts | 32 +- packages/snaps-types/global.d.ts | 6 +- packages/snaps-types/src/types.d.ts | 6 +- packages/snaps-utils/src/mock.test.ts | 11 +- packages/snaps-utils/src/mock.ts | 25 +- yarn.lock | 3 +- 44 files changed, 788 insertions(+), 776 deletions(-) create mode 100644 packages/snaps-controllers/src/snaps/endowments/ethereum-provider.test.ts create mode 100644 packages/snaps-controllers/src/snaps/endowments/ethereum-provider.ts create mode 100644 packages/snaps-controllers/src/test-utils/service.ts diff --git a/packages/examples/.eslintrc.js b/packages/examples/.eslintrc.js index d506fc2e66..2a42863470 100644 --- a/packages/examples/.eslintrc.js +++ b/packages/examples/.eslintrc.js @@ -9,7 +9,8 @@ module.exports = { browser: true, }, globals: { - wallet: true, + ethereum: true, + snaps: true, }, rules: { 'no-alert': 'off', diff --git a/packages/examples/examples/bls-signer/snap.manifest.json b/packages/examples/examples/bls-signer/snap.manifest.json index 44432a314d..567f4dec95 100644 --- a/packages/examples/examples/bls-signer/snap.manifest.json +++ b/packages/examples/examples/bls-signer/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps-monorepo.git" }, "source": { - "shasum": "V9PaaBMIyXuDzYQfUWBZjNStPgSv1MBp6g9En7iV6x4=", + "shasum": "miCH7djJ/dGwPije8HLyMJsmXkmjD1E5XJ4ZWIRJt48=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/examples/bls-signer/src/index.js b/packages/examples/examples/bls-signer/src/index.js index 8c22bda4b3..9a2c687567 100644 --- a/packages/examples/examples/bls-signer/src/index.js +++ b/packages/examples/examples/bls-signer/src/index.js @@ -32,7 +32,7 @@ module.exports.onRpcRequest = async ({ request }) => { throw rpcErrors.eth.unauthorized(); } - const PRIVATE_KEY = await wallet.request({ + const PRIVATE_KEY = await snaps.request({ method: 'snap_getEntropy', params: { version: 1, @@ -53,7 +53,7 @@ module.exports.onRpcRequest = async ({ request }) => { * @returns {Promise} The BLS12-381 public key. */ async function getPubKey() { - const PRIV_KEY = await wallet.request({ + const PRIV_KEY = await snaps.request({ method: 'snap_getAppKey', }); return bls.getPublicKey(PRIV_KEY); @@ -70,7 +70,7 @@ async function getPubKey() { * and `false` otherwise. */ async function promptUser(header, message) { - const response = await wallet.request({ + const response = await snaps.request({ method: 'snap_confirm', params: [{ prompt: header, textAreaContent: message }], }); diff --git a/packages/examples/examples/browserify/snap.manifest.json b/packages/examples/examples/browserify/snap.manifest.json index 042f1e5057..7b9f15fb98 100644 --- a/packages/examples/examples/browserify/snap.manifest.json +++ b/packages/examples/examples/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps-monorepo.git" }, "source": { - "shasum": "C25lIsH+SbFGCJM9i1lGLdPYq7YsUX0UHr/tZFMfsHI=", + "shasum": "8+W9D201oiHmmRETMd+n4W7Qj8Q+NicMhFutRy7Wgm4=", "location": { "npm": { "filePath": "dist/snap.js", diff --git a/packages/examples/examples/browserify/src/snap.ts b/packages/examples/examples/browserify/src/snap.ts index f679eb769e..8622c8c19f 100644 --- a/packages/examples/examples/browserify/src/snap.ts +++ b/packages/examples/examples/browserify/src/snap.ts @@ -14,7 +14,7 @@ import { OnRpcRequestHandler } from '@metamask/snaps-types'; export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { switch (request.method) { case 'inApp': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { @@ -24,7 +24,7 @@ export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { ], }); case 'native': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { diff --git a/packages/examples/examples/ethers-js/snap.manifest.json b/packages/examples/examples/ethers-js/snap.manifest.json index 40cd84dc3d..fbae482d22 100644 --- a/packages/examples/examples/ethers-js/snap.manifest.json +++ b/packages/examples/examples/ethers-js/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps-monorepo.git" }, "source": { - "shasum": "zLzPFhtq7ZvlAkDtR+hmKm0fS/qh51QTE1eyAqpsuYQ=", + "shasum": "VOyBi0w/CPD9GUbWssCYdqrJtaPG0Lnu6GhT2ZN1eiI=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,6 +16,8 @@ } } }, - "initialPermissions": {}, + "initialPermissions": { + "endowment:ethereum-provider": {} + }, "manifestVersion": "0.1" } diff --git a/packages/examples/examples/ethers-js/src/index.js b/packages/examples/examples/ethers-js/src/index.js index ddfa3a4819..746c8e51d2 100644 --- a/packages/examples/examples/ethers-js/src/index.js +++ b/packages/examples/examples/ethers-js/src/index.js @@ -5,11 +5,7 @@ const ethers = require('ethers'); -/* - * The `wallet` API is a superset of the standard provider, - * and can be used to initialize an ethers.js provider like this: - */ -const provider = new ethers.providers.Web3Provider(wallet); +const provider = new ethers.providers.Web3Provider(ethereum); /** * Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`. @@ -22,7 +18,7 @@ const provider = new ethers.providers.Web3Provider(wallet); */ module.exports.onRpcRequest = async ({ request }) => { console.log('received request', request); - const privKey = await wallet.request({ + const privKey = await snaps.request({ method: 'snap_getAppKey', }); console.log(`privKey is ${privKey}`); diff --git a/packages/examples/examples/notifications/snap.manifest.json b/packages/examples/examples/notifications/snap.manifest.json index 73f7481329..5f5f903179 100644 --- a/packages/examples/examples/notifications/snap.manifest.json +++ b/packages/examples/examples/notifications/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-template.git" }, "source": { - "shasum": "7FEkvygLr4v+1VdoUCTGPLz4BNWRZe5q4F46tHbEWMA=", + "shasum": "ShjN76CTBN8ByMf+CZ9A81rHcKg3VcpU/aDsC8HjFls=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/examples/notifications/src/index.js b/packages/examples/examples/notifications/src/index.js index 82dbd2d979..f4e228c2ea 100644 --- a/packages/examples/examples/notifications/src/index.js +++ b/packages/examples/examples/notifications/src/index.js @@ -13,7 +13,7 @@ module.exports.onRpcRequest = async ({ origin, request }) => { switch (request.method) { case 'inApp': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { @@ -23,7 +23,7 @@ module.exports.onRpcRequest = async ({ origin, request }) => { ], }); case 'native': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { diff --git a/packages/examples/examples/rollup/snap.manifest.json b/packages/examples/examples/rollup/snap.manifest.json index 543c747402..e36f76a8e7 100644 --- a/packages/examples/examples/rollup/snap.manifest.json +++ b/packages/examples/examples/rollup/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps-monorepo.git" }, "source": { - "shasum": "KBCbe8PgRkiublGQuK8NPeUex737E8Px3fSrSdnYuIg=", + "shasum": "lXWNW+zE94++8/TeAZ1stUKzoRe1ohLqJY4dUiS54QA=", "location": { "npm": { "filePath": "dist/snap.js", diff --git a/packages/examples/examples/rollup/src/snap.ts b/packages/examples/examples/rollup/src/snap.ts index f679eb769e..8622c8c19f 100644 --- a/packages/examples/examples/rollup/src/snap.ts +++ b/packages/examples/examples/rollup/src/snap.ts @@ -14,7 +14,7 @@ import { OnRpcRequestHandler } from '@metamask/snaps-types'; export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { switch (request.method) { case 'inApp': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { @@ -24,7 +24,7 @@ export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { ], }); case 'native': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { diff --git a/packages/examples/examples/typescript/snap.manifest.json b/packages/examples/examples/typescript/snap.manifest.json index 1da1bad0ba..fcc03aac08 100644 --- a/packages/examples/examples/typescript/snap.manifest.json +++ b/packages/examples/examples/typescript/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-template.git" }, "source": { - "shasum": "yUHyjRd6jE4MaIr2yc9eqRV2ndwBQAiYHlOpPQITY4c=", + "shasum": "s67hGX4eR3eMxlZIPN3C/M19HkeubAWpVID4HQvH2Ww=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/examples/typescript/src/index.ts b/packages/examples/examples/typescript/src/index.ts index 98faaebbcb..d7a93c4ae0 100644 --- a/packages/examples/examples/typescript/src/index.ts +++ b/packages/examples/examples/typescript/src/index.ts @@ -15,7 +15,7 @@ import { getMessage } from './message'; export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { switch (request.method) { case 'hello': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { diff --git a/packages/examples/examples/webpack/snap.manifest.json b/packages/examples/examples/webpack/snap.manifest.json index 7258fa77cf..8a94c08daf 100644 --- a/packages/examples/examples/webpack/snap.manifest.json +++ b/packages/examples/examples/webpack/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps-monorepo.git" }, "source": { - "shasum": "ZWFcrhWfJfQko9o1PLh+IdOh+GbmDVpA5Ndhq8rhLJM=", + "shasum": "IWOYjh6N3N8KUj08/0bjGsRHJQSNuJlMvt/3/cCNPBk=", "location": { "npm": { "filePath": "dist/snap.js", diff --git a/packages/examples/examples/webpack/src/snap.ts b/packages/examples/examples/webpack/src/snap.ts index f679eb769e..8622c8c19f 100644 --- a/packages/examples/examples/webpack/src/snap.ts +++ b/packages/examples/examples/webpack/src/snap.ts @@ -14,7 +14,7 @@ import { OnRpcRequestHandler } from '@metamask/snaps-types'; export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { switch (request.method) { case 'inApp': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { @@ -24,7 +24,7 @@ export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { ], }); case 'native': - return wallet.request({ + return snaps.request({ method: 'snap_notify', params: [ { diff --git a/packages/multichain-provider/package.json b/packages/multichain-provider/package.json index 1dfa98adce..7b296df37d 100644 --- a/packages/multichain-provider/package.json +++ b/packages/multichain-provider/package.json @@ -25,8 +25,8 @@ "publish:package": "../../scripts/publish-package.sh" }, "dependencies": { + "@metamask/providers": "^10.2.0", "@metamask/safe-event-emitter": "^2.0.0", - "@metamask/snaps-types": "^0.23.0", "@metamask/snaps-utils": "^0.23.0", "@metamask/utils": "^3.3.1", "nanoid": "^3.1.31" diff --git a/packages/multichain-provider/src/MultiChainProvider.test.ts b/packages/multichain-provider/src/MultiChainProvider.test.ts index f905f3d607..4ac59d1060 100644 --- a/packages/multichain-provider/src/MultiChainProvider.test.ts +++ b/packages/multichain-provider/src/MultiChainProvider.test.ts @@ -1,4 +1,4 @@ -import { SnapProvider } from '@metamask/snaps-types'; +import { MetaMaskInpageProvider } from '@metamask/providers'; import { NamespaceId, RequestNamespace } from '@metamask/snaps-utils'; import { getRequestNamespace, @@ -14,7 +14,7 @@ Object.assign(globalThis, { }); // Used for mocking the provider's return values. -declare const ethereum: SnapProvider; +declare const ethereum: MetaMaskInpageProvider; /** * Get a new `MultiChainProvider` instance, that is connected to the given diff --git a/packages/multichain-provider/src/MultiChainProvider.ts b/packages/multichain-provider/src/MultiChainProvider.ts index e4f579d8ba..3529e5a956 100644 --- a/packages/multichain-provider/src/MultiChainProvider.ts +++ b/packages/multichain-provider/src/MultiChainProvider.ts @@ -12,7 +12,7 @@ import { Session, } from '@metamask/snaps-utils'; import { JsonRpcRequest, Json } from '@metamask/utils'; -import type { SnapProvider } from '@metamask/snaps-types'; +import { MetaMaskInpageProvider } from '@metamask/providers'; import { nanoid } from 'nanoid'; import { Provider } from './Provider'; @@ -21,7 +21,7 @@ declare global { // here. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { - ethereum: SnapProvider; + ethereum: MetaMaskInpageProvider; } } diff --git a/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts b/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts index 02fe7a317e..45f2c36ec0 100644 --- a/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts +++ b/packages/snaps-controllers/src/services/AbstractExecutionService.test.ts @@ -1,16 +1,13 @@ -import { ControllerMessenger } from '@metamask/controllers'; import { HandlerType } from '@metamask/snaps-utils'; -import { - ErrorMessageEvent, - ExecutionServiceMessenger, -} from './ExecutionService'; +import { createService } from '../test-utils'; +import { ExecutionServiceArgs } from './AbstractExecutionService'; import { NodeThreadExecutionService } from './node'; class MockExecutionService extends NodeThreadExecutionService { - constructor(messenger: ExecutionServiceMessenger) { + constructor({ messenger, setupSnapProvider }: ExecutionServiceArgs) { super({ messenger, - setupSnapProvider: () => undefined, + setupSnapProvider, }); } @@ -27,19 +24,7 @@ describe('AbstractExecutionService', () => { it('logs error for unrecognized notifications', async () => { const consoleErrorSpy = jest.spyOn(console, 'error'); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new MockExecutionService( - controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - ); + const { service } = createService(MockExecutionService); await service.executeSnap({ snapId: 'TestSnap', @@ -66,19 +51,7 @@ describe('AbstractExecutionService', () => { it('logs error for malformed UnhandledError notification', async () => { const consoleErrorSpy = jest.spyOn(console, 'error'); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new MockExecutionService( - controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - ); + const { service } = createService(MockExecutionService); await service.executeSnap({ snapId: 'TestSnap', @@ -112,20 +85,7 @@ describe('AbstractExecutionService', () => { }); it('throws an error if RPC request handler is unavailable', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new MockExecutionService( - controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - ); - + const { service } = createService(MockExecutionService); const snapId = 'TestSnap'; await expect( service.handleRpcRequest(snapId, { diff --git a/packages/snaps-controllers/src/services/iframe/IframeExecutionService.test.ts b/packages/snaps-controllers/src/services/iframe/IframeExecutionService.test.ts index 8ca405329e..8d667ae2bd 100644 --- a/packages/snaps-controllers/src/services/iframe/IframeExecutionService.test.ts +++ b/packages/snaps-controllers/src/services/iframe/IframeExecutionService.test.ts @@ -1,16 +1,11 @@ -import { ControllerMessenger } from '@metamask/controllers'; -import { JsonRpcEngine } from 'json-rpc-engine'; -import { createEngineStream } from 'json-rpc-middleware-stream'; -import pump from 'pump'; import { HandlerType } from '@metamask/snaps-utils'; -import { ErrorMessageEvent } from '../ExecutionService'; -import { setupMultiplex } from '../AbstractExecutionService'; +import { createService } from '@metamask/snaps-controllers/test-utils'; import { IframeExecutionService } from './IframeExecutionService'; import fixJSDOMPostMessageEventSource from './test/fixJSDOMPostMessageEventSource'; import { PORT as serverPort, - stop as stopServer, start as startServer, + stop as stopServer, } from './test/server'; // We do not use our default endowments in these tests because JSDOM doesn't @@ -18,6 +13,15 @@ import { const iframeUrl = new URL(`http://localhost:${serverPort}`); +const createIFrameService = () => { + const { service, ...rest } = createService(IframeExecutionService, { + iframeUrl, + }); + + const removeListener = fixJSDOMPostMessageEventSource(service); + return { service, removeListener, ...rest }; +}; + describe('IframeExecutionService', () => { // The tests start running before the server is ready if we don't use the done callback. // eslint-disable-next-line jest/no-done-callback @@ -31,53 +35,15 @@ describe('IframeExecutionService', () => { }); it('can boot', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const iframeExecutionService = new IframeExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - iframeUrl, - }); - const removeListener = fixJSDOMPostMessageEventSource( - iframeExecutionService, - ); - expect(iframeExecutionService).toBeDefined(); - await iframeExecutionService.terminateAllSnaps(); + const { service, removeListener } = createIFrameService(); + expect(service).toBeDefined(); + await service.terminateAllSnaps(); removeListener(); }); it('can create a snap worker and start the snap', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const iframeExecutionService = new IframeExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - iframeUrl, - }); - const removeListener = fixJSDOMPostMessageEventSource( - iframeExecutionService, - ); - const response = await iframeExecutionService.executeSnap({ + const { service, removeListener } = createIFrameService(); + const response = await service.executeSnap({ snapId: 'TestSnap', sourceCode: ` console.log('foo'); @@ -85,34 +51,15 @@ describe('IframeExecutionService', () => { endowments: ['console'], }); expect(response).toStrictEqual('OK'); - await iframeExecutionService.terminateAllSnaps(); + await service.terminateAllSnaps(); removeListener(); }); it('can handle a crashed snap', async () => { expect.assertions(1); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const iframeExecutionService = new IframeExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - iframeUrl, - }); - const removeListener = fixJSDOMPostMessageEventSource( - iframeExecutionService, - ); + const { service, removeListener } = createIFrameService(); const action = async () => { - await iframeExecutionService.executeSnap({ + await service.executeSnap({ snapId: 'TestSnap', sourceCode: ` throw new Error("potato"); @@ -124,61 +71,27 @@ describe('IframeExecutionService', () => { await expect(action()).rejects.toThrow( /Error while running snap 'TestSnap'/u, ); - await iframeExecutionService.terminateAllSnaps(); + await service.terminateAllSnaps(); removeListener(); }); it('can detect outbound requests', async () => { const blockNumber = '0xa70e75'; expect.assertions(4); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const messenger = controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }); + const { service, removeListener, messenger } = createIFrameService(); const publishSpy = jest.spyOn(messenger, 'publish'); - const iframeExecutionService = new IframeExecutionService({ - messenger, - setupSnapProvider: (_snapId, rpcStream) => { - const mux = setupMultiplex(rpcStream, 'foo'); - const stream = mux.createStream('metamask-provider'); - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - if (req.method === 'metamask_getProviderState') { - res.result = { isUnlocked: false, accounts: [] }; - return end(); - } else if (req.method === 'eth_blockNumber') { - res.result = blockNumber; - return end(); - } - return next(); - }); - const providerStream = createEngineStream({ engine }); - pump(stream, providerStream, stream); - }, - iframeUrl, - }); const snapId = 'TestSnap'; - const removeListener = fixJSDOMPostMessageEventSource( - iframeExecutionService, - ); - const executeResult = await iframeExecutionService.executeSnap({ + const executeResult = await service.executeSnap({ snapId, sourceCode: ` - module.exports.onRpcRequest = () => wallet.request({ method: 'eth_blockNumber', params: [] }); + module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); `, - endowments: [], + endowments: ['ethereum'], }); expect(executeResult).toBe('OK'); - const result = await iframeExecutionService.handleRpcRequest(snapId, { + const result = await service.handleRpcRequest(snapId, { origin: 'foo', handler: HandlerType.OnRpcRequest, request: { @@ -201,7 +114,7 @@ describe('IframeExecutionService', () => { 'TestSnap', ); - await iframeExecutionService.terminateAllSnaps(); + await service.terminateAllSnaps(); removeListener(); }); }); diff --git a/packages/snaps-controllers/src/services/node/NodeProcessExecutionService.test.ts b/packages/snaps-controllers/src/services/node/NodeProcessExecutionService.test.ts index 9788cbf11a..2b5cf221af 100644 --- a/packages/snaps-controllers/src/services/node/NodeProcessExecutionService.test.ts +++ b/packages/snaps-controllers/src/services/node/NodeProcessExecutionService.test.ts @@ -1,53 +1,19 @@ -import { ControllerMessenger } from '@metamask/controllers'; import { HandlerType, SnapId } from '@metamask/snaps-utils'; -import { JsonRpcEngine } from 'json-rpc-engine'; -import { createEngineStream } from 'json-rpc-middleware-stream'; -import pump from 'pump'; -import { ErrorMessageEvent, SnapErrorJson } from '../ExecutionService'; -import { setupMultiplex } from '../AbstractExecutionService'; +import { SnapErrorJson } from '../ExecutionService'; +import { createService, MOCK_BLOCK_NUMBER } from '../../test-utils'; import { NodeProcessExecutionService } from './NodeProcessExecutionService'; const ON_RPC_REQUEST = HandlerType.OnRpcRequest; describe('NodeProcessExecutionService', () => { it('can boot', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeProcessExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeProcessExecutionService); expect(service).toBeDefined(); await service.terminateAllSnaps(); }); it('can create a snap worker and start the snap', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeProcessExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeProcessExecutionService); const response = await service.executeSnap({ snapId: 'TestSnap', sourceCode: ` @@ -61,22 +27,7 @@ describe('NodeProcessExecutionService', () => { it('can handle a crashed snap', async () => { expect.assertions(1); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeProcessExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeProcessExecutionService); const action = async () => { await service.executeSnap({ snapId: 'TestSnap', @@ -95,22 +46,7 @@ describe('NodeProcessExecutionService', () => { it('can handle errors in request handler', async () => { expect.assertions(1); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeProcessExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeProcessExecutionService); const snapId = 'TestSnap'; await service.executeSnap({ snapId, @@ -137,22 +73,9 @@ describe('NodeProcessExecutionService', () => { it('can handle errors out of band', async () => { expect.assertions(2); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeProcessExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service, controllerMessenger } = createService( + NodeProcessExecutionService, + ); const snapId = 'TestSnap'; await service.executeSnap({ snapId, @@ -211,46 +134,15 @@ describe('NodeProcessExecutionService', () => { it('can detect outbound requests', async () => { expect.assertions(4); - const blockNumber = '0xa70e75'; - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const messenger = controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }); + const { service, messenger } = createService(NodeProcessExecutionService); const publishSpy = jest.spyOn(messenger, 'publish'); - const service = new NodeProcessExecutionService({ - messenger, - setupSnapProvider: (_snapId, rpcStream) => { - const mux = setupMultiplex(rpcStream, 'foo'); - const stream = mux.createStream('metamask-provider'); - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - if (req.method === 'metamask_getProviderState') { - res.result = { isUnlocked: false, accounts: [] }; - return end(); - } else if (req.method === 'eth_blockNumber') { - res.result = blockNumber; - return end(); - } - return next(); - }); - const providerStream = createEngineStream({ engine }); - pump(stream, providerStream, stream); - }, - }); const snapId = 'TestSnap'; const executeResult = await service.executeSnap({ snapId, sourceCode: ` - module.exports.onRpcRequest = () => wallet.request({ method: 'eth_blockNumber', params: [] }); + module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); `, - endowments: [], + endowments: ['ethereum'], }); expect(executeResult).toBe('OK'); @@ -266,7 +158,7 @@ describe('NodeProcessExecutionService', () => { }, }); - expect(result).toBe(blockNumber); + expect(result).toBe(MOCK_BLOCK_NUMBER); expect(publishSpy).toHaveBeenCalledWith( 'ExecutionService:outboundRequest', diff --git a/packages/snaps-controllers/src/services/node/NodeThreadExecutionService.test.ts b/packages/snaps-controllers/src/services/node/NodeThreadExecutionService.test.ts index 0cfdac1e69..5092cbdb0d 100644 --- a/packages/snaps-controllers/src/services/node/NodeThreadExecutionService.test.ts +++ b/packages/snaps-controllers/src/services/node/NodeThreadExecutionService.test.ts @@ -1,53 +1,22 @@ -import { ControllerMessenger } from '@metamask/controllers'; import { HandlerType, SnapId } from '@metamask/snaps-utils'; -import { JsonRpcEngine } from 'json-rpc-engine'; -import { createEngineStream } from 'json-rpc-middleware-stream'; -import pump from 'pump'; -import { ErrorMessageEvent, SnapErrorJson } from '../ExecutionService'; -import { setupMultiplex } from '../AbstractExecutionService'; +import { + createService, + MOCK_BLOCK_NUMBER, +} from '@metamask/snaps-controllers/test-utils'; +import { SnapErrorJson } from '../ExecutionService'; import { NodeThreadExecutionService } from './NodeThreadExecutionService'; const ON_RPC_REQUEST = HandlerType.OnRpcRequest; describe('NodeThreadExecutionService', () => { it('can boot', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeThreadExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeThreadExecutionService); expect(service).toBeDefined(); await service.terminateAllSnaps(); }); it('can create a snap worker and start the snap', async () => { - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeThreadExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeThreadExecutionService); const response = await service.executeSnap({ snapId: 'TestSnap', sourceCode: ` @@ -61,22 +30,7 @@ describe('NodeThreadExecutionService', () => { it('can handle a crashed snap', async () => { expect.assertions(1); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeThreadExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeThreadExecutionService); const action = async () => { await service.executeSnap({ snapId: 'TestSnap', @@ -95,22 +49,7 @@ describe('NodeThreadExecutionService', () => { it('can handle errors in request handler', async () => { expect.assertions(1); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeThreadExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service } = createService(NodeThreadExecutionService); const snapId = 'TestSnap'; await service.executeSnap({ snapId, @@ -137,22 +76,9 @@ describe('NodeThreadExecutionService', () => { it('can handle errors out of band', async () => { expect.assertions(2); - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const service = new NodeThreadExecutionService({ - messenger: controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }), - setupSnapProvider: () => { - // do nothing - }, - }); + const { service, controllerMessenger } = createService( + NodeThreadExecutionService, + ); const snapId = 'TestSnap'; await service.executeSnap({ snapId, @@ -211,46 +137,16 @@ describe('NodeThreadExecutionService', () => { it('can detect outbound requests', async () => { expect.assertions(4); - const blockNumber = '0xa70e75'; - const controllerMessenger = new ControllerMessenger< - never, - ErrorMessageEvent - >(); - const messenger = controllerMessenger.getRestricted< - 'ExecutionService', - never, - ErrorMessageEvent['type'] - >({ - name: 'ExecutionService', - }); + const { service, messenger } = createService(NodeThreadExecutionService); const publishSpy = jest.spyOn(messenger, 'publish'); - const service = new NodeThreadExecutionService({ - messenger, - setupSnapProvider: (_snapId, rpcStream) => { - const mux = setupMultiplex(rpcStream, 'foo'); - const stream = mux.createStream('metamask-provider'); - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - if (req.method === 'metamask_getProviderState') { - res.result = { isUnlocked: false, accounts: [] }; - return end(); - } else if (req.method === 'eth_blockNumber') { - res.result = blockNumber; - return end(); - } - return next(); - }); - const providerStream = createEngineStream({ engine }); - pump(stream, providerStream, stream); - }, - }); + const snapId = 'TestSnap'; const executeResult = await service.executeSnap({ snapId, sourceCode: ` - module.exports.onRpcRequest = () => wallet.request({ method: 'eth_blockNumber', params: [] }); + module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); `, - endowments: [], + endowments: ['ethereum'], }); expect(executeResult).toBe('OK'); @@ -266,7 +162,7 @@ describe('NodeThreadExecutionService', () => { }, }); - expect(result).toBe(blockNumber); + expect(result).toBe(MOCK_BLOCK_NUMBER); expect(publishSpy).toHaveBeenCalledWith( 'ExecutionService:outboundRequest', diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.ts b/packages/snaps-controllers/src/snaps/SnapController.test.ts index e791731a6c..7109654d13 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.test.ts @@ -925,25 +925,43 @@ describe('SnapController', () => { it('does not timeout while waiting for response from MetaMask', async () => { const sourceCode = ` - module.exports.onRpcRequest = () => wallet.request({ method: 'eth_blockNumber', params: [] }); + module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); `; - const [snapController, service] = getSnapControllerWithEES( - getSnapControllerWithEESOptions({ - idleTimeCheckInterval: 30000, - maxIdleTime: 160000, - state: { - snaps: getPersistedSnapsState( - getPersistedSnapObject({ - sourceCode, - manifest: getSnapManifest({ - shasum: getSnapSourceShasum(sourceCode), - }), + const options = getSnapControllerWithEESOptions({ + environmentEndowmentPermissions: [SnapEndowments.EthereumProvider], + idleTimeCheckInterval: 30000, + maxIdleTime: 160000, + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ + sourceCode, + manifest: getSnapManifest({ + shasum: getSnapSourceShasum(sourceCode), }), - ), - }, - }), - ); + }), + ), + }, + }); + + const { rootMessenger } = options; + + const originalCall = rootMessenger.call.bind(rootMessenger); + + jest.spyOn(rootMessenger, 'call').mockImplementation((method, ...args) => { + // Give snap EIP-1193 permission + if (method === 'PermissionController:hasPermission') { + return true; + } else if ( + method === 'PermissionController:getEndowments' && + args[1] === SnapEndowments.EthereumProvider + ) { + return ['ethereum']; + } + return originalCall(method, ...args) as any; + }); + + const [snapController, service] = getSnapControllerWithEES(options); const snap = snapController.getExpect(MOCK_SNAP_ID); @@ -958,7 +976,12 @@ describe('SnapController', () => { const engine = new JsonRpcEngine(); const middleware = createAsyncMiddleware(async (req, res, _next) => { if (req.method === 'metamask_getProviderState') { - res.result = { isUnlocked: false, accounts: [] }; + res.result = { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }; } else if (req.method === 'eth_blockNumber') { await new Promise((resolve) => setTimeout(resolve, 400)); res.result = blockNumber; @@ -995,26 +1018,44 @@ describe('SnapController', () => { it('does not timeout while waiting for response from MetaMask when snap does multiple calls', async () => { const sourceCode = ` - const fetch = async () => parseInt(await wallet.request({ method: 'eth_blockNumber', params: [] }), 16); + const fetch = async () => parseInt(await ethereum.request({ method: 'eth_blockNumber', params: [] }), 16); module.exports.onRpcRequest = async () => (await fetch()) + (await fetch()); `; - const [snapController, service] = getSnapControllerWithEES( - getSnapControllerWithEESOptions({ - idleTimeCheckInterval: 30000, - maxIdleTime: 160000, - state: { - snaps: getPersistedSnapsState( - getPersistedSnapObject({ - sourceCode, - manifest: getSnapManifest({ - shasum: getSnapSourceShasum(sourceCode), - }), + const options = getSnapControllerWithEESOptions({ + environmentEndowmentPermissions: [SnapEndowments.EthereumProvider], + idleTimeCheckInterval: 30000, + maxIdleTime: 160000, + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ + sourceCode, + manifest: getSnapManifest({ + shasum: getSnapSourceShasum(sourceCode), }), - ), - }, - }), - ); + }), + ), + }, + }); + + const { rootMessenger } = options; + + const originalCall = rootMessenger.call.bind(rootMessenger); + + jest.spyOn(rootMessenger, 'call').mockImplementation((method, ...args) => { + // Give snap EIP-1193 permission + if (method === 'PermissionController:hasPermission') { + return true; + } else if ( + method === 'PermissionController:getEndowments' && + args[1] === SnapEndowments.EthereumProvider + ) { + return ['ethereum']; + } + return originalCall(method, ...args) as any; + }); + + const [snapController, service] = getSnapControllerWithEES(options); const snap = snapController.getExpect(MOCK_SNAP_ID); @@ -1027,7 +1068,12 @@ describe('SnapController', () => { const engine = new JsonRpcEngine(); const middleware = createAsyncMiddleware(async (req, res, _next) => { if (req.method === 'metamask_getProviderState') { - res.result = { isUnlocked: false, accounts: [] }; + res.result = { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }; } else if (req.method === 'eth_blockNumber') { await new Promise((resolve) => setTimeout(resolve, 400)); res.result = '0xa70e77'; diff --git a/packages/snaps-controllers/src/snaps/endowments/enum.ts b/packages/snaps-controllers/src/snaps/endowments/enum.ts index fd9f5619ec..33438f8f0e 100644 --- a/packages/snaps-controllers/src/snaps/endowments/enum.ts +++ b/packages/snaps-controllers/src/snaps/endowments/enum.ts @@ -4,4 +4,5 @@ export enum SnapEndowments { TransactionInsight = 'endowment:transaction-insight', Keyring = 'endowment:keyring', Cronjob = 'endowment:cronjob', + EthereumProvider = 'endowment:ethereum-provider', } diff --git a/packages/snaps-controllers/src/snaps/endowments/ethereum-provider.test.ts b/packages/snaps-controllers/src/snaps/endowments/ethereum-provider.test.ts new file mode 100644 index 0000000000..eab7be3929 --- /dev/null +++ b/packages/snaps-controllers/src/snaps/endowments/ethereum-provider.test.ts @@ -0,0 +1,19 @@ +import { PermissionType } from '@metamask/controllers'; +import { SnapEndowments } from './enum'; +import { ethereumProviderEndowmentBuilder } from './ethereum-provider'; + +describe('endowment:eip1193', () => { + it('builds the expected permission specification', () => { + const specification = ethereumProviderEndowmentBuilder.specificationBuilder( + {}, + ); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetKey: SnapEndowments.EthereumProvider, + endowmentGetter: expect.any(Function), + allowedCaveats: null, + }); + + expect(specification.endowmentGetter()).toStrictEqual(['ethereum']); + }); +}); diff --git a/packages/snaps-controllers/src/snaps/endowments/ethereum-provider.ts b/packages/snaps-controllers/src/snaps/endowments/ethereum-provider.ts new file mode 100644 index 0000000000..fa1540b3d8 --- /dev/null +++ b/packages/snaps-controllers/src/snaps/endowments/ethereum-provider.ts @@ -0,0 +1,46 @@ +import { + EndowmentGetterParams, + PermissionSpecificationBuilder, + PermissionType, + ValidPermissionSpecification, +} from '@metamask/controllers'; +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.EthereumProvider; + +type EthereumProviderEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetKey: typeof permissionName; + endowmentGetter: (_options?: any) => ['ethereum']; + allowedCaveats: null; +}>; + +/** + * `endowment:ethereum-provider` returns the name of the ethereum global browser API. + * This is intended to populate the endowments of the + * SES Compartment in which a Snap executes. + * + * This populates the global scope with an EIP-1193 provider, which DOES NOT implement all legacy functionality exposed to dapps. + * + * @param _builderOptions - Optional specification builder options. + * @returns The specification for the network endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + EthereumProviderEndowmentSpecification +> = (_builderOptions?: any) => { + return { + permissionType: PermissionType.Endowment, + targetKey: permissionName, + allowedCaveats: null, + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => { + return ['ethereum']; + }, + }; +}; + +export const ethereumProviderEndowmentBuilder = Object.freeze({ + targetKey: permissionName, + specificationBuilder, +} as const); diff --git a/packages/snaps-controllers/src/snaps/endowments/index.ts b/packages/snaps-controllers/src/snaps/endowments/index.ts index eeb0a1b727..4b991fa04d 100644 --- a/packages/snaps-controllers/src/snaps/endowments/index.ts +++ b/packages/snaps-controllers/src/snaps/endowments/index.ts @@ -13,6 +13,7 @@ import { keyringCaveatSpecifications, getKeyringCaveatMapper, } from './keyring'; +import { ethereumProviderEndowmentBuilder } from './ethereum-provider'; export const endowmentPermissionBuilders = { [networkAccessEndowmentBuilder.targetKey]: networkAccessEndowmentBuilder, @@ -21,6 +22,8 @@ export const endowmentPermissionBuilders = { transactionInsightEndowmentBuilder, [keyringEndowmentBuilder.targetKey]: keyringEndowmentBuilder, [cronjobEndowmentBuilder.targetKey]: cronjobEndowmentBuilder, + [ethereumProviderEndowmentBuilder.targetKey]: + ethereumProviderEndowmentBuilder, } as const; export const endowmentCaveatSpecifications = { diff --git a/packages/snaps-controllers/src/test-utils/execution-environment.ts b/packages/snaps-controllers/src/test-utils/execution-environment.ts index eda38d4764..bab30508ed 100644 --- a/packages/snaps-controllers/src/test-utils/execution-environment.ts +++ b/packages/snaps-controllers/src/test-utils/execution-environment.ts @@ -44,7 +44,12 @@ export const getNodeEES = (messenger: ReturnType) => const engine = new JsonRpcEngine(); engine.push((req, res, next, end) => { if (req.method === 'metamask_getProviderState') { - res.result = { isUnlocked: false, accounts: [] }; + res.result = { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }; return end(); } else if (req.method === 'eth_blockNumber') { res.result = MOCK_BLOCK_NUMBER; diff --git a/packages/snaps-controllers/src/test-utils/index.ts b/packages/snaps-controllers/src/test-utils/index.ts index 3e75f9cac8..15cc2a8422 100644 --- a/packages/snaps-controllers/src/test-utils/index.ts +++ b/packages/snaps-controllers/src/test-utils/index.ts @@ -1,3 +1,4 @@ export * from './controller'; export * from './execution-environment'; export * from './multichain'; +export * from './service'; diff --git a/packages/snaps-controllers/src/test-utils/service.ts b/packages/snaps-controllers/src/test-utils/service.ts new file mode 100644 index 0000000000..f6874f3c10 --- /dev/null +++ b/packages/snaps-controllers/src/test-utils/service.ts @@ -0,0 +1,62 @@ +import { Duplex } from 'stream'; +import { ControllerMessenger } from '@metamask/controllers'; +import { JsonRpcEngine } from 'json-rpc-engine'; +import { createEngineStream } from 'json-rpc-middleware-stream'; +import pump from 'pump'; +import { ErrorMessageEvent, setupMultiplex } from '../services'; +import { MOCK_BLOCK_NUMBER } from './execution-environment'; + +export const createService = < + Service extends new (...args: any[]) => InstanceType, +>( + ServiceClass: Service, + options?: Omit< + ConstructorParameters[0], + 'messenger' | 'setupSnapProvider' + >, +) => { + const controllerMessenger = new ControllerMessenger< + never, + ErrorMessageEvent + >(); + + const messenger = controllerMessenger.getRestricted< + 'ExecutionService', + never, + ErrorMessageEvent['type'] + >({ + name: 'ExecutionService', + }); + + const service = new ServiceClass({ + messenger, + setupSnapProvider: (_snapId: string, rpcStream: Duplex) => { + const mux = setupMultiplex(rpcStream, 'foo'); + const stream = mux.createStream('metamask-provider'); + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => { + if (req.method === 'metamask_getProviderState') { + res.result = { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }; + return end(); + } + + if (req.method === 'eth_blockNumber') { + res.result = MOCK_BLOCK_NUMBER; + return end(); + } + + return next(); + }); + const providerStream = createEngineStream({ engine }); + pump(stream, providerStream, stream); + }, + ...options, + }); + + return { service, messenger, controllerMessenger }; +}; diff --git a/packages/snaps-execution-environments/jest.config.js b/packages/snaps-execution-environments/jest.config.js index fb20429f31..e9c3866721 100644 --- a/packages/snaps-execution-environments/jest.config.js +++ b/packages/snaps-execution-environments/jest.config.js @@ -5,10 +5,10 @@ module.exports = deepmerge(baseConfig, { coveragePathIgnorePatterns: ['./src/index.ts'], coverageThreshold: { global: { - branches: 89.47, - functions: 90.26, - lines: 88.19, - statements: 88.19, + branches: 89.7, + functions: 90.43, + lines: 88.45, + statements: 88.45, }, }, testEnvironment: '/jest.environment.js', diff --git a/packages/snaps-execution-environments/package.json b/packages/snaps-execution-environments/package.json index b2daf1ea20..b5a8283e42 100644 --- a/packages/snaps-execution-environments/package.json +++ b/packages/snaps-execution-environments/package.json @@ -37,6 +37,7 @@ "@metamask/snaps-utils": "^0.23.0", "@metamask/utils": "^3.3.1", "eth-rpc-errors": "^4.0.3", + "json-rpc-engine": "^6.1.0", "pump": "^3.0.0", "ses": "^0.17.0", "stream-browserify": "^3.0.0", diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts index f771dc62ad..030b902032 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.ts @@ -111,6 +111,41 @@ class TestSnapExecutor extends BaseSnapExecutor { }); } + // Utility function for executing snaps + public async executeSnap( + id: number, + name: string, + code: string, + endowments: string[], + ) { + const providerRequestPromise = this.readRpc(); + await this.writeCommand({ + jsonrpc: '2.0', + id, + method: 'executeSnap', + params: [name, code, endowments], + }); + + // In case we are running fake timers, execute a tiny step that forces setTimeout to execute, is required for stream comms + jest.advanceTimersByTime(1); + const providerRequest = await providerRequestPromise; + await this.writeRpc({ + name: 'metamask-provider', + data: { + jsonrpc: '2.0', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: providerRequest.data.id!, + result: { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }, + }, + }); + jest.advanceTimersByTime(1); + } + public writeCommand(message: JsonRpcRequest): Promise { return new Promise((resolve, reject) => this.commandLeft.write(message, (error) => { @@ -200,12 +235,7 @@ describe('BaseSnapExecutor', () => { const executor = new TestSnapExecutor(); const consoleErrorSpy = jest.spyOn(console, 'error'); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -236,12 +266,7 @@ describe('BaseSnapExecutor', () => { const executor = new TestSnapExecutor(); const consoleErrorSpy = jest.spyOn(console, 'error'); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -308,19 +333,7 @@ describe('BaseSnapExecutor', () => { const executor = new TestSnapExecutor(); // Initiate the snaps - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [SNAP_NAME_1, CODE_1, TIMER_ENDOWMENTS], - }); - - await executor.writeCommand({ - jsonrpc: '2.0', - id: 2, - method: 'executeSnap', - params: [SNAP_NAME_2, CODE_2, TIMER_ENDOWMENTS], - }); + await executor.executeSnap(1, SNAP_NAME_1, CODE_1, TIMER_ENDOWMENTS); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -328,6 +341,8 @@ describe('BaseSnapExecutor', () => { result: 'OK', }); + await executor.executeSnap(2, SNAP_NAME_2, CODE_2, TIMER_ENDOWMENTS); + expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', id: 2, @@ -407,12 +422,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -457,18 +467,13 @@ describe('BaseSnapExecutor', () => { }); }); - it('reports when outbound requests are made', async () => { + it('reports when outbound requests are made using ethereum', async () => { const CODE = ` - module.exports.onRpcRequest = () => wallet.request({ method: 'eth_blockNumber', params: [] }); + module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, ['ethereum']); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -493,14 +498,14 @@ describe('BaseSnapExecutor', () => { method: 'OutboundRequest', }); - const providerRequest = await executor.readRpc(); - expect(providerRequest).toStrictEqual({ + const blockNumRequest = await executor.readRpc(); + expect(blockNumRequest).toStrictEqual({ name: 'metamask-provider', data: { id: expect.any(Number), jsonrpc: '2.0', - method: 'metamask_getProviderState', - params: undefined, + method: 'eth_blockNumber', + params: [], }, }); @@ -509,18 +514,61 @@ describe('BaseSnapExecutor', () => { data: { jsonrpc: '2.0', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: providerRequest.data.id!, - result: { isUnlocked: false, accounts: [] }, + id: blockNumRequest.data.id!, + result: '0xa70e77', }, }); - const blockNumRequest = await executor.readRpc(); - expect(blockNumRequest).toStrictEqual({ + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + method: 'OutboundResponse', + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: '0xa70e77', + }); + }); + + it('reports when outbound requests are made using snap API', async () => { + const CODE = ` + module.exports.onRpcRequest = () => snaps.request({ method: 'wallet_getPermissions', params: [] }); + `; + const executor = new TestSnapExecutor(); + + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, ['ethereum']); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'OK', + }); + + await executor.writeCommand({ + jsonrpc: '2.0', + id: 2, + method: 'snapRpc', + params: [ + FAKE_SNAP_NAME, + ON_RPC_REQUEST, + FAKE_ORIGIN, + { jsonrpc: '2.0', method: '', params: [] }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + method: 'OutboundRequest', + }); + + const walletRequest = await executor.readRpc(); + expect(walletRequest).toStrictEqual({ name: 'metamask-provider', data: { id: expect.any(Number), jsonrpc: '2.0', - method: 'eth_blockNumber', + method: 'wallet_getPermissions', params: [], }, }); @@ -530,8 +578,8 @@ describe('BaseSnapExecutor', () => { data: { jsonrpc: '2.0', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: blockNumRequest.data.id!, - result: '0xa70e77', + id: walletRequest.data.id!, + result: [], }, }); @@ -543,7 +591,89 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ id: 2, jsonrpc: '2.0', - result: '0xa70e77', + result: [], + }); + }); + + it("doesn't allow snap APIs in the Ethereum provider", async () => { + const CODE = ` + module.exports.onRpcRequest = () => ethereum.request({ method: 'snap_confirm', params: [] }); + `; + const executor = new TestSnapExecutor(); + + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, ['ethereum']); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'OK', + }); + + await executor.writeCommand({ + jsonrpc: '2.0', + id: 2, + method: 'snapRpc', + params: [ + FAKE_SNAP_NAME, + ON_RPC_REQUEST, + FAKE_ORIGIN, + { jsonrpc: '2.0', method: '', params: [] }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + error: { + code: -32601, + message: 'The method does not exist / is not available.', + data: { + method: 'snap_confirm', + }, + stack: expect.any(String), + }, + id: 2, + }); + }); + + it('only allows certain methods in snap API', async () => { + const CODE = ` + module.exports.onRpcRequest = () => snaps.request({ method: 'eth_blockNumber', params: [] }); + `; + const executor = new TestSnapExecutor(); + + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'OK', + }); + + await executor.writeCommand({ + jsonrpc: '2.0', + id: 2, + method: 'snapRpc', + params: [ + FAKE_SNAP_NAME, + ON_RPC_REQUEST, + FAKE_ORIGIN, + { jsonrpc: '2.0', method: '', params: [] }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + error: { + code: -32603, + message: + 'The global Snap API only allows RPC methods starting with `wallet_*` and `snap_*`.', + data: { + originalError: { + code: 'ERR_ASSERTION', + }, + }, + }, + id: 2, }); }); @@ -560,12 +690,7 @@ describe('BaseSnapExecutor', () => { emitter.on(type, listener as any), ); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -623,12 +748,7 @@ describe('BaseSnapExecutor', () => { emitter.on(type, listener as any), ); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -686,12 +806,7 @@ describe('BaseSnapExecutor', () => { emitter.on(type, listener as any), ); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -742,12 +857,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -794,12 +904,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -875,12 +980,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -934,12 +1034,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1023,12 +1118,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1060,12 +1150,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1104,12 +1189,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1151,12 +1231,7 @@ describe('BaseSnapExecutor', () => { const consoleErrorSpy = jest.spyOn(console, 'error'); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1193,14 +1268,19 @@ describe('BaseSnapExecutor', () => { jest.useRealTimers(); const consoleLogSpy = jest.spyOn(console, 'log'); const consoleWarnSpy = jest.spyOn(console, 'warn'); - const TIMER_ENDOWMENTS = ['setTimeout', 'clearTimeout', 'console']; + const TIMER_ENDOWMENTS = [ + 'setTimeout', + 'clearTimeout', + 'console', + 'ethereum', + ]; const CODE = ` let promise; module.exports.onRpcRequest = async ({request}) => { switch (request.method) { case 'first': - promise = wallet.request({ method: 'eth_blockNumber', params: [] }) + promise = ethereum.request({ method: 'eth_blockNumber', params: [] }) .then(() => console.log('Jailbreak')); return 'FIRST OK'; case 'second': @@ -1211,13 +1291,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - // --- Execute Snap - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1243,27 +1317,6 @@ describe('BaseSnapExecutor', () => { method: 'OutboundRequest', }); - const providerRequest = await executor.readRpc(); - expect(providerRequest).toStrictEqual({ - name: 'metamask-provider', - data: { - id: expect.any(Number), - jsonrpc: '2.0', - method: 'metamask_getProviderState', - params: undefined, - }, - }); - - await executor.writeRpc({ - name: 'metamask-provider', - data: { - jsonrpc: '2.0', - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: providerRequest.data.id!, - result: { isUnlocked: false, accounts: [] }, - }, - }); - const blockNumRequest = await executor.readRpc(); expect(blockNumRequest).toStrictEqual({ name: 'metamask-provider', @@ -1320,14 +1373,19 @@ describe('BaseSnapExecutor', () => { jest.useRealTimers(); const consoleLogSpy = jest.spyOn(console, 'log'); const consoleWarnSpy = jest.spyOn(console, 'warn'); - const TIMER_ENDOWMENTS = ['setTimeout', 'clearTimeout', 'console']; + const TIMER_ENDOWMENTS = [ + 'setTimeout', + 'clearTimeout', + 'console', + 'ethereum', + ]; const CODE = ` let promise; module.exports.onRpcRequest = async ({request}) => { switch (request.method) { case 'first': - promise = wallet.request({ method: 'eth_blockNumber', params: [] }) + promise = ethereum.request({ method: 'eth_blockNumber', params: [] }) .catch(() => console.log('Jailbreak')); return 'FIRST OK'; case 'second': @@ -1339,12 +1397,7 @@ describe('BaseSnapExecutor', () => { const executor = new TestSnapExecutor(); // --- Execute Snap - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, TIMER_ENDOWMENTS); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1370,27 +1423,6 @@ describe('BaseSnapExecutor', () => { method: 'OutboundRequest', }); - const providerRequest = await executor.readRpc(); - expect(providerRequest).toStrictEqual({ - name: 'metamask-provider', - data: { - id: expect.any(Number), - jsonrpc: '2.0', - method: 'metamask_getProviderState', - params: undefined, - }, - }); - - await executor.writeRpc({ - name: 'metamask-provider', - data: { - jsonrpc: '2.0', - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: providerRequest.data.id!, - result: { isUnlocked: false, accounts: [] }, - }, - }); - const blockNumRequest = await executor.readRpc(); expect(blockNumRequest).toStrictEqual({ name: 'metamask-provider', @@ -1450,16 +1482,11 @@ describe('BaseSnapExecutor', () => { // This will ensure that the reject(reason); is called from inside the proxy method // when the original promise throws an error (i.e. RPC request fails). const CODE = ` - module.exports.onRpcRequest = () => wallet.request({ method: 'eth_blockNumber', params: [] }); + module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, ['ethereum']); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', @@ -1484,27 +1511,6 @@ describe('BaseSnapExecutor', () => { method: 'OutboundRequest', }); - const providerRequest = await executor.readRpc(); - expect(providerRequest).toStrictEqual({ - name: 'metamask-provider', - data: { - id: expect.any(Number), - jsonrpc: '2.0', - method: 'metamask_getProviderState', - params: undefined, - }, - }); - - await executor.writeRpc({ - name: 'metamask-provider', - data: { - jsonrpc: '2.0', - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: providerRequest.data.id!, - result: { isUnlocked: false, accounts: [] }, - }, - }); - const blockNumRequest = await executor.readRpc(); expect(blockNumRequest).toStrictEqual({ name: 'metamask-provider', @@ -1547,13 +1553,7 @@ describe('BaseSnapExecutor', () => { `; const executor = new TestSnapExecutor(); - await executor.writeCommand({ - jsonrpc: '2.0', - id: 1, - method: 'executeSnap', - params: [FAKE_SNAP_NAME, CODE, []], - }); - + await executor.executeSnap(1, FAKE_SNAP_NAME, CODE, []); expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', id: 1, diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts index ae14009bf4..ba1c404954 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts @@ -1,9 +1,9 @@ // eslint-disable-next-line @typescript-eslint/triple-slash-reference, spaced-comment /// import { Duplex } from 'stream'; - -import { MetaMaskInpageProvider } from '@metamask/providers'; -import { SnapProvider, SnapExports } from '@metamask/snaps-types'; +import { StreamProvider } from '@metamask/providers'; +import { createIdRemapMiddleware } from 'json-rpc-engine'; +import { SnapExports, SnapsGlobalObject } from '@metamask/snaps-types'; import { errorCodes, ethErrors, serializeError } from 'eth-rpc-errors'; import { isObject, @@ -24,6 +24,7 @@ import { } from '@metamask/snaps-utils'; import { validate } from 'superstruct'; +import { RequestArguments } from '@metamask/providers/dist/BaseProvider'; import EEOpenRPCDocument from '../openrpc.json'; import { CommandMethodsMapping, @@ -290,13 +291,22 @@ export class BaseSnapExecutor { }); }; - const wallet = this.createSnapProvider(); + const provider = new StreamProvider(this.rpcStream, { + jsonRpcStreamName: 'metamask-provider', + rpcMiddleware: [createIdRemapMiddleware()], + }); + + await provider.initialize(); + + const snap = this.createSnapGlobal(provider); + const ethereum = this.createEIP1193Provider(provider); // We specifically use any type because the Snap can modify the object any way they want const snapModule: any = { exports: {} }; try { const { endowments, teardown: endowmentTeardown } = createEndowments( - wallet, + snap, + ethereum, _endowments, ); @@ -362,17 +372,48 @@ export class BaseSnapExecutor { } /** - * Instantiates a snap provider object (i.e. `globalThis.wallet`). + * Instantiates a snap API object (i.e. `globalThis.snap`). * + * @param provider - A StreamProvider connected to MetaMask. * @returns The snap provider object. */ - private createSnapProvider(): SnapProvider { - const provider = new MetaMaskInpageProvider(this.rpcStream, { - shouldSendMetadata: false, - }); + private createSnapGlobal(provider: StreamProvider): SnapsGlobalObject { + const originalRequest = provider.request; + + const request = async (args: RequestArguments) => { + assert( + args.method.startsWith('wallet_') || args.method.startsWith('snap_'), + 'The global Snap API only allows RPC methods starting with `wallet_*` and `snap_*`.', + ); + this.notify({ method: 'OutboundRequest' }); + try { + return await withTeardown(originalRequest(args), this as any); + } finally { + this.notify({ method: 'OutboundResponse' }); + } + }; + + return { request }; + } + + /** + * Instantiates an EIP-1193 Ethereum provider object (i.e. `globalThis.ethereum`). + * + * @param provider - A StreamProvider connected to MetaMask. + * @returns The EIP-1193 Ethereum provider object. + */ + private createEIP1193Provider(provider: StreamProvider): StreamProvider { const originalRequest = provider.request; provider.request = async (args) => { + assert( + !args.method.startsWith('snap_'), + ethErrors.rpc.methodNotFound({ + data: { + method: args.method, + }, + }), + ); this.notify({ method: 'OutboundRequest' }); try { return await withTeardown(originalRequest(args), this as any); diff --git a/packages/snaps-execution-environments/src/common/endowments/index.test.ts b/packages/snaps-execution-environments/src/common/endowments/index.test.ts index b52a3628c1..30b1206909 100644 --- a/packages/snaps-execution-environments/src/common/endowments/index.test.ts +++ b/packages/snaps-execution-environments/src/common/endowments/index.test.ts @@ -1,6 +1,9 @@ import fetchMock from 'jest-fetch-mock'; import { createEndowments, isConstructor } from '.'; +const mockSnapsAPI = { foo: Symbol('bar') }; +const mockEthereum = { foo: Symbol('bar') }; + describe('Endowment utils', () => { describe('createEndowments', () => { beforeAll(() => { @@ -14,27 +17,31 @@ describe('Endowment utils', () => { }); it('handles no endowments', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments } = createEndowments(mockWallet as any); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ); - expect(createEndowments(mockWallet as any, [])).toStrictEqual({ + expect( + createEndowments(mockSnapsAPI as any, mockEthereum as any), + ).toStrictEqual({ endowments: { - wallet: mockWallet, + snaps: mockSnapsAPI, }, teardown: expect.any(Function), }); - expect(endowments.wallet).toBe(mockWallet); + expect(endowments.snaps).toBe(mockSnapsAPI); }); it('handles unattenuated endowments', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments } = createEndowments(mockWallet as any, [ - 'Uint8Array', - 'Date', - ]); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['Uint8Array', 'Date'], + ); expect(endowments).toStrictEqual({ - wallet: mockWallet, + snaps: mockSnapsAPI, Uint8Array, Date, }); @@ -43,54 +50,56 @@ describe('Endowment utils', () => { }); it('handles special cases where endowment is a function but not a constructor', () => { - const mockWallet = { foo: Symbol('bar') }; const mockEndowment = () => { return {}; }; Object.assign(globalThis, { mockEndowment }); - const { endowments } = createEndowments(mockWallet as any, [ - 'Date', - 'mockEndowment', - ]); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['Date', 'mockEndowment'], + ); expect(endowments.Date).toBe(Date); expect(endowments.mockEndowment).toBeDefined(); }); it('handles factory endowments', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments } = createEndowments(mockWallet as any, [ - 'setTimeout', - ]); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['setTimeout'], + ); expect(endowments).toStrictEqual({ - wallet: mockWallet, + snaps: mockSnapsAPI, setTimeout: expect.any(Function), }); expect(endowments.setTimeout).not.toBe(setTimeout); }); it('handles some endowments from the same factory', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments } = createEndowments(mockWallet as any, [ - 'setTimeout', - ]); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['setTimeout'], + ); expect(endowments).toMatchObject({ - wallet: mockWallet, + snaps: mockSnapsAPI, setTimeout: expect.any(Function), }); expect(endowments.setTimeout).not.toBe(setTimeout); }); it('handles all endowments from the same factory', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments } = createEndowments(mockWallet as any, [ - 'setTimeout', - 'clearTimeout', - ]); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['setTimeout', 'clearTimeout'], + ); expect(endowments).toMatchObject({ - wallet: mockWallet, + snaps: mockSnapsAPI, setTimeout: expect.any(Function), clearTimeout: expect.any(Function), }); @@ -98,18 +107,21 @@ describe('Endowment utils', () => { }); it('handles multiple endowments, factory and non-factory', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments } = createEndowments(mockWallet as any, [ - 'console', - 'Uint8Array', - 'Math', - 'setTimeout', - 'clearTimeout', - 'WebAssembly', - ]); + const { endowments } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + [ + 'console', + 'Uint8Array', + 'Math', + 'setTimeout', + 'clearTimeout', + 'WebAssembly', + ], + ); expect(endowments).toMatchObject({ - wallet: mockWallet, + snaps: mockSnapsAPI, console, Uint8Array, Math: expect.any(Object), @@ -118,7 +130,7 @@ describe('Endowment utils', () => { WebAssembly, }); - expect(endowments.wallet).toBe(mockWallet); + expect(endowments.snaps).toBe(mockSnapsAPI); expect(endowments.console).toBe(console); expect(endowments.Uint8Array).toBe(Uint8Array); expect(endowments.WebAssembly).toBe(WebAssembly); @@ -129,20 +141,17 @@ describe('Endowment utils', () => { }); it('throws for unknown endowments', () => { - const mockWallet = { foo: Symbol('bar') }; - expect(() => createEndowments(mockWallet as any, ['foo'])).toThrow( - 'Unknown endowment: "foo"', - ); + expect(() => + createEndowments(mockSnapsAPI as any, mockEthereum as any, ['foo']), + ).toThrow('Unknown endowment: "foo"'); }); it('teardown calls all teardown functions', () => { - const mockWallet = { foo: Symbol('bar') }; - const { endowments, teardown } = createEndowments(mockWallet as any, [ - 'setTimeout', - 'clearTimeout', - 'setInterval', - 'clearInterval', - ]); + const { endowments, teardown } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], + ); const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout'); const clearIntervalSpy = jest.spyOn(globalThis, 'clearInterval'); @@ -166,7 +175,7 @@ describe('Endowment utils', () => { expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); expect(clearIntervalSpy).toHaveBeenCalledTimes(1); expect(endowments).toMatchObject({ - wallet: mockWallet, + snaps: mockSnapsAPI, setTimeout: expect.any(Function), clearTimeout: expect.any(Function), setInterval: expect.any(Function), @@ -175,12 +184,11 @@ describe('Endowment utils', () => { }); it('teardown can be called multiple times', async () => { - const { endowments, teardown } = createEndowments({} as any, [ - 'setTimeout', - 'clearTimeout', - 'setInterval', - 'clearInterval', - ]); + const { endowments, teardown } = createEndowments( + mockSnapsAPI as any, + mockEthereum as any, + ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], + ); const { setInterval, setTimeout } = endowments as { setInterval: typeof globalThis.setInterval; diff --git a/packages/snaps-execution-environments/src/common/endowments/index.ts b/packages/snaps-execution-environments/src/common/endowments/index.ts index 42a9ef6387..66caa99553 100644 --- a/packages/snaps-execution-environments/src/common/endowments/index.ts +++ b/packages/snaps-execution-environments/src/common/endowments/index.ts @@ -1,5 +1,6 @@ -import { SnapProvider } from '@metamask/snaps-types'; +import { SnapsGlobalObject } from '@metamask/snaps-types'; import { hasProperty } from '@metamask/utils'; +import { StreamProvider } from '@metamask/providers'; import { rootRealmGlobal } from '../globalObject'; import interval from './interval'; import network from './network'; @@ -42,12 +43,14 @@ const endowmentFactories = [timeout, interval, network, crypto, math].reduce( * such attenuated / modified endowments. Otherwise, the value that's on the * root realm global will be used. * - * @param wallet - The Snap's provider object. + * @param snaps - The Snaps global API object. + * @param ethereum - The Snap's EIP-1193 provider object. * @param endowments - The list of endowments to provide to the snap. * @returns An object containing the Snap's endowments. */ export function createEndowments( - wallet: SnapProvider, + snaps: SnapsGlobalObject, + ethereum: StreamProvider, endowments: string[] = [], ): { endowments: Record; teardown: () => Promise } { const attenuatedEndowments: Record = {}; @@ -87,6 +90,9 @@ export function createEndowments( typeof globalValue === 'function' && !isConstructor(globalValue) ? globalValue.bind(rootRealmGlobal) : globalValue; + } else if (endowmentName === 'ethereum') { + // Special case for adding the EIP-1193 provider. + allEndowments[endowmentName] = ethereum; } else { // If we get to this point, we've been passed an endowment that doesn't // exist in our current environment. @@ -95,7 +101,7 @@ export function createEndowments( return { allEndowments, teardowns }; }, { - allEndowments: { wallet } as Record, + allEndowments: { snaps } as Record, teardowns: [] as (() => void)[], }, ); diff --git a/packages/snaps-execution-environments/src/iframe/IFrameSnapExecutor.test.ts b/packages/snaps-execution-environments/src/iframe/IFrameSnapExecutor.test.ts index 9629906ea1..48968fdd0a 100644 --- a/packages/snaps-execution-environments/src/iframe/IFrameSnapExecutor.test.ts +++ b/packages/snaps-execution-environments/src/iframe/IFrameSnapExecutor.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import/no-unassigned-import import 'ses'; import { EventEmitter } from 'stream'; -import { Json, JsonRpcSuccess } from '@metamask/utils'; +import { Json, JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; import { SNAP_STREAM_NAMES, HandlerType } from '@metamask/snaps-utils'; import { IFrameSnapExecutor } from './IFrameSnapExecutor'; @@ -39,6 +39,18 @@ describe('IFrameSnapExecutor', () => { const emit = (data: Json) => parentEmitter.emit('message', { data: { data, target: 'child' } }); const emitChunk = (name: string, data: Json) => emit({ name, data }); + const waitForOutbound = (request: Partial>): any => + new Promise((resolve) => { + childEmitter.on('message', ({ data: { name, data } }) => { + if ( + name === SNAP_STREAM_NAMES.JSON_RPC && + data.name === 'metamask-provider' && + data.data.method === request.method + ) { + resolve(data.data); + } + }); + }); const waitForResponse = (response: JsonRpcSuccess) => new Promise((resolve) => { childEmitter.on('message', ({ data }) => { @@ -78,6 +90,24 @@ describe('IFrameSnapExecutor', () => { params: [FAKE_SNAP_NAME, CODE, []], }); + const providerRequest = await waitForOutbound({ + method: 'metamask_getProviderState', + }); + + emitChunk(SNAP_STREAM_NAMES.JSON_RPC, { + name: 'metamask-provider', + data: { + jsonrpc: '2.0', + id: providerRequest.id, + result: { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }, + }, + }); + expect( await waitForResponse({ result: 'OK', id: 1, jsonrpc: '2.0' }), ).not.toBeNull(); diff --git a/packages/snaps-execution-environments/src/node-process/ChildProcessSnapExecutor.test.ts b/packages/snaps-execution-environments/src/node-process/ChildProcessSnapExecutor.test.ts index 7436742fa9..989872dd59 100644 --- a/packages/snaps-execution-environments/src/node-process/ChildProcessSnapExecutor.test.ts +++ b/packages/snaps-execution-environments/src/node-process/ChildProcessSnapExecutor.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import/no-unassigned-import import 'ses'; import { EventEmitter } from 'stream'; -import { Json, JsonRpcSuccess } from '@metamask/utils'; +import { Json, JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; import { SNAP_STREAM_NAMES, HandlerType } from '@metamask/snaps-utils'; import { ChildProcessSnapExecutor } from './ChildProcessSnapExecutor'; @@ -36,6 +36,18 @@ describe('ChildProcessSnapExecutor', () => { // Utility functions const emit = (data: Json) => parentEmitter.emit('message', { data }); const emitChunk = (name: string, data: Json) => emit({ name, data }); + const waitForOutbound = (request: Partial>): any => + new Promise((resolve) => { + childEmitter.on('message', ({ data: { name, data } }) => { + if ( + name === SNAP_STREAM_NAMES.JSON_RPC && + data.name === 'metamask-provider' && + data.data.method === request.method + ) { + resolve(data.data); + } + }); + }); const waitForResponse = (response: JsonRpcSuccess) => new Promise((resolve) => { childEmitter.on('message', ({ data }) => { @@ -61,6 +73,24 @@ describe('ChildProcessSnapExecutor', () => { params: [FAKE_SNAP_NAME, CODE, []], }); + const providerRequest = await waitForOutbound({ + method: 'metamask_getProviderState', + }); + + emitChunk(SNAP_STREAM_NAMES.JSON_RPC, { + name: 'metamask-provider', + data: { + jsonrpc: '2.0', + id: providerRequest.id, + result: { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }, + }, + }); + expect( await waitForResponse({ result: 'OK', id: 1, jsonrpc: '2.0' }), ).not.toBeNull(); diff --git a/packages/snaps-execution-environments/src/node-thread/ThreadSnapExecutor.test.ts b/packages/snaps-execution-environments/src/node-thread/ThreadSnapExecutor.test.ts index 4d486db07c..900d79fcbb 100644 --- a/packages/snaps-execution-environments/src/node-thread/ThreadSnapExecutor.test.ts +++ b/packages/snaps-execution-environments/src/node-thread/ThreadSnapExecutor.test.ts @@ -2,7 +2,7 @@ import 'ses'; import { EventEmitter } from 'stream'; import { parentPort } from 'worker_threads'; -import { Json, JsonRpcSuccess } from '@metamask/utils'; +import { Json, JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; import { SNAP_STREAM_NAMES, HandlerType } from '@metamask/snaps-utils'; import { ThreadSnapExecutor } from './ThreadSnapExecutor'; @@ -47,6 +47,18 @@ describe('ThreadSnapExecutor', () => { // Utility functions const emit = (data: Json) => parentEmitter.emit('message', { data }); const emitChunk = (name: string, data: Json) => emit({ name, data }); + const waitForOutbound = (request: Partial>): any => + new Promise((resolve) => { + childEmitter.on('message', ({ data: { name, data } }) => { + if ( + name === SNAP_STREAM_NAMES.JSON_RPC && + data.name === 'metamask-provider' && + data.data.method === request.method + ) { + resolve(data.data); + } + }); + }); const waitForResponse = (response: JsonRpcSuccess) => new Promise((resolve) => { childEmitter.on('message', ({ data }) => { @@ -72,6 +84,24 @@ describe('ThreadSnapExecutor', () => { params: [FAKE_SNAP_NAME, CODE, []], }); + const providerRequest = await waitForOutbound({ + method: 'metamask_getProviderState', + }); + + emitChunk(SNAP_STREAM_NAMES.JSON_RPC, { + name: 'metamask-provider', + data: { + jsonrpc: '2.0', + id: providerRequest.id, + result: { + isUnlocked: false, + accounts: [], + chainId: '0x1', + networkVersion: '1', + }, + }, + }); + expect( await waitForResponse({ result: 'OK', id: 1, jsonrpc: '2.0' }), ).not.toBeNull(); diff --git a/packages/snaps-types/global.d.ts b/packages/snaps-types/global.d.ts index 357b0e2bc0..ffc6559804 100644 --- a/packages/snaps-types/global.d.ts +++ b/packages/snaps-types/global.d.ts @@ -1,6 +1,8 @@ -import { SnapProvider } from './src'; +import { MetaMaskInpageProvider } from '@metamask/providers'; +import { SnapsGlobalObject } from './src'; // Types that should be available globally within a Snap declare global { - const wallet: SnapProvider; + const ethereum: MetaMaskInpageProvider; + const snaps: SnapsGlobalObject; } diff --git a/packages/snaps-types/src/types.d.ts b/packages/snaps-types/src/types.d.ts index 2b2679ce9c..587d953c6f 100644 --- a/packages/snaps-types/src/types.d.ts +++ b/packages/snaps-types/src/types.d.ts @@ -1,4 +1,4 @@ -import { MetaMaskInpageProvider } from '@metamask/providers'; +import { StreamProvider } from '@metamask/providers'; import { Json, JsonRpcRequest } from '@metamask/types'; export type SnapRpcHandler = (args: { @@ -22,7 +22,9 @@ export type OnCronjobHandler = (args: { request: JsonRpcRequest; }) => Promise; -export type SnapProvider = MetaMaskInpageProvider; +export type SnapsGlobalObject = { request: StreamProvider['request'] }; + +export type Ethereum = StreamProvider; // CAIP2 - https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-2.md type ChainId = `${string}:${string | number}`; diff --git a/packages/snaps-utils/src/mock.test.ts b/packages/snaps-utils/src/mock.test.ts index 6fbc2e571d..4a9734f522 100644 --- a/packages/snaps-utils/src/mock.test.ts +++ b/packages/snaps-utils/src/mock.test.ts @@ -3,10 +3,15 @@ import crypto from 'crypto'; import { generateMockEndowments, isConstructor } from './mock'; describe('generateMockEndowments', () => { - it('includes mock snap provider', async () => { + it('includes mock snap API', async () => { const endowments = generateMockEndowments(); - expect(endowments.wallet).toBeInstanceOf(EventEmitter); - expect(await endowments.wallet.request()).toBe(true); + expect(await endowments.snaps.request()).toBe(true); + }); + + it('includes mock ethereum provider', async () => { + const endowments = generateMockEndowments(); + expect(endowments.ethereum).toBeInstanceOf(EventEmitter); + expect(await endowments.ethereum.request()).toBe(true); }); it('returns mock class WebSocket', () => { diff --git a/packages/snaps-utils/src/mock.ts b/packages/snaps-utils/src/mock.ts index e8dc24f4aa..a9dca3f21c 100644 --- a/packages/snaps-utils/src/mock.ts +++ b/packages/snaps-utils/src/mock.ts @@ -6,19 +6,32 @@ const NETWORK_APIS = ['fetch', 'WebSocket']; export const ALL_APIS: string[] = [...DEFAULT_ENDOWMENTS, ...NETWORK_APIS]; -type MockSnapProvider = EventEmitter & { +type MockSnapGlobal = { + request: () => Promise; +}; + +type MockEthereumProvider = EventEmitter & { request: () => Promise; }; /** - * Get a mock snap provider, that always returns `true` for requests. + * Get a mock snap API, that always returns `true` for requests. * * @returns A mocked snap provider. */ -function getMockSnapProvider(): MockSnapProvider { - const mockProvider = new EventEmitter() as Partial; +function getMockSnapsGlobal(): MockSnapGlobal { + return { request: async () => true }; +} + +/** + * Get a mock Ethereum provider, that always returns `true` for requests. + * + * @returns A mocked ethereum provider. + */ +function getMockEthereumProvider(): MockEthereumProvider { + const mockProvider = new EventEmitter() as Partial; mockProvider.request = async () => true; - return mockProvider as MockSnapProvider; + return mockProvider as MockEthereumProvider; } /** @@ -104,6 +117,6 @@ const generateMockEndowment = (key: string) => { export const generateMockEndowments = () => { return ALL_APIS.reduce>( (acc, cur) => ({ ...acc, [cur]: generateMockEndowment(cur) }), - { wallet: getMockSnapProvider() }, + { snaps: getMockSnapsGlobal(), ethereum: getMockEthereumProvider() }, ); }; diff --git a/yarn.lock b/yarn.lock index 84a8d75203..792e390c4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2780,8 +2780,8 @@ __metadata: "@metamask/eslint-config-jest": ^9.0.0 "@metamask/eslint-config-nodejs": ^9.0.0 "@metamask/eslint-config-typescript": ^9.0.1 + "@metamask/providers": ^10.2.0 "@metamask/safe-event-emitter": ^2.0.0 - "@metamask/snaps-types": ^0.23.0 "@metamask/snaps-utils": ^0.23.0 "@metamask/utils": ^3.3.1 "@types/jest": ^27.5.1 @@ -3098,6 +3098,7 @@ __metadata: jest-fetch-mock: ^3.0.3 jest-it-up: ^2.0.0 jsdom: ^19.0.0 + json-rpc-engine: ^6.1.0 mock-socket: ^9.1.5 node-polyfill-webpack-plugin: ^1.1.4 prettier: ^2.3.2