import { ObservableStore } from '@metamask/obs-store'; import log from 'loglevel'; import BN from 'bn.js'; import createId from '../../../shared/modules/random-id'; import { bnToHex } from '../lib/util'; import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import { TRANSACTION_TYPES, TRANSACTION_STATUSES, } from '../../../shared/constants/transaction'; import { CHAIN_ID_TO_NETWORK_ID_MAP, CHAIN_ID_TO_TYPE_MAP, GOERLI, GOERLI_CHAIN_ID, KOVAN, KOVAN_CHAIN_ID, MAINNET, MAINNET_CHAIN_ID, RINKEBY, RINKEBY_CHAIN_ID, ROPSTEN, ROPSTEN_CHAIN_ID, } from '../../../shared/constants/network'; import { NETWORK_EVENTS } from './network'; const fetchWithTimeout = getFetchWithTimeout(30000); /** * 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 the built-in Infura networks are supported (i.e. anything in `INFURA_PROVIDER_TYPES`). We will not * attempt to retrieve incoming transactions on any custom RPC endpoints. */ const etherscanSupportedNetworks = [ GOERLI_CHAIN_ID, KOVAN_CHAIN_ID, MAINNET_CHAIN_ID, RINKEBY_CHAIN_ID, ROPSTEN_CHAIN_ID, ]; export default class IncomingTransactionsController { constructor(opts = {}) { const { blockTracker, networkController, preferencesController } = opts; this.blockTracker = blockTracker; this.networkController = networkController; this.preferencesController = preferencesController; this._onLatestBlock = async (newBlockNumberHex) => { const selectedAddress = this.preferencesController.getSelectedAddress(); const newBlockNumberDec = parseInt(newBlockNumberHex, 16); await this._update({ address: selectedAddress, newBlockNumberDec, }); }; const initState = { incomingTransactions: {}, incomingTxLastFetchedBlocksByNetwork: { [GOERLI]: null, [KOVAN]: null, [MAINNET]: null, [RINKEBY]: null, [ROPSTEN]: null, }, ...opts.initState, }; this.store = new ObservableStore(initState); this.preferencesController.store.subscribe( pairwise((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.subscribe( pairwise(async (prevState, currState) => { const { selectedAddress: prevSelectedAddress } = prevState; const { selectedAddress: currSelectedAddress } = currState; if (currSelectedAddress === prevSelectedAddress) { return; } await this._update({ address: currSelectedAddress, }); }), ); this.networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, async () => { const address = this.preferencesController.getSelectedAddress(); await this._update({ address, }); }); } start() { const { featureFlags = {} } = this.preferencesController.store.getState(); const { showIncomingTransactions } = featureFlags; if (!showIncomingTransactions) { return; } this.blockTracker.removeListener('latest', this._onLatestBlock); this.blockTracker.addListener('latest', this._onLatestBlock); } stop() { this.blockTracker.removeListener('latest', this._onLatestBlock); } async _update({ address, newBlockNumberDec } = {}) { const chainId = this.networkController.getCurrentChainId(); if (!etherscanSupportedNetworks.includes(chainId)) { return; } try { const dataForUpdate = await this._getDataForUpdate({ address, chainId, newBlockNumberDec, }); this._updateStateWithNewTxData(dataForUpdate); } catch (err) { log.error(err); } } async _getDataForUpdate({ address, chainId, newBlockNumberDec } = {}) { const { incomingTransactions: currentIncomingTxs, incomingTxLastFetchedBlocksByNetwork: currentBlocksByNetwork, } = this.store.getState(); const lastFetchBlockByCurrentNetwork = currentBlocksByNetwork[CHAIN_ID_TO_TYPE_MAP[chainId]]; let blockToFetchFrom = lastFetchBlockByCurrentNetwork || newBlockNumberDec; if (blockToFetchFrom === undefined) { blockToFetchFrom = parseInt(this.blockTracker.getCurrentBlock(), 16); } const { latestIncomingTxBlockNumber, txs: newTxs } = await this._fetchAll( address, blockToFetchFrom, chainId, ); return { latestIncomingTxBlockNumber, newTxs, currentIncomingTxs, currentBlocksByNetwork, fetchedBlockNumber: blockToFetchFrom, chainId, }; } _updateStateWithNewTxData({ latestIncomingTxBlockNumber, newTxs, currentIncomingTxs, currentBlocksByNetwork, fetchedBlockNumber, chainId, }) { const newLatestBlockHashByNetwork = latestIncomingTxBlockNumber ? parseInt(latestIncomingTxBlockNumber, 10) + 1 : fetchedBlockNumber + 1; const newIncomingTransactions = { ...currentIncomingTxs, }; newTxs.forEach((tx) => { newIncomingTransactions[tx.hash] = tx; }); this.store.updateState({ incomingTxLastFetchedBlocksByNetwork: { ...currentBlocksByNetwork, [CHAIN_ID_TO_TYPE_MAP[chainId]]: newLatestBlockHashByNetwork, }, incomingTransactions: newIncomingTransactions, }); } async _fetchAll(address, fromBlock, chainId) { const fetchedTxResponse = await this._fetchTxs(address, fromBlock, chainId); return this._processTxFetchResponse(fetchedTxResponse); } async _fetchTxs(address, fromBlock, chainId) { const etherscanSubdomain = chainId === MAINNET_CHAIN_ID ? 'api' : `api-${CHAIN_ID_TO_TYPE_MAP[chainId]}`; const apiUrl = `https://${etherscanSubdomain}.etherscan.io`; 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 parsedResponse = await response.json(); return { ...parsedResponse, address, chainId, }; } _processTxFetchResponse({ status, result = [], address, chainId }) { 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; } }); const incomingTxs = remoteTxs.filter( (tx) => tx.txParams?.to?.toLowerCase() === address.toLowerCase(), ); incomingTxs.sort((a, b) => (a.time < b.time ? -1 : 1)); let latestIncomingTxBlockNumber = null; incomingTxs.forEach((tx) => { if ( tx.blockNumber && (!latestIncomingTxBlockNumber || parseInt(latestIncomingTxBlockNumber, 10) < parseInt(tx.blockNumber, 10)) ) { latestIncomingTxBlockNumber = tx.blockNumber; } }); return { latestIncomingTxBlockNumber, txs: incomingTxs, }; } return { latestIncomingTxBlockNumber: null, txs: [], }; } _normalizeTxFromEtherscan(txMeta, chainId) { const time = parseInt(txMeta.timeStamp, 10) * 1000; const status = txMeta.isError === '0' ? TRANSACTION_STATUSES.CONFIRMED : TRANSACTION_STATUSES.FAILED; return { blockNumber: txMeta.blockNumber, id: createId(), chainId, metamaskNetworkId: CHAIN_ID_TO_NETWORK_ID_MAP[chainId], status, time, txParams: { from: txMeta.from, gas: bnToHex(new BN(txMeta.gas)), gasPrice: bnToHex(new BN(txMeta.gasPrice)), nonce: bnToHex(new BN(txMeta.nonce)), to: txMeta.to, value: bnToHex(new BN(txMeta.value)), }, hash: txMeta.hash, type: TRANSACTION_TYPES.INCOMING, }; } } function pairwise(fn) { let first = true; let cache; return (value) => { try { if (first) { first = false; return fn(value, value); } return fn(cache, value); } finally { cache = value; } }; }