diff --git a/js/package.json b/js/package.json index a3d32cd429d..02d11a74a28 100644 --- a/js/package.json +++ b/js/package.json @@ -153,7 +153,7 @@ "debounce": "1.0.0", "es6-error": "4.0.0", "es6-promise": "4.0.5", - "ethereumjs-tx": "1.1.4", + "ethereumjs-tx": "1.2.5", "eventemitter3": "2.0.2", "file-saver": "1.3.3", "flat": "2.0.1", @@ -200,6 +200,8 @@ "scryptsy": "2.0.0", "solc": "ngotchac/solc-js", "store": "1.3.20", + "u2f-api": "0.0.9", + "u2f-api-polyfill": "0.4.3", "uglify-js": "2.8.2", "useragent.js": "0.5.6", "utf8": "2.1.2", diff --git a/js/src/3rdparty/ledger/index.js b/js/src/3rdparty/ledger/index.js index 3759b4bd57b..a5b876dcac8 100644 --- a/js/src/3rdparty/ledger/index.js +++ b/js/src/3rdparty/ledger/index.js @@ -14,12 +14,4 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -import Ledger3 from './vendor/ledger3'; -import LedgerEth from './vendor/ledger-eth'; - -export function create () { - const ledger = new Ledger3('w0w'); - const app = new LedgerEth(ledger); - - return app; -} +export default from './ledger'; diff --git a/js/src/3rdparty/ledger/ledger.js b/js/src/3rdparty/ledger/ledger.js new file mode 100644 index 00000000000..13a67199857 --- /dev/null +++ b/js/src/3rdparty/ledger/ledger.js @@ -0,0 +1,136 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import 'u2f-api-polyfill'; + +import BigNumber from 'bignumber.js'; +import Transaction from 'ethereumjs-tx'; +import u2fapi from 'u2f-api'; + +import Ledger3 from './vendor/ledger3'; +import LedgerEth from './vendor/ledger-eth'; + +const LEDGER_PATH_ETC = "44’/60’/160720'/0'/0"; +const LEDGER_PATH_ETH = "44'/60'/0'/0"; +const SCRAMBLE_KEY = 'w0w'; + +function numberToHex (number) { + return `0x${new BigNumber(number).toString(16)}`; +} + +export default class Ledger { + constructor (api, ledger) { + this._api = api; + this._ledger = ledger; + + this._isSupported = false; + + this.checkJSSupport(); + } + + // FIXME: Until we have https support from Parity u2f will not work. Here we mark it completely + // as unsupported until a full end-to-end environment is available. + get isSupported () { + return false && this._isSupported; + } + + checkJSSupport () { + return u2fapi + .isSupported() + .then((isSupported) => { + console.log('Ledger:checkJSSupport', isSupported); + + this._isSupported = isSupported; + }); + } + + getAppConfiguration () { + return new Promise((resolve, reject) => { + this._ledger.getAppConfiguration((response, error) => { + if (error) { + reject(error); + return; + } + + resolve(response); + }); + }); + } + + scan () { + return new Promise((resolve, reject) => { + this._ledger.getAddress(LEDGER_PATH_ETH, (response, error) => { + if (error) { + reject(error); + return; + } + + resolve([response.address]); + }, true, false); + }); + } + + signTransaction (transaction) { + return this._api.net.version().then((_chainId) => { + return new Promise((resolve, reject) => { + const chainId = new BigNumber(_chainId).toNumber(); + const tx = new Transaction({ + data: transaction.data || transaction.input, + gasPrice: numberToHex(transaction.gasPrice), + gasLimit: numberToHex(transaction.gasLimit), + nonce: numberToHex(transaction.nonce), + to: transaction.to ? transaction.to.toLowerCase() : undefined, + value: numberToHex(transaction.value), + v: Buffer.from([chainId]), // pass the chainId to the ledger + r: Buffer.from([]), + s: Buffer.from([]) + }); + const rawTransaction = tx.serialize().toString('hex'); + + this._ledger.signTransaction(LEDGER_PATH_ETH, rawTransaction, (response, error) => { + if (error) { + reject(error); + return; + } + + tx.v = Buffer.from(response.v, 'hex'); + tx.r = Buffer.from(response.r, 'hex'); + tx.s = Buffer.from(response.s, 'hex'); + + if (chainId !== Math.floor((tx.v[0] - 35) / 2)) { + reject(new Error('Invalid EIP155 signature received from Ledger.')); + return; + } + + resolve(`0x${tx.serialize().toString('hex')}`); + }); + }); + }); + } + + static create (api, ledger) { + if (!ledger) { + ledger = new LedgerEth(new Ledger3(SCRAMBLE_KEY)); + } + + return new Ledger(api, ledger); + } +} + +export { + LEDGER_PATH_ETC, + LEDGER_PATH_ETH +}; diff --git a/js/src/3rdparty/ledger/ledger.spec.js b/js/src/3rdparty/ledger/ledger.spec.js new file mode 100644 index 00000000000..406a4bfcd07 --- /dev/null +++ b/js/src/3rdparty/ledger/ledger.spec.js @@ -0,0 +1,120 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import sinon from 'sinon'; + +import Ledger from './'; + +const TEST_ADDRESS = '0x63Cf90D3f0410092FC0fca41846f596223979195'; + +let api; +let ledger; +let vendor; + +function createApi () { + api = { + net: { + version: sinon.stub().resolves('2') + } + }; + + return api; +} + +function createVendor (error = null) { + vendor = { + getAddress: (path, callback) => { + callback({ + address: TEST_ADDRESS + }, error); + }, + getAppConfiguration: (callback) => { + callback({}, error); + }, + signTransaction: (path, rawTransaction, callback) => { + callback({ + v: [39], + r: [0], + s: [0] + }, error); + } + }; + + return vendor; +} + +function create (error) { + ledger = new Ledger(createApi(), createVendor(error)); + + return ledger; +} + +describe('3rdparty/ledger', () => { + beforeEach(() => { + create(); + + sinon.spy(vendor, 'getAddress'); + sinon.spy(vendor, 'getAppConfiguration'); + sinon.spy(vendor, 'signTransaction'); + }); + + afterEach(() => { + vendor.getAddress.restore(); + vendor.getAppConfiguration.restore(); + vendor.signTransaction.restore(); + }); + + describe('getAppConfiguration', () => { + beforeEach(() => { + return ledger.getAppConfiguration(); + }); + + it('calls into getAppConfiguration', () => { + expect(vendor.getAppConfiguration).to.have.been.called; + }); + }); + + describe('scan', () => { + beforeEach(() => { + return ledger.scan(); + }); + + it('calls into getAddress', () => { + expect(vendor.getAddress).to.have.been.called; + }); + }); + + describe('signTransaction', () => { + beforeEach(() => { + return ledger.signTransaction({ + data: '0x0', + gasPrice: 20000000, + gasLimit: 1000000, + nonce: 2, + to: '0x63Cf90D3f0410092FC0fca41846f596223979195', + value: 1 + }); + }); + + it('retrieves chainId via API', () => { + expect(api.net.version).to.have.been.called; + }); + + it('calls into signTransaction', () => { + expect(vendor.signTransaction).to.have.been.called; + }); + }); +}); diff --git a/js/src/api/format/output.js b/js/src/api/format/output.js index 094cda25aeb..952002b606f 100644 --- a/js/src/api/format/output.js +++ b/js/src/api/format/output.js @@ -128,6 +128,18 @@ export function outLog (log) { return log; } +export function outHwAccountInfo (infos) { + return Object + .keys(infos) + .reduce((ret, _address) => { + const address = outAddress(_address); + + ret[address] = infos[_address]; + + return ret; + }, {}); +} + export function outNumber (number) { return new BigNumber(number || 0); } diff --git a/js/src/api/format/output.spec.js b/js/src/api/format/output.spec.js index 151353453c4..c2375167080 100644 --- a/js/src/api/format/output.spec.js +++ b/js/src/api/format/output.spec.js @@ -16,7 +16,7 @@ import BigNumber from 'bignumber.js'; -import { outBlock, outAccountInfo, outAddress, outChainStatus, outDate, outHistogram, outNumber, outPeer, outPeers, outReceipt, outRecentDapps, outSyncing, outTransaction, outTrace, outVaultMeta } from './output'; +import { outBlock, outAccountInfo, outAddress, outChainStatus, outDate, outHistogram, outHwAccountInfo, outNumber, outPeer, outPeers, outReceipt, outRecentDapps, outSyncing, outTransaction, outTrace, outVaultMeta } from './output'; import { isAddress, isBigNumber, isInstanceOf } from '../../../test/types'; describe('api/format/output', () => { @@ -163,6 +163,16 @@ describe('api/format/output', () => { }); }); + describe('outHwAccountInfo', () => { + it('returns objects with formatted addresses', () => { + expect(outHwAccountInfo( + { '0x63cf90d3f0410092fc0fca41846f596223979195': { manufacturer: 'mfg', name: 'type' } } + )).to.deep.equal({ + '0x63Cf90D3f0410092FC0fca41846f596223979195': { manufacturer: 'mfg', name: 'type' } + }); + }); + }); + describe('outNumber', () => { it('returns a BigNumber equalling the value', () => { const bn = outNumber('0x123456'); diff --git a/js/src/api/rpc/parity/parity.js b/js/src/api/rpc/parity/parity.js index 027d367d3e1..31de948dc3f 100644 --- a/js/src/api/rpc/parity/parity.js +++ b/js/src/api/rpc/parity/parity.js @@ -15,7 +15,7 @@ // along with Parity. If not, see . import { inAddress, inAddresses, inData, inHex, inNumber16, inOptions, inBlockNumber } from '../../format/input'; -import { outAccountInfo, outAddress, outAddresses, outChainStatus, outHistogram, outNumber, outPeers, outRecentDapps, outTransaction, outVaultMeta } from '../../format/output'; +import { outAccountInfo, outAddress, outAddresses, outChainStatus, outHistogram, outHwAccountInfo, outNumber, outPeers, outRecentDapps, outTransaction, outVaultMeta } from '../../format/output'; export default class Parity { constructor (transport) { @@ -200,6 +200,12 @@ export default class Parity { .then(outVaultMeta); } + hardwareAccountsInfo () { + return this._transport + .execute('parity_hardwareAccountsInfo') + .then(outHwAccountInfo); + } + hashContent (url) { return this._transport .execute('parity_hashContent', url); diff --git a/js/src/jsonrpc/interfaces/parity.js b/js/src/jsonrpc/interfaces/parity.js index 2fbec7aaa99..8aca8312fc3 100644 --- a/js/src/jsonrpc/interfaces/parity.js +++ b/js/src/jsonrpc/interfaces/parity.js @@ -393,6 +393,32 @@ export default { } }, + hardwareAccountsInfo: { + section: SECTION_ACCOUNTS, + desc: 'Provides metadata for attached hardware wallets', + params: [], + returns: { + type: Object, + desc: 'Maps account address to metadata.', + details: { + manufacturer: { + type: String, + desc: 'Manufacturer' + }, + name: { + type: String, + desc: 'Account name' + } + }, + example: { + '0x0024d0c7ab4c52f723f3aaf0872b9ea4406846a4': { + manufacturer: 'Ledger', + name: 'Nano S' + } + } + } + }, + listOpenedVaults: { desc: 'Returns a list of all opened vaults', params: [], diff --git a/js/src/mobx/hardwareStore.js b/js/src/mobx/hardwareStore.js new file mode 100644 index 00000000000..65213ad4e1a --- /dev/null +++ b/js/src/mobx/hardwareStore.js @@ -0,0 +1,159 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { action, computed, observable, transaction } from 'mobx'; + +import Ledger from '~/3rdparty/ledger'; + +const HW_SCAN_INTERVAL = 5000; +let instance = null; + +export default class HardwareStore { + @observable isScanning = false; + @observable wallets = {}; + + constructor (api) { + this._api = api; + this._ledger = Ledger.create(api); + this._pollId = null; + + this._pollScan(); + } + + isConnected (address) { + return computed(() => !!this.wallets[address]).get(); + } + + @action setScanning = (isScanning) => { + this.isScanning = isScanning; + } + + @action setWallets = (wallets) => { + this.wallets = wallets; + } + + _pollScan = () => { + this._pollId = setTimeout(() => { + this.scan().then(this._pollScan); + }, HW_SCAN_INTERVAL); + } + + scanLedger () { + if (!this._ledger.isSupported) { + return Promise.resolve({}); + } + + return this._ledger + .scan() + .then((wallets) => { + console.log('HardwareStore::scanLedger', wallets); + + return wallets.reduce((hwInfo, wallet) => { + wallet.manufacturer = 'Ledger'; + wallet.name = 'Nano S'; + wallet.via = 'ledger'; + + hwInfo[wallet.address] = wallet; + + return hwInfo; + }, {}); + }) + .catch((error) => { + console.warn('HardwareStore::scanLedger', error); + + return {}; + }); + } + + scanParity () { + return this._api.parity + .hardwareAccountsInfo() + .then((hwInfo) => { + Object + .keys(hwInfo) + .forEach((address) => { + const info = hwInfo[address]; + + info.address = address; + info.via = 'parity'; + }); + + return hwInfo; + }) + .catch((error) => { + console.warn('HardwareStore::scanParity', error); + + return {}; + }); + } + + scan () { + this.setScanning(true); + + // NOTE: Depending on how the hardware is configured and how the local env setup + // is done, different results will be retrieved via Parity vs. the browser APIs + // (latter is Chrome-only, needs the browser app enabled on a Ledger, former is + // not intended as a network call, i.e. hw wallet is with the user) + return Promise + .all([ + this.scanParity(), + this.scanLedger() + ]) + .then(([hwAccounts, ledgerAccounts]) => { + transaction(() => { + this.setWallets(Object.assign({}, hwAccounts, ledgerAccounts)); + this.setScanning(false); + }); + }); + } + + createAccountInfo (entry) { + const { address, manufacturer, name } = entry; + + return Promise + .all([ + this._api.parity.setAccountName(address, name), + this._api.parity.setAccountMeta(address, { + description: `${manufacturer} ${name}`, + hardware: { + manufacturer + }, + tags: ['hardware'], + timestamp: Date.now() + }) + ]) + .catch((error) => { + console.warn('HardwareStore::createEntry', error); + throw error; + }); + } + + signLedger (transaction) { + return this._ledger.signTransaction(transaction); + } + + static get (api) { + if (!instance) { + instance = new HardwareStore(api); + } + + return instance; + } +} + +export { + HW_SCAN_INTERVAL +}; diff --git a/js/src/mobx/hardwareStore.spec.js b/js/src/mobx/hardwareStore.spec.js new file mode 100644 index 00000000000..14feb57401a --- /dev/null +++ b/js/src/mobx/hardwareStore.spec.js @@ -0,0 +1,220 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import sinon from 'sinon'; + +import HardwareStore, { HW_SCAN_INTERVAL } from './hardwareStore'; + +const ADDRESS = '0x1234567890123456789012345678901234567890'; +const WALLET = { + address: ADDRESS, + name: 'testing' +}; + +let api; +let clock; +let ledger; +let store; + +function createApi () { + api = { + parity: { + hardwareAccountsInfo: sinon.stub().resolves({ ADDRESS: WALLET }), + setAccountMeta: sinon.stub().resolves(true), + setAccountName: sinon.stub().resolves(true) + } + }; + + return api; +} + +function createLedger () { + ledger = { + isSupported: true, + getAppConfiguration: sinon.stub().resolves(), + scan: sinon.stub().resolves(WALLET), + signTransaction: sinon.stub().resolves() + }; + + return ledger; +} + +function create () { + clock = sinon.useFakeTimers(); + store = new HardwareStore(createApi()); + store._ledger = createLedger(); + + return store; +} + +function teardown () { + clock.restore(); +} + +describe('mobx/HardwareStore', () => { + beforeEach(() => { + create(); + }); + + afterEach(() => { + teardown(); + }); + + describe('@computed', () => { + describe('isConnected', () => { + beforeEach(() => { + store.setWallets({ [ADDRESS]: WALLET }); + }); + + it('returns true for available', () => { + expect(store.isConnected(ADDRESS)).to.be.true; + }); + + it('returns false for non-available', () => { + expect(store.isConnected('nothing')).to.be.false; + }); + }); + }); + + describe('background polling', () => { + let pollId; + + beforeEach(() => { + pollId = store._pollId; + sinon.spy(store, 'scan'); + }); + + afterEach(() => { + store.scan.restore(); + }); + + it('starts the polling at creation', () => { + expect(pollId).not.to.be.null; + }); + + it('scans when timer elapsed', () => { + expect(store.scan).not.to.have.been.called; + clock.tick(HW_SCAN_INTERVAL + 1); + expect(store.scan).to.have.been.called; + }); + }); + + describe('@action', () => { + describe('setScanning', () => { + it('sets the flag', () => { + store.setScanning('testScanning'); + expect(store.isScanning).to.equal('testScanning'); + }); + }); + + describe('setWallets', () => { + it('sets the wallets', () => { + store.setWallets('testWallet'); + expect(store.wallets).to.equal('testWallet'); + }); + }); + }); + + describe('operations', () => { + describe('createAccountInfo', () => { + beforeEach(() => { + return store.createAccountInfo({ + address: 'testAddr', + manufacturer: 'testMfg', + name: 'testName' + }); + }); + + it('calls into parity_setAccountName', () => { + expect(api.parity.setAccountName).to.have.been.calledWith('testAddr', 'testName'); + }); + + it('calls into parity_setAccountMeta', () => { + expect(api.parity.setAccountMeta).to.have.been.calledWith('testAddr', sinon.match({ + description: 'testMfg testName', + hardware: { + manufacturer: 'testMfg' + } + })); + }); + }); + + describe('scanLedger', () => { + beforeEach(() => { + return store.scanLedger(); + }); + + it('calls scan on the Ledger APIs', () => { + expect(ledger.scan).to.have.been.called; + }); + }); + + describe('scanParity', () => { + beforeEach(() => { + return store.scanParity(); + }); + + it('calls parity_hardwareAccountsInfo', () => { + expect(api.parity.hardwareAccountsInfo).to.have.been.called; + }); + }); + + describe('scan', () => { + beforeEach(() => { + sinon.spy(store, 'setScanning'); + sinon.spy(store, 'setWallets'); + sinon.spy(store, 'scanLedger'); + sinon.spy(store, 'scanParity'); + + return store.scan(); + }); + + afterEach(() => { + store.setScanning.restore(); + store.setWallets.restore(); + store.scanLedger.restore(); + store.scanParity.restore(); + }); + + it('calls scanLedger', () => { + expect(store.scanLedger).to.have.been.called; + }); + + it('calls scanParity', () => { + expect(store.scanParity).to.have.been.called; + }); + + it('sets and resets the scanning state', () => { + expect(store.setScanning).to.have.been.calledWith(true); + expect(store.setScanning).to.have.been.calledWith(false); + }); + + it('sets the wallets', () => { + expect(store.setWallets).to.have.been.called; + }); + }); + + describe('signLedger', () => { + beforeEach(() => { + return store.signLedger('testTx'); + }); + + it('calls signTransaction on the ledger', () => { + expect(ledger.signTransaction).to.have.been.calledWith('testTx'); + }); + }); + }); +}); diff --git a/js/src/views/historyStore.js b/js/src/mobx/historyStore.js similarity index 100% rename from js/src/views/historyStore.js rename to js/src/mobx/historyStore.js diff --git a/js/src/views/historyStore.spec.js b/js/src/mobx/historyStore.spec.js similarity index 98% rename from js/src/views/historyStore.spec.js rename to js/src/mobx/historyStore.spec.js index 446aea68bd0..b791eb036d2 100644 --- a/js/src/views/historyStore.spec.js +++ b/js/src/mobx/historyStore.spec.js @@ -29,7 +29,7 @@ function create () { return store; } -describe('views/HistoryStore', () => { +describe('mobx/HistoryStore', () => { beforeEach(() => { create(); }); diff --git a/js/src/redux/providers/personalActions.js b/js/src/redux/providers/personalActions.js index f73e3ae42a1..01ba13b5306 100644 --- a/js/src/redux/providers/personalActions.js +++ b/js/src/redux/providers/personalActions.js @@ -30,6 +30,7 @@ export function personalAccountsInfo (accountsInfo) { const accounts = {}; const contacts = {}; const contracts = {}; + const hardware = {}; const wallets = {}; Object.keys(accountsInfo || {}) @@ -43,7 +44,12 @@ export function personalAccountsInfo (accountsInfo) { account.wallet = true; wallets[account.address] = account; } else if (account.meta.contract) { + account.contract = true; contracts[account.address] = account; + } else if (account.meta.hardware) { + account.hardware = true; + hardware[account.address] = account; + accounts[account.address] = account; } else { contacts[account.address] = account; } @@ -93,12 +99,13 @@ export function personalAccountsInfo (accountsInfo) { } }); - const data = { + dispatch(_personalAccountsInfo({ accountsInfo, - accounts, contacts, contracts - }; - - dispatch(_personalAccountsInfo(data)); + accounts, + contacts, + contracts, + hardware + })); dispatch(attachWallets(wallets)); BalancesProvider.get().fetchAllBalances({ diff --git a/js/src/redux/providers/personalReducer.js b/js/src/redux/providers/personalReducer.js index 9f5f2dfbc20..ba13ef0035b 100644 --- a/js/src/redux/providers/personalReducer.js +++ b/js/src/redux/providers/personalReducer.js @@ -14,33 +14,36 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -import { handleActions } from 'redux-actions'; import { isEqual } from 'lodash'; +import { handleActions } from 'redux-actions'; const initialState = { accountsInfo: {}, accounts: {}, - hasAccounts: false, contacts: {}, - hasContacts: false, contracts: {}, + hardware: {}, + hasAccounts: false, + hasContacts: false, hasContracts: false, + hasHardware: false, visibleAccounts: [] }; export default handleActions({ personalAccountsInfo (state, action) { const accountsInfo = action.accountsInfo || state.accountsInfo; - const { accounts, contacts, contracts } = action; + const { accounts, contacts, contracts, hardware } = action; return Object.assign({}, state, { accountsInfo, accounts, - hasAccounts: Object.keys(accounts).length !== 0, contacts, - hasContacts: Object.keys(contacts).length !== 0, contracts, - hasContracts: Object.keys(contracts).length !== 0 + hasAccounts: Object.keys(accounts).length !== 0, + hasContacts: Object.keys(contacts).length !== 0, + hasContracts: Object.keys(contracts).length !== 0, + hasHardware: Object.keys(hardware).length !== 0 }); }, diff --git a/js/src/redux/providers/signerMiddleware.js b/js/src/redux/providers/signerMiddleware.js index 34aa569a518..3f427044e5b 100644 --- a/js/src/redux/providers/signerMiddleware.js +++ b/js/src/redux/providers/signerMiddleware.js @@ -17,11 +17,13 @@ import * as actions from './signerActions'; import { inHex } from '~/api/format/input'; -import { Signer } from '../../util/signer'; +import HardwareStore from '~/mobx/hardwareStore'; +import { Signer } from '~/util/signer'; export default class SignerMiddleware { constructor (api) { this._api = api; + this._hwstore = HardwareStore.get(api); } toMiddleware () { @@ -51,11 +53,9 @@ export default class SignerMiddleware { }; } - onConfirmStart = (store, action) => { - const { condition, gas = 0, gasPrice = 0, id, password, payload, wallet } = action.payload; - - const handlePromise = (promise) => { - promise + _createConfirmPromiseHandler (store, id) { + return (promise) => { + return promise .then((txHash) => { if (!txHash) { store.dispatch(actions.errorConfirmRequest({ id, err: 'Unable to confirm.' })); @@ -69,62 +69,100 @@ export default class SignerMiddleware { store.dispatch(actions.errorConfirmRequest({ id, err: error.message })); }); }; + } - // Sign request in-browser - const transaction = payload.sendTransaction || payload.signTransaction; + createNoncePromise (transaction) { + return !transaction.nonce || transaction.nonce.isZero() + ? this._api.parity.nextNonce(transaction.from) + : Promise.resolve(transaction.nonce); + } - if (wallet && transaction) { - const noncePromise = transaction.nonce.isZero() - ? this._api.parity.nextNonce(transaction.from) - : Promise.resolve(transaction.nonce); - - const { worker } = store.getState().worker; - - const signerPromise = worker && worker._worker.state === 'activated' - ? worker - .postMessage({ - action: 'getSignerSeed', - data: { wallet, password } - }) - .then((result) => { - const seed = Buffer.from(result.data); - - return new Signer(seed); - }) - : Signer.fromJson(wallet, password); - - // NOTE: Derving the key takes significant amount of time, - // make sure to display some kind of "in-progress" state. - return Promise - .all([ signerPromise, noncePromise ]) - .then(([ signer, nonce ]) => { - const txData = { - to: inHex(transaction.to), - nonce: inHex(transaction.nonce.isZero() ? nonce : transaction.nonce), - gasPrice: inHex(transaction.gasPrice), - gasLimit: inHex(transaction.gas), - value: inHex(transaction.value), - data: inHex(transaction.data) - }; - - return signer.signTransaction(txData); + confirmLedgerTransaction (store, id, transaction) { + return this + .createNoncePromise(transaction) + .then((nonce) => { + transaction.nonce = nonce; + + return this._hwstore.signLedger(transaction); + }) + .then((rawTx) => { + const handlePromise = this._createConfirmPromiseHandler(store, id); + + return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx)); + }); + } + + confirmWalletTransaction (store, id, transaction, wallet, password) { + const handlePromise = this._createConfirmPromiseHandler(store, id); + const { worker } = store.getState().worker; + + const signerPromise = worker && worker._worker.state === 'activated' + ? worker + .postMessage({ + action: 'getSignerSeed', + data: { wallet, password } }) - .then((rawTx) => { - return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx)); + .then((result) => { + const seed = Buffer.from(result.data); + + return new Signer(seed); }) - .catch((error) => { - console.error(error.message); - store.dispatch(actions.errorConfirmRequest({ id, err: error.message })); - }); + : Signer.fromJson(wallet, password); + + // NOTE: Derving the key takes significant amount of time, + // make sure to display some kind of "in-progress" state. + return Promise + .all([ signerPromise, this.createNoncePromise(transaction) ]) + .then(([ signer, nonce ]) => { + const txData = { + to: inHex(transaction.to), + nonce: inHex(transaction.nonce.isZero() ? nonce : transaction.nonce), + gasPrice: inHex(transaction.gasPrice), + gasLimit: inHex(transaction.gas), + value: inHex(transaction.value), + data: inHex(transaction.data) + }; + + return signer.signTransaction(txData); + }) + .then((rawTx) => { + return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx)); + }) + .catch((error) => { + console.error(error.message); + store.dispatch(actions.errorConfirmRequest({ id, err: error.message })); + }); + } + + onConfirmStart = (store, action) => { + const { condition, gas = 0, gasPrice = 0, id, password, payload, wallet } = action.payload; + const handlePromise = this._createConfirmPromiseHandler(store, id); + const transaction = payload.sendTransaction || payload.signTransaction; + + if (transaction) { + const hardwareAccount = this._hwstore.wallets[transaction.from]; + + if (wallet) { + return this.confirmWalletTransaction(store, id, transaction, wallet, password); + } else if (hardwareAccount) { + switch (hardwareAccount.via) { + case 'ledger': + return this.confirmLedgerTransaction(store, id, transaction); + + case 'parity': + default: + break; + } + } } - handlePromise(this._api.signer.confirmRequest(id, { gas, gasPrice, condition }, password)); + return handlePromise(this._api.signer.confirmRequest(id, { gas, gasPrice, condition }, password)); } onRejectStart = (store, action) => { const id = action.payload; - this._api.signer + return this._api.signer .rejectRequest(id) .then(() => { store.dispatch(actions.successRejectRequest({ id })); diff --git a/js/src/redux/providers/signerMiddleware.spec.js b/js/src/redux/providers/signerMiddleware.spec.js new file mode 100644 index 00000000000..1dcc19d7524 --- /dev/null +++ b/js/src/redux/providers/signerMiddleware.spec.js @@ -0,0 +1,221 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import BigNumber from 'bignumber.js'; +import sinon from 'sinon'; + +import SignerMiddleware from './signerMiddleware'; + +const ADDRESS = '0x3456789012345678901234567890123456789012'; +const RAW_SIGNED = 'testSignResponse'; +const NONCE = new BigNumber(0x123454321); +const TRANSACTION = { + from: ADDRESS, + nonce: NONCE +}; +const PAYLOAD = { + condition: 'testCondition', + gas: 'testGas', + gasPrice: 'testGasPrice', + id: 'testId', + password: 'testPassword', + payload: { + sendTransaction: TRANSACTION + } +}; +const ACTION = { + payload: PAYLOAD +}; + +let api; +let clock; +let hwstore; +let middleware; +let store; + +function createApi () { + api = { + net: { + version: sinon.stub().resolves('2') + }, + parity: { + nextNonce: sinon.stub().resolves(NONCE) + }, + signer: { + confirmRequest: sinon.stub().resolves(true), + confirmRequestRaw: sinon.stub().resolves(true), + rejectRequest: sinon.stub().resolves(true) + } + }; + + return api; +} + +function createHwStore () { + hwstore = { + signLedger: sinon.stub().resolves(RAW_SIGNED), + wallets: { + [ADDRESS]: { + address: ADDRESS, + via: 'ledger' + } + } + }; + + return hwstore; +} + +function createRedux () { + return { + dispatch: sinon.stub(), + getState: () => { + return { + worker: { + worker: null + } + }; + } + }; +} + +function create () { + clock = sinon.useFakeTimers(); + store = createRedux(); + middleware = new SignerMiddleware(createApi()); + + return middleware; +} + +function teardown () { + clock.restore(); +} + +describe('redux/SignerMiddleware', () => { + beforeEach(() => { + create(); + }); + + afterEach(() => { + teardown(); + }); + + describe('createNoncePromise', () => { + it('resolves via transaction.nonce when available', () => { + const nonce = new BigNumber('0xabc'); + + return middleware.createNoncePromise({ nonce }).then((_nonce) => { + expect(_nonce).to.equal(nonce); + }); + }); + + it('calls parity_nextNonce', () => { + return middleware.createNoncePromise({ from: 'testing' }).then((nonce) => { + expect(api.parity.nextNonce).to.have.been.calledWith('testing'); + expect(nonce).to.equal(NONCE); + }); + }); + }); + + describe('confirmLedgerTransaction', () => { + beforeEach(() => { + sinon.spy(middleware, 'createNoncePromise'); + middleware._hwstore = createHwStore(); + + return middleware.confirmLedgerTransaction(store, PAYLOAD.id, TRANSACTION); + }); + + afterEach(() => { + middleware.createNoncePromise.restore(); + }); + + it('creates nonce via createNoncePromise', () => { + expect(middleware.createNoncePromise).to.have.been.calledWith(TRANSACTION); + }); + + it('calls into hardware signLedger', () => { + expect(hwstore.signLedger).to.have.been.calledWith(TRANSACTION); + }); + + it('confirms via signer_confirmRequestRaw', () => { + expect(api.signer.confirmRequestRaw).to.have.been.calledWith(PAYLOAD.id, RAW_SIGNED); + }); + }); + + describe('onConfirmStart', () => { + describe('normal accounts', () => { + beforeEach(() => { + return middleware.onConfirmStart(store, ACTION); + }); + + it('calls into signer_confirmRequest', () => { + expect(api.signer.confirmRequest).to.have.been.calledWith( + PAYLOAD.id, + { + condition: PAYLOAD.condition, + gas: PAYLOAD.gas, + gasPrice: PAYLOAD.gasPrice + }, + PAYLOAD.password + ); + }); + }); + + describe('hardware accounts', () => { + beforeEach(() => { + sinon.spy(middleware, 'confirmLedgerTransaction'); + middleware._hwstore = createHwStore(); + + return middleware.onConfirmStart(store, ACTION); + }); + + afterEach(() => { + middleware.confirmLedgerTransaction.restore(); + }); + + it('calls out to confirmLedgerTransaction', () => { + expect(middleware.confirmLedgerTransaction).to.have.been.called; + }); + }); + + describe('json wallet accounts', () => { + beforeEach(() => { + sinon.spy(middleware, 'confirmWalletTransaction'); + + return middleware.onConfirmStart(store, { + payload: Object.assign({}, PAYLOAD, { wallet: 'testWallet' }) + }); + }); + + afterEach(() => { + middleware.confirmWalletTransaction.restore(); + }); + + it('calls out to confirmWalletTransaction', () => { + expect(middleware.confirmWalletTransaction).to.have.been.called; + }); + }); + }); + + describe('onRejectStart', () => { + beforeEach(() => { + return middleware.onRejectStart(store, { payload: 'testId' }); + }); + + it('calls into signer_rejectRequest', () => { + expect(api.signer.rejectRequest).to.have.been.calledWith('testId'); + }); + }); +}); diff --git a/js/src/routes.js b/js/src/routes.js index cc2c876ee81..72097ae627d 100644 --- a/js/src/routes.js +++ b/js/src/routes.js @@ -14,9 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import HistoryStore from '~/mobx/historyStore'; import { Accounts, Account, Addresses, Address, Application, - Contract, Contracts, Dapp, Dapps, HistoryStore, Home, + Contract, Contracts, Dapp, Dapps, Home, Settings, SettingsBackground, SettingsParity, SettingsProxy, SettingsViews, Signer, Status, Vaults, Wallet, Web, WriteContract diff --git a/js/src/ui/Actionbar/actionbar.css b/js/src/ui/Actionbar/actionbar.css index ec740e635c5..0fcd4450fed 100644 --- a/js/src/ui/Actionbar/actionbar.css +++ b/js/src/ui/Actionbar/actionbar.css @@ -14,6 +14,7 @@ /* You should have received a copy of the GNU General Public License /* along with Parity. If not, see . */ + .actionbar { padding: 0 24px !important; margin-bottom: 0; @@ -31,10 +32,13 @@ button { margin: 10px 0 10px 16px !important; - color: white !important; - } - svg { - fill: white !important; + &:not([disabled]) { + color: white !important; + + svg { + fill: white !important; + } + } } } diff --git a/js/src/ui/IdentityIcon/identityIcon.css b/js/src/ui/IdentityIcon/identityIcon.css index a651568f5aa..b995b5aad14 100644 --- a/js/src/ui/IdentityIcon/identityIcon.css +++ b/js/src/ui/IdentityIcon/identityIcon.css @@ -14,9 +14,15 @@ /* You should have received a copy of the GNU General Public License /* along with Parity. If not, see . */ + .icon { border-radius: 50%; margin: 0; + + &.disabled { + filter: grayscale(100%); + opacity: 0.33; + } } .center { diff --git a/js/src/ui/IdentityIcon/identityIcon.js b/js/src/ui/IdentityIcon/identityIcon.js index aea7e941b5d..3db1bc76310 100644 --- a/js/src/ui/IdentityIcon/identityIcon.js +++ b/js/src/ui/IdentityIcon/identityIcon.js @@ -33,6 +33,7 @@ class IdentityIcon extends Component { button: PropTypes.bool, center: PropTypes.bool, className: PropTypes.string, + disabled: PropTypes.bool, images: PropTypes.object.isRequired, inline: PropTypes.bool, padded: PropTypes.bool, @@ -83,10 +84,11 @@ class IdentityIcon extends Component { } render () { - const { address, button, className, center, inline, padded, tiny } = this.props; + const { address, button, className, center, disabled, inline, padded, tiny } = this.props; const { iconsrc } = this.state; const classes = [ styles.icon, + disabled ? styles.disabled : '', tiny ? styles.tiny : '', button ? styles.button : '', center ? styles.center : styles.left, diff --git a/js/src/views/Account/Header/header.js b/js/src/views/Account/Header/header.js index e92f545a167..dc367d1366a 100644 --- a/js/src/views/Account/Header/header.js +++ b/js/src/views/Account/Header/header.js @@ -27,6 +27,7 @@ export default class Header extends Component { balance: PropTypes.object, children: PropTypes.node, className: PropTypes.string, + disabled: PropTypes.bool, hideName: PropTypes.bool, isContract: PropTypes.bool }; @@ -39,7 +40,7 @@ export default class Header extends Component { }; render () { - const { account, balance, children, className, hideName } = this.props; + const { account, balance, children, className, disabled, hideName } = this.props; if (!account) { return null; @@ -58,12 +59,15 @@ export default class Header extends Component {
{ this.renderName() }
-
{ address }
+
+ { address } +
{ this.renderVault() } { this.renderUuid() } diff --git a/js/src/views/Account/account.js b/js/src/views/Account/account.js index 4e457c94da8..197bda59146 100644 --- a/js/src/views/Account/account.js +++ b/js/src/views/Account/account.js @@ -21,12 +21,15 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import shapeshiftBtn from '~/../assets/images/shapeshift-btn.png'; +import HardwareStore from '~/mobx/hardwareStore'; import { EditMeta, DeleteAccount, Shapeshift, Verification, Transfer, PasswordManager } from '~/modals'; import { setVisibleAccounts } from '~/redux/providers/personalActions'; import { fetchCertifiers, fetchCertifications } from '~/redux/providers/certifications/actions'; import { Actionbar, Button, Page } from '~/ui'; import { DeleteIcon, EditIcon, LockedIcon, SendIcon, VerifyIcon } from '~/ui/Icons'; +import DeleteAddress from '../Address/Delete'; + import Header from './Header'; import Store from './store'; import Transactions from './Transactions'; @@ -34,6 +37,10 @@ import styles from './account.css'; @observer class Account extends Component { + static contextTypes = { + api: PropTypes.object.isRequired + }; + static propTypes = { fetchCertifiers: PropTypes.func.isRequired, fetchCertifications: PropTypes.func.isRequired, @@ -45,6 +52,7 @@ class Account extends Component { } store = new Store(); + hwstore = HardwareStore.get(this.context.api); componentDidMount () { this.props.fetchCertifiers(); @@ -83,6 +91,8 @@ class Account extends Component { return null; } + const isAvailable = !account.hardware || this.hwstore.isConnected(address); + return (
{ this.renderDeleteDialog(account) } @@ -91,11 +101,12 @@ class Account extends Component { { this.renderPasswordDialog(account) } { this.renderTransferDialog(account, balance) } { this.renderVerificationDialog() } - { this.renderActionbar(balance) } + { this.renderActionbar(account, balance) }
, -