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 '../../../../shared/constants/network'; import { TRANSACTION_CATEGORIES, TRANSACTION_STATUSES, } from '../../../../shared/constants/transaction'; import { NETWORK_EVENTS } from '../../../../app/scripts/controllers/network'; 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], NETWORK_EVENTS.NETWORK_DID_CHANGE, ); 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, }); }); }); });