sparrow-controllers
Version:
Collection of platform-agnostic modules for creating secure data models for cryptocurrency wallets
915 lines • 47.2 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TransactionController = exports.SPEED_UP_RATE = exports.CANCEL_RATE = exports.WalletDevice = exports.TransactionStatus = void 0;
const events_1 = require("events");
const ethereumjs_util_1 = require("ethereumjs-util");
const eth_rpc_errors_1 = require("eth-rpc-errors");
const eth_method_registry_1 = __importDefault(require("eth-method-registry"));
const eth_query_1 = __importDefault(require("eth-query"));
const common_1 = __importDefault(require("@ethereumjs/common"));
const tx_1 = require("@ethereumjs/tx");
const uuid_1 = require("uuid");
const async_mutex_1 = require("async-mutex");
const BaseController_1 = require("../BaseController");
const util_1 = require("../util");
const constants_1 = require("../constants");
const HARDFORK = 'london';
/**
* The status of the transaction. Each status represents the state of the transaction internally
* in the wallet. Some of these correspond with the state of the transaction on the network, but
* some are wallet-specific.
*/
var TransactionStatus;
(function (TransactionStatus) {
TransactionStatus["approved"] = "approved";
TransactionStatus["cancelled"] = "cancelled";
TransactionStatus["confirmed"] = "confirmed";
TransactionStatus["failed"] = "failed";
TransactionStatus["rejected"] = "rejected";
TransactionStatus["signed"] = "signed";
TransactionStatus["submitted"] = "submitted";
TransactionStatus["unapproved"] = "unapproved";
})(TransactionStatus = exports.TransactionStatus || (exports.TransactionStatus = {}));
/**
* Options for wallet device.
*/
var WalletDevice;
(function (WalletDevice) {
WalletDevice["MM_MOBILE"] = "metamask_mobile";
WalletDevice["MM_EXTENSION"] = "metamask_extension";
WalletDevice["OTHER"] = "other_device";
})(WalletDevice = exports.WalletDevice || (exports.WalletDevice = {}));
/**
* Multiplier used to determine a transaction's increased gas fee during cancellation
*/
exports.CANCEL_RATE = 1.5;
/**
* Multiplier used to determine a transaction's increased gas fee during speed up
*/
exports.SPEED_UP_RATE = 1.1;
/**
* Controller responsible for submitting and managing transactions
*/
class TransactionController extends BaseController_1.BaseController {
/**
* Creates a TransactionController instance.
*
* @param options - The controller options.
* @param options.getNetworkState - Gets the state of the network controller.
* @param options.onNetworkStateChange - Allows subscribing to network controller state changes.
* @param options.getProvider - Returns a provider for the current network.
* @param config - Initial options used to configure this controller.
* @param state - Initial state to set on this controller.
*/
constructor({ getNetworkState, onNetworkStateChange, getProvider, }, config, state) {
super(config, state);
this.mutex = new async_mutex_1.Mutex();
this.normalizeTokenTx = (txMeta, currentNetworkID, currentChainId) => {
const time = parseInt(txMeta.timeStamp, 10) * 1000;
const { to, from, gas, gasPrice, gasUsed, hash, contractAddress, tokenDecimal, tokenSymbol, value, } = txMeta;
return {
id: (0, uuid_1.v1)({ msecs: time }),
isTransfer: true,
networkID: currentNetworkID,
chainId: currentChainId,
status: TransactionStatus.confirmed,
time,
transaction: {
chainId: 1,
from,
gas,
gasPrice,
gasUsed,
to,
value,
},
transactionHash: hash,
transferInformation: {
contractAddress,
decimals: Number(tokenDecimal),
symbol: tokenSymbol,
},
verifiedOnBlockchain: false,
};
};
/**
* EventEmitter instance used to listen to specific transactional events
*/
this.hub = new events_1.EventEmitter();
/**
* Name of this controller used during composition
*/
this.name = 'TransactionController';
this.defaultConfig = {
interval: 15000,
txHistoryLimit: 40,
};
this.defaultState = {
methodData: {},
transactions: [],
};
this.initialize();
const provider = getProvider();
this.getNetworkState = getNetworkState;
this.ethQuery = new eth_query_1.default(provider);
this.registry = new eth_method_registry_1.default({ provider });
onNetworkStateChange(() => {
const newProvider = getProvider();
this.ethQuery = new eth_query_1.default(newProvider);
this.registry = new eth_method_registry_1.default({ provider: newProvider });
});
this.poll();
}
failTransaction(transactionMeta, error) {
const newTransactionMeta = Object.assign(Object.assign({}, transactionMeta), { error, status: TransactionStatus.failed });
this.updateTransaction(newTransactionMeta);
this.hub.emit(`${transactionMeta.id}:finished`, newTransactionMeta);
}
registryLookup(fourBytePrefix) {
return __awaiter(this, void 0, void 0, function* () {
const registryMethod = yield this.registry.lookup(fourBytePrefix);
const parsedRegistryMethod = this.registry.parse(registryMethod);
return { registryMethod, parsedRegistryMethod };
});
}
/**
* Normalizes the transaction information from etherscan
* to be compatible with the TransactionMeta interface.
*
* @param txMeta - The transaction.
* @param currentNetworkID - The current network ID.
* @param currentChainId - The current chain ID.
* @returns The normalized transaction.
*/
normalizeTx(txMeta, currentNetworkID, currentChainId) {
const time = parseInt(txMeta.timeStamp, 10) * 1000;
const normalizedTransactionBase = {
blockNumber: txMeta.blockNumber,
id: (0, uuid_1.v1)({ msecs: time }),
networkID: currentNetworkID,
chainId: currentChainId,
time,
transaction: {
data: txMeta.input,
from: txMeta.from,
gas: (0, util_1.BNToHex)(new ethereumjs_util_1.BN(txMeta.gas)),
gasPrice: (0, util_1.BNToHex)(new ethereumjs_util_1.BN(txMeta.gasPrice)),
gasUsed: (0, util_1.BNToHex)(new ethereumjs_util_1.BN(txMeta.gasUsed)),
nonce: (0, util_1.BNToHex)(new ethereumjs_util_1.BN(txMeta.nonce)),
to: txMeta.to,
value: (0, util_1.BNToHex)(new ethereumjs_util_1.BN(txMeta.value)),
},
transactionHash: txMeta.hash,
verifiedOnBlockchain: false,
};
/* istanbul ignore else */
if (txMeta.isError === '0') {
return Object.assign(Object.assign({}, normalizedTransactionBase), { status: TransactionStatus.confirmed });
}
/* istanbul ignore next */
return Object.assign(Object.assign({}, normalizedTransactionBase), { error: new Error('Transaction failed'), status: TransactionStatus.failed });
}
/**
* Starts a new polling interval.
*
* @param interval - The polling interval used to fetch new transaction statuses.
*/
poll(interval) {
return __awaiter(this, void 0, void 0, function* () {
interval && this.configure({ interval }, false, false);
this.handle && clearTimeout(this.handle);
yield (0, util_1.safelyExecute)(() => this.queryTransactionStatuses());
this.handle = setTimeout(() => {
this.poll(this.config.interval);
}, this.config.interval);
});
}
/**
* Handle new method data request.
*
* @param fourBytePrefix - The method prefix.
* @returns The method data object corresponding to the given signature prefix.
*/
handleMethodData(fourBytePrefix) {
return __awaiter(this, void 0, void 0, function* () {
const releaseLock = yield this.mutex.acquire();
try {
const { methodData } = this.state;
const knownMethod = Object.keys(methodData).find((knownFourBytePrefix) => fourBytePrefix === knownFourBytePrefix);
if (knownMethod) {
return methodData[fourBytePrefix];
}
const registry = yield this.registryLookup(fourBytePrefix);
this.update({
methodData: Object.assign(Object.assign({}, methodData), { [fourBytePrefix]: registry }),
});
return registry;
}
finally {
releaseLock();
}
});
}
/**
* Add a new unapproved transaction to state. Parameters will be validated, a
* unique transaction id will be generated, and gas and gasPrice will be calculated
* if not provided. If A `<tx.id>:unapproved` hub event will be emitted once added.
*
* @param transaction - The transaction object to add.
* @param origin - The domain origin to append to the generated TransactionMeta.
* @param deviceConfirmedOn - An enum to indicate what device the transaction was confirmed to append to the generated TransactionMeta.
* @returns Object containing a promise resolving to the transaction hash if approved.
*/
addTransaction(transaction, origin, deviceConfirmedOn) {
return __awaiter(this, void 0, void 0, function* () {
const { provider, network } = this.getNetworkState();
const { transactions } = this.state;
transaction = (0, util_1.normalizeTransaction)(transaction);
(0, util_1.validateTransaction)(transaction);
const transactionMeta = {
id: (0, uuid_1.v1)(),
networkID: network,
chainId: provider.chainId,
origin,
status: TransactionStatus.unapproved,
time: Date.now(),
transaction,
deviceConfirmedOn,
verifiedOnBlockchain: false,
};
try {
const { gas } = yield this.estimateGas(transaction);
transaction.gas = gas;
}
catch (error) {
this.failTransaction(transactionMeta, error);
return Promise.reject(error);
}
const result = new Promise((resolve, reject) => {
this.hub.once(`${transactionMeta.id}:finished`, (meta) => {
switch (meta.status) {
case TransactionStatus.submitted:
return resolve(meta.transactionHash);
case TransactionStatus.rejected:
return reject(eth_rpc_errors_1.ethErrors.provider.userRejectedRequest('User rejected the transaction'));
case TransactionStatus.cancelled:
return reject(eth_rpc_errors_1.ethErrors.rpc.internal('User cancelled the transaction'));
case TransactionStatus.failed:
return reject(eth_rpc_errors_1.ethErrors.rpc.internal(meta.error.message));
/* istanbul ignore next */
default:
return reject(eth_rpc_errors_1.ethErrors.rpc.internal(`MetaMask Tx Signature: Unknown problem: ${JSON.stringify(meta)}`));
}
});
});
transactions.push(transactionMeta);
this.update({ transactions: this.trimTransactionsForState(transactions) });
this.hub.emit(`unapprovedTransaction`, transactionMeta);
return { result, transactionMeta };
});
}
prepareUnsignedEthTx(txParams) {
return tx_1.TransactionFactory.fromTxData(txParams, {
common: this.getCommonConfiguration(),
freeze: false,
});
}
/**
* `@ethereumjs/tx` uses `@ethereumjs/common` as a configuration tool for
* specifying which chain, network, hardfork and EIPs to support for
* a transaction. By referencing this configuration, and analyzing the fields
* specified in txParams, @ethereumjs/tx is able to determine which EIP-2718
* transaction type to use.
*
* @returns {Common} common configuration object
*/
getCommonConfiguration() {
const { network: networkId, provider: { type: chain, chainId, nickname: name }, } = this.getNetworkState();
if (chain !== constants_1.RPC) {
return new common_1.default({ chain, hardfork: HARDFORK });
}
const customChainParams = {
name,
chainId: parseInt(chainId, undefined),
networkId: parseInt(networkId, undefined),
};
return common_1.default.forCustomChain(constants_1.MAINNET, customChainParams, HARDFORK);
}
/**
* Approves a transaction and updates it's status in state. If this is not a
* retry transaction, a nonce will be generated. The transaction is signed
* using the sign configuration property, then published to the blockchain.
* A `<tx.id>:finished` hub event is fired after success or failure.
*
* @param transactionID - The ID of the transaction to approve.
*/
approveTransaction(transactionID) {
return __awaiter(this, void 0, void 0, function* () {
const { transactions } = this.state;
const releaseLock = yield this.mutex.acquire();
const { provider } = this.getNetworkState();
const { chainId: currentChainId } = provider;
const index = transactions.findIndex(({ id }) => transactionID === id);
const transactionMeta = transactions[index];
const { nonce } = transactionMeta.transaction;
try {
const { from } = transactionMeta.transaction;
if (!this.sign) {
releaseLock();
this.failTransaction(transactionMeta, new Error('No sign method defined.'));
return;
}
else if (!currentChainId) {
releaseLock();
this.failTransaction(transactionMeta, new Error('No chainId defined.'));
return;
}
const chainId = parseInt(currentChainId, undefined);
const { approved: status } = TransactionStatus;
const txNonce = nonce ||
(yield (0, util_1.query)(this.ethQuery, 'getTransactionCount', [from, 'pending']));
transactionMeta.status = status;
transactionMeta.transaction.nonce = txNonce;
transactionMeta.transaction.chainId = chainId;
const baseTxParams = Object.assign(Object.assign({}, transactionMeta.transaction), { gasLimit: transactionMeta.transaction.gas, chainId, nonce: txNonce, status });
const isEIP1559 = (0, util_1.isEIP1559Transaction)(transactionMeta.transaction);
const txParams = isEIP1559
? Object.assign(Object.assign({}, baseTxParams), { maxFeePerGas: transactionMeta.transaction.maxFeePerGas, maxPriorityFeePerGas: transactionMeta.transaction.maxPriorityFeePerGas, estimatedBaseFee: transactionMeta.transaction.estimatedBaseFee,
// specify type 2 if maxFeePerGas and maxPriorityFeePerGas are set
type: 2 }) : baseTxParams;
// delete gasPrice if maxFeePerGas and maxPriorityFeePerGas are set
if (isEIP1559) {
delete txParams.gasPrice;
}
const unsignedEthTx = this.prepareUnsignedEthTx(txParams);
const signedTx = yield this.sign(unsignedEthTx, from);
transactionMeta.status = TransactionStatus.signed;
this.updateTransaction(transactionMeta);
const rawTransaction = (0, ethereumjs_util_1.bufferToHex)(signedTx.serialize());
transactionMeta.rawTransaction = rawTransaction;
this.updateTransaction(transactionMeta);
const transactionHash = yield (0, util_1.query)(this.ethQuery, 'sendRawTransaction', [
rawTransaction,
]);
transactionMeta.transactionHash = transactionHash;
transactionMeta.status = TransactionStatus.submitted;
this.updateTransaction(transactionMeta);
this.hub.emit(`${transactionMeta.id}:finished`, transactionMeta);
}
catch (error) {
this.failTransaction(transactionMeta, error);
}
finally {
releaseLock();
}
});
}
/**
* Cancels a transaction based on its ID by setting its status to "rejected"
* and emitting a `<tx.id>:finished` hub event.
*
* @param transactionID - The ID of the transaction to cancel.
*/
cancelTransaction(transactionID) {
const transactionMeta = this.state.transactions.find(({ id }) => id === transactionID);
if (!transactionMeta) {
return;
}
transactionMeta.status = TransactionStatus.rejected;
this.hub.emit(`${transactionMeta.id}:finished`, transactionMeta);
const transactions = this.state.transactions.filter(({ id }) => id !== transactionID);
this.update({ transactions: this.trimTransactionsForState(transactions) });
}
/**
* Attempts to cancel a transaction based on its ID by setting its status to "rejected"
* and emitting a `<tx.id>:finished` hub event.
*
* @param transactionID - The ID of the transaction to cancel.
* @param gasValues - The gas values to use for the cancellation transation.
*/
stopTransaction(transactionID, gasValues) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
if (gasValues) {
(0, util_1.validateGasValues)(gasValues);
}
const transactionMeta = this.state.transactions.find(({ id }) => id === transactionID);
if (!transactionMeta) {
return;
}
if (!this.sign) {
throw new Error('No sign method defined.');
}
// gasPrice (legacy non EIP1559)
const minGasPrice = (0, util_1.getIncreasedPriceFromExisting)(transactionMeta.transaction.gasPrice, exports.CANCEL_RATE);
const gasPriceFromValues = (0, util_1.isGasPriceValue)(gasValues) && gasValues.gasPrice;
const newGasPrice = (gasPriceFromValues &&
(0, util_1.validateMinimumIncrease)(gasPriceFromValues, minGasPrice)) ||
minGasPrice;
// maxFeePerGas (EIP1559)
const existingMaxFeePerGas = (_a = transactionMeta.transaction) === null || _a === void 0 ? void 0 : _a.maxFeePerGas;
const minMaxFeePerGas = (0, util_1.getIncreasedPriceFromExisting)(existingMaxFeePerGas, exports.CANCEL_RATE);
const maxFeePerGasValues = (0, util_1.isFeeMarketEIP1559Values)(gasValues) && gasValues.maxFeePerGas;
const newMaxFeePerGas = (maxFeePerGasValues &&
(0, util_1.validateMinimumIncrease)(maxFeePerGasValues, minMaxFeePerGas)) ||
(existingMaxFeePerGas && minMaxFeePerGas);
// maxPriorityFeePerGas (EIP1559)
const existingMaxPriorityFeePerGas = (_b = transactionMeta.transaction) === null || _b === void 0 ? void 0 : _b.maxPriorityFeePerGas;
const minMaxPriorityFeePerGas = (0, util_1.getIncreasedPriceFromExisting)(existingMaxPriorityFeePerGas, exports.CANCEL_RATE);
const maxPriorityFeePerGasValues = (0, util_1.isFeeMarketEIP1559Values)(gasValues) && gasValues.maxPriorityFeePerGas;
const newMaxPriorityFeePerGas = (maxPriorityFeePerGasValues &&
(0, util_1.validateMinimumIncrease)(maxPriorityFeePerGasValues, minMaxPriorityFeePerGas)) ||
(existingMaxPriorityFeePerGas && minMaxPriorityFeePerGas);
const txParams = newMaxFeePerGas && newMaxPriorityFeePerGas
? {
from: transactionMeta.transaction.from,
gasLimit: transactionMeta.transaction.gas,
maxFeePerGas: newMaxFeePerGas,
maxPriorityFeePerGas: newMaxPriorityFeePerGas,
type: 2,
nonce: transactionMeta.transaction.nonce,
to: transactionMeta.transaction.from,
value: '0x0',
}
: {
from: transactionMeta.transaction.from,
gasLimit: transactionMeta.transaction.gas,
gasPrice: newGasPrice,
nonce: transactionMeta.transaction.nonce,
to: transactionMeta.transaction.from,
value: '0x0',
};
const unsignedEthTx = this.prepareUnsignedEthTx(txParams);
const signedTx = yield this.sign(unsignedEthTx, transactionMeta.transaction.from);
const rawTransaction = (0, ethereumjs_util_1.bufferToHex)(signedTx.serialize());
yield (0, util_1.query)(this.ethQuery, 'sendRawTransaction', [rawTransaction]);
transactionMeta.status = TransactionStatus.cancelled;
this.hub.emit(`${transactionMeta.id}:finished`, transactionMeta);
});
}
/**
* Attempts to speed up a transaction increasing transaction gasPrice by ten percent.
*
* @param transactionID - The ID of the transaction to speed up.
* @param gasValues - The gas values to use for the speed up transation.
*/
speedUpTransaction(transactionID, gasValues) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
if (gasValues) {
(0, util_1.validateGasValues)(gasValues);
}
const transactionMeta = this.state.transactions.find(({ id }) => id === transactionID);
/* istanbul ignore next */
if (!transactionMeta) {
return;
}
/* istanbul ignore next */
if (!this.sign) {
throw new Error('No sign method defined.');
}
const { transactions } = this.state;
// gasPrice (legacy non EIP1559)
const minGasPrice = (0, util_1.getIncreasedPriceFromExisting)(transactionMeta.transaction.gasPrice, exports.SPEED_UP_RATE);
const gasPriceFromValues = (0, util_1.isGasPriceValue)(gasValues) && gasValues.gasPrice;
const newGasPrice = (gasPriceFromValues &&
(0, util_1.validateMinimumIncrease)(gasPriceFromValues, minGasPrice)) ||
minGasPrice;
// maxFeePerGas (EIP1559)
const existingMaxFeePerGas = (_a = transactionMeta.transaction) === null || _a === void 0 ? void 0 : _a.maxFeePerGas;
const minMaxFeePerGas = (0, util_1.getIncreasedPriceFromExisting)(existingMaxFeePerGas, exports.SPEED_UP_RATE);
const maxFeePerGasValues = (0, util_1.isFeeMarketEIP1559Values)(gasValues) && gasValues.maxFeePerGas;
const newMaxFeePerGas = (maxFeePerGasValues &&
(0, util_1.validateMinimumIncrease)(maxFeePerGasValues, minMaxFeePerGas)) ||
(existingMaxFeePerGas && minMaxFeePerGas);
// maxPriorityFeePerGas (EIP1559)
const existingMaxPriorityFeePerGas = (_b = transactionMeta.transaction) === null || _b === void 0 ? void 0 : _b.maxPriorityFeePerGas;
const minMaxPriorityFeePerGas = (0, util_1.getIncreasedPriceFromExisting)(existingMaxPriorityFeePerGas, exports.SPEED_UP_RATE);
const maxPriorityFeePerGasValues = (0, util_1.isFeeMarketEIP1559Values)(gasValues) && gasValues.maxPriorityFeePerGas;
const newMaxPriorityFeePerGas = (maxPriorityFeePerGasValues &&
(0, util_1.validateMinimumIncrease)(maxPriorityFeePerGasValues, minMaxPriorityFeePerGas)) ||
(existingMaxPriorityFeePerGas && minMaxPriorityFeePerGas);
const txParams = newMaxFeePerGas && newMaxPriorityFeePerGas
? Object.assign(Object.assign({}, transactionMeta.transaction), { gasLimit: transactionMeta.transaction.gas, maxFeePerGas: newMaxFeePerGas, maxPriorityFeePerGas: newMaxPriorityFeePerGas, type: 2 }) : Object.assign(Object.assign({}, transactionMeta.transaction), { gasLimit: transactionMeta.transaction.gas, gasPrice: newGasPrice });
const unsignedEthTx = this.prepareUnsignedEthTx(txParams);
const signedTx = yield this.sign(unsignedEthTx, transactionMeta.transaction.from);
const rawTransaction = (0, ethereumjs_util_1.bufferToHex)(signedTx.serialize());
const transactionHash = yield (0, util_1.query)(this.ethQuery, 'sendRawTransaction', [
rawTransaction,
]);
const baseTransactionMeta = Object.assign(Object.assign({}, transactionMeta), { id: (0, uuid_1.v1)(), time: Date.now(), transactionHash });
const newTransactionMeta = newMaxFeePerGas && newMaxPriorityFeePerGas
? Object.assign(Object.assign({}, baseTransactionMeta), { transaction: Object.assign(Object.assign({}, transactionMeta.transaction), { maxFeePerGas: newMaxFeePerGas, maxPriorityFeePerGas: newMaxPriorityFeePerGas }) }) : Object.assign(Object.assign({}, baseTransactionMeta), { transaction: Object.assign(Object.assign({}, transactionMeta.transaction), { gasPrice: newGasPrice }) });
transactions.push(newTransactionMeta);
this.update({ transactions: this.trimTransactionsForState(transactions) });
this.hub.emit(`${transactionMeta.id}:speedup`, newTransactionMeta);
});
}
/**
* Estimates required gas for a given transaction.
*
* @param transaction - The transaction to estimate gas for.
* @returns The gas and gas price.
*/
estimateGas(transaction) {
return __awaiter(this, void 0, void 0, function* () {
const estimatedTransaction = Object.assign({}, transaction);
const { gas, gasPrice: providedGasPrice, to, value, data, } = estimatedTransaction;
const gasPrice = typeof providedGasPrice === 'undefined'
? yield (0, util_1.query)(this.ethQuery, 'gasPrice')
: providedGasPrice;
const { isCustomNetwork } = this.getNetworkState();
// 1. If gas is already defined on the transaction, use it
if (typeof gas !== 'undefined') {
return { gas, gasPrice };
}
const { gasLimit } = yield (0, util_1.query)(this.ethQuery, 'getBlockByNumber', [
'latest',
false,
]);
// 2. If to is not defined or this is not a contract address, and there is no data use 0x5208 / 21000.
// If the newtwork is a custom network then bypass this check and fetch 'estimateGas'.
/* istanbul ignore next */
const code = to ? yield (0, util_1.query)(this.ethQuery, 'getCode', [to]) : undefined;
/* istanbul ignore next */
if (!isCustomNetwork &&
(!to || (to && !data && (!code || code === '0x')))) {
return { gas: '0x5208', gasPrice };
}
// if data, should be hex string format
estimatedTransaction.data = !data
? data
: /* istanbul ignore next */ (0, ethereumjs_util_1.addHexPrefix)(data);
// 3. If this is a contract address, safely estimate gas using RPC
estimatedTransaction.value =
typeof value === 'undefined' ? '0x0' : /* istanbul ignore next */ value;
const gasLimitBN = (0, util_1.hexToBN)(gasLimit);
estimatedTransaction.gas = (0, util_1.BNToHex)((0, util_1.fractionBN)(gasLimitBN, 19, 20));
const gasHex = yield (0, util_1.query)(this.ethQuery, 'estimateGas', [
estimatedTransaction,
]);
// 4. Pad estimated gas without exceeding the most recent block gasLimit. If the network is a
// a custom network then return the eth_estimateGas value.
const gasBN = (0, util_1.hexToBN)(gasHex);
const maxGasBN = gasLimitBN.muln(0.9);
const paddedGasBN = gasBN.muln(1.5);
/* istanbul ignore next */
if (gasBN.gt(maxGasBN) || isCustomNetwork) {
return { gas: (0, ethereumjs_util_1.addHexPrefix)(gasHex), gasPrice };
}
/* istanbul ignore next */
if (paddedGasBN.lt(maxGasBN)) {
return { gas: (0, ethereumjs_util_1.addHexPrefix)((0, util_1.BNToHex)(paddedGasBN)), gasPrice };
}
return { gas: (0, ethereumjs_util_1.addHexPrefix)((0, util_1.BNToHex)(maxGasBN)), gasPrice };
});
}
/**
* Check the status of submitted transactions on the network to determine whether they have
* been included in a block. Any that have been included in a block are marked as confirmed.
*/
queryTransactionStatuses() {
return __awaiter(this, void 0, void 0, function* () {
const { transactions } = this.state;
const { provider, network: currentNetworkID } = this.getNetworkState();
const { chainId: currentChainId } = provider;
let gotUpdates = false;
yield (0, util_1.safelyExecute)(() => Promise.all(transactions.map((meta, index) => __awaiter(this, void 0, void 0, function* () {
// Using fallback to networkID only when there is no chainId present.
// Should be removed when networkID is completely removed.
const txBelongsToCurrentChain = meta.chainId === currentChainId ||
(!meta.chainId && meta.networkID === currentNetworkID);
if (!meta.verifiedOnBlockchain && txBelongsToCurrentChain) {
const [reconciledTx, updateRequired] = yield this.blockchainTransactionStateReconciler(meta);
if (updateRequired) {
transactions[index] = reconciledTx;
gotUpdates = updateRequired;
}
}
}))));
/* istanbul ignore else */
if (gotUpdates) {
this.update({
transactions: this.trimTransactionsForState(transactions),
});
}
});
}
/**
* Updates an existing transaction in state.
*
* @param transactionMeta - The new transaction to store in state.
*/
updateTransaction(transactionMeta) {
const { transactions } = this.state;
transactionMeta.transaction = (0, util_1.normalizeTransaction)(transactionMeta.transaction);
(0, util_1.validateTransaction)(transactionMeta.transaction);
const index = transactions.findIndex(({ id }) => transactionMeta.id === id);
transactions[index] = transactionMeta;
this.update({ transactions: this.trimTransactionsForState(transactions) });
}
/**
* Removes all transactions from state, optionally based on the current network.
*
* @param ignoreNetwork - Determines whether to wipe all transactions, or just those on the
* current network. If `true`, all transactions are wiped.
*/
wipeTransactions(ignoreNetwork) {
/* istanbul ignore next */
if (ignoreNetwork) {
this.update({ transactions: [] });
return;
}
const { provider, network: currentNetworkID } = this.getNetworkState();
const { chainId: currentChainId } = provider;
const newTransactions = this.state.transactions.filter(({ networkID, chainId }) => {
// Using fallback to networkID only when there is no chainId present. Should be removed when networkID is completely removed.
const isCurrentNetwork = chainId === currentChainId ||
(!chainId && networkID === currentNetworkID);
return !isCurrentNetwork;
});
this.update({
transactions: this.trimTransactionsForState(newTransactions),
});
}
/**
* Get transactions from Etherscan for the given address. By default all transactions are
* returned, but the `fromBlock` option can be given to filter just for transactions from a
* specific block onward.
*
* @param address - The address to fetch the transactions for.
* @param opt - Object containing optional data, fromBlock and Etherscan API key.
* @returns The block number of the latest incoming transaction.
*/
fetchAll(address, opt) {
return __awaiter(this, void 0, void 0, function* () {
const { provider, network: currentNetworkID } = this.getNetworkState();
const { chainId: currentChainId, type: networkType } = provider;
const { transactions } = this.state;
const supportedNetworkIds = ['1', '3', '4', '42'];
/* istanbul ignore next */
if (supportedNetworkIds.indexOf(currentNetworkID) === -1) {
return undefined;
}
const [etherscanTxResponse, etherscanTokenResponse] = yield (0, util_1.handleTransactionFetch)(networkType, address, this.config.txHistoryLimit, opt);
const normalizedTxs = etherscanTxResponse.result.map((tx) => this.normalizeTx(tx, currentNetworkID, currentChainId));
const normalizedTokenTxs = etherscanTokenResponse.result.map((tx) => this.normalizeTokenTx(tx, currentNetworkID, currentChainId));
const [updateRequired, allTxs] = this.etherscanTransactionStateReconciler([...normalizedTxs, ...normalizedTokenTxs], transactions);
allTxs.sort((a, b) => (a.time < b.time ? -1 : 1));
let latestIncomingTxBlockNumber;
allTxs.forEach((tx) => __awaiter(this, void 0, void 0, function* () {
/* istanbul ignore next */
if (
// Using fallback to networkID only when there is no chainId present. Should be removed when networkID is completely removed.
(tx.chainId === currentChainId ||
(!tx.chainId && tx.networkID === currentNetworkID)) &&
tx.transaction.to &&
tx.transaction.to.toLowerCase() === address.toLowerCase()) {
if (tx.blockNumber &&
(!latestIncomingTxBlockNumber ||
parseInt(latestIncomingTxBlockNumber, 10) <
parseInt(tx.blockNumber, 10))) {
latestIncomingTxBlockNumber = tx.blockNumber;
}
}
/* istanbul ignore else */
if (tx.toSmartContract === undefined) {
// If not `to` is a contract deploy, if not `data` is send eth
if (tx.transaction.to &&
(!tx.transaction.data || tx.transaction.data !== '0x')) {
const code = yield (0, util_1.query)(this.ethQuery, 'getCode', [
tx.transaction.to,
]);
tx.toSmartContract = (0, util_1.isSmartContractCode)(code);
}
else {
tx.toSmartContract = false;
}
}
}));
// Update state only if new transactions were fetched or
// the status or gas data of a transaction has changed
if (updateRequired) {
this.update({ transactions: this.trimTransactionsForState(allTxs) });
}
return latestIncomingTxBlockNumber;
});
}
/**
* Trim the amount of transactions that are set on the state. Checks
* if the length of the tx history is longer then desired persistence
* limit and then if it is removes the oldest confirmed or rejected tx.
* Pending or unapproved transactions will not be removed by this
* operation. For safety of presenting a fully functional transaction UI
* representation, this function will not break apart transactions with the
* same nonce, created on the same day, per network. Not accounting for transactions of the same
* nonce, same day and network combo can result in confusing or broken experiences
* in the UI. The transactions are then updated using the BaseController update.
*
* @param transactions - The transactions to be applied to the state.
* @returns The trimmed list of transactions.
*/
trimTransactionsForState(transactions) {
const nonceNetworkSet = new Set();
const txsToKeep = transactions.reverse().filter((tx) => {
const { chainId, networkID, status, transaction, time } = tx;
if (transaction) {
const key = `${transaction.nonce}-${chainId !== null && chainId !== void 0 ? chainId : networkID}-${new Date(time).toDateString()}`;
if (nonceNetworkSet.has(key)) {
return true;
}
else if (nonceNetworkSet.size < this.config.txHistoryLimit ||
!this.isFinalState(status)) {
nonceNetworkSet.add(key);
return true;
}
}
return false;
});
txsToKeep.reverse();
return txsToKeep;
}
/**
* Determines if the transaction is in a final state.
*
* @param status - The transaction status.
* @returns Whether the transaction is in a final state.
*/
isFinalState(status) {
return (status === TransactionStatus.rejected ||
status === TransactionStatus.confirmed ||
status === TransactionStatus.failed ||
status === TransactionStatus.cancelled);
}
/**
* Method to verify the state of a transaction using the Blockchain as a source of truth.
*
* @param meta - The local transaction to verify on the blockchain.
* @returns A tuple containing the updated transaction, and whether or not an update was required.
*/
blockchainTransactionStateReconciler(meta) {
return __awaiter(this, void 0, void 0, function* () {
const { status, transactionHash } = meta;
switch (status) {
case TransactionStatus.confirmed:
const txReceipt = yield (0, util_1.query)(this.ethQuery, 'getTransactionReceipt', [
transactionHash,
]);
if (!txReceipt) {
return [meta, false];
}
meta.verifiedOnBlockchain = true;
meta.transaction.gasUsed = txReceipt.gasUsed;
// According to the Web3 docs:
// TRUE if the transaction was successful, FALSE if the EVM reverted the transaction.
if (Number(txReceipt.status) === 0) {
const error = new Error('Transaction failed. The transaction was reversed');
this.failTransaction(meta, error);
return [meta, false];
}
return [meta, true];
case TransactionStatus.submitted:
const txObj = yield (0, util_1.query)(this.ethQuery, 'getTransactionByHash', [
transactionHash,
]);
if (!txObj) {
const receiptShowsFailedStatus = yield this.checkTxReceiptStatusIsFailed(transactionHash);
// Case the txObj is evaluated as false, a second check will
// determine if the tx failed or it is pending or confirmed
if (receiptShowsFailedStatus) {
const error = new Error('Transaction failed. The transaction was dropped or replaced by a new one');
this.failTransaction(meta, error);
}
}
/* istanbul ignore next */
if (txObj === null || txObj === void 0 ? void 0 : txObj.blockNumber) {
meta.status = TransactionStatus.confirmed;
this.hub.emit(`${meta.id}:confirmed`, meta);
return [meta, true];
}
return [meta, false];
default:
return [meta, false];
}
});
}
/**
* Method to check if a tx has failed according to their receipt
* According to the Web3 docs:
* TRUE if the transaction was successful, FALSE if the EVM reverted the transaction.
* The receipt is not available for pending transactions and returns null.
*
* @param txHash - The transaction hash.
* @returns Whether the transaction has failed.
*/
checkTxReceiptStatusIsFailed(txHash) {
return __awaiter(this, void 0, void 0, function* () {
const txReceipt = yield (0, util_1.query)(this.ethQuery, 'getTransactionReceipt', [
txHash,
]);
if (!txReceipt) {
// Transaction is pending
return false;
}
return Number(txReceipt.status) === 0;
});
}
/**
* Method to verify the state of transactions using Etherscan as a source of truth.
*
* @param remoteTxs - Transactions to reconcile that are from a remote source.
* @param localTxs - Transactions to reconcile that are local.
* @returns A tuple containing a boolean indicating whether or not an update was required, and the updated transaction.
*/
etherscanTransactionStateReconciler(remoteTxs, localTxs) {
const updatedTxs = this.getUpdatedTransactions(remoteTxs, localTxs);
const newTxs = this.getNewTransactions(remoteTxs, localTxs);
const updatedLocalTxs = localTxs.map((tx) => {
const txIdx = updatedTxs.findIndex(({ transactionHash }) => transactionHash === tx.transactionHash);
return txIdx === -1 ? tx : updatedTxs[txIdx];
});
const updateRequired = newTxs.length > 0 || updatedLocalTxs.length > 0;
return [updateRequired, [...newTxs, ...updatedLocalTxs]];
}
/**
* Get all transactions that are in the remote transactions array
* but not in the local transactions array.
*
* @param remoteTxs - Array of transactions from remote source.
* @param localTxs - Array of transactions stored locally.
* @returns The new transactions.
*/
getNewTransactions(remoteTxs, localTxs) {
return remoteTxs.filter((tx) => {
const alreadyInTransactions = localTxs.find(({ transactionHash }) => transactionHash === tx.transactionHash);
return !alreadyInTransactions;
});
}
/**
* Get all the transactions that are locally outdated with respect
* to a remote source (etherscan or blockchain). The returned array
* contains the transactions with the updated data.
*
* @param remoteTxs - Array of transactions from remote source.
* @param localTxs - Array of transactions stored locally.
* @returns The updated transactions.
*/
getUpdatedTransactions(remoteTxs, localTxs) {
return remoteTxs.filter((remoteTx) => {
const isTxOutdated = localTxs.find((localTx) => {
return (remoteTx.transactionHash === localTx.transactionHash &&
this.isTransactionOutdated(remoteTx, localTx));
});
return isTxOutdated;
});
}
/**
* Verifies if a local transaction is outdated with respect to the remote transaction.
*
* @param remoteTx - The remote transaction from Etherscan.
* @param localTx - The local transaction.
* @returns Whether the transaction is outdated.
*/
isTransactionOutdated(remoteTx, localTx) {
const statusOutdated = this.isStatusOutdated(remoteTx.transactionHash, localTx.transactionHash, remoteTx.status, localTx.status);
const gasDataOutdated = this.isGasDataOutdated(remoteTx.transaction.gasUsed, localTx.transaction.gasUsed);
return statusOutdated || gasDataOutdated;
}
/**
* Verifies if the status of a local transaction is outdated with respect to the remote transaction.
*
* @param remoteTxHash - Remote transaction hash.
* @param localTxHash - Local transaction hash.
* @param remoteTxStatus - Remote transaction status.
* @param localTxStatus - Local transaction status.
* @returns Whether the status is outdated.
*/
isStatusOutdated(remoteTxHash, localTxHash, remoteTxStatus, localTxStatus) {
return remoteTxHash === localTxHash && remoteTxStatus !== localTxStatus;
}
/**
* Verifies if the gas data of a local transaction is outdated with respect to the remote transaction.
*
* @param remoteGasUsed - Remote gas used in the transaction.
* @param localGasUsed - Local gas used in the transaction.
* @returns Whether the gas data is outdated.
*/
isGasDataOutdated(remoteGasUsed, localGasUsed) {
return remoteGasUsed !== localGasUsed;
}
}
exports.TransactionController = TransactionController;
exports.default = TransactionController;
//# sourceMappingURL=TransactionController.js.map