import { ObservableStore } from '@metamask/obs-store'; import log from 'loglevel'; import BN from 'bn.js'; import createId from '../../../shared/modules/random-id'; import { previousValueComparator } from '../lib/util'; import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import { TransactionType, TransactionStatus, } from '../../../shared/constants/transaction'; import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../shared/constants/network'; import { bnToHex } from '../../../shared/modules/conversion.utils'; const fetchWithTimeout = getFetchWithTimeout(); /** * @typedef {import('../../../shared/constants/transaction').TransactionMeta} TransactionMeta */ /** * A transaction object in the format returned by the Etherscan API. * * Note that this is not an exhaustive type definiton; only the properties we use are defined * * @typedef {object} EtherscanTransaction * @property {string} blockNumber - The number of the block this transaction was found in, in decimal * @property {string} from - The hex-prefixed address of the sender * @property {string} gas - The gas limit, in decimal GWEI * @property {string} [gasPrice] - The gas price, in decimal WEI * @property {string} [maxFeePerGas] - The maximum fee per gas, inclusive of tip, in decimal WEI * @property {string} [maxPriorityFeePerGas] - The maximum tip per gas in decimal WEI * @property {string} hash - The hex-prefixed transaction hash * @property {string} isError - Whether the transaction was confirmed or failed (0 for confirmed, 1 for failed) * @property {string} nonce - The transaction nonce, in decimal * @property {string} timeStamp - The timestamp for the transaction, in seconds * @property {string} to - The hex-prefixed address of the recipient * @property {string} value - The amount of ETH sent in this transaction, in decimal WEI */ /** * This controller is responsible for retrieving incoming transactions. Etherscan is polled once every block to check * for new incoming transactions for the current selected account on the current network * * Note that only Etherscan-compatible networks are supported. We will not attempt to retrieve incoming transactions * on non-compatible custom RPC endpoints. */ export default class IncomingTransactionsController { constructor(opts = {}) { const { blockTracker, onNetworkDidChange, getCurrentChainId, preferencesController, onboardingController, } = opts; this.blockTracker = blockTracker; this.getCurrentChainId = getCurrentChainId; this.preferencesController = preferencesController; this.onboardingController = onboardingController; this._onLatestBlock = async (newBlockNumberHex) => { const selectedAddress = this.preferencesController.getSelectedAddress(); const newBlockNumberDec = parseInt(newBlockNumberHex, 16); await this._update(selectedAddress, newBlockNumberDec); }; const incomingTxLastFetchedBlockByChainId = Object.keys( ETHERSCAN_SUPPORTED_NETWORKS, ).reduce((network, chainId) => { network[chainId] = null; return network; }, {}); const initState = { incomingTransactions: {}, incomingTxLastFetchedBlockByChainId, ...opts.initState, }; this.store = new ObservableStore(initState); this.preferencesController.store.subscribe( previousValueComparator((prevState, currState) => { const { featureFlags: { showIncomingTransactions: prevShowIncomingTransactions, } = {}, } = prevState; const { featureFlags: { showIncomingTransactions: currShowIncomingTransactions, } = {}, } = currState; if (currShowIncomingTransactions === prevShowIncomingTransactions) { return; } if (prevShowIncomingTransactions && !currShowIncomingTransactions) { this.stop(); return; } this.start(); }, this.preferencesController.store.getState()), ); this.preferencesController.store.subscribe( previousValueComparator(async (prevState, currState) => { const { selectedAddress: prevSelectedAddress } = prevState; const { selectedAddress: currSelectedAddress } = currState; if (currSelectedAddress === prevSelectedAddress) { return; } await this._update(currSelectedAddress); }, this.preferencesController.store.getState()), ); this.onboardingController.store.subscribe( previousValueComparator(async (prevState, currState) => { const { completedOnboarding: prevCompletedOnboarding } = prevState; const { completedOnboarding: currCompletedOnboarding } = currState; if (!prevCompletedOnboarding && currCompletedOnboarding) { const address = this.preferencesController.getSelectedAddress(); await this._update(address); } }, this.onboardingController.store.getState()), ); onNetworkDidChange(async () => { const address = this.preferencesController.getSelectedAddress(); await this._update(address); }); } start() { const chainId = this.getCurrentChainId(); if (this._allowedToMakeFetchIncomingTx(chainId)) { this.blockTracker.removeListener('latest', this._onLatestBlock); this.blockTracker.addListener('latest', this._onLatestBlock); } } stop() { this.blockTracker.removeListener('latest', this._onLatestBlock); } /** * Determines the correct block number to begin looking for new transactions * from, fetches the transactions and then saves them and the next block * number to begin fetching from in state. Block numbers and transactions are * stored per chainId. * * @private * @param {string} address - address to lookup transactions for * @param {number} [newBlockNumberDec] - block number to begin fetching from */ async _update(address, newBlockNumberDec) { const chainId = this.getCurrentChainId(); if (!address || !this._allowedToMakeFetchIncomingTx(chainId)) { return; } try { const currentState = this.store.getState(); const currentBlock = parseInt(this.blockTracker.getCurrentBlock(), 16); const mostRecentlyFetchedBlock = currentState.incomingTxLastFetchedBlockByChainId[chainId]; const blockToFetchFrom = mostRecentlyFetchedBlock ?? newBlockNumberDec ?? currentBlock; const newIncomingTxs = await this._getNewIncomingTransactions( address, blockToFetchFrom, chainId, ); let newMostRecentlyFetchedBlock = blockToFetchFrom; newIncomingTxs.forEach((tx) => { if ( tx.blockNumber && parseInt(newMostRecentlyFetchedBlock, 10) < parseInt(tx.blockNumber, 10) ) { newMostRecentlyFetchedBlock = parseInt(tx.blockNumber, 10); } }); this.store.updateState({ incomingTxLastFetchedBlockByChainId: { ...currentState.incomingTxLastFetchedBlockByChainId, [chainId]: newMostRecentlyFetchedBlock + 1, }, incomingTransactions: newIncomingTxs.reduce( (transactions, tx) => { transactions[tx.hash] = tx; return transactions; }, { ...currentState.incomingTransactions, }, ), }); } catch (err) { log.error(err); } } /** * fetches transactions for the given address and chain, via etherscan, then * processes the data into the necessary shape for usage in this controller. * * @private * @param {string} [address] - Address to fetch transactions for * @param {number} [fromBlock] - Block to look for transactions at * @param {string} [chainId] - The chainId for the current network * @returns {TransactionMeta[]} */ async _getNewIncomingTransactions(address, fromBlock, chainId) { const etherscanDomain = ETHERSCAN_SUPPORTED_NETWORKS[chainId].domain; const etherscanSubdomain = ETHERSCAN_SUPPORTED_NETWORKS[chainId].subdomain; const apiUrl = `https://${etherscanSubdomain}.${etherscanDomain}`; let url = `${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1`; if (fromBlock) { url += `&startBlock=${parseInt(fromBlock, 10)}`; } const response = await fetchWithTimeout(url); const { status, result } = await response.json(); let newIncomingTxs = []; if (status === '1' && Array.isArray(result) && result.length > 0) { const remoteTxList = {}; const remoteTxs = []; result.forEach((tx) => { if (!remoteTxList[tx.hash]) { remoteTxs.push(this._normalizeTxFromEtherscan(tx, chainId)); remoteTxList[tx.hash] = 1; } }); newIncomingTxs = remoteTxs.filter( (tx) => tx.txParams?.to?.toLowerCase() === address.toLowerCase(), ); newIncomingTxs.sort((a, b) => (a.time < b.time ? -1 : 1)); } return newIncomingTxs; } /** * Transmutes a EtherscanTransaction into a TransactionMeta * * @param {EtherscanTransaction} etherscanTransaction - the transaction to normalize * @param {string} chainId - The chainId of the current network * @returns {TransactionMeta} */ _normalizeTxFromEtherscan(etherscanTransaction, chainId) { const time = parseInt(etherscanTransaction.timeStamp, 10) * 1000; const status = etherscanTransaction.isError === '0' ? TransactionStatus.confirmed : TransactionStatus.failed; const txParams = { from: etherscanTransaction.from, gas: bnToHex(new BN(etherscanTransaction.gas)), nonce: bnToHex(new BN(etherscanTransaction.nonce)), to: etherscanTransaction.to, value: bnToHex(new BN(etherscanTransaction.value)), }; if (etherscanTransaction.gasPrice) { txParams.gasPrice = bnToHex(new BN(etherscanTransaction.gasPrice)); } else if (etherscanTransaction.maxFeePerGas) { txParams.maxFeePerGas = bnToHex( new BN(etherscanTransaction.maxFeePerGas), ); txParams.maxPriorityFeePerGas = bnToHex( new BN(etherscanTransaction.maxPriorityFeePerGas), ); } return { blockNumber: etherscanTransaction.blockNumber, id: createId(), chainId, metamaskNetworkId: ETHERSCAN_SUPPORTED_NETWORKS[chainId].networkId, status, time, txParams, hash: etherscanTransaction.hash, type: TransactionType.incoming, }; } /** * @param chainId - {string} The chainId of the current network * @returns {boolean} Whether or not the user has consented to show incoming transactions */ _allowedToMakeFetchIncomingTx(chainId) { const { featureFlags = {} } = this.preferencesController.store.getState(); const { completedOnboarding } = this.onboardingController.store.getState(); const hasIncomingTransactionsFeatureEnabled = Boolean( featureFlags.showIncomingTransactions, ); const isEtherscanSupportedNetwork = Boolean( ETHERSCAN_SUPPORTED_NETWORKS[chainId], ); return ( completedOnboarding && isEtherscanSupportedNetwork && hasIncomingTransactionsFeatureEnabled ); } }