diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index 0c15b35af3..75198cf4c3 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,6 +1,6 @@ { - "branches": 80.71, - "functions": 92.02, - "lines": 91.66, - "statements": 91.36 + "branches": 80.28, + "functions": 90.41, + "lines": 91.06, + "statements": 90.65 } 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 09e75111a8..27a38020b0 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -191,6 +191,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'ethereum.request' }, }); const blockNumRequest = await executor.readRpc(); @@ -217,6 +218,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'ethereum.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -255,6 +257,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'snap.request' }, }); const walletRequest = await executor.readRpc(); @@ -281,6 +284,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'snap.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -416,6 +420,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'ethereum.request' }, }); const blockNumRequest = await executor.readRpc(); @@ -442,6 +447,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'ethereum.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -482,6 +488,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'snap.request' }, }); const getSnapsRequest = await executor.readRpc(); @@ -516,6 +523,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'snap.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -898,6 +906,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'snap.request' }, }); const request = await executor.readRpc(); @@ -930,6 +939,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'snap.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -973,6 +983,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'ethereum.request' }, }); const request = await executor.readRpc(); @@ -1007,6 +1018,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'ethereum.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -1016,6 +1028,69 @@ describe('BaseSnapExecutor', () => { }); }); + it('reports when outbound requests are made using fetch', async () => { + const CODE = ` + module.exports.onRpcRequest = () => fetch('https://metamask.io').then(res => res.text()); + `; + + const fetchSpy = spy(globalThis, 'fetch'); + + fetchSpy.mockImplementation(async () => { + return new Response('foo'); + }); + + const executor = new TestSnapExecutor(); + await executor.executeSnap(1, MOCK_SNAP_ID, CODE, ['fetch']); + + 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.OnRpcRequest, + MOCK_ORIGIN, + { jsonrpc: '2.0', method: '', params: [] }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + method: 'OutboundResponse', + params: { source: 'fetch' }, + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + method: 'OutboundResponse', + params: { source: 'fetch' }, + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: 'foo', + }); + }); + it('notifies execution service of out of band errors via unhandledrejection', async () => { const CODE = ` module.exports.onRpcRequest = async () => 'foo'; @@ -1565,6 +1640,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'ethereum.request' }, }); const blockNumRequest = await executor.readRpc(); @@ -1609,6 +1685,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'ethereum.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -1672,6 +1749,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'ethereum.request' }, }); const blockNumRequest = await executor.readRpc(); @@ -1719,6 +1797,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'ethereum.request' }, }); expect(await executor.readCommand()).toStrictEqual({ @@ -1764,6 +1843,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundRequest', + params: { source: 'ethereum.request' }, }); const blockNumRequest = await executor.readRpc(); @@ -1793,6 +1873,7 @@ describe('BaseSnapExecutor', () => { expect(await executor.readCommand()).toStrictEqual({ jsonrpc: '2.0', method: 'OutboundResponse', + params: { source: 'ethereum.request' }, }); expect(await executor.readCommand()).toStrictEqual({ diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts index dcd036411e..c2cd7add59 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts @@ -110,6 +110,10 @@ const EXECUTION_ENVIRONMENT_METHODS = { type Methods = typeof EXECUTION_ENVIRONMENT_METHODS; +export type NotifyFunction = ( + notification: Omit, +) => Promise; + export class BaseSnapExecutor { private readonly snapData: Map; @@ -325,7 +329,7 @@ export class BaseSnapExecutor { protected async startSnap( snapId: string, sourceCode: string, - _endowments?: string[], + _endowments: string[], ): Promise { log(`Starting snap '${snapId}' in worker.`); if (this.snapPromiseErrorHandler) { @@ -359,12 +363,13 @@ export class BaseSnapExecutor { const snapModule: any = { exports: {} }; try { - const { endowments, teardown: endowmentTeardown } = createEndowments( + const { endowments, teardown: endowmentTeardown } = createEndowments({ snap, ethereum, snapId, - _endowments, - ); + endowments: _endowments, + notify: this.#notify.bind(this), + }); // !!! Ensure that this is the only place the data is being set. // Other methods access the object value and mutate its properties. @@ -454,11 +459,17 @@ export class BaseSnapExecutor { assertSnapOutboundRequest(sanitizedArgs); return await withTeardown( (async () => { - await this.#notify({ method: 'OutboundRequest' }); + await this.#notify({ + method: 'OutboundRequest', + params: { source: 'snap.request' }, + }); try { return await originalRequest(sanitizedArgs); } finally { - await this.#notify({ method: 'OutboundResponse' }); + await this.#notify({ + method: 'OutboundResponse', + params: { source: 'snap.request' }, + }); } })(), this as any, @@ -500,11 +511,17 @@ export class BaseSnapExecutor { assertEthereumOutboundRequest(sanitizedArgs); return await withTeardown( (async () => { - await this.#notify({ method: 'OutboundRequest' }); + await this.#notify({ + method: 'OutboundRequest', + params: { source: 'ethereum.request' }, + }); try { return await originalRequest(sanitizedArgs); } finally { - await this.#notify({ method: 'OutboundResponse' }); + await this.#notify({ + method: 'OutboundResponse', + params: { source: 'ethereum.request' }, + }); } })(), this as any, diff --git a/packages/snaps-execution-environments/src/common/endowments/commonEndowmentFactory.ts b/packages/snaps-execution-environments/src/common/endowments/commonEndowmentFactory.ts index 16c3a13fa1..b05678de65 100644 --- a/packages/snaps-execution-environments/src/common/endowments/commonEndowmentFactory.ts +++ b/packages/snaps-execution-environments/src/common/endowments/commonEndowmentFactory.ts @@ -1,3 +1,4 @@ +import type { NotifyFunction } from '../BaseSnapExecutor'; import { rootRealmGlobal } from '../globalObject'; import consoleEndowment from './console'; import crypto from './crypto'; @@ -11,6 +12,7 @@ import timeout from './timeout'; export type EndowmentFactoryOptions = { snapId?: string; + notify?: NotifyFunction; }; export type EndowmentFactory = { diff --git a/packages/snaps-execution-environments/src/common/endowments/endowments.test.browser.ts b/packages/snaps-execution-environments/src/common/endowments/endowments.test.browser.ts index 9eea60279e..888ce0070d 100644 --- a/packages/snaps-execution-environments/src/common/endowments/endowments.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/endowments/endowments.test.browser.ts @@ -33,10 +33,16 @@ lockdown({ globalThis.atob = harden(originalAtob); globalThis.btoa = harden(originalBtoa); +const mockNotify = () => { + // no-op +}; + describe('endowments', () => { describe('hardening', () => { const modules = buildCommonEndowments(); - modules.forEach((endowment) => endowment.factory({ snapId: MOCK_SNAP_ID })); + modules.forEach((endowment) => + endowment.factory({ snapId: MOCK_SNAP_ID, notify: mockNotify }), + ); // Specially attenuated endowments or endowments that require // to be imported in a different way @@ -56,7 +62,9 @@ describe('endowments', () => { Request: RequestHardened, Headers: HeadersHardened, Response: ResponseHardened, - } = network.factory(); + } = network.factory({ + notify: mockNotify, + }); const { Date: DateAttenuated } = date.factory(); const { console: consoleAttenuated } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID, 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 d2672f9ded..d372e50985 100644 --- a/packages/snaps-execution-environments/src/common/endowments/index.test.ts +++ b/packages/snaps-execution-environments/src/common/endowments/index.test.ts @@ -6,6 +6,14 @@ import { createEndowments } from '.'; const mockSnapAPI = { foo: Symbol('bar') }; const mockEthereum = { foo: Symbol('bar') }; +const mockOptions = { + snap: mockSnapAPI as any, + ethereum: mockEthereum as any, + snapId: MOCK_SNAP_ID, + notify: jest.fn(), + endowments: [], +}; + describe('Endowment utils', () => { describe('createEndowments', () => { beforeAll(() => { @@ -19,53 +27,41 @@ describe('Endowment utils', () => { }); it('handles no endowments', () => { - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ); - - expect( - createEndowments(mockSnapAPI as any, mockEthereum as any, MOCK_SNAP_ID), - ).toStrictEqual({ + const result = createEndowments(mockOptions); + + expect(result).toStrictEqual({ endowments: { snap: mockSnapAPI, }, teardown: expect.any(Function), }); - expect(endowments.snap).toBe(mockSnapAPI); + expect(result.endowments.snap).toBe(mockSnapAPI); }); it('handles special cases where endowment is not available as part of a factory', () => { const mockEndowment = {}; Object.assign(globalThis, { mockEndowment }); - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['mockEndowment'], - ); + const { endowments } = createEndowments({ + ...mockOptions, + endowments: ['mockEndowment'], + }); expect(endowments.mockEndowment).toBeDefined(); }); it('handles special case for ethereum endowment', () => { Object.assign(globalThis, { ethereum: {} }); - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['ethereum'], - ); + const { endowments } = createEndowments({ + ...mockOptions, + endowments: ['ethereum'], + }); expect(endowments.ethereum).toBe(mockEthereum); }); it('handles factory endowments', () => { - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['setTimeout'], - ); + const { endowments } = createEndowments({ + ...mockOptions, + endowments: ['setTimeout'], + }); expect(endowments).toStrictEqual({ snap: mockSnapAPI, @@ -75,12 +71,10 @@ describe('Endowment utils', () => { }); it('handles some endowments from the same factory', () => { - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['setTimeout'], - ); + const { endowments } = createEndowments({ + ...mockOptions, + endowments: ['setTimeout'], + }); expect(endowments).toMatchObject({ snap: mockSnapAPI, @@ -90,12 +84,10 @@ describe('Endowment utils', () => { }); it('handles all endowments from the same factory', () => { - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['setTimeout', 'clearTimeout'], - ); + const { endowments } = createEndowments({ + ...mockOptions, + endowments: ['setTimeout', 'clearTimeout'], + }); expect(endowments).toMatchObject({ snap: mockSnapAPI, @@ -106,11 +98,9 @@ describe('Endowment utils', () => { }); it('handles multiple endowments, factory and non-factory', () => { - const { endowments } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - [ + const { endowments } = createEndowments({ + ...mockOptions, + endowments: [ 'console', 'Uint8Array', 'Math', @@ -118,7 +108,7 @@ describe('Endowment utils', () => { 'clearTimeout', 'WebAssembly', ], - ); + }); expect(endowments).toMatchObject({ snap: mockSnapAPI, @@ -142,22 +132,23 @@ describe('Endowment utils', () => { it('throws for unknown endowments', () => { expect(() => - createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['foo'], - ), + createEndowments({ + ...mockOptions, + endowments: ['foo'], + }), ).toThrow('Unknown endowment: "foo"'); }); it('teardown calls all teardown functions', async () => { - const { endowments, teardown } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], - ); + const { endowments, teardown } = createEndowments({ + ...mockOptions, + endowments: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + ], + }); const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout'); const clearIntervalSpy = jest.spyOn(globalThis, 'clearInterval'); @@ -190,12 +181,15 @@ describe('Endowment utils', () => { }); it('teardown can be called multiple times', async () => { - const { endowments, teardown } = createEndowments( - mockSnapAPI as any, - mockEthereum as any, - MOCK_SNAP_ID, - ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], - ); + const { endowments, teardown } = createEndowments({ + ...mockOptions, + endowments: [ + '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 4c3569d5ef..92f4b62f60 100644 --- a/packages/snaps-execution-environments/src/common/endowments/index.ts +++ b/packages/snaps-execution-environments/src/common/endowments/index.ts @@ -4,6 +4,7 @@ import type { SnapsProvider } from '@metamask/snaps-sdk'; import { logWarning } from '@metamask/snaps-utils'; import { hasProperty } from '@metamask/utils'; +import type { NotifyFunction } from '../BaseSnapExecutor'; import { rootRealmGlobal } from '../globalObject'; import type { EndowmentFactoryOptions } from './commonEndowmentFactory'; import buildCommonEndowments from './commonEndowmentFactory'; @@ -45,18 +46,27 @@ const endowmentFactories = registeredEndowments.reduce((factories, builder) => { * such attenuated / modified endowments. Otherwise, the value that's on the * root realm global will be used. * - * @param snap - The Snaps global API object. - * @param ethereum - The Snap's EIP-1193 provider object. - * @param snapId - The id of the snap that will use the created endowments. - * @param endowments - The list of endowments to provide to the snap. + * @param options - An options bag. + * @param options.snap - The Snaps global API object. + * @param options.ethereum - The Snap's EIP-1193 provider object. + * @param options.snapId - The id of the snap that will use the created endowments. + * @param options.endowments - The list of endowments to provide to the snap. + * @param options.notify - A reference to the notify function of the snap executor. * @returns An object containing the Snap's endowments. */ -export function createEndowments( - snap: SnapsProvider, - ethereum: StreamProvider, - snapId: string, - endowments: string[] = [], -): { endowments: Record; teardown: () => Promise } { +export function createEndowments({ + snap, + ethereum, + snapId, + endowments, + notify, +}: { + snap: SnapsProvider; + ethereum: StreamProvider; + snapId: string; + endowments: string[]; + notify: NotifyFunction; +}): { endowments: Record; teardown: () => Promise } { const attenuatedEndowments: Record = {}; // TODO: All endowments should be hardened to prevent covert communication @@ -80,7 +90,7 @@ export function createEndowments( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { teardownFunction, ...endowment } = endowmentFactories.get( endowmentName, - )!({ snapId }); + )!({ snapId, notify }); Object.assign(attenuatedEndowments, endowment); if (teardownFunction) { teardowns.push(teardownFunction); diff --git a/packages/snaps-execution-environments/src/common/endowments/network.test.ts b/packages/snaps-execution-environments/src/common/endowments/network.test.ts index 9b8bee6a7d..a5763cc2ae 100644 --- a/packages/snaps-execution-environments/src/common/endowments/network.test.ts +++ b/packages/snaps-execution-environments/src/common/endowments/network.test.ts @@ -7,6 +7,8 @@ describe('Network endowments', () => { globalThis.harden = (value) => value; }); + const factoryOptions = { notify: jest.fn() }; + describe('fetch', () => { beforeEach(() => { fetchMock.enableMocks(); @@ -19,27 +21,46 @@ describe('Network endowments', () => { it('fetches and reads body', async () => { const RESULT = 'OK'; fetchMock.mockOnce(async () => Promise.resolve(RESULT)); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const result = await (await fetch('foo.com')).text(); expect(result).toStrictEqual(RESULT); }); + it('send notification about outbound request and response', async () => { + const notify = jest.fn(); + const RESULT = 'OK'; + fetchMock.mockOnce(async () => Promise.resolve(RESULT)); + const { fetch } = network.factory({ notify }); + const result = await (await fetch('foo.com')).text(); + expect(result).toStrictEqual(RESULT); + expect(notify).toHaveBeenNthCalledWith(1, { + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + expect(notify).toHaveBeenNthCalledWith(2, { + method: 'OutboundResponse', + params: { source: 'fetch' }, + }); + expect(notify).toHaveBeenNthCalledWith(3, { + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + expect(notify).toHaveBeenNthCalledWith(4, { + method: 'OutboundResponse', + params: { source: 'fetch' }, + }); + expect(notify).toHaveBeenCalledTimes(4); + }); + it('can use AbortController normally', async () => { - let resolve: ((result: string) => void) | null = null; - fetchMock.mockOnce( - async () => - new Promise((_resolve) => { - resolve = _resolve; - }), - ); + fetchMock.mockOnce(async () => 'FAIL'); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const controller = new AbortController(); const fetchPromise = fetch('foo.com', { signal: controller.signal }); controller.abort(); - (resolve as any)('FAIL'); await expect(fetchPromise).rejects.toThrow('The operation was aborted'); }); @@ -58,12 +79,9 @@ describe('Network endowments', () => { it.todo('reason from AbortController.abort() is passed down'); it('should not expose then or catch after teardown has been called', async () => { - let fetchResolve: ((result: string) => void) | null = null; - fetchMock.mockOnce( - async () => new Promise((resolve) => (fetchResolve = resolve)), - ); + fetchMock.mockOnce(async () => 'Resolved'); - const { fetch, teardownFunction } = network.factory(); + const { fetch, teardownFunction } = network.factory(factoryOptions); const ErrorProxy = jest .fn() .mockImplementation((reason) => Error(reason)); @@ -79,7 +97,6 @@ describe('Network endowments', () => { .catch((error) => console.log(error)); const teardownPromise = teardownFunction(); - (fetchResolve as any)('Resolved'); await teardownPromise; await new Promise((resolve) => setTimeout(() => resolve('Resolved'), 0)); @@ -90,7 +107,7 @@ describe('Network endowments', () => { const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); fetchMock.mockReject(new Error('Failed to fetch.')); - const { fetch, teardownFunction } = network.factory(); + const { fetch, teardownFunction } = network.factory(factoryOptions); // eslint-disable-next-line jest/valid-expect-in-promise fetch('foo.com').catch(() => { @@ -116,7 +133,7 @@ describe('Network endowments', () => { const RESULT = 'OK'; fetchMock.mockOnce(async () => Promise.resolve(RESULT)); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const result = await fetch('foo.com'); expect(result).toBeDefined(); @@ -139,7 +156,7 @@ describe('Network endowments', () => { const RESULT = 'OK'; fetchMock.mockOnce(async () => Promise.resolve(RESULT)); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const result = await fetch('foo.com'); expect(result.bodyUsed).toBe(false); @@ -150,7 +167,7 @@ describe('Network endowments', () => { const RESULT = 'OK'; fetchMock.mockOnce(async () => Promise.resolve(RESULT)); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const result = await fetch('foo.com'); expect(result.bodyUsed).toBe(false); @@ -163,7 +180,7 @@ describe('Network endowments', () => { const RESULT = 'OK'; fetchMock.mockOnce(async () => Promise.resolve(RESULT)); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const result = await fetch('foo.com'); expect(result.bodyUsed).toBe(false); @@ -177,7 +194,7 @@ describe('Network endowments', () => { const RESULT = '{}'; fetchMock.mockOnce(async () => Promise.resolve(RESULT)); - const { fetch } = network.factory(); + const { fetch } = network.factory(factoryOptions); const result = await fetch('foo.com'); expect(result.bodyUsed).toBe(false); diff --git a/packages/snaps-execution-environments/src/common/endowments/network.ts b/packages/snaps-execution-environments/src/common/endowments/network.ts index 9c61643699..7dccdf47f5 100644 --- a/packages/snaps-execution-environments/src/common/endowments/network.ts +++ b/packages/snaps-execution-environments/src/common/endowments/network.ts @@ -1,4 +1,7 @@ +import { assert } from '@metamask/utils'; + import { withTeardown } from '../utils'; +import type { EndowmentFactoryOptions } from './commonEndowmentFactory'; /** * This class wraps a Response object. @@ -9,9 +12,20 @@ class ResponseWrapper implements Response { #ogResponse: Response; - constructor(ogResponse: Response, teardownRef: { lastTeardown: number }) { + #onStart: () => Promise; + + #onFinish: () => Promise; + + constructor( + ogResponse: Response, + teardownRef: { lastTeardown: number }, + onStart: () => Promise, + onFinish: () => Promise, + ) { this.#ogResponse = ogResponse; this.#teardownRef = teardownRef; + this.#onStart = onStart; + this.#onFinish = onFinish; } get body(): ReadableStream | null { @@ -51,31 +65,83 @@ class ResponseWrapper implements Response { } async text() { - return withTeardown(this.#ogResponse.text(), this as any); + return await withTeardown( + (async () => { + await this.#onStart(); + try { + return await this.#ogResponse.text(); + } finally { + await this.#onFinish(); + } + })(), + this.#teardownRef, + ); } async arrayBuffer(): Promise { - return withTeardown( - this.#ogResponse.arrayBuffer(), - this as any, + return await withTeardown( + (async () => { + await this.#onStart(); + try { + return await this.#ogResponse.arrayBuffer(); + } finally { + await this.#onFinish(); + } + })(), + this.#teardownRef, ); } async blob(): Promise { - return withTeardown(this.#ogResponse.blob(), this as any); + return await withTeardown( + (async () => { + await this.#onStart(); + try { + return await this.#ogResponse.blob(); + } finally { + await this.#onFinish(); + } + })(), + this.#teardownRef, + ); } clone(): Response { const newResponse = this.#ogResponse.clone(); - return new ResponseWrapper(newResponse, this.#teardownRef); + return new ResponseWrapper( + newResponse, + this.#teardownRef, + this.#onStart, + this.#onFinish, + ); } async formData(): Promise { - return withTeardown(this.#ogResponse.formData(), this as any); + return await withTeardown( + (async () => { + await this.#onStart(); + try { + return await this.#ogResponse.formData(); + } finally { + await this.#onFinish(); + } + })(), + this.#teardownRef, + ); } async json(): Promise { - return withTeardown(this.#ogResponse.json(), this as any); + return await withTeardown( + (async () => { + await this.#onStart(); + try { + return await this.#ogResponse.json(); + } finally { + await this.#onFinish(); + } + })(), + this.#teardownRef, + ); } } @@ -89,10 +155,13 @@ class ResponseWrapper implements Response { * to ensure that a bad actor cannot get access to the original function, thus * potentially preventing the network requests from being torn down. * + * @param options - An options bag. + * @param options.notify - A reference to the notify function of the snap executor. * @returns An object containing a wrapped `fetch` * function, as well as a teardown function. */ -const createNetwork = () => { +const createNetwork = ({ notify }: EndowmentFactoryOptions = {}) => { + assert(notify, 'Notify must be passed to network endowment factory'); // Open fetch calls or open body streams const openConnections = new Set<{ cancel: () => Promise }>(); // Track last teardown count @@ -121,58 +190,95 @@ const createNetwork = () => { ); } + let started = false; + const onStart = async () => { + if (!started) { + started = true; + await notify({ + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + } + }; + + let finished = false; + const onFinish = async () => { + if (!finished) { + finished = true; + await notify({ + method: 'OutboundResponse', + params: { source: 'fetch' }, + }); + } + }; + let res: Response; let openFetchConnection: { cancel: () => Promise } | undefined; - try { - const fetchPromise = fetch(input, { - ...init, - signal: abortController.signal, - }); - - openFetchConnection = { - cancel: async () => { - abortController.abort(); - try { - await fetchPromise; - } catch { - /* do nothing */ + return await withTeardown( + (async () => { + try { + await notify({ + method: 'OutboundRequest', + params: { source: 'fetch' }, + }); + const fetchPromise = fetch(input, { + ...init, + signal: abortController.signal, + }); + + openFetchConnection = { + cancel: async () => { + abortController.abort(); + try { + await fetchPromise; + } catch { + /* do nothing */ + } + }, + }; + openConnections.add(openFetchConnection); + + res = new ResponseWrapper( + await fetchPromise, + teardownRef, + onStart, + onFinish, + ); + } finally { + if (openFetchConnection !== undefined) { + openConnections.delete(openFetchConnection); } - }, - }; - openConnections.add(openFetchConnection); + await notify({ + method: 'OutboundResponse', + params: { source: 'fetch' }, + }); + } - res = new ResponseWrapper( - await withTeardown(fetchPromise, teardownRef), - teardownRef, - ); - } finally { - if (openFetchConnection !== undefined) { - openConnections.delete(openFetchConnection); - } - } + if (res.body !== null) { + const body = new WeakRef(res.body); - if (res.body !== null) { - const body = new WeakRef(res.body); - - const openBodyConnection = { - cancel: - /* istanbul ignore next: see it.todo('can be torn down during body read') test */ - async () => { - try { - await body.deref()?.cancel(); - } catch { - /* do nothing */ - } - }, - }; - openConnections.add(openBodyConnection); - cleanup.register( - res.body, - /* istanbul ignore next: can't test garbage collection without modifying node parameters */ - () => openConnections.delete(openBodyConnection), - ); - } - return harden(res); + const openBodyConnection = { + cancel: + /* istanbul ignore next: see it.todo('can be torn down during body read') test */ + async () => { + try { + await body.deref()?.cancel(); + } catch { + /* do nothing */ + } + }, + }; + openConnections.add(openBodyConnection); + cleanup.register( + res.body, + /* istanbul ignore next: can't test garbage collection without modifying node parameters */ + () => openConnections.delete(openBodyConnection), + ); + } + return harden(res); + })(), + teardownRef, + ); }; const teardownFunction = async () => { diff --git a/packages/snaps-execution-environments/src/common/validation.ts b/packages/snaps-execution-environments/src/common/validation.ts index 13e4c7effa..6c6d6cac41 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -73,7 +73,7 @@ export const TerminateRequestArgumentsStruct = union([ export const ExecuteSnapRequestArgumentsStruct = tuple([ string(), string(), - optional(array(EndowmentStruct)), + array(EndowmentStruct), ]); export const SnapRpcRequestArgumentsStruct = tuple([