UNPKG

@upbond/upbond-embed

Version:
654 lines (574 loc) 22 kB
import { ObservableStore, storeAsStream } from "@metamask/obs-store"; import { createIdRemapMiddleware, createStreamMiddleware, JRPCEngine, JRPCRequest, JRPCResponse, JRPCSuccess, ObjectMultiplex, SafeEventEmitter, } from "@toruslabs/openlogin-jrpc"; import { EthereumRpcError, ethErrors } from "eth-rpc-errors"; import dequal from "fast-deep-equal"; import { duplex as isDuplexStream } from "is-stream"; import pump from "pump"; import type { Duplex } from "readable-stream"; import { BaseProviderState, JsonRpcConnection, Maybe, ProviderOptions, PublicConfigState, RequestArguments, SendSyncJsonRpcRequest, SentWarningsState, UnvalidatedJsonRpcRequest, WalletProviderState, } from "./interfaces"; import log from "./loglevel"; import messages from "./messages"; import { createErrorMiddleware, EMITTED_NOTIFICATIONS, logStreamDisconnectWarning, NOOP } from "./utils"; SafeEventEmitter.defaultMaxListeners = 100; // resolve response.result, reject errors const getRpcPromiseCallback = (resolve, reject, unwrapResult = true) => (error, response) => { if (error || response.error) { return reject(error || response.error); } return !unwrapResult || Array.isArray(response) ? resolve(response) : resolve(response.result); }; class UpbondInpageProvider extends SafeEventEmitter { protected static _defaultState: BaseProviderState = { accounts: null, isConnected: false, isUnlocked: false, initialized: false, isPermanentlyDisconnected: false, hasEmittedConnection: 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; _rpcEngine: JRPCEngine; public networkVersion: string | null; shouldSendMetadata: boolean; /** * Indicating that this provider is a MetaMask provider. */ public readonly isUpbond: true; _publicConfigStore: ObservableStore<PublicConfigState>; tryPreopenHandle: (payload: UnvalidatedJsonRpcRequest | UnvalidatedJsonRpcRequest[], cb: (...args: unknown[]) => void) => void; enable: () => Promise<string[]>; protected _state: BaseProviderState; protected _jsonRpcConnection: JsonRpcConnection; protected _sentWarnings: SentWarningsState = { // methods enable: false, experimentalMethods: false, send: false, publicConfigStore: false, // events events: { close: false, data: false, networkChanged: false, notification: false, }, }; constructor( connectionStream: Duplex, { maxEventListeners = 100, shouldSendMetadata = true, jsonRpcStreamName = "provider" }: ProviderOptions = {} ) { super(); if (!isDuplexStream(connectionStream)) { throw new Error(messages.errors.invalidDuplexStream()); } this.isUpbond = true; this.setMaxListeners(maxEventListeners); // private state this._state = { ...UpbondInpageProvider._defaultState, }; // public state this.selectedAddress = null; this.networkVersion = null; this.chainId = null; this.shouldSendMetadata = shouldSendMetadata; // bind functions (to prevent e.g. web3@1.x from making unbound calls) this._handleAccountsChanged = this._handleAccountsChanged.bind(this); this._handleChainChanged = this._handleChainChanged.bind(this); this._handleUnlockStateChanged = this._handleUnlockStateChanged.bind(this); this._handleConnect = this._handleConnect.bind(this); this._handleDisconnect = this._handleDisconnect.bind(this); this._handleStreamDisconnect = this._handleStreamDisconnect.bind(this); this._sendSync = this._sendSync.bind(this); this._rpcRequest = this._rpcRequest.bind(this); this._warnOfDeprecation = this._warnOfDeprecation.bind(this); this._initializeState = this._initializeState.bind(this); this.request = this.request.bind(this); this.send = this.send.bind(this); this.sendAsync = this.sendAsync.bind(this); // this.enable = this.enable.bind(this); // setup connectionStream multiplexing const mux = new ObjectMultiplex(); pump(connectionStream, mux, connectionStream, this._handleStreamDisconnect.bind(this, "MetaMask")); // subscribe to metamask public config (one-way) this._publicConfigStore = new ObservableStore({ storageKey: "Metamask-Config" }); // handle isUnlocked changes, and chainChanged and networkChanged events // this._publicConfigStore.subscribe((stringifiedState) => { // // This is because we are using store as string // const state = JSON.parse(stringifiedState as unknown as string); // if ("isUnlocked" in state && state.isUnlocked !== this._state.isUnlocked) { // this._state.isUnlocked = state.isUnlocked; // if (!this._state.isUnlocked) { // // accounts are never exposed when the extension is locked // this._handleAccountsChanged([]); // } else { // // this will get the exposed accounts, if any // try { // this._rpcRequest( // { method: "eth_accounts", params: [] }, // NOOP, // true // indicating that eth_accounts _should_ update accounts // ); // } catch (_) { // // Swallow error // } // } // } // if ("selectedAddress" in state && this.selectedAddress !== state.selectedAddress) { // try { // this._rpcRequest( // { method: "eth_accounts", params: [] }, // NOOP, // true // indicating that eth_accounts _should_ update accounts // ); // } catch (_) { // // Swallow error // } // } // // Emit chainChanged event on chain change // if ("chainId" in state && state.chainId !== this.chainId) { // this.chainId = state.chainId || null; // this.emit("chainChanged", this.chainId); // // indicate that we've connected, for EIP-1193 compliance // // we do this here so that iframe can initialize // if (!this._state.hasEmittedConnection) { // this._handleConnect(this.chainId); // this._state.hasEmittedConnection = true; // } // } pump( mux.createStream("publicConfig") as unknown as Duplex, storeAsStream(this._publicConfigStore), // RPC requests should still work if only this stream fails logStreamDisconnectWarning.bind(this, "MetaMask PublicConfigStore") ); // ignore phishing warning message (handled elsewhere) mux.ignoreStream("phishing"); // setup own event listeners // EIP-1193 connect this.on("connect", () => { this._state.isConnected = true; }); // connect to async provider 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 JRPCEngine(); rpcEngine.push(createIdRemapMiddleware()); rpcEngine.push(createErrorMiddleware()); rpcEngine.push(jsonRpcConnection.middleware); this._rpcEngine = rpcEngine; // json rpc notification listener jsonRpcConnection.events.on("notification", (payload) => { const { method, params } = payload; if (method === "wallet_accountsChanged") { this._handleAccountsChanged(params); } else if (method === "wallet_unlockStateChanged") { this._handleUnlockStateChanged(params); } else if (method === "wallet_chainChanged") { this._handleChainChanged(params); } else if (EMITTED_NOTIFICATIONS.includes(payload.method)) { // EIP 1193 subscriptions, per eth-json-rpc-filters/subscriptionManager this.emit("data", payload); // deprecated this.emit("notification", params.result); this.emit("message", { type: method, data: params, }); } // Backward compatibility for older non EIP 1193 subscriptions // this.emit('data', null, payload) }); } get publicConfigStore(): ObservableStore<PublicConfigState> { if (!this._sentWarnings.publicConfigStore) { log.warn(messages.warnings.publicConfigStore); this._sentWarnings.publicConfigStore = true; } return this._publicConfigStore; } /** * Returns whether the inpage provider is connected to Torus. */ 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. * @returns A Promise that resolves with the result of the RPC method, * or rejects if an error is encountered. */ async request<T>(args: RequestArguments): Promise<Maybe<T>> { 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. * * @param payload - The RPC request object. * @param cb - The callback function. */ sendAsync(payload: JRPCRequest<unknown>, callback: (error: Error | null, result?: JRPCResponse<unknown>) => void): void { this._rpcRequest(payload, callback); } /** * We override the following event methods so that we can warn consumers * about deprecated events: * addListener, on, once, prependListener, prependOnceListener */ addListener(eventName: string, listener: (...args: unknown[]) => void): this { this._warnOfDeprecation(eventName); return super.addListener(eventName, listener); } on(eventName: string, listener: (...args: unknown[]) => void): this { this._warnOfDeprecation(eventName); return super.on(eventName, listener); } once(eventName: string, listener: (...args: unknown[]) => void): this { this._warnOfDeprecation(eventName); return super.once(eventName, listener); } prependListener(eventName: string, listener: (...args: unknown[]) => void): this { this._warnOfDeprecation(eventName); return super.prependListener(eventName, listener); } prependOnceListener(eventName: string, listener: (...args: unknown[]) => void): this { this._warnOfDeprecation(eventName); return super.prependOnceListener(eventName, listener); } // Private Methods //= =================== /** * Constructor helper. * Populates initial state by calling 'wallet_getProviderState' and emits * necessary events. */ async _initializeState(): Promise<void> { try { const { accounts, chainId, isUnlocked, networkVersion } = (await this.request({ method: "wallet_getProviderState", })) as WalletProviderState; // 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) { log.error("MetaMask: Failed to get initial state. Please report this bug.", error); throw new Error(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. * ::dwiyan:: * @param payload - The RPC request object. * @param callback - The consumer's callback. * @param isInternal - false - Whether the request is internal. */ _rpcRequest(payload: UnvalidatedJsonRpcRequest | UnvalidatedJsonRpcRequest[], callback: (...args: any[]) => void, isInternal = false): void { let cb = callback; const _payload = payload; 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: JRPCSuccess<string[]>) => { this._handleAccountsChanged(res.result || [], _payload.method === "eth_accounts", isInternal); callback(err, res); }; } else if (_payload.method === "wallet_getProviderState") { this._rpcEngine.handle(payload as JRPCRequest<unknown>, cb); return; } } this.tryPreopenHandle(_payload, cb); } /** * Submits an RPC request for the given method, with the given params. * * @deprecated Use "request" instead. * @param method - The method to request. * @param params - Any params for the method. * @returns A Promise that resolves with the JSON-RPC response object for the * request. */ send<T>(method: string, params?: T[]): Promise<JRPCResponse<T>>; /** * Submits an RPC request per the given JSON-RPC request object. * * @deprecated Use "request" instead. * @param payload - A JSON-RPC request object. * @param callback - An error-first callback that will receive the JSON-RPC * response object. */ send<T>(payload: JRPCRequest<unknown>, callback: (error: Error | null, result?: JRPCResponse<T>) => void): void; /** * Accepts a JSON-RPC request object, and synchronously returns the cached result * for the given method. Only supports 4 specific RPC methods. * * @deprecated Use "request" instead. * @param payload - A JSON-RPC request object. * @returns A JSON-RPC response object. */ send<T>(payload: SendSyncJsonRpcRequest): JRPCResponse<T>; send(methodOrPayload: unknown, callbackOrArgs?: unknown): unknown { if (!this._sentWarnings.send) { log.warn(messages.warnings.sendDeprecation); this._sentWarnings.send = true; } if (typeof methodOrPayload === "string" && (!callbackOrArgs || Array.isArray(callbackOrArgs))) { return new Promise((resolve, reject) => { try { this._rpcRequest({ method: methodOrPayload, params: callbackOrArgs }, getRpcPromiseCallback(resolve, reject, false)); } catch (error) { reject(error); } }); } if (methodOrPayload && typeof methodOrPayload === "object" && typeof callbackOrArgs === "function") { return this._rpcRequest(methodOrPayload as JRPCRequest<unknown>, callbackOrArgs as (...args: unknown[]) => void); } return this._sendSync(methodOrPayload as SendSyncJsonRpcRequest); } /** * DEPRECATED. * Internal backwards compatibility method, used in send. */ _sendSync(payload: SendSyncJsonRpcRequest): JRPCSuccess<unknown> { let result; switch (payload.method) { case "eth_accounts": result = this.selectedAddress ? [this.selectedAddress] : []; break; case "eth_coinbase": result = this.selectedAddress || null; break; case "eth_uninstallFilter": this._rpcRequest(payload, NOOP); result = true; break; case "net_version": result = this.networkVersion || null; break; default: throw new Error(messages.errors.unsupportedSync(payload.method)); } return { id: payload.id, jsonrpc: payload.jsonrpc, result, }; } /** * 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): void { if (!this._state.isConnected) { this._state.isConnected = true; this.emit("connect", { 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): void { 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() ); log.debug(error); } else { error = new EthereumRpcError( 1011, // Internal error errorMessage || messages.errors.permanentlyDisconnected() ); 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): void { logStreamDisconnectWarning(streamName, error, this); this._handleDisconnect(false, error ? error.message : undefined); } /** * Called when accounts may have changed. */ protected _handleAccountsChanged(accounts: unknown[], isEthAccounts = false, isInternal = false): void { // defensive programming let finalAccounts = accounts; if (!Array.isArray(finalAccounts)) { log.error("MetaMask: Received non-array accounts parameter. Please report this bug.", finalAccounts); finalAccounts = []; } for (const account of accounts) { if (typeof account !== "string") { log.error("MetaMask: Received non-string account. Please report this bug.", accounts); finalAccounts = []; break; } } // emit accountsChanged if anything about the accounts array has changed if (!dequal(this._state.accounts, finalAccounts)) { // we should always have the correct accounts even before eth_accounts // returns, except in cases where isInternal is true if (isEthAccounts && Array.isArray(this._state.accounts) && this._state.accounts.length > 0 && !isInternal) { log.error('MetaMask: "eth_accounts" unexpectedly updated accounts. Please report this bug.', finalAccounts); } this._state.accounts = finalAccounts as string[]; this.emit("accountsChanged", finalAccounts); } // handle selectedAddress if (this.selectedAddress !== finalAccounts[0]) { this.selectedAddress = (finalAccounts[0] as string) || null; } } /** * 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. */ protected _handleChainChanged({ chainId, networkVersion }: { chainId?: string; networkVersion?: string } = {}): void { if (!chainId || !networkVersion) { 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); } } } } /** * 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. */ protected _handleUnlockStateChanged({ accounts, isUnlocked }: { accounts?: string[]; isUnlocked?: boolean } = {}): void { if (typeof isUnlocked !== "boolean") { log.error("MetaMask: Received invalid isUnlocked parameter. Please report this bug.", { isUnlocked }); return; } if (isUnlocked !== this._state.isUnlocked) { this._state.isUnlocked = isUnlocked; this._handleAccountsChanged(accounts || []); } } /** * Warns of deprecation for the given event, if applicable. */ protected _warnOfDeprecation(eventName: string): void { if (this._sentWarnings.events[eventName] === false) { log.warn(messages.warnings.events[eventName]); this._sentWarnings.events[eventName] = true; } } } export default UpbondInpageProvider;