/* This file is part of web3.js. web3.js is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. web3.js is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with web3.js. If not, see . */ /** * @file index.js * @author Fabian Vogelsteller * @author Marek Kotewicz * @date 2017 */ 'use strict'; var _ = require('underscore'); var errors = require('web3-core-helpers').errors; var formatters = require('web3-core-helpers').formatters; var utils = require('web3-utils'); var promiEvent = require('web3-core-promievent'); var Subscriptions = require('web3-core-subscriptions').subscriptions; var EthersTransactionUtils = require('@ethersproject/transactions'); var Method = function Method(options) { if (!options.call || !options.name) { throw new Error('When creating a method you need to provide at least the "name" and "call" property.'); } this.name = options.name; this.call = options.call; this.params = options.params || 0; this.inputFormatter = options.inputFormatter; this.outputFormatter = options.outputFormatter; this.transformPayload = options.transformPayload; this.extraFormatters = options.extraFormatters; this.abiCoder = options.abiCoder; // Will be used to encode the revert reason string this.requestManager = options.requestManager; // reference to eth.accounts this.accounts = options.accounts; this.defaultBlock = options.defaultBlock || 'latest'; this.defaultAccount = options.defaultAccount || null; this.transactionBlockTimeout = options.transactionBlockTimeout || 50; this.transactionConfirmationBlocks = options.transactionConfirmationBlocks || 24; this.transactionPollingTimeout = options.transactionPollingTimeout || 750; this.defaultCommon = options.defaultCommon; this.defaultChain = options.defaultChain; this.defaultHardfork = options.defaultHardfork; this.handleRevert = options.handleRevert; }; Method.prototype.setRequestManager = function (requestManager, accounts) { this.requestManager = requestManager; // reference to eth.accounts if (accounts) { this.accounts = accounts; } }; Method.prototype.createFunction = function (requestManager, accounts) { var func = this.buildCall(); func.call = this.call; this.setRequestManager(requestManager || this.requestManager, accounts || this.accounts); return func; }; Method.prototype.attachToObject = function (obj) { var func = this.buildCall(); func.call = this.call; var name = this.name.split('.'); if (name.length > 1) { obj[name[0]] = obj[name[0]] || {}; obj[name[0]][name[1]] = func; } else { obj[name[0]] = func; } }; /** * Should be used to determine name of the jsonrpc method based on arguments * * @method getCall * @param {Array} arguments * @return {String} name of jsonrpc method */ Method.prototype.getCall = function (args) { return _.isFunction(this.call) ? this.call(args) : this.call; }; /** * Should be used to extract callback from array of arguments. Modifies input param * * @method extractCallback * @param {Array} arguments * @return {Function|Null} callback, if exists */ Method.prototype.extractCallback = function (args) { if (_.isFunction(args[args.length - 1])) { return args.pop(); // modify the args array! } }; /** * Should be called to check if the number of arguments is correct * * @method validateArgs * @param {Array} arguments * @throws {Error} if it is not */ Method.prototype.validateArgs = function (args) { if (args.length !== this.params) { throw errors.InvalidNumberOfParams(args.length, this.params, this.name); } }; /** * Should be called to format input args of method * * @method formatInput * @param {Array} * @return {Array} */ Method.prototype.formatInput = function (args) { var _this = this; if (!this.inputFormatter) { return args; } return this.inputFormatter.map(function (formatter, index) { // bind this for defaultBlock, and defaultAccount return formatter ? formatter.call(_this, args[index]) : args[index]; }); }; /** * Should be called to format output(result) of method * * @method formatOutput * @param {Object} * @return {Object} */ Method.prototype.formatOutput = function (result) { var _this = this; if (_.isArray(result)) { return result.map(function (res) { return _this.outputFormatter && res ? _this.outputFormatter(res) : res; }); } else { return this.outputFormatter && result ? this.outputFormatter(result) : result; } }; /** * Should create payload from given input args * * @method toPayload * @param {Array} args * @return {Object} */ Method.prototype.toPayload = function (args) { var call = this.getCall(args); var callback = this.extractCallback(args); var params = this.formatInput(args); this.validateArgs(params); var payload = { method: call, params: params, callback: callback }; if (this.transformPayload) { payload = this.transformPayload(payload); } return payload; }; Method.prototype._confirmTransaction = function (defer, result, payload) { var method = this, promiseResolved = false, canUnsubscribe = true, timeoutCount = 0, confirmationCount = 0, intervalId = null, lastBlock = null, receiptJSON = '', gasProvided = (_.isObject(payload.params[0]) && payload.params[0].gas) ? payload.params[0].gas : null, isContractDeployment = _.isObject(payload.params[0]) && payload.params[0].data && payload.params[0].from && !payload.params[0].to, hasBytecode = isContractDeployment && payload.params[0].data.length > 2; // add custom send Methods var _ethereumCalls = [ new Method({ name: 'getBlockByNumber', call: 'eth_getBlockByNumber', params: 2, inputFormatter: [formatters.inputBlockNumberFormatter, function (val) { return !!val; }], outputFormatter: formatters.outputBlockFormatter }), new Method({ name: 'getTransactionReceipt', call: 'eth_getTransactionReceipt', params: 1, inputFormatter: [null], outputFormatter: formatters.outputTransactionReceiptFormatter }), new Method({ name: 'getCode', call: 'eth_getCode', params: 2, inputFormatter: [formatters.inputAddressFormatter, formatters.inputDefaultBlockNumberFormatter] }), new Method({ name: 'getTransactionByHash', call: 'eth_getTransactionByHash', params: 1, inputFormatter: [null], outputFormatter: formatters.outputTransactionFormatter }), new Subscriptions({ name: 'subscribe', type: 'eth', subscriptions: { 'newBlockHeaders': { subscriptionName: 'newHeads', params: 0, outputFormatter: formatters.outputBlockFormatter } } }) ]; // attach methods to this._ethereumCall var _ethereumCall = {}; _.each(_ethereumCalls, function (mthd) { mthd.attachToObject(_ethereumCall); mthd.requestManager = method.requestManager; // assign rather than call setRequestManager() }); // fire "receipt" and confirmation events and resolve after var checkConfirmation = function (existingReceipt, isPolling, err, blockHeader, sub) { if (!err) { // create fake unsubscribe if (!sub) { sub = { unsubscribe: function () { clearInterval(intervalId); } }; } // if we have a valid receipt we don't need to send a request return (existingReceipt ? promiEvent.resolve(existingReceipt) : _ethereumCall.getTransactionReceipt(result)) // catch error from requesting receipt .catch(function (err) { sub.unsubscribe(); promiseResolved = true; utils._fireError({ message: 'Failed to check for transaction receipt:', data: err }, defer.eventEmitter, defer.reject); }) // if CONFIRMATION listener exists check for confirmations, by setting canUnsubscribe = false .then(async function (receipt) { if (!receipt || !receipt.blockHash) { throw new Error('Receipt missing or blockHash null'); } // apply extra formatters if (method.extraFormatters && method.extraFormatters.receiptFormatter) { receipt = method.extraFormatters.receiptFormatter(receipt); } // check if confirmation listener exists if (defer.eventEmitter.listeners('confirmation').length > 0) { var block; // If there was an immediately retrieved receipt, it's already // been confirmed by the direct call to checkConfirmation needed // for parity instant-seal if (existingReceipt === undefined || confirmationCount !== 0) { // Get latest block to emit with confirmation var latestBlock = await _ethereumCall.getBlockByNumber('latest'); var latestBlockHash = latestBlock ? latestBlock.hash : null; if (isPolling) { // Check if actually a new block is existing on polling if (lastBlock) { block = await _ethereumCall.getBlockByNumber(lastBlock.number + 1); if (block) { lastBlock = block; defer.eventEmitter.emit('confirmation', confirmationCount, receipt, latestBlockHash); } } else { block = await _ethereumCall.getBlockByNumber(receipt.blockNumber); lastBlock = block; defer.eventEmitter.emit('confirmation', confirmationCount, receipt, latestBlockHash); } } else { defer.eventEmitter.emit('confirmation', confirmationCount, receipt, latestBlockHash); } } if ((isPolling && block) || !isPolling) { confirmationCount++; } canUnsubscribe = false; if (confirmationCount === method.transactionConfirmationBlocks + 1) { // add 1 so we account for conf 0 sub.unsubscribe(); defer.eventEmitter.removeAllListeners(); } } return receipt; }) // CHECK for CONTRACT DEPLOYMENT .then(async function (receipt) { if (isContractDeployment && !promiseResolved) { if (!receipt.contractAddress) { if (canUnsubscribe) { sub.unsubscribe(); promiseResolved = true; } utils._fireError(errors.NoContractAddressFoundError(receipt), defer.eventEmitter, defer.reject, null, receipt); return; } var code; try { code = await _ethereumCall.getCode(receipt.contractAddress); } catch (err) { // ignore; } if (!code) { return; } // If deployment is status.true and there was a real // bytecode string, assume it was successful. var deploymentSuccess = receipt.status === true && hasBytecode; if (deploymentSuccess || code.length > 2) { defer.eventEmitter.emit('receipt', receipt); // if contract, return instance instead of receipt if (method.extraFormatters && method.extraFormatters.contractDeployFormatter) { defer.resolve(method.extraFormatters.contractDeployFormatter(receipt)); } else { defer.resolve(receipt); } // need to remove listeners, as they aren't removed automatically when succesfull if (canUnsubscribe) { defer.eventEmitter.removeAllListeners(); } } else { utils._fireError(errors.ContractCodeNotStoredError(receipt), defer.eventEmitter, defer.reject, null, receipt); } if (canUnsubscribe) { sub.unsubscribe(); } promiseResolved = true; } return receipt; }) // CHECK for normal tx check for receipt only .then(async function (receipt) { if (!isContractDeployment && !promiseResolved) { if (!receipt.outOfGas && (!gasProvided || gasProvided !== receipt.gasUsed) && (receipt.status === true || receipt.status === '0x1' || typeof receipt.status === 'undefined')) { defer.eventEmitter.emit('receipt', receipt); defer.resolve(receipt); // need to remove listeners, as they aren't removed automatically when succesfull if (canUnsubscribe) { defer.eventEmitter.removeAllListeners(); } } else { receiptJSON = JSON.stringify(receipt, null, 2); if (receipt.status === false || receipt.status === '0x0') { try { var revertMessage = null; if (method.handleRevert && (method.call === 'eth_sendTransaction' || method.call === 'eth_sendRawTransaction')) { var txReplayOptions = payload.params[0]; // If send was raw, fetch the transaction and reconstitute the // original params so they can be replayed with `eth_call` if (method.call === 'eth_sendRawTransaction') { var rawTransactionHex = payload.params[0]; var parsedTx = EthersTransactionUtils.parse(rawTransactionHex); txReplayOptions = formatters.inputTransactionFormatter({ data: parsedTx.data, to: parsedTx.to, from: parsedTx.from, gas: parsedTx.gasLimit.toHexString(), gasPrice: parsedTx.gasPrice.toHexString(), value: parsedTx.value.toHexString() }); } // Get revert reason string with eth_call revertMessage = await method.getRevertReason(txReplayOptions, receipt.blockNumber); if (revertMessage) { // Only throw a revert error if a revert reason is existing utils._fireError(errors.TransactionRevertInstructionError(revertMessage.reason, revertMessage.signature, receipt), defer.eventEmitter, defer.reject, null, receipt); } else { throw false; // Throw false and let the try/catch statement handle the error correctly after } } else { throw false; // Throw false and let the try/catch statement handle the error correctly after } } catch (error) { // Throw an normal revert error if no revert reason is given or the detection of it is disabled utils._fireError(errors.TransactionRevertedWithoutReasonError(receipt), defer.eventEmitter, defer.reject, null, receipt); } } else { // Throw OOG if status is not existing and provided gas and used gas are equal utils._fireError(errors.TransactionOutOfGasError(receipt), defer.eventEmitter, defer.reject, null, receipt); } } if (canUnsubscribe) { sub.unsubscribe(); } promiseResolved = true; } }) // time out the transaction if not mined after 50 blocks .catch(function () { timeoutCount++; // check to see if we are http polling if (!!isPolling) { // polling timeout is different than transactionBlockTimeout blocks since we are triggering every second if (timeoutCount - 1 >= method.transactionPollingTimeout) { sub.unsubscribe(); promiseResolved = true; utils._fireError(errors.TransactionError('Transaction was not mined within ' + method.transactionPollingTimeout + ' seconds, please make sure your transaction was properly sent. Be aware that it might still be mined!'), defer.eventEmitter, defer.reject); } } else { if (timeoutCount - 1 >= method.transactionBlockTimeout) { sub.unsubscribe(); promiseResolved = true; utils._fireError(errors.TransactionError('Transaction was not mined within ' + method.transactionBlockTimeout + ' blocks, please make sure your transaction was properly sent. Be aware that it might still be mined!'), defer.eventEmitter, defer.reject); } } }); } else { sub.unsubscribe(); promiseResolved = true; utils._fireError({ message: 'Failed to subscribe to new newBlockHeaders to confirm the transaction receipts.', data: err }, defer.eventEmitter, defer.reject); } }; // start watching for confirmation depending on the support features of the provider var startWatching = function (existingReceipt) { const startInterval = () => { intervalId = setInterval(checkConfirmation.bind(null, existingReceipt, true), 1000); }; if (!this.requestManager.provider.on) { startInterval(); } else { _ethereumCall.subscribe('newBlockHeaders', function (err, blockHeader, sub) { if (err || !blockHeader) { // fall back to polling startInterval(); } else { checkConfirmation(existingReceipt, false, err, blockHeader, sub); } }); } }.bind(this); // first check if we already have a confirmed transaction _ethereumCall.getTransactionReceipt(result) .then(function (receipt) { if (receipt && receipt.blockHash) { if (defer.eventEmitter.listeners('confirmation').length > 0) { // We must keep on watching for new Blocks, if a confirmation listener is present startWatching(receipt); } checkConfirmation(receipt, false); } else if (!promiseResolved) { startWatching(); } }) .catch(function () { if (!promiseResolved) startWatching(); }); }; var getWallet = function (from, accounts) { var wallet = null; // is index given if (_.isNumber(from)) { wallet = accounts.wallet[from]; // is account given } else if (_.isObject(from) && from.address && from.privateKey) { wallet = from; // search in wallet for address } else { wallet = accounts.wallet[from.toLowerCase()]; } return wallet; }; Method.prototype.buildCall = function () { var method = this, isSendTx = (method.call === 'eth_sendTransaction' || method.call === 'eth_sendRawTransaction'), // || method.call === 'personal_sendTransaction' isCall = (method.call === 'eth_call'); // actual send function var send = function () { var defer = promiEvent(!isSendTx), payload = method.toPayload(Array.prototype.slice.call(arguments)); // CALLBACK function var sendTxCallback = function (err, result) { if (method.handleRevert && isCall && method.abiCoder) { var reasonData; // Ganache / Geth <= 1.9.13 return the reason data as a successful eth_call response // Geth >= 1.9.15 attaches the reason data to an error object. // Geth 1.9.14 is missing revert reason (https://github.com/ethereum/web3.js/issues/3520) if (!err && method.isRevertReasonString(result)) { reasonData = result.substring(10); } else if (err && err.data) { reasonData = err.data.substring(10); } if (reasonData) { var reason = method.abiCoder.decodeParameter('string', '0x' + reasonData); var signature = 'Error(String)'; utils._fireError(errors.RevertInstructionError(reason, signature), defer.eventEmitter, defer.reject, payload.callback, { reason: reason, signature: signature }); return; } } try { result = method.formatOutput(result); } catch (e) { err = e; } if (result instanceof Error) { err = result; } if (!err) { if (payload.callback) { payload.callback(null, result); } } else { if (err.error) { err = err.error; } return utils._fireError(err, defer.eventEmitter, defer.reject, payload.callback); } // return PROMISE if (!isSendTx) { if (!err) { defer.resolve(result); } // return PROMIEVENT } else { defer.eventEmitter.emit('transactionHash', result); method._confirmTransaction(defer, result, payload); } }; // SENDS the SIGNED SIGNATURE var sendSignedTx = function (sign) { var signedPayload = _.extend({}, payload, { method: 'eth_sendRawTransaction', params: [sign.rawTransaction] }); method.requestManager.send(signedPayload, sendTxCallback); }; var sendRequest = function (payload, method) { if (method && method.accounts && method.accounts.wallet && method.accounts.wallet.length) { var wallet; // ETH_SENDTRANSACTION if (payload.method === 'eth_sendTransaction') { var tx = payload.params[0]; wallet = getWallet((_.isObject(tx)) ? tx.from : null, method.accounts); // If wallet was found, sign tx, and send using sendRawTransaction if (wallet && wallet.privateKey) { var txOptions = _.omit(tx, 'from'); if (method.defaultChain && !txOptions.chain) { txOptions.chain = method.defaultChain; } if (method.defaultHardfork && !txOptions.hardfork) { txOptions.hardfork = method.defaultHardfork; } if (method.defaultCommon && !txOptions.common) { txOptions.common = method.defaultCommon; } return method.accounts.signTransaction(txOptions, wallet.privateKey) .then(sendSignedTx) .catch(function (err) { if (_.isFunction(defer.eventEmitter.listeners) && defer.eventEmitter.listeners('error').length) { defer.eventEmitter.emit('error', err); defer.eventEmitter.removeAllListeners(); defer.eventEmitter.catch(function () { }); } defer.reject(err); }); } // ETH_SIGN } else if (payload.method === 'eth_sign') { var data = payload.params[1]; wallet = getWallet(payload.params[0], method.accounts); // If wallet was found, sign tx, and send using sendRawTransaction if (wallet && wallet.privateKey) { var sign = method.accounts.sign(data, wallet.privateKey); if (payload.callback) { payload.callback(null, sign.signature); } defer.resolve(sign.signature); return; } } } return method.requestManager.send(payload, sendTxCallback); }; // Send the actual transaction if (isSendTx && _.isObject(payload.params[0]) && typeof payload.params[0].gasPrice === 'undefined') { var getGasPrice = (new Method({ name: 'getGasPrice', call: 'eth_gasPrice', params: 0 })).createFunction(method.requestManager); getGasPrice(function (err, gasPrice) { if (gasPrice) { payload.params[0].gasPrice = gasPrice; } if (isSendTx) { setTimeout(() => { defer.eventEmitter.emit('sending', payload); }, 0); } sendRequest(payload, method); }); } else { if (isSendTx) { setTimeout(() => { defer.eventEmitter.emit('sending', payload); }, 0); } sendRequest(payload, method); } if (isSendTx) { setTimeout(() => { defer.eventEmitter.emit('sent', payload); }, 0); } return defer.eventEmitter; }; // necessary to attach things to the method send.method = method; // necessary for batch requests send.request = this.request.bind(this); return send; }; /** * Returns the revert reason string if existing or otherwise false. * * @method getRevertReason * * @param {Object} txOptions * @param {Number} blockNumber * * @returns {Promise} */ Method.prototype.getRevertReason = function (txOptions, blockNumber) { var self = this; return new Promise(function (resolve, reject) { (new Method({ name: 'call', call: 'eth_call', params: 2, abiCoder: self.abiCoder, handleRevert: true })) .createFunction(self.requestManager)(txOptions, utils.numberToHex(blockNumber)) .then(function () { resolve(false); }) .catch(function (error) { if (error.reason) { resolve({ reason: error.reason, signature: error.signature }); } else { reject(error); } }); }); }; /** * Checks if the given hex string is a revert message from the EVM * * @method isRevertReasonString * * @param {String} data - Hex string prefixed with 0x * * @returns {Boolean} */ Method.prototype.isRevertReasonString = function (data) { return _.isString(data) && ((data.length - 2) / 2) % 32 === 4 && data.substring(0, 10) === '0x08c379a0'; }; /** * Should be called to create the pure JSONRPC request which can be used in a batch request * * @method request * @return {Object} jsonrpc request */ Method.prototype.request = function () { var payload = this.toPayload(Array.prototype.slice.call(arguments)); payload.format = this.formatOutput.bind(this); return payload; }; module.exports = Method;