import assert from 'assert' import sinon from 'sinon' import proxyquire from 'proxyquire' import nock from 'nock' import { cloneDeep } from 'lodash' import waitUntilCalled from '../../../lib/wait-until-called' import { GOERLI, KOVAN, MAINNET, MAINNET_CHAIN_ID, RINKEBY, ROPSTEN, ROPSTEN_CHAIN_ID, ROPSTEN_NETWORK_ID, } from '../../../../app/scripts/controllers/network/enums' import { TRANSACTION_CATEGORIES, TRANSACTION_STATUSES, } from '../../../../shared/constants/transaction' const IncomingTransactionsController = proxyquire( '../../../../app/scripts/controllers/incoming-transactions', { '../lib/random-id': { default: () => 54321 }, }, ).default const FAKE_CHAIN_ID = '0x1338' const MOCK_SELECTED_ADDRESS = '0x0101' const SET_STATE_TIMEOUT = 10 function getEmptyInitState() { return { incomingTransactions: {}, incomingTxLastFetchedBlocksByNetwork: { [GOERLI]: null, [KOVAN]: null, [MAINNET]: null, [RINKEBY]: null, [ROPSTEN]: null, }, } } function getNonEmptyInitState() { return { incomingTransactions: { '0x123456': { id: 777 }, }, incomingTxLastFetchedBlocksByNetwork: { [GOERLI]: 1, [KOVAN]: 2, [MAINNET]: 3, [RINKEBY]: 5, [ROPSTEN]: 4, }, } } function getMockNetworkController(chainId = FAKE_CHAIN_ID) { return { getCurrentChainId: () => chainId, on: 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 getMockBlockTracker() { return { addListener: sinon.stub().callsArgWithAsync(1, '0xa'), removeListener: sinon.spy(), testProperty: 'fakeBlockTracker', getCurrentBlock: () => '0xa', } } /** * 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 WEI * @property {string} gasPrice - The gas price, 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 */ /** * Returns a transaction object matching the expected format returned * by the Etherscan API * * @param {string} [toAddress] - The hex-prefixed address of the recipient * @param {number} [blockNumber] - The block number for the transaction * @returns {EtherscanTransaction} */ const getFakeEtherscanTransaction = ( toAddress = MOCK_SELECTED_ADDRESS, blockNumber = 10, ) => { return { blockNumber: blockNumber.toString(), from: '0xfake', gas: '0', gasPrice: '0', hash: '0xfake', isError: '0', nonce: '100', timeStamp: '16000000000000', to: toAddress, value: '0', } } 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 incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(), preferencesController: getMockPreferencesController(), initState: {}, }, ) sinon.spy(incomingTransactionsController, '_update') assert.deepEqual( incomingTransactionsController.store.getState(), getEmptyInitState(), ) assert(incomingTransactionsController.networkController.on.calledOnce) assert.equal( incomingTransactionsController.networkController.on.getCall(0).args[0], 'networkDidChange', ) const networkControllerListenerCallback = incomingTransactionsController.networkController.on.getCall( 0, ).args[1] assert.equal(incomingTransactionsController._update.callCount, 0) networkControllerListenerCallback('testNetworkType') assert.equal(incomingTransactionsController._update.callCount, 1) assert.deepEqual( incomingTransactionsController._update.getCall(0).args[0], { address: '0x0101', }, ) incomingTransactionsController._update.resetHistory() }) it('should set the store to a provided initial state', function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) assert.deepEqual( 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(), networkController: getMockNetworkController(), preferencesController: getMockPreferencesController(), initState: {}, }, ) incomingTransactionsController.start() assert(incomingTransactionsController.blockTracker.addListener.calledOnce) assert.equal( 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(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) const startBlock = getNonEmptyInitState() .incomingTxLastFetchedBlocksByNetwork[ROPSTEN] nock('https://api-ropsten.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, ) incomingTransactionsController.start() 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: '3', status: TRANSACTION_STATUSES.CONFIRMED, time: 16000000000000000, transactionCategory: TRANSACTION_CATEGORIES.INCOMING, txParams: { from: '0xfake', gas: '0x0', gasPrice: '0x0', nonce: '0x64', to: '0x0101', value: '0x0', }, }, }, incomingTxLastFetchedBlocksByNetwork: { ...getNonEmptyInitState().incomingTxLastFetchedBlocksByNetwork, [ROPSTEN]: 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(), networkController: getMockNetworkController(), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) // reply with a valid request for any supported network, so that this test has every opportunity to fail for (const network of [ GOERLI, KOVAN, MAINNET, RINKEBY, ROPSTEN, 'undefined', ]) { nock( `https://api${ network === MAINNET ? '' : `-${network.toLowerCase()}` }.etherscan.io`, ) .get(/api.+/u) .reply( 200, JSON.stringify({ 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(), networkController: getMockNetworkController(), preferencesController: getMockPreferencesController({ showIncomingTransactions: false, }), initState: getNonEmptyInitState(), }, ) // reply with a valid request for any supported network, so that this test has every opportunity to fail for (const network of [ GOERLI, KOVAN, MAINNET, RINKEBY, ROPSTEN, 'undefined', ]) { nock( `https://api${ network === MAINNET ? '' : `-${network.toLowerCase()}` }.etherscan.io`, ) .get(/api.+/u) .reply( 200, JSON.stringify({ 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(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) // reply with a valid request for any supported network, so that this test has every opportunity to fail for (const network of [ GOERLI, KOVAN, MAINNET, RINKEBY, ROPSTEN, 'undefined', ]) { nock( `https://api${ network === MAINNET ? '' : `-${network.toLowerCase()}` }.etherscan.io`, ) .get(/api.+/u) .reply( 200, JSON.stringify({ 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(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) // reply with a valid request for any supported network, so that this test has every opportunity to fail for (const network of [ GOERLI, KOVAN, MAINNET, RINKEBY, ROPSTEN, 'undefined', ]) { nock( `https://api${ network === MAINNET ? '' : `-${network.toLowerCase()}` }.etherscan.io`, ) .get(/api.+/u) .reply( 200, JSON.stringify({ 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(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) const NEW_MOCK_SELECTED_ADDRESS = `${MOCK_SELECTED_ADDRESS}9` const startBlock = getNonEmptyInitState() .incomingTxLastFetchedBlocksByNetwork[ROPSTEN] nock('https://api-ropsten.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(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: '3', status: TRANSACTION_STATUSES.CONFIRMED, time: 16000000000000000, transactionCategory: TRANSACTION_CATEGORIES.INCOMING, txParams: { from: '0xfake', gas: '0x0', gasPrice: '0x0', nonce: '0x64', to: '0x01019', value: '0x0', }, }, }, incomingTxLastFetchedBlocksByNetwork: { ...getNonEmptyInitState().incomingTxLastFetchedBlocksByNetwork, [ROPSTEN]: 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() }, networkController: getMockNetworkController(), preferencesController: getMockPreferencesController(), 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 for (const network of [ GOERLI, KOVAN, MAINNET, RINKEBY, ROPSTEN, 'undefined', ]) { nock( `https://api${ network === MAINNET ? '' : `-${network.toLowerCase()}` }.etherscan.io`, ) .get(/api.+/u) .reply( 200, JSON.stringify({ status: '1', result: [getFakeEtherscanTransaction(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 incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) const startBlock = getNonEmptyInitState() .incomingTxLastFetchedBlocksByNetwork[ROPSTEN] nock('https://api-ropsten.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 = incomingTransactionsController.networkController.on.getCall( 0, ).args[1] incomingTransactionsController.networkController = getMockNetworkController( ROPSTEN_CHAIN_ID, ) await subscription(ROPSTEN) 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: '3', status: TRANSACTION_STATUSES.CONFIRMED, time: 16000000000000000, transactionCategory: TRANSACTION_CATEGORIES.INCOMING, txParams: { from: '0xfake', gas: '0x0', gasPrice: '0x0', nonce: '0x64', to: '0x0101', value: '0x0', }, }, }, incomingTxLastFetchedBlocksByNetwork: { ...getNonEmptyInitState().incomingTxLastFetchedBlocksByNetwork, [ROPSTEN]: 11, }, }, 'State should have been updated after first block was received', ) }) it('should not update when switching to an unsupported network', async function () { const networkController = getMockNetworkController(ROPSTEN_CHAIN_ID) const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController, preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) // reply with a valid request for any supported network, so that this test has every opportunity to fail for (const network of [ GOERLI, KOVAN, MAINNET, RINKEBY, ROPSTEN, 'undefined', ]) { nock( `https://api${ network === MAINNET ? '' : `-${network.toLowerCase()}` }.etherscan.io`, ) .get(/api.+/u) .reply( 200, JSON.stringify({ 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 = incomingTransactionsController.networkController.on.getCall( 0, ).args[1] networkController.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('_getDataForUpdate', function () { it('should call fetchAll with the correct params when passed a new block number and the current network has no stored block', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getEmptyInitState(), }, ) incomingTransactionsController._fetchAll = sinon.stub().returns({}) await incomingTransactionsController._getDataForUpdate({ address: 'fakeAddress', chainId: ROPSTEN_CHAIN_ID, newBlockNumberDec: 999, }) assert(incomingTransactionsController._fetchAll.calledOnce) assert.deepEqual( incomingTransactionsController._fetchAll.getCall(0).args, ['fakeAddress', 999, ROPSTEN_CHAIN_ID], ) }) it('should call fetchAll with the correct params when passed a new block number but the current network has a stored block', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) incomingTransactionsController._fetchAll = sinon.stub().returns({}) await incomingTransactionsController._getDataForUpdate({ address: 'fakeAddress', chainId: ROPSTEN_CHAIN_ID, newBlockNumberDec: 999, }) assert(incomingTransactionsController._fetchAll.calledOnce) assert.deepEqual( incomingTransactionsController._fetchAll.getCall(0).args, ['fakeAddress', 4, ROPSTEN_CHAIN_ID], ) }) it('should return the expected data', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) incomingTransactionsController._fetchAll = sinon.stub().returns({ latestIncomingTxBlockNumber: 444, txs: [{ id: 555 }], }) const result = await incomingTransactionsController._getDataForUpdate({ address: 'fakeAddress', chainId: ROPSTEN_CHAIN_ID, }) assert.deepEqual(result, { latestIncomingTxBlockNumber: 444, newTxs: [{ id: 555 }], currentIncomingTxs: { '0x123456': { id: 777 }, }, currentBlocksByNetwork: { [GOERLI]: 1, [KOVAN]: 2, [MAINNET]: 3, [RINKEBY]: 5, [ROPSTEN]: 4, }, fetchedBlockNumber: 4, chainId: ROPSTEN_CHAIN_ID, }) }) }) describe('_updateStateWithNewTxData', function () { const MOCK_INPUT_WITHOUT_LASTEST = { newTxs: [{ id: 555, hash: '0xfff' }], currentIncomingTxs: { '0x123456': { id: 777, hash: '0x123456' }, }, currentBlocksByNetwork: { [GOERLI]: 1, [KOVAN]: 2, [MAINNET]: 3, [RINKEBY]: 5, [ROPSTEN]: 4, }, fetchedBlockNumber: 1111, chainId: ROPSTEN_CHAIN_ID, } const MOCK_INPUT_WITH_LASTEST = { ...MOCK_INPUT_WITHOUT_LASTEST, latestIncomingTxBlockNumber: 444, } it('should update state with correct blockhash and transactions when passed a truthy latestIncomingTxBlockNumber', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) sinon.spy(incomingTransactionsController.store, 'updateState') await incomingTransactionsController._updateStateWithNewTxData( MOCK_INPUT_WITH_LASTEST, ) assert(incomingTransactionsController.store.updateState.calledOnce) assert.deepEqual( incomingTransactionsController.store.updateState.getCall(0).args[0], { incomingTxLastFetchedBlocksByNetwork: { ...MOCK_INPUT_WITH_LASTEST.currentBlocksByNetwork, [ROPSTEN]: 445, }, incomingTransactions: { '0x123456': { id: 777, hash: '0x123456' }, '0xfff': { id: 555, hash: '0xfff' }, }, }, ) }) it('should update state with correct blockhash and transactions when passed a falsy latestIncomingTxBlockNumber', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) sinon.spy(incomingTransactionsController.store, 'updateState') await incomingTransactionsController._updateStateWithNewTxData( MOCK_INPUT_WITHOUT_LASTEST, ) assert(incomingTransactionsController.store.updateState.calledOnce) assert.deepEqual( incomingTransactionsController.store.updateState.getCall(0).args[0], { incomingTxLastFetchedBlocksByNetwork: { ...MOCK_INPUT_WITH_LASTEST.currentBlocksByNetwork, [ROPSTEN]: 1112, }, incomingTransactions: { '0x123456': { id: 777, hash: '0x123456' }, '0xfff': { id: 555, hash: '0xfff' }, }, }, ) }) }) describe('_fetchTxs', function () { const mockFetch = sinon.stub().returns( Promise.resolve({ json: () => Promise.resolve({ someKey: 'someValue' }), }), ) 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 network', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) await incomingTransactionsController._fetchTxs( '0xfakeaddress', '789', ROPSTEN_CHAIN_ID, ) assert(mockFetch.calledOnce) assert.equal( mockFetch.getCall(0).args[0], `https://api-${ROPSTEN}.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', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(MAINNET_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) await incomingTransactionsController._fetchTxs( '0xfakeaddress', '789', MAINNET_CHAIN_ID, ) assert(mockFetch.calledOnce) assert.equal( 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 network, but a falsy block number', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) await incomingTransactionsController._fetchTxs( '0xfakeaddress', null, ROPSTEN_CHAIN_ID, ) assert(mockFetch.calledOnce) assert.equal( mockFetch.getCall(0).args[0], `https://api-${ROPSTEN}.etherscan.io/api?module=account&action=txlist&address=0xfakeaddress&tag=latest&page=1`, ) }) it('should return the results from the fetch call, plus the address and currentNetworkID, when passed an address, block number and supported network', async function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) const result = await incomingTransactionsController._fetchTxs( '0xfakeaddress', '789', ROPSTEN_CHAIN_ID, ) assert(mockFetch.calledOnce) assert.deepEqual(result, { someKey: 'someValue', address: '0xfakeaddress', chainId: ROPSTEN_CHAIN_ID, }) }) }) describe('_processTxFetchResponse', function () { it('should return a null block number and empty tx array if status is 0', function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) const result = incomingTransactionsController._processTxFetchResponse({ status: '0', result: [{ id: 1 }], address: '0xfakeaddress', }) assert.deepEqual(result, { latestIncomingTxBlockNumber: null, txs: [], }) }) it('should return a null block number and empty tx array if the passed result array is empty', function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) const result = incomingTransactionsController._processTxFetchResponse({ status: '1', result: [], address: '0xfakeaddress', }) assert.deepEqual(result, { latestIncomingTxBlockNumber: null, txs: [], }) }) it('should return the expected block number and tx list when passed data from a successful fetch', function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), initState: getNonEmptyInitState(), }, ) incomingTransactionsController._normalizeTxFromEtherscan = (tx) => ({ ...tx, currentNetworkID: ROPSTEN_NETWORK_ID, normalized: true, }) const result = incomingTransactionsController._processTxFetchResponse({ status: '1', address: '0xfakeaddress', chainId: ROPSTEN_CHAIN_ID, result: [ { hash: '0xabc123', txParams: { to: '0xfakeaddress', }, blockNumber: 5000, time: 10, }, { hash: '0xabc123', txParams: { to: '0xfakeaddress', }, blockNumber: 5000, time: 10, }, { hash: '0xabc1234', txParams: { to: '0xfakeaddress', }, blockNumber: 5000, time: 9, }, { hash: '0xabc12345', txParams: { to: '0xfakeaddress', }, blockNumber: 5001, time: 11, }, { hash: '0xabc123456', txParams: { to: '0xfakeaddress', }, blockNumber: 5001, time: 12, }, { hash: '0xabc1234567', txParams: { to: '0xanotherFakeaddress', }, blockNumber: 5002, time: 13, }, ], }) assert.deepEqual(result, { latestIncomingTxBlockNumber: 5001, txs: [ { hash: '0xabc1234', txParams: { to: '0xfakeaddress', }, blockNumber: 5000, time: 9, normalized: true, currentNetworkID: ROPSTEN_NETWORK_ID, }, { hash: '0xabc123', txParams: { to: '0xfakeaddress', }, blockNumber: 5000, time: 10, normalized: true, currentNetworkID: ROPSTEN_NETWORK_ID, }, { hash: '0xabc12345', txParams: { to: '0xfakeaddress', }, blockNumber: 5001, time: 11, normalized: true, currentNetworkID: ROPSTEN_NETWORK_ID, }, { hash: '0xabc123456', txParams: { to: '0xfakeaddress', }, blockNumber: 5001, time: 12, normalized: true, currentNetworkID: ROPSTEN_NETWORK_ID, }, ], }) }) }) describe('_normalizeTxFromEtherscan', function () { it('should return the expected data when the tx is in error', function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), 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', }, ROPSTEN_CHAIN_ID, ) assert.deepEqual(result, { blockNumber: 333, id: 54321, metamaskNetworkId: ROPSTEN_NETWORK_ID, status: TRANSACTION_STATUSES.FAILED, time: 4444000, txParams: { from: '0xa', gas: '0xb', gasPrice: '0xc', nonce: '0xd', to: '0xe', value: '0xf', }, hash: '0xg', transactionCategory: TRANSACTION_CATEGORIES.INCOMING, }) }) it('should return the expected data when the tx is not in error', function () { const incomingTransactionsController = new IncomingTransactionsController( { blockTracker: getMockBlockTracker(), networkController: getMockNetworkController(ROPSTEN_CHAIN_ID), preferencesController: getMockPreferencesController(), 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', }, ROPSTEN_CHAIN_ID, ) assert.deepEqual(result, { blockNumber: 333, id: 54321, metamaskNetworkId: ROPSTEN_NETWORK_ID, status: TRANSACTION_STATUSES.CONFIRMED, time: 4444000, txParams: { from: '0xa', gas: '0xb', gasPrice: '0xc', nonce: '0xd', to: '0xe', value: '0xf', }, hash: '0xg', transactionCategory: TRANSACTION_CATEGORIES.INCOMING, }) }) }) })