Merge pull request #237 from poanetwork/multiple-hardware
(Feature) Multiple Ledger accounts for one session
This commit is contained in:
commit
c988d7cce8
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
<h3 className="hw-connect__hdPath__title">Select HD Path</h3>
|
||||
|
@ -67,26 +55,46 @@ class AccountList extends Component {
|
|||
<h3 className="hw-connect">
|
||||
<h3 className="hw-connect__unlock-title">{`Unlock ${capitalizeFirstLetter(device)}`}</h3>
|
||||
{device.toLowerCase() === 'ledger' ? this.renderHdPathSelector() : null}
|
||||
<p className="hw-connect__msg">Select the account to view in Nifty Wallet</p>
|
||||
<p className="hw-connect__msg">Select the accounts to view in Nifty Wallet</p>
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderInput = (a, i) => {
|
||||
const { device, selectedAccount, selectedAccounts } = this.props
|
||||
if (isLedger(device)) {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`selectedAccount-${i}`}
|
||||
id={`address-${i}`}
|
||||
value={a.index}
|
||||
onChange={(e) => this.props.onAccountChange(e.target.value)}
|
||||
checked={selectedAccounts.includes(a.index.toString())}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<input
|
||||
type="radio"
|
||||
name="selectedAccount"
|
||||
id={`address-${i}`}
|
||||
value={a.index}
|
||||
onChange={(e) => this.props.onAccountChange(e.target.value)}
|
||||
checked={selectedAccount === a.index.toString()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
renderAccounts = () => {
|
||||
const rows = []
|
||||
this.props.accounts.forEach((a, i) => {
|
||||
rows.push(
|
||||
<div className="hw-account-list__item" key={a.address}>
|
||||
<div className="hw-account-list__item__radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="selectedAccount"
|
||||
id={`address-${i}`}
|
||||
value={a.index}
|
||||
onChange={(e) => this.props.onAccountChange(e.target.value)}
|
||||
checked={this.props.selectedAccount === a.index.toString()}
|
||||
/>
|
||||
{this.renderInput(a, i)}
|
||||
<label className="hw-account-list__item__label" htmlFor={`address-${i}`}>
|
||||
{`${a.address.slice(0, 4)}...${a.address.slice(-4)}`}
|
||||
<span
|
||||
|
@ -125,7 +133,7 @@ class AccountList extends Component {
|
|||
}
|
||||
|
||||
renderButtons = () => {
|
||||
const disabled = this.props.selectedAccount === null
|
||||
const disabled = !this.props.selectedAccount && this.props.selectedAccounts.length === 0
|
||||
const buttonProps = {}
|
||||
if (disabled) {
|
||||
buttonProps.disabled = true
|
||||
|
@ -182,6 +190,7 @@ AccountList.propTypes = {
|
|||
getPage: PropTypes.func.isRequired,
|
||||
network: PropTypes.string,
|
||||
selectedAccount: PropTypes.string,
|
||||
selectedAccounts: PropTypes.array,
|
||||
history: PropTypes.object,
|
||||
onUnlockAccount: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
|
|
|
@ -7,6 +7,8 @@ import AccountList from './account-list'
|
|||
import { formatBalance } from '../../util'
|
||||
import { getPlatform } from '../../../../app/scripts/lib/util'
|
||||
import { PLATFORM_FIREFOX } from '../../../../app/scripts/lib/enums'
|
||||
import { isLedger } from './util'
|
||||
import { getMetaMaskAccounts } from '../../../../ui/app/selectors'
|
||||
|
||||
class ConnectHardwareForm extends Component {
|
||||
constructor (props, context) {
|
||||
|
@ -14,6 +16,7 @@ class ConnectHardwareForm extends Component {
|
|||
this.state = {
|
||||
error: null,
|
||||
selectedAccount: null,
|
||||
selectedAccounts: [],
|
||||
accounts: [],
|
||||
browserSupported: true,
|
||||
unlocked: false,
|
||||
|
@ -69,7 +72,25 @@ class ConnectHardwareForm extends Component {
|
|||
}
|
||||
|
||||
onAccountChange = (account) => {
|
||||
this.setState({selectedAccount: account.toString(), error: null})
|
||||
let selectedAcc = account.toString()
|
||||
if (isLedger(this.state.device)) {
|
||||
const selectedAccounts = this.state.selectedAccounts
|
||||
if (!selectedAccounts.includes(selectedAcc)) {
|
||||
selectedAccounts.push(selectedAcc)
|
||||
} else {
|
||||
const indToRemove = selectedAccounts.indexOf(selectedAcc)
|
||||
selectedAccounts.splice(indToRemove, 1)
|
||||
selectedAcc = selectedAccounts[selectedAccounts.length - 1]
|
||||
}
|
||||
const newState = {
|
||||
selectedAccounts,
|
||||
selectedAccount: selectedAcc,
|
||||
error: null,
|
||||
}
|
||||
this.setState(newState)
|
||||
} else {
|
||||
this.setState({selectedAccount: account.toString(), error: null})
|
||||
}
|
||||
}
|
||||
|
||||
onAccountRestriction = () => {
|
||||
|
@ -97,19 +118,20 @@ class ConnectHardwareForm extends Component {
|
|||
}
|
||||
|
||||
const newState = { unlocked: true, device, error: null }
|
||||
// Default to the first account
|
||||
if (this.state.selectedAccount === null) {
|
||||
accounts.forEach((a, i) => {
|
||||
if (a.address.toLowerCase() === this.props.address) {
|
||||
newState.selectedAccount = a.index.toString()
|
||||
}
|
||||
})
|
||||
// If the page doesn't contain the selected account, let's deselect it
|
||||
} else if (!accounts.filter(a => a.index.toString() === this.state.selectedAccount).length) {
|
||||
newState.selectedAccount = null
|
||||
if (!isLedger(device)) {
|
||||
// Default to the first account
|
||||
if (this.state.selectedAccount === null) {
|
||||
accounts.forEach((a, i) => {
|
||||
if (a.address.toLowerCase() === this.props.address) {
|
||||
newState.selectedAccount = a.index.toString()
|
||||
}
|
||||
})
|
||||
// If the page doesn't contain the selected account, let's deselect it
|
||||
} else if (!accounts.filter(a => a.index.toString() === this.state.selectedAccount).length) {
|
||||
newState.selectedAccount = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map accounts with balances
|
||||
newState.accounts = accounts.map(account => {
|
||||
const normalizedAddress = account.address.toLowerCase()
|
||||
|
@ -136,6 +158,7 @@ class ConnectHardwareForm extends Component {
|
|||
this.setState({
|
||||
error: null,
|
||||
selectedAccount: null,
|
||||
selectedAccounts: [],
|
||||
accounts: [],
|
||||
unlocked: false,
|
||||
})
|
||||
|
@ -146,16 +169,35 @@ class ConnectHardwareForm extends Component {
|
|||
|
||||
onUnlockAccount = (device) => {
|
||||
|
||||
if (this.state.selectedAccount === null) {
|
||||
if (!this.state.selectedAccount && this.state.selectedAccounts.length === 0) {
|
||||
this.setState({ error: 'You need to select an account!' })
|
||||
}
|
||||
|
||||
this.props.unlockHardwareWalletAccount(this.state.selectedAccount, device)
|
||||
.then(_ => {
|
||||
this.props.goHome()
|
||||
}).catch(e => {
|
||||
this.setState({ error: (e.message || e.toString()) })
|
||||
})
|
||||
if (this.state.selectedAccounts.length > 0) {
|
||||
this.unlockHardwareWalletAccounts(this.state.selectedAccounts, device)
|
||||
.then(_ => {
|
||||
this.props.goHome()
|
||||
})
|
||||
} else {
|
||||
this.props.unlockHardwareWalletAccount(this.state.selectedAccount, device)
|
||||
.then(_ => {
|
||||
this.props.goHome()
|
||||
}).catch(e => {
|
||||
this.setState({ error: (e.message || e.toString()) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unlockHardwareWalletAccounts = (accounts, device) => {
|
||||
return accounts.reduce((promise, account) => {
|
||||
return promise
|
||||
.then((result) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(this.props.unlockHardwareWalletAccount(account, device))
|
||||
})
|
||||
})
|
||||
.catch(e => this.setState({ error: (e.message || e.toString()) }))
|
||||
}, Promise.resolve())
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
|
@ -185,6 +227,7 @@ class ConnectHardwareForm extends Component {
|
|||
device={this.state.device}
|
||||
accounts={this.state.accounts}
|
||||
selectedAccount={this.state.selectedAccount}
|
||||
selectedAccounts={this.state.selectedAccounts}
|
||||
onAccountChange={this.onAccountChange}
|
||||
network={this.props.network}
|
||||
getPage={this.getPage}
|
||||
|
@ -239,8 +282,9 @@ ConnectHardwareForm.propTypes = {
|
|||
|
||||
const mapStateToProps = state => {
|
||||
const {
|
||||
metamask: { network, selectedAddress, identities = {}, accounts = [] },
|
||||
metamask: { network, selectedAddress, identities = {} },
|
||||
} = state
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const numberOfExistingAccounts = Object.keys(identities).length
|
||||
const {
|
||||
appState: { defaultHdPaths },
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
function isLedger (device) {
|
||||
return device && device.toLowerCase().includes('ledger')
|
||||
}
|
||||
|
||||
function getHdPaths () {
|
||||
return [
|
||||
{
|
||||
label: `Ledger Live`,
|
||||
value: `m/44'/60'/0'/0/0`,
|
||||
},
|
||||
{
|
||||
label: `Legacy (MEW / MyCrypto)`,
|
||||
value: `m/44'/60'/0'`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isLedger,
|
||||
getHdPaths,
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import actions from '../../../ui/app/actions'
|
||||
|
||||
class SendError extends Component {
|
||||
class ErrorComponent extends Component {
|
||||
static propTypes = {
|
||||
error: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
hideWarning: PropTypes.func,
|
||||
}
|
||||
|
||||
render () {
|
||||
|
@ -29,7 +31,7 @@ class SendError extends Component {
|
|||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => this.props.onClose(e)}
|
||||
onClick={(e) => this.props.hideWarning()}
|
||||
/>
|
||||
<div style={{
|
||||
marginLeft: '30px',
|
||||
|
@ -47,4 +49,10 @@ class SendError extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = SendError
|
||||
function mapDispatchToProps (dispatch) {
|
||||
return {
|
||||
hideWarning: () => dispatch(actions.hideWarning()),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = connect(null, mapDispatchToProps)(ErrorComponent)
|
|
@ -24,6 +24,7 @@ const connect = require('react-redux').connect
|
|||
const abiDecoder = require('abi-decoder')
|
||||
const { tokenInfoGetter, calcTokenAmount } = require('../../../ui/app/token-util')
|
||||
const BigNumber = require('bignumber.js')
|
||||
import { getMetaMaskAccounts } from '../../../ui/app/selectors'
|
||||
|
||||
const MIN_GAS_PRICE_BN = new BN('0')
|
||||
const MIN_GAS_LIMIT_BN = new BN('21000')
|
||||
|
@ -44,9 +45,10 @@ function PendingTx () {
|
|||
}
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
return {
|
||||
identities: state.metamask.identities,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts,
|
||||
selectedAddress: state.metamask.selectedAddress,
|
||||
unapprovedTxs: state.metamask.unapprovedTxs,
|
||||
unapprovedMsgs: state.metamask.unapprovedMsgs,
|
||||
|
|
|
@ -4,9 +4,10 @@ import { connect } from 'react-redux'
|
|||
import SendProfile from './send-profile'
|
||||
import ExecutorCell from './executor-cell'
|
||||
import SendHeader from './send-header'
|
||||
import SendError from './send-error'
|
||||
import ErrorComponent from '../error'
|
||||
import actions from '../../../../ui/app/actions'
|
||||
import { ifContractAcc } from '../../util'
|
||||
import { getMetaMaskAccounts } from '../../../../ui/app/selectors'
|
||||
import Web3 from 'web3'
|
||||
|
||||
const ownerABI = [{
|
||||
|
@ -73,12 +74,7 @@ class ChooseContractExecutor extends Component {
|
|||
<div className="send-screen flex-column flex-grow">
|
||||
<SendProfile />
|
||||
<SendHeader title="Choose contract executor" back={() => this.back()} />
|
||||
<SendError
|
||||
error={error}
|
||||
onClose={() => {
|
||||
this.props.hideWarning()
|
||||
}}
|
||||
/>
|
||||
<ErrorComponent error={error} />
|
||||
<div style={{ padding: '0 30px' }}>
|
||||
<span className="hw-connect__header__msg">Contract transaction will be executed from selected account</span>
|
||||
</div>
|
||||
|
@ -237,9 +233,10 @@ class ChooseContractExecutor extends Component {
|
|||
}
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const result = {
|
||||
selected: state.metamask.selectedAddress,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts,
|
||||
keyrings: state.metamask.keyrings,
|
||||
identities: state.metamask.identities,
|
||||
warning: state.appState.warning,
|
||||
|
|
|
@ -4,7 +4,8 @@ import { connect } from 'react-redux'
|
|||
import PersistentForm from '../../../lib/persistent-form'
|
||||
import SendProfile from './send-profile'
|
||||
import SendHeader from './send-header'
|
||||
import SendError from './send-error'
|
||||
import ErrorComponent from '../error'
|
||||
import ToastComponent from '../toast'
|
||||
import Select from 'react-select'
|
||||
import actions from '../../../../ui/app/actions'
|
||||
import abiEncoder from 'web3-eth-abi'
|
||||
|
@ -109,15 +110,9 @@ class SendTransactionScreen extends PersistentForm {
|
|||
copyDisabled: true,
|
||||
}
|
||||
|
||||
this.timerID = null
|
||||
PersistentForm.call(this)
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.hideToast()
|
||||
clearTimeout(this.timerID)
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
this.getContractMethods()
|
||||
}
|
||||
|
@ -132,11 +127,8 @@ class SendTransactionScreen extends PersistentForm {
|
|||
<div className="send-screen flex-column flex-grow">
|
||||
<SendProfile />
|
||||
<SendHeader title="Execute Method" />
|
||||
<SendError
|
||||
error={error}
|
||||
onClose={() => { this.props.hideWarning() }}
|
||||
/>
|
||||
{this.props.toastMsg ? <div className="toast">{this.props.toastMsg}</div> : null}
|
||||
<ErrorComponent error={error} />
|
||||
<ToastComponent msg={this.props.toastMsg} isSuccess={true} />
|
||||
<div style={{ padding: '0 30px' }}>
|
||||
<Select
|
||||
clearable={false}
|
||||
|
@ -387,7 +379,6 @@ class SendTransactionScreen extends PersistentForm {
|
|||
}
|
||||
|
||||
setOutputValue = (val, type) => {
|
||||
console.log(val)
|
||||
if (!type) {
|
||||
return val || ''
|
||||
}
|
||||
|
@ -423,9 +414,6 @@ class SendTransactionScreen extends PersistentForm {
|
|||
if (txData) {
|
||||
copyToClipboard(txData)
|
||||
this.props.displayToast('Contract ABI encoded method call has been successfully copied to clipboard')
|
||||
this.timerID = setTimeout(() => {
|
||||
this.props.hideToast()
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import Identicon from '../identicon'
|
|||
import { addressSummary } from '../../util'
|
||||
import EthBalance from '../eth-balance'
|
||||
import TokenBalance from '../token-balance'
|
||||
import { getMetaMaskAccounts } from '../../../../ui/app/selectors'
|
||||
|
||||
class SendProfile extends Component {
|
||||
render () {
|
||||
|
@ -77,9 +78,10 @@ class SendProfile extends Component {
|
|||
}
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
var result = {
|
||||
address: state.metamask.selectedAddress,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts,
|
||||
identities: state.metamask.identities,
|
||||
network: state.metamask.network,
|
||||
conversionRate: state.metamask.conversionRate,
|
||||
|
|
|
@ -18,14 +18,16 @@ BigNumber.config({ ERRORS: false })
|
|||
const log = require('loglevel')
|
||||
import SendProfile from './send-profile'
|
||||
import SendHeader from './send-header'
|
||||
import SendError from './send-error'
|
||||
import ErrorComponent from '../error'
|
||||
import { getMetaMaskAccounts } from '../../../../ui/app/selectors'
|
||||
|
||||
module.exports = connect(mapStateToProps)(SendTransactionScreen)
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
var result = {
|
||||
address: state.metamask.selectedAddress,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts,
|
||||
identities: state.metamask.identities,
|
||||
warning: state.appState.warning,
|
||||
network: state.metamask.network,
|
||||
|
@ -95,7 +97,7 @@ SendTransactionScreen.prototype.render = function () {
|
|||
}),
|
||||
|
||||
// error message
|
||||
h(SendError, {
|
||||
h(ErrorComponent, {
|
||||
error,
|
||||
}),
|
||||
|
||||
|
|
|
@ -14,13 +14,15 @@ const EnsInput = require('../ens-input')
|
|||
const ethUtil = require('ethereumjs-util')
|
||||
import SendProfile from './send-profile'
|
||||
import SendHeader from './send-header'
|
||||
import SendError from './send-error'
|
||||
import ErrorComponent from '../error'
|
||||
import { getMetaMaskAccounts } from '../../../../ui/app/selectors'
|
||||
module.exports = connect(mapStateToProps)(SendTransactionScreen)
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
var result = {
|
||||
address: state.metamask.selectedAddress,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts,
|
||||
identities: state.metamask.identities,
|
||||
warning: state.appState.warning,
|
||||
network: state.metamask.network,
|
||||
|
@ -69,11 +71,8 @@ SendTransactionScreen.prototype.render = function () {
|
|||
}),
|
||||
|
||||
// error message
|
||||
h(SendError, {
|
||||
h(ErrorComponent, {
|
||||
error,
|
||||
onClose: () => {
|
||||
this.props.dispatch(actions.hideWarning())
|
||||
},
|
||||
}),
|
||||
|
||||
// 'to' field
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import classnames from 'classnames'
|
||||
import actions from '../../../ui/app/actions'
|
||||
|
||||
class ToastComponent extends Component {
|
||||
static propTypes = {
|
||||
msg: PropTypes.string,
|
||||
isSuccess: PropTypes.bool,
|
||||
hideToast: PropTypes.func,
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props)
|
||||
this.timerID = null
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (!prevProps.msg && this.props.msg) {
|
||||
this.timerID = setTimeout(() => {
|
||||
this.props.hideToast()
|
||||
clearTimeout(this.timerID)
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.hideToast()
|
||||
clearTimeout(this.timerID)
|
||||
}
|
||||
|
||||
render () {
|
||||
const { msg } = this.props
|
||||
return msg ? (
|
||||
<div
|
||||
className={classnames('toast', {
|
||||
'green': this.props.isSuccess,
|
||||
'red': !this.props.isSuccess,
|
||||
})}
|
||||
onClick={(e) => this.props.hideToast()}
|
||||
>{(msg && msg.message) || msg}</div>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps (state) {
|
||||
return {
|
||||
toastMsg: state.appState.toastMsg,
|
||||
}
|
||||
}
|
||||
|
||||
function mapDispatchToProps (dispatch) {
|
||||
return {
|
||||
hideToast: () => dispatch(actions.hideToast()),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = connect(mapStateToProps, mapDispatchToProps)(ToastComponent)
|
|
@ -13,6 +13,7 @@ import PendingMsg from './components/pending-msg'
|
|||
import PendingPersonalMsg from './components/pending-personal-msg'
|
||||
import PendingTypedMsg from './components/pending-typed-msg'
|
||||
const Loading = require('./components/loading')
|
||||
const { getMetaMaskAccounts } = require('../../ui/app/selectors')
|
||||
|
||||
module.exports = connect(mapStateToProps)(ConfirmTxScreen)
|
||||
|
||||
|
@ -21,8 +22,8 @@ function mapStateToProps (state) {
|
|||
const { screenParams, pendingTxIndex } = appState.currentView
|
||||
return {
|
||||
identities: metamask.identities,
|
||||
accounts: getMetaMaskAccounts(state),
|
||||
keyrings: metamask.keyrings,
|
||||
accounts: metamask.accounts,
|
||||
selectedAddress: metamask.selectedAddress,
|
||||
unapprovedTxs: metamask.unapprovedTxs,
|
||||
unapprovedMsgs: metamask.unapprovedMsgs,
|
||||
|
|
|
@ -280,10 +280,13 @@ app sections
|
|||
|
||||
/* unlock */
|
||||
.toast {
|
||||
border: 1px solid #60db97 !important;
|
||||
background-image: url('../images/remove.svg');
|
||||
background-size: 12px 12px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 5px 5px;
|
||||
border: 1px solid !important;
|
||||
color: #ffffff !important;
|
||||
font-size: 12px;
|
||||
background: #60db97;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
width: 357px;
|
||||
|
@ -294,6 +297,17 @@ app sections
|
|||
right: 0px;
|
||||
z-index: 100;
|
||||
animation: 500ms ease-out 0s move;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast.green {
|
||||
background-color: #60db97;
|
||||
border-color: #60db97 !important;
|
||||
}
|
||||
|
||||
.toast.red {
|
||||
background-color: #ff1345;
|
||||
border-color: #ff1345 !important;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
|
|
|
@ -10124,8 +10124,8 @@
|
|||
}
|
||||
},
|
||||
"eth-hd-keyring": {
|
||||
"version": "https://registry.npmjs.org/eth-hd-keyring/-/eth-hd-keyring-2.0.0.tgz",
|
||||
"from": "eth-hd-keyring@2.0.0",
|
||||
"version": "github:vbaranov/eth-hd-keyring#64d0fa741af88d5f232f9518fd150190c421b3e7",
|
||||
"from": "github:vbaranov/eth-hd-keyring#2.0.1",
|
||||
"requires": {
|
||||
"bip39": "^2.2.0",
|
||||
"eth-sig-util": "^2.0.1",
|
||||
|
@ -10450,7 +10450,7 @@
|
|||
"bip39": "^2.4.0",
|
||||
"bluebird": "^3.5.0",
|
||||
"browser-passworder": "^2.0.3",
|
||||
"eth-hd-keyring": "https://registry.npmjs.org/eth-hd-keyring/-/eth-hd-keyring-2.0.0.tgz",
|
||||
"eth-hd-keyring": "github:vbaranov/eth-hd-keyring#64d0fa741af88d5f232f9518fd150190c421b3e7",
|
||||
"eth-sig-util": "^1.4.0",
|
||||
"eth-simple-keyring": "^2.0.0",
|
||||
"ethereumjs-util": "^5.1.2",
|
||||
|
@ -10524,9 +10524,8 @@
|
|||
}
|
||||
},
|
||||
"eth-ledger-bridge-keyring": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eth-ledger-bridge-keyring/-/eth-ledger-bridge-keyring-0.1.1.tgz",
|
||||
"integrity": "sha512-EhClGSy5ixcd55yHGXoA3C7I8iFFi6kgSqvKOSj+5URtg5PYpHP8kv+KemFPOT1Px6se/IFHI9OIelUS8kN3lw==",
|
||||
"version": "github:vbaranov/eth-ledger-bridge-keyring#ab97ae49167ddbe8442c06c857c3f8ca5f7cbcf6",
|
||||
"from": "github:vbaranov/eth-ledger-bridge-keyring#0.1.0-multiple-accounts",
|
||||
"requires": {
|
||||
"eth-sig-util": "^1.4.2",
|
||||
"ethereumjs-tx": "^1.3.4",
|
||||
|
@ -10540,7 +10539,18 @@
|
|||
"resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-1.4.2.tgz",
|
||||
"integrity": "sha1-jZWCAsftuq6Dlwf7pvCf8ydgYhA=",
|
||||
"requires": {
|
||||
"ethereumjs-abi": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"ethereumjs-util": "^5.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethereumjs-abi": {
|
||||
"version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"from": "git+https://github.com/ethereumjs/ethereumjs-abi.git",
|
||||
"requires": {
|
||||
"bn.js": "^4.10.0",
|
||||
"ethereumjs-util": "^5.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ethereum-common": {
|
||||
|
@ -10963,6 +10973,16 @@
|
|||
"requires": {
|
||||
"ethereumjs-abi": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"ethereumjs-util": "^5.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethereumjs-abi": {
|
||||
"version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"from": "git+https://github.com/ethereumjs/ethereumjs-abi.git",
|
||||
"requires": {
|
||||
"bn.js": "^4.10.0",
|
||||
"ethereumjs-util": "^5.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ethereum-common": {
|
||||
|
@ -14994,7 +15014,8 @@
|
|||
"bindings": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.3.0.tgz",
|
||||
"integrity": "sha512-DpLh5EzMR2kzvX1KIlVC0VkC3iZtHKTgdtZ0a3pglBZdaQFjt5S9g9xd1lE+YvXyfd6mtCeRnrUfOLYiTMlNSw=="
|
||||
"integrity": "sha512-DpLh5EzMR2kzvX1KIlVC0VkC3iZtHKTgdtZ0a3pglBZdaQFjt5S9g9xd1lE+YvXyfd6mtCeRnrUfOLYiTMlNSw==",
|
||||
"dev": true
|
||||
},
|
||||
"bip39": {
|
||||
"version": "2.5.0",
|
||||
|
@ -15013,6 +15034,7 @@
|
|||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz",
|
||||
"integrity": "sha1-AfqHSHhcpwlV1QESF9GzE5lpyiI=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
|
@ -15047,7 +15069,8 @@
|
|||
"bn.js": {
|
||||
"version": "4.11.8",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
|
||||
"integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA=="
|
||||
"integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==",
|
||||
"dev": true
|
||||
},
|
||||
"body-parser": {
|
||||
"version": "1.18.3",
|
||||
|
@ -15090,12 +15113,14 @@
|
|||
"brorand": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
|
||||
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
|
||||
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
|
||||
"dev": true
|
||||
},
|
||||
"browserify-aes": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
|
||||
"integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"buffer-xor": "^1.0.3",
|
||||
"cipher-base": "^1.0.0",
|
||||
|
@ -15252,7 +15277,8 @@
|
|||
"buffer-xor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
|
||||
"integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk="
|
||||
"integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=",
|
||||
"dev": true
|
||||
},
|
||||
"builtin-modules": {
|
||||
"version": "1.1.1",
|
||||
|
@ -15350,6 +15376,7 @@
|
|||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
|
||||
"integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
|
@ -15509,6 +15536,7 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
||||
"integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cipher-base": "^1.0.1",
|
||||
"inherits": "^2.0.1",
|
||||
|
@ -15521,6 +15549,7 @@
|
|||
"version": "1.1.7",
|
||||
"resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
|
||||
"integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cipher-base": "^1.0.3",
|
||||
"create-hash": "^1.1.0",
|
||||
|
@ -15820,6 +15849,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz",
|
||||
"integrity": "sha1-Pja2xCs3BDgjzbwzLVjzHiRFSAs=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"browserify-aes": "^1.0.6",
|
||||
"create-hash": "^1.1.2",
|
||||
|
@ -15858,6 +15888,7 @@
|
|||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz",
|
||||
"integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bn.js": "^4.4.0",
|
||||
"brorand": "^1.0.1",
|
||||
|
@ -16150,17 +16181,6 @@
|
|||
"requires": {
|
||||
"ethereumjs-abi": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"ethereumjs-util": "^5.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethereumjs-abi": {
|
||||
"version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"from": "git+https://github.com/ethereumjs/ethereumjs-abi.git",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bn.js": "^4.10.0",
|
||||
"ethereumjs-util": "^5.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ethereum-common": {
|
||||
|
@ -16171,7 +16191,8 @@
|
|||
},
|
||||
"ethereumjs-abi": {
|
||||
"version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"from": "git+https://github.com/ethereumjs/ethereumjs-abi.git#2863c40e0982acfc0b7163f0285d4c56427c7799",
|
||||
"from": "git+https://github.com/ethereumjs/ethereumjs-abi.git",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bn.js": "^4.10.0",
|
||||
"ethereumjs-util": "^5.0.0"
|
||||
|
@ -16366,6 +16387,7 @@
|
|||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-5.2.0.tgz",
|
||||
"integrity": "sha512-CJAKdI0wgMbQFLlLRtZKGcy/L6pzVRgelIZqRqNbuVFM3K9VEnyfbcvz0ncWMRNCe4kaHWjwRYQcYMucmwsnWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bn.js": "^4.11.0",
|
||||
"create-hash": "^1.1.2",
|
||||
|
@ -16455,6 +16477,7 @@
|
|||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/ethjs-util/-/ethjs-util-0.1.6.tgz",
|
||||
"integrity": "sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-hex-prefixed": "1.0.0",
|
||||
"strip-hex-prefix": "1.0.0"
|
||||
|
@ -16476,6 +16499,7 @@
|
|||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
|
||||
"integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"md5.js": "^1.3.4",
|
||||
"safe-buffer": "^5.1.1"
|
||||
|
@ -16886,6 +16910,7 @@
|
|||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
|
||||
"integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
|
@ -16895,6 +16920,7 @@
|
|||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.5.tgz",
|
||||
"integrity": "sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
|
@ -16922,6 +16948,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
||||
"integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hash.js": "^1.0.3",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
|
@ -17089,7 +17116,8 @@
|
|||
"is-hex-prefixed": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-hex-prefixed/-/is-hex-prefixed-1.0.0.tgz",
|
||||
"integrity": "sha1-fY035q135dEnFIkTxXPggtd39VQ="
|
||||
"integrity": "sha1-fY035q135dEnFIkTxXPggtd39VQ=",
|
||||
"dev": true
|
||||
},
|
||||
"is-natural-number": {
|
||||
"version": "4.0.1",
|
||||
|
@ -17290,6 +17318,7 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/keccak/-/keccak-1.4.0.tgz",
|
||||
"integrity": "sha512-eZVaCpblK5formjPjeTBik7TAg+pqnDrMHIffSvi9Lh7PQgM1+hSzakUeZFCk9DVVG0dacZJuaz2ntwlzZUIBw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bindings": "^1.2.1",
|
||||
"inherits": "^2.0.3",
|
||||
|
@ -17589,6 +17618,7 @@
|
|||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||
"integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hash-base": "^3.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
|
@ -17748,12 +17778,14 @@
|
|||
"minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"dev": true
|
||||
},
|
||||
"minimalistic-crypto-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
|
||||
"integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo="
|
||||
"integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
|
@ -17823,7 +17855,8 @@
|
|||
"nan": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "http://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
|
||||
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA=="
|
||||
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
|
||||
"dev": true
|
||||
},
|
||||
"nano-json-stream-parser": {
|
||||
"version": "0.1.2",
|
||||
|
@ -18490,6 +18523,7 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
|
||||
"integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hash-base": "^3.0.0",
|
||||
"inherits": "^2.0.1"
|
||||
|
@ -18499,6 +18533,7 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/rlp/-/rlp-2.1.0.tgz",
|
||||
"integrity": "sha512-93U7IKH5j7nmXFVg19MeNBGzQW5uXW1pmCuKY8veeKIhYTE32C2d0mOegfiIAfXcHOKJjjPlJisn8iHDF5AezA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.1"
|
||||
}
|
||||
|
@ -18512,7 +18547,8 @@
|
|||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true
|
||||
},
|
||||
"safe-event-emitter": {
|
||||
"version": "1.0.1",
|
||||
|
@ -18564,6 +18600,7 @@
|
|||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-3.5.2.tgz",
|
||||
"integrity": "sha512-iin3kojdybY6NArd+UFsoTuapOF7bnJNf2UbcWXaY3z+E1sJDipl60vtzB5hbO/uquBu7z0fd4VC4Irp+xoFVQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bindings": "^1.2.1",
|
||||
"bip66": "^1.1.3",
|
||||
|
@ -18695,6 +18732,7 @@
|
|||
"version": "2.4.11",
|
||||
"resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
|
||||
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
|
@ -18905,6 +18943,7 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz",
|
||||
"integrity": "sha1-DF8VX+8RUTczd96du1iNoFUA428=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-hex-prefixed": "1.0.0"
|
||||
}
|
||||
|
|
|
@ -116,7 +116,7 @@
|
|||
"eth-json-rpc-filters": "github:poanetwork/eth-json-rpc-filters#3.0.2",
|
||||
"eth-json-rpc-infura": "^3.0.0",
|
||||
"eth-keychain-controller": "github:vbaranov/KeyringController#simple-address",
|
||||
"eth-ledger-bridge-keyring": "^0.1.0",
|
||||
"eth-ledger-bridge-keyring": "github:vbaranov/eth-ledger-bridge-keyring#0.1.0-multiple-accounts",
|
||||
"eth-method-registry": "^1.0.0",
|
||||
"eth-net-props": "^1.0.10",
|
||||
"eth-phishing-detect": "^1.1.4",
|
||||
|
|
|
@ -30,6 +30,7 @@ module.exports = {
|
|||
info: By.css('li.dropdown-menu-item:nth-child(4)'),
|
||||
},
|
||||
account: {
|
||||
item: By.className('dropdown-menu-item'),
|
||||
account1: By.css('#app-content > div > div.full-width > div.full-width > div > div:nth-child(2) > span > div > div > span > div > li:nth-child(2) > span'),
|
||||
account2: By.css('#app-content > div > div.full-width > div.full-width > div > div:nth-child(2) > span > div > div > span > div > li:nth-child(3) > span'),
|
||||
account3: By.css('#app-content > div > div.full-width > div.full-width > div > div:nth-child(2) > span > div > div > span > div > li:nth-child(4) > span'),
|
||||
|
@ -54,6 +55,17 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
screens: {
|
||||
hdWallet: {
|
||||
buttonArrow: By.className('fa fa-arrow-left fa-lg cursor-pointer'),
|
||||
error: By.className('error'),
|
||||
title: By.className('section-title flex-row flex-center'),
|
||||
buttonConnect: {
|
||||
enabled: By.className('hw-connect__connect-btn'),
|
||||
disabled: By.className('hw-connect__connect-btn disabled'),
|
||||
},
|
||||
image: By.className('hw-connect__btn__img'),
|
||||
imageSelected: By.className('hw-connect__btn selected'),
|
||||
},
|
||||
chooseContractExecutor: {
|
||||
title: By.className('flex-center send-header'),
|
||||
titleText: 'Choose contract executor',
|
||||
|
|
|
@ -269,6 +269,97 @@ describe('Metamask popup page', async function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Connect Hardware Wallet', async function () {
|
||||
|
||||
it("Account menu contais item 'Connect HD wallet'", async function () {
|
||||
const menu = await f.waitUntilShowUp(menus.account.menu)
|
||||
await menu.click()
|
||||
await f.waitUntilShowUp(menus.account.item)
|
||||
const items = await driver.findElements(menus.account.item)
|
||||
await f.delay(500)
|
||||
assert.equal(await items[4].getText(), 'Connect hardware wallet', "item's text incorrect")
|
||||
await items[4].click()
|
||||
})
|
||||
|
||||
|
||||
it("Opens screen 'Connect HD wallet',title is correct", async function () {
|
||||
const title = await f.waitUntilShowUp(screens.hdWallet.title)
|
||||
assert.equal(await title.getText(), 'Connect to hardware wallet', "item's text incorrect")
|
||||
})
|
||||
|
||||
if (process.env.SELENIUM_BROWSER === 'chrome') {
|
||||
it("Button 'Connect' disabled by default", async function () {
|
||||
const button = await f.waitUntilShowUp(screens.hdWallet.buttonConnect.disabled)
|
||||
assert.notEqual(button, false, "button isn't displayed")
|
||||
assert.equal(await button.getText(), 'CONNECT', 'button has incorrect text')
|
||||
})
|
||||
|
||||
it('Ledger image is displayed', async function () {
|
||||
const image = await f.waitUntilShowUp(screens.hdWallet.image)
|
||||
assert.notEqual(image, false, "ledger's image isn't displayed")
|
||||
const src = await image.getAttribute('src')
|
||||
assert.equal(src.includes('images/ledger-logo.svg'), true, 'Ledger has incorrect image')
|
||||
})
|
||||
|
||||
it('Trezor image is displayed', async function () {
|
||||
const images = await driver.findElements(screens.hdWallet.image)
|
||||
assert.notEqual(images[1], false, "trezor's image isn't displayed")
|
||||
const src = await images[1].getAttribute('src')
|
||||
assert.equal(src.includes('images/trezor-logo.svg'), true, 'Trezor has incorrect image')
|
||||
})
|
||||
|
||||
it("Button 'Connect' enabled if Trezor selected", async function () {
|
||||
const images = await driver.findElements(screens.hdWallet.image)
|
||||
await images[1].click()
|
||||
const button = await f.waitUntilShowUp(screens.hdWallet.buttonConnect.enabled)
|
||||
assert.equal(await button.isEnabled(), true, 'button is disabled')
|
||||
})
|
||||
|
||||
it("Button 'Connect' enabled if Ledger selected", async function () {
|
||||
const images = await driver.findElements(screens.hdWallet.image)
|
||||
await images[0].click()
|
||||
const button = await f.waitUntilShowUp(screens.hdWallet.buttonConnect.enabled)
|
||||
assert.equal(await button.isEnabled(), true, 'button is disabled')
|
||||
})
|
||||
|
||||
it('Only one device can be selected', async function () {
|
||||
const selected = await driver.findElements(screens.hdWallet.imageSelected)
|
||||
assert.equal(await selected.length, 1, 'more than one device is selected')
|
||||
})
|
||||
|
||||
it('Error message if connect Ledger', async function () {
|
||||
const button = await f.waitUntilShowUp(screens.hdWallet.buttonConnect.enabled)
|
||||
await button.click()
|
||||
const error = await f.waitUntilShowUp(screens.hdWallet.error)
|
||||
const shouldBe = "TransportError: U2F browser support is needed for Ledger. Please use Chrome, Opera or Firefox with a U2F extension. Also make sure you're on an HTTPS connection"
|
||||
assert.equal(await error.getText(), shouldBe, 'error has incorrect text')
|
||||
})
|
||||
|
||||
it('Popup opens if connect Trezor', async function () {
|
||||
const images = await driver.findElements(screens.hdWallet.image)
|
||||
await images[1].click()
|
||||
const button = await f.waitUntilShowUp(screens.hdWallet.buttonConnect.enabled)
|
||||
await button.click()
|
||||
const allHandles = await driver.getAllWindowHandles()
|
||||
assert.equal(allHandles.length, 2, "popup isn't opened")
|
||||
await f.switchToFirstPage()
|
||||
await driver.navigate().refresh()
|
||||
})
|
||||
}
|
||||
it('Button arrow leads to main screen', async function () {
|
||||
const menu = await f.waitUntilShowUp(menus.account.menu)
|
||||
await menu.click()
|
||||
await f.waitUntilShowUp(menus.account.item)
|
||||
const items = await driver.findElements(menus.account.item)
|
||||
await f.delay(500)
|
||||
await items[4].click()
|
||||
const arrow = await f.waitUntilShowUp(screens.hdWallet.buttonArrow)
|
||||
await arrow.click()
|
||||
const ident = await f.waitUntilShowUp(screens.main.identicon, 20)
|
||||
assert.notEqual(ident, false, "main screen isn't opened")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import Account', async function () {
|
||||
|
||||
it('Open import account menu', async function () {
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
const assert = require('assert')
|
||||
const sinon = require('sinon')
|
||||
const CachedBalancesController = require('../../../../app/scripts/controllers/cached-balances')
|
||||
|
||||
describe('CachedBalancesController', () => {
|
||||
describe('updateCachedBalances', () => {
|
||||
it('should update the cached balances', async () => {
|
||||
const controller = new CachedBalancesController({
|
||||
getNetwork: () => Promise.resolve(17),
|
||||
accountTracker: {
|
||||
store: {
|
||||
subscribe: () => {},
|
||||
},
|
||||
},
|
||||
initState: {
|
||||
cachedBalances: 'mockCachedBalances',
|
||||
},
|
||||
})
|
||||
|
||||
controller._generateBalancesToCache = sinon.stub().callsFake(() => Promise.resolve('mockNewCachedBalances'))
|
||||
|
||||
await controller.updateCachedBalances({ accounts: 'mockAccounts' })
|
||||
|
||||
assert.equal(controller._generateBalancesToCache.callCount, 1)
|
||||
assert.deepEqual(controller._generateBalancesToCache.args[0], ['mockAccounts', 17])
|
||||
assert.equal(controller.store.getState().cachedBalances, 'mockNewCachedBalances')
|
||||
})
|
||||
})
|
||||
|
||||
describe('_generateBalancesToCache', () => {
|
||||
it('should generate updated account balances where the current network was updated', () => {
|
||||
const controller = new CachedBalancesController({
|
||||
accountTracker: {
|
||||
store: {
|
||||
subscribe: () => {},
|
||||
},
|
||||
},
|
||||
initState: {
|
||||
cachedBalances: {
|
||||
17: {
|
||||
a: '0x1',
|
||||
b: '0x2',
|
||||
c: '0x3',
|
||||
},
|
||||
16: {
|
||||
a: '0xa',
|
||||
b: '0xb',
|
||||
c: '0xc',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = controller._generateBalancesToCache({
|
||||
a: { balance: '0x4' },
|
||||
b: { balance: null },
|
||||
c: { balance: '0x5' },
|
||||
}, 17)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
17: {
|
||||
a: '0x4',
|
||||
b: '0x2',
|
||||
c: '0x5',
|
||||
},
|
||||
16: {
|
||||
a: '0xa',
|
||||
b: '0xb',
|
||||
c: '0xc',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should generate updated account balances where the a new network was selected', () => {
|
||||
const controller = new CachedBalancesController({
|
||||
accountTracker: {
|
||||
store: {
|
||||
subscribe: () => {},
|
||||
},
|
||||
},
|
||||
initState: {
|
||||
cachedBalances: {
|
||||
17: {
|
||||
a: '0x1',
|
||||
b: '0x2',
|
||||
c: '0x3',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = controller._generateBalancesToCache({
|
||||
a: { balance: '0x4' },
|
||||
b: { balance: null },
|
||||
c: { balance: '0x5' },
|
||||
}, 16)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
17: {
|
||||
a: '0x1',
|
||||
b: '0x2',
|
||||
c: '0x3',
|
||||
},
|
||||
16: {
|
||||
a: '0x4',
|
||||
c: '0x5',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_registerUpdates', () => {
|
||||
it('should subscribe to the account tracker with the updateCachedBalances method', async () => {
|
||||
const subscribeSpy = sinon.spy()
|
||||
const controller = new CachedBalancesController({
|
||||
getNetwork: () => Promise.resolve(17),
|
||||
accountTracker: {
|
||||
store: {
|
||||
subscribe: subscribeSpy,
|
||||
},
|
||||
},
|
||||
})
|
||||
subscribeSpy.resetHistory()
|
||||
|
||||
const updateCachedBalancesSpy = sinon.spy()
|
||||
controller.updateCachedBalances = updateCachedBalancesSpy
|
||||
controller._registerUpdates({ accounts: 'mockAccounts' })
|
||||
|
||||
assert.equal(subscribeSpy.callCount, 1)
|
||||
|
||||
subscribeSpy.args[0][0]()
|
||||
|
||||
assert.equal(updateCachedBalancesSpy.callCount, 1)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
|
@ -358,7 +358,8 @@ describe('MetaMaskController', function () {
|
|||
let addNewAccountStub
|
||||
let getAccountsStub
|
||||
beforeEach(async function () {
|
||||
accountToUnlock = 10
|
||||
this.timeout(10000)
|
||||
accountToUnlock = 4
|
||||
windowOpenStub = sinon.stub(window, 'open')
|
||||
windowOpenStub.returns(noop)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import assert from 'assert'
|
||||
import SendContractError from '../../../../../../old-ui/app/components/send/send-error'
|
||||
import ErrorComponent from '../../../../../old-ui/app/components/error'
|
||||
import { mount } from 'enzyme'
|
||||
import thunk from 'redux-thunk'
|
||||
import configureMockStore from 'redux-mock-store'
|
||||
|
@ -16,12 +16,12 @@ const mockStore = configureMockStore(middlewares)
|
|||
const store = mockStore(state)
|
||||
let wrapper
|
||||
|
||||
describe('SendContractError component', () => {
|
||||
describe('renders SendContractError component', () => {
|
||||
describe('ErrorComponent', () => {
|
||||
describe('renders ErrorComponent', () => {
|
||||
beforeEach(function () {
|
||||
wrapper = mount(
|
||||
<Provider store={store}>
|
||||
<SendContractError error="Error!"/>
|
||||
<ErrorComponent error="Error!"/>
|
||||
</Provider>
|
||||
)
|
||||
})
|
||||
|
@ -30,11 +30,11 @@ describe('SendContractError component', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('doesn\'t render SendContractError component', () => {
|
||||
describe('doesn\'t render ErrorComponent component', () => {
|
||||
beforeEach(function () {
|
||||
wrapper = mount(
|
||||
<Provider store={store}>
|
||||
<SendContractError/>
|
||||
<ErrorComponent/>
|
||||
</Provider>
|
||||
)
|
||||
})
|
|
@ -12,6 +12,7 @@ describe('ChooseContractExecutor component', () => {
|
|||
metamask: {
|
||||
selectedAddress: '0x99a22ce737b6a48f44cad6331432ce98693cad07',
|
||||
accounts: ['0x99a22ce737b6a48f44cad6331432ce98693cad07'],
|
||||
cachedBalances: {'0x99a22ce737b6a48f44cad6331432ce98693cad07': 1},
|
||||
keyrings: [
|
||||
{
|
||||
'type': 'HD Key Tree',
|
||||
|
|
|
@ -11,6 +11,7 @@ const state = {
|
|||
metamask: {
|
||||
selectedAddress: '0x99a22ce737b6a48f44cad6331432ce98693cad07',
|
||||
accounts: ['0x99a22ce737b6a48f44cad6331432ce98693cad07'],
|
||||
cachedBalances: {'0x99a22ce737b6a48f44cad6331432ce98693cad07': 1},
|
||||
identities: {
|
||||
'0x99a22ce737b6a48f44cad6331432ce98693cad07': {
|
||||
name: 'Account 1',
|
||||
|
|
|
@ -91,6 +91,7 @@ var actions = {
|
|||
importNewAccount,
|
||||
addNewAccount,
|
||||
connectHardware,
|
||||
connectHardwareAndUnlockAddress,
|
||||
checkHardwareStatus,
|
||||
forgetDevice,
|
||||
unlockHardwareWalletAccount,
|
||||
|
@ -788,6 +789,23 @@ function connectHardware (deviceName, page, hdPath) {
|
|||
}
|
||||
}
|
||||
|
||||
function connectHardwareAndUnlockAddress (deviceName, hdPath, addressToUnlock) {
|
||||
log.debug(`background.connectHardwareAndUnlockAddress`, deviceName, hdPath, addressToUnlock)
|
||||
return (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
background.connectHardwareAndUnlockAddress(deviceName, hdPath, addressToUnlock, (err, accounts) => {
|
||||
if (err) {
|
||||
log.error(err)
|
||||
return reject(err)
|
||||
}
|
||||
|
||||
forceUpdateMetamaskState(dispatch)
|
||||
return resolve(accounts)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function unlockHardwareWalletAccount (index, deviceName, hdPath) {
|
||||
log.debug(`background.unlockHardwareWalletAccount`, index, deviceName, hdPath)
|
||||
return (dispatch, getState) => {
|
||||
|
|
|
@ -7,6 +7,7 @@ const h = require('react-hyperscript')
|
|||
const actions = require('./actions')
|
||||
const classnames = require('classnames')
|
||||
const log = require('loglevel')
|
||||
const { getMetaMaskAccounts } = require('./selectors')
|
||||
|
||||
// init
|
||||
const InitializeScreen = require('../../mascara/src/app/first-time').default
|
||||
|
@ -279,9 +280,10 @@ function mapStateToProps (state) {
|
|||
loadingMessage,
|
||||
} = appState
|
||||
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
|
||||
const {
|
||||
identities,
|
||||
accounts,
|
||||
address,
|
||||
keyrings,
|
||||
isInitialized,
|
||||
|
|
|
@ -13,6 +13,7 @@ const { getEnvironmentType } = require('../../../../app/scripts/lib/util')
|
|||
const Tooltip = require('../tooltip')
|
||||
import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display'
|
||||
import { PRIMARY } from '../../constants/common'
|
||||
import { getMetaMaskAccounts } from '../../selectors'
|
||||
|
||||
const {
|
||||
SETTINGS_ROUTE,
|
||||
|
@ -41,7 +42,7 @@ function mapStateToProps (state) {
|
|||
isAccountMenuOpen: state.metamask.isAccountMenuOpen,
|
||||
keyrings: state.metamask.keyrings,
|
||||
identities: state.metamask.identities,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts: getMetaMaskAccounts(state),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,14 +6,14 @@ const TokenBalance = require('./token-balance')
|
|||
const Identicon = require('./identicon')
|
||||
import UserPreferencedCurrencyDisplay from './user-preferenced-currency-display'
|
||||
import { PRIMARY, SECONDARY } from '../constants/common'
|
||||
const { getAssetImages, conversionRateSelector, getCurrentCurrency} = require('../selectors')
|
||||
const { getAssetImages, conversionRateSelector, getCurrentCurrency, getMetaMaskAccounts } = require('../selectors')
|
||||
|
||||
const { formatBalance } = require('../util')
|
||||
|
||||
module.exports = connect(mapStateToProps)(BalanceComponent)
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const accounts = state.metamask.accounts
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const network = state.metamask.network
|
||||
const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0]
|
||||
const account = accounts[selectedAddress]
|
||||
|
|
|
@ -18,6 +18,7 @@ import { isBalanceSufficient } from '../../send/send.utils'
|
|||
import { conversionGreaterThan } from '../../../conversion-util'
|
||||
import { MIN_GAS_LIMIT_DEC } from '../../send/send.constants'
|
||||
import { addressSlicer, valuesFor } from '../../../util'
|
||||
import { getMetaMaskAccounts } from '../../../selectors'
|
||||
|
||||
const casedContractMap = Object.keys(contractMap).reduce((acc, base) => {
|
||||
return {
|
||||
|
@ -47,11 +48,11 @@ const mapStateToProps = (state, props) => {
|
|||
} = confirmTransaction
|
||||
const { txParams = {}, lastGasPrice, id: transactionId } = txData
|
||||
const { from: fromAddress, to: txParamsToAddress } = txParams
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const {
|
||||
conversionRate,
|
||||
identities,
|
||||
currentCurrency,
|
||||
accounts,
|
||||
selectedAddress,
|
||||
selectedAddressTxList,
|
||||
assetImages,
|
||||
|
|
|
@ -3,6 +3,7 @@ const PropTypes = require('prop-types')
|
|||
const h = require('react-hyperscript')
|
||||
const connect = require('react-redux').connect
|
||||
const actions = require('../../../../actions')
|
||||
const { getMetaMaskAccounts } = require('../../../../selectors')
|
||||
const ConnectScreen = require('./connect-screen')
|
||||
const AccountList = require('./account-list')
|
||||
const { DEFAULT_ROUTE } = require('../../../../routes')
|
||||
|
@ -225,8 +226,9 @@ ConnectHardwareForm.propTypes = {
|
|||
|
||||
const mapStateToProps = state => {
|
||||
const {
|
||||
metamask: { network, selectedAddress, identities = {}, accounts = [] },
|
||||
metamask: { network, selectedAddress, identities = {} },
|
||||
} = state
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const numberOfExistingAccounts = Object.keys(identities).length
|
||||
const {
|
||||
appState: { defaultHdPaths },
|
||||
|
|
|
@ -7,6 +7,7 @@ const connect = require('react-redux').connect
|
|||
const actions = require('../../../../actions')
|
||||
const FileInput = require('react-simple-file-input').default
|
||||
const { DEFAULT_ROUTE } = require('../../../../routes')
|
||||
const { getMetaMaskAccounts } = require('../../../../selectors')
|
||||
const HELP_LINK = 'https://support.metamask.io/kb/article/7-importing-accounts'
|
||||
import Button from '../../../button'
|
||||
|
||||
|
@ -136,7 +137,7 @@ JsonImportSubview.propTypes = {
|
|||
const mapStateToProps = state => {
|
||||
return {
|
||||
error: state.appState.warning,
|
||||
firstAddress: Object.keys(state.metamask.accounts)[0],
|
||||
firstAddress: Object.keys(getMetaMaskAccounts(state))[0],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ const PropTypes = require('prop-types')
|
|||
const connect = require('react-redux').connect
|
||||
const actions = require('../../../../actions')
|
||||
const { DEFAULT_ROUTE } = require('../../../../routes')
|
||||
const { getMetaMaskAccounts } = require('../../../../selectors')
|
||||
import Button from '../../../button'
|
||||
|
||||
PrivateKeyImportView.contextTypes = {
|
||||
|
@ -22,7 +23,7 @@ module.exports = compose(
|
|||
function mapStateToProps (state) {
|
||||
return {
|
||||
error: state.appState.warning,
|
||||
firstAddress: Object.keys(state.metamask.accounts)[0],
|
||||
firstAddress: Object.keys(getMetaMaskAccounts(state))[0],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,9 @@ const abi = require('human-standard-token-abi')
|
|||
const {
|
||||
multiplyCurrencies,
|
||||
} = require('../../conversion-util')
|
||||
const {
|
||||
getMetaMaskAccounts,
|
||||
} = require('../../selectors')
|
||||
const {
|
||||
estimateGasPriceFromRecentBlocks,
|
||||
} = require('./send.utils')
|
||||
|
@ -53,10 +56,8 @@ const selectors = {
|
|||
module.exports = selectors
|
||||
|
||||
function accountsWithSendEtherInfoSelector (state) {
|
||||
const {
|
||||
accounts,
|
||||
identities,
|
||||
} = state.metamask
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const { identities } = state.metamask
|
||||
|
||||
const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => {
|
||||
return Object.assign({}, account, identities[key])
|
||||
|
@ -71,7 +72,7 @@ function accountsWithSendEtherInfoSelector (state) {
|
|||
// const autoAddTokensThreshold = 1
|
||||
|
||||
// const numberOfTransactions = state.metamask.selectedAddressTxList.length
|
||||
// const numberOfAccounts = Object.keys(state.metamask.accounts).length
|
||||
// const numberOfAccounts = Object.keys(getMetaMaskAccounts(state)).length
|
||||
// const numberOfTokensAdded = state.metamask.tokens.length
|
||||
|
||||
// const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) &&
|
||||
|
@ -150,14 +151,14 @@ function getRecentBlocks (state) {
|
|||
}
|
||||
|
||||
function getSelectedAccount (state) {
|
||||
const accounts = state.metamask.accounts
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const selectedAddress = getSelectedAddress(state)
|
||||
|
||||
return accounts[selectedAddress]
|
||||
}
|
||||
|
||||
function getSelectedAddress (state) {
|
||||
const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0]
|
||||
const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0]
|
||||
|
||||
return selectedAddress
|
||||
}
|
||||
|
|
|
@ -2,12 +2,18 @@ import { connect } from 'react-redux'
|
|||
import { withRouter } from 'react-router-dom'
|
||||
import { compose } from 'recompose'
|
||||
import TransactionViewBalance from './transaction-view-balance.component'
|
||||
import { getSelectedToken, getSelectedAddress, getSelectedTokenAssetImage } from '../../selectors'
|
||||
import {
|
||||
getSelectedToken,
|
||||
getSelectedAddress,
|
||||
getSelectedTokenAssetImage,
|
||||
getMetaMaskAccounts,
|
||||
} from '../../selectors'
|
||||
import { showModal } from '../../actions'
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const selectedAddress = getSelectedAddress(state)
|
||||
const { metamask: { network, accounts } } = state
|
||||
const { metamask: { network } } = state
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const account = accounts[selectedAddress]
|
||||
const { balance } = account
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ function mapStateToProps (state) {
|
|||
network: state.metamask.network,
|
||||
sidebarOpen: state.appState.sidebar.isOpen,
|
||||
identities: state.metamask.identities,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts: selectors.getMetaMaskAccounts(state),
|
||||
tokens: state.metamask.tokens,
|
||||
keyrings: state.metamask.keyrings,
|
||||
selectedAddress: selectors.getSelectedAddress(state),
|
||||
|
|
|
@ -12,6 +12,7 @@ const R = require('ramda')
|
|||
const SignatureRequest = require('./components/signature-request')
|
||||
const Loading = require('./components/loading-screen')
|
||||
const { DEFAULT_ROUTE } = require('./routes')
|
||||
const { getMetaMaskAccounts } = require('./selectors')
|
||||
|
||||
module.exports = compose(
|
||||
withRouter,
|
||||
|
@ -28,7 +29,7 @@ function mapStateToProps (state) {
|
|||
|
||||
return {
|
||||
identities: state.metamask.identities,
|
||||
accounts: state.metamask.accounts,
|
||||
accounts: getMetaMaskAccounts(state),
|
||||
selectedAddress: state.metamask.selectedAddress,
|
||||
unapprovedTxs: state.metamask.unapprovedTxs,
|
||||
unapprovedMsgs: state.metamask.unapprovedMsgs,
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
const abi = require('human-standard-token-abi')
|
||||
|
||||
import {
|
||||
transactionsSelector,
|
||||
} from './selectors/transactions'
|
||||
|
||||
const {
|
||||
multiplyCurrencies,
|
||||
} = require('./conversion-util')
|
||||
|
@ -34,12 +32,13 @@ const selectors = {
|
|||
getCurrentViewContext,
|
||||
getTotalUnapprovedCount,
|
||||
preferencesSelector,
|
||||
getMetaMaskAccounts,
|
||||
}
|
||||
|
||||
module.exports = selectors
|
||||
|
||||
function getSelectedAddress (state) {
|
||||
const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0]
|
||||
const selectedAddress = state.metamask.selectedAddress || Object.keys(getMetaMaskAccounts(state))[0]
|
||||
|
||||
return selectedAddress
|
||||
}
|
||||
|
@ -51,8 +50,27 @@ function getSelectedIdentity (state) {
|
|||
return identities[selectedAddress]
|
||||
}
|
||||
|
||||
function getMetaMaskAccounts (state) {
|
||||
const currentAccounts = state.metamask.accounts
|
||||
const cachedBalances = state.metamask.cachedBalances
|
||||
const selectedAccounts = {}
|
||||
|
||||
Object.keys(currentAccounts).forEach(accountID => {
|
||||
const account = currentAccounts[accountID]
|
||||
if (account && account.balance === null || account.balance === undefined) {
|
||||
selectedAccounts[accountID] = {
|
||||
...account,
|
||||
balance: cachedBalances[accountID],
|
||||
}
|
||||
} else {
|
||||
selectedAccounts[accountID] = account
|
||||
}
|
||||
})
|
||||
return selectedAccounts
|
||||
}
|
||||
|
||||
function getSelectedAccount (state) {
|
||||
const accounts = state.metamask.accounts
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const selectedAddress = getSelectedAddress(state)
|
||||
|
||||
return accounts[selectedAddress]
|
||||
|
@ -100,10 +118,8 @@ function getAddressBook (state) {
|
|||
}
|
||||
|
||||
function accountsWithSendEtherInfoSelector (state) {
|
||||
const {
|
||||
accounts,
|
||||
identities,
|
||||
} = state.metamask
|
||||
const accounts = getMetaMaskAccounts(state)
|
||||
const { identities } = state.metamask
|
||||
|
||||
const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => {
|
||||
return Object.assign({}, account, identities[key])
|
||||
|
@ -169,7 +185,7 @@ function autoAddToBetaUI (state) {
|
|||
const autoAddTokensThreshold = 1
|
||||
|
||||
const numberOfTransactions = state.metamask.selectedAddressTxList.length
|
||||
const numberOfAccounts = Object.keys(state.metamask.accounts).length
|
||||
const numberOfAccounts = Object.keys(getMetaMaskAccounts(state)).length
|
||||
const numberOfTokensAdded = state.metamask.tokens.length
|
||||
|
||||
const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) &&
|
||||
|
|
Loading…
Reference in New Issue