diff --git a/jest.config.js b/jest.config.js index 517c00b0..fc5200f4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,10 +20,10 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '/mocks/', '/test/'], coverageThreshold: { global: { - branches: 56.19, - functions: 53.93, - lines: 59.01, - statements: 59.29, + branches: 58.12, + functions: 54.95, + lines: 60.76, + statements: 60.99, }, }, projects: [ @@ -31,7 +31,11 @@ module.exports = { ...baseConfig, displayName: 'StreamProvider', testEnvironment: 'node', - testMatch: ['**/StreamProvider.test.ts', '**/utils.test.ts'], + testMatch: [ + '**/StreamProvider.test.ts', + '**/utils.test.ts', + '**/middleware/*.test.ts', + ], }, { ...baseConfig, diff --git a/src/MetaMaskInpageProvider.test.ts b/src/MetaMaskInpageProvider.test.ts index 15d6ee6a..5bf6f35d 100644 --- a/src/MetaMaskInpageProvider.test.ts +++ b/src/MetaMaskInpageProvider.test.ts @@ -813,6 +813,128 @@ describe('MetaMaskInpageProvider: RPC', () => { expect(provider.networkVersion).toBe('1'); }); }); + + describe('warnings', () => { + describe('rpc methods', () => { + const warnings = [ + { + method: 'eth_decrypt', + warning: messages.warnings.rpc.ethDecryptDeprecation, + }, + { + method: 'eth_getEncryptionPublicKey', + warning: messages.warnings.rpc.ethGetEncryptionPublicKeyDeprecation, + }, + ]; + + for (const { method, warning } of warnings) { + describe(method, () => { + it('should warn the first time the method is called', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + const { provider, connectionStream } = await getInitializedProvider( + { + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + connectionStream.reply('metamask-provider', { + id, + jsonrpc: '2.0', + result: null, + }); + }, + }, + ], + }, + ); + + await provider.request({ method }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(warning); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should not warn the second time the method is called', async () => { + const { provider, connectionStream } = await getInitializedProvider( + { + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + connectionStream.reply('metamask-provider', { + id, + jsonrpc: '2.0', + result: null, + }); + }, + }, + ], + }, + ); + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + + await provider.request({ method }); + await provider.request({ method }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(warning); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should allow the method to succeed', async () => { + const { provider, connectionStream } = await getInitializedProvider( + { + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + connectionStream.reply('metamask-provider', { + id, + jsonrpc: '2.0', + result: 'success!', + }); + }, + }, + ], + }, + ); + + const response = await provider.request({ method }); + expect(response).toBe('success!'); + }); + + it('should allow the method to fail', async () => { + const { provider, connectionStream } = await getInitializedProvider( + { + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + connectionStream.reply('metamask-provider', { + id, + jsonrpc: '2.0', + error: { code: 0, message: 'failure!' }, + }); + }, + }, + ], + }, + ); + + await expect(() => + provider.request({ method }), + ).rejects.toMatchObject({ + code: 0, + message: 'failure!', + }); + }); + }); + } + }); + }); }); describe('MetaMaskInpageProvider: Miscellanea', () => { diff --git a/src/extension-provider/createExternalExtensionProvider.test.ts b/src/extension-provider/createExternalExtensionProvider.test.ts index d9de9fde..8156fda2 100644 --- a/src/extension-provider/createExternalExtensionProvider.test.ts +++ b/src/extension-provider/createExternalExtensionProvider.test.ts @@ -1,7 +1,104 @@ +import type { JsonRpcRequest } from 'json-rpc-engine'; +import type { BaseProvider } from '../BaseProvider'; import { StreamProvider } from '../StreamProvider'; import { MockPort } from '../../test/mocks/MockPort'; +import messages from '../messages'; import { createExternalExtensionProvider } from './createExternalExtensionProvider'; +/** + * A fully initialized extension provider, and additional mocks to help + * test the provider. + */ +interface InitializedExtensionProviderDetails { + /** The initialized provider, created using a mocked Port instance. */ + provider: StreamProvider; + /** The mock Port instance used to create the provider. */ + port: MockPort; + /** A mock function that can be used to inspect what gets written to the + * mock connection Stream. + */ + onWrite: ReturnType; +} + +/** + * The `createExternalExtensionProvider` function initializes the wallet state + * asynchronously without blocking on it. This helper function + * returns a provider initialized with the specified values. + * + * @param options - Options bag. + * @param options.initialState - The initial provider state returned on + * initialization. See {@link MetaMaskInpageProvider._initializeState}. + * @param options.onMethodCalled - A set of configuration objects for adding + * method-specific callbacks. + * @param options.onMethodCalled[].substream - The substream of the method that + * the callback is for. + * @param options.onMethodCalled[].method - The name of the method that the + * callback is for. + * @param options.onMethodCalled[].callback - The method callback. + * @returns A tuple of the initialized provider, the mock port used, and an + * "onWrite" stub that can be used to inspect message sent by the provider. + */ +async function getInitializedProvider({ + initialState: { + accounts = [], + chainId = '0x0', + isUnlocked = true, + networkVersion = '0', + } = {}, + onMethodCalled = [], +}: { + initialState?: Partial[0]>; + onMethodCalled?: { + substream: string; + method: string; + callback: (data: JsonRpcRequest) => void; + }[]; +} = {}): Promise { + const onWrite = jest.fn(); + const port = new MockPort((name, data) => { + if ( + name === 'metamask-provider' && + data.method === 'metamask_getProviderState' + ) { + // Wrap in `setImmediate` to ensure a reply is recieved by the provider + // after the provider has processed the request, to ensure that the + // provider recognizes the id. + setImmediate(() => + port.reply('metamask-provider', { + id: onWrite.mock.calls[0][1].id, + jsonrpc: '2.0', + result: { + accounts, + chainId, + isUnlocked, + networkVersion, + }, + }), + ); + } + for (const { substream, method, callback } of onMethodCalled) { + if (name === substream && data.method === method) { + // Wrap in `setImmediate` to ensure a reply is recieved by the provider + // after the provider has processed the request, to ensure that the + // provider recognizes the id. + setImmediate(() => callback(data)); + } + } + onWrite(name, data); + }); + // `global.chrome.runtime` mock setup by `jest-chrome` in `jest.setup.js` + (global.chrome.runtime.connect as any).mockImplementation(() => { + return port; + }); + + const provider = createExternalExtensionProvider(); + await new Promise((resolve: () => void) => { + provider.on('_initialized', resolve); + }); + + return { provider, port, onWrite }; +} + describe('createExternalExtensionProvider', () => { it('can be called and not throw', () => { // `global.chrome.runtime` mock setup by `jest-chrome` in `jest.setup.js` @@ -28,4 +125,117 @@ describe('createExternalExtensionProvider', () => { const results = createExternalExtensionProvider(); expect(results).toBeInstanceOf(StreamProvider); }); + + describe('RPC warnings', () => { + const warnings = [ + { + method: 'eth_decrypt', + warning: messages.warnings.rpc.ethDecryptDeprecation, + }, + { + method: 'eth_getEncryptionPublicKey', + warning: messages.warnings.rpc.ethGetEncryptionPublicKeyDeprecation, + }, + ]; + + for (const { method, warning } of warnings) { + describe(method, () => { + it('should warn the first time the method is called', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + const { provider, port } = await getInitializedProvider({ + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + port.reply('metamask-provider', { + id, + jsonrpc: '2.0', + result: null, + }); + }, + }, + ], + }); + + await provider.request({ method }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(warning); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should not warn the second time the method is called', async () => { + const { provider, port } = await getInitializedProvider({ + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + port.reply('metamask-provider', { + id, + jsonrpc: '2.0', + result: null, + }); + }, + }, + ], + }); + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + + await provider.request({ method }); + await provider.request({ method }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(warning); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should allow the method to succeed', async () => { + const { provider, port } = await getInitializedProvider({ + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + port.reply('metamask-provider', { + id, + jsonrpc: '2.0', + result: 'success!', + }); + }, + }, + ], + }); + + const response = await provider.request({ method }); + + expect(response).toBe('success!'); + }); + + it('should allow the method to fail', async () => { + const { provider, port } = await getInitializedProvider({ + onMethodCalled: [ + { + substream: 'metamask-provider', + method, + callback: ({ id }) => { + port.reply('metamask-provider', { + id, + jsonrpc: '2.0', + error: { code: 0, message: 'failure!' }, + }); + }, + }, + ], + }); + + await expect(() => + provider.request({ method }), + ).rejects.toMatchObject({ + code: 0, + message: 'failure!', + }); + }); + }); + } + }); }); diff --git a/src/messages.ts b/src/messages.ts index 680f2cca..5eebff76 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -34,6 +34,10 @@ const messages = { networkChanged: `MetaMask: The event 'networkChanged' is deprecated and may be removed in the future. Use 'chainChanged' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193#chainchanged`, notification: `MetaMask: The event 'notification' is deprecated and may be removed in the future. Use 'message' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193#message`, }, + rpc: { + ethDecryptDeprecation: `MetaMask: The RPC method 'eth_decrypt' is deprecated and may be removed in the future.\nFor more information, see: https://medium.com/metamask/metamask-api-method-deprecation-2b0564a84686`, + ethGetEncryptionPublicKeyDeprecation: `MetaMask: The RPC method 'eth_getEncryptionPublicKey' is deprecated and may be removed in the future.\nFor more information, see: https://medium.com/metamask/metamask-api-method-deprecation-2b0564a84686`, + }, // misc experimentalMethods: `MetaMask: 'ethereum._metamask' exposes non-standard, experimental methods. They may be removed or changed without warning.`, }, diff --git a/src/middleware/createRpcWarningMiddleware.test.ts b/src/middleware/createRpcWarningMiddleware.test.ts new file mode 100644 index 00000000..98d8baa0 --- /dev/null +++ b/src/middleware/createRpcWarningMiddleware.test.ts @@ -0,0 +1,128 @@ +import { JsonRpcEngine, JsonRpcFailure, JsonRpcSuccess } from 'json-rpc-engine'; +import messages from '../messages'; +import { createRpcWarningMiddleware } from './createRpcWarningMiddleware'; + +const warnings = [ + { + method: 'eth_decrypt', + warning: messages.warnings.rpc.ethDecryptDeprecation, + }, + { + method: 'eth_getEncryptionPublicKey', + warning: messages.warnings.rpc.ethGetEncryptionPublicKeyDeprecation, + }, +]; + +describe('createRpcWarningMiddleware', () => { + for (const { method, warning } of warnings) { + describe(method, () => { + it('should warn the first time the method is called', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + await engine.handle({ jsonrpc: '2.0', id: 1, method }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(warning); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should not warn the second time the method is called', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + await engine.handle({ jsonrpc: '2.0', id: 1, method }); + await engine.handle({ jsonrpc: '2.0', id: 1, method }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(warning); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should allow the method to succeed', async () => { + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + engine.push((_req, res, _next, end) => { + res.result = 'success!'; + end(); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method, + })) as JsonRpcSuccess; + + expect(response.result).toBe('success!'); + }); + + it('should allow the method to fail', async () => { + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + engine.push(() => { + throw new Error('Failure!'); + }); + + const result = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method, + })) as JsonRpcFailure; + + expect(result.error.message).toBe('Failure!'); + }); + }); + } + + describe('unaffected method', () => { + it('should not issue a warning', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + await engine.handle({ jsonrpc: '2.0', id: 1, method: 'eth_chainId' }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should allow the method to succeed', async () => { + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + engine.push((_req, res, _next, end) => { + res.result = 'success!'; + end(); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'eth_chainId', + })) as JsonRpcSuccess; + + expect(response.result).toBe('success!'); + }); + + it('should allow the method to fail', async () => { + const middleware = createRpcWarningMiddleware(globalThis.console); + const engine = new JsonRpcEngine(); + engine.push(middleware); + engine.push(() => { + throw new Error('Failure!'); + }); + + const result = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'eth_chainId', + })) as JsonRpcFailure; + + expect(result.error.message).toBe('Failure!'); + }); + }); +}); diff --git a/src/middleware/createRpcWarningMiddleware.ts b/src/middleware/createRpcWarningMiddleware.ts new file mode 100644 index 00000000..9257b236 --- /dev/null +++ b/src/middleware/createRpcWarningMiddleware.ts @@ -0,0 +1,36 @@ +import type { JsonRpcMiddleware } from 'json-rpc-engine'; + +import type { ConsoleLike } from '../utils'; +import messages from '../messages'; + +/** + * Create JSON-RPC middleware that logs warnings for deprecated RPC methods. + * + * @param log - The logging API to use. + * @returns The JSON-RPC middleware. + */ +export function createRpcWarningMiddleware( + log: ConsoleLike, +): JsonRpcMiddleware { + const sentWarnings = { + ethDecryptDeprecation: false, + ethGetEncryptionPublicKeyDeprecation: false, + }; + + return (req, _res, next) => { + if ( + sentWarnings.ethDecryptDeprecation === false && + req.method === 'eth_decrypt' + ) { + log.warn(messages.warnings.rpc.ethDecryptDeprecation); + sentWarnings.ethDecryptDeprecation = true; + } else if ( + sentWarnings.ethGetEncryptionPublicKeyDeprecation === false && + req.method === 'eth_getEncryptionPublicKey' + ) { + log.warn(messages.warnings.rpc.ethGetEncryptionPublicKeyDeprecation); + sentWarnings.ethGetEncryptionPublicKeyDeprecation = true; + } + next(); + }; +} diff --git a/src/utils.ts b/src/utils.ts index 0f453913..93772872 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import { PendingJsonRpcResponse, } from 'json-rpc-engine'; import { ethErrors } from 'eth-rpc-errors'; +import { createRpcWarningMiddleware } from './middleware/createRpcWarningMiddleware'; export type Maybe = Partial | null | undefined; @@ -30,6 +31,7 @@ export const EMITTED_NOTIFICATIONS = Object.freeze([ export const getDefaultExternalMiddleware = (logger: ConsoleLike = console) => [ createIdRemapMiddleware(), createErrorMiddleware(logger), + createRpcWarningMiddleware(logger), ]; /**