diff --git a/src/BaseProvider.ts b/src/BaseProvider.ts index 7399a051..67e8f246 100644 --- a/src/BaseProvider.ts +++ b/src/BaseProvider.ts @@ -53,9 +53,14 @@ export interface BaseProviderState { } /** - * An abstract class implementing the EIP-1193 interface. The class is abstract - * because the internal `_rpcEngine` needs to be wired up with a middleware that - * hands off JSON-RPC requests. + * An abstract class implementing the EIP-1193 interface. Implementers must: + * + * 1. At initialization, push a middleware to the internal `_rpcEngine` that + * hands off requests to the server and receives responses in return. + * 2. At initialization, retrieve initial state and call + * {@link BaseProvider._initialize} **once**. + * 3. Ensure that the provider's state is synchronized with the wallet. + * 4. Ensure that notifications are received and emitted as appropriate. */ export default abstract class BaseProvider extends SafeEventEmitter { protected readonly _log: ConsoleLike; @@ -101,16 +106,16 @@ export default abstract class BaseProvider extends SafeEventEmitter { this.setMaxListeners(maxEventListeners); - // private state + // Private state this._state = { ...BaseProvider._defaultState, }; - // public state + // Public state this.selectedAddress = null; this.chainId = null; - // bind functions to prevent consumers from making unbound calls + // 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); @@ -119,20 +124,19 @@ export default abstract class BaseProvider extends SafeEventEmitter { this._rpcRequest = this._rpcRequest.bind(this); this.request = this.request.bind(this); - // setup own event listeners - - // EIP-1193 connect + // EIP-1193 connect, emitted in _initialize this.on('connect', () => { this._state.isConnected = true; }); - // handle RPC requests via dapp-side rpc engine + // Handle RPC requests via dapp-side RPC engine. + // + // ATTN: Implementers must push middleware that hands off requests to + // the server. const rpcEngine = new JsonRpcEngine(); rpcEngine.push(createIdRemapMiddleware()); rpcEngine.push(createErrorMiddleware(this._log)); this._rpcEngine = rpcEngine; - - this._initializeState(); } //==================== @@ -197,37 +201,37 @@ export default abstract class BaseProvider extends SafeEventEmitter { //==================== /** - * Constructor helper. - * Populates initial state by calling 'metamask_getProviderState' and emits - * necessary events. + * Sets initial state if provided and marks this provider as initialized. + * Throws if called more than once. + * + * @param initialState - The provider's initial state. + * @emits BaseProvider#_initialized */ - private async _initializeState() { - try { - const { accounts, chainId, isUnlocked, networkVersion } = - (await this.request({ - method: 'metamask_getProviderState', - })) as { - accounts: string[]; - chainId: string; - isUnlocked: boolean; - networkVersion: string; - }; + protected _initialize(initialState?: { + accounts: string[]; + chainId: string; + isUnlocked: boolean; + networkVersion: string; + }) { + if (this._state.initialized === true) { + throw new Error('Provider already initialized.'); + } - // indicate that we've connected, for EIP-1193 compliance + if (initialState) { + const { accounts, chainId, isUnlocked, networkVersion } = initialState; + + // EIP-1193 connect 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'); } + + // Mark provider as initialized regardless of whether initial state was + // retrieved. + this._state.initialized = true; + this.emit('_initialized'); } /** diff --git a/src/StreamProvider.ts b/src/StreamProvider.ts index ee6a290a..0ab36248 100644 --- a/src/StreamProvider.ts +++ b/src/StreamProvider.ts @@ -1,5 +1,4 @@ import type { Duplex } from 'stream'; -import type { EventEmitter } from 'events'; import ObjectMultiplex from '@metamask/object-multiplex'; import SafeEventEmitter from '@metamask/safe-event-emitter'; import { duplex as isDuplex } from 'is-stream'; @@ -7,7 +6,7 @@ import type { JsonRpcMiddleware } from 'json-rpc-engine'; import { createStreamMiddleware } from 'json-rpc-middleware-stream'; import pump from 'pump'; import messages from './messages'; -import { ConsoleLike, EMITTED_NOTIFICATIONS } from './utils'; +import { EMITTED_NOTIFICATIONS } from './utils'; import BaseProvider, { BaseProviderOptions } from './BaseProvider'; export interface StreamProviderOptions extends BaseProviderOptions { @@ -56,7 +55,7 @@ export default class StreamProvider extends BaseProvider { // Bind functions to prevent consumers from making unbound calls this._handleStreamDisconnect = this._handleStreamDisconnect.bind(this); - // setup connectionStream multiplexing + // Set up connectionStream multiplexing const mux = new ObjectMultiplex(); pump( connectionStream, @@ -65,8 +64,7 @@ export default class StreamProvider extends BaseProvider { this._handleStreamDisconnect.bind(this, 'MetaMask'), ); - // setup RPC connection - + // Set up RPC connection this._jsonRpcConnection = createStreamMiddleware(); pump( this._jsonRpcConnection.stream, @@ -78,7 +76,10 @@ export default class StreamProvider extends BaseProvider { // Wire up the JsonRpcEngine to the JSON-RPC connection stream this._rpcEngine.push(this._jsonRpcConnection.middleware); - // handle JSON-RPC notifications + // Set initial state + this._initializeAsync(); + + // Handle JSON-RPC notifications this._jsonRpcConnection.events.on('notification', (payload) => { const { method, params } = payload; if (method === 'metamask_accountsChanged') { @@ -105,37 +106,43 @@ export default class StreamProvider extends BaseProvider { //==================== /** - * Called when connection is lost to critical streams. + * Constructor helper. Calls `metamask_getProviderState` and passes the result + * to {@link BaseProvider._initialize}. Logs an error if getting initial state + * fails. + */ + private async _initializeAsync() { + let initialState: Parameters[0]; + + try { + initialState = (await this.request({ + method: 'metamask_getProviderState', + })) as Parameters[0]; + } catch (error) { + this._log.error( + 'MetaMask: Failed to get initial state. Please report this bug.', + error, + ); + } + this._initialize(initialState); + } + + /** + * Called when connection is lost to critical streams. Emits an 'error' event + * from the provider with the error message and stack if present. * * @emits MetamaskInpageProvider#disconnect */ - protected _handleStreamDisconnect(streamName: string, error: Error) { - logStreamDisconnectWarning(this._log, streamName, error, this); - this._handleDisconnect(false, error ? error.message : undefined); - } -} + private _handleStreamDisconnect(streamName: string, error: Error) { + let warningMsg = `MetaMask: Lost connection to "${streamName}".`; + if (error?.stack) { + warningMsg += `\n${error.stack}`; + } -/** - * Logs a stream disconnection error. Emits an 'error' if given an - * EventEmitter that has listeners for the 'error' event. - * - * @param log - The logging API to use. - * @param remoteLabel - The label of the disconnected stream. - * @param error - The associated error to log. - * @param emitter - The logging API to use. - */ -function logStreamDisconnectWarning( - log: ConsoleLike, - remoteLabel: string, - error: Error, - emitter: EventEmitter, -): void { - let warningMsg = `MetaMask: Lost connection to "${remoteLabel}".`; - if (error?.stack) { - warningMsg += `\n${error.stack}`; - } - log.warn(warningMsg); - if (emitter && emitter.listenerCount('error') > 0) { - emitter.emit('error', warningMsg); + this._log.warn(warningMsg); + if (this.listenerCount('error') > 0) { + this.emit('error', warningMsg); + } + + this._handleDisconnect(false, error ? error.message : undefined); } }