diff --git a/js/src/api/api.js b/js/src/api/api.js index bb622ab46..1eac2d89d 100644 --- a/js/src/api/api.js +++ b/js/src/api/api.js @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import EventEmitter from 'eventemitter3'; + import { Http, Ws } from './transport'; import Contract from './contract'; @@ -22,8 +24,10 @@ import Subscriptions from './subscriptions'; import util from './util'; import { isFunction } from './util/types'; -export default class Api { +export default class Api extends EventEmitter { constructor (transport) { + super(); + if (!transport || !isFunction(transport.execute)) { throw new Error('EthApi needs transport with execute() function defined'); } diff --git a/js/src/api/transport/http/http.js b/js/src/api/transport/http/http.js index 17d428e75..36e0ae1b7 100644 --- a/js/src/api/transport/http/http.js +++ b/js/src/api/transport/http/http.js @@ -51,14 +51,14 @@ export default class Http extends JsonRpcBase { return fetch(this._url, request) .catch((error) => { - this._connected = false; + this._setDisconnected(); throw error; }) .then((response) => { - this._connected = true; + this._setConnected(); if (response.status !== 200) { - this._connected = false; + this._setDisconnected(); this.error(JSON.stringify({ status: response.status, statusText: response.statusText })); console.error(`${method}(${JSON.stringify(params)}): ${response.status}: ${response.statusText}`); diff --git a/js/src/api/transport/http/http.spec.js b/js/src/api/transport/http/http.spec.js index d67f11307..685c6b948 100644 --- a/js/src/api/transport/http/http.spec.js +++ b/js/src/api/transport/http/http.spec.js @@ -37,6 +37,26 @@ describe('api/transport/Http', () => { }); }); + describe('transport emitter', () => { + it('emits close event', (done) => { + transport.once('close', () => { + done(); + }); + + transport.execute('eth_call'); + }); + + it('emits open event', (done) => { + mockHttp([{ method: 'eth_call', reply: { result: '' } }]); + + transport.once('open', () => { + done(); + }); + + transport.execute('eth_call'); + }); + }); + describe('transport', () => { const RESULT = ['this is some result']; diff --git a/js/src/api/transport/jsonRpcBase.js b/js/src/api/transport/jsonRpcBase.js index 76f380935..d5c1e8cb7 100644 --- a/js/src/api/transport/jsonRpcBase.js +++ b/js/src/api/transport/jsonRpcBase.js @@ -14,8 +14,12 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -export default class JsonRpcBase { +import EventEmitter from 'eventemitter3'; + +export default class JsonRpcBase extends EventEmitter { constructor () { + super(); + this._id = 1; this._debug = false; this._connected = false; @@ -32,6 +36,20 @@ export default class JsonRpcBase { return json; } + _setConnected () { + if (!this._connected) { + this._connected = true; + this.emit('open'); + } + } + + _setDisconnected () { + if (this._connected) { + this._connected = false; + this.emit('close'); + } + } + get id () { return this._id; } diff --git a/js/src/api/transport/ws/ws.js b/js/src/api/transport/ws/ws.js index 591cf3062..ef739ae13 100644 --- a/js/src/api/transport/ws/ws.js +++ b/js/src/api/transport/ws/ws.js @@ -22,7 +22,7 @@ import TransportError from '../error'; /* global WebSocket */ export default class Ws extends JsonRpcBase { - constructor (url, token, connect = true) { + constructor (url, token, autoconnect = true) { super(); this._url = url; @@ -32,14 +32,14 @@ export default class Ws extends JsonRpcBase { this._connecting = false; this._connected = false; this._lastError = null; - this._autoConnect = false; + this._autoConnect = autoconnect; this._retries = 0; this._reconnectTimeoutId = null; this._connectPromise = null; this._connectPromiseFunctions = {}; - if (connect) { + if (autoconnect) { this.connect(); } } @@ -124,11 +124,8 @@ export default class Ws extends JsonRpcBase { } _onOpen = (event) => { - console.log('ws:onOpen'); - - this._connected = true; + this._setConnected(); this._connecting = false; - this._autoConnect = true; this._retries = 0; Object.keys(this._messages) @@ -142,7 +139,7 @@ export default class Ws extends JsonRpcBase { } _onClose = (event) => { - this._connected = false; + this._setDisconnected(); this._connecting = false; event.timestamp = Date.now(); @@ -209,8 +206,8 @@ export default class Ws extends JsonRpcBase { if (result.error) { this.error(event.data); - // Don't print error if request rejected... - if (!/rejected/.test(result.error.message)) { + // Don't print error if request rejected or not is not yet up... + if (!/(rejected|not yet up)/.test(result.error.message)) { console.error(`${method}(${JSON.stringify(params)}): ${result.error.code}: ${result.error.message}`); } diff --git a/js/src/api/transport/ws/ws.spec.js b/js/src/api/transport/ws/ws.spec.js index 9303803bf..019cd166e 100644 --- a/js/src/api/transport/ws/ws.spec.js +++ b/js/src/api/transport/ws/ws.spec.js @@ -21,6 +21,37 @@ describe('api/transport/Ws', () => { let transport; let scope; + describe('transport emitter', () => { + const connect = () => { + const scope = mockWs(); + const transport = new Ws(TEST_WS_URL); + + return { transport, scope }; + }; + + it('emits open event', (done) => { + const { transport, scope } = connect(); + + transport.once('open', () => { + done(); + }); + + scope.stop(); + }); + + it('emits close event', (done) => { + const { transport, scope } = connect(); + + transport.once('open', () => { + scope.server.close(); + }); + + transport.once('close', () => { + done(); + }); + }); + }); + describe('transport', () => { let result; diff --git a/js/src/config.js b/js/src/config.js index 87fedecb8..6914f2f03 100644 --- a/js/src/config.js +++ b/js/src/config.js @@ -17,12 +17,20 @@ import LogLevel from 'loglevel'; export const LOG_KEYS = { + Balances: { + key: 'balances', + desc: 'Balances fetching' + }, TransferModalStore: { - path: 'modals/Transfer/store', - desc: 'Transfer Modal MobX Store' + key: 'modalsTransferStore', + desc: 'Transfer modal MobX store' + }, + Signer: { + key: 'secureApi', + desc: 'The Signer and the Secure API' } }; export const getLogger = (LOG_KEY) => { - return LogLevel.getLogger(LOG_KEY.path); + return LogLevel.getLogger(LOG_KEY.key); }; diff --git a/js/src/redux/providers/balances.js b/js/src/redux/providers/balances.js index 8d46e42d2..bb5ed19fa 100644 --- a/js/src/redux/providers/balances.js +++ b/js/src/redux/providers/balances.js @@ -21,51 +21,164 @@ import { padRight } from '~/api/util/format'; import Contracts from '~/contracts'; +let instance = null; + export default class Balances { constructor (store, api) { this._api = api; this._store = store; - this._tokenregSubId = null; - this._tokenregMetaSubId = null; + this._tokenreg = null; + this._tokenregSID = null; + this._tokenMetaSID = null; - // Throttled `retrieveTokens` function + this._blockNumberSID = null; + this._accountsInfoSID = null; + + // Throtthled load tokens (no more than once + // every minute) + this.loadTokens = throttle( + this._loadTokens, + 60 * 1000, + { leading: true, trailing: true } + ); + + // Throttled `_fetchBalances` function // that gets called max once every 40s this.longThrottledFetch = throttle( - this.fetchBalances, + this._fetchBalances, 40 * 1000, - { trailing: true } + { leading: false, trailing: true } ); this.shortThrottledFetch = throttle( - this.fetchBalances, + this._fetchBalances, 2 * 1000, - { trailing: true } + { leading: false, trailing: true } ); // Fetch all tokens every 2 minutes this.throttledTokensFetch = throttle( - this.fetchTokens, - 60 * 1000, - { trailing: true } + this._fetchTokens, + 2 * 60 * 1000, + { leading: false, trailing: true } ); + + // Unsubscribe previous instance if it exists + if (instance) { + Balances.stop(); + } } - start () { - this.subscribeBlockNumber(); - this.subscribeAccountsInfo(); + static get (store = {}) { + if (!instance && store) { + const { api } = store.getState(); + return Balances.instantiate(store, api); + } - this.loadTokens(); + return instance; + } + + static instantiate (store, api) { + if (!instance) { + instance = new Balances(store, api); + } + + return instance; + } + + static start () { + if (!instance) { + return Promise.reject('BalancesProvider has not been intiated yet'); + } + + const self = instance; + + // Unsubscribe from previous subscriptions + return Balances + .stop() + .then(() => self.loadTokens()) + .then(() => { + const promises = [ + self.subscribeBlockNumber(), + self.subscribeAccountsInfo() + ]; + + return Promise.all(promises); + }); + } + + static stop () { + if (!instance) { + return Promise.resolve(); + } + + const self = instance; + const promises = []; + + if (self._blockNumberSID) { + const p = self._api + .unsubscribe(self._blockNumberSID) + .then(() => { + self._blockNumberSID = null; + }); + + promises.push(p); + } + + if (self._accountsInfoSID) { + const p = self._api + .unsubscribe(self._accountsInfoSID) + .then(() => { + self._accountsInfoSID = null; + }); + + promises.push(p); + } + + // Unsubscribe without adding the promises + // to the result, since it would have to wait for a + // reconnection to resolve if the Node is disconnected + if (self._tokenreg) { + if (self._tokenregSID) { + const tokenregSID = self._tokenregSID; + + self._tokenreg + .unsubscribe(tokenregSID) + .then(() => { + if (self._tokenregSID === tokenregSID) { + self._tokenregSID = null; + } + }); + } + + if (self._tokenMetaSID) { + const tokenMetaSID = self._tokenMetaSID; + + self._tokenreg + .unsubscribe(tokenMetaSID) + .then(() => { + if (self._tokenMetaSID === tokenMetaSID) { + self._tokenMetaSID = null; + } + }); + } + } + + return Promise.all(promises); } subscribeAccountsInfo () { - this._api + return this._api .subscribe('parity_allAccountsInfo', (error, accountsInfo) => { if (error) { return; } - this.fetchBalances(); + this.fetchAllBalances(); + }) + .then((accountsInfoSID) => { + this._accountsInfoSID = accountsInfoSID; }) .catch((error) => { console.warn('_subscribeAccountsInfo', error); @@ -73,49 +186,97 @@ export default class Balances { } subscribeBlockNumber () { - this._api + return this._api .subscribe('eth_blockNumber', (error) => { if (error) { return console.warn('_subscribeBlockNumber', error); } - const { syncing } = this._store.getState().nodeStatus; - - this.throttledTokensFetch(); - - // If syncing, only retrieve balances once every - // few seconds - if (syncing) { - this.shortThrottledFetch.cancel(); - return this.longThrottledFetch(); - } - - this.longThrottledFetch.cancel(); - return this.shortThrottledFetch(); + return this.fetchAllBalances(); + }) + .then((blockNumberSID) => { + this._blockNumberSID = blockNumberSID; }) .catch((error) => { console.warn('_subscribeBlockNumber', error); }); } - fetchBalances () { - this._store.dispatch(fetchBalances()); + fetchAllBalances (options = {}) { + // If it's a network change, reload the tokens + // ( and then fetch the tokens balances ) and fetch + // the accounts balances + if (options.changedNetwork) { + this.loadTokens({ skipNotifications: true }); + this.loadTokens.flush(); + + this.fetchBalances({ + force: true, + skipNotifications: true + }); + + return; + } + + this.fetchTokensBalances(options); + this.fetchBalances(options); } - fetchTokens () { - this._store.dispatch(fetchTokensBalances()); + fetchTokensBalances (options) { + const { skipNotifications = false, force = false } = options; + + this.throttledTokensFetch(skipNotifications); + + if (force) { + this.throttledTokensFetch.flush(); + } + } + + fetchBalances (options) { + const { skipNotifications = false, force = false } = options; + const { syncing } = this._store.getState().nodeStatus; + + // If syncing, only retrieve balances once every + // few seconds + if (syncing) { + this.shortThrottledFetch.cancel(); + this.longThrottledFetch(skipNotifications); + + if (force) { + this.longThrottledFetch.flush(); + } + + return; + } + + this.longThrottledFetch.cancel(); + this.shortThrottledFetch(skipNotifications); + + if (force) { + this.shortThrottledFetch.flush(); + } + } + + _fetchBalances (skipNotifications = false) { + this._store.dispatch(fetchBalances(null, skipNotifications)); + } + + _fetchTokens (skipNotifications = false) { + this._store.dispatch(fetchTokensBalances(null, null, skipNotifications)); } getTokenRegistry () { return Contracts.get().tokenReg.getContract(); } - loadTokens () { - this + _loadTokens (options = {}) { + return this .getTokenRegistry() .then((tokenreg) => { + this._tokenreg = tokenreg; + this._store.dispatch(setTokenReg(tokenreg)); - this._store.dispatch(loadTokens()); + this._store.dispatch(loadTokens(options)); return this.attachToTokens(tokenreg); }) @@ -133,7 +294,7 @@ export default class Balances { } attachToNewToken (tokenreg) { - if (this._tokenregSubId) { + if (this._tokenregSID) { return Promise.resolve(); } @@ -149,13 +310,13 @@ export default class Balances { this.handleTokensLogs(logs); }) - .then((tokenregSubId) => { - this._tokenregSubId = tokenregSubId; + .then((tokenregSID) => { + this._tokenregSID = tokenregSID; }); } attachToTokenMetaChange (tokenreg) { - if (this._tokenregMetaSubId) { + if (this._tokenMetaSID) { return Promise.resolve(); } @@ -172,8 +333,8 @@ export default class Balances { this.handleTokensLogs(logs); }) - .then((tokenregMetaSubId) => { - this._tokenregMetaSubId = tokenregMetaSubId; + .then((tokenMetaSID) => { + this._tokenMetaSID = tokenMetaSID; }); } diff --git a/js/src/redux/providers/balancesActions.js b/js/src/redux/providers/balancesActions.js index 56a2ebafd..932ddfb8d 100644 --- a/js/src/redux/providers/balancesActions.js +++ b/js/src/redux/providers/balancesActions.js @@ -23,18 +23,27 @@ import { setAddressImage } from './imagesActions'; import * as ABIS from '~/contracts/abi'; import { notifyTransaction } from '~/util/notifications'; +import { LOG_KEYS, getLogger } from '~/config'; import imagesEthereum from '../../../assets/images/contracts/ethereum-black-64x64.png'; +const log = getLogger(LOG_KEYS.Balances); + const ETH = { name: 'Ethereum', tag: 'ETH', image: imagesEthereum }; -function setBalances (_balances) { +function setBalances (_balances, skipNotifications = false) { return (dispatch, getState) => { const state = getState(); + const currentTokens = Object.values(state.balances.tokens || {}); + const currentTags = [ 'eth' ] + .concat(currentTokens.map((token) => token.tag)) + .filter((tag) => tag) + .map((tag) => tag.toLowerCase()); + const accounts = state.personal.accounts; const nextBalances = _balances; const prevBalances = state.balances.balances; @@ -48,38 +57,55 @@ function setBalances (_balances) { const balance = Object.assign({}, balances[address]); const { tokens, txCount = balance.txCount } = nextBalances[address]; - const nextTokens = balance.tokens.slice(); - tokens.forEach((t) => { - const { token, value } = t; - const { tag } = token; + const prevTokens = balance.tokens.slice(); + const nextTokens = []; - const tokenIndex = nextTokens.findIndex((tok) => tok.token.tag === tag); + currentTags + .forEach((tag) => { + const prevToken = prevTokens.find((tok) => tok.token.tag.toLowerCase() === tag); + const nextToken = tokens.find((tok) => tok.token.tag.toLowerCase() === tag); - if (tokenIndex === -1) { - nextTokens.push({ - token, - value - }); - } else { - const oldValue = nextTokens[tokenIndex].value; + // If the given token is not in the current tokens, skip + if (!nextToken && !prevToken) { + return false; + } + + // No updates + if (!nextToken) { + return nextTokens.push(prevToken); + } + + const { token, value } = nextToken; + + // If it's a new token, push it + if (!prevToken) { + return nextTokens.push({ + token, value + }); + } + + // Otherwise, update the value + const prevValue = prevToken.value; // If received a token/eth (old value < new value), notify - if (oldValue.lt(value) && accounts[address]) { + if (prevValue.lt(value) && accounts[address] && !skipNotifications) { const account = accounts[address]; - const txValue = value.minus(oldValue); + const txValue = value.minus(prevValue); const redirectToAccount = () => { - const route = `/account/${account.address}`; + const route = `/accounts/${account.address}`; dispatch(push(route)); }; notifyTransaction(account, token, txValue, redirectToAccount); } - nextTokens[tokenIndex] = { token, value }; - } - }); + return nextTokens.push({ + ...prevToken, + value + }); + }); balances[address] = { txCount: txCount || new BigNumber(0), tokens: nextTokens }; }); @@ -123,7 +149,9 @@ export function setTokenImage (tokenAddress, image) { }; } -export function loadTokens () { +export function loadTokens (options = {}) { + log.debug('loading tokens', Object.keys(options).length ? options : ''); + return (dispatch, getState) => { const { tokenreg } = getState().balances; @@ -131,7 +159,7 @@ export function loadTokens () { .call() .then((numTokens) => { const tokenIds = range(numTokens.toNumber()); - dispatch(fetchTokens(tokenIds)); + dispatch(fetchTokens(tokenIds, options)); }) .catch((error) => { console.warn('balances::loadTokens', error); @@ -139,8 +167,9 @@ export function loadTokens () { }; } -export function fetchTokens (_tokenIds) { +export function fetchTokens (_tokenIds, options = {}) { const tokenIds = uniq(_tokenIds || []); + return (dispatch, getState) => { const { api, images, balances } = getState(); const { tokenreg } = balances; @@ -161,8 +190,9 @@ export function fetchTokens (_tokenIds) { dispatch(setAddressImage(address, image, true)); }); + log.debug('fetched token', tokens); dispatch(setTokens(tokens)); - dispatch(fetchBalances()); + dispatch(updateTokensFilter(null, null, options)); }) .catch((error) => { console.warn('balances::fetchTokens', error); @@ -170,7 +200,7 @@ export function fetchTokens (_tokenIds) { }; } -export function fetchBalances (_addresses) { +export function fetchBalances (_addresses, skipNotifications = false) { return (dispatch, getState) => { const { api, personal } = getState(); const { visibleAccounts, accounts } = personal; @@ -192,8 +222,7 @@ export function fetchBalances (_addresses) { balances[addr] = accountsBalances[idx]; }); - dispatch(setBalances(balances)); - updateTokensFilter(addresses)(dispatch, getState); + dispatch(setBalances(balances, skipNotifications)); }) .catch((error) => { console.warn('balances::fetchBalances', error); @@ -201,7 +230,7 @@ export function fetchBalances (_addresses) { }; } -export function updateTokensFilter (_addresses, _tokens) { +export function updateTokensFilter (_addresses, _tokens, options = {}) { return (dispatch, getState) => { const { api, balances, personal } = getState(); const { visibleAccounts, accounts } = personal; @@ -214,27 +243,32 @@ export function updateTokensFilter (_addresses, _tokens) { const tokenAddresses = tokens.map((t) => t.address).sort(); if (tokensFilter.filterFromId || tokensFilter.filterToId) { + // Has the tokens addresses changed (eg. a network change) const sameTokens = isEqual(tokenAddresses, tokensFilter.tokenAddresses); - const sameAddresses = isEqual(addresses, tokensFilter.addresses); - if (sameTokens && sameAddresses) { + // Addresses that are not in the current filter (omit those + // that the filter includes) + const newAddresses = addresses.filter((address) => !tokensFilter.addresses.includes(address)); + + // If no new addresses and the same tokens, don't change the filter + if (sameTokens && newAddresses.length === 0) { + log.debug('no need to update token filter', addresses, tokenAddresses, tokensFilter); return queryTokensFilter(tokensFilter)(dispatch, getState); } } - let promise = Promise.resolve(); + log.debug('updating the token filter', addresses, tokenAddresses); + const promises = []; if (tokensFilter.filterFromId) { - promise = promise.then(() => api.eth.uninstallFilter(tokensFilter.filterFromId)); + promises.push(api.eth.uninstallFilter(tokensFilter.filterFromId)); } if (tokensFilter.filterToId) { - promise = promise.then(() => api.eth.uninstallFilter(tokensFilter.filterToId)); + promises.push(api.eth.uninstallFilter(tokensFilter.filterToId)); } - if (tokenAddresses.length === 0 || addresses.length === 0) { - return promise; - } + const promise = Promise.all(promises); const TRANSFER_SIGNATURE = api.util.sha3('Transfer(address,address,uint256)'); const topicsFrom = [ TRANSFER_SIGNATURE, addresses, null ]; @@ -269,8 +303,10 @@ export function updateTokensFilter (_addresses, _tokens) { addresses, tokenAddresses }; + const { skipNotifications } = options; + dispatch(setTokensFilter(nextTokensFilter)); - fetchTokensBalances(addresses, tokens)(dispatch, getState); + fetchTokensBalances(addresses, tokens, skipNotifications)(dispatch, getState); }) .catch((error) => { console.warn('balances::updateTokensFilter', error); @@ -326,7 +362,7 @@ export function queryTokensFilter (tokensFilter) { }; } -export function fetchTokensBalances (_addresses = null, _tokens = null) { +export function fetchTokensBalances (_addresses = null, _tokens = null, skipNotifications = false) { return (dispatch, getState) => { const { api, personal, balances } = getState(); const { visibleAccounts, accounts } = personal; @@ -348,7 +384,7 @@ export function fetchTokensBalances (_addresses = null, _tokens = null) { balances[addr] = tokensBalances[idx]; }); - dispatch(setBalances(balances)); + dispatch(setBalances(balances, skipNotifications)); }) .catch((error) => { console.warn('balances::fetchTokensBalances', error); diff --git a/js/src/redux/providers/chainMiddleware.js b/js/src/redux/providers/chainMiddleware.js index 77c757da6..62c10bcb2 100644 --- a/js/src/redux/providers/chainMiddleware.js +++ b/js/src/redux/providers/chainMiddleware.js @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import BalancesProvider from './balances'; import { showSnackbar } from './snackbarActions'; import { DEFAULT_NETCHAIN } from './statusReducer'; @@ -29,6 +30,11 @@ export default class ChainMiddleware { if (newChain !== nodeStatus.netChain && nodeStatus.netChain !== DEFAULT_NETCHAIN) { store.dispatch(showSnackbar(`Switched to ${newChain}. Please reload the page.`, 60000)); + + // Fetch the new balances without notifying the user of any change + BalancesProvider.get(store).fetchAllBalances({ + changedNetwork: true + }); } } } diff --git a/js/src/redux/providers/chainMiddleware.spec.js b/js/src/redux/providers/chainMiddleware.spec.js index ed2d5eca6..2c0a51602 100644 --- a/js/src/redux/providers/chainMiddleware.spec.js +++ b/js/src/redux/providers/chainMiddleware.spec.js @@ -16,13 +16,18 @@ import sinon from 'sinon'; +import Contracts from '~/contracts'; import { initialState as defaultNodeStatusState } from './statusReducer'; import ChainMiddleware from './chainMiddleware'; +import { createWsApi } from '~/../test/e2e/ethapi'; let middleware; let next; let store; +const api = createWsApi(); +Contracts.create(api); + function createMiddleware (collection = {}) { middleware = new ChainMiddleware().toMiddleware(); next = sinon.stub(); @@ -30,6 +35,7 @@ function createMiddleware (collection = {}) { dispatch: sinon.stub(), getState: () => { return { + api: api, nodeStatus: Object.assign({}, defaultNodeStatusState, collection) }; } diff --git a/js/src/redux/providers/personalActions.js b/js/src/redux/providers/personalActions.js index d16e722bf..27cc47c27 100644 --- a/js/src/redux/providers/personalActions.js +++ b/js/src/redux/providers/personalActions.js @@ -16,7 +16,8 @@ import { isEqual, intersection } from 'lodash'; -import { fetchBalances } from './balancesActions'; +import BalancesProvider from './balances'; +import { updateTokensFilter } from './balancesActions'; import { attachWallets } from './walletActions'; import Contract from '~/api/contract'; @@ -98,7 +99,10 @@ export function personalAccountsInfo (accountsInfo) { dispatch(_personalAccountsInfo(data)); dispatch(attachWallets(wallets)); - dispatch(fetchBalances()); + + BalancesProvider.get().fetchAllBalances({ + force: true + }); }) .catch((error) => { console.warn('personalAccountsInfo', error); @@ -130,6 +134,18 @@ export function setVisibleAccounts (addresses) { } dispatch(_setVisibleAccounts(addresses)); - dispatch(fetchBalances(addresses)); + + // Don't update the balances if no new addresses displayed + if (addresses.length === 0) { + return; + } + + // Update the Tokens filter to take into account the new + // addresses + dispatch(updateTokensFilter()); + + BalancesProvider.get().fetchBalances({ + force: true + }); }; } diff --git a/js/src/redux/providers/status.js b/js/src/redux/providers/status.js index 6d0e24c6b..e419d78f8 100644 --- a/js/src/redux/providers/status.js +++ b/js/src/redux/providers/status.js @@ -14,9 +14,15 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import BalancesProvider from './balances'; import { statusBlockNumber, statusCollection, statusLogs } from './statusActions'; import { isEqual } from 'lodash'; +import { LOG_KEYS, getLogger } from '~/config'; + +const log = getLogger(LOG_KEYS.Signer); +let instance = null; + export default class Status { constructor (store, api) { this._api = api; @@ -27,20 +33,90 @@ export default class Status { this._longStatus = {}; this._minerSettings = {}; - this._longStatusTimeoutId = null; + this._timeoutIds = {}; + this._blockNumberSubscriptionId = null; this._timestamp = Date.now(); + + // On connecting, stop all subscriptions + api.on('connecting', this.stop, this); + + // On connected, start the subscriptions + api.on('connected', this.start, this); + + // On disconnected, stop all subscriptions + api.on('disconnected', this.stop, this); + + this.updateApiStatus(); + } + + static instantiate (store, api) { + if (!instance) { + instance = new Status(store, api); + } + + return instance; } start () { - this._subscribeBlockNumber(); - this._pollStatus(); - this._pollLongStatus(); - this._pollLogs(); + log.debug('status::start'); + + Promise + .all([ + this._subscribeBlockNumber(), + + this._pollLogs(), + this._pollLongStatus(), + this._pollStatus() + ]) + .then(() => { + return BalancesProvider.start(); + }); } - _subscribeBlockNumber () { - this._api + stop () { + log.debug('status::stop'); + + const promises = []; + + if (this._blockNumberSubscriptionId) { + const promise = this._api + .unsubscribe(this._blockNumberSubscriptionId) + .then(() => { + this._blockNumberSubscriptionId = null; + }); + + promises.push(promise); + } + + Object.values(this._timeoutIds).forEach((timeoutId) => { + clearTimeout(timeoutId); + }); + + const promise = BalancesProvider.stop(); + promises.push(promise); + + return Promise.all(promises) + .then(() => true) + .catch((error) => { + console.error('status::stop', error); + return true; + }) + .then(() => this.updateApiStatus()); + } + + updateApiStatus () { + const apiStatus = this.getApiStatus(); + log.debug('status::updateApiStatus', apiStatus); + + if (!isEqual(apiStatus, this._apiStatus)) { + this._store.dispatch(statusCollection(apiStatus)); + this._apiStatus = apiStatus; + } + } + + _subscribeBlockNumber = () => { + return this._api .subscribe('eth_blockNumber', (error, blockNumber) => { if (error) { return; @@ -51,6 +127,10 @@ export default class Status { this._api.eth .getBlockByNumber(blockNumber) .then((block) => { + if (!block) { + return; + } + this._store.dispatch(statusCollection({ blockTimestamp: block.timestamp, gasLimit: block.gasLimit @@ -59,6 +139,9 @@ export default class Status { .catch((error) => { console.warn('status._subscribeBlockNumber', 'getBlockByNumber', error); }); + }) + .then((blockNumberSubscriptionId) => { + this._blockNumberSubscriptionId = blockNumberSubscriptionId; }); } @@ -72,11 +155,7 @@ export default class Status { .catch(() => false); } - _pollStatus = () => { - const nextTimeout = (timeout = 1000) => { - setTimeout(() => this._pollStatus(), timeout); - }; - + getApiStatus = () => { const { isConnected, isConnecting, needsToken, secureToken } = this._api; const apiStatus = { @@ -86,19 +165,23 @@ export default class Status { secureToken }; - const gotConnected = !this._apiStatus.isConnected && apiStatus.isConnected; + return apiStatus; + } - if (gotConnected) { - this._pollLongStatus(); - } + _pollStatus = () => { + const nextTimeout = (timeout = 1000) => { + if (this._timeoutIds.status) { + clearTimeout(this._timeoutIds.status); + } - if (!isEqual(apiStatus, this._apiStatus)) { - this._store.dispatch(statusCollection(apiStatus)); - this._apiStatus = apiStatus; - } + this._timeoutIds.status = setTimeout(() => this._pollStatus(), timeout); + }; - if (!isConnected) { - return nextTimeout(250); + this.updateApiStatus(); + + if (!this._api.isConnected) { + nextTimeout(250); + return Promise.resolve(); } const { refreshStatus } = this._store.getState().nodeStatus; @@ -110,7 +193,7 @@ export default class Status { statusPromises.push(this._api.eth.hashrate()); } - Promise + return Promise .all(statusPromises) .then(([ syncing, ...statusResults ]) => { const status = statusResults.length === 0 @@ -125,11 +208,11 @@ export default class Status { this._store.dispatch(statusCollection(status)); this._status = status; } - - nextTimeout(); }) .catch((error) => { console.error('_pollStatus', error); + }) + .then(() => { nextTimeout(); }); } @@ -140,7 +223,7 @@ export default class Status { * from the UI */ _pollMinerSettings = () => { - Promise + return Promise .all([ this._api.eth.coinbase(), this._api.parity.extraData(), @@ -175,21 +258,21 @@ export default class Status { */ _pollLongStatus = () => { if (!this._api.isConnected) { - return; + return Promise.resolve(); } const nextTimeout = (timeout = 30000) => { - if (this._longStatusTimeoutId) { - clearTimeout(this._longStatusTimeoutId); + if (this._timeoutIds.longStatus) { + clearTimeout(this._timeoutIds.longStatus); } - this._longStatusTimeoutId = setTimeout(this._pollLongStatus, timeout); + this._timeoutIds.longStatus = setTimeout(() => this._pollLongStatus(), timeout); }; // Poll Miner settings just in case - this._pollMinerSettings(); + const minerPromise = this._pollMinerSettings(); - Promise + const mainPromise = Promise .all([ this._api.parity.netPeers(), this._api.web3.clientVersion(), @@ -225,21 +308,31 @@ export default class Status { }) .catch((error) => { console.error('_pollLongStatus', error); + }) + .then(() => { + nextTimeout(60000); }); - nextTimeout(60000); + return Promise.all([ minerPromise, mainPromise ]); } _pollLogs = () => { - const nextTimeout = (timeout = 1000) => setTimeout(this._pollLogs, timeout); + const nextTimeout = (timeout = 1000) => { + if (this._timeoutIds.logs) { + clearTimeout(this._timeoutIds.logs); + } + + this._timeoutIds.logs = setTimeout(this._pollLogs, timeout); + }; + const { devLogsEnabled } = this._store.getState().nodeStatus; if (!devLogsEnabled) { nextTimeout(); - return; + return Promise.resolve(); } - Promise + return Promise .all([ this._api.parity.devLogs(), this._api.parity.devLogsLevels() @@ -249,11 +342,12 @@ export default class Status { devLogs: devLogs.slice(-1024), devLogsLevels })); - nextTimeout(); }) .catch((error) => { console.error('_pollLogs', error); - nextTimeout(); + }) + .then(() => { + return nextTimeout(); }); } } diff --git a/js/src/redux/store.js b/js/src/redux/store.js index 9924aa461..132375784 100644 --- a/js/src/redux/store.js +++ b/js/src/redux/store.js @@ -38,10 +38,10 @@ export default function (api, browserHistory) { const middleware = initMiddleware(api, browserHistory); const store = applyMiddleware(...middleware)(storeCreation)(reducers); - new BalancesProvider(store, api).start(); + BalancesProvider.instantiate(store, api); + StatusProvider.instantiate(store, api); new PersonalProvider(store, api).start(); new SignerProvider(store, api).start(); - new StatusProvider(store, api).start(); store.dispatch(loadWallet(api)); setupWorker(store); diff --git a/js/src/secureApi.js b/js/src/secureApi.js index 445151c69..b39ecaf3b 100644 --- a/js/src/secureApi.js +++ b/js/src/secureApi.js @@ -17,133 +17,34 @@ import { uniq } from 'lodash'; import Api from './api'; +import { LOG_KEYS, getLogger } from '~/config'; +const log = getLogger(LOG_KEYS.Signer); const sysuiToken = window.localStorage.getItem('sysuiToken'); export default class SecureApi extends Api { + _isConnecting = false; + _needsToken = false; + _tokens = []; + + _dappsInterface = null; + _dappsPort = 8080; + _signerPort = 8180; + constructor (url, nextToken) { - super(new Api.Transport.Ws(url, sysuiToken, false)); + const transport = new Api.Transport.Ws(url, sysuiToken, false); + super(transport); this._url = url; - this._isConnecting = true; - this._needsToken = false; - this._dappsPort = 8080; - this._dappsInterface = null; - this._signerPort = 8180; - - // Try tokens from localstorage, then from hash + // Try tokens from localstorage, from hash and 'initial' this._tokens = uniq([sysuiToken, nextToken, 'initial']) .filter((token) => token) .map((token) => ({ value: token, tried: false })); - this._tryNextToken(); - } - - saveToken = () => { - window.localStorage.setItem('sysuiToken', this._transport.token); - // DEBUG: console.log('SecureApi:saveToken', this._transport.token); - } - - /** - * Returns a Promise that gets resolved with - * a boolean: `true` if the node is up, `false` - * otherwise - */ - _checkNodeUp () { - const url = this._url.replace(/wss?/, 'http'); - return fetch(url, { method: 'HEAD' }) - .then( - (r) => r.status === 200, - () => false - ) - .catch(() => false); - } - - _setManual () { - this._needsToken = true; - this._isConnecting = false; - } - - _tryNextToken () { - const nextTokenIndex = this._tokens.findIndex((t) => !t.tried); - - if (nextTokenIndex < 0) { - return this._setManual(); - } - - const nextToken = this._tokens[nextTokenIndex]; - nextToken.tried = true; - - this.updateToken(nextToken.value); - } - - _followConnection = () => { - const token = this.transport.token; - - return this - .transport - .connect() - .then(() => { - if (token === 'initial') { - return this.signer - .generateAuthorizationToken() - .then((token) => { - return this.updateToken(token); - }) - .catch((e) => console.error(e)); - } - - this.connectSuccess(); - return true; - }) - .catch((e) => { - this - ._checkNodeUp() - .then((isNodeUp) => { - // Try again in a few... - if (!isNodeUp) { - this._isConnecting = false; - const timeout = this.transport.retryTimeout; - - window.setTimeout(() => { - this._followConnection(); - }, timeout); - - return; - } - - this._tryNextToken(); - return false; - }); - }); - } - - connectSuccess () { - this._isConnecting = false; - this._needsToken = false; - - this.saveToken(); - - Promise - .all([ - this.parity.dappsPort(), - this.parity.dappsInterface(), - this.parity.signerPort() - ]) - .then(([dappsPort, dappsInterface, signerPort]) => { - this._dappsPort = dappsPort.toNumber(); - this._dappsInterface = dappsInterface; - this._signerPort = signerPort.toNumber(); - }); - - // DEBUG: console.log('SecureApi:connectSuccess', this._transport.token); - } - - updateToken (token) { - this._transport.updateToken(token.replace(/[^a-zA-Z0-9]/g, ''), false); - return this._followConnection(); - // DEBUG: console.log('SecureApi:updateToken', this._transport.token, connectState); + // When the transport is closed, try to reconnect + transport.on('close', this.connect, this); + this.connect(); } get dappsPort () { @@ -151,17 +52,19 @@ export default class SecureApi extends Api { } get dappsUrl () { - let hostname; + return `http://${this.hostname}:${this.dappsPort}`; + } + get hostname () { if (window.location.hostname === 'home.parity') { - hostname = 'dapps.parity'; - } else if (!this._dappsInterface || this._dappsInterface === '0.0.0.0') { - hostname = window.location.hostname; - } else { - hostname = this._dappsInterface; + return 'dapps.parity'; } - return `http://${hostname}:${this._dappsPort}`; + if (!this._dappsInterface || this._dappsInterface === '0.0.0.0') { + return window.location.hostname; + } + + return this._dappsInterface; } get signerPort () { @@ -183,4 +86,279 @@ export default class SecureApi extends Api { get secureToken () { return this._transport.token; } + + connect () { + if (this._isConnecting) { + return; + } + + log.debug('trying to connect...'); + + this._isConnecting = true; + + this.emit('connecting'); + + // Reset the tested Tokens + this._resetTokens(); + + // Try to connect + return this._connect() + .then((connected) => { + this._isConnecting = false; + + if (connected) { + const token = this.secureToken; + log.debug('got connected ; saving token', token); + + // Save the sucessful token + this._saveToken(token); + this._needsToken = false; + + // Emit the connected event + return this.emit('connected'); + } + + // If not connected, we need a new token + log.debug('needs a token'); + this._needsToken = true; + + return this.emit('disconnected'); + }) + .catch((error) => { + this._isConnecting = false; + + log.debug('emitting "disconnected"'); + this.emit('disconnected'); + console.error('unhandled error in secureApi', error); + }); + } + + /** + * Returns a Promise that gets resolved with + * a boolean: `true` if the node is up, `false` + * otherwise (HEAD request to the Node) + */ + isNodeUp () { + const url = this._url.replace(/wss?/, 'http'); + return fetch(url, { method: 'HEAD' }) + .then( + (r) => r.status === 200, + () => false + ) + .catch(() => false); + } + + /** + * Update the given token, ie. add it to the token + * list, and then try to connect (if not already connecting) + */ + updateToken (_token) { + const token = this._sanitiseToken(_token); + log.debug('updating token', token); + + // Update the tokens list: put the new one on first position + this._tokens = [ { value: token, tried: false } ].concat(this._tokens); + + // Try to connect with the new token added + return this.connect(); + } + + /** + * Try to connect to the Node with the next Token in + * the list + */ + _connect () { + log.debug('trying next token'); + + // Get the first not-tried token + const nextToken = this._getNextToken(); + + // If no more tokens to try, user has to enter a new one + if (!nextToken) { + return Promise.resolve(false); + } + + nextToken.tried = true; + + return this._connectWithToken(nextToken.value) + .then((validToken) => { + // If not valid, try again with the next token in the list + if (!validToken) { + return this._connect(); + } + + // If correct and valid token, wait until the Node is ready + // and resolve as connected + return this._waitUntilNodeReady() + .then(() => this._fetchSettings()) + .then(() => true); + }) + .catch((error) => { + log.error('unkown error in _connect', error); + return false; + }); + } + + /** + * Connect with the given token. + * It returns a Promise that gets resolved + * with `validToken` as argument, whether the given token + * is valid or not + */ + _connectWithToken (_token) { + // Sanitize the token first + const token = this._sanitiseToken(_token); + + // Update the token in the transport layer + this.transport.updateToken(token, false); + log.debug('connecting with token', token); + + return this.transport.connect() + .then(() => { + log.debug('connected with', token); + + if (token === 'initial') { + return this._generateAuthorizationToken(); + } + + // The token is valid ! + return true; + }) + .catch((error) => { + // Log if it's not a close error (ie. wrong token) + if (error && error.type !== 'close') { + log.debug('did not connect ; error', error); + } + + // Check if the Node is up + return this.isNodeUp() + .then((isNodeUp) => { + // If it's not up, try again in a few... + if (!isNodeUp) { + const timeout = this.transport.retryTimeout; + + log.debug('node is not up ; will try again in', timeout, 'ms'); + + return new Promise((resolve, reject) => { + window.setTimeout(() => { + this._connectWithToken(token).then(resolve).catch(reject); + }, timeout); + }); + } + + // The token is invalid + log.debug('tried with a wrong token', token); + return false; + }); + }); + } + + /** + * Retrieve the correct ports from the Node + */ + _fetchSettings () { + return Promise + .all([ + this.parity.dappsPort(), + this.parity.dappsInterface(), + this.parity.signerPort() + ]) + .then(([dappsPort, dappsInterface, signerPort]) => { + this._dappsPort = dappsPort.toNumber(); + this._dappsInterface = dappsInterface; + this._signerPort = signerPort.toNumber(); + }); + } + + /** + * Try to generate an Authorization Token. + * Then try to connect with the new token. + */ + _generateAuthorizationToken () { + return this.signer + .generateAuthorizationToken() + .then((token) => this._connectWithToken(token)); + } + + /** + * Get the next token to try, if any left + */ + _getNextToken () { + // Get the first not-tried token + const nextTokenIndex = this._tokens.findIndex((t) => !t.tried); + + // If no more tokens to try, user has to enter a new one + if (nextTokenIndex < 0) { + return null; + } + + const nextToken = this._tokens[nextTokenIndex]; + return nextToken; + } + + _resetTokens () { + this._tokens = this._tokens.map((token) => ({ + ...token, + tried: false + })); + } + + _sanitiseToken (token) { + return token.replace(/[^a-zA-Z0-9]/g, ''); + } + + _saveToken (token) { + window.localStorage.setItem('sysuiToken', token); + } + + /** + * Promise gets resolved when the node is up + * and running (it might take some time before + * the node is actually ready even when the client + * is connected). + * + * We check that the `parity_enode` RPC calls + * returns successfully + */ + _waitUntilNodeReady (_timeleft) { + // Default timeout to 30 seconds + const timeleft = Number.isFinite(_timeleft) + ? _timeleft + : 30 * 1000; + + // After timeout, just resolve the promise... + if (timeleft <= 0) { + console.warn('node is still not ready after 30 seconds...'); + return Promise.resolve(true); + } + + const start = Date.now(); + + return this + .parity.enode() + .then(() => true) + .catch((error) => { + if (!error) { + return true; + } + + if (error.type !== 'NETWORK_DISABLED') { + throw error; + } + + // Timeout between 250ms and 750ms + const timeout = Math.floor(250 + (500 * Math.random())); + + log.debug('waiting until node is ready', 'retry in', timeout, 'ms'); + + // Retry in a few... + return new Promise((resolve, reject) => { + window.setTimeout(() => { + const duration = Date.now() - start; + + this._waitUntilNodeReady(timeleft - duration).then(resolve).catch(reject); + }, timeout); + }); + }); + } } diff --git a/js/src/util/notifications.js b/js/src/util/notifications.js index 488ac2daf..b8bbfe08a 100644 --- a/js/src/util/notifications.js +++ b/js/src/util/notifications.js @@ -16,7 +16,6 @@ import Push from 'push.js'; import BigNumber from 'bignumber.js'; -import { noop } from 'lodash'; import { fromWei } from '~/api/util/wei'; @@ -33,13 +32,34 @@ export function notifyTransaction (account, token, _value, onClick) { ? ethereumIcon : (token.image || unkownIcon); - Push.create(`${name}`, { - body: `You just received ${value.toFormat()} ${token.tag.toUpperCase()}`, - icon: { - x16: icon, - x32: icon - }, - timeout: 20000, - onClick: onClick || noop - }); + let _notification = null; + + Push + .create(`${name}`, { + body: `You just received ${value.toFormat(3)} ${token.tag.toUpperCase()}`, + icon: { + x16: icon, + x32: icon + }, + timeout: 20000, + onClick: () => { + // Focus on the UI + try { + window.focus(); + } catch (e) {} + + if (onClick && typeof onClick === 'function') { + onClick(); + } + + // Close the notification + if (_notification) { + _notification.close(); + _notification = null; + } + } + }) + .then((notification) => { + _notification = notification; + }); } diff --git a/js/src/views/Application/Status/status.js b/js/src/views/Application/Status/status.js index 1e2cd4d41..54dad960e 100644 --- a/js/src/views/Application/Status/status.js +++ b/js/src/views/Application/Status/status.js @@ -59,6 +59,10 @@ class Status extends Component { renderConsensus () { const { upgradeStore } = this.props; + if (!upgradeStore || !upgradeStore.consensusCapability) { + return null; + } + if (upgradeStore.consensusCapability === 'capable') { return (
@@ -67,7 +71,9 @@ class Status extends Component { defaultMessage='Capable' />
); - } else if (upgradeStore.consensusCapability.capableUntil) { + } + + if (upgradeStore.consensusCapability.capableUntil) { return (
); - } else if (upgradeStore.consensusCapability.incapableSince) { + } + + if (upgradeStore.consensusCapability.incapableSince) { return (
{ const log = LOG_KEYS[logKey]; - const logger = LogLevel.getLogger(log.path); + const logger = LogLevel.getLogger(log.key); const level = logger.getLevel(); nextState[logKey] = { level, log }; @@ -133,11 +133,11 @@ export default class Parity extends Component { return Object.keys(logLevels).map((logKey) => { const { level, log } = logLevels[logKey]; - const { path, desc } = log; + const { key, desc } = log; const onChange = (_, index) => { const nextLevel = Object.values(selectValues)[index].value; - LogLevel.getLogger(path).setLevel(nextLevel); + LogLevel.getLogger(key).setLevel(nextLevel); this.loadLogLevels(); }; diff --git a/js/src/views/Status/components/MiningSettings/miningSettings.js b/js/src/views/Status/components/MiningSettings/miningSettings.js index b65eed929..8cc04d45e 100644 --- a/js/src/views/Status/components/MiningSettings/miningSettings.js +++ b/js/src/views/Status/components/MiningSettings/miningSettings.js @@ -37,6 +37,14 @@ export default class MiningSettings extends Component { const { nodeStatus } = this.props; const { coinbase, defaultExtraData, extraData, gasFloorTarget, minGasPrice } = nodeStatus; + const extradata = extraData + ? decodeExtraData(extraData) + : ''; + + const defaultExtradata = defaultExtraData + ? decodeExtraData(defaultExtraData) + : ''; + return (
@@ -53,9 +61,9 @@ export default class MiningSettings extends Component { @@ -121,7 +127,7 @@ export default class Status extends Component { allowCopy readOnly label='network port' - value={ nodeStatus.netPort.toString() } + value={ netPort.toString() } { ...this._test('network-port') } />
@@ -146,7 +152,7 @@ export default class Status extends Component { allowCopy readOnly label='rpc port' - value={ rpcSettings.port.toString() } + value={ rpcPort.toString() } { ...this._test('rpc-port') } /> diff --git a/js/test/mockRpc.js b/js/test/mockRpc.js index 13f752f8e..fef27de4b 100644 --- a/js/test/mockRpc.js +++ b/js/test/mockRpc.js @@ -47,8 +47,8 @@ export function mockHttp (requests) { } export function mockWs (requests) { - const scope = { requests: 0, body: {} }; let mockServer = new MockWsServer(TEST_WS_URL); + const scope = { requests: 0, body: {}, server: mockServer }; scope.isDone = () => scope.requests === requests.length; scope.stop = () => {