diff --git a/app/scripts/controllers/incoming-transactions.js b/app/scripts/controllers/incoming-transactions.js new file mode 100644 index 000000000..4b4314427 --- /dev/null +++ b/app/scripts/controllers/incoming-transactions.js @@ -0,0 +1,222 @@ +const ObservableStore = require('obs-store') +const log = require('loglevel') +const BN = require('bn.js') +const createId = require('../lib/random-id') +const { bnToHex } = require('../lib/util') +const { + MAINNET_CODE, + ROPSTEN_CODE, + RINKEYBY_CODE, + KOVAN_CODE, + ROPSTEN, + RINKEBY, + KOVAN, + MAINNET, +} = require('./network/enums') +const networkTypeToIdMap = { + [ROPSTEN]: ROPSTEN_CODE, + [RINKEBY]: RINKEYBY_CODE, + [KOVAN]: KOVAN_CODE, + [MAINNET]: MAINNET_CODE, +} + +class IncomingTransactionsController { + + constructor (opts = {}) { + const { + blockTracker, + networkController, + preferencesController, + } = opts + this.blockTracker = blockTracker + this.networkController = networkController + this.preferencesController = preferencesController + this.getCurrentNetwork = () => networkController.getProviderConfig().type + + const initState = Object.assign({ + incomingTransactions: {}, + incomingTxLastFetchedBlocksByNetwork: { + [ROPSTEN]: null, + [RINKEBY]: null, + [KOVAN]: null, + [MAINNET]: null, + }, + }, opts.initState) + this.store = new ObservableStore(initState) + + this.networkController.on('networkDidChange', async (newType) => { + const address = this.preferencesController.getSelectedAddress() + await this._update({ + address, + networkType: newType, + }) + }) + this.blockTracker.on('latest', async (newBlockNumberHex) => { + const address = this.preferencesController.getSelectedAddress() + await this._update({ + address, + newBlockNumberDec: parseInt(newBlockNumberHex, 16), + }) + }) + this.preferencesController.store.subscribe(async ({ selectedAddress }) => { + await this._update({ + address: selectedAddress, + }) + }) + } + + async _update ({ address, newBlockNumberDec, networkType } = {}) { + try { + const dataForUpdate = await this._getDataForUpdate({ address, newBlockNumberDec, networkType }) + await this._updateStateWithNewTxData(dataForUpdate) + } catch (err) { + log.error(err) + } + } + + async _getDataForUpdate ({ address, newBlockNumberDec, networkType } = {}) { + const { + incomingTransactions: currentIncomingTxs, + incomingTxLastFetchedBlocksByNetwork: currentBlocksByNetwork, + } = this.store.getState() + + const network = networkType || this.getCurrentNetwork() + const lastFetchBlockByCurrentNetwork = currentBlocksByNetwork[network] + let blockToFetchFrom = lastFetchBlockByCurrentNetwork || newBlockNumberDec + if (blockToFetchFrom === undefined) { + blockToFetchFrom = parseInt(this.blockTracker.getCurrentBlock(), 16) + } + + const { latestIncomingTxBlockNumber, txs: newTxs } = await this._fetchAll(address, blockToFetchFrom, network) + + return { + latestIncomingTxBlockNumber, + newTxs, + currentIncomingTxs, + currentBlocksByNetwork, + fetchedBlockNumber: blockToFetchFrom, + network, + } + } + + async _updateStateWithNewTxData ({ + latestIncomingTxBlockNumber, + newTxs, + currentIncomingTxs, + currentBlocksByNetwork, + fetchedBlockNumber, + network, + }) { + const newLatestBlockHashByNetwork = latestIncomingTxBlockNumber + ? parseInt(latestIncomingTxBlockNumber, 10) + 1 + : fetchedBlockNumber + 1 + const newIncomingTransactions = { + ...currentIncomingTxs, + } + newTxs.forEach(tx => { newIncomingTransactions[tx.hash] = tx }) + + this.store.updateState({ + incomingTxLastFetchedBlocksByNetwork: { + ...currentBlocksByNetwork, + [network]: newLatestBlockHashByNetwork, + }, + incomingTransactions: newIncomingTransactions, + }) + } + + async _fetchAll (address, fromBlock, networkType) { + try { + const fetchedTxResponse = await this._fetchTxs(address, fromBlock, networkType) + return this._processTxFetchResponse(fetchedTxResponse) + } catch (err) { + log.error(err) + } + } + + async _fetchTxs (address, fromBlock, networkType) { + let etherscanSubdomain = 'api' + const currentNetworkID = networkTypeToIdMap[networkType] + const supportedNetworkTypes = [ROPSTEN, RINKEBY, KOVAN, MAINNET] + + if (supportedNetworkTypes.indexOf(networkType) === -1) { + return {} + } + + if (networkType !== MAINNET) { + etherscanSubdomain = `api-${networkType}` + } + const apiUrl = `https://${etherscanSubdomain}.etherscan.io` + let url = `${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1` + + if (fromBlock) { + url += `&startBlock=${parseInt(fromBlock, 10)}` + } + const response = await fetch(url) + const parsedResponse = await response.json() + + return { + ...parsedResponse, + address, + currentNetworkID, + } + } + + _processTxFetchResponse ({ status, result, address, currentNetworkID }) { + if (status !== '0' && result.length > 0) { + const remoteTxList = {} + const remoteTxs = [] + result.forEach((tx) => { + if (!remoteTxList[tx.hash]) { + remoteTxs.push(this._normalizeTxFromEtherscan(tx, currentNetworkID)) + remoteTxList[tx.hash] = 1 + } + }) + + const incomingTxs = remoteTxs.filter(tx => tx.txParams.to && tx.txParams.to.toLowerCase() === address.toLowerCase()) + incomingTxs.sort((a, b) => (a.time < b.time ? -1 : 1)) + + let latestIncomingTxBlockNumber = null + incomingTxs.forEach((tx) => { + if ( + tx.blockNumber && + (!latestIncomingTxBlockNumber || + parseInt(latestIncomingTxBlockNumber, 10) < parseInt(tx.blockNumber, 10)) + ) { + latestIncomingTxBlockNumber = tx.blockNumber + } + }) + return { + latestIncomingTxBlockNumber, + txs: incomingTxs, + } + } + return { + latestIncomingTxBlockNumber: null, + txs: [], + } + } + + _normalizeTxFromEtherscan (txMeta, currentNetworkID) { + const time = parseInt(txMeta.timeStamp, 10) * 1000 + const status = txMeta.isError === '0' ? 'confirmed' : 'failed' + return { + blockNumber: txMeta.blockNumber, + id: createId(), + metamaskNetworkId: currentNetworkID, + status, + time, + txParams: { + from: txMeta.from, + gas: bnToHex(new BN(txMeta.gas)), + gasPrice: bnToHex(new BN(txMeta.gasPrice)), + nonce: bnToHex(new BN(txMeta.nonce)), + to: txMeta.to, + value: bnToHex(new BN(txMeta.value)), + }, + hash: txMeta.hash, + transactionCategory: 'incoming', + } + } +} + +module.exports = IncomingTransactionsController diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e9299d5f8..14fa143f4 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -30,6 +30,7 @@ const InfuraController = require('./controllers/infura') const CachedBalancesController = require('./controllers/cached-balances') const OnboardingController = require('./controllers/onboarding') const RecentBlocksController = require('./controllers/recent-blocks') +const IncomingTransactionsController = require('./controllers/incoming-transactions') const MessageManager = require('./lib/message-manager') const PersonalMessageManager = require('./lib/personal-message-manager') const TypedMessageManager = require('./lib/typed-message-manager') @@ -137,6 +138,13 @@ module.exports = class MetamaskController extends EventEmitter { networkController: this.networkController, }) + this.incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: this.blockTracker, + networkController: this.networkController, + preferencesController: this.preferencesController, + initState: initState.IncomingTransactionsController, + }) + // account tracker watches balances, nonces, and any code at their address. this.accountTracker = new AccountTracker({ provider: this.provider, @@ -270,6 +278,7 @@ module.exports = class MetamaskController extends EventEmitter { CachedBalancesController: this.cachedBalancesController.store, OnboardingController: this.onboardingController.store, ProviderApprovalController: this.providerApprovalController.store, + IncomingTransactionsController: this.incomingTransactionsController.store, }) this.memStore = new ComposableObservableStore(null, { @@ -294,6 +303,7 @@ module.exports = class MetamaskController extends EventEmitter { // ProviderApprovalController ProviderApprovalController: this.providerApprovalController.store, ProviderApprovalControllerMemStore: this.providerApprovalController.memStore, + IncomingTransactionsController: this.incomingTransactionsController.store, }) this.memStore.subscribe(this.sendUpdate.bind(this)) } diff --git a/development/states/confirm-sig-requests.json b/development/states/confirm-sig-requests.json index 16199f48f..ae7f3454d 100644 --- a/development/states/confirm-sig-requests.json +++ b/development/states/confirm-sig-requests.json @@ -63,6 +63,7 @@ ], "tokens": [], "transactions": {}, + "incomingTransactions": {}, "selectedAddressTxList": [], "unapprovedTxs": {}, "unapprovedMsgs": { diff --git a/development/states/currency-localization.json b/development/states/currency-localization.json index 9d5f771c2..dff527f5a 100644 --- a/development/states/currency-localization.json +++ b/development/states/currency-localization.json @@ -64,6 +64,7 @@ ], "tokens": [], "transactions": {}, + "incomingTransactions": {}, "selectedAddressTxList": [], "unapprovedMsgs": {}, "unapprovedMsgCount": 0, diff --git a/development/states/send-new-ui.json b/development/states/send-new-ui.json index bcfc76221..69b4b0568 100644 --- a/development/states/send-new-ui.json +++ b/development/states/send-new-ui.json @@ -28,6 +28,7 @@ "conversionRate": 1200.88200327, "conversionDate": 1489013762, "noActiveNotices": true, + "incomingTransactions": {}, "frequentRpcList": [], "network": "3", "accounts": { diff --git a/development/states/tx-list-items.json b/development/states/tx-list-items.json index fd60003ba..08d1cf263 100644 --- a/development/states/tx-list-items.json +++ b/development/states/tx-list-items.json @@ -64,6 +64,7 @@ ], "tokens": [], "transactions": {}, + "incomingTransactions": {}, "selectedAddressTxList": [ { "err": { diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 122945ec1..ed25b0abe 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -12,6 +12,7 @@ } }, "cachedBalances": {}, + "incomingTransactions": {}, "unapprovedTxs": { "8393540981007587": { "id": 8393540981007587, diff --git a/test/unit/app/controllers/incoming-transactions-test.js b/test/unit/app/controllers/incoming-transactions-test.js new file mode 100644 index 000000000..923da7de9 --- /dev/null +++ b/test/unit/app/controllers/incoming-transactions-test.js @@ -0,0 +1,638 @@ +const assert = require('assert') +const sinon = require('sinon') +const proxyquire = require('proxyquire') +const IncomingTransactionsController = proxyquire('../../../../app/scripts/controllers/incoming-transactions', { + '../lib/random-id': () => 54321, +}) + +const { + ROPSTEN, + RINKEBY, + KOVAN, + MAINNET, +} = require('../../../../app/scripts/controllers/network/enums') + +describe('IncomingTransactionsController', () => { + const EMPTY_INIT_STATE = { + incomingTransactions: {}, + incomingTxLastFetchedBlocksByNetwork: { + [ROPSTEN]: null, + [RINKEBY]: null, + [KOVAN]: null, + [MAINNET]: null, + }, + } + + const NON_EMPTY_INIT_STATE = { + incomingTransactions: { + '0x123456': { id: 777 }, + }, + incomingTxLastFetchedBlocksByNetwork: { + [ROPSTEN]: 1, + [RINKEBY]: 2, + [KOVAN]: 3, + [MAINNET]: 4, + }, + } + + const NON_EMPTY_INIT_STATE_WITH_FAKE_NETWORK_STATE = { + incomingTransactions: { + '0x123456': { id: 777 }, + }, + incomingTxLastFetchedBlocksByNetwork: { + [ROPSTEN]: 1, + [RINKEBY]: 2, + [KOVAN]: 3, + [MAINNET]: 4, + FAKE_NETWORK: 1111, + }, + } + + const MOCK_BLOCKTRACKER = { + on: sinon.spy(), + testProperty: 'fakeBlockTracker', + getCurrentBlock: () => '0xa', + } + + const MOCK_NETWORK_CONTROLLER = { + getProviderConfig: () => ({ type: 'FAKE_NETWORK' }), + on: sinon.spy(), + } + + const MOCK_PREFERENCES_CONTROLLER = { + getSelectedAddress: sinon.stub().returns('0x0101'), + store: { + subscribe: sinon.spy(), + }, + } + + describe('constructor', () => { + it('should set up correct store, listeners and properties in the constructor', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: {}, + }) + sinon.spy(incomingTransactionsController, '_update') + + assert.deepEqual(incomingTransactionsController.blockTracker, MOCK_BLOCKTRACKER) + assert.deepEqual(incomingTransactionsController.networkController, MOCK_NETWORK_CONTROLLER) + assert.equal(incomingTransactionsController.preferencesController, MOCK_PREFERENCES_CONTROLLER) + assert.equal(incomingTransactionsController.getCurrentNetwork(), 'FAKE_NETWORK') + + assert.deepEqual(incomingTransactionsController.store.getState(), EMPTY_INIT_STATE) + + 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', + networkType: 'testNetworkType', + }) + + incomingTransactionsController._update.resetHistory() + + assert(incomingTransactionsController.blockTracker.on.calledOnce) + assert.equal(incomingTransactionsController.blockTracker.on.getCall(0).args[0], 'latest') + const blockTrackerListenerCallback = incomingTransactionsController.blockTracker.on.getCall(0).args[1] + assert.equal(incomingTransactionsController._update.callCount, 0) + blockTrackerListenerCallback('0xabc') + assert.equal(incomingTransactionsController._update.callCount, 1) + assert.deepEqual(incomingTransactionsController._update.getCall(0).args[0], { + address: '0x0101', + newBlockNumberDec: 2748, + }) + }) + + it('should set the store to a provided initial state', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + assert.deepEqual(incomingTransactionsController.store.getState(), NON_EMPTY_INIT_STATE) + }) + }) + + describe('_getDataForUpdate', () => { + it('should call fetchAll with the correct params when passed a new block number and the current network has no stored block', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + incomingTransactionsController._fetchAll = sinon.stub().returns({}) + + await incomingTransactionsController._getDataForUpdate({ address: 'fakeAddress', newBlockNumberDec: 999 }) + + assert(incomingTransactionsController._fetchAll.calledOnce) + + assert.deepEqual(incomingTransactionsController._fetchAll.getCall(0).args, [ + 'fakeAddress', 999, 'FAKE_NETWORK', + ]) + }) + + it('should call fetchAll with the correct params when passed a new block number but the current network has a stored block', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE_WITH_FAKE_NETWORK_STATE, + }) + incomingTransactionsController._fetchAll = sinon.stub().returns({}) + + await incomingTransactionsController._getDataForUpdate({ address: 'fakeAddress', newBlockNumberDec: 999 }) + + assert(incomingTransactionsController._fetchAll.calledOnce) + + assert.deepEqual(incomingTransactionsController._fetchAll.getCall(0).args, [ + 'fakeAddress', 1111, 'FAKE_NETWORK', + ]) + }) + + it('should call fetchAll with the correct params when passed a new network type but no block info exists', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE_WITH_FAKE_NETWORK_STATE, + }) + incomingTransactionsController._fetchAll = sinon.stub().returns({}) + + await incomingTransactionsController._getDataForUpdate({ + address: 'fakeAddress', + networkType: 'NEW_FAKE_NETWORK', + }) + + assert(incomingTransactionsController._fetchAll.calledOnce) + + assert.deepEqual(incomingTransactionsController._fetchAll.getCall(0).args, [ + 'fakeAddress', 10, 'NEW_FAKE_NETWORK', + ]) + }) + + it('should call fetchAll with the correct params when passed a new block number but the current network has a stored block', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE_WITH_FAKE_NETWORK_STATE, + }) + incomingTransactionsController._fetchAll = sinon.stub().returns({}) + + await incomingTransactionsController._getDataForUpdate({ address: 'fakeAddress', newBlockNumberDec: 999 }) + + assert(incomingTransactionsController._fetchAll.calledOnce) + + assert.deepEqual(incomingTransactionsController._fetchAll.getCall(0).args, [ + 'fakeAddress', 1111, 'FAKE_NETWORK', + ]) + }) + + it('should return the expected data', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE_WITH_FAKE_NETWORK_STATE, + }) + incomingTransactionsController._fetchAll = sinon.stub().returns({ + latestIncomingTxBlockNumber: 444, + txs: [{ id: 555 }], + }) + + const result = await incomingTransactionsController._getDataForUpdate({ + address: 'fakeAddress', + networkType: 'FAKE_NETWORK', + }) + + assert.deepEqual(result, { + latestIncomingTxBlockNumber: 444, + newTxs: [{ id: 555 }], + currentIncomingTxs: { + '0x123456': { id: 777 }, + }, + currentBlocksByNetwork: { + [ROPSTEN]: 1, + [RINKEBY]: 2, + [KOVAN]: 3, + [MAINNET]: 4, + FAKE_NETWORK: 1111, + }, + fetchedBlockNumber: 1111, + network: 'FAKE_NETWORK', + }) + }) + }) + + describe('_updateStateWithNewTxData', () => { + const MOCK_INPUT_WITHOUT_LASTEST = { + newTxs: [{ id: 555, hash: '0xfff' }], + currentIncomingTxs: { + '0x123456': { id: 777, hash: '0x123456' }, + }, + currentBlocksByNetwork: { + [ROPSTEN]: 1, + [RINKEBY]: 2, + [KOVAN]: 3, + [MAINNET]: 4, + FAKE_NETWORK: 1111, + }, + fetchedBlockNumber: 1111, + network: 'FAKE_NETWORK', + } + + 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 () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + 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, + 'FAKE_NETWORK': 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 () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + 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, + 'FAKE_NETWORK': 1112, + }, + incomingTransactions: { + '0x123456': { id: 777, hash: '0x123456' }, + '0xfff': { id: 555, hash: '0xfff' }, + }, + }) + }) + }) + + describe('_fetchTxs', () => { + const mockFetch = sinon.stub().returns(Promise.resolve({ + json: () => Promise.resolve({ someKey: 'someValue' }), + })) + let tempFetch + beforeEach(() => { + tempFetch = global.fetch + global.fetch = mockFetch + }) + + afterEach(() => { + global.fetch = tempFetch + mockFetch.resetHistory() + }) + + it('should call fetch with the expected url when passed an address, block number and supported network', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + await incomingTransactionsController._fetchTxs('0xfakeaddress', '789', ROPSTEN) + + 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 () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + await incomingTransactionsController._fetchTxs('0xfakeaddress', '789', MAINNET) + + 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 () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + await incomingTransactionsController._fetchTxs('0xfakeaddress', null, ROPSTEN) + + 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 not fetch and return an empty object when passed an unsported network', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + const result = await incomingTransactionsController._fetchTxs('0xfakeaddress', null, 'UNSUPPORTED_NETWORK') + + assert(mockFetch.notCalled) + assert.deepEqual(result, {}) + }) + + it('should return the results from the fetch call, plus the address and currentNetworkID, when passed an address, block number and supported network', async () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + const result = await incomingTransactionsController._fetchTxs('0xfakeaddress', '789', ROPSTEN) + + assert(mockFetch.calledOnce) + assert.deepEqual(result, { + someKey: 'someValue', + address: '0xfakeaddress', + currentNetworkID: 3, + }) + }) + }) + + describe('_processTxFetchResponse', () => { + it('should return a null block number and empty tx array if status is 0', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + 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', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + 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', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + incomingTransactionsController._normalizeTxFromEtherscan = (tx, currentNetworkID) => ({ + ...tx, + currentNetworkID, + normalized: true, + }) + + const result = incomingTransactionsController._processTxFetchResponse({ + status: '1', + address: '0xfakeaddress', + currentNetworkID: 'FAKE_NETWORK', + 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: 'FAKE_NETWORK', + }, + { + hash: '0xabc123', + txParams: { + to: '0xfakeaddress', + }, + blockNumber: 5000, + time: 10, + normalized: true, + currentNetworkID: 'FAKE_NETWORK', + }, + { + hash: '0xabc12345', + txParams: { + to: '0xfakeaddress', + }, + blockNumber: 5001, + time: 11, + normalized: true, + currentNetworkID: 'FAKE_NETWORK', + }, + { + hash: '0xabc123456', + txParams: { + to: '0xfakeaddress', + }, + blockNumber: 5001, + time: 12, + normalized: true, + currentNetworkID: 'FAKE_NETWORK', + }, + ], + }) + }) + }) + + describe('_normalizeTxFromEtherscan', () => { + it('should return the expected data when the tx is in error', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + const result = incomingTransactionsController._normalizeTxFromEtherscan({ + timeStamp: '4444', + isError: '1', + blockNumber: 333, + from: '0xa', + gas: '11', + gasPrice: '12', + nonce: '13', + to: '0xe', + value: '15', + hash: '0xg', + }, 'FAKE_NETWORK') + + assert.deepEqual(result, { + blockNumber: 333, + id: 54321, + metamaskNetworkId: 'FAKE_NETWORK', + 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', () => { + const incomingTransactionsController = new IncomingTransactionsController({ + blockTracker: MOCK_BLOCKTRACKER, + networkController: MOCK_NETWORK_CONTROLLER, + preferencesController: MOCK_PREFERENCES_CONTROLLER, + initState: NON_EMPTY_INIT_STATE, + }) + + const result = incomingTransactionsController._normalizeTxFromEtherscan({ + timeStamp: '4444', + isError: '0', + blockNumber: 333, + from: '0xa', + gas: '11', + gasPrice: '12', + nonce: '13', + to: '0xe', + value: '15', + hash: '0xg', + }, 'FAKE_NETWORK') + + assert.deepEqual(result, { + blockNumber: 333, + id: 54321, + metamaskNetworkId: 'FAKE_NETWORK', + status: 'confirmed', + time: 4444000, + txParams: { + from: '0xa', + gas: '0xb', + gasPrice: '0xc', + nonce: '0xd', + to: '0xe', + value: '0xf', + }, + hash: '0xg', + transactionCategory: 'incoming', + }) + }) + }) +}) diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js index 8bdb6a313..f4f8d97b2 100644 --- a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js @@ -36,6 +36,7 @@ export default class TransactionListItem extends PureComponent { rpcPrefs: PropTypes.object, data: PropTypes.string, getContractMethodData: PropTypes.func, + isDeposit: PropTypes.bool, } static defaultProps = { @@ -117,7 +118,7 @@ export default class TransactionListItem extends PureComponent { } renderPrimaryCurrency () { - const { token, primaryTransaction: { txParams: { data } = {} } = {}, value } = this.props + const { token, primaryTransaction: { txParams: { data } = {} } = {}, value, isDeposit } = this.props return token ? ( @@ -132,7 +133,7 @@ export default class TransactionListItem extends PureComponent { className="transaction-list-item__amount transaction-list-item__amount--primary" value={value} type={PRIMARY} - prefix="-" + prefix={isDeposit ? '' : '-'} /> ) } diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.container.js b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js index 1675958aa..27b9e2608 100644 --- a/ui/app/components/app/transaction-list-item/transaction-list-item.container.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js @@ -21,8 +21,10 @@ const mapStateToProps = (state, ownProps) => { const { showFiatInTestnets } = preferencesSelector(state) const isMainnet = getIsMainnet(state) const { transactionGroup: { primaryTransaction } = {} } = ownProps - const { txParams: { gas: gasLimit, gasPrice, data } = {} } = primaryTransaction - const selectedAccountBalance = accounts[getSelectedAddress(state)].balance + const { txParams: { gas: gasLimit, gasPrice, data, to } = {} } = primaryTransaction + const selectedAddress = getSelectedAddress(state) + const selectedAccountBalance = accounts[selectedAddress].balance + const isDeposit = selectedAddress === to const selectRpcInfo = frequentRpcListDetail.find(rpcInfo => rpcInfo.rpcUrl === provider.rpcTarget) const { rpcPrefs } = selectRpcInfo || {} @@ -42,6 +44,7 @@ const mapStateToProps = (state, ownProps) => { selectedAccountBalance, hasEnoughCancelGas, rpcPrefs, + isDeposit, } } @@ -68,12 +71,13 @@ const mapDispatchToProps = dispatch => { const mergeProps = (stateProps, dispatchProps, ownProps) => { const { transactionGroup: { primaryTransaction, initialTransaction } = {} } = ownProps + const { isDeposit } = stateProps const { retryTransaction, ...restDispatchProps } = dispatchProps - const { txParams: { nonce, data } = {}, time } = initialTransaction + const { txParams: { nonce, data } = {}, time = 0 } = initialTransaction const { txParams: { value } = {} } = primaryTransaction const tokenData = data && getTokenData(data) - const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) + const nonceAndDate = nonce && !isDeposit ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) return { ...stateProps, diff --git a/ui/app/components/app/transaction-list/index.scss b/ui/app/components/app/transaction-list/index.scss index 42eddd31e..7535137e2 100644 --- a/ui/app/components/app/transaction-list/index.scss +++ b/ui/app/components/app/transaction-list/index.scss @@ -11,15 +11,34 @@ } &__header { - flex: 0 0 auto; - font-size: 14px; - line-height: 20px; - color: $Grey-400; border-bottom: 1px solid $Grey-100; - padding: 8px 0 8px 20px; - @media screen and (max-width: $break-small) { - padding: 8px 0 8px 16px; + &__tabs { + display: flex; + } + + &__tab, + &__tab--selected { + flex: 0 0 auto; + font-size: 14px; + line-height: 20px; + color: $Grey-400; + padding: 8px 0 8px 20px; + cursor: pointer; + + &:hover { + font-weight: bold; + } + + @media screen and (max-width: $break-small) { + padding: 8px 0 8px 16px; + } + } + + &__tab--selected { + font-weight: bold; + color: $Blue-400; + cursor: auto; } } diff --git a/ui/app/helpers/constants/transactions.js b/ui/app/helpers/constants/transactions.js index d0a819b9b..e91e56ddc 100644 --- a/ui/app/helpers/constants/transactions.js +++ b/ui/app/helpers/constants/transactions.js @@ -20,5 +20,6 @@ export const TRANSFER_FROM_ACTION_KEY = 'transferFrom' export const SIGNATURE_REQUEST_KEY = 'signatureRequest' export const CONTRACT_INTERACTION_KEY = 'contractInteraction' export const CANCEL_ATTEMPT_ACTION_KEY = 'cancelAttempt' +export const DEPOSIT_TRANSACTION_KEY = 'deposit' export const TRANSACTION_TYPE_SHAPESHIFT = 'shapeshift' diff --git a/ui/app/helpers/utils/transactions.util.js b/ui/app/helpers/utils/transactions.util.js index b65bda5b2..cb347ffaa 100644 --- a/ui/app/helpers/utils/transactions.util.js +++ b/ui/app/helpers/utils/transactions.util.js @@ -21,6 +21,7 @@ import { SIGNATURE_REQUEST_KEY, CONTRACT_INTERACTION_KEY, CANCEL_ATTEMPT_ACTION_KEY, + DEPOSIT_TRANSACTION_KEY, } from '../constants/transactions' import log from 'loglevel' @@ -124,6 +125,10 @@ export function isTokenMethodAction (transactionCategory) { export function getTransactionActionKey (transaction) { const { msgParams, type, transactionCategory } = transaction + if (transactionCategory === 'incoming') { + return DEPOSIT_TRANSACTION_KEY + } + if (type === 'cancel') { return CANCEL_ATTEMPT_ACTION_KEY } diff --git a/ui/app/selectors/transactions.js b/ui/app/selectors/transactions.js index b1d27b333..5450978a6 100644 --- a/ui/app/selectors/transactions.js +++ b/ui/app/selectors/transactions.js @@ -10,11 +10,16 @@ import { TRANSACTION_TYPE_RETRY, } from '../../../app/scripts/controllers/transactions/enums' import { hexToDecimal } from '../helpers/utils/conversions.util' - import { selectedTokenAddressSelector } from './tokens' import txHelper from '../../lib/tx-helper' export const shapeShiftTxListSelector = state => state.metamask.shapeShiftTxList + +export const incomingTxListSelector = state => { + const selectedAddress = state.metamask.selectedAddress + return Object.values(state.metamask.incomingTransactions) + .filter(({ txParams }) => txParams.to === selectedAddress) +} export const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs export const selectedAddressTxListSelector = state => state.metamask.selectedAddressTxList export const unapprovedPersonalMsgsSelector = state => state.metamask.unapprovedPersonalMsgs @@ -55,9 +60,10 @@ export const transactionsSelector = createSelector( selectedTokenAddressSelector, unapprovedMessagesSelector, shapeShiftTxListSelector, + incomingTxListSelector, selectedAddressTxListSelector, - (selectedTokenAddress, unapprovedMessages = [], shapeShiftTxList = [], transactions = []) => { - const txsToRender = transactions.concat(unapprovedMessages, shapeShiftTxList) + (selectedTokenAddress, unapprovedMessages = [], shapeShiftTxList = [], incomingTxList = [], transactions = []) => { + const txsToRender = transactions.concat(unapprovedMessages, shapeShiftTxList, incomingTxList) return selectedTokenAddress ? txsToRender @@ -158,17 +164,18 @@ const insertTransactionGroupByTime = (transactionGroups, transactionGroup) => { } /** - * @name mergeShapeshiftTransactionGroups + * @name mergeNonNonceTransactionGroups * @private - * @description Inserts (mutates) shapeshift transactionGroups into an array of nonce-ordered - * transactionGroups by time. Shapeshift transactionGroups need to be sorted by time within the list - * of transactions as they do not have nonces. + * @description Inserts (mutates) transactionGroups that are not to be ordered by nonce into an array + * of nonce-ordered transactionGroups by time. Shapeshift transactionGroups need to be sorted by time + * within the list of transactions as they do not have nonces. * @param {transactionGroup[]} orderedTransactionGroups - Array of transactionGroups ordered by * nonce. - * @param {transactionGroup[]} shapeshiftTransactionGroups - Array of shapeshift transactionGroups + * @param {transactionGroup[]} nonNonceTransactionGroups - Array of transactionGroups not intended to be ordered by nonce, + * but intended to be ordered by timestamp */ -const mergeShapeshiftTransactionGroups = (orderedTransactionGroups, shapeshiftTransactionGroups) => { - shapeshiftTransactionGroups.forEach(shapeshiftGroup => { +const mergeNonNonceTransactionGroups = (orderedTransactionGroups, nonNonceTransactionGroups) => { + nonNonceTransactionGroups.forEach(shapeshiftGroup => { insertTransactionGroupByTime(orderedTransactionGroups, shapeshiftGroup) }) } @@ -183,13 +190,14 @@ export const nonceSortedTransactionsSelector = createSelector( (transactions = []) => { const unapprovedTransactionGroups = [] const shapeshiftTransactionGroups = [] + const incomingTransactionGroups = [] const orderedNonces = [] const nonceToTransactionsMap = {} transactions.forEach(transaction => { - const { txParams: { nonce } = {}, status, type, time: txTime, key } = transaction + const { txParams: { nonce } = {}, status, type, time: txTime, key, transactionCategory } = transaction - if (typeof nonce === 'undefined') { + if (typeof nonce === 'undefined' || transactionCategory === 'incoming') { const transactionGroup = { transactions: [transaction], initialTransaction: transaction, @@ -200,6 +208,8 @@ export const nonceSortedTransactionsSelector = createSelector( if (key === 'shapeshift') { shapeshiftTransactionGroups.push(transactionGroup) + } else if (transactionCategory === 'incoming') { + incomingTransactionGroups.push(transactionGroup) } else { insertTransactionGroupByTime(unapprovedTransactionGroups, transactionGroup) } @@ -245,7 +255,8 @@ export const nonceSortedTransactionsSelector = createSelector( }) const orderedTransactionGroups = orderedNonces.map(nonce => nonceToTransactionsMap[nonce]) - mergeShapeshiftTransactionGroups(orderedTransactionGroups, shapeshiftTransactionGroups) + mergeNonNonceTransactionGroups(orderedTransactionGroups, shapeshiftTransactionGroups) + mergeNonNonceTransactionGroups(orderedTransactionGroups, incomingTransactionGroups) return unapprovedTransactionGroups.concat(orderedTransactionGroups) } )