diff --git a/MetaMaskConnector.js b/MetaMaskConnector.js index b931c72..7b5b39d 100644 --- a/MetaMaskConnector.js +++ b/MetaMaskConnector.js @@ -10,12 +10,14 @@ class MetaMaskConnector { constructor(options) { this.config = Object.assign({}, { port: DEFAULT_PORT }, options); } + async start() { this._app = express(); this._app.use(express.static(path.resolve(__dirname, 'client'))); this._wss = await this._runServer(); await this._initialize(); } + stop() { return new Promise(resolve => { this._wss.close(() => { @@ -25,6 +27,7 @@ class MetaMaskConnector { }); }); } + _runServer() { return new Promise((resolve, reject) => { this._server = this._app.listen(this.config.port, 'localhost', err => { @@ -33,6 +36,7 @@ class MetaMaskConnector { }); }); } + _initialize() { return new Promise(resolve => { this._wss.on('connection', ws => { @@ -49,9 +53,11 @@ class MetaMaskConnector { }); }); } + ready() { return this._ws && this._ws.readyState === WebSocket.OPEN; } + static handleMessage(msg) { let message; try { @@ -60,8 +66,9 @@ class MetaMaskConnector { throw new Error('Could not parse message from socket. Is it valid JSON?'); } const { action, requestId, payload } = message; - return MetaMaskConnector.handleAction(action, requestId, payload); + return this.handleAction(action, requestId, payload); } + static handleAction(action, requestId, payload) { if (action === 'error') { throw new Error(payload); @@ -72,6 +79,7 @@ class MetaMaskConnector { responsePayload: payload, }; } + send(action, requestId, payload, requiredAction) { return new Promise(resolve => { const onMsg = msg => { @@ -79,8 +87,11 @@ class MetaMaskConnector { responseAction, responseRequestId, responsePayload, - } = MetaMaskConnector.handleMessage(msg.data); - if (requiredAction === responseAction) { + } = this.constructor.handleMessage(msg.data); + if ( + requiredAction === responseAction && + requestId === responseRequestId + ) { this._ws.removeEventListener('message', onMsg); resolve({ requestId: responseRequestId, @@ -93,6 +104,7 @@ class MetaMaskConnector { this._ws.send(msg); }); } + getProvider() { return new RemoteMetaMaskProvider(this); } diff --git a/RemoteMetaMaskProvider.js b/RemoteMetaMaskProvider.js index 2d574cd..e843fa8 100644 --- a/RemoteMetaMaskProvider.js +++ b/RemoteMetaMaskProvider.js @@ -3,9 +3,20 @@ class RemoteMetaMaskProvider { this._connector = connector; this._callbacks = new Map(); } - _getAsyncMethod(method) { - // Sync methods don't work with MetaMask - this.syncMethods = [ + + // Generate a request id to track callbacks from async methods + static generateRequestId() { + const s4 = () => + Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + } + + // Get the associated async method for the given sync method (MetaMask does + // not work with sync methods) + static getAsyncMethod(method) { + const syncMethods = [ 'version_node', 'version_network', 'version_ethereum', @@ -20,13 +31,17 @@ class RemoteMetaMaskProvider { 'eth_accounts', 'eth_blockNumber', ]; - const idx = this.syncMethods.indexOf(method); + + // Translate the defined sync methods + const idx = syncMethods.indexOf(method); if (idx >= 0) { - return this.syncMethods[idx].replace( + return syncMethods[idx].replace( /(.+)_([a-z])(.+)/, (str, p1, p2, p3) => `${p1}_get${p2.toUpperCase()}${p3}`, ); } + + // Translate other sync methods const translateMethod = { net_version: 'version_getNetwork', eth_getLogs: 'eth_filter', @@ -35,60 +50,85 @@ class RemoteMetaMaskProvider { if (Object.prototype.hasOwnProperty.call(translateMethod, method)) { return translateMethod[method]; } + return method; } - _guid() { - this.s4 = () => - Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); - return ` - ${this.s4()} - ${this.s4()} - - - ${this.s4()} - - - ${this.s4()} - - - ${this.s4()} - - - ${this.s4()} - ${this.s4()} - ${this.s4()} - `.replace(/[\n\r]+ */g, ''); + + // When connected to a remote network, the return values for "gasPrice" and + // "value" are strings, so we will need to properly format them for ethers. + // Ideally we would use the big number type from bn.js or bignumber.js but + // ethers does not support any big number type other than it's own. + static formatResult(_result) { + const result = _result; + + // Format "gasPrice" + if (result && typeof result.gasPrice === 'string') { + result.gasPrice = parseInt(result.gasPrice, 10); + } + + // Format "value" + if (result && typeof result.value === 'string') { + result.value = parseInt(result.value, 10); + } + + // Format for "eth_filter" + if (result && result.logIndex) return [result]; + + return result; } + + // Define send method send(_payload, _callback) { if (!this._connector.ready()) { return _callback( - new Error("Can't send. Not connected to a MetaMask socket."), + new Error('Unable to send. Not connected to a MetaMask socket.'), ); } - // Because requests are handled across a WebSocket they need to be - // associated with their callback with an ID which is returned with the - // response. - const requestId = this._guid(); - const payload = _payload; + + // Because requests are handled across a WebSocket, their callbacks need to + // be associated with an ID which is returned with the response. + const requestId = this.constructor.generateRequestId(); + + // Set the callback using the requestId this._callbacks.set(requestId, _callback); - payload.method = this._getAsyncMethod(payload.method); + + // Set the payload to allow reassignment + const payload = _payload; + + // Get the async method (Metamask does not support sync methods) + payload.method = this.constructor.getAsyncMethod(payload.method); + return this._connector .send('execute', requestId, payload, 'executed') .then(({ requestId: responseRequestId, result }) => { + // Exit if a response for this request was already handled + if (!this._callbacks.has(responseRequestId)) return; + + // Get the request callback using the returned request id const requestCallback = this._callbacks.get(responseRequestId); - if (!this._callbacks.has(responseRequestId)) { - return; // A response for this request was already handled - } - this._callbacks.delete(responseRequestId); + + // Throw error if send error if (result && result.error) { requestCallback(new Error(result.error)); } + + // Format result to work with ethers + const formattedResult = this.constructor.formatResult(result); + + // Handle request callback requestCallback(null, { id: payload.id, jsonrpc: '2.0', - result, + result: formattedResult, }); + + // Delete the callback after the request has been handled + this._callbacks.delete(responseRequestId); }) .catch(err => _callback(err)); } + + // Define async send method sendAsync(payload, callback) { this.send(payload, callback); } diff --git a/client/index.css b/client/index.css new file mode 100644 index 0000000..6539c52 --- /dev/null +++ b/client/index.css @@ -0,0 +1,11 @@ +ul { + list-style: none; + margin: 0; + padding: 2rem; +} + +li { + line-height: 1.5; + padding: 1rem; + word-break: break-all; +} diff --git a/client/index.html b/client/index.html index ba0b380..fba8f0b 100644 --- a/client/index.html +++ b/client/index.html @@ -3,6 +3,7 @@ + MetaMask Connector diff --git a/client/index.js b/client/index.js index 8a670ca..9f8c690 100644 --- a/client/index.js +++ b/client/index.js @@ -3,12 +3,13 @@ /* global web3:true */ /* global window:true */ -(async function client(w) { +(async w => { const addLog = msg => { const logEntry = document.createElement('li'); - logEntry.innerText = `${new Date().toString()} - ${msg}`; + logEntry.innerText = `${new Date().toString()}\n${msg}`; document.querySelector('#messages').appendChild(logEntry); }; + const checkUnlocked = () => new Promise((resolve, reject) => { web3.eth.getAccounts((err, accounts) => { @@ -16,6 +17,7 @@ return resolve(accounts && !!accounts[0]); }); }); + const execute = (requestId, method, params) => new Promise((resolve, reject) => { const splitMethod = method.split('_'); @@ -25,7 +27,10 @@ if (err) { return reject(err); } - addLog(`Result from ${requestId} ${method}: ${JSON.stringify(result)}`); + addLog( + `Request ID: ${requestId} + Result from ${method}: ${JSON.stringify(result)}`, + ); return resolve(result); }); try { @@ -34,10 +39,12 @@ reject(e); } }); + async function executeAction(requestId, { method, params }, reply) { let result; addLog( - `Request ${requestId} Calling ${method} with ${JSON.stringify(params)}`, + `Request ID: ${requestId} + Calling ${method}: ${JSON.stringify(params)}`, ); try { result = await execute(requestId, method, params); @@ -48,6 +55,7 @@ } return reply('executed', requestId, result); } + if (!w.web3) { return addLog('MetaMask not found!'); } @@ -71,5 +79,6 @@ } return true; }; + return true; })(window);