import EventEmitter from 'safe-event-emitter';
import { ObservableStore } from '@metamask/obs-store';
import { bufferToHex, keccak, toBuffer, isHexString } from 'ethereumjs-util';
import EthQuery from 'ethjs-query';
import { ethErrors } from 'eth-rpc-errors';
import abi from 'human-standard-token-abi';
import Common from '@ethereumjs/common';
import { TransactionFactory } from '@ethereumjs/tx';
import { ethers } from 'ethers';
import NonceTracker from 'nonce-tracker';
import log from 'loglevel';
import BigNumber from 'bignumber.js';
import cleanErrorStack from '../../lib/cleanErrorStack';
import {
hexToBn,
bnToHex,
BnMultiplyByFraction,
addHexPrefix,
getChainType,
} from '../../lib/util';
import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/helpers/constants/error-keys';
import { getSwapsTokensReceivedFromTxMeta } from '../../../../ui/pages/swaps/swaps.util';
import { hexWEIToDecGWEI } from '../../../../ui/helpers/utils/conversions.util';
import {
TRANSACTION_STATUSES,
TRANSACTION_TYPES,
TRANSACTION_ENVELOPE_TYPES,
} from '../../../../shared/constants/transaction';
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../ui/helpers/constants/transactions';
import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller';
import {
GAS_LIMITS,
GAS_ESTIMATE_TYPES,
GAS_RECOMMENDATIONS,
CUSTOM_GAS_ESTIMATE,
} from '../../../../shared/constants/gas';
import { decGWEIToHexWEI } from '../../../../shared/modules/conversion.utils';
import {
HARDFORKS,
MAINNET,
NETWORK_TYPE_RPC,
CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP,
} from '../../../../shared/constants/network';
import { isEIP1559Transaction } from '../../../../shared/modules/transaction.utils';
import { readAddressAsContract } from '../../../../shared/modules/contract-utils';
import TransactionStateManager from './tx-state-manager';
import TxGasUtil from './tx-gas-utils';
import PendingTransactionTracker from './pending-tx-tracker';
import * as txUtils from './lib/util';
const hstInterface = new ethers.utils.Interface(abi);
const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory
export const TRANSACTION_EVENTS = {
ADDED: 'Transaction Added',
APPROVED: 'Transaction Approved',
FINALIZED: 'Transaction Finalized',
REJECTED: 'Transaction Rejected',
SUBMITTED: 'Transaction Submitted',
};
/**
* @typedef {Object} CustomGasSettings
* @property {string} [gas] - The gas limit to use for the transaction
* @property {string} [gasPrice] - The gasPrice to use for a legacy transaction
* @property {string} [maxFeePerGas] - The maximum amount to pay per gas on a
* EIP-1559 transaction
* @property {string} [maxPriorityFeePerGas] - The maximum amount of paid fee
* to be distributed to miner in an EIP-1559 transaction
*/
/**
Transaction Controller is an aggregate of sub-controllers and trackers
composing them in a way to be exposed to the metamask controller
- txStateManager
responsible for the state of a transaction and
storing the transaction
- pendingTxTracker
watching blocks for transactions to be include
and emitting confirmed events
- txGasUtil
gas calculations and safety buffering
- nonceTracker
calculating nonces
@class
@param {Object} opts
@param {Object} opts.initState - initial transaction list default is an empty array
@param {Object} opts.networkStore - an observable store for network number
@param {Object} opts.blockTracker - An instance of eth-blocktracker
@param {Object} opts.provider - A network provider.
@param {Function} opts.signTransaction - function the signs an @ethereumjs/tx
@param {Object} opts.getPermittedAccounts - get accounts that an origin has permissions for
@param {Function} opts.signTransaction - ethTx signer that returns a rawTx
@param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state
@param {Object} opts.preferencesStore
*/
export default class TransactionController extends EventEmitter {
constructor(opts) {
super();
this.networkStore = opts.networkStore || new ObservableStore({});
this._getCurrentChainId = opts.getCurrentChainId;
this.getProviderConfig = opts.getProviderConfig;
this._getCurrentNetworkEIP1559Compatibility =
opts.getCurrentNetworkEIP1559Compatibility;
this._getCurrentAccountEIP1559Compatibility =
opts.getCurrentAccountEIP1559Compatibility;
this.preferencesStore = opts.preferencesStore || new ObservableStore({});
this.provider = opts.provider;
this.getPermittedAccounts = opts.getPermittedAccounts;
this.blockTracker = opts.blockTracker;
this.signEthTx = opts.signTransaction;
this.inProcessOfSigning = new Set();
this._trackMetaMetricsEvent = opts.trackMetaMetricsEvent;
this._getParticipateInMetrics = opts.getParticipateInMetrics;
this._getEIP1559GasFeeEstimates = opts.getEIP1559GasFeeEstimates;
this.memStore = new ObservableStore({});
this.query = new EthQuery(this.provider);
this.txGasUtil = new TxGasUtil(this.provider);
this._mapMethods();
this.txStateManager = new TransactionStateManager({
initState: opts.initState,
txHistoryLimit: opts.txHistoryLimit,
getNetwork: this.getNetwork.bind(this),
getCurrentChainId: opts.getCurrentChainId,
});
this._onBootCleanUp();
this.store = this.txStateManager.store;
this.nonceTracker = new NonceTracker({
provider: this.provider,
blockTracker: this.blockTracker,
getPendingTransactions: this.txStateManager.getPendingTransactions.bind(
this.txStateManager,
),
getConfirmedTransactions: this.txStateManager.getConfirmedTransactions.bind(
this.txStateManager,
),
});
this.pendingTxTracker = new PendingTransactionTracker({
provider: this.provider,
nonceTracker: this.nonceTracker,
publishTransaction: (rawTx) => this.query.sendRawTransaction(rawTx),
getPendingTransactions: () => {
const pending = this.txStateManager.getPendingTransactions();
const approved = this.txStateManager.getApprovedTransactions();
return [...pending, ...approved];
},
approveTransaction: this.approveTransaction.bind(this),
getCompletedTransactions: this.txStateManager.getConfirmedTransactions.bind(
this.txStateManager,
),
});
this.txStateManager.store.subscribe(() =>
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE),
);
this._setupListeners();
// memstore is computed from a few different stores
this._updateMemstore();
this.txStateManager.store.subscribe(() => this._updateMemstore());
this.networkStore.subscribe(() => {
this._onBootCleanUp();
this._updateMemstore();
});
// request state update to finalize initialization
this._updatePendingTxsAfterFirstBlock();
}
/**
* Gets the current chainId in the network store as a number, returning 0 if
* the chainId parses to NaN.
*
* @returns {number} The numerical chainId.
*/
getChainId() {
const networkState = this.networkStore.getState();
const chainId = this._getCurrentChainId();
const integerChainId = parseInt(chainId, 16);
if (networkState === 'loading' || Number.isNaN(integerChainId)) {
return 0;
}
return integerChainId;
}
async getEIP1559Compatibility(fromAddress) {
const currentNetworkIsCompatible = await this._getCurrentNetworkEIP1559Compatibility();
const fromAccountIsCompatible = await this._getCurrentAccountEIP1559Compatibility(
fromAddress,
);
return currentNetworkIsCompatible && fromAccountIsCompatible;
}
/**
* @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
*/
async getCommonConfiguration(fromAddress) {
const { type, nickname: name } = this.getProviderConfig();
const supportsEIP1559 = await this.getEIP1559Compatibility(fromAddress);
// This logic below will have to be updated each time a hardfork happens
// that carries with it a new Transaction type. It is inconsequential for
// hardforks that do not include new types.
const hardfork = supportsEIP1559 ? HARDFORKS.LONDON : HARDFORKS.BERLIN;
// type will be one of our default network names or 'rpc'. the default
// network names are sufficient configuration, simply pass the name as the
// chain argument in the constructor.
if (type !== NETWORK_TYPE_RPC) {
return new Common({
chain: type,
hardfork,
});
}
// For 'rpc' we need to use the same basic configuration as mainnet,
// since we only support EVM compatible chains, and then override the
// name, chainId and networkId properties. This is done using the
// `forCustomChain` static method on the Common class.
const chainId = parseInt(this._getCurrentChainId(), 16);
const networkId = this.networkStore.getState();
const customChainParams = {
name,
chainId,
// It is improbable for a transaction to be signed while the network
// is loading for two reasons.
// 1. Pending, unconfirmed transactions are wiped on network change
// 2. The UI is unusable (loading indicator) when network is loading.
// setting the networkId to 0 is for type safety and to explicity lead
// the transaction to failing if a user is able to get to this branch
// on a custom network that requires valid network id. I have not ran
// into this limitation on any network I have attempted, even when
// hardcoding networkId to 'loading'.
networkId: networkId === 'loading' ? 0 : parseInt(networkId, 10),
};
return Common.forCustomChain(MAINNET, customChainParams, hardfork);
}
/**
Adds a tx to the txlist
@emits ${txMeta.id}:unapproved
*/
addTransaction(txMeta) {
this.txStateManager.addTransaction(txMeta);
this.emit(`${txMeta.id}:unapproved`, txMeta);
this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.ADDED);
}
/**
Wipes the transactions for a given account
@param {string} address - hex string of the from address for txs being removed
*/
wipeTransactions(address) {
this.txStateManager.wipeTransactions(address);
}
/**
* Add a new unapproved transaction to the pipeline
*
* @returns {Promise} the hash of the transaction after being submitted to the network
* @param {Object} txParams - txParams for the transaction
* @param {Object} opts - with the key origin to put the origin on the txMeta
*/
async newUnapprovedTransaction(txParams, opts = {}) {
log.debug(
`MetaMaskController newUnapprovedTransaction ${JSON.stringify(txParams)}`,
);
const initialTxMeta = await this.addUnapprovedTransaction(
txParams,
opts.origin,
);
// listen for tx completion (success, fail)
return new Promise((resolve, reject) => {
this.txStateManager.once(
`${initialTxMeta.id}:finished`,
(finishedTxMeta) => {
switch (finishedTxMeta.status) {
case TRANSACTION_STATUSES.SUBMITTED:
return resolve(finishedTxMeta.hash);
case TRANSACTION_STATUSES.REJECTED:
return reject(
cleanErrorStack(
ethErrors.provider.userRejectedRequest(
'MetaMask Tx Signature: User denied transaction signature.',
),
),
);
case TRANSACTION_STATUSES.FAILED:
return reject(
cleanErrorStack(
ethErrors.rpc.internal(finishedTxMeta.err.message),
),
);
default:
return reject(
cleanErrorStack(
ethErrors.rpc.internal(
`MetaMask Tx Signature: Unknown problem: ${JSON.stringify(
finishedTxMeta.txParams,
)}`,
),
),
);
}
},
);
});
}
/**
* Validates and generates a txMeta with defaults and puts it in txStateManager
* store.
*
* @returns {txMeta}
*/
async addUnapprovedTransaction(txParams, origin) {
// validate
const normalizedTxParams = txUtils.normalizeTxParams(txParams);
const eip1559Compatibility = await this.getEIP1559Compatibility();
txUtils.validateTxParams(normalizedTxParams, eip1559Compatibility);
/**
`generateTxMeta` adds the default txMeta properties to the passed object.
These include the tx's `id`. As we use the id for determining order of
txes in the tx-state-manager, it is necessary to call the asynchronous
method `this._determineTransactionType` after `generateTxMeta`.
*/
let txMeta = this.txStateManager.generateTxMeta({
txParams: normalizedTxParams,
origin,
});
if (origin === 'metamask') {
// Assert the from address is the selected address
if (normalizedTxParams.from !== this.getSelectedAddress()) {
throw ethErrors.rpc.internal({
message: `Internally initiated transaction is using invalid account.`,
data: {
origin,
fromAddress: normalizedTxParams.from,
selectedAddress: this.getSelectedAddress(),
},
});
}
} else {
// Assert that the origin has permissions to initiate transactions from
// the specified address
const permittedAddresses = await this.getPermittedAccounts(origin);
if (!permittedAddresses.includes(normalizedTxParams.from)) {
throw ethErrors.provider.unauthorized({ data: { origin } });
}
}
const { type, getCodeResponse } = await this._determineTransactionType(
txParams,
);
txMeta.type = type;
// ensure value
txMeta.txParams.value = txMeta.txParams.value
? addHexPrefix(txMeta.txParams.value)
: '0x0';
this.addTransaction(txMeta);
this.emit('newUnapprovedTx', txMeta);
try {
txMeta = await this.addTxGasDefaults(txMeta, getCodeResponse);
} catch (error) {
log.warn(error);
txMeta = this.txStateManager.getTransaction(txMeta.id);
txMeta.loadingDefaults = false;
this.txStateManager.updateTransaction(
txMeta,
'Failed to calculate gas defaults.',
);
throw error;
}
txMeta.loadingDefaults = false;
// save txMeta
this.txStateManager.updateTransaction(
txMeta,
'Added new unapproved transaction.',
);
return txMeta;
}
/**
* Adds the tx gas defaults: gas && gasPrice
* @param {Object} txMeta - the txMeta object
* @returns {Promise