Skip to content

Commit

Permalink
Merge pull request #237 from poanetwork/multiple-hardware
Browse files Browse the repository at this point in the history
(Feature) Multiple Ledger accounts for one session
  • Loading branch information
vbaranov authored Jan 16, 2019
2 parents 380b5c8 + d9bbbe0 commit c988d7c
Show file tree
Hide file tree
Showing 42 changed files with 861 additions and 153 deletions.
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions app/scripts/controllers/cached-balances.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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
102 changes: 102 additions & 0 deletions app/scripts/metamask-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 {

/**
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -227,13 +236,15 @@ 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, {
NetworkController: this.networkController.store,
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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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 }
}
Expand Down
12 changes: 11 additions & 1 deletion old-ui/app/account-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
5 changes: 4 additions & 1 deletion old-ui/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions old-ui/app/components/account-dropdowns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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' : '',
Expand Down Expand Up @@ -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()),
},
}
}
Expand Down
4 changes: 3 additions & 1 deletion old-ui/app/components/buy-button-subview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit c988d7c

Please sign in to comment.