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' const IncomingTransactionsController = proxyquire('../../../../app/scripts/controllers/incoming-transactions', { '../lib/random-id': { default: () => 54321 }, }).default const FAKE_CHAIN_ID = '0x1338' const MOCK_SELECTED_ADDRESS = '0x0101' 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: 'confirmed', time: 16000000000000000, transactionCategory: '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]) { 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')), 1000) }), ]) 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]) { 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')), 1000) }), ]) 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]) { 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')), 1000) }), ]) 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]) { 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')), 1000) }), ]) 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: 'confirmed', time: 16000000000000000, transactionCategory: '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]) { 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')), 1000) }), ]) 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: 'confirmed', time: 16000000000000000, transactionCategory: '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]) { 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')), 1000) }), ]) 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: 'failed', time: 4444000, txParams: { from: '0xa', gas: '0xb', gasPrice: '0xc', nonce: '0xd', to: '0xe', value: '0xf', }, hash: '0xg', transactionCategory: '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: 'confirmed', time: 4444000, txParams: { from: '0xa', gas: '0xb', gasPrice: '0xc', nonce: '0xd', to: '0xe', value: '0xf', }, hash: '0xg', transactionCategory: 'incoming', }) }) }) })