From 37209a7d2e9529e242f800863598197727589fb7 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 22 Aug 2023 10:17:07 +0100 Subject: [PATCH] Replace IncomingTransactionsController with helper (#20378) Remove the IncomingTransactionController and replace it with an internal helper class. Move incoming transactions into the central transactions object. Create a new RemoteTransactionSource interface to decouple incoming transaction support from Etherscan. Split the incoming transaction logic into multiple files for easier maintenance. --- .../controllers/incoming-transactions.js | 320 ---- .../controllers/incoming-transactions.test.js | 1448 ----------------- .../EtherscanRemoteTransactionSource.test.ts | 219 +++ .../EtherscanRemoteTransactionSource.ts | 156 ++ .../IncomingTransactionHelper.test.ts | 585 +++++++ .../transactions/IncomingTransactionHelper.ts | 282 ++++ .../transactions/etherscan.test.ts | 153 ++ .../controllers/transactions/etherscan.ts | 205 +++ app/scripts/controllers/transactions/index.js | 81 +- .../controllers/transactions/index.test.js | 103 +- app/scripts/controllers/transactions/types.ts | 49 + app/scripts/lib/setupSentry.js | 3 - app/scripts/metamask-controller.js | 113 +- app/scripts/metamask-controller.test.js | 83 + app/scripts/migrations/095.test.ts | 364 +++++ app/scripts/migrations/095.ts | 94 ++ app/scripts/migrations/index.js | 2 + jest.config.js | 6 + shared/constants/transaction.ts | 3 +- test/e2e/fixture-builder.js | 85 +- test/e2e/tests/clear-activity.spec.js | 3 +- ...rs-after-init-opt-in-background-state.json | 10 - .../errors-after-init-opt-in-ui-state.json | 9 +- ...s-before-init-opt-in-background-state.json | 10 - .../errors-before-init-opt-in-ui-state.json | 10 - .../account-details-modal.test.js | 1 - ...nonce-sorted-transactions-selector.test.js | 2 +- ui/selectors/transactions.js | 5 +- ui/store/actions.ts | 4 +- 29 files changed, 2491 insertions(+), 1917 deletions(-) delete mode 100644 app/scripts/controllers/incoming-transactions.js delete mode 100644 app/scripts/controllers/incoming-transactions.test.js create mode 100644 app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts create mode 100644 app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts create mode 100644 app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts create mode 100644 app/scripts/controllers/transactions/IncomingTransactionHelper.ts create mode 100644 app/scripts/controllers/transactions/etherscan.test.ts create mode 100644 app/scripts/controllers/transactions/etherscan.ts create mode 100644 app/scripts/controllers/transactions/types.ts create mode 100644 app/scripts/migrations/095.test.ts create mode 100644 app/scripts/migrations/095.ts diff --git a/app/scripts/controllers/incoming-transactions.js b/app/scripts/controllers/incoming-transactions.js deleted file mode 100644 index 9b0b85f33..000000000 --- a/app/scripts/controllers/incoming-transactions.js +++ /dev/null @@ -1,320 +0,0 @@ -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 - ); - } -} diff --git a/app/scripts/controllers/incoming-transactions.test.js b/app/scripts/controllers/incoming-transactions.test.js deleted file mode 100644 index c46c3190b..000000000 --- a/app/scripts/controllers/incoming-transactions.test.js +++ /dev/null @@ -1,1448 +0,0 @@ -import { strict as assert } from 'assert'; -import sinon from 'sinon'; -import proxyquire from 'proxyquire'; -import nock from 'nock'; -import { cloneDeep } from 'lodash'; - -import waitUntilCalled from '../../../test/lib/wait-until-called'; -import { - ETHERSCAN_SUPPORTED_NETWORKS, - CHAIN_IDS, - NETWORK_TYPES, - NETWORK_IDS, -} from '../../../shared/constants/network'; -import { - TransactionType, - TransactionStatus, -} from '../../../shared/constants/transaction'; -import { MILLISECOND } from '../../../shared/constants/time'; - -const IncomingTransactionsController = proxyquire('./incoming-transactions', { - '../../../shared/modules/random-id': { default: () => 54321 }, -}).default; - -const FAKE_CHAIN_ID = '0x1338'; -const MOCK_SELECTED_ADDRESS = '0x0101'; -const SET_STATE_TIMEOUT = MILLISECOND * 10; - -const EXISTING_INCOMING_TX = { id: 777, hash: '0x123456' }; -const PREPOPULATED_INCOMING_TXS_BY_HASH = { - [EXISTING_INCOMING_TX.hash]: EXISTING_INCOMING_TX, -}; -const PREPOPULATED_BLOCKS_BY_NETWORK = { - [CHAIN_IDS.GOERLI]: 1, - [CHAIN_IDS.MAINNET]: 3, - [CHAIN_IDS.SEPOLIA]: 6, -}; -const EMPTY_BLOCKS_BY_NETWORK = Object.keys( - ETHERSCAN_SUPPORTED_NETWORKS, -).reduce((network, chainId) => { - network[chainId] = null; - return network; -}, {}); - -function getEmptyInitState() { - return { - incomingTransactions: {}, - incomingTxLastFetchedBlockByChainId: EMPTY_BLOCKS_BY_NETWORK, - }; -} - -function getNonEmptyInitState() { - return { - incomingTransactions: PREPOPULATED_INCOMING_TXS_BY_HASH, - incomingTxLastFetchedBlockByChainId: PREPOPULATED_BLOCKS_BY_NETWORK, - }; -} - -function getMockNetworkControllerMethods(chainId = FAKE_CHAIN_ID) { - return { - getCurrentChainId: () => chainId, - onNetworkDidChange: sinon.spy(), - }; -} - -function getMockPreferencesController({ - showIncomingTransactions = true, -} = {}) { - return { - getSelectedAddress: sinon.stub().returns(MOCK_SELECTED_ADDRESS), - store: { - getState: sinon.stub().returns({ - featureFlags: { - showIncomingTransactions, - }, - }), - subscribe: sinon.spy(), - }, - }; -} - -function getMockOnboardingController({ completedOnboarding = true } = {}) { - return { - store: { - getState: sinon.stub().returns({ - completedOnboarding, - }), - subscribe: sinon.spy(), - }, - }; -} - -function getMockBlockTracker() { - return { - addListener: sinon.stub().callsArgWithAsync(1, '0xa'), - removeListener: sinon.spy(), - testProperty: 'fakeBlockTracker', - getCurrentBlock: () => '0xa', - }; -} - -function getDefaultControllerOpts() { - return { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getEmptyInitState(), - }; -} - -/** - * @typedef {import( - * '../../../../app/scripts/controllers/incoming-transactions' - * ).EtherscanTransaction} EtherscanTransaction - */ - -/** - * Returns a transaction object matching the expected format returned - * by the Etherscan API - * - * @param {object} [params] - options bag - * @param {string} [params.toAddress] - The hex-prefixed address of the recipient - * @param {number} [params.blockNumber] - The block number for the transaction - * @param {boolean} [params.useEIP1559] - Use EIP-1559 gas fields - * @param params.hash - * @returns {EtherscanTransaction} - */ -const getFakeEtherscanTransaction = ({ - toAddress = MOCK_SELECTED_ADDRESS, - blockNumber = 10, - useEIP1559 = false, - hash = '0xfake', -} = {}) => { - if (useEIP1559) { - return { - blockNumber: blockNumber.toString(), - from: '0xfake', - gas: '0', - maxFeePerGas: '10', - maxPriorityFeePerGas: '1', - hash, - isError: '0', - nonce: '100', - timeStamp: '16000000000000', - to: toAddress, - value: '0', - }; - } - return { - blockNumber: blockNumber.toString(), - from: '0xfake', - gas: '0', - gasPrice: '0', - hash: '0xfake', - isError: '0', - nonce: '100', - timeStamp: '16000000000000', - to: toAddress, - value: '0', - }; -}; - -function nockEtherscanApiForAllChains(mockResponse) { - Object.values(ETHERSCAN_SUPPORTED_NETWORKS).forEach( - ({ domain, subdomain }) => { - nock(`https://${domain}.${subdomain}`) - .get(/api.+/u) - .reply(200, JSON.stringify(mockResponse)); - }, - ); -} - -describe('IncomingTransactionsController', function () { - afterEach(function () { - sinon.restore(); - nock.cleanAll(); - }); - - describe('constructor', function () { - it('should set up correct store, listeners and properties in the constructor', function () { - const mockedNetworkMethods = getMockNetworkControllerMethods(); - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...mockedNetworkMethods, - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: {}, - }, - ); - sinon.spy(incomingTransactionsController, '_update'); - - assert.deepStrictEqual( - incomingTransactionsController.store.getState(), - getEmptyInitState(), - ); - - assert(mockedNetworkMethods.onNetworkDidChange.calledOnce); - const networkControllerListenerCallback = - mockedNetworkMethods.onNetworkDidChange.getCall(0).args[0]; - assert.strictEqual(incomingTransactionsController._update.callCount, 0); - networkControllerListenerCallback('testNetworkType'); - assert.strictEqual(incomingTransactionsController._update.callCount, 1); - assert.deepStrictEqual( - incomingTransactionsController._update.getCall(0).args[0], - '0x0101', - ); - - incomingTransactionsController._update.resetHistory(); - }); - - it('should set the store to a provided initial state', function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - assert.deepStrictEqual( - incomingTransactionsController.store.getState(), - getNonEmptyInitState(), - ); - }); - }); - - describe('update events', function () { - it('should set up a listener for the latest block', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: {}, - getCurrentChainId: () => CHAIN_IDS.GOERLI, - }, - ); - - incomingTransactionsController.start(); - - assert( - incomingTransactionsController.blockTracker.addListener.calledOnce, - ); - assert.strictEqual( - incomingTransactionsController.blockTracker.addListener.getCall(0) - .args[0], - 'latest', - ); - }); - - it('should update upon latest block when started and on supported network', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - const startBlock = - getNonEmptyInitState().incomingTxLastFetchedBlockByChainId[ - CHAIN_IDS.GOERLI - ]; - nock('https://api-goerli.etherscan.io') - .get( - `/api?module=account&action=txlist&address=${MOCK_SELECTED_ADDRESS}&tag=latest&page=1&startBlock=${startBlock}`, - ) - .reply( - 200, - JSON.stringify({ - status: '1', - result: [ - getFakeEtherscanTransaction(), - getFakeEtherscanTransaction({ - hash: '0xfakeeip1559', - useEIP1559: true, - }), - ], - }), - ); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - - incomingTransactionsController.start(); - await updateStateCalled(); - - const actualState = incomingTransactionsController.store.getState(); - const generatedTxId = actualState?.incomingTransactions?.['0xfake']?.id; - - const actualStateWithoutGenerated = cloneDeep(actualState); - delete actualStateWithoutGenerated?.incomingTransactions?.['0xfake']?.id; - delete actualStateWithoutGenerated?.incomingTransactions?.[ - '0xfakeeip1559' - ]?.id; - - assert.ok( - typeof generatedTxId === 'number' && generatedTxId > 0, - 'Generated transaction ID should be a positive number', - ); - assert.deepStrictEqual( - actualStateWithoutGenerated, - { - incomingTransactions: { - ...getNonEmptyInitState().incomingTransactions, - '0xfake': { - blockNumber: '10', - hash: '0xfake', - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.confirmed, - time: 16000000000000000, - type: TransactionType.incoming, - txParams: { - from: '0xfake', - gas: '0x0', - gasPrice: '0x0', - nonce: '0x64', - to: '0x0101', - value: '0x0', - }, - }, - '0xfakeeip1559': { - blockNumber: '10', - hash: '0xfakeeip1559', - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.confirmed, - time: 16000000000000000, - type: TransactionType.incoming, - txParams: { - from: '0xfake', - gas: '0x0', - maxFeePerGas: '0xa', - maxPriorityFeePerGas: '0x1', - nonce: '0x64', - to: '0x0101', - value: '0x0', - }, - }, - }, - incomingTxLastFetchedBlockByChainId: { - ...getNonEmptyInitState().incomingTxLastFetchedBlockByChainId, - [CHAIN_IDS.GOERLI]: 11, - }, - }, - 'State should have been updated after first block was received', - ); - }); - - it('should not update upon latest block when started and not on supported network', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - // reply with a valid request for any supported network, so that this test has every opportunity to fail - nockEtherscanApiForAllChains({ - status: '1', - result: [getFakeEtherscanTransaction()], - }); - - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - const putStateStub = sinon.stub( - incomingTransactionsController.store, - 'putState', - ); - const putStateCalled = waitUntilCalled( - putStateStub, - incomingTransactionsController.store, - ); - - incomingTransactionsController.start(); - - try { - await Promise.race([ - updateStateCalled(), - putStateCalled(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('TIMEOUT')), SET_STATE_TIMEOUT); - }), - ]); - assert.fail('Update state should not have been called'); - } catch (error) { - assert(error.message === 'TIMEOUT', 'TIMEOUT error should be thrown'); - } - }); - - it('should not update upon latest block when started and incoming transactions disabled', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(), - preferencesController: getMockPreferencesController({ - showIncomingTransactions: false, - }), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - // reply with a valid request for any supported network, so that this test has every opportunity to fail - nockEtherscanApiForAllChains({ - status: '1', - result: [getFakeEtherscanTransaction()], - }); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - const putStateStub = sinon.stub( - incomingTransactionsController.store, - 'putState', - ); - const putStateCalled = waitUntilCalled( - putStateStub, - incomingTransactionsController.store, - ); - - incomingTransactionsController.start(); - - try { - await Promise.race([ - updateStateCalled(), - putStateCalled(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('TIMEOUT')), SET_STATE_TIMEOUT); - }), - ]); - assert.fail('Update state should not have been called'); - } catch (error) { - assert(error.message === 'TIMEOUT', 'TIMEOUT error should be thrown'); - } - }); - - it('should not update upon latest block when not started', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - // reply with a valid request for any supported network, so that this test has every opportunity to fail - nockEtherscanApiForAllChains({ - status: '1', - result: [getFakeEtherscanTransaction()], - }); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - const putStateStub = sinon.stub( - incomingTransactionsController.store, - 'putState', - ); - const putStateCalled = waitUntilCalled( - putStateStub, - incomingTransactionsController.store, - ); - - try { - await Promise.race([ - updateStateCalled(), - putStateCalled(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('TIMEOUT')), SET_STATE_TIMEOUT); - }), - ]); - assert.fail('Update state should not have been called'); - } catch (error) { - assert(error.message === 'TIMEOUT', 'TIMEOUT error should be thrown'); - } - }); - - it('should not update upon latest block when stopped', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - // reply with a valid request for any supported network, so that this test has every opportunity to fail - nockEtherscanApiForAllChains({ - status: '1', - result: [getFakeEtherscanTransaction()], - }); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - const putStateStub = sinon.stub( - incomingTransactionsController.store, - 'putState', - ); - const putStateCalled = waitUntilCalled( - putStateStub, - incomingTransactionsController.store, - ); - - incomingTransactionsController.stop(); - - try { - await Promise.race([ - updateStateCalled(), - putStateCalled(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('TIMEOUT')), SET_STATE_TIMEOUT); - }), - ]); - assert.fail('Update state should not have been called'); - } catch (error) { - assert(error.message === 'TIMEOUT', 'TIMEOUT error should be thrown'); - } - }); - - it('should update when the selected address changes and on supported network', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - const NEW_MOCK_SELECTED_ADDRESS = `${MOCK_SELECTED_ADDRESS}9`; - const startBlock = - getNonEmptyInitState().incomingTxLastFetchedBlockByChainId[ - CHAIN_IDS.GOERLI - ]; - nock('https://api-goerli.etherscan.io') - .get( - `/api?module=account&action=txlist&address=${NEW_MOCK_SELECTED_ADDRESS}&tag=latest&page=1&startBlock=${startBlock}`, - ) - .reply( - 200, - JSON.stringify({ - status: '1', - result: [ - getFakeEtherscanTransaction({ - toAddress: NEW_MOCK_SELECTED_ADDRESS, - }), - ], - }), - ); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - - const subscription = - incomingTransactionsController.preferencesController.store.subscribe.getCall( - 1, - ).args[0]; - // The incoming transactions controller will always skip the first event - // We need to call subscription twice to test the event handling - // TODO: stop skipping the first event - await subscription({ selectedAddress: MOCK_SELECTED_ADDRESS }); - await subscription({ selectedAddress: NEW_MOCK_SELECTED_ADDRESS }); - await updateStateCalled(); - - const actualState = incomingTransactionsController.store.getState(); - const generatedTxId = actualState?.incomingTransactions?.['0xfake']?.id; - - const actualStateWithoutGenerated = cloneDeep(actualState); - delete actualStateWithoutGenerated?.incomingTransactions?.['0xfake']?.id; - - assert.ok( - typeof generatedTxId === 'number' && generatedTxId > 0, - 'Generated transaction ID should be a positive number', - ); - assert.deepStrictEqual( - actualStateWithoutGenerated, - { - incomingTransactions: { - ...getNonEmptyInitState().incomingTransactions, - '0xfake': { - blockNumber: '10', - hash: '0xfake', - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.confirmed, - time: 16000000000000000, - type: TransactionType.incoming, - txParams: { - from: '0xfake', - gas: '0x0', - gasPrice: '0x0', - nonce: '0x64', - to: '0x01019', - value: '0x0', - }, - }, - }, - incomingTxLastFetchedBlockByChainId: { - ...getNonEmptyInitState().incomingTxLastFetchedBlockByChainId, - [CHAIN_IDS.GOERLI]: 11, - }, - }, - 'State should have been updated after first block was received', - ); - }); - - it('should not update when the selected address changes and not on supported network', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: { ...getMockBlockTracker() }, - ...getMockNetworkControllerMethods(), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - const NEW_MOCK_SELECTED_ADDRESS = `${MOCK_SELECTED_ADDRESS}9`; - // reply with a valid request for any supported network, so that this test has every opportunity to fail - nockEtherscanApiForAllChains({ - status: '1', - result: [ - getFakeEtherscanTransaction({ toAddress: NEW_MOCK_SELECTED_ADDRESS }), - ], - }); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - const putStateStub = sinon.stub( - incomingTransactionsController.store, - 'putState', - ); - const putStateCalled = waitUntilCalled( - putStateStub, - incomingTransactionsController.store, - ); - - const subscription = - incomingTransactionsController.preferencesController.store.subscribe.getCall( - 1, - ).args[0]; - // The incoming transactions controller will always skip the first event - // We need to call subscription twice to test the event handling - // TODO: stop skipping the first event - await subscription({ selectedAddress: MOCK_SELECTED_ADDRESS }); - await subscription({ selectedAddress: NEW_MOCK_SELECTED_ADDRESS }); - - try { - await Promise.race([ - updateStateCalled(), - putStateCalled(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('TIMEOUT')), SET_STATE_TIMEOUT); - }), - ]); - assert.fail('Update state should not have been called'); - } catch (error) { - assert(error.message === 'TIMEOUT', 'TIMEOUT error should be thrown'); - } - }); - - it('should update when switching to a supported network', async function () { - const mockedNetworkMethods = getMockNetworkControllerMethods( - CHAIN_IDS.GOERLI, - ); - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...mockedNetworkMethods, - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - const startBlock = - getNonEmptyInitState().incomingTxLastFetchedBlockByChainId[ - CHAIN_IDS.GOERLI - ]; - nock('https://api-goerli.etherscan.io') - .get( - `/api?module=account&action=txlist&address=${MOCK_SELECTED_ADDRESS}&tag=latest&page=1&startBlock=${startBlock}`, - ) - .reply( - 200, - JSON.stringify({ - status: '1', - result: [getFakeEtherscanTransaction()], - }), - ); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - - const subscription = - mockedNetworkMethods.onNetworkDidChange.getCall(0).args[0]; - await subscription(CHAIN_IDS.GOERLI); - await updateStateCalled(); - - const actualState = incomingTransactionsController.store.getState(); - const generatedTxId = actualState?.incomingTransactions?.['0xfake']?.id; - - const actualStateWithoutGenerated = cloneDeep(actualState); - delete actualStateWithoutGenerated?.incomingTransactions?.['0xfake']?.id; - - assert.ok( - typeof generatedTxId === 'number' && generatedTxId > 0, - 'Generated transaction ID should be a positive number', - ); - assert.deepStrictEqual( - actualStateWithoutGenerated, - { - incomingTransactions: { - ...getNonEmptyInitState().incomingTransactions, - '0xfake': { - blockNumber: '10', - hash: '0xfake', - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.confirmed, - time: 16000000000000000, - type: TransactionType.incoming, - txParams: { - from: '0xfake', - gas: '0x0', - gasPrice: '0x0', - nonce: '0x64', - to: '0x0101', - value: '0x0', - }, - }, - }, - incomingTxLastFetchedBlockByChainId: { - ...getNonEmptyInitState().incomingTxLastFetchedBlockByChainId, - [CHAIN_IDS.GOERLI]: 11, - }, - }, - 'State should have been updated after first block was received', - ); - }); - - it('should not update when switching to an unsupported network', async function () { - const mockedNetworkMethods = getMockNetworkControllerMethods( - CHAIN_IDS.GOERLI, - ); - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...mockedNetworkMethods, - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - // reply with a valid request for any supported network, so that this test has every opportunity to fail - nockEtherscanApiForAllChains({ - status: '1', - result: [getFakeEtherscanTransaction()], - }); - const updateStateStub = sinon.stub( - incomingTransactionsController.store, - 'updateState', - ); - const updateStateCalled = waitUntilCalled( - updateStateStub, - incomingTransactionsController.store, - ); - const putStateStub = sinon.stub( - incomingTransactionsController.store, - 'putState', - ); - const putStateCalled = waitUntilCalled( - putStateStub, - incomingTransactionsController.store, - ); - - const subscription = - mockedNetworkMethods.onNetworkDidChange.getCall(0).args[0]; - - incomingTransactionsController.getCurrentChainId = () => FAKE_CHAIN_ID; - await subscription(); - - try { - await Promise.race([ - updateStateCalled(), - putStateCalled(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('TIMEOUT')), SET_STATE_TIMEOUT); - }), - ]); - assert.fail('Update state should not have been called'); - } catch (error) { - assert(error.message === 'TIMEOUT', 'TIMEOUT error should be thrown'); - } - }); - }); - - describe('block explorer lookup', function () { - let sandbox; - - beforeEach(function () { - sandbox = sinon.createSandbox(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - function stubFetch() { - return sandbox.stub(window, 'fetch'); - } - - function assertStubNotCalled(stub) { - assert(stub.callCount === 0); - } - - async function triggerUpdate(incomingTransactionsController) { - const subscription = - incomingTransactionsController.preferencesController.store.subscribe.getCall( - 1, - ).args[0]; - - // Sets address causing a call to _update - await subscription({ selectedAddress: MOCK_SELECTED_ADDRESS }); - } - - it('should not happen when incoming transactions feature is disabled', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - ...getDefaultControllerOpts(), - preferencesController: getMockPreferencesController({ - showIncomingTransactions: false, - }), - }, - ); - const fetchStub = stubFetch(); - await triggerUpdate(incomingTransactionsController); - assertStubNotCalled(fetchStub); - }); - - it('should not happen when onboarding is in progress', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - ...getDefaultControllerOpts(), - onboardingController: getMockOnboardingController({ - completedOnboarding: false, - }), - }, - ); - - const fetchStub = stubFetch(); - await triggerUpdate(incomingTransactionsController); - assertStubNotCalled(fetchStub); - }); - - it('should not happen when chain id is not supported', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - ...getDefaultControllerOpts(), - getCurrentChainId: () => FAKE_CHAIN_ID, - }, - ); - - const fetchStub = stubFetch(); - await triggerUpdate(incomingTransactionsController); - assertStubNotCalled(fetchStub); - }); - - it('should make api call when chain id, incoming features, and onboarding status are ok', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - ...getDefaultControllerOpts(), - getCurrentChainId: () => CHAIN_IDS.GOERLI, - onboardingController: getMockOnboardingController({ - completedOnboarding: true, - }), - preferencesController: getMockPreferencesController({ - showIncomingTransactions: true, - }), - }, - ); - - const fetchStub = stubFetch(); - await triggerUpdate(incomingTransactionsController); - assert(fetchStub.callCount === 1); - }); - }); - - describe('_update', function () { - describe('when state is empty (initialized)', function () { - it('should use provided block number and update the latest block seen', async function () { - const incomingTransactionsController = - new IncomingTransactionsController({ - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getEmptyInitState(), - getCurrentChainId: () => CHAIN_IDS.GOERLI, - }); - sinon.spy(incomingTransactionsController.store, 'updateState'); - - incomingTransactionsController._getNewIncomingTransactions = sinon - .stub() - .returns([]); - - await incomingTransactionsController._update('fakeAddress', 999); - assert( - incomingTransactionsController._getNewIncomingTransactions.calledOnce, - ); - assert.deepStrictEqual( - incomingTransactionsController._getNewIncomingTransactions.getCall(0) - .args, - ['fakeAddress', 999, CHAIN_IDS.GOERLI], - ); - assert.deepStrictEqual( - incomingTransactionsController.store.updateState.getCall(0).args[0], - { - incomingTxLastFetchedBlockByChainId: { - ...EMPTY_BLOCKS_BY_NETWORK, - [CHAIN_IDS.GOERLI]: 1000, - }, - incomingTransactions: {}, - }, - ); - }); - - it('should update the last fetched block for network to highest block seen in incoming txs', async function () { - const incomingTransactionsController = - new IncomingTransactionsController({ - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getEmptyInitState(), - getCurrentChainId: () => CHAIN_IDS.GOERLI, - }); - - const NEW_TRANSACTION_ONE = { - id: 555, - hash: '0xfff', - blockNumber: 444, - }; - const NEW_TRANSACTION_TWO = { - id: 556, - hash: '0xffa', - blockNumber: 443, - }; - - sinon.spy(incomingTransactionsController.store, 'updateState'); - - incomingTransactionsController._getNewIncomingTransactions = sinon - .stub() - .returns([NEW_TRANSACTION_ONE, NEW_TRANSACTION_TWO]); - await incomingTransactionsController._update('fakeAddress', 10); - - assert(incomingTransactionsController.store.updateState.calledOnce); - - assert.deepStrictEqual( - incomingTransactionsController._getNewIncomingTransactions.getCall(0) - .args, - ['fakeAddress', 10, CHAIN_IDS.GOERLI], - ); - - assert.deepStrictEqual( - incomingTransactionsController.store.updateState.getCall(0).args[0], - { - incomingTxLastFetchedBlockByChainId: { - ...EMPTY_BLOCKS_BY_NETWORK, - [CHAIN_IDS.GOERLI]: 445, - }, - incomingTransactions: { - [NEW_TRANSACTION_ONE.hash]: NEW_TRANSACTION_ONE, - [NEW_TRANSACTION_TWO.hash]: NEW_TRANSACTION_TWO, - }, - }, - ); - }); - }); - - describe('when state is populated with prior data for network', function () { - it('should use the last fetched block for the current network and increment by 1 in state', async function () { - const incomingTransactionsController = - new IncomingTransactionsController({ - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - getCurrentChainId: () => CHAIN_IDS.GOERLI, - }); - sinon.spy(incomingTransactionsController.store, 'updateState'); - incomingTransactionsController._getNewIncomingTransactions = sinon - .stub() - .returns([]); - - await incomingTransactionsController._update('fakeAddress', 999); - - assert( - incomingTransactionsController._getNewIncomingTransactions.calledOnce, - ); - - assert.deepStrictEqual( - incomingTransactionsController._getNewIncomingTransactions.getCall(0) - .args, - ['fakeAddress', 1, CHAIN_IDS.GOERLI], - ); - - assert.deepStrictEqual( - incomingTransactionsController.store.updateState.getCall(0).args[0], - { - incomingTxLastFetchedBlockByChainId: { - ...PREPOPULATED_BLOCKS_BY_NETWORK, - [CHAIN_IDS.GOERLI]: - PREPOPULATED_BLOCKS_BY_NETWORK[CHAIN_IDS.GOERLI] + 1, - }, - incomingTransactions: PREPOPULATED_INCOMING_TXS_BY_HASH, - }, - ); - }); - }); - - it('should update the last fetched block for network to highest block seen in incoming txs', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - getCurrentChainId: () => CHAIN_IDS.GOERLI, - }, - ); - - const NEW_TRANSACTION_ONE = { - id: 555, - hash: '0xfff', - blockNumber: 444, - }; - const NEW_TRANSACTION_TWO = { - id: 556, - hash: '0xffa', - blockNumber: 443, - }; - - sinon.spy(incomingTransactionsController.store, 'updateState'); - - incomingTransactionsController._getNewIncomingTransactions = sinon - .stub() - .returns([NEW_TRANSACTION_ONE, NEW_TRANSACTION_TWO]); - await incomingTransactionsController._update('fakeAddress', 10); - - assert(incomingTransactionsController.store.updateState.calledOnce); - - assert.deepStrictEqual( - incomingTransactionsController._getNewIncomingTransactions.getCall(0) - .args, - ['fakeAddress', 1, CHAIN_IDS.GOERLI], - ); - - assert.deepStrictEqual( - incomingTransactionsController.store.updateState.getCall(0).args[0], - { - incomingTxLastFetchedBlockByChainId: { - ...PREPOPULATED_BLOCKS_BY_NETWORK, - [CHAIN_IDS.GOERLI]: 445, - }, - incomingTransactions: { - ...PREPOPULATED_INCOMING_TXS_BY_HASH, - [NEW_TRANSACTION_ONE.hash]: NEW_TRANSACTION_ONE, - [NEW_TRANSACTION_TWO.hash]: NEW_TRANSACTION_TWO, - }, - }, - ); - }); - }); - - describe('_getNewIncomingTransactions', function () { - const ADDRESS_TO_FETCH_FOR = '0xfakeaddress'; - const FETCHED_TX = getFakeEtherscanTransaction({ - toAddress: ADDRESS_TO_FETCH_FOR, - }); - const mockFetch = sinon.stub().returns( - Promise.resolve({ - json: () => Promise.resolve({ status: '1', result: [FETCHED_TX] }), - }), - ); - let tempFetch; - beforeEach(function () { - tempFetch = window.fetch; - window.fetch = mockFetch; - }); - - afterEach(function () { - window.fetch = tempFetch; - mockFetch.resetHistory(); - }); - - it('should call fetch with the expected url when passed an address, block number and supported chainId', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - await incomingTransactionsController._getNewIncomingTransactions( - ADDRESS_TO_FETCH_FOR, - '789', - CHAIN_IDS.GOERLI, - ); - - assert(mockFetch.calledOnce); - assert.strictEqual( - mockFetch.getCall(0).args[0], - `https://api-${NETWORK_TYPES.GOERLI}.etherscan.io/api?module=account&action=txlist&address=0xfakeaddress&tag=latest&page=1&startBlock=789`, - ); - }); - - it('should call fetch with the expected url when passed an address, block number and MAINNET chainId', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.MAINNET), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - await incomingTransactionsController._getNewIncomingTransactions( - ADDRESS_TO_FETCH_FOR, - '789', - CHAIN_IDS.MAINNET, - ); - - assert(mockFetch.calledOnce); - assert.strictEqual( - mockFetch.getCall(0).args[0], - `https://api.etherscan.io/api?module=account&action=txlist&address=0xfakeaddress&tag=latest&page=1&startBlock=789`, - ); - }); - - it('should call fetch with the expected url when passed an address and supported chainId, but a falsy block number', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - await incomingTransactionsController._getNewIncomingTransactions( - ADDRESS_TO_FETCH_FOR, - null, - CHAIN_IDS.GOERLI, - ); - - assert(mockFetch.calledOnce); - assert.strictEqual( - mockFetch.getCall(0).args[0], - `https://api-${NETWORK_TYPES.GOERLI}.etherscan.io/api?module=account&action=txlist&address=0xfakeaddress&tag=latest&page=1`, - ); - }); - - it('should return an array of normalized transactions', async function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - const result = - await incomingTransactionsController._getNewIncomingTransactions( - ADDRESS_TO_FETCH_FOR, - '789', - CHAIN_IDS.GOERLI, - ); - - assert(mockFetch.calledOnce); - assert.deepStrictEqual(result, [ - incomingTransactionsController._normalizeTxFromEtherscan( - FETCHED_TX, - CHAIN_IDS.GOERLI, - ), - ]); - }); - - it('should return empty tx array if status is 0', async function () { - const mockFetchStatusZero = sinon.stub().returns( - Promise.resolve({ - json: () => Promise.resolve({ status: '0', result: [FETCHED_TX] }), - }), - ); - const tempFetchStatusZero = window.fetch; - window.fetch = mockFetchStatusZero; - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - const result = - await incomingTransactionsController._getNewIncomingTransactions( - ADDRESS_TO_FETCH_FOR, - '789', - CHAIN_IDS.GOERLI, - ); - assert.deepStrictEqual(result, []); - window.fetch = tempFetchStatusZero; - mockFetchStatusZero.reset(); - }); - - it('should return empty tx array if result array is empty', async function () { - const mockFetchEmptyResult = sinon.stub().returns( - Promise.resolve({ - json: () => Promise.resolve({ status: '1', result: [] }), - }), - ); - const tempFetchEmptyResult = window.fetch; - window.fetch = mockFetchEmptyResult; - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - const result = - await incomingTransactionsController._getNewIncomingTransactions( - ADDRESS_TO_FETCH_FOR, - '789', - CHAIN_IDS.GOERLI, - ); - assert.deepStrictEqual(result, []); - window.fetch = tempFetchEmptyResult; - mockFetchEmptyResult.reset(); - }); - }); - - describe('_normalizeTxFromEtherscan', function () { - it('should return the expected data when the tx is in error', function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - const result = incomingTransactionsController._normalizeTxFromEtherscan( - { - timeStamp: '4444', - isError: '1', - blockNumber: 333, - from: '0xa', - gas: '11', - gasPrice: '12', - nonce: '13', - to: '0xe', - value: '15', - hash: '0xg', - }, - CHAIN_IDS.GOERLI, - ); - - assert.deepStrictEqual(result, { - blockNumber: 333, - id: 54321, - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.failed, - time: 4444000, - txParams: { - from: '0xa', - gas: '0xb', - gasPrice: '0xc', - nonce: '0xd', - to: '0xe', - value: '0xf', - }, - hash: '0xg', - type: TransactionType.incoming, - }); - }); - - it('should return the expected data when the tx is not in error', function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - const result = incomingTransactionsController._normalizeTxFromEtherscan( - { - timeStamp: '4444', - isError: '0', - blockNumber: 333, - from: '0xa', - gas: '11', - gasPrice: '12', - nonce: '13', - to: '0xe', - value: '15', - hash: '0xg', - }, - CHAIN_IDS.GOERLI, - ); - - assert.deepStrictEqual(result, { - blockNumber: 333, - id: 54321, - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.confirmed, - time: 4444000, - txParams: { - from: '0xa', - gas: '0xb', - gasPrice: '0xc', - nonce: '0xd', - to: '0xe', - value: '0xf', - }, - hash: '0xg', - type: TransactionType.incoming, - }); - }); - - it('should return the expected data when the tx uses EIP-1559 fields', function () { - const incomingTransactionsController = new IncomingTransactionsController( - { - blockTracker: getMockBlockTracker(), - ...getMockNetworkControllerMethods(CHAIN_IDS.GOERLI), - preferencesController: getMockPreferencesController(), - onboardingController: getMockOnboardingController(), - initState: getNonEmptyInitState(), - }, - ); - - const result = incomingTransactionsController._normalizeTxFromEtherscan( - { - timeStamp: '4444', - isError: '0', - blockNumber: 333, - from: '0xa', - gas: '11', - maxFeePerGas: '12', - maxPriorityFeePerGas: '1', - nonce: '13', - to: '0xe', - value: '15', - hash: '0xg', - }, - CHAIN_IDS.GOERLI, - ); - - assert.deepStrictEqual(result, { - blockNumber: 333, - id: 54321, - metamaskNetworkId: NETWORK_IDS.GOERLI, - chainId: CHAIN_IDS.GOERLI, - status: TransactionStatus.confirmed, - time: 4444000, - txParams: { - from: '0xa', - gas: '0xb', - maxFeePerGas: '0xc', - maxPriorityFeePerGas: '0x1', - nonce: '0xd', - to: '0xe', - value: '0xf', - }, - hash: '0xg', - type: TransactionType.incoming, - }); - }); - }); -}); diff --git a/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts b/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts new file mode 100644 index 000000000..2e7effd2c --- /dev/null +++ b/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts @@ -0,0 +1,219 @@ +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { + TransactionStatus, + TransactionType, +} from '../../../../shared/constants/transaction'; +import createRandomId from '../../../../shared/modules/random-id'; +import type { + EtherscanTokenTransactionMeta, + EtherscanTransactionMeta, + EtherscanTransactionMetaBase, + EtherscanTransactionResponse, +} from './etherscan'; +import { + fetchEtherscanTokenTransactions, + fetchEtherscanTransactions, +} from './etherscan'; +import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource'; + +jest.mock('./etherscan', () => ({ + fetchEtherscanTransactions: jest.fn(), + fetchEtherscanTokenTransactions: jest.fn(), +})); + +jest.mock('../../../../shared/modules/random-id'); + +const ID_MOCK = 123; + +const ETHERSCAN_TRANSACTION_BASE_MOCK: EtherscanTransactionMetaBase = { + blockNumber: '4535105', + confirmations: '4', + contractAddress: '', + cumulativeGasUsed: '693910', + from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', + gas: '335208', + gasPrice: '20000000000', + gasUsed: '21000', + hash: '0x342e9d73e10004af41d04973339fc7219dbadcbb5629730cfe65e9f9cb15ff91', + nonce: '1', + timeStamp: '1543596356', + transactionIndex: '13', + value: '50000000000000000', + blockHash: '0x0000000001', + to: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', +}; + +const ETHERSCAN_TRANSACTION_SUCCESS_MOCK: EtherscanTransactionMeta = { + ...ETHERSCAN_TRANSACTION_BASE_MOCK, + functionName: 'testFunction', + input: '0x', + isError: '0', + methodId: 'testId', + txreceipt_status: '1', +}; + +const ETHERSCAN_TRANSACTION_ERROR_MOCK: EtherscanTransactionMeta = { + ...ETHERSCAN_TRANSACTION_SUCCESS_MOCK, + isError: '1', +}; + +const ETHERSCAN_TOKEN_TRANSACTION_MOCK: EtherscanTokenTransactionMeta = { + ...ETHERSCAN_TRANSACTION_BASE_MOCK, + tokenDecimal: '456', + tokenName: 'TestToken', + tokenSymbol: 'ABC', +}; + +const ETHERSCAN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse = + { + result: [ + ETHERSCAN_TRANSACTION_SUCCESS_MOCK, + ETHERSCAN_TRANSACTION_ERROR_MOCK, + ], + }; + +const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse = + { + result: [ + ETHERSCAN_TOKEN_TRANSACTION_MOCK, + ETHERSCAN_TOKEN_TRANSACTION_MOCK, + ], + }; + +const ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse = + { + result: [], + }; + +const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse = + ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK as any; + +const EXPECTED_NORMALISED_TRANSACTION_BASE = { + blockNumber: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.blockNumber, + chainId: undefined, + hash: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.hash, + id: ID_MOCK, + metamaskNetworkId: undefined, + status: TransactionStatus.confirmed, + time: 1543596356000, + txParams: { + from: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.from, + gas: '0x51d68', + gasPrice: '0x4a817c800', + nonce: '0x1', + to: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.to, + value: '0xb1a2bc2ec50000', + }, + type: TransactionType.incoming, +}; + +const EXPECTED_NORMALISED_TRANSACTION_SUCCESS = { + ...EXPECTED_NORMALISED_TRANSACTION_BASE, + txParams: { + ...EXPECTED_NORMALISED_TRANSACTION_BASE.txParams, + data: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.input, + }, +}; + +const EXPECTED_NORMALISED_TRANSACTION_ERROR = { + ...EXPECTED_NORMALISED_TRANSACTION_SUCCESS, + status: TransactionStatus.failed, +}; + +const EXPECTED_NORMALISED_TOKEN_TRANSACTION = { + ...EXPECTED_NORMALISED_TRANSACTION_BASE, +}; + +describe('EtherscanRemoteTransactionSource', () => { + const fetchEtherscanTransactionsMock = + fetchEtherscanTransactions as jest.MockedFn< + typeof fetchEtherscanTransactions + >; + + const fetchEtherscanTokenTransactionsMock = + fetchEtherscanTokenTransactions as jest.MockedFn< + typeof fetchEtherscanTokenTransactions + >; + + const createIdMock = createRandomId as jest.MockedFn; + + beforeEach(() => { + jest.resetAllMocks(); + + fetchEtherscanTransactionsMock.mockResolvedValue( + ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK, + ); + + fetchEtherscanTokenTransactionsMock.mockResolvedValue( + ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK, + ); + + createIdMock.mockReturnValue(ID_MOCK); + }); + + describe('isSupportedNetwork', () => { + it('returns true if chain ID in constant', () => { + expect( + new EtherscanRemoteTransactionSource().isSupportedNetwork( + CHAIN_IDS.MAINNET, + '1', + ), + ).toBe(true); + }); + + it('returns false if chain ID not in constant', () => { + expect( + new EtherscanRemoteTransactionSource().isSupportedNetwork( + CHAIN_IDS.LOCALHOST, + '1', + ), + ).toBe(false); + }); + }); + + describe('fetchTransactions', () => { + it('returns normalized transactions fetched from Etherscan', async () => { + fetchEtherscanTransactionsMock.mockResolvedValueOnce( + ETHERSCAN_TRANSACTION_RESPONSE_MOCK, + ); + + const transactions = + await new EtherscanRemoteTransactionSource().fetchTransactions( + {} as any, + ); + + expect(transactions).toStrictEqual([ + EXPECTED_NORMALISED_TRANSACTION_SUCCESS, + EXPECTED_NORMALISED_TRANSACTION_ERROR, + ]); + }); + + it('returns normalized token transactions fetched from Etherscan', async () => { + fetchEtherscanTokenTransactionsMock.mockResolvedValueOnce( + ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK, + ); + + const transactions = + await new EtherscanRemoteTransactionSource().fetchTransactions( + {} as any, + ); + + expect(transactions).toStrictEqual([ + EXPECTED_NORMALISED_TOKEN_TRANSACTION, + EXPECTED_NORMALISED_TOKEN_TRANSACTION, + ]); + }); + + it('returns no normalized token transactions if flag disabled', async () => { + fetchEtherscanTokenTransactionsMock.mockResolvedValueOnce( + ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK, + ); + + const transactions = await new EtherscanRemoteTransactionSource({ + includeTokenTransfers: false, + }).fetchTransactions({} as any); + + expect(transactions).toStrictEqual([]); + }); + }); +}); diff --git a/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts b/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts new file mode 100644 index 000000000..ee37d0d4b --- /dev/null +++ b/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts @@ -0,0 +1,156 @@ +import { BNToHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { BN } from 'ethereumjs-util'; +import createId from '../../../../shared/modules/random-id'; + +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '../../../../shared/constants/transaction'; +import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../../shared/constants/network'; +import type { + EtherscanTokenTransactionMeta, + EtherscanTransactionMeta, + EtherscanTransactionMetaBase, + EtherscanTransactionRequest, + EtherscanTransactionResponse, +} from './etherscan'; +import { + fetchEtherscanTokenTransactions, + fetchEtherscanTransactions, +} from './etherscan'; +import { + RemoteTransactionSource, + RemoteTransactionSourceRequest, +} from './types'; + +/** + * A RemoteTransactionSource that fetches transaction data from Etherscan. + */ +export class EtherscanRemoteTransactionSource + implements RemoteTransactionSource +{ + #apiKey?: string; + + #includeTokenTransfers: boolean; + + constructor({ + apiKey, + includeTokenTransfers, + }: { apiKey?: string; includeTokenTransfers?: boolean } = {}) { + this.#apiKey = apiKey; + this.#includeTokenTransfers = includeTokenTransfers ?? true; + } + + isSupportedNetwork(chainId: Hex, _networkId: string): boolean { + return Object.keys(ETHERSCAN_SUPPORTED_NETWORKS).includes(chainId); + } + + async fetchTransactions( + request: RemoteTransactionSourceRequest, + ): Promise { + const etherscanRequest: EtherscanTransactionRequest = { + ...request, + apiKey: this.#apiKey, + chainId: request.currentChainId, + }; + + const transactionPromise = fetchEtherscanTransactions(etherscanRequest); + + const tokenTransactionPromise = this.#includeTokenTransfers + ? fetchEtherscanTokenTransactions(etherscanRequest) + : Promise.resolve({ + result: [] as EtherscanTokenTransactionMeta[], + } as EtherscanTransactionResponse); + + const [etherscanTransactions, etherscanTokenTransactions] = + await Promise.all([transactionPromise, tokenTransactionPromise]); + + const transactions = etherscanTransactions.result.map((tx) => + this.#normalizeTransaction( + tx, + request.currentNetworkId, + request.currentChainId, + ), + ); + + const tokenTransactions = etherscanTokenTransactions.result.map((tx) => + this.#normalizeTokenTransaction( + tx, + request.currentNetworkId, + request.currentChainId, + ), + ); + + return [...transactions, ...tokenTransactions]; + } + + #normalizeTransaction( + txMeta: EtherscanTransactionMeta, + currentNetworkId: string, + currentChainId: Hex, + ): TransactionMeta { + const base = this.#normalizeTransactionBase( + txMeta, + currentNetworkId, + currentChainId, + ); + + return { + ...base, + txParams: { + ...base.txParams, + data: txMeta.input, + }, + ...(txMeta.isError === '0' + ? { status: TransactionStatus.confirmed } + : { + status: TransactionStatus.failed, + }), + }; + } + + #normalizeTokenTransaction( + txMeta: EtherscanTokenTransactionMeta, + currentNetworkId: string, + currentChainId: Hex, + ): TransactionMeta { + const base = this.#normalizeTransactionBase( + txMeta, + currentNetworkId, + currentChainId, + ); + + return { + ...base, + }; + } + + #normalizeTransactionBase( + txMeta: EtherscanTransactionMetaBase, + currentNetworkId: string, + currentChainId: Hex, + ): TransactionMeta { + const time = parseInt(txMeta.timeStamp, 10) * 1000; + + return { + blockNumber: txMeta.blockNumber, + chainId: currentChainId, + hash: txMeta.hash, + id: createId(), + metamaskNetworkId: currentNetworkId, + status: TransactionStatus.confirmed, + 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)), + }, + type: TransactionType.incoming, + } as TransactionMeta; + } +} diff --git a/app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts b/app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts new file mode 100644 index 000000000..f20675215 --- /dev/null +++ b/app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts @@ -0,0 +1,585 @@ +import { NetworkType } from '@metamask/controller-utils'; +import type { BlockTracker, NetworkState } from '@metamask/network-controller'; + +import { + TransactionMeta, + TransactionStatus, +} from '../../../../shared/constants/transaction'; +import { IncomingTransactionHelper } from './IncomingTransactionHelper'; +import { RemoteTransactionSource } from './types'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + isSmartContractCode: jest.fn(), + query: () => Promise.resolve({}), +})); + +const NETWORK_STATE_MOCK: NetworkState = { + providerConfig: { + chainId: '0x1', + type: NetworkType.mainnet, + }, + networkId: '1', +} as unknown as NetworkState; + +const ADDERSS_MOCK = '0x1'; +const FROM_BLOCK_HEX_MOCK = '0x20'; +const FROM_BLOCK_DECIMAL_MOCK = 32; + +const BLOCK_TRACKER_MOCK = { + addListener: jest.fn(), + removeListener: jest.fn(), + getLatestBlock: jest.fn(() => FROM_BLOCK_HEX_MOCK), +} as unknown as jest.Mocked; + +const CONTROLLER_ARGS_MOCK = { + blockTracker: BLOCK_TRACKER_MOCK, + getCurrentAccount: () => ADDERSS_MOCK, + getNetworkState: () => NETWORK_STATE_MOCK, + remoteTransactionSource: {} as RemoteTransactionSource, + transactionLimit: 1, +}; + +const TRANSACTION_MOCK: TransactionMeta = { + blockNumber: '123', + chainId: '0x1', + status: TransactionStatus.submitted, + time: 0, + txParams: { to: '0x1' }, +} as unknown as TransactionMeta; + +const TRANSACTION_MOCK_2: TransactionMeta = { + blockNumber: '234', + chainId: '0x1', + hash: '0x2', + time: 1, + txParams: { to: '0x1' }, +} as unknown as TransactionMeta; + +const createRemoteTransactionSourceMock = ( + remoteTransactions: TransactionMeta[], + { + isSupportedNetwork, + error, + }: { isSupportedNetwork?: boolean; error?: boolean } = {}, +): RemoteTransactionSource => ({ + isSupportedNetwork: jest.fn(() => isSupportedNetwork ?? true), + fetchTransactions: jest.fn(() => + error + ? Promise.reject(new Error('Test Error')) + : Promise.resolve(remoteTransactions), + ), +}); + +async function emitBlockTrackerLatestEvent( + helper: IncomingTransactionHelper, + { start, error }: { start?: boolean; error?: boolean } = {}, +) { + const transactionsListener = jest.fn(); + const blockNumberListener = jest.fn(); + + if (error) { + transactionsListener.mockImplementation(() => { + throw new Error('Test Error'); + }); + } + + helper.hub.addListener('transactions', transactionsListener); + helper.hub.addListener('updatedLastFetchedBlockNumbers', blockNumberListener); + + if (start !== false) { + helper.start(); + } + + await BLOCK_TRACKER_MOCK.addListener.mock.calls[0]?.[1]?.( + FROM_BLOCK_HEX_MOCK, + ); + + return { + transactions: transactionsListener.mock.calls[0]?.[0], + lastFetchedBlockNumbers: + blockNumberListener.mock.calls[0]?.[0].lastFetchedBlockNumbers, + transactionsListener, + blockNumberListener, + }; +} + +describe('IncomingTransactionHelper', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('on block tracker latest event', () => { + it('handles errors', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK_2, + ]), + }); + + await emitBlockTrackerLatestEvent(helper, { error: true }); + }); + + describe('fetches remote transactions', () => { + it('using remote transaction source', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource, + }); + + await emitBlockTrackerLatestEvent(helper); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith({ + address: ADDERSS_MOCK, + currentChainId: NETWORK_STATE_MOCK.providerConfig.chainId, + currentNetworkId: NETWORK_STATE_MOCK.networkId, + fromBlock: expect.any(Number), + limit: CONTROLLER_ARGS_MOCK.transactionLimit, + }); + }); + + it('using from block as latest block minus ten if no last fetched data', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource, + }); + + await emitBlockTrackerLatestEvent(helper); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith( + expect.objectContaining({ + fromBlock: FROM_BLOCK_DECIMAL_MOCK - 10, + }), + ); + }); + + it('using from block as last fetched value plus one', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource, + lastFetchedBlockNumbers: { + [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]: + FROM_BLOCK_DECIMAL_MOCK, + }, + }); + + await emitBlockTrackerLatestEvent(helper); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith( + expect.objectContaining({ + fromBlock: FROM_BLOCK_DECIMAL_MOCK + 1, + }), + ); + }); + }); + + describe('emits transactions event', () => { + it('if new transaction fetched', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK_2, + ]), + }); + + const { transactions } = await emitBlockTrackerLatestEvent(helper); + + expect(transactions).toStrictEqual({ + added: [TRANSACTION_MOCK_2], + updated: [], + }); + }); + + it('if new outgoing transaction fetched and update transactions enabled', async () => { + const outgoingTransaction = { + ...TRANSACTION_MOCK_2, + txParams: { + ...TRANSACTION_MOCK_2.txParams, + from: '0x1', + to: '0x2', + }, + }; + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + outgoingTransaction, + ]), + updateTransactions: true, + }); + + const { transactions } = await emitBlockTrackerLatestEvent(helper); + + expect(transactions).toStrictEqual({ + added: [outgoingTransaction], + updated: [], + }); + }); + + it('if existing transaction fetched with different status and update transactions enabled', async () => { + const updatedTransaction = { + ...TRANSACTION_MOCK, + status: TransactionStatus.confirmed, + } as TransactionMeta; + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + updatedTransaction, + ]), + getLocalTransactions: () => [TRANSACTION_MOCK], + updateTransactions: true, + }); + + const { transactions } = await emitBlockTrackerLatestEvent(helper); + + expect(transactions).toStrictEqual({ + added: [], + updated: [updatedTransaction], + }); + }); + + it('sorted by time in ascending order', async () => { + const firstTransaction = { ...TRANSACTION_MOCK, time: 5 }; + const secondTransaction = { ...TRANSACTION_MOCK, time: 6 }; + const thirdTransaction = { ...TRANSACTION_MOCK, time: 7 }; + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + firstTransaction, + thirdTransaction, + secondTransaction, + ]), + }); + + const { transactions } = await emitBlockTrackerLatestEvent(helper); + + expect(transactions).toStrictEqual({ + added: [firstTransaction, secondTransaction, thirdTransaction], + updated: [], + }); + }); + + it('does not if identical transaction fetched and update transactions enabled', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK, + ]), + getLocalTransactions: () => [TRANSACTION_MOCK], + updateTransactions: true, + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + + it('does not if disabled', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK, + ]), + isEnabled: jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false), + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + + it('does not if current network is not supported by remote transaction source', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock( + [TRANSACTION_MOCK], + { isSupportedNetwork: false }, + ), + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + + it('does not if no remote transactions', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + + it('does not if update transactions disabled and no incoming transactions', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + { + ...TRANSACTION_MOCK, + txParams: { to: '0x2' }, + } as TransactionMeta, + { + ...TRANSACTION_MOCK, + txParams: { to: undefined } as any, + } as TransactionMeta, + ]), + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + + it('does not if error fetching transactions', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock( + [TRANSACTION_MOCK], + { error: true }, + ), + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + + it('does not if not started', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK, + ]), + }); + + const { transactionsListener } = await emitBlockTrackerLatestEvent( + helper, + { start: false }, + ); + + expect(transactionsListener).not.toHaveBeenCalled(); + }); + }); + + describe('emits updatedLastFetchedBlockNumbers event', () => { + it('if fetched transaction has higher block number', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK_2, + ]), + }); + + const { lastFetchedBlockNumbers } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(lastFetchedBlockNumbers).toStrictEqual({ + [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]: + parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10), + }); + }); + + it('does not if no fetched transactions', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + const { blockNumberListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(blockNumberListener).not.toHaveBeenCalled(); + }); + + it('does not if no block number on fetched transaction', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + { ...TRANSACTION_MOCK_2, blockNumber: undefined }, + ]), + }); + + const { blockNumberListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(blockNumberListener).not.toHaveBeenCalled(); + }); + + it('does not if fetch transaction not to current account', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + { + ...TRANSACTION_MOCK_2, + txParams: { to: '0x2' }, + } as TransactionMeta, + ]), + }); + + const { blockNumberListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(blockNumberListener).not.toHaveBeenCalled(); + }); + + it('does not if fetched transaction has same block number', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK_2, + ]), + lastFetchedBlockNumbers: { + [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]: + parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10), + }, + }); + + const { blockNumberListener } = await emitBlockTrackerLatestEvent( + helper, + ); + + expect(blockNumberListener).not.toHaveBeenCalled(); + }); + }); + }); + + describe('start', () => { + it('adds listener to block tracker', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.start(); + + expect( + CONTROLLER_ARGS_MOCK.blockTracker.addListener, + ).toHaveBeenCalledTimes(1); + }); + + it('does nothing if already started', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.start(); + helper.start(); + + expect( + CONTROLLER_ARGS_MOCK.blockTracker.addListener, + ).toHaveBeenCalledTimes(1); + }); + + it('does nothing if disabled', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + isEnabled: () => false, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.start(); + + expect( + CONTROLLER_ARGS_MOCK.blockTracker.addListener, + ).not.toHaveBeenCalled(); + }); + + it('does nothing if network not supported by remote transaction source', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([], { + isSupportedNetwork: false, + }), + }); + + helper.start(); + + expect( + CONTROLLER_ARGS_MOCK.blockTracker.addListener, + ).not.toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + it('removes listener from block tracker', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.start(); + helper.stop(); + + expect( + CONTROLLER_ARGS_MOCK.blockTracker.removeListener, + ).toHaveBeenCalledTimes(1); + }); + }); + + describe('update', () => { + it('emits transactions event', async () => { + const listener = jest.fn(); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource: createRemoteTransactionSourceMock([ + TRANSACTION_MOCK_2, + ]), + }); + + helper.hub.on('transactions', listener); + + await helper.update(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + added: [TRANSACTION_MOCK_2], + updated: [], + }); + }); + }); +}); diff --git a/app/scripts/controllers/transactions/IncomingTransactionHelper.ts b/app/scripts/controllers/transactions/IncomingTransactionHelper.ts new file mode 100644 index 000000000..3acb63726 --- /dev/null +++ b/app/scripts/controllers/transactions/IncomingTransactionHelper.ts @@ -0,0 +1,282 @@ +import EventEmitter from 'events'; +import type { BlockTracker, NetworkState } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; + +import log from 'loglevel'; +import { TransactionMeta } from '../../../../shared/constants/transaction'; +import { RemoteTransactionSource } from './types'; + +const UPDATE_CHECKS: ((txMeta: TransactionMeta) => any)[] = [ + (txMeta) => txMeta.status, +]; + +export class IncomingTransactionHelper { + hub: EventEmitter; + + #blockTracker: BlockTracker; + + #getCurrentAccount: () => string; + + #getLocalTransactions: () => TransactionMeta[]; + + #getNetworkState: () => NetworkState; + + #isEnabled: () => boolean; + + #isRunning: boolean; + + #isUpdating: boolean; + + #lastFetchedBlockNumbers: Record; + + #onLatestBlock: (blockNumberHex: Hex) => Promise; + + #remoteTransactionSource: RemoteTransactionSource; + + #transactionLimit?: number; + + #updateTransactions: boolean; + + constructor({ + blockTracker, + getCurrentAccount, + getLocalTransactions, + getNetworkState, + isEnabled, + lastFetchedBlockNumbers, + remoteTransactionSource, + transactionLimit, + updateTransactions, + }: { + blockTracker: BlockTracker; + getCurrentAccount: () => string; + getNetworkState: () => NetworkState; + getLocalTransactions?: () => TransactionMeta[]; + isEnabled?: () => boolean; + lastFetchedBlockNumbers?: Record; + remoteTransactionSource: RemoteTransactionSource; + transactionLimit?: number; + updateTransactions?: boolean; + }) { + this.hub = new EventEmitter(); + + this.#blockTracker = blockTracker; + this.#getCurrentAccount = getCurrentAccount; + this.#getLocalTransactions = getLocalTransactions || (() => []); + this.#getNetworkState = getNetworkState; + this.#isEnabled = isEnabled ?? (() => true); + this.#isRunning = false; + this.#isUpdating = false; + this.#lastFetchedBlockNumbers = lastFetchedBlockNumbers ?? {}; + this.#remoteTransactionSource = remoteTransactionSource; + this.#transactionLimit = transactionLimit; + this.#updateTransactions = updateTransactions ?? false; + + // Using a property instead of a method to provide a listener reference + // with the correct scope that we can remove later if stopped. + this.#onLatestBlock = async (blockNumberHex: Hex) => { + await this.update(blockNumberHex); + }; + } + + start() { + if (this.#isRunning) { + return; + } + + if (!this.#canStart()) { + return; + } + + this.#blockTracker.addListener('latest', this.#onLatestBlock); + this.#isRunning = true; + } + + stop() { + this.#blockTracker.removeListener('latest', this.#onLatestBlock); + this.#isRunning = false; + } + + async update(latestBlockNumberHex?: Hex): Promise { + if (this.#isUpdating) { + return; + } + + this.#isUpdating = true; + + try { + if (!this.#canStart()) { + return; + } + + const latestBlockNumber = parseInt( + latestBlockNumberHex || (await this.#blockTracker.getLatestBlock()), + 16, + ); + + const fromBlock = this.#getFromBlock(latestBlockNumber); + const address = this.#getCurrentAccount(); + const currentChainId = this.#getCurrentChainId(); + const currentNetworkId = this.#getCurrentNetworkId(); + + let remoteTransactions = []; + + try { + remoteTransactions = + await this.#remoteTransactionSource.fetchTransactions({ + address, + currentChainId, + currentNetworkId, + fromBlock, + limit: this.#transactionLimit, + }); + } catch (error: any) { + return; + } + + if (!this.#updateTransactions) { + remoteTransactions = remoteTransactions.filter( + (tx) => tx.txParams.to?.toLowerCase() === address.toLowerCase(), + ); + } + + const localTransactions = this.#updateTransactions + ? this.#getLocalTransactions() + : []; + + const newTransactions = this.#getNewTransactions( + remoteTransactions, + localTransactions, + ); + + const updatedTransactions = this.#getUpdatedTransactions( + remoteTransactions, + localTransactions, + ); + + if (newTransactions.length > 0 || updatedTransactions.length > 0) { + this.#sortTransactionsByTime(newTransactions); + this.#sortTransactionsByTime(updatedTransactions); + + this.hub.emit('transactions', { + added: newTransactions, + updated: updatedTransactions, + }); + } + + this.#updateLastFetchedBlockNumber(remoteTransactions); + } catch (error) { + log.error('Error while checking incoming transactions', error); + } finally { + this.#isUpdating = false; + } + } + + #sortTransactionsByTime(transactions: TransactionMeta[]) { + transactions.sort((a, b) => (a.time < b.time ? -1 : 1)); + } + + #getNewTransactions( + remoteTxs: TransactionMeta[], + localTxs: TransactionMeta[], + ): TransactionMeta[] { + return remoteTxs.filter( + (tx) => !localTxs.some(({ hash }) => hash === tx.hash), + ); + } + + #getUpdatedTransactions( + remoteTxs: TransactionMeta[], + localTxs: TransactionMeta[], + ): TransactionMeta[] { + return remoteTxs.filter((remoteTx) => + localTxs.some( + (localTx) => + remoteTx.hash === localTx.hash && + this.#isTransactionOutdated(remoteTx, localTx), + ), + ); + } + + #isTransactionOutdated( + remoteTx: TransactionMeta, + localTx: TransactionMeta, + ): boolean { + return UPDATE_CHECKS.some( + (getValue) => getValue(remoteTx) !== getValue(localTx), + ); + } + + #getFromBlock(latestBlockNumber: number): number { + const lastFetchedKey = this.#getBlockNumberKey(); + + const lastFetchedBlockNumber = + this.#lastFetchedBlockNumbers[lastFetchedKey]; + + if (lastFetchedBlockNumber) { + return lastFetchedBlockNumber + 1; + } + + // Avoid using latest block as remote transaction source + // may not have indexed it yet + return Math.max(latestBlockNumber - 10, 0); + } + + #updateLastFetchedBlockNumber(remoteTxs: TransactionMeta[]) { + let lastFetchedBlockNumber = -1; + + for (const tx of remoteTxs) { + const currentBlockNumberValue = tx.blockNumber + ? parseInt(tx.blockNumber, 10) + : -1; + + lastFetchedBlockNumber = Math.max( + lastFetchedBlockNumber, + currentBlockNumberValue, + ); + } + + if (lastFetchedBlockNumber === -1) { + return; + } + + const lastFetchedKey = this.#getBlockNumberKey(); + const previousValue = this.#lastFetchedBlockNumbers[lastFetchedKey]; + + if (previousValue === lastFetchedBlockNumber) { + return; + } + + this.#lastFetchedBlockNumbers[lastFetchedKey] = lastFetchedBlockNumber; + + this.hub.emit('updatedLastFetchedBlockNumbers', { + lastFetchedBlockNumbers: this.#lastFetchedBlockNumbers, + blockNumber: lastFetchedBlockNumber, + }); + } + + #getBlockNumberKey(): string { + return `${this.#getCurrentChainId()}#${this.#getCurrentAccount().toLowerCase()}`; + } + + #canStart(): boolean { + const isEnabled = this.#isEnabled(); + const currentChainId = this.#getCurrentChainId(); + const currentNetworkId = this.#getCurrentNetworkId(); + + const isSupportedNetwork = this.#remoteTransactionSource.isSupportedNetwork( + currentChainId, + currentNetworkId, + ); + + return isEnabled && isSupportedNetwork; + } + + #getCurrentChainId(): Hex { + return this.#getNetworkState().providerConfig.chainId; + } + + #getCurrentNetworkId(): string { + return this.#getNetworkState().networkId as string; + } +} diff --git a/app/scripts/controllers/transactions/etherscan.test.ts b/app/scripts/controllers/transactions/etherscan.test.ts new file mode 100644 index 000000000..4b18ffa86 --- /dev/null +++ b/app/scripts/controllers/transactions/etherscan.test.ts @@ -0,0 +1,153 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import { + CHAIN_IDS, + ETHERSCAN_SUPPORTED_NETWORKS, +} from '../../../../shared/constants/network'; +import type { + EtherscanTransactionMeta, + EtherscanTransactionRequest, + EtherscanTransactionResponse, +} from './etherscan'; +import * as Etherscan from './etherscan'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + handleFetch: jest.fn(), +})); + +const ADDERSS_MOCK = '0x2A2D72308838A6A46a0B5FDA3055FE915b5D99eD'; + +const REQUEST_MOCK: EtherscanTransactionRequest = { + address: ADDERSS_MOCK, + chainId: CHAIN_IDS.GOERLI, + limit: 3, + fromBlock: 2, + apiKey: 'testApiKey', +}; + +const RESPONSE_MOCK: EtherscanTransactionResponse = { + result: [ + { from: ADDERSS_MOCK, nonce: '0x1' } as EtherscanTransactionMeta, + { from: ADDERSS_MOCK, nonce: '0x2' } as EtherscanTransactionMeta, + ], +}; + +describe('Etherscan', () => { + const handleFetchMock = handleFetch as jest.MockedFunction< + typeof handleFetch + >; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe.each([ + ['fetchEtherscanTransactions', 'txlist'], + ['fetchEtherscanTokenTransactions', 'tokentx'], + ])('%s', (method, action) => { + it('returns fetched response', async () => { + handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK); + + const result = await (Etherscan as any)[method](REQUEST_MOCK); + + expect(result).toStrictEqual(RESPONSE_MOCK); + }); + + it('fetches from Etherscan URL', async () => { + handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK); + + await (Etherscan as any)[method](REQUEST_MOCK); + + expect(handleFetchMock).toHaveBeenCalledTimes(1); + expect(handleFetchMock).toHaveBeenCalledWith( + `https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${ + ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain + }/api?` + + `module=account` + + `&address=${REQUEST_MOCK.address}` + + `&startBlock=${REQUEST_MOCK.fromBlock}` + + `&apikey=${REQUEST_MOCK.apiKey}` + + `&offset=${REQUEST_MOCK.limit}` + + `&order=desc` + + `&action=${action}` + + `&tag=latest` + + `&page=1`, + ); + }); + + it('supports alternate networks', async () => { + handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK); + + await (Etherscan as any)[method]({ + ...REQUEST_MOCK, + chainId: CHAIN_IDS.MAINNET, + }); + + expect(handleFetchMock).toHaveBeenCalledTimes(1); + expect(handleFetchMock).toHaveBeenCalledWith( + `https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.MAINNET].subdomain}.${ + ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.MAINNET].domain + }/api?` + + `module=account` + + `&address=${REQUEST_MOCK.address}` + + `&startBlock=${REQUEST_MOCK.fromBlock}` + + `&apikey=${REQUEST_MOCK.apiKey}` + + `&offset=${REQUEST_MOCK.limit}` + + `&order=desc` + + `&action=${action}` + + `&tag=latest` + + `&page=1`, + ); + }); + + it('throws if message is not ok', async () => { + handleFetchMock.mockResolvedValueOnce({ + status: '0', + message: 'NOTOK', + result: 'test error', + }); + + await expect((Etherscan as any)[method](REQUEST_MOCK)).rejects.toThrow( + 'Etherscan request failed - test error', + ); + }); + + it('throws if chain is not supported', async () => { + const unsupportedChainId = '0x11111111111111111111'; + + await expect( + (Etherscan as any)[method]({ + ...REQUEST_MOCK, + chainId: unsupportedChainId, + }), + ).rejects.toThrow( + `Etherscan does not support chain with ID: ${unsupportedChainId}`, + ); + }); + + it('does not include empty values in fetched URL', async () => { + handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK); + + await (Etherscan as any)[method]({ + ...REQUEST_MOCK, + fromBlock: undefined, + apiKey: undefined, + }); + + expect(handleFetchMock).toHaveBeenCalledTimes(1); + expect(handleFetchMock).toHaveBeenCalledWith( + `https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${ + ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain + }/api?` + + `module=account` + + `&address=${REQUEST_MOCK.address}` + + `&offset=${REQUEST_MOCK.limit}` + + `&order=desc` + + `&action=${action}` + + `&tag=latest` + + `&page=1`, + ); + }); + }); +}); diff --git a/app/scripts/controllers/transactions/etherscan.ts b/app/scripts/controllers/transactions/etherscan.ts new file mode 100644 index 000000000..b01f94fa4 --- /dev/null +++ b/app/scripts/controllers/transactions/etherscan.ts @@ -0,0 +1,205 @@ +import { handleFetch } from '@metamask/controller-utils'; +import { Hex } from '@metamask/utils'; +import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../../shared/constants/network'; + +export interface EtherscanTransactionMetaBase { + blockNumber: string; + blockHash: string; + confirmations: string; + contractAddress: string; + cumulativeGasUsed: string; + from: string; + gas: string; + gasPrice: string; + gasUsed: string; + hash: string; + nonce: string; + timeStamp: string; + to: string; + transactionIndex: string; + value: string; +} + +export interface EtherscanTransactionMeta extends EtherscanTransactionMetaBase { + functionName: string; + input: string; + isError: string; + methodId: string; + txreceipt_status: string; +} + +export interface EtherscanTokenTransactionMeta + extends EtherscanTransactionMetaBase { + tokenDecimal: string; + tokenName: string; + tokenSymbol: string; +} + +export interface EtherscanTransactionResponse< + T extends EtherscanTransactionMetaBase, +> { + result: T[]; +} + +export interface EtherscanTransactionRequest { + address: string; + apiKey?: string; + chainId: Hex; + fromBlock?: number; + limit?: number; +} + +interface RawEtherscanResponse { + status: '0' | '1'; + message: string; + result: string | T[]; +} + +/** + * Retrieves transaction data from Etherscan. + * + * @param request - Configuration required to fetch transactions. + * @param request.address - Address to retrieve transactions for. + * @param request.apiKey - Etherscan API key. + * @param request.chainId - Current chain ID used to determine subdomain and domain. + * @param request.fromBlock - Block number to start fetching transactions from. + * @param request.limit - Number of transactions to retrieve. + * @returns An Etherscan response object containing the request status and an array of token transaction data. + */ +export async function fetchEtherscanTransactions({ + address, + apiKey, + chainId, + fromBlock, + limit, +}: EtherscanTransactionRequest): Promise< + EtherscanTransactionResponse +> { + return await fetchTransactions('txlist', { + address, + apiKey, + chainId, + fromBlock, + limit, + }); +} + +/** + * Retrieves token transaction data from Etherscan. + * + * @param request - Configuration required to fetch token transactions. + * @param request.address - Address to retrieve token transactions for. + * @param request.apiKey - Etherscan API key. + * @param request.chainId - Current chain ID used to determine subdomain and domain. + * @param request.fromBlock - Block number to start fetching token transactions from. + * @param request.limit - Number of token transactions to retrieve. + * @returns An Etherscan response object containing the request status and an array of token transaction data. + */ +export async function fetchEtherscanTokenTransactions({ + address, + apiKey, + chainId, + fromBlock, + limit, +}: EtherscanTransactionRequest): Promise< + EtherscanTransactionResponse +> { + return await fetchTransactions('tokentx', { + address, + apiKey, + chainId, + fromBlock, + limit, + }); +} + +/** + * Retrieves transaction data from Etherscan from a specific endpoint. + * + * @param action - The Etherscan endpoint to use. + * @param options - Options bag. + * @param options.address - Address to retrieve transactions for. + * @param options.apiKey - Etherscan API key. + * @param options.chainId - Current chain ID used to determine subdomain and domain. + * @param options.fromBlock - Block number to start fetching transactions from. + * @param options.limit - Number of transactions to retrieve. + * @returns An object containing the request status and an array of transaction data. + */ +async function fetchTransactions( + action: string, + { + address, + apiKey, + chainId, + fromBlock, + limit, + }: { + address: string; + apiKey?: string; + chainId: Hex; + fromBlock?: number; + limit?: number; + }, +): Promise> { + const urlParams = { + module: 'account', + address, + startBlock: fromBlock?.toString(), + apikey: apiKey, + offset: limit?.toString(), + order: 'desc', + }; + + const etherscanTxUrl = getEtherscanApiUrl(chainId, { + ...urlParams, + action, + }); + + const response = (await handleFetch( + etherscanTxUrl, + )) as RawEtherscanResponse; + + if (response.status === '0' && response.message === 'NOTOK') { + throw new Error(`Etherscan request failed - ${response.result}`); + } + + return { result: response.result as T[] }; +} + +/** + * Return a URL that can be used to fetch data from Etherscan. + * + * @param chainId - Current chain ID used to determine subdomain and domain. + * @param urlParams - The parameters used to construct the URL. + * @returns URL to access Etherscan data. + */ +function getEtherscanApiUrl( + chainId: Hex, + urlParams: Record, +): string { + type SupportedChainId = keyof typeof ETHERSCAN_SUPPORTED_NETWORKS; + + const networkInfo = ETHERSCAN_SUPPORTED_NETWORKS[chainId as SupportedChainId]; + + if (!networkInfo) { + throw new Error(`Etherscan does not support chain with ID: ${chainId}`); + } + + const apiUrl = `https://${networkInfo.subdomain}.${networkInfo.domain}`; + let url = `${apiUrl}/api?`; + + // eslint-disable-next-line guard-for-in + for (const paramKey in urlParams) { + const value = urlParams[paramKey]; + + if (!value) { + continue; + } + + url += `${paramKey}=${value}&`; + } + + url += 'tag=latest&page=1'; + + return url; +} diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index e8fa7dd93..8e30f4c5d 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -72,6 +72,8 @@ import TransactionStateManager from './tx-state-manager'; import TxGasUtil from './tx-gas-utils'; import PendingTransactionTracker from './pending-tx-tracker'; import * as txUtils from './lib/util'; +import { IncomingTransactionHelper } from './IncomingTransactionHelper'; +import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource'; const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory const UPDATE_POST_TX_BALANCE_TIMEOUT = 5000; @@ -127,6 +129,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain'; * @param {object} opts.initState - initial transaction list default is an empty array * @param {Function} opts.getNetworkId - Get the current network ID. * @param {Function} opts.getNetworkStatus - Get the current network status. + * @param {Function} opts.getNetworkState - Get the network state. * @param {Function} opts.onNetworkStateChange - Subscribe to network state change events. * @param {object} opts.blockTracker - An instance of eth-blocktracker * @param {object} opts.provider - A network provider. @@ -134,6 +137,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain'; * @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 {Function} opts.hasCompletedOnboarding - Returns whether or not the user has completed the onboarding flow * @param {object} opts.preferencesStore */ @@ -142,6 +146,7 @@ export default class TransactionController extends EventEmitter { super(); this.getNetworkId = opts.getNetworkId; this.getNetworkStatus = opts.getNetworkStatus; + this._getNetworkState = opts.getNetworkState; this._getCurrentChainId = opts.getCurrentChainId; this.getProviderConfig = opts.getProviderConfig; this._getCurrentNetworkEIP1559Compatibility = @@ -166,6 +171,7 @@ export default class TransactionController extends EventEmitter { this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails; this.securityProviderRequest = opts.securityProviderRequest; this.messagingSystem = opts.messenger; + this._hasCompletedOnboarding = opts.hasCompletedOnboarding; this.memStore = new ObservableStore({}); @@ -216,6 +222,32 @@ export default class TransactionController extends EventEmitter { this.txStateManager.getConfirmedTransactions.bind(this.txStateManager), }); + this.incomingTransactionHelper = new IncomingTransactionHelper({ + blockTracker: this.blockTracker, + getCurrentAccount: () => this.getSelectedAddress(), + getNetworkState: () => this._getNetworkState(), + isEnabled: () => + Boolean( + this.preferencesStore.getState().featureFlags + ?.showIncomingTransactions && this._hasCompletedOnboarding(), + ), + lastFetchedBlockNumbers: opts.initState?.lastFetchedBlockNumbers || {}, + remoteTransactionSource: new EtherscanRemoteTransactionSource({ + includeTokenTransfers: false, + }), + updateTransactions: false, + }); + + this.incomingTransactionHelper.hub.on( + 'transactions', + this._onIncomingTransactions.bind(this), + ); + + this.incomingTransactionHelper.hub.on( + 'updatedLastFetchedBlockNumbers', + this._onUpdatedLastFetchedBlockNumbers.bind(this), + ); + this.txStateManager.store.subscribe(() => this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE), ); @@ -759,6 +791,18 @@ export default class TransactionController extends EventEmitter { ); } + startIncomingTransactionPolling() { + this.incomingTransactionHelper.start(); + } + + stopIncomingTransactionPolling() { + this.incomingTransactionHelper.stop(); + } + + async updateIncomingTransactions() { + await this.incomingTransactionHelper.update(); + } + // // PRIVATE METHODS // @@ -2086,11 +2130,18 @@ export default class TransactionController extends EventEmitter { * Updates the memStore in transaction controller */ _updateMemstore() { + const { transactions } = this.store.getState(); const unapprovedTxs = this.txStateManager.getUnapprovedTxList(); + const currentNetworkTxList = this.txStateManager.getTransactions({ limit: MAX_MEMSTORE_TX_LIST_SIZE, }); - this.memStore.updateState({ unapprovedTxs, currentNetworkTxList }); + + this.memStore.updateState({ + unapprovedTxs, + currentNetworkTxList, + transactions, + }); } _calculateTransactionsCost(txMeta, approvalTxMeta) { @@ -2734,6 +2785,34 @@ export default class TransactionController extends EventEmitter { ); } + _onIncomingTransactions({ added: transactions }) { + log.debug('Detected new incoming transactions', transactions); + + const currentTransactions = this.store.getState().transactions || {}; + + const incomingTransactions = transactions + .filter((tx) => !this._hasTransactionHash(tx.hash, currentTransactions)) + .reduce((result, tx) => { + result[tx.id] = tx; + return result; + }, {}); + + const updatedTransactions = { + ...currentTransactions, + ...incomingTransactions, + }; + + this.store.updateState({ transactions: updatedTransactions }); + } + + _onUpdatedLastFetchedBlockNumbers({ lastFetchedBlockNumbers }) { + this.store.updateState({ lastFetchedBlockNumbers }); + } + + _hasTransactionHash(hash, transactions) { + return Object.values(transactions).some((tx) => tx.hash === hash); + } + // Approvals async _requestTransactionApproval( diff --git a/app/scripts/controllers/transactions/index.test.js b/app/scripts/controllers/transactions/index.test.js index a94e331b1..77150f34a 100644 --- a/app/scripts/controllers/transactions/index.test.js +++ b/app/scripts/controllers/transactions/index.test.js @@ -38,6 +38,7 @@ import { ORIGIN_METAMASK } from '../../../../shared/constants/app'; import { NetworkStatus } from '../../../../shared/constants/network'; import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils'; import TxGasUtil from './tx-gas-utils'; +import * as IncomingTransactionHelperClass from './IncomingTransactionHelper'; import TransactionController from '.'; const noop = () => true; @@ -51,6 +52,16 @@ const actionId = 'DUMMY_ACTION_ID'; const VALID_ADDRESS = '0x0000000000000000000000000000000000000000'; const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001'; +const TRANSACTION_META_MOCK = { + hash: '0x1', + id: 1, + status: TransactionStatus.confirmed, + transaction: { + from: VALID_ADDRESS, + }, + time: 123456789, +}; + async function flushPromises() { await new Promise((resolve) => setImmediate(resolve)); } @@ -65,7 +76,9 @@ describe('Transaction Controller', function () { getCurrentChainId, messengerMock, resultCallbacksMock, - updateSpy; + updateSpy, + incomingTransactionHelperClassMock, + incomingTransactionHelperEventMock; beforeEach(function () { fragmentExists = false; @@ -101,6 +114,16 @@ describe('Transaction Controller', function () { call: sinon.stub(), }; + incomingTransactionHelperEventMock = sinon.spy(); + + incomingTransactionHelperClassMock = sinon + .stub(IncomingTransactionHelperClass, 'IncomingTransactionHelper') + .returns({ + hub: { + on: incomingTransactionHelperEventMock, + }, + }); + txController = new TransactionController({ provider, getGasPrice() { @@ -148,6 +171,10 @@ describe('Transaction Controller', function () { ); }); + afterEach(function () { + incomingTransactionHelperClassMock.restore(); + }); + function getLastTxMeta() { return updateSpy.lastCall.args[0]; } @@ -3374,4 +3401,78 @@ describe('Transaction Controller', function () { assert.deepEqual(transaction1, transaction2); }); }); + + describe('on incoming transaction helper transactions event', function () { + it('adds new transactions to state', async function () { + const existingTransaction = TRANSACTION_META_MOCK; + + const incomingTransaction1 = { + ...TRANSACTION_META_MOCK, + id: 2, + hash: '0x2', + }; + + const incomingTransaction2 = { + ...TRANSACTION_META_MOCK, + id: 3, + hash: '0x3', + }; + + txController.store.getState().transactions = { + [existingTransaction.id]: existingTransaction, + }; + + await incomingTransactionHelperEventMock.firstCall.args[1]({ + added: [incomingTransaction1, incomingTransaction2], + updated: [], + }); + + assert.deepEqual(txController.store.getState().transactions, { + [existingTransaction.id]: existingTransaction, + [incomingTransaction1.id]: incomingTransaction1, + [incomingTransaction2.id]: incomingTransaction2, + }); + }); + + it('ignores new transactions if hash matches existing transaction', async function () { + const existingTransaction = TRANSACTION_META_MOCK; + const incomingTransaction1 = { ...TRANSACTION_META_MOCK, id: 2 }; + const incomingTransaction2 = { ...TRANSACTION_META_MOCK, id: 3 }; + + txController.store.getState().transactions = { + [existingTransaction.id]: existingTransaction, + }; + + await incomingTransactionHelperEventMock.firstCall.args[1]({ + added: [incomingTransaction1, incomingTransaction2], + updated: [], + }); + + assert.deepEqual(txController.store.getState().transactions, { + [existingTransaction.id]: existingTransaction, + }); + }); + }); + + describe('on incoming transaction helper updatedLastFetchedBlockNumbers event', function () { + it('updates state', async function () { + const lastFetchedBlockNumbers = { + key: 234, + }; + + assert.deepEqual( + txController.store.getState().lastFetchedBlockNumbers, + undefined, + ); + + await incomingTransactionHelperEventMock.secondCall.args[1]({ + lastFetchedBlockNumbers, + }); + + assert.deepEqual( + txController.store.getState().lastFetchedBlockNumbers, + lastFetchedBlockNumbers, + ); + }); + }); }); diff --git a/app/scripts/controllers/transactions/types.ts b/app/scripts/controllers/transactions/types.ts new file mode 100644 index 000000000..e59205e1b --- /dev/null +++ b/app/scripts/controllers/transactions/types.ts @@ -0,0 +1,49 @@ +import { Hex } from '@metamask/utils'; +import { TransactionMeta } from '../../../../shared/constants/transaction'; + +/** + * The configuration required to fetch transaction data from a RemoteTransactionSource. + */ +export interface RemoteTransactionSourceRequest { + /** + * The address of the account to fetch transactions for. + */ + address: string; + + /** + * API key if required by the remote source. + */ + apiKey?: string; + + /** + * The chainId of the current network. + */ + currentChainId: Hex; + + /** + * The networkId of the current network. + */ + currentNetworkId: string; + + /** + * Block number to start fetching transactions from. + */ + fromBlock?: number; + + /** + * Maximum number of transactions to retrieve. + */ + limit?: number; +} + +/** + * An object capable of fetching transaction data from a remote source. + * Used by the IncomingTransactionHelper to retrieve remote transaction data. + */ +export interface RemoteTransactionSource { + isSupportedNetwork: (chainId: Hex, networkId: string) => boolean; + + fetchTransactions: ( + request: RemoteTransactionSourceRequest, + ) => Promise; +} diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index e17481f80..5a0e95a80 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -58,9 +58,6 @@ export const SENTRY_BACKGROUND_STATE = { EncryptionPublicKeyController: { unapprovedEncryptionPublicKeyMsgCount: true, }, - IncomingTransactionsController: { - incomingTxLastFetchedBlockByChainId: true, - }, KeyringController: { isUnlocked: true, }, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index daf2a74e1..183de1fb0 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -190,7 +190,6 @@ import CachedBalancesController from './controllers/cached-balances'; import AlertController from './controllers/alert'; import OnboardingController from './controllers/onboarding'; import Backup from './lib/backup'; -import IncomingTransactionsController from './controllers/incoming-transactions'; import DecryptMessageController from './controllers/decrypt-message'; import TransactionController from './controllers/transactions'; import DetectTokensController from './controllers/detect-tokens'; @@ -418,10 +417,6 @@ export default class MetamaskController extends EventEmitter { provider: this.provider, }); - this.preferencesController.store.subscribe(async ({ currentLocale }) => { - await updateCurrentLocale(currentLocale); - }); - const tokensControllerMessenger = this.controllerMessenger.getRestricted({ name: 'TokensController', allowedActions: ['ApprovalController:addRequest'], @@ -744,19 +739,6 @@ export default class MetamaskController extends EventEmitter { initState: initState.OnboardingController, }); - this.incomingTransactionsController = new IncomingTransactionsController({ - blockTracker: this.blockTracker, - onNetworkDidChange: networkControllerMessenger.subscribe.bind( - networkControllerMessenger, - 'NetworkController:networkDidChange', - ), - getCurrentChainId: () => - this.networkController.state.providerConfig.chainId, - preferencesController: this.preferencesController, - onboardingController: this.onboardingController, - initState: initState.IncomingTransactionsController, - }); - // account tracker watches balances, nonces, and any code at their address this.accountTracker = new AccountTracker({ provider: this.provider, @@ -1192,6 +1174,9 @@ export default class MetamaskController extends EventEmitter { this.networkController.state.networksMetadata?.[ this.networkController.state.selectedNetworkClientId ]?.status, + getNetworkState: () => this.networkController.state, + hasCompletedOnboarding: () => + this.onboardingController.store.getState().completedOnboarding, onNetworkStateChange: (listener) => { networkControllerMessenger.subscribe( 'NetworkController:stateChange', @@ -1660,7 +1645,6 @@ export default class MetamaskController extends EventEmitter { CachedBalancesController: this.cachedBalancesController.store, AlertController: this.alertController.store, OnboardingController: this.onboardingController.store, - IncomingTransactionsController: this.incomingTransactionsController.store, PermissionController: this.permissionController, PermissionLogController: this.permissionLogController.store, SubjectMetadataController: this.subjectMetadataController, @@ -1706,8 +1690,6 @@ export default class MetamaskController extends EventEmitter { CurrencyController: this.currencyRateController, AlertController: this.alertController.store, OnboardingController: this.onboardingController.store, - IncomingTransactionsController: - this.incomingTransactionsController.store, PermissionController: this.permissionController, PermissionLogController: this.permissionLogController.store, SubjectMetadataController: this.subjectMetadataController, @@ -1803,7 +1785,7 @@ export default class MetamaskController extends EventEmitter { triggerNetworkrequests() { this.accountTracker.start(); - this.incomingTransactionsController.start(); + this.txController.startIncomingTransactionPolling(); if (this.preferencesController.store.getState().useCurrencyRateCheck) { this.currencyRateController.start(); } @@ -1814,7 +1796,7 @@ export default class MetamaskController extends EventEmitter { stopNetworkRequests() { this.accountTracker.stop(); - this.incomingTransactionsController.stop(); + this.txController.stopIncomingTransactionPolling(); if (this.preferencesController.store.getState().useCurrencyRateCheck) { this.currencyRateController.stop(); } @@ -1991,40 +1973,22 @@ export default class MetamaskController extends EventEmitter { * becomes unlocked are handled in MetaMaskController._onUnlock. */ setupControllerEventSubscriptions() { - const handleAccountsChange = async (origin, newAccounts) => { - if (this.isUnlocked()) { - this.notifyConnections(origin, { - method: NOTIFICATION_NAMES.accountsChanged, - // This should be the same as the return value of `eth_accounts`, - // namely an array of the current / most recently selected Ethereum - // account. - params: - newAccounts.length < 2 - ? // If the length is 1 or 0, the accounts are sorted by definition. - newAccounts - : // If the length is 2 or greater, we have to execute - // `eth_accounts` vi this method. - await this.getPermittedAccounts(origin), - }); + let lastSelectedAddress; + + this.preferencesController.store.subscribe(async (state) => { + const { selectedAddress, currentLocale } = state; + + await updateCurrentLocale(currentLocale); + + if (state?.featureFlags?.showIncomingTransactions) { + this.txController.startIncomingTransactionPolling(); + } else { + this.txController.stopIncomingTransactionPolling(); } - this.permissionLogController.updateAccountsHistory(origin, newAccounts); - }; - - // This handles account changes whenever the selected address changes. - let lastSelectedAddress; - this.preferencesController.store.subscribe(async ({ selectedAddress }) => { if (selectedAddress && selectedAddress !== lastSelectedAddress) { lastSelectedAddress = selectedAddress; - const permittedAccountsMap = getPermittedAccountsByOrigin( - this.permissionController.state, - ); - - for (const [origin, accounts] of permittedAccountsMap.entries()) { - if (accounts.includes(selectedAddress)) { - handleAccountsChange(origin, accounts); - } - } + await this._onAccountChange(selectedAddress); } }); @@ -2036,12 +2000,19 @@ export default class MetamaskController extends EventEmitter { const changedAccounts = getChangedAccounts(currentValue, previousValue); for (const [origin, accounts] of changedAccounts.entries()) { - handleAccountsChange(origin, accounts); + this._notifyAccountsChange(origin, accounts); } }, getPermittedAccountsByOrigin, ); + this.controllerMessenger.subscribe( + 'NetworkController:networkDidChange', + async () => { + await this.txController.updateIncomingTransactions(); + }, + ); + ///: BEGIN:ONLY_INCLUDE_IN(snaps) // Record Snap metadata whenever a Snap is added to state. this.controllerMessenger.subscribe( @@ -4819,4 +4790,38 @@ export default class MetamaskController extends EventEmitter { return null; } + + async _onAccountChange(newAddress) { + const permittedAccountsMap = getPermittedAccountsByOrigin( + this.permissionController.state, + ); + + for (const [origin, accounts] of permittedAccountsMap.entries()) { + if (accounts.includes(newAddress)) { + this._notifyAccountsChange(origin, accounts); + } + } + + await this.txController.updateIncomingTransactions(); + } + + async _notifyAccountsChange(origin, newAccounts) { + if (this.isUnlocked()) { + this.notifyConnections(origin, { + method: NOTIFICATION_NAMES.accountsChanged, + // This should be the same as the return value of `eth_accounts`, + // namely an array of the current / most recently selected Ethereum + // account. + params: + newAccounts.length < 2 + ? // If the length is 1 or 0, the accounts are sorted by definition. + newAccounts + : // If the length is 2 or greater, we have to execute + // `eth_accounts` vi this method. + await this.getPermittedAccounts(origin), + }); + } + + this.permissionLogController.updateAccountsHistory(origin, newAccounts); + } } diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index ec7f40643..c9d0b66c6 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -16,6 +16,7 @@ import { METAMASK_HOTLIST_DIFF_FILE, } from '@metamask/phishing-controller'; import { NetworkType } from '@metamask/controller-utils'; +import { ControllerMessenger } from '@metamask/base-controller'; import { TransactionStatus } from '../../shared/constants/transaction'; import createTxMeta from '../../test/lib/createTxMeta'; import { NETWORK_TYPES } from '../../shared/constants/network'; @@ -23,6 +24,8 @@ import { createTestProviderTools } from '../../test/stub/provider'; import { HardwareDeviceNames } from '../../shared/constants/hardware-wallets'; import { KeyringType } from '../../shared/constants/keyring'; import { deferredPromise } from './lib/util'; +import TransactionController from './controllers/transactions'; +import PreferencesController from './controllers/preferences'; const Ganache = require('../../test/e2e/ganache'); @@ -83,6 +86,14 @@ function MockEthContract() { }; } +function MockPreferencesController(...args) { + const controller = new PreferencesController(...args); + + sinon.stub(controller.store, 'subscribe'); + + return controller; +} + // TODO, Feb 24, 2023: // ethjs-contract is being added to proxyquire, but we might want to discontinue proxyquire // this is for expediency as we resolve a bug for v10.26.0. The proper solution here would have @@ -91,6 +102,7 @@ function MockEthContract() { const MetaMaskController = proxyquire('./metamask-controller', { './lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock }, 'ethjs-contract': MockEthContract, + './controllers/preferences': { default: MockPreferencesController }, }).default; const MetaMaskControllerMV3 = proxyquire('./metamask-controller', { @@ -279,6 +291,23 @@ describe('MetaMaskController', function () { beforeEach(function () { sandbox.spy(MetaMaskController.prototype, 'resetStates'); + sandbox.stub( + TransactionController.prototype, + 'updateIncomingTransactions', + ); + + sandbox.stub( + TransactionController.prototype, + 'startIncomingTransactionPolling', + ); + + sandbox.stub( + TransactionController.prototype, + 'stopIncomingTransactionPolling', + ); + + sandbox.spy(ControllerMessenger.prototype, 'subscribe'); + metamaskController = new MetaMaskController({ showUserConfirmation: noop, encryptor: { @@ -1647,6 +1676,60 @@ describe('MetaMaskController', function () { }); }); }); + + describe('incoming transactions', function () { + let txControllerStub, preferencesControllerSpy, controllerMessengerSpy; + + beforeEach(function () { + txControllerStub = TransactionController.prototype; + preferencesControllerSpy = metamaskController.preferencesController; + controllerMessengerSpy = ControllerMessenger.prototype; + }); + + it('starts incoming transaction polling if show incoming transactions enabled', async function () { + assert(txControllerStub.startIncomingTransactionPolling.notCalled); + + await preferencesControllerSpy.store.subscribe.lastCall.args[0]({ + featureFlags: { + showIncomingTransactions: true, + }, + }); + + assert(txControllerStub.startIncomingTransactionPolling.calledOnce); + }); + + it('stops incoming transaction polling if show incoming transactions disabled', async function () { + assert(txControllerStub.stopIncomingTransactionPolling.notCalled); + + await preferencesControllerSpy.store.subscribe.lastCall.args[0]({ + featureFlags: { + showIncomingTransactions: false, + }, + }); + + assert(txControllerStub.stopIncomingTransactionPolling.calledOnce); + }); + + it('updates incoming transactions when changing account', async function () { + assert(txControllerStub.updateIncomingTransactions.notCalled); + + await preferencesControllerSpy.store.subscribe.lastCall.args[0]({ + selectedAddress: 'foo', + }); + + assert(txControllerStub.updateIncomingTransactions.calledOnce); + }); + + it('updates incoming transactions when changing network', async function () { + assert(txControllerStub.updateIncomingTransactions.notCalled); + + await controllerMessengerSpy.subscribe.args + .filter((args) => args[0] === 'NetworkController:networkDidChange') + .slice(-1)[0][1](); + + assert(txControllerStub.updateIncomingTransactions.calledOnce); + }); + }); }); describe('MV3 Specific behaviour', function () { diff --git a/app/scripts/migrations/095.test.ts b/app/scripts/migrations/095.test.ts new file mode 100644 index 000000000..d4858de90 --- /dev/null +++ b/app/scripts/migrations/095.test.ts @@ -0,0 +1,364 @@ +import { migrate } from './095'; + +const INCOMING_TRANSACTION_MOCK = { + blockNumber: '1', + chainId: '0x539', + hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab', + id: 1, + metamaskNetworkId: '1337', + status: 'confirmed', + time: 1671635520000, + txParams: { + from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6', + gas: '0x5208', + gasPrice: '0x329af9707', + to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + value: '0xDE0B6B3A7640000', + }, + type: 'incoming', +}; + +const INCOMING_TRANSACTION_2_MOCK = { + ...INCOMING_TRANSACTION_MOCK, + blockNumber: '2', + id: 2, + chainId: '0x540', + txParams: { + ...INCOMING_TRANSACTION_MOCK.txParams, + to: '0x2', + }, +}; + +const TRANSACTION_MOCK = { + ...INCOMING_TRANSACTION_MOCK, + blockNumber: '3', + id: 3, + type: 'contractInteraction', +}; + +describe('migration #95', () => { + it('updates the version metadata', async () => { + const oldStorage = { + meta: { version: 94 }, + data: {}, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.meta).toStrictEqual({ version: 95 }); + }); + + it('does nothing if no IncomingTransactionsController state', async () => { + const oldData = { + some: 'data', + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldData); + }); + + it('removes IncomingTransactionsController state', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + }, + incomingTxLastFetchedBlockByChainId: { + '0x5': 1234, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: expect.any(Object), + }); + }); + + describe('moves incoming transactions', () => { + it('if no TransactionController state', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK, + }, + lastFetchedBlockNumbers: expect.any(Object), + }, + }); + }); + + it('if existing TransactionController state', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK, + }, + }, + TransactionController: { + transactions: { + [TRANSACTION_MOCK.id]: TRANSACTION_MOCK, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: { + ...oldData.TransactionController.transactions, + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK, + }, + lastFetchedBlockNumbers: expect.any(Object), + }, + }); + }); + + it.each([ + ['undefined', undefined], + ['empty', {}], + ])( + 'does nothing if incoming transactions %s', + async (_title, incomingTransactions) => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions, + }, + TransactionController: { + transactions: { + [TRANSACTION_MOCK.id]: TRANSACTION_MOCK, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: oldData.TransactionController, + }); + }, + ); + }); + + describe('generates last fetched block numbers', () => { + it('if incoming transactions have chain ID, block number, and to address', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: expect.any(Object), + lastFetchedBlockNumbers: { + [`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]: + parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10), + [`${INCOMING_TRANSACTION_2_MOCK.chainId}#${INCOMING_TRANSACTION_2_MOCK.txParams.to}`]: + parseInt(INCOMING_TRANSACTION_2_MOCK.blockNumber, 10), + }, + }, + }); + }); + + it('using highest block number for each chain ID and to address', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: { + ...INCOMING_TRANSACTION_2_MOCK, + chainId: INCOMING_TRANSACTION_MOCK.chainId, + txParams: { + ...INCOMING_TRANSACTION_2_MOCK.txParams, + to: INCOMING_TRANSACTION_MOCK.txParams.to, + }, + }, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: expect.any(Object), + lastFetchedBlockNumbers: { + [`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]: + parseInt(INCOMING_TRANSACTION_2_MOCK.blockNumber, 10), + }, + }, + }); + }); + + it('ignoring incoming transactions with no chain ID', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: { + ...INCOMING_TRANSACTION_2_MOCK, + chainId: undefined, + }, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: expect.any(Object), + lastFetchedBlockNumbers: { + [`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]: + parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10), + }, + }, + }); + }); + + it('ignoring incoming transactions with no block number', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: { + ...INCOMING_TRANSACTION_2_MOCK, + blockNumber: undefined, + }, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: expect.any(Object), + lastFetchedBlockNumbers: { + [`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]: + parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10), + }, + }, + }); + }); + + it('ignoring incoming transactions with no to address', async () => { + const oldData = { + some: 'data', + IncomingTransactionsController: { + incomingTransactions: { + [INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK, + [INCOMING_TRANSACTION_2_MOCK.id]: { + ...INCOMING_TRANSACTION_2_MOCK, + txParams: { + ...INCOMING_TRANSACTION_2_MOCK.txParams, + to: undefined, + }, + }, + }, + }, + }; + + const oldStorage = { + meta: { version: 94 }, + data: oldData, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + some: oldData.some, + TransactionController: { + transactions: expect.any(Object), + lastFetchedBlockNumbers: { + [`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]: + parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10), + }, + }, + }); + }); + }); +}); diff --git a/app/scripts/migrations/095.ts b/app/scripts/migrations/095.ts new file mode 100644 index 000000000..39128284e --- /dev/null +++ b/app/scripts/migrations/095.ts @@ -0,0 +1,94 @@ +import { cloneDeep } from 'lodash'; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; + +export const version = 95; + +/** + * This migration does the following: + * + * - Moves any incoming transactions from the IncomingTransactionsController to the TransactionController state. + * - Generates the new lastFetchedBlockNumbers object in the TransactionController using any existing incoming transactions. + * - Removes the IncomingTransactionsController state. + * + * @param originalVersionedData - Versioned MetaMask extension state, exactly what we persist to dist. + * @param originalVersionedData.meta - State metadata. + * @param originalVersionedData.meta.version - The current state version. + * @param originalVersionedData.data - The persisted MetaMask state, keyed by controller. + * @returns Updated versioned MetaMask extension state. + */ +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + migrateData(versionedData.data); + return versionedData; +} + +function migrateData(state: Record): void { + moveIncomingTransactions(state); + generateLastFetchedBlockNumbers(state); + removeIncomingTransactionsControllerState(state); +} + +function moveIncomingTransactions(state: Record) { + const incomingTransactions: Record = + state.IncomingTransactionsController?.incomingTransactions || {}; + + if (Object.keys(incomingTransactions).length === 0) { + return; + } + + const transactions = state.TransactionController?.transactions || {}; + + const updatedTransactions = Object.values(incomingTransactions).reduce( + (result: Record, tx: any) => { + result[tx.id] = tx; + return result; + }, + transactions, + ); + + state.TransactionController = { + ...(state.TransactionController || {}), + transactions: updatedTransactions, + }; +} + +function generateLastFetchedBlockNumbers(state: Record) { + const incomingTransactions: Record = + state.IncomingTransactionsController?.incomingTransactions || {}; + + if (Object.keys(incomingTransactions).length === 0) { + return; + } + + const lastFetchedBlockNumbers: Record = {}; + + for (const tx of Object.values(incomingTransactions)) { + if (!tx.blockNumber || !tx.chainId || !tx.txParams.to) { + continue; + } + + const txBlockNumber = parseInt(tx.blockNumber, 10); + const key = `${tx.chainId}#${tx.txParams.to.toLowerCase()}`; + const highestBlockNumber = lastFetchedBlockNumbers[key] || -1; + + lastFetchedBlockNumbers[key] = Math.max(highestBlockNumber, txBlockNumber); + } + + state.TransactionController = { + ...state.TransactionController, + lastFetchedBlockNumbers, + }; +} + +function removeIncomingTransactionsControllerState( + state: Record, +) { + delete state.IncomingTransactionsController; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index aa7e7ddbc..c23ec9608 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -99,6 +99,7 @@ import * as m092 from './092'; import * as m092point1 from './092.1'; import * as m093 from './093'; import * as m094 from './094'; +import * as m095 from './095'; const migrations = [ m002, @@ -195,5 +196,6 @@ const migrations = [ m092point1, m093, m094, + m095, ]; export default migrations; diff --git a/jest.config.js b/jest.config.js index 9f4a2a858..c95ec4ce8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,9 @@ module.exports = { '/app/scripts/controllers/permissions/**/*.js', '/app/scripts/controllers/sign.ts', '/app/scripts/controllers/decrypt-message.ts', + '/app/scripts/controllers/transactions/etherscan.ts', + '/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts', + '/app/scripts/controllers/transactions/IncomingTransactionHelper.ts', '/app/scripts/flask/**/*.js', '/app/scripts/lib/**/*.js', '/app/scripts/lib/createRPCMethodTrackingMiddleware.js', @@ -37,6 +40,9 @@ module.exports = { testMatch: [ '/app/scripts/constants/error-utils.test.js', '/app/scripts/controllers/app-state.test.js', + '/app/scripts/controllers/transactions/etherscan.test.ts', + '/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts', + '/app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts', '/app/scripts/controllers/mmi-controller.test.js', '/app/scripts/controllers/permissions/**/*.test.js', '/app/scripts/controllers/sign.test.ts', diff --git a/shared/constants/transaction.ts b/shared/constants/transaction.ts index 1d7021402..d14e9400e 100644 --- a/shared/constants/transaction.ts +++ b/shared/constants/transaction.ts @@ -269,7 +269,7 @@ export interface TxParams { /** The amount of wei, in hexadecimal, to send */ value: string; /** The transaction count for the current account/network */ - nonce: number; + nonce: string; /** The amount of gwei, in hexadecimal, per unit of gas */ gasPrice?: string; /** The max amount of gwei, in hexadecimal, the user is willing to pay */ @@ -329,6 +329,7 @@ export interface TransactionMeta { * on incoming transactions! */ blockNumber?: string; + chainId: string; /** An internally unique tx identifier. */ id: number; /** Time the transaction was first suggested, in unix epoch time (ms). */ diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 12de298bd..79b943411 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -197,16 +197,6 @@ function defaultFixture() { gasEstimateType: 'none', gasFeeEstimates: {}, }, - IncomingTransactionsController: { - incomingTransactions: {}, - incomingTxLastFetchedBlockByChainId: { - [CHAIN_IDS.MAINNET]: null, - [CHAIN_IDS.LINEA_MAINNET]: null, - [CHAIN_IDS.GOERLI]: null, - [CHAIN_IDS.SEPOLIA]: null, - [CHAIN_IDS.LINEA_GOERLI]: null, - }, - }, KeyringController: { vault: '{"data":"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT","iv":"FbeHDAW5afeWNORfNJBR0Q==","salt":"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8="}', @@ -487,40 +477,6 @@ class FixtureBuilder { return this; } - withIncomingTransactionsController(data) { - merge( - this.fixture.data.IncomingTransactionsController - ? this.fixture.data.IncomingTransactionsController - : (this.fixture.data.IncomingTransactionsController = {}), - data, - ); - return this; - } - - withIncomingTransactionsControllerOneTransaction() { - return this.withIncomingTransactionsController({ - incomingTransactions: { - '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab': { - blockNumber: '1', - chainId: CHAIN_IDS.LOCALHOST, - hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab', - id: 5748272735958807, - metamaskNetworkId: '1337', - status: 'confirmed', - time: 1671635520000, - txParams: { - from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6', - gas: '0x5208', - gasPrice: '0x329af9707', - to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - value: '0xDE0B6B3A7640000', - }, - type: 'incoming', - }, - }, - }); - } - withKeyringController(data) { merge(this.fixture.data.KeyringController, data); return this; @@ -1488,6 +1444,47 @@ class FixtureBuilder { }); } + withTransactionControllerIncomingTransaction() { + return this.withTransactionController({ + transactions: { + 5748272735958807: { + blockNumber: '1', + chainId: CHAIN_IDS.LOCALHOST, + hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab', + id: 5748272735958807, + metamaskNetworkId: '1337', + status: 'confirmed', + time: 1671635520000, + txParams: { + from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6', + gas: '0x5208', + gasPrice: '0x329af9707', + to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + value: '0xDE0B6B3A7640000', + }, + type: 'incoming', + }, + }, + }); + } + + withTransactionControllerCompletedAndIncomingTransaction() { + const completedTransaction = + this.withTransactionControllerCompletedTransaction().fixture.data + .TransactionController.transactions; + + const incomingTransaction = + this.withTransactionControllerIncomingTransaction().fixture.data + .TransactionController.transactions; + + return this.withTransactionController({ + transactions: { + ...completedTransaction, + ...incomingTransaction, + }, + }); + } + build() { this.fixture.meta = { version: 74, diff --git a/test/e2e/tests/clear-activity.spec.js b/test/e2e/tests/clear-activity.spec.js index 96f6bc358..77d9fdb8b 100644 --- a/test/e2e/tests/clear-activity.spec.js +++ b/test/e2e/tests/clear-activity.spec.js @@ -21,8 +21,7 @@ describe('Clear account activity', function () { await withFixtures( { fixtures: new FixtureBuilder() - .withTransactionControllerCompletedTransaction() - .withIncomingTransactionsControllerOneTransaction() + .withTransactionControllerCompletedAndIncomingTransaction() .build(), ganacheOptions, title: this.test.title, diff --git a/test/e2e/tests/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/state-snapshots/errors-after-init-opt-in-background-state.json index 5a4012071..839e0d76b 100644 --- a/test/e2e/tests/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/state-snapshots/errors-after-init-opt-in-background-state.json @@ -57,16 +57,6 @@ }, "EnsController": "object", "GasFeeController": "object", - "IncomingTransactionsController": { - "incomingTransactions": "object", - "incomingTxLastFetchedBlockByChainId": { - "0x1": null, - "0xe708": null, - "0x5": null, - "0xaa36a7": null, - "0xe704": null - } - }, "KeyringController": { "isUnlocked": false, "keyringTypes": "object", diff --git a/test/e2e/tests/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/state-snapshots/errors-after-init-opt-in-ui-state.json index 7843dcd7f..98eaf99d3 100644 --- a/test/e2e/tests/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -100,6 +100,7 @@ "metaMetricsId": "fake-metrics-id", "eventsBeforeMetricsOptIn": "object", "traits": "object", + "transactions": "object", "fragments": "object", "segmentApiCalls": "object", "previousUserTraits": "object", @@ -113,14 +114,6 @@ "web3ShimUsageOrigins": "object", "seedPhraseBackedUp": true, "onboardingTabs": "object", - "incomingTransactions": "object", - "incomingTxLastFetchedBlockByChainId": { - "0x1": null, - "0xe708": null, - "0x5": null, - "0xaa36a7": null, - "0xe704": null - }, "subjects": "object", "permissionHistory": "object", "permissionActivityLog": "object", diff --git a/test/e2e/tests/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/state-snapshots/errors-before-init-opt-in-background-state.json index 422bcb0e2..3dbc33e79 100644 --- a/test/e2e/tests/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/state-snapshots/errors-before-init-opt-in-background-state.json @@ -32,16 +32,6 @@ "usdConversionRate": "number" }, "GasFeeController": "object", - "IncomingTransactionsController": { - "incomingTransactions": "object", - "incomingTxLastFetchedBlockByChainId": { - "0x1": null, - "0xe708": null, - "0x5": null, - "0xaa36a7": null, - "0xe704": null - } - }, "KeyringController": { "vault": "string" }, "MetaMetricsController": { "eventsBeforeMetricsOptIn": "object", diff --git a/test/e2e/tests/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/state-snapshots/errors-before-init-opt-in-ui-state.json index 0a9fac1d0..ca6006c7a 100644 --- a/test/e2e/tests/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -32,16 +32,6 @@ "usdConversionRate": "number" }, "GasFeeController": "object", - "IncomingTransactionsController": { - "incomingTransactions": "object", - "incomingTxLastFetchedBlockByChainId": { - "0x1": null, - "0xe708": null, - "0x5": null, - "0xaa36a7": null, - "0xe704": null - } - }, "KeyringController": { "vault": "string" }, "MetaMetricsController": { "eventsBeforeMetricsOptIn": "object", diff --git a/ui/components/app/modals/account-details-modal/account-details-modal.test.js b/ui/components/app/modals/account-details-modal/account-details-modal.test.js index 6b365c5de..e238d8637 100644 --- a/ui/components/app/modals/account-details-modal/account-details-modal.test.js +++ b/ui/components/app/modals/account-details-modal/account-details-modal.test.js @@ -205,7 +205,6 @@ describe('Account Details Modal', () => { }, }, cachedBalances: {}, - incomingTransactions: {}, selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', accounts: { '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { diff --git a/ui/selectors/nonce-sorted-transactions-selector.test.js b/ui/selectors/nonce-sorted-transactions-selector.test.js index 89a2599af..6471cca92 100644 --- a/ui/selectors/nonce-sorted-transactions-selector.test.js +++ b/ui/selectors/nonce-sorted-transactions-selector.test.js @@ -83,7 +83,7 @@ const getStateTree = ({ featureFlags: { showIncomingTransactions: true, }, - incomingTransactions: [...incomingTxList], + transactions: [...incomingTxList], currentNetworkTxList: [...txList], }, }); diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js index 61dc52883..9dbe31e84 100644 --- a/ui/selectors/transactions.js +++ b/ui/selectors/transactions.js @@ -27,6 +27,7 @@ const INVALID_INITIAL_TRANSACTION_TYPES = [ export const incomingTxListSelector = (state) => { const { showIncomingTransactions } = state.metamask.featureFlags; + if (!showIncomingTransactions) { return []; } @@ -34,8 +35,10 @@ export const incomingTxListSelector = (state) => { const { networkId } = state.metamask; const { chainId } = getProviderConfig(state); const selectedAddress = getSelectedAddress(state); - return Object.values(state.metamask.incomingTransactions).filter( + + return Object.values(state.metamask.transactions || {}).filter( (tx) => + tx.type === TransactionType.incoming && tx.txParams.to === selectedAddress && transactionMatchesNetwork(tx, chainId, networkId), ); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index fe2dc5b44..2040eae8e 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -85,6 +85,7 @@ import { import { decimalToHex } from '../../shared/modules/conversion.utils'; import { TxGasFees, PriorityLevels } from '../../shared/constants/gas'; import { + TransactionMeta, TransactionMetaMetricsEvent, TransactionType, } from '../../shared/constants/transaction'; @@ -94,10 +95,9 @@ import { isErrorWithMessage, logErrorWithMessage, } from '../../shared/modules/error'; -import { TransactionMeta } from '../../app/scripts/controllers/incoming-transactions'; import { TxParams } from '../../app/scripts/controllers/transactions/tx-state-manager'; -import { CustomGasSettings } from '../../app/scripts/controllers/transactions'; import { ThemeType } from '../../shared/constants/preferences'; +import { CustomGasSettings } from '../../app/scripts/controllers/transactions'; import * as actionConstants from './actionConstants'; ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) import { updateCustodyState } from './institutional/institution-actions';