UNPKG

@alayanetwork/inpage-provider

Version:

A JavaScript PlatON provider that connects over a WebExtension port.

684 lines (583 loc) 19.9 kB
const pump = require('pump') const RpcEngine = require('json-rpc-engine') const createIdRemapMiddleware = require('json-rpc-engine/src/idRemapMiddleware') const createJsonRpcStream = require('json-rpc-middleware-stream') const ObservableStore = require('obs-store') const asStream = require('obs-store/lib/asStream') const ObjectMultiplex = require('obj-multiplex') const SafeEventEmitter = require('safe-event-emitter') const dequal = require('fast-deep-equal') const { ethErrors } = require('eth-json-rpc-errors') const log = require('loglevel') const eutil = require('@alayanetwork/ethereumjs-util') const messages = require('./messages') const { sendSiteMetadata } = require('./siteMetadata') const { createErrorMiddleware, EMITTED_NOTIFICATIONS, getRpcPromiseCallback, logStreamDisconnectWarning, NOOP, } = require('./utils') module.exports = class MetamaskInpageProvider extends SafeEventEmitter { /** * @param {Object} connectionStream - A Node.js stream * @param {Object} opts - An options bag * @param {number} opts.maxEventListeners - The maximum number of event listeners * @param {boolean} opts.shouldSendMetadata - Whether the provider should send page metadata */ constructor ( connectionStream, { shouldSendMetadata = true, maxEventListeners = 100 } = {}, ) { if ( typeof shouldSendMetadata !== 'boolean' || typeof maxEventListeners !== 'number' ) { throw new Error('Invalid options.') } super() this.isMetaMask = true this.setMaxListeners(maxEventListeners) // private state this._state = { sentWarnings: { // methods enable: false, experimentalMethods: false, send: false, // events events: { chainIdChanged: false, close: false, data: false, networkChanged: false, notification: false, }, // misc // TODO:deprecation:remove autoRefresh: false, publicConfigStore: false, }, isConnected: undefined, accounts: undefined, isUnlocked: undefined, } this._metamask = this._getExperimentalApi() // public state this.selectedAddress = null this.networkVersion = undefined this.chainId = undefined this.hrp = undefined // bind functions (to prevent e.g. web3@1.x from making unbound calls) this._handleAccountsChanged = this._handleAccountsChanged.bind(this) this._handleDisconnect = this._handleDisconnect.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) // setup connectionStream multiplexing const mux = new ObjectMultiplex() pump( connectionStream, mux, connectionStream, this._handleDisconnect.bind(this, 'Alaya-MetaMask'), ) // subscribe to metamask public config (one-way) this._publicConfigStore = new ObservableStore({ storageKey: 'Alaya-MetaMask-Config' }) // handle isUnlocked changes, and chainChanged and networkChanged events this._publicConfigStore.subscribe((state) => { if ('isUnlocked' in state && state.isUnlocked !== this._state.isUnlocked) { this._state.isUnlocked = state.isUnlocked if (this._state.isUnlocked) { // this will get the exposed accounts, if any try { this._rpcRequest( { method: 'platon_accounts', params: [] }, NOOP, true, // indicating that platon_accounts _should_ update accounts ) } catch (_) { /* no-op */ } } else { // accounts are never exposed when the extension is locked this._handleAccountsChanged([]) } } // Emit chainChanged event on chain change if ('chainId' in state && state.chainId !== this.chainId) { this.chainId = state.chainId this.emit('chainChanged', this.chainId) this.emit('chainIdChanged', this.chainId) // TODO:deprecation:remove } // Emit hrpChanged event on hrp change if ('hrp' in state && state.hrp !== this.hrp) { this.hrp = state.hrp if (this.selectedAddress && !this.selectedAddress.startsWith(this.hrp)) { this.selectedAddress = eutil.toBech32Address(this.hrp, eutil.decodeBech32Address(this.selectedAddress)) } this.emit('hrpChanged', this.hrp) this.emit('hrpChanged', this.hrp) // TODO:deprecation:remove } // Emit networkChanged event on network change if ('networkVersion' in state && state.networkVersion !== this.networkVersion) { this.networkVersion = state.networkVersion this.emit('networkChanged', this.networkVersion) } }) pump( mux.createStream('publicConfiga'), asStream(this._publicConfigStore), // RPC requests should still work if only this stream fails logStreamDisconnectWarning.bind(this, 'Alaya-MetaMask PublicConfigStore'), ) // ignore phishing warning message (handled elsewhere) mux.ignoreStream('phishinga') // setup own event listeners // EIP-1193 connect this.on('connect', () => { this._state.isConnected = true }) // setup RPC connection const jsonRpcConnection = createJsonRpcStream() pump( jsonRpcConnection.stream, mux.createStream('providera'), jsonRpcConnection.stream, this._handleDisconnect.bind(this, 'Alaya-MetaMask RpcProvider'), ) // handle RPC requests via dapp-side rpc engine const rpcEngine = new RpcEngine() 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, result } = payload if (method === 'wallet_accountsChanged') { this._handleAccountsChanged(result) return } if (EMITTED_NOTIFICATIONS.includes(method)) { this.emit('data', payload) // deprecated this.emit('message', { type: method, data: params, }) // deprecated this.emit('notification', params.result) } }) // miscellanea // send website metadata if (shouldSendMetadata) { const domContentLoadedHandler = () => { sendSiteMetadata(this._rpcEngine) window.removeEventListener('DOMContentLoaded', domContentLoadedHandler) } window.addEventListener('DOMContentLoaded', domContentLoadedHandler) } // indicate that we've connected, for EIP-1193 compliance setTimeout(() => this.emit('connect', { chainId: this.chainId })) // TODO:deprecation:remove this._web3Ref = undefined // TODO:deprecation:remove // if true, MetaMask reloads the page if window.web3 has been accessed this.autoRefreshOnNetworkChange = true // TODO:deprecation:remove // wait a second to attempt to send this, so that the warning can be silenced setTimeout(() => { if (this.autoRefreshOnNetworkChange && !this._state.sentWarnings.autoRefresh) { log.warn(messages.warnings.autoRefreshDeprecation) this._state.sentWarnings.autoRefresh = true } }, 1000) } get publicConfigStore () { if (!this._state.sentWarnings.publicConfigStore) { log.warn(messages.warnings.publicConfigStore) this._state.sentWarnings.publicConfigStore = true } return this._publicConfigStore } //==================== // Public Methods //==================== /** * Returns whether the inpage provider is connected to MetaMask. */ isConnected () { return this._state.isConnected } /** * Submits an RPC request to MetaMask for the given method, with the given params. * Resolves with the result of the method call, or rejects on error. * * @param {Object} args - The RPC request arguments. * @param {string} args.method - The RPC method name. * @param {unknown[] | Object} [args.params] - The parameters for the RPC method. * @returns {Promise<unknown>} A Promise that resolves with the result of the RPC method, * or rejects if an error is encountered. */ async request (args) { if (!args || typeof args !== 'object' || Array.isArray(args)) { throw ethErrors.rpc.invalidRequest({ message: `Expected a single, non-array, object argument.`, data: args, }) } const { method, params } = args if (typeof method !== 'string' || method.length === 0) { throw ethErrors.rpc.invalidRequest({ message: `'args.method' must be a non-empty string.`, data: args, }) } if ( params !== undefined && !Array.isArray(params) && (typeof params !== 'object' || params === null) ) { throw ethErrors.rpc.invalidRequest({ message: `'args.params' must be an object or array if provided.`, data: args, }) } return new Promise((resolve, reject) => { this._rpcRequest( { method, params }, getRpcPromiseCallback(resolve, reject), ) }) } /** * Submit a JSON-RPC request object and a callback to make an RPC method call. * * @param {Object} payload - The RPC request object. * @param {Function} callback - The callback function. */ sendAsync (payload, cb) { this._rpcRequest(payload, cb) } /** * We override the following event methods so that we can warn consumers * about deprecated events: * addListener, on, once, prependListener, prependOnceListener */ /** * @inheritdoc */ addListener (eventName, listener) { this._warnOfDeprecation(eventName) return super.addListener(eventName, listener) } /** * @inheritdoc */ on (eventName, listener) { this._warnOfDeprecation(eventName) return super.on(eventName, listener) } /** * @inheritdoc */ once (eventName, listener) { this._warnOfDeprecation(eventName) return super.once(eventName, listener) } /** * @inheritdoc */ prependListener (eventName, listener) { this._warnOfDeprecation(eventName) return super.prependListener(eventName, listener) } /** * @inheritdoc */ prependOnceListener (eventName, listener) { this._warnOfDeprecation(eventName) return super.prependOnceListener(eventName, listener) } //==================== // Private Methods //==================== /** * Internal RPC method. Forwards requests to background via the RPC engine. * Also remap ids inbound and outbound. * * @param {Object} payload - The RPC request object. * @param {Function} callback - The consumer's callback. * @param {boolean} isInternal - Whether the request is internal. */ _rpcRequest (payload, callback, isInternal = false) { let cb = callback if (!Array.isArray(payload)) { if (!payload.jsonrpc) { payload.jsonrpc = '2.0' } if ( payload.method === 'platon_accounts' || payload.method === 'platon_requestAccounts' ) { // handle accounts changing cb = (err, res) => { this._handleAccountsChanged( res.result || [], payload.method === 'platon_accounts', isInternal, ) callback(err, res) } } } this._rpcEngine.handle(payload, cb) } /** * Called when connection is lost to critical streams. */ _handleDisconnect (streamName, err) { logStreamDisconnectWarning.bind(this)(streamName, err) const disconnectError = { code: 1011, reason: messages.errors.disconnected(), } if (this._state.isConnected) { this.emit('disconnect', disconnectError) this.emit('close', disconnectError) // deprecated } this._state.isConnected = false } /** * 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 {string[]} accounts - The new accounts value. * @param {boolean} isEthAccounts - Whether the accounts value was returned by * a call to platon_accounts. * @param {boolean} isInternal - Whether the accounts value was returned by an * internally initiated request. */ _handleAccountsChanged (accounts, isEthAccounts = false, isInternal = false) { accounts = accounts.map((account) => { if (account && !account.startsWith(this.hrp)) { account = eutil.toBech32Address(this.hrp, eutil.decodeBech32Address(account)) return account } return account }) let _accounts = accounts if (!Array.isArray(accounts)) { log.error( 'Alaya-MetaMask: Received non-array accounts parameter. Please report this bug.', accounts, ) _accounts = [] } // 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 platon_accounts // returns, except in cases where isInternal is true if (isEthAccounts && this._state.accounts !== undefined && !isInternal) { log.error( `MetaMask: 'platon_accounts' unexpectedly updated accounts. Please report this bug.`, _accounts, ) } this._state.accounts = _accounts // handle selectedAddress if (this.selectedAddress !== _accounts[0]) { this.selectedAddress = _accounts[0] || null } // TODO:deprecation:remove // handle web3 if (this._web3Ref) { this._web3Ref.defaultAccount = this.selectedAddress } else if ( window.web3 && window.web3.eth && typeof window.web3.eth === 'object' ) { window.web3.eth.defaultAccount = this.selectedAddress } // only emit the event once all state has been updated this.emit('accountsChanged', _accounts) } } /** * Warns of deprecation for the given event, if applicable. */ _warnOfDeprecation (eventName) { if (this._state.sentWarnings.events[eventName] === false) { console.warn(messages.warnings.events[eventName]) this._state.sentWarnings.events[eventName] = true } } /** * Constructor helper. * Gets experimental _metamask API as Proxy, so that we can warn consumers * about its experiment nature. */ _getExperimentalApi () { return new Proxy( { /** * Determines if MetaMask is unlocked by the user. * * @returns {Promise<boolean>} - Promise resolving to true if MetaMask is currently unlocked */ isUnlocked: async () => { if (this._state.isUnlocked === undefined) { await new Promise( (resolve) => this._publicConfigStore.once('update', () => resolve()), ) } return this._state.isUnlocked }, /** * Make a batch RPC request. */ requestBatch: async (requests) => { 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), ) }) }, // TODO:deprecation:remove isEnabled, isApproved /** * DEPRECATED. To be removed. * Synchronously determines if this domain is currently enabled, with a potential false negative if called to soon * * @returns {boolean} - returns true if this domain is currently enabled */ isEnabled: () => { return Array.isArray(this._state.accounts) && this._state.accounts.length > 0 }, /** * DEPRECATED. To be removed. * Asynchronously determines if this domain is currently enabled * * @returns {Promise<boolean>} - Promise resolving to true if this domain is currently enabled */ isApproved: async () => { if (this._state.accounts === undefined) { await new Promise( (resolve) => this.once('accountsChanged', () => resolve()), ) } return Array.isArray(this._state.accounts) && this._state.accounts.length > 0 }, }, { get: (obj, prop) => { if (!this._state.sentWarnings.experimentalMethods) { log.warn(messages.warnings.experimentalMethods) this._state.sentWarnings.experimentalMethods = true } return obj[prop] }, }, ) } //==================== // Deprecated Methods //==================== /** * DEPRECATED. * Equivalent to: ethereum.request('platon_requestAccounts') * * @returns {Promise<Array<string>>} - A promise that resolves to an array of addresses. */ enable () { if (!this._state.sentWarnings.enable) { log.warn(messages.warnings.enableDeprecation) this._state.sentWarnings.enable = true } return new Promise((resolve, reject) => { try { this._rpcRequest( { method: 'platon_requestAccounts', params: [] }, getRpcPromiseCallback(resolve, reject), ) } catch (error) { reject(error) } }) } /** * DEPRECATED. * Sends an RPC request to MetaMask. * Many different return types, which is why this method should not be used. * * @param {(string | Object)} methodOrPayload - The method name, or the RPC request object. * @param {Array<any> | Function} [callbackOrArgs] - If given a method name, the method's parameters. * @returns {unknown} - The method result, or a JSON RPC response object. */ send (methodOrPayload, callbackOrArgs) { if (!this._state.sentWarnings.send) { log.warn(messages.warnings.sendDeprecation) this._state.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) } }) } else if ( typeof methodOrPayload === 'object' && typeof callbackOrArgs === 'function' ) { return this._rpcRequest(methodOrPayload, callbackOrArgs) } return this._sendSync(methodOrPayload) } /** * DEPRECATED. * Internal backwards compatibility method, used in send. */ _sendSync (payload) { let result switch (payload.method) { case 'platon_accounts': result = this.selectedAddress ? [this.selectedAddress] : [] break case 'platon_coinbase': result = this.selectedAddress || null break case 'platon_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, } } }