diff --git a/src/BaseProvider.ts b/src/BaseProvider.ts new file mode 100644 index 00000000..118e5073 --- /dev/null +++ b/src/BaseProvider.ts @@ -0,0 +1,540 @@ +import { Duplex } from 'stream'; +import pump from 'pump'; +import { + JsonRpcEngine, + createIdRemapMiddleware, + JsonRpcRequest, + JsonRpcId, + JsonRpcVersion, + JsonRpcSuccess, + JsonRpcMiddleware, +} from 'json-rpc-engine'; +import { createStreamMiddleware } from 'json-rpc-middleware-stream'; +import ObjectMultiplex from '@metamask/object-multiplex'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import dequal from 'fast-deep-equal'; +import { ethErrors, EthereumRpcError } from 'eth-rpc-errors'; +import { duplex as isDuplex } from 'is-stream'; + +import messages from './messages'; +import { + createErrorMiddleware, + EMITTED_NOTIFICATIONS, + getRpcPromiseCallback, + logStreamDisconnectWarning, + ConsoleLike, + Maybe, +} from './utils'; + +export interface UnvalidatedJsonRpcRequest { + id?: JsonRpcId; + jsonrpc?: JsonRpcVersion; + method: string; + params?: unknown; +} + +export interface BaseProviderOptions { + + /** + * The name of the stream used to connect to the wallet. + */ + jsonRpcStreamName?: string; + + /** + * The logging API to use. + */ + logger?: ConsoleLike; + + /** + * The maximum number of event listeners. + */ + maxEventListeners?: number; + +} + +export interface RequestArguments { + + /** The RPC method to request. */ + method: string; + + /** The params of the RPC method, if any. */ + params?: unknown[] | Record; +} + +export interface BaseProviderState { + accounts: null | string[]; + isConnected: boolean; + isUnlocked: boolean; + initialized: boolean; + isPermanentlyDisconnected: boolean; +} + +export interface JsonRpcConnection { + events: SafeEventEmitter; + middleware: JsonRpcMiddleware; + stream: _Readable.Duplex; +} + +export default class BaseProvider extends SafeEventEmitter { + + protected readonly _log: ConsoleLike; + + protected _state: BaseProviderState; + + protected _rpcEngine: JsonRpcEngine; + + protected _jsonRpcConnection: JsonRpcConnection; + + protected static _defaultState: BaseProviderState = { + accounts: null, + isConnected: false, + isUnlocked: false, + initialized: false, + isPermanentlyDisconnected: false, + }; + + /** + * The chain ID of the currently connected Ethereum chain. + * See [chainId.network]{@link https://chainid.network} for more information. + */ + public chainId: string | null; + + /** + * The user's currently selected Ethereum address. + * If null, MetaMask is either locked or the user has not permitted any + * addresses to be viewed. + */ + public selectedAddress: string | null; + + /** + * @param connectionStream - A Node.js duplex stream + * @param options - An options bag + * @param options.jsonRpcStreamName - The name of the internal JSON-RPC stream. + * Default: metamask-provider + * @param options.logger - The logging API to use. Default: console + * @param options.maxEventListeners - The maximum number of event + * listeners. Default: 100 + */ + constructor( + connectionStream: typeof Duplex, + { + jsonRpcStreamName = 'metamask-provider', + logger = console, + maxEventListeners = 100, + }: BaseProviderOptions = {}, + ) { + super(); + + if (!isDuplex(connectionStream)) { + throw new Error(messages.errors.invalidDuplexStream()); + } + + this._log = logger; + + this.setMaxListeners(maxEventListeners); + + // private state + this._state = { + ...BaseProvider._defaultState, + }; + + // public state + this.selectedAddress = null; + this.chainId = null; + + // bind functions (to prevent consumers from making unbound calls) + this._handleAccountsChanged = this._handleAccountsChanged.bind(this); + this._handleConnect = this._handleConnect.bind(this); + this._handleChainChanged = this._handleChainChanged.bind(this); + this._handleDisconnect = this._handleDisconnect.bind(this); + this._handleStreamDisconnect = this._handleStreamDisconnect.bind(this); + this._handleUnlockStateChanged = this._handleUnlockStateChanged.bind(this); + this._rpcRequest = this._rpcRequest.bind(this); + this.request = this.request.bind(this); + + // setup connectionStream multiplexing + const mux = new ObjectMultiplex(); + pump( + connectionStream, + mux as unknown as Duplex, + connectionStream, + this._handleStreamDisconnect.bind(this, 'MetaMask'), + ); + + // setup own event listeners + + // EIP-1193 connect + this.on('connect', () => { + this._state.isConnected = true; + }); + + // setup RPC connection + + this._jsonRpcConnection = createStreamMiddleware(); + pump( + this._jsonRpcConnection.stream, + mux.createStream(jsonRpcStreamName) as unknown as Duplex, + this._jsonRpcConnection.stream, + this._handleStreamDisconnect.bind(this, 'MetaMask RpcProvider'), + ); + + // handle RPC requests via dapp-side rpc engine + const rpcEngine = new JsonRpcEngine(); + rpcEngine.push(createIdRemapMiddleware()); + rpcEngine.push(createErrorMiddleware(this._log)); + rpcEngine.push(this._jsonRpcConnection.middleware); + this._rpcEngine = rpcEngine; + + this._initializeState(); + + // handle JSON-RPC notifications + this._jsonRpcConnection.events.on('notification', (payload) => { + const { method, params } = payload; + if (method === 'metamask_accountsChanged') { + this._handleAccountsChanged(params); + + } else if (method === 'metamask_unlockStateChanged') { + this._handleUnlockStateChanged(params); + } else if (method === 'metamask_chainChanged') { + this._handleChainChanged(params); + } else if (EMITTED_NOTIFICATIONS.includes(method)) { + this.emit('message', { + type: method, + data: params, + }); + } else if (method === 'METAMASK_STREAM_FAILURE') { + connectionStream.destroy( + new Error(messages.errors.permanentlyDisconnected()), + ); + } + }); + + } + + //==================== + // Public Methods + //==================== + + /** + * Returns whether the provider can process RPC requests. + */ + isConnected(): boolean { + return this._state.isConnected; + } + + /** + * Submits an RPC request for the given method, with the given params. + * Resolves with the result of the method call, or rejects on error. + * + * @param args - The RPC request arguments. + * @param args.method - The RPC method name. + * @param args.params - The parameters for the RPC method. + * @returns A Promise that resolves with the result of the RPC method, + * or rejects if an error is encountered. + */ + async request(args: RequestArguments): Promise> { + if (!args || typeof args !== 'object' || Array.isArray(args)) { + throw ethErrors.rpc.invalidRequest({ + message: messages.errors.invalidRequestArgs(), + data: args, + }); + } + + const { method, params } = args; + + if (typeof method !== 'string' || method.length === 0) { + throw ethErrors.rpc.invalidRequest({ + message: messages.errors.invalidRequestMethod(), + data: args, + }); + } + + if ( + params !== undefined && !Array.isArray(params) && + (typeof params !== 'object' || params === null) + ) { + throw ethErrors.rpc.invalidRequest({ + message: messages.errors.invalidRequestParams(), + data: args, + }); + } + + return new Promise((resolve, reject) => { + this._rpcRequest( + { method, params }, + getRpcPromiseCallback(resolve, reject), + ); + }); + } + + //==================== + // Private Methods + //==================== + + /** + * Constructor helper. + * Populates initial state by calling 'metamask_getProviderState' and emits + * necessary events. + */ + private async _initializeState() { + try { + const { + accounts, + chainId, + isUnlocked, + networkVersion, + } = await this.request({ + method: 'metamask_getProviderState', + }) as { + accounts: string[]; + chainId: string; + isUnlocked: boolean; + networkVersion: string; + }; + + // indicate that we've connected, for EIP-1193 compliance + this.emit('connect', { chainId }); + + this._handleChainChanged({ chainId, networkVersion }); + this._handleUnlockStateChanged({ accounts, isUnlocked }); + this._handleAccountsChanged(accounts); + } catch (error) { + this._log.error( + 'MetaMask: Failed to get initial state. Please report this bug.', + error, + ); + } finally { + this._state.initialized = true; + this.emit('_initialized'); + } + } + + /** + * Internal RPC method. Forwards requests to background via the RPC engine. + * Also remap ids inbound and outbound. + * + * @param payload - The RPC request object. + * @param callback - The consumer's callback. + */ + protected _rpcRequest( + payload: UnvalidatedJsonRpcRequest | UnvalidatedJsonRpcRequest[], + callback: (...args: any[]) => void, + ) { + let cb = callback; + + if (!Array.isArray(payload)) { + if (!payload.jsonrpc) { + payload.jsonrpc = '2.0'; + } + + if ( + payload.method === 'eth_accounts' || + payload.method === 'eth_requestAccounts' + ) { + + // handle accounts changing + cb = (err: Error, res: JsonRpcSuccess) => { + this._handleAccountsChanged( + res.result || [], + payload.method === 'eth_accounts', + ); + callback(err, res); + }; + } + return this._rpcEngine.handle(payload as JsonRpcRequest, cb); + } + return this._rpcEngine.handle(payload as JsonRpcRequest[], cb); + } + + /** + * When the provider becomes connected, updates internal state and emits + * required events. Idempotent. + * + * @param chainId - The ID of the newly connected chain. + * @emits MetaMaskInpageProvider#connect + */ + protected _handleConnect(chainId: string) { + if (!this._state.isConnected) { + this._state.isConnected = true; + this.emit('connect', { chainId }); + this._log.debug(messages.info.connected(chainId)); + } + } + + /** + * When the provider becomes disconnected, updates internal state and emits + * required events. Idempotent with respect to the isRecoverable parameter. + * + * Error codes per the CloseEvent status codes as required by EIP-1193: + * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes + * + * @param isRecoverable - Whether the disconnection is recoverable. + * @param errorMessage - A custom error message. + * @emits MetaMaskInpageProvider#disconnect + */ + protected _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { + if ( + this._state.isConnected || + (!this._state.isPermanentlyDisconnected && !isRecoverable) + ) { + this._state.isConnected = false; + + let error; + if (isRecoverable) { + error = new EthereumRpcError( + 1013, // Try again later + errorMessage || messages.errors.disconnected(), + ); + this._log.debug(error); + } else { + error = new EthereumRpcError( + 1011, // Internal error + errorMessage || messages.errors.permanentlyDisconnected(), + ); + this._log.error(error); + this.chainId = null; + this._state.accounts = null; + this.selectedAddress = null; + this._state.isUnlocked = false; + this._state.isPermanentlyDisconnected = true; + } + + this.emit('disconnect', error); + } + } + + /** + * Called when connection is lost to critical streams. + * + * @emits MetamaskInpageProvider#disconnect + */ + protected _handleStreamDisconnect(streamName: string, error: Error) { + logStreamDisconnectWarning(this._log, streamName, error, this); + this._handleDisconnect(false, error ? error.message : undefined); + } + + /** + * Upon receipt of a new chainId and networkVersion, emits corresponding + * events and sets relevant public state. + * Does nothing if neither the chainId nor the networkVersion are different + * from existing values. + * + * @emits MetamaskInpageProvider#chainChanged + * @param networkInfo - An object with network info. + * @param networkInfo.chainId - The latest chain ID. + * @param networkInfo.networkVersion - The latest network ID. + */ + protected _handleChainChanged({ + chainId, + networkVersion, + }: { chainId?: string; networkVersion?: string } = {}) { + if ( + !chainId || typeof chainId !== 'string' || !chainId.startsWith('0x') || + !networkVersion || typeof networkVersion !== 'string' + ) { + this._log.error( + 'MetaMask: Received invalid network parameters. Please report this bug.', + { chainId, networkVersion }, + ); + return; + } + + if (networkVersion === 'loading') { + this._handleDisconnect(true); + } else { + this._handleConnect(chainId); + + if (chainId !== this.chainId) { + this.chainId = chainId; + if (this._state.initialized) { + this.emit('chainChanged', this.chainId); + } + } + } + } + + /** + * Called when accounts may have changed. Diffs the new accounts value with + * the current one, updates all state as necessary, and emits the + * accountsChanged event. + * + * @param accounts - The new accounts value. + * @param isEthAccounts - Whether the accounts value was returned by + * a call to eth_accounts. + */ + protected _handleAccountsChanged(accounts: unknown[], isEthAccounts = false): void { + let _accounts = accounts; + + if (!Array.isArray(accounts)) { + this._log.error( + 'MetaMask: Received invalid accounts parameter. Please report this bug.', + accounts, + ); + _accounts = []; + } + + for (const account of accounts) { + if (typeof account !== 'string') { + this._log.error( + 'MetaMask: Received non-string account. Please report this bug.', + accounts, + ); + _accounts = []; + break; + } + } + + // emit accountsChanged if anything about the accounts array has changed + if (!dequal(this._state.accounts, _accounts)) { + + // we should always have the correct accounts even before eth_accounts + // returns + if (isEthAccounts && this._state.accounts !== null) { + this._log.error( + `MetaMask: 'eth_accounts' unexpectedly updated accounts. Please report this bug.`, + _accounts, + ); + } + + this._state.accounts = _accounts as string[]; + + // handle selectedAddress + if (this.selectedAddress !== _accounts[0]) { + this.selectedAddress = _accounts[0] as string || null; + } + + // finally, after all state has been updated, emit the event + if (this._state.initialized) { + this.emit('accountsChanged', _accounts); + } + } + } + + /** + * Upon receipt of a new isUnlocked state, sets relevant public state. + * Calls the accounts changed handler with the received accounts, or an empty + * array. + * + * Does nothing if the received value is equal to the existing value. + * There are no lock/unlock events. + * + * @param opts - Options bag. + * @param opts.accounts - The exposed accounts, if any. + * @param opts.isUnlocked - The latest isUnlocked value. + */ + protected _handleUnlockStateChanged({ + accounts, + isUnlocked, + }: { accounts?: string[]; isUnlocked?: boolean } = {}) { + if (typeof isUnlocked !== 'boolean') { + this._log.error('MetaMask: Received invalid isUnlocked parameter. Please report this bug.'); + return; + } + + if (isUnlocked !== this._state.isUnlocked) { + this._state.isUnlocked = isUnlocked; + this._handleAccountsChanged(accounts || []); + } + } + +} diff --git a/src/MetaMaskInpageProvider.ts b/src/MetaMaskInpageProvider.ts index dc61ee2d..67dbb4b2 100644 --- a/src/MetaMaskInpageProvider.ts +++ b/src/MetaMaskInpageProvider.ts @@ -1,56 +1,25 @@ import { Duplex } from 'stream'; -import pump from 'pump'; import { - JsonRpcEngine, - createIdRemapMiddleware, JsonRpcRequest, JsonRpcResponse, - JsonRpcId, - JsonRpcVersion, - JsonRpcSuccess, } from 'json-rpc-engine'; -import { createStreamMiddleware } from 'json-rpc-middleware-stream'; -import ObjectMultiplex from '@metamask/object-multiplex'; -import SafeEventEmitter from '@metamask/safe-event-emitter'; -import dequal from 'fast-deep-equal'; -import { ethErrors, EthereumRpcError } from 'eth-rpc-errors'; -import { duplex as isDuplex } from 'is-stream'; - -import messages from './messages'; +import { ethErrors } from 'eth-rpc-errors'; import sendSiteMetadata from './siteMetadata'; +import messages from './messages'; import { - createErrorMiddleware, EMITTED_NOTIFICATIONS, getRpcPromiseCallback, - logStreamDisconnectWarning, NOOP, - ConsoleLike, - Maybe, } from './utils'; +import BaseProvider, { BaseProviderOptions, UnvalidatedJsonRpcRequest } from './BaseProvider'; -interface UnvalidatedJsonRpcRequest { - id?: JsonRpcId; - jsonrpc?: JsonRpcVersion; - method: string; - params?: unknown; +export interface SendSyncJsonRpcRequest extends JsonRpcRequest { + method: 'eth_accounts' | 'eth_coinbase' | 'eth_uninstallFilter' | 'net_version'; } -export interface MetaMaskInpageProviderOptions { +type WarningEventName = keyof SentWarningsState['events']; - /** - * The name of the stream used to connect to the wallet. - */ - jsonRpcStreamName?: string; - - /** - * The logging API to use. - */ - logger?: ConsoleLike; - - /** - * The maximum number of event listeners. - */ - maxEventListeners?: number; +export interface MetaMaskInpageProviderOptions extends BaseProviderOptions { /** * Whether the provider should send page metadata. @@ -58,79 +27,48 @@ export interface MetaMaskInpageProviderOptions { shouldSendMetadata?: boolean; } -export interface RequestArguments { - - /** The RPC method to request. */ - method: string; - - /** The params of the RPC method, if any. */ - params?: unknown[] | Record; +interface SentWarningsState { + // methods + enable: boolean; + experimentalMethods: boolean; + send: boolean; + // events + events: { + close: boolean; + data: boolean; + networkChanged: boolean; + notification: boolean; + }; } -export interface SendSyncJsonRpcRequest extends JsonRpcRequest { - method: 'eth_accounts' | 'eth_coinbase' | 'eth_uninstallFilter' | 'net_version'; -} +export default class MetaMaskInpageProvider extends BaseProvider { -interface InternalState { - sentWarnings: { + protected _sentWarnings: SentWarningsState = { // methods - enable: boolean; - experimentalMethods: boolean; - send: boolean; + enable: false, + experimentalMethods: false, + send: false, // events events: { - close: boolean; - data: boolean; - networkChanged: boolean; - notification: boolean; - }; + close: false, + data: false, + networkChanged: false, + notification: false, + }, }; - accounts: null | string[]; - isConnected: boolean; - isUnlocked: boolean; - initialized: boolean; - isPermanentlyDisconnected: boolean; -} - -type WarningEventName = keyof InternalState['sentWarnings']['events']; - -export default class MetaMaskInpageProvider extends SafeEventEmitter { - - private readonly _log: ConsoleLike; - - private _state: InternalState; - - private _rpcEngine: JsonRpcEngine; /** - * The chain ID of the currently connected Ethereum chain. - * See [chainId.network]{@link https://chainid.network} for more information. + * Experimental methods can be found here. */ - public chainId: string | null; + public readonly _metamask: ReturnType; - /** - * The network ID of the currently connected Ethereum chain. - * @deprecated Use {@link chainId} instead. - */ public networkVersion: string | null; - /** - * The user's currently selected Ethereum address. - * If null, MetaMask is either locked or the user has not permitted any - * addresses to be viewed. - */ - public selectedAddress: string | null; - /** * Indicating that this provider is a MetaMask provider. */ public readonly isMetaMask: true; - /** - * Experimental methods can be found here. - */ - public readonly _metamask: ReturnType; - /** * @param connectionStream - A Node.js duplex stream * @param options - An options bag @@ -151,142 +89,32 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { shouldSendMetadata = true, }: MetaMaskInpageProviderOptions = {}, ) { - if (!isDuplex(connectionStream)) { - throw new Error(messages.errors.invalidDuplexStream()); - } - if ( - typeof maxEventListeners !== 'number' || - typeof shouldSendMetadata !== 'boolean' - ) { - throw new Error(messages.errors.invalidOptions( - maxEventListeners, shouldSendMetadata, - )); - } + super(connectionStream, { jsonRpcStreamName, logger, maxEventListeners }); - validateLoggerObject(logger); - - super(); - - this._log = logger; + this.networkVersion = null; this.isMetaMask = true; - this.setMaxListeners(maxEventListeners); - - // private state - this._state = { - sentWarnings: { - // methods - enable: false, - experimentalMethods: false, - send: false, - // events - events: { - close: false, - data: false, - networkChanged: false, - notification: false, - }, - }, - accounts: null, - isConnected: false, - isUnlocked: false, - initialized: false, - isPermanentlyDisconnected: false, - }; - - this._metamask = this._getExperimentalApi(); - - // public state - this.selectedAddress = null; - this.networkVersion = null; - this.chainId = null; - - // bind functions (to prevent consumers from making unbound calls) - this._handleAccountsChanged = this._handleAccountsChanged.bind(this); - this._handleConnect = this._handleConnect.bind(this); - this._handleChainChanged = this._handleChainChanged.bind(this); - this._handleDisconnect = this._handleDisconnect.bind(this); - this._handleStreamDisconnect = this._handleStreamDisconnect.bind(this); - this._handleUnlockStateChanged = this._handleUnlockStateChanged.bind(this); this._sendSync = this._sendSync.bind(this); - this._rpcRequest = this._rpcRequest.bind(this); - this._warnOfDeprecation = this._warnOfDeprecation.bind(this); this.enable = this.enable.bind(this); - this.request = this.request.bind(this); this.send = this.send.bind(this); this.sendAsync = this.sendAsync.bind(this); + this._warnOfDeprecation = this._warnOfDeprecation.bind(this); - // setup connectionStream multiplexing - const mux = new ObjectMultiplex(); - pump( - connectionStream, - mux as unknown as Duplex, - connectionStream, - this._handleStreamDisconnect.bind(this, 'MetaMask'), - ); - - // ignore phishing warning message (handled elsewhere) - mux.ignoreStream('phishing'); - - // setup own event listeners - - // EIP-1193 connect - this.on('connect', () => { - this._state.isConnected = true; - }); - - // setup RPC connection - - const jsonRpcConnection = createStreamMiddleware(); - pump( - jsonRpcConnection.stream, - mux.createStream(jsonRpcStreamName) as unknown as Duplex, - jsonRpcConnection.stream, - this._handleStreamDisconnect.bind(this, 'MetaMask RpcProvider'), - ); - - // handle RPC requests via dapp-side rpc engine - const rpcEngine = new JsonRpcEngine(); - rpcEngine.push(createIdRemapMiddleware()); - rpcEngine.push(createErrorMiddleware(this._log)); - rpcEngine.push(jsonRpcConnection.middleware); - this._rpcEngine = rpcEngine; - - this._initializeState(); + this._metamask = this._getExperimentalApi(); // handle JSON-RPC notifications - jsonRpcConnection.events.on('notification', (payload) => { - const { method, params } = payload; - - if (method === 'metamask_accountsChanged') { - this._handleAccountsChanged(params); - - } else if (method === 'metamask_unlockStateChanged') { - this._handleUnlockStateChanged(params); - } else if (method === 'metamask_chainChanged') { - this._handleChainChanged(params); - } else if (EMITTED_NOTIFICATIONS.includes(method)) { + this._jsonRpcConnection.events.on('notification', (payload) => { + const { method } = payload; + if (EMITTED_NOTIFICATIONS.includes(method)) { // deprecated // emitted here because that was the original order this.emit('data', payload); - - this.emit('message', { - type: method, - data: params, - }); - // deprecated this.emit('notification', payload.params.result); - } else if (method === 'METAMASK_STREAM_FAILURE') { - connectionStream.destroy( - new Error(messages.errors.permanentlyDisconnected()), - ); } }); - // miscellanea - // send website metadata if (shouldSendMetadata) { if (document.readyState === 'complete') { @@ -305,58 +133,6 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { // Public Methods //==================== - /** - * Returns whether the provider can process RPC requests. - */ - isConnected(): boolean { - return this._state.isConnected; - } - - /** - * Submits an RPC request for the given method, with the given params. - * Resolves with the result of the method call, or rejects on error. - * - * @param args - The RPC request arguments. - * @param args.method - The RPC method name. - * @param args.params - The parameters for the RPC method. - * @returns A Promise that resolves with the result of the RPC method, - * or rejects if an error is encountered. - */ - async request(args: RequestArguments): Promise> { - if (!args || typeof args !== 'object' || Array.isArray(args)) { - throw ethErrors.rpc.invalidRequest({ - message: messages.errors.invalidRequestArgs(), - data: args, - }); - } - - const { method, params } = args; - - if (typeof method !== 'string' || method.length === 0) { - throw ethErrors.rpc.invalidRequest({ - message: messages.errors.invalidRequestMethod(), - data: args, - }); - } - - if ( - params !== undefined && !Array.isArray(params) && - (typeof params !== 'object' || params === null) - ) { - throw ethErrors.rpc.invalidRequest({ - message: messages.errors.invalidRequestParams(), - data: args, - }); - } - - return new Promise((resolve, reject) => { - this._rpcRequest( - { method, params }, - getRpcPromiseCallback(resolve, reject), - ); - }); - } - /** * Submits an RPC request per the given JSON-RPC request object. * @@ -405,96 +181,6 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { // Private Methods //==================== - /** - * Constructor helper. - * Populates initial state by calling 'metamask_getProviderState' and emits - * necessary events. - */ - private async _initializeState() { - try { - const { - accounts, - chainId, - isUnlocked, - networkVersion, - } = await this.request({ - method: 'metamask_getProviderState', - }) as { - accounts: string[]; - chainId: string; - isUnlocked: boolean; - networkVersion: string; - }; - - // indicate that we've connected, for EIP-1193 compliance - this.emit('connect', { chainId }); - - this._handleChainChanged({ chainId, networkVersion }); - this._handleUnlockStateChanged({ accounts, isUnlocked }); - this._handleAccountsChanged(accounts); - } catch (error) { - this._log.error( - 'MetaMask: Failed to get initial state. Please report this bug.', - error, - ); - } finally { - this._state.initialized = true; - this.emit('_initialized'); - } - } - - /** - * Internal RPC method. Forwards requests to background via the RPC engine. - * Also remap ids inbound and outbound. - * - * @param payload - The RPC request object. - * @param callback - The consumer's callback. - */ - private _rpcRequest( - payload: UnvalidatedJsonRpcRequest | UnvalidatedJsonRpcRequest[], - callback: (...args: any[]) => void, - ) { - let cb = callback; - - if (!Array.isArray(payload)) { - if (!payload.jsonrpc) { - payload.jsonrpc = '2.0'; - } - - if ( - payload.method === 'eth_accounts' || - payload.method === 'eth_requestAccounts' - ) { - - // handle accounts changing - cb = (err: Error, res: JsonRpcSuccess) => { - this._handleAccountsChanged( - res.result || [], - payload.method === 'eth_accounts', - ); - callback(err, res); - }; - } - return this._rpcEngine.handle(payload as JsonRpcRequest, cb); - } - return this._rpcEngine.handle(payload as JsonRpcRequest[], cb); - } - - /** - * When the provider becomes connected, updates internal state and emits - * required events. Idempotent. - * - * @param chainId - The ID of the newly connected chain. - * @emits MetaMaskInpageProvider#connect - */ - private _handleConnect(chainId: string) { - if (!this._state.isConnected) { - this._state.isConnected = true; - this.emit('connect', { chainId }); - this._log.debug(messages.info.connected(chainId)); - } - } - /** * When the provider becomes disconnected, updates internal state and emits * required events. Idempotent with respect to the isRecoverable parameter. @@ -506,245 +192,25 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { * @param errorMessage - A custom error message. * @emits MetaMaskInpageProvider#disconnect */ - private _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { + protected _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { + super._handleDisconnect(isRecoverable, errorMessage); if ( - this._state.isConnected || - (!this._state.isPermanentlyDisconnected && !isRecoverable) + this.networkVersion && !isRecoverable ) { - this._state.isConnected = false; - - let error; - if (isRecoverable) { - error = new EthereumRpcError( - 1013, // Try again later - errorMessage || messages.errors.disconnected(), - ); - this._log.debug(error); - } else { - error = new EthereumRpcError( - 1011, // Internal error - errorMessage || messages.errors.permanentlyDisconnected(), - ); - this._log.error(error); - this.chainId = null; - this.networkVersion = null; - this._state.accounts = null; - this.selectedAddress = null; - this._state.isUnlocked = false; - this._state.isPermanentlyDisconnected = true; - } - - this.emit('disconnect', error); - this.emit('close', error); // deprecated - } - } - - /** - * Called when connection is lost to critical streams. - * - * @emits MetamaskInpageProvider#disconnect - */ - private _handleStreamDisconnect(streamName: string, error: Error) { - logStreamDisconnectWarning(this._log, streamName, error, this); - this._handleDisconnect(false, error ? error.message : undefined); - } - - /** - * Upon receipt of a new chainId and networkVersion, emits corresponding - * events and sets relevant public state. - * Does nothing if neither the chainId nor the networkVersion are different - * from existing values. - * - * @emits MetamaskInpageProvider#chainChanged - * @param networkInfo - An object with network info. - * @param networkInfo.chainId - The latest chain ID. - * @param networkInfo.networkVersion - The latest network ID. - */ - private _handleChainChanged({ - chainId, - networkVersion, - }: { chainId?: string; networkVersion?: string } = {}) { - if ( - !chainId || typeof chainId !== 'string' || !chainId.startsWith('0x') || - !networkVersion || typeof networkVersion !== 'string' - ) { - this._log.error( - 'MetaMask: Received invalid network parameters. Please report this bug.', - { chainId, networkVersion }, - ); - return; - } - - if (networkVersion === 'loading') { - this._handleDisconnect(true); - } else { - this._handleConnect(chainId); - - if (chainId !== this.chainId) { - this.chainId = chainId; - if (this._state.initialized) { - this.emit('chainChanged', this.chainId); - } - } - - if (networkVersion !== this.networkVersion) { - this.networkVersion = networkVersion; - if (this._state.initialized) { - this.emit('networkChanged', this.networkVersion); - } - } - } - } - - /** - * Called when accounts may have changed. Diffs the new accounts value with - * the current one, updates all state as necessary, and emits the - * accountsChanged event. - * - * @param accounts - The new accounts value. - * @param isEthAccounts - Whether the accounts value was returned by - * a call to eth_accounts. - */ - private _handleAccountsChanged(accounts: unknown[], isEthAccounts = false): void { - let _accounts = accounts; - - if (!Array.isArray(accounts)) { - this._log.error( - 'MetaMask: Received invalid accounts parameter. Please report this bug.', - accounts, - ); - _accounts = []; - } - - for (const account of accounts) { - if (typeof account !== 'string') { - this._log.error( - 'MetaMask: Received non-string account. Please report this bug.', - accounts, - ); - _accounts = []; - break; - } - } - - // emit accountsChanged if anything about the accounts array has changed - if (!dequal(this._state.accounts, _accounts)) { - - // we should always have the correct accounts even before eth_accounts - // returns - if (isEthAccounts && this._state.accounts !== null) { - this._log.error( - `MetaMask: 'eth_accounts' unexpectedly updated accounts. Please report this bug.`, - _accounts, - ); - } - - this._state.accounts = _accounts as string[]; - - // handle selectedAddress - if (this.selectedAddress !== _accounts[0]) { - this.selectedAddress = _accounts[0] as string || null; - } - - // finally, after all state has been updated, emit the event - if (this._state.initialized) { - this.emit('accountsChanged', _accounts); - } - } - } - - /** - * Upon receipt of a new isUnlocked state, sets relevant public state. - * Calls the accounts changed handler with the received accounts, or an empty - * array. - * - * Does nothing if the received value is equal to the existing value. - * There are no lock/unlock events. - * - * @param opts - Options bag. - * @param opts.accounts - The exposed accounts, if any. - * @param opts.isUnlocked - The latest isUnlocked value. - */ - private _handleUnlockStateChanged({ - accounts, - isUnlocked, - }: { accounts?: string[]; isUnlocked?: boolean} = {}) { - if (typeof isUnlocked !== 'boolean') { - this._log.error('MetaMask: Received invalid isUnlocked parameter. Please report this bug.'); - return; - } - - if (isUnlocked !== this._state.isUnlocked) { - this._state.isUnlocked = isUnlocked; - this._handleAccountsChanged(accounts || []); + this.networkVersion = null; } } /** * Warns of deprecation for the given event, if applicable. */ - private _warnOfDeprecation(eventName: string): void { - if (this._state.sentWarnings.events[eventName as WarningEventName] === false) { + protected _warnOfDeprecation(eventName: string): void { + if (this._sentWarnings?.events[eventName as WarningEventName] === false) { this._log.warn(messages.warnings.events[eventName as WarningEventName]); - this._state.sentWarnings.events[eventName as WarningEventName] = true; + this._sentWarnings.events[eventName as WarningEventName] = true; } } - /** - * Constructor helper. - * Gets experimental _metamask API as Proxy, so that we can warn consumers - * about its experiment nature. - */ - private _getExperimentalApi() { - return new Proxy( - { - - /** - * Determines if MetaMask is unlocked by the user. - * - * @returns Promise resolving to true if MetaMask is currently unlocked - */ - isUnlocked: async () => { - if (!this._state.initialized) { - await new Promise((resolve) => { - this.on('_initialized', () => resolve()); - }); - } - return this._state.isUnlocked; - }, - - /** - * Make a batch RPC request. - */ - requestBatch: async (requests: UnvalidatedJsonRpcRequest[]) => { - if (!Array.isArray(requests)) { - throw ethErrors.rpc.invalidRequest({ - message: 'Batch requests must be made with an array of request objects.', - data: requests, - }); - } - - return new Promise((resolve, reject) => { - this._rpcRequest( - requests, - getRpcPromiseCallback(resolve, reject), - ); - }); - }, - }, - { - get: (obj, prop, ...args) => { - - if (!this._state.sentWarnings.experimentalMethods) { - this._log.warn(messages.warnings.experimentalMethods); - this._state.sentWarnings.experimentalMethods = true; - } - return Reflect.get(obj, prop, ...args); - }, - }, - ); - } - //==================== // Deprecated Methods //==================== @@ -756,9 +222,9 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { * @returns A promise that resolves to an array of addresses. */ enable(): Promise { - if (!this._state.sentWarnings.enable) { + if (!this._sentWarnings.enable) { this._log.warn(messages.warnings.enableDeprecation); - this._state.sentWarnings.enable = true; + this._sentWarnings.enable = true; } return new Promise((resolve, reject) => { @@ -808,9 +274,9 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { send(payload: SendSyncJsonRpcRequest): JsonRpcResponse; send(methodOrPayload: unknown, callbackOrArgs?: unknown): unknown { - if (!this._state.sentWarnings.send) { + if (!this._sentWarnings.send) { this._log.warn(messages.warnings.sendDeprecation); - this._state.sentWarnings.send = true; + this._sentWarnings.send = true; } if ( @@ -845,7 +311,7 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { * * @deprecated */ - private _sendSync(payload: SendSyncJsonRpcRequest) { + protected _sendSync(payload: SendSyncJsonRpcRequest) { let result; switch (payload.method) { @@ -876,19 +342,59 @@ export default class MetaMaskInpageProvider extends SafeEventEmitter { result, }; } -} -function validateLoggerObject(logger: ConsoleLike): void { - if (logger !== console) { - if (typeof logger === 'object') { - const methodKeys = ['log', 'warn', 'error', 'debug', 'info', 'trace']; - for (const key of methodKeys) { - if (typeof logger[key as keyof ConsoleLike] !== 'function') { - throw new Error(messages.errors.invalidLoggerMethod(key)); - } - } - return; - } - throw new Error(messages.errors.invalidLoggerObject()); + /** + * Constructor helper. + * Gets experimental _metamask API as Proxy, so that we can warn consumers + * about its experiment nature. + */ + protected _getExperimentalApi() { + return new Proxy( + { + + /** + * Determines if MetaMask is unlocked by the user. + * + * @returns Promise resolving to true if MetaMask is currently unlocked + */ + isUnlocked: async () => { + if (!this._state.initialized) { + await new Promise((resolve) => { + this.on('_initialized', () => resolve()); + }); + } + return this._state.isUnlocked; + }, + + /** + * Make a batch RPC request. + */ + requestBatch: async (requests: UnvalidatedJsonRpcRequest[]) => { + if (!Array.isArray(requests)) { + throw ethErrors.rpc.invalidRequest({ + message: 'Batch requests must be made with an array of request objects.', + data: requests, + }); + } + + return new Promise((resolve, reject) => { + this._rpcRequest( + requests, + getRpcPromiseCallback(resolve, reject), + ); + }); + }, + }, + { + get: (obj, prop, ...args) => { + + if (!this._sentWarnings.experimentalMethods) { + this._log.warn(messages.warnings.experimentalMethods); + this._sentWarnings.experimentalMethods = true; + } + return Reflect.get(obj, prop, ...args); + }, + }, + ); } } diff --git a/src/index.ts b/src/index.ts index 445e1eb4..6207a1ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ import MetaMaskInpageProvider from './MetaMaskInpageProvider'; -import { initializeProvider, setGlobalProvider } from './initializeProvider'; +import BaseProvider from './BaseProvider'; +import { initializeProvider, setGlobalProvider } from './initializeInpageProvider'; import shimWeb3 from './shimWeb3'; export { initializeProvider, MetaMaskInpageProvider, + BaseProvider, setGlobalProvider, shimWeb3, }; diff --git a/src/initializeProvider.ts b/src/initializeInpageProvider.ts similarity index 100% rename from src/initializeProvider.ts rename to src/initializeInpageProvider.ts diff --git a/src/messages.ts b/src/messages.ts index c718d1a3..d89ff300 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -5,7 +5,6 @@ const messages = { sendSiteMetadata: () => `MetaMask: Failed to send site metadata. This is an internal error, please report this bug.`, unsupportedSync: (method: string) => `MetaMask: The MetaMask Ethereum provider does not support synchronous methods like ${method} without a callback parameter.`, invalidDuplexStream: () => 'Must provide a Node.js-style duplex stream.', - invalidOptions: (maxEventListeners: unknown, shouldSendMetadata: unknown) => `Invalid options. Received: { maxEventListeners: ${maxEventListeners}, shouldSendMetadata: ${shouldSendMetadata} }`, invalidRequestArgs: () => `Expected a single, non-array, object argument.`, invalidRequestMethod: () => `'args.method' must be a non-empty string.`, invalidRequestParams: () => `'args.params' must be an object or array if provided.`, diff --git a/test/BaseProvider.rpc.test.js b/test/BaseProvider.rpc.test.js new file mode 100644 index 00000000..5b816ef6 --- /dev/null +++ b/test/BaseProvider.rpc.test.js @@ -0,0 +1,307 @@ +const { BaseProvider } = require('../dist'); +const { default: messages } = require('../dist/messages'); + +const MockDuplexStream = require('./mocks/DuplexStream'); + +const MOCK_ERROR_MESSAGE = 'Did you specify a mock return value?'; + +function initializeProvider() { + jest.useFakeTimers(); + const mockStream = new MockDuplexStream(); + const provider = new BaseProvider(mockStream); + provider.mockStream = mockStream; + provider.autoRefreshOnNetworkChange = false; + jest.runAllTimers(); + return provider; +} + +describe('BaseProvider: RPC', () => { + + // mocking the underlying stream, and testing the basic functionality of + // .reqest, .sendAsync, and .send + describe('integration', () => { + + let provider; + const mockRpcEngineResponse = jest.fn(); + + const resetRpcEngineResponseMock = () => { + mockRpcEngineResponse.mockClear() + .mockReturnValue([new Error(MOCK_ERROR_MESSAGE), undefined]); + }; + + const setNextRpcEngineResponse = (err = null, res = {}) => { + mockRpcEngineResponse.mockReturnValueOnce([err, res]); + }; + + beforeEach(() => { + resetRpcEngineResponseMock(); + provider = initializeProvider(); + jest.spyOn(provider, '_handleAccountsChanged').mockImplementation(); + jest.spyOn(provider._rpcEngine, 'handle').mockImplementation( + (_payload, cb) => cb(...mockRpcEngineResponse()), + ); + }); + + it('.request returns result on success', async () => { + setNextRpcEngineResponse(null, { result: 42 }); + const result = await provider.request( + { method: 'foo', params: ['bar'] }, + ); + expect(provider._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + + expect(result).toBe(42); + }); + + it('.request throws on error', async () => { + setNextRpcEngineResponse(new Error('foo')); + + await expect( + provider.request({ method: 'foo', params: ['bar'] }), + ).rejects.toThrow('foo'); + + expect(provider._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + }); + + }); + + describe('.request', () => { + + let provider; + const mockRpcRequestResponse = jest.fn(); + + const resetRpcRequestResponseMock = () => { + mockRpcRequestResponse.mockClear() + .mockReturnValue([new Error(MOCK_ERROR_MESSAGE), undefined]); + }; + + const setNextRpcRequestResponse = (err = null, res = {}) => { + mockRpcRequestResponse.mockReturnValueOnce([err, res]); + }; + + beforeEach(() => { + resetRpcRequestResponseMock(); + provider = initializeProvider(); + jest.spyOn(provider, '_rpcRequest').mockImplementation( + (_payload, cb, _isInternal) => cb(...mockRpcRequestResponse()), + ); + }); + + it('returns result on success', async () => { + setNextRpcRequestResponse(null, { result: 42 }); + const result = await provider.request({ method: 'foo', params: ['bar'] }); + + expect(result).toBe(42); + + expect(provider._rpcRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + }); + + it('throws on error', async () => { + setNextRpcRequestResponse(new Error('foo')); + + await expect( + provider.request({ method: 'foo', params: ['bar'] }), + ).rejects.toThrow('foo'); + + expect(provider._rpcRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + }); + + it('throws on non-object args', async () => { + await expect( + () => provider.request(), + ).rejects.toThrow(messages.errors.invalidRequestArgs()); + + await expect( + () => provider.request(null), + ).rejects.toThrow(messages.errors.invalidRequestArgs()); + + await expect( + () => provider.request([]), + ).rejects.toThrow(messages.errors.invalidRequestArgs()); + + await expect( + () => provider.request('foo'), + ).rejects.toThrow(messages.errors.invalidRequestArgs()); + }); + + it('throws on invalid args.method', async () => { + await expect( + () => provider.request({}), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + + await expect( + () => provider.request({ method: null }), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + + await expect( + () => provider.request({ method: 2 }), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + + await expect( + () => provider.request({ method: '' }), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + }); + + it('throws on invalid args.params', async () => { + await expect( + () => provider.request({ method: 'foo', params: null }), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + + await expect( + () => provider.request({ method: 'foo', params: 2 }), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + + await expect( + () => provider.request({ method: 'foo', params: true }), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + + await expect( + () => provider.request({ method: 'foo', params: 'a' }), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + }); + }); + + // this also tests sendAsync, it being effectively an alias for this method + describe('._rpcRequest', () => { + + let provider; + const mockRpcEngineResponse = jest.fn(); + + const resetRpcEngineResponseMock = () => { + mockRpcEngineResponse.mockClear() + .mockReturnValue([new Error(MOCK_ERROR_MESSAGE), undefined]); + }; + + const setNextRpcEngineResponse = (err = null, res = {}) => { + mockRpcEngineResponse.mockReturnValueOnce([err, res]); + }; + + beforeEach(() => { + resetRpcEngineResponseMock(); + provider = initializeProvider(); + jest.spyOn(provider, '_handleAccountsChanged').mockImplementation(); + jest.spyOn(provider._rpcEngine, 'handle').mockImplementation( + (_payload, cb) => cb(...mockRpcEngineResponse()), + ); + }); + + it('returns response object on success', async () => { + setNextRpcEngineResponse(null, { result: 42 }); + await new Promise((done) => { + provider._rpcRequest( + { method: 'foo', params: ['bar'] }, + (err, res) => { + + expect(provider._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + + expect(err).toBeNull(); + expect(res).toStrictEqual({ result: 42 }); + done(); + }, + ); + }); + }); + + it('returns response object on error', async () => { + setNextRpcEngineResponse(new Error('foo'), { error: 'foo' }); + await new Promise((done) => { + provider._rpcRequest( + { method: 'foo', params: ['bar'] }, + (err, res) => { + + expect(provider._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + + expect(err).toStrictEqual(new Error('foo')); + expect(res).toStrictEqual({ error: 'foo' }); + done(); + }, + ); + }); + }); + + it('calls _handleAccountsChanged on request for eth_accounts', async () => { + setNextRpcEngineResponse(null, { result: ['0x1'] }); + await new Promise((done) => { + provider._rpcRequest( + { method: 'eth_accounts' }, + (err, res) => { + + expect(provider._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ method: 'eth_accounts' }), + expect.any(Function), + ); + + expect(provider._handleAccountsChanged) + .toHaveBeenCalledWith(['0x1'], true); + + expect(err).toBeNull(); + expect(res).toStrictEqual({ result: ['0x1'] }); + done(); + }, + ); + }); + }); + + it( + 'calls _handleAccountsChanged with empty array on eth_accounts request returning error', + async () => { + setNextRpcEngineResponse(new Error('foo'), { error: 'foo' }); + await new Promise((done) => { + provider._rpcRequest( + { method: 'eth_accounts' }, + (err, res) => { + + expect(provider._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ method: 'eth_accounts' }), + expect.any(Function), + ); + + expect(provider._handleAccountsChanged) + .toHaveBeenCalledWith([], true); + + expect(err).toStrictEqual(new Error('foo')); + expect(res).toStrictEqual({ error: 'foo' }); + done(); + }, + ); + }); + }, + ); + }); + +}); diff --git a/test/MetaMaskInpageProvider.misc.test.js b/test/MetaMaskInpageProvider.misc.test.js index b587d097..0a3d7638 100644 --- a/test/MetaMaskInpageProvider.misc.test.js +++ b/test/MetaMaskInpageProvider.misc.test.js @@ -56,28 +56,6 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { ).toThrow(messages.errors.invalidDuplexStream()); }); - it('throws if bad options are provided', () => { - const stream = new MockDuplexStream(); - - expect( - () => new MetaMaskInpageProvider(stream, null), - ).toThrow('Cannot read property \'jsonRpcStreamName\' of null'); - - expect( - () => new MetaMaskInpageProvider(stream, { - maxEventListeners: 10, - shouldSendMetadata: 'foo', - }), - ).toThrow(messages.errors.invalidOptions(10, 'foo')); - - expect( - () => new MetaMaskInpageProvider(stream, { - maxEventListeners: 'foo', - shouldSendMetadata: true, - }), - ).toThrow(messages.errors.invalidOptions('foo', true)); - }); - it('accepts valid custom logger', () => { const stream = new MockDuplexStream(); const customLogger = { @@ -96,51 +74,6 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { ).not.toThrow(); }); - it('throws if non-object logger provided', () => { - const stream = new MockDuplexStream(); - - expect( - () => new MetaMaskInpageProvider(stream, { - logger: 'foo', - }), - ).toThrow(messages.errors.invalidLoggerObject()); - }); - - it('throws if provided logger is missing method key', () => { - const stream = new MockDuplexStream(); - const customLogger = { - debug: console.debug, - error: console.error, - info: console.info, - log: console.log, - trace: console.trace, - // warn: console.warn, // missing - }; - - expect( - () => new MetaMaskInpageProvider(stream, { - logger: customLogger, - }), - ).toThrow(messages.errors.invalidLoggerMethod('warn')); - }); - - it('throws if provided logger has invalid method', () => { - const stream = new MockDuplexStream(); - const customLogger = { - debug: console.debug, - error: console.error, - info: console.info, - log: console.log, - trace: console.trace, - warn: 'foo', // not a function - }; - - expect( - () => new MetaMaskInpageProvider(stream, { - logger: customLogger, - }), - ).toThrow(messages.errors.invalidLoggerMethod('warn')); - }); }); describe('isConnected', () => {