2025-04-19 15:38:48 +08:00

712 lines
30 KiB
JavaScript

/*
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 <http://www.gnu.org/licenses/>.
*/
/**
* @file index.js
* @author Fabian Vogelsteller <fabian@ethereum.org>
* @author Marek Kotewicz <marek@parity.io>
* @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<Boolean|String>}
*/
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;