diff --git a/.circleci/config.yml b/.circleci/config.yml index b21448460..317618c46 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -179,7 +179,7 @@ jobs: key: dependency-cache-{{ .Revision }} - run: name: Test - command: sudo npm install -g npm@6 && npm audit + command: sudo npm install -g npm@6.4.1 && npm audit test-e2e-chrome: docker: diff --git a/app/scripts/controllers/cached-balances.js b/app/scripts/controllers/cached-balances.js new file mode 100644 index 000000000..925c45334 --- /dev/null +++ b/app/scripts/controllers/cached-balances.js @@ -0,0 +1,83 @@ +const ObservableStore = require('obs-store') +const extend = require('xtend') + +/** + * @typedef {Object} CachedBalancesOptions + * @property {Object} accountTracker An {@code AccountTracker} reference + * @property {Function} getNetwork A function to get the current network + * @property {Object} initState The initial controller state + */ + +/** + * Background controller responsible for maintaining + * a cache of account balances in local storage + */ +class CachedBalancesController { + /** + * Creates a new controller instance + * + * @param {CachedBalancesOptions} [opts] Controller configuration parameters + */ + constructor (opts = {}) { + const { accountTracker, getNetwork } = opts + + this.accountTracker = accountTracker + this.getNetwork = getNetwork + + const initState = extend({ + cachedBalances: {}, + }, opts.initState) + this.store = new ObservableStore(initState) + + this._registerUpdates() + } + + /** + * Updates the cachedBalances property for the current network. Cached balances will be updated to those in the passed accounts + * if balances in the passed accounts are truthy. + * + * @param {Object} obj The the recently updated accounts object for the current network + * @returns {Promise} + */ + async updateCachedBalances ({ accounts }) { + const network = await this.getNetwork() + const balancesToCache = await this._generateBalancesToCache(accounts, network) + this.store.updateState({ + cachedBalances: balancesToCache, + }) + } + + _generateBalancesToCache (newAccounts, currentNetwork) { + const { cachedBalances } = this.store.getState() + const currentNetworkBalancesToCache = { ...cachedBalances[currentNetwork] } + + Object.keys(newAccounts).forEach(accountID => { + const account = newAccounts[accountID] + + if (account.balance) { + currentNetworkBalancesToCache[accountID] = account.balance + } + }) + const balancesToCache = { + ...cachedBalances, + [currentNetwork]: currentNetworkBalancesToCache, + } + + return balancesToCache + } + + /** + * Sets up listeners and subscriptions which should trigger an update of cached balances. These updates will + * happen when the current account changes. Which happens on block updates, as well as on network and account + * selections. + * + * @private + * + */ + _registerUpdates () { + const update = this.updateCachedBalances.bind(this) + this.accountTracker.store.subscribe(update) + } +} + +module.exports = CachedBalancesController diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index cc13c9ad7..0ef1a7287 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -29,6 +29,7 @@ const ShapeShiftController = require('./controllers/shapeshift') const AddressBookController = require('./controllers/address-book') const InfuraController = require('./controllers/infura') const BlacklistController = require('./controllers/blacklist') +const CachedBalancesController = require('./controllers/cached-balances') const RecentBlocksController = require('./controllers/recent-blocks') const MessageManager = require('./lib/message-manager') const PersonalMessageManager = require('./lib/personal-message-manager') @@ -52,6 +53,8 @@ const EthQuery = require('eth-query') const ethUtil = require('ethereumjs-util') const sigUtil = require('eth-sig-util') +const accountsPerPage = 5 + module.exports = class MetamaskController extends EventEmitter { /** @@ -138,6 +141,12 @@ module.exports = class MetamaskController extends EventEmitter { } }) + this.cachedBalancesController = new CachedBalancesController({ + accountTracker: this.accountTracker, + getNetwork: this.networkController.getNetworkState.bind(this.networkController), + initState: initState.CachedBalancesController, + }) + // ensure accountTracker updates balances after network change this.networkController.on('networkDidChange', () => { this.accountTracker._updateAccounts() @@ -227,6 +236,7 @@ module.exports = class MetamaskController extends EventEmitter { ShapeShiftController: this.shapeshiftController.store, NetworkController: this.networkController.store, InfuraController: this.infuraController.store, + CachedBalancesController: this.cachedBalancesController.store, }) this.memStore = new ComposableObservableStore(null, { @@ -234,6 +244,7 @@ module.exports = class MetamaskController extends EventEmitter { AccountTracker: this.accountTracker.store, TxController: this.txController.memStore, BalancesController: this.balancesController.store, + CachedBalancesController: this.cachedBalancesController.store, TokenRatesController: this.tokenRatesController.store, MessageManager: this.messageManager.memStore, PersonalMessageManager: this.personalMessageManager.memStore, @@ -374,6 +385,7 @@ module.exports = class MetamaskController extends EventEmitter { // hardware wallets connectHardware: nodeify(this.connectHardware, this), + connectHardwareAndUnlockAddress: nodeify(this.connectHardwareAndUnlockAddress, this), forgetDevice: nodeify(this.forgetDevice, this), checkHardwareStatus: nodeify(this.checkHardwareStatus, this), unlockHardwareWalletAccount: nodeify(this.unlockHardwareWalletAccount, this), @@ -645,6 +657,72 @@ module.exports = class MetamaskController extends EventEmitter { return accounts } + connectHardwareAndUnlockAddress (deviceName, hdPath, addressToUnlock) { + return new Promise(async (resolve, reject) => { + try { + const keyring = await this.getKeyringForDevice(deviceName, hdPath) + + const accountsFromFirstPage = await keyring.getFirstPage() + const initialPage = 0 + let accounts = await this.findAccountInLedger({ + accounts: accountsFromFirstPage, + keyring, + page: initialPage, + addressToUnlock, + hdPath, + }) + accounts = accounts || accountsFromFirstPage + + // Merge with existing accounts + // and make sure addresses are not repeated + const oldAccounts = await this.keyringController.getAccounts() + const accountsToTrack = [...new Set(oldAccounts.concat(accounts.map(a => a.address.toLowerCase())))] + this.accountTracker.syncWithAddresses(accountsToTrack) + + resolve(accountsFromFirstPage) + } catch (e) { + reject(e) + } + }) + } + + async findAccountInLedger ({accounts, keyring, page, addressToUnlock, hdPath}) { + return new Promise(async (resolve, reject) => { + // to do: store pages depth in dropdown + const pagesDepth = 10 + if (page >= pagesDepth) { + reject({ + message: `Requested account ${addressToUnlock} is not found in ${pagesDepth} pages of ${hdPath} path of Ledger. Try to unlock this account from Ledger.`, + }) + return + } + if (accounts.length) { + const accountIsFound = accounts.some((account, ind) => { + const normalizedAddress = account.address.toLowerCase() + if (normalizedAddress === addressToUnlock) { + const indToUnlock = page * accountsPerPage + ind + keyring.setAccountToUnlock(indToUnlock) + } + return normalizedAddress === addressToUnlock + }) + + if (!accountIsFound) { + accounts = await keyring.getNextPage() + page++ + this.findAccountInLedger({accounts, keyring, page, addressToUnlock, hdPath}) + .then(accounts => { + resolve(accounts) + }) + .catch(e => { + reject(e) + }) + } else { + resolve(accounts) + } + } + }) + } + /** * Check if the device is unlocked * @@ -674,21 +752,45 @@ module.exports = class MetamaskController extends EventEmitter { */ async unlockHardwareWalletAccount (index, deviceName, hdPath) { const keyring = await this.getKeyringForDevice(deviceName, hdPath) + let hdAccounts + let indexInPage + if (deviceName.includes('ledger')) { + hdAccounts = await keyring.getFirstPage() + const accountPosition = Number(index) + 1 + const pages = Math.ceil(accountPosition / accountsPerPage) + indexInPage = index % accountsPerPage + if (pages > 1) { + for (let iterator = 0; iterator < pages; iterator++) { + hdAccounts = await keyring.getNextPage() + iterator++ + } + } + } keyring.setAccountToUnlock(index) const oldAccounts = await this.keyringController.getAccounts() const keyState = await this.keyringController.addNewAccount(keyring) const newAccounts = await this.keyringController.getAccounts() this.preferencesController.setAddresses(newAccounts) + + let selectedAddressChanged = false newAccounts.forEach(address => { if (!oldAccounts.includes(address)) { // Set the account label to Trezor 1 / Ledger 1, etc this.preferencesController.setAccountLabel(address, `${deviceName[0].toUpperCase()}${deviceName.slice(1)} ${parseInt(index, 10) + 1}`) // Select the account this.preferencesController.setSelectedAddress(address) + selectedAddressChanged = true } }) + if (deviceName.includes('ledger')) { + if (!selectedAddressChanged) { + // Select the account + this.preferencesController.setSelectedAddress(hdAccounts[indexInPage].address) + } + } + const { identities } = this.preferencesController.store.getState() return { ...keyState, identities } } diff --git a/old-ui/app/account-detail.js b/old-ui/app/account-detail.js index 722394505..70d07bbd0 100644 --- a/old-ui/app/account-detail.js +++ b/old-ui/app/account-detail.js @@ -15,15 +15,20 @@ const TabBar = require('./components/tab-bar') const TokenList = require('./components/token-list') const AccountDropdowns = require('./components/account-dropdowns').AccountDropdowns const CopyButton = require('./components/copyButton') +const ToastComponent = require('./components/toast') +import { getMetaMaskAccounts } from '../../ui/app/selectors' module.exports = connect(mapStateToProps)(AccountDetailScreen) function mapStateToProps (state) { + const accounts = getMetaMaskAccounts(state) return { metamask: state.metamask, identities: state.metamask.identities, keyrings: state.metamask.keyrings, - accounts: state.metamask.accounts, + warning: state.appState.warning, + toastMsg: state.appState.toastMsg, + accounts, address: state.metamask.selectedAddress, accountDetail: state.appState.accountDetail, network: state.metamask.network, @@ -62,6 +67,11 @@ AccountDetailScreen.prototype.render = function () { h('.account-detail-section.full-flex-height', [ + h(ToastComponent, { + msg: props.toastMsg, + isSuccess: false, + }), + // identicon, label, balance, etc h('.account-data-subsection', { style: { diff --git a/old-ui/app/app.js b/old-ui/app/app.js index 99b09dd9a..c8c5d084f 100644 --- a/old-ui/app/app.js +++ b/old-ui/app/app.js @@ -44,6 +44,7 @@ const DeleteRpc = require('./components/delete-rpc') const DeleteImportedAccount = require('./components/delete-imported-account') const ConfirmChangePassword = require('./components/confirm-change-password') const ethNetProps = require('eth-net-props') +const { getMetaMaskAccounts } = require('../../ui/app/selectors') module.exports = compose( withRouter, @@ -54,9 +55,11 @@ inherits(App, Component) function App () { Component.call(this) } function mapStateToProps (state) { + + const accounts = getMetaMaskAccounts(state) + const { identities, - accounts, address, keyrings, isInitialized, diff --git a/old-ui/app/components/account-dropdowns.js b/old-ui/app/components/account-dropdowns.js index 41cefd0c4..1d356ef67 100644 --- a/old-ui/app/components/account-dropdowns.js +++ b/old-ui/app/components/account-dropdowns.js @@ -10,6 +10,7 @@ const ethUtil = require('ethereumjs-util') const copyToClipboard = require('copy-to-clipboard') const ethNetProps = require('eth-net-props') const { getCurrentKeyring, ifLooseAcc, ifContractAcc } = require('../util') +const { getHdPaths } = require('./connect-hardware/util') class AccountDropdowns extends Component { constructor (props) { @@ -62,6 +63,25 @@ class AccountDropdowns extends Component { closeMenu: () => {}, onClick: () => { this.props.actions.showAccountDetail(identity.address) + if (this.ifHardwareAcc(keyring)) { + const ledger = 'ledger' + if (keyring.type.toLowerCase().includes(ledger)) { + const hdPaths = getHdPaths() + return new Promise((resolve, reject) => { + this.props.actions.connectHardwareAndUnlockAddress(ledger, hdPaths[1].value, identity.address) + .then(_ => resolve()) + .catch(e => { + this.props.actions.connectHardwareAndUnlockAddress(ledger, hdPaths[0].value, identity.address) + .then(_ => resolve()) + .catch(e => reject(e)) + }) + }) + .catch(e => { + this.props.actions.displayWarning((e && e.message) || e) + this.props.actions.displayToast(e) + }) + } + } }, style: { marginTop: index === 0 ? '5px' : '', @@ -404,7 +424,16 @@ const mapDispatchToProps = (dispatch) => { showConnectHWWalletPage: () => dispatch(actions.showConnectHWWalletPage()), showQrView: (selected, identity) => dispatch(actions.showQrView(selected, identity)), showDeleteImportedAccount: (identity) => dispatch(actions.showDeleteImportedAccount(identity)), + displayWarning: (msg) => dispatch(actions.displayWarning(msg)), getContract: (addr) => dispatch(actions.getContract(addr)), + setHardwareWalletDefaultHdPath: ({device, path}) => { + return dispatch(actions.setHardwareWalletDefaultHdPath({device, path})) + }, + connectHardwareAndUnlockAddress: (deviceName, hdPath, address) => { + return dispatch(actions.connectHardwareAndUnlockAddress(deviceName, hdPath, address)) + }, + displayToast: (msg) => dispatch(actions.displayToast(msg)), + hideToast: () => dispatch(actions.hideToast()), }, } } diff --git a/old-ui/app/components/buy-button-subview.js b/old-ui/app/components/buy-button-subview.js index b6205c5a5..edcd7c460 100644 --- a/old-ui/app/components/buy-button-subview.js +++ b/old-ui/app/components/buy-button-subview.js @@ -10,6 +10,7 @@ import { getNetworkDisplayName } from '../../../app/scripts/controllers/network/ import { getFaucets, getExchanges } from '../../../app/scripts/lib/buy-eth-url' import ethNetProps from 'eth-net-props' import PropTypes from 'prop-types' +import { getMetaMaskAccounts } from '../../../ui/app/selectors' class BuyButtonSubview extends Component { render () { @@ -197,9 +198,10 @@ BuyButtonSubview.propTypes = { } function mapStateToProps (state) { + const accounts = getMetaMaskAccounts(state) return { identity: state.appState.identity, - account: state.metamask.accounts[state.appState.buyView.buyAddress], + account: accounts[state.appState.buyView.buyAddress], warning: state.appState.warning, buyView: state.appState.buyView, network: state.metamask.network, diff --git a/old-ui/app/components/connect-hardware/account-list.js b/old-ui/app/components/connect-hardware/account-list.js index 10cfbf038..c9d9c2bd4 100644 --- a/old-ui/app/components/connect-hardware/account-list.js +++ b/old-ui/app/components/connect-hardware/account-list.js @@ -4,25 +4,13 @@ import ethNetProps from 'eth-net-props' import { default as Select } from 'react-select' import Button from '../../../../ui/app/components/button' import { capitalizeFirstLetter } from '../../../../app/scripts/lib/util' +import { isLedger, getHdPaths } from './util' class AccountList extends Component { constructor (props, context) { super(props) } - getHdPaths = () => { - return [ - { - label: `Ledger Live`, - value: `m/44'/60'/0'/0/0`, - }, - { - label: `Legacy (MEW / MyCrypto)`, - value: `m/44'/60'/0'`, - }, - ] - } - goToNextPage = () => { // If we have < 5 accounts, it's restricted by BIP-44 if (this.props.accounts.length === 5) { @@ -39,7 +27,7 @@ class AccountList extends Component { renderHdPathSelector = () => { const { onPathChange, selectedPath } = this.props - const options = this.getHdPaths() + const options = getHdPaths() return (

Select HD Path

@@ -67,26 +55,46 @@ class AccountList extends Component {

{`Unlock ${capitalizeFirstLetter(device)}`}

{device.toLowerCase() === 'ledger' ? this.renderHdPathSelector() : null} -

Select the account to view in Nifty Wallet

+

Select the accounts to view in Nifty Wallet

) } + renderInput = (a, i) => { + const { device, selectedAccount, selectedAccounts } = this.props + if (isLedger(device)) { + return ( + this.props.onAccountChange(e.target.value)} + checked={selectedAccounts.includes(a.index.toString())} + /> + ) + } else { + return ( + this.props.onAccountChange(e.target.value)} + checked={selectedAccount === a.index.toString()} + /> + ) + } + } + renderAccounts = () => { const rows = [] this.props.accounts.forEach((a, i) => { rows.push(
- this.props.onAccountChange(e.target.value)} - checked={this.props.selectedAccount === a.index.toString()} - /> + {this.renderInput(a, i)}