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) }
,
- }
- key='passwordManager'
- label={
-
- }
- onClick={ this.store.togglePasswordDialog }
- />,
+ !account.hardware && (
+ }
+ key='passwordManager'
+ label={
+
+ }
+ onClick={ this.store.togglePasswordDialog }
+ />
+ ),
}
key='delete'
@@ -202,6 +215,23 @@ class Account extends Component {
return null;
}
+ if (account.hardware) {
+ return (
+
+ }
+ visible
+ route='/accounts'
+ onClose={ this.store.toggleDeleteDialog }
+ />
+ );
+ }
+
return (
,
- { context: { store: createRedux() } }
+ {
+ context: {
+ store: createRedux()
+ }
+ }
).find('Account').shallow();
instance = component.instance();
store = instance.store;
@@ -133,14 +138,14 @@ describe('views/Account', () => {
render();
expect(store.isDeleteVisible).to.be.false;
- expect(instance.renderDeleteDialog()).to.be.null;
+ expect(instance.renderDeleteDialog(ACCOUNTS[ADDRESS])).to.be.null;
});
it('renders the modal when visible', () => {
render();
store.toggleDeleteDialog();
- expect(instance.renderDeleteDialog().type).to.match(/Connect/);
+ expect(instance.renderDeleteDialog(ACCOUNTS[ADDRESS]).type).to.match(/Connect/);
});
});
@@ -149,14 +154,14 @@ describe('views/Account', () => {
render();
expect(store.isEditVisible).to.be.false;
- expect(instance.renderEditDialog()).to.be.null;
+ expect(instance.renderEditDialog(ACCOUNTS[ADDRESS])).to.be.null;
});
it('renders the modal when visible', () => {
render();
store.toggleEditDialog();
- expect(instance.renderEditDialog({ address: ADDRESS }).type).to.match(/Connect/);
+ expect(instance.renderEditDialog(ACCOUNTS[ADDRESS]).type).to.match(/Connect/);
});
});
diff --git a/js/src/views/Account/account.test.js b/js/src/views/Account/account.test.js
index 07095f2107e..8683465fb3f 100644
--- a/js/src/views/Account/account.test.js
+++ b/js/src/views/Account/account.test.js
@@ -17,6 +17,11 @@
import sinon from 'sinon';
const ADDRESS = '0x0123456789012345678901234567890123456789';
+const ACCOUNTS = {
+ [ADDRESS]: {
+ address: ADDRESS
+ }
+};
function createRedux () {
return {
@@ -47,6 +52,7 @@ function createRedux () {
}
export {
+ ACCOUNTS,
ADDRESS,
createRedux
};
diff --git a/js/src/views/Accounts/List/list.js b/js/src/views/Accounts/List/list.js
index 89d51ac5bcd..edd90db7b95 100644
--- a/js/src/views/Accounts/List/list.js
+++ b/js/src/views/Accounts/List/list.js
@@ -29,6 +29,7 @@ class List extends Component {
accounts: PropTypes.object,
balances: PropTypes.object,
certifications: PropTypes.object.isRequired,
+ disabled: PropTypes.object,
empty: PropTypes.bool,
link: PropTypes.string,
order: PropTypes.string,
@@ -50,7 +51,7 @@ class List extends Component {
}
render () {
- const { accounts, balances, empty } = this.props;
+ const { accounts, balances, disabled, empty } = this.props;
if (empty) {
return (
@@ -64,14 +65,16 @@ class List extends Component {
const addresses = this
.getAddresses()
- .map((address, idx) => {
+ .map((address) => {
const account = accounts[address] || {};
const balance = balances[address] || {};
+ const isDisabled = disabled ? disabled[address] : false;
const owners = account.owners || null;
return {
account,
balance,
+ isDisabled,
owners
};
});
@@ -85,13 +88,14 @@ class List extends Component {
}
renderSummary = (item) => {
- const { account, balance, owners } = item;
+ const { account, balance, isDisabled, owners } = item;
const { handleAddSearchToken, link } = this.props;
return (
.
-import { uniq, isEqual, pickBy, omitBy } from 'lodash';
+import { observe } from 'mobx';
+import { observer } from 'mobx-react';
+import { uniq, isEqual, pickBy } from 'lodash';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { bindActionCreators } from 'redux';
-import List from './List';
+import HardwareStore from '~/mobx/hardwareStore';
import { CreateAccount, CreateWallet } from '~/modals';
import { Actionbar, ActionbarExport, ActionbarSearch, ActionbarSort, Button, Page, Tooltip } from '~/ui';
import { AddIcon, KeyIcon } from '~/ui/Icons';
import { setVisibleAccounts } from '~/redux/providers/personalActions';
+import List from './List';
import styles from './accounts.css';
+@observer
class Accounts extends Component {
static contextTypes = {
api: PropTypes.object
}
static propTypes = {
- setVisibleAccounts: PropTypes.func.isRequired,
accounts: PropTypes.object.isRequired,
+ accountsInfo: PropTypes.object.isRequired,
+ balances: PropTypes.object,
hasAccounts: PropTypes.bool.isRequired,
- balances: PropTypes.object
+ setVisibleAccounts: PropTypes.func.isRequired
}
+ hwstore = HardwareStore.get(this.context.api);
+
state = {
+ _observeCancel: null,
addressBook: false,
newDialog: false,
newWalletDialog: false,
@@ -58,6 +66,10 @@ class Accounts extends Component {
}, 100);
this.setVisibleAccounts();
+
+ this.setState({
+ _observeCancel: observe(this.hwstore, 'wallets', this.onHardwareChange, true)
+ });
}
componentWillReceiveProps (nextProps) {
@@ -71,13 +83,13 @@ class Accounts extends Component {
componentWillUnmount () {
this.props.setVisibleAccounts([]);
+ this.state._observeCancel();
}
setVisibleAccounts (props = this.props) {
const { accounts, setVisibleAccounts } = props;
- const addresses = Object.keys(accounts);
- setVisibleAccounts(addresses);
+ setVisibleAccounts(Object.keys(accounts));
}
render () {
@@ -98,6 +110,7 @@ class Accounts extends Component {
}
/>
+ { this.renderHwWallets() }
{ this.renderWallets() }
{ this.renderAccounts() }
@@ -121,8 +134,7 @@ class Accounts extends Component {
renderAccounts () {
const { accounts, balances } = this.props;
-
- const _accounts = omitBy(accounts, (a) => a.wallet);
+ const _accounts = pickBy(accounts, (account) => account.uuid);
const _hasAccounts = Object.keys(_accounts).length > 0;
if (!this.state.show) {
@@ -145,27 +157,60 @@ class Accounts extends Component {
renderWallets () {
const { accounts, balances } = this.props;
-
- const wallets = pickBy(accounts, (a) => a.wallet);
+ const wallets = pickBy(accounts, (account) => account.wallet);
const hasWallets = Object.keys(wallets).length > 0;
+ if (!hasWallets) {
+ return null;
+ }
+
if (!this.state.show) {
return this.renderLoading(wallets);
}
const { searchValues, sortOrder } = this.state;
- if (!wallets || Object.keys(wallets).length === 0) {
+ return (
+
+ );
+ }
+
+ renderHwWallets () {
+ const { accounts, balances } = this.props;
+ const { wallets } = this.hwstore;
+ const hardware = pickBy(accounts, (account) => account.hardware);
+ const hasHardware = Object.keys(hardware).length > 0;
+
+ if (!hasHardware) {
return null;
}
+ if (!this.state.show) {
+ return this.renderLoading(hardware);
+ }
+
+ const { searchValues, sortOrder } = this.state;
+ const disabled = Object
+ .keys(hardware)
+ .filter((address) => !wallets[address])
+ .reduce((result, address) => {
+ result[address] = true;
+ return result;
+ }, {});
+
return (
@@ -342,16 +387,29 @@ class Accounts extends Component {
onNewAccountUpdate = () => {
}
+
+ onHardwareChange = () => {
+ const { accountsInfo } = this.props;
+ const { wallets } = this.hwstore;
+
+ Object
+ .keys(wallets)
+ .filter((address) => !accountsInfo[address])
+ .forEach((address) => this.hwstore.createAccountInfo(wallets[address]));
+
+ this.setVisibleAccounts();
+ }
}
function mapStateToProps (state) {
- const { accounts, hasAccounts } = state.personal;
+ const { accounts, accountsInfo, hasAccounts } = state.personal;
const { balances } = state.balances;
return {
- accounts: accounts,
- hasAccounts: hasAccounts,
- balances
+ accounts,
+ accountsInfo,
+ balances,
+ hasAccounts
};
}
diff --git a/js/src/views/Address/Delete/delete.js b/js/src/views/Address/Delete/delete.js
index 8aa5f61113a..cf0380f2f69 100644
--- a/js/src/views/Address/Delete/delete.js
+++ b/js/src/views/Address/Delete/delete.js
@@ -35,13 +35,14 @@ class Delete extends Component {
address: PropTypes.string,
account: PropTypes.object,
+ confirmMessage: PropTypes.node,
visible: PropTypes.bool,
onClose: PropTypes.func,
newError: PropTypes.func
};
render () {
- const { account, visible } = this.props;
+ const { account, confirmMessage, visible } = this.props;
if (!visible) {
return null;
@@ -61,10 +62,14 @@ class Delete extends Component {
onConfirm={ this.onDeleteConfirmed }
>
-
+ {
+ confirmMessage || (
+
+ )
+ }
{
- const isAccount = accounts[address].uuid;
+ const account = accounts[address];
+ const isAccount = account.uuid || (account.meta && account.meta.hardware);
const isWhitelisted = !whitelist || whitelist.includes(address);
return isAccount && isWhitelisted;
diff --git a/js/src/views/Signer/components/Account/account.js b/js/src/views/Signer/components/Account/account.js
index 1a7197d1aeb..f3f4b66e3a2 100644
--- a/js/src/views/Signer/components/Account/account.js
+++ b/js/src/views/Signer/components/Account/account.js
@@ -23,8 +23,9 @@ import styles from './account.css';
export default class Account extends Component {
static propTypes = {
- className: PropTypes.string,
address: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ disabled: PropTypes.bool,
externalLink: PropTypes.string.isRequired,
isTest: PropTypes.bool.isRequired,
balance: PropTypes.object // eth BigNumber, not required since it mght take time to fetch
@@ -52,7 +53,7 @@ export default class Account extends Component {
}
render () {
- const { address, externalLink, isTest, className } = this.props;
+ const { address, disabled, externalLink, isTest, className } = this.props;
return (
@@ -63,6 +64,7 @@ export default class Account extends Component {
>
diff --git a/js/src/views/Signer/components/RequestPending/requestPending.js b/js/src/views/Signer/components/RequestPending/requestPending.js
index 2d745d26fab..181d12462f5 100644
--- a/js/src/views/Signer/components/RequestPending/requestPending.js
+++ b/js/src/views/Signer/components/RequestPending/requestPending.js
@@ -36,7 +36,7 @@ export default class RequestPending extends Component {
PropTypes.shape({ sign: PropTypes.object.isRequired }),
PropTypes.shape({ signTransaction: PropTypes.object.isRequired })
]).isRequired,
- store: PropTypes.object.isRequired
+ signerstore: PropTypes.object.isRequired
};
static defaultProps = {
@@ -44,15 +44,8 @@ export default class RequestPending extends Component {
isSending: false
};
- onConfirm = data => {
- const { onConfirm, payload } = this.props;
-
- data.payload = payload;
- onConfirm(data);
- };
-
render () {
- const { className, date, focus, gasLimit, id, isSending, isTest, onReject, payload, store, origin } = this.props;
+ const { className, date, focus, gasLimit, id, isSending, isTest, onReject, payload, signerstore, origin } = this.props;
if (payload.sign) {
const { sign } = payload;
@@ -70,7 +63,7 @@ export default class RequestPending extends Component {
onConfirm={ this.onConfirm }
onReject={ onReject }
origin={ origin }
- store={ store }
+ signerstore={ signerstore }
/>
);
}
@@ -90,7 +83,7 @@ export default class RequestPending extends Component {
onConfirm={ this.onConfirm }
onReject={ onReject }
origin={ origin }
- store={ store }
+ signerstore={ signerstore }
transaction={ transaction }
/>
);
@@ -99,4 +92,11 @@ export default class RequestPending extends Component {
console.error('RequestPending: Unknown payload', payload);
return null;
}
+
+ onConfirm = (data) => {
+ const { onConfirm, payload } = this.props;
+
+ data.payload = payload;
+ onConfirm(data);
+ };
}
diff --git a/js/src/views/Signer/components/RequestPending/requestPending.spec.js b/js/src/views/Signer/components/RequestPending/requestPending.spec.js
new file mode 100644
index 00000000000..e21662fcf3e
--- /dev/null
+++ b/js/src/views/Signer/components/RequestPending/requestPending.spec.js
@@ -0,0 +1,112 @@
+// 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 { shallow } from 'enzyme';
+import React from 'react';
+import sinon from 'sinon';
+
+import RequestPending from './';
+
+const ADDRESS = '0x1234567890123456789012345678901234567890';
+const TRANSACTION = {
+ from: ADDRESS,
+ gas: new BigNumber(21000),
+ gasPrice: new BigNumber(20000000),
+ value: new BigNumber(1)
+};
+const PAYLOAD_SENDTX = {
+ sendTransaction: TRANSACTION
+};
+const PAYLOAD_SIGN = {
+ sign: {
+ address: ADDRESS,
+ data: 'testing'
+ }
+};
+const PAYLOAD_SIGNTX = {
+ signTransaction: TRANSACTION
+};
+
+let component;
+let onConfirm;
+let onReject;
+
+function render (payload) {
+ onConfirm = sinon.stub();
+ onReject = sinon.stub();
+
+ component = shallow(
+
+ );
+
+ return component;
+}
+
+describe('views/Signer/RequestPending', () => {
+ describe('sendTransaction', () => {
+ beforeEach(() => {
+ render(PAYLOAD_SENDTX);
+ });
+
+ it('renders defaults', () => {
+ expect(component).to.be.ok;
+ });
+
+ it('renders TransactionPending component', () => {
+ expect(component.find('Connect(TransactionPending)')).to.have.length(1);
+ });
+ });
+
+ describe('sign', () => {
+ beforeEach(() => {
+ render(PAYLOAD_SIGN);
+ });
+
+ it('renders defaults', () => {
+ expect(component).to.be.ok;
+ });
+
+ it('renders SignRequest component', () => {
+ expect(component.find('SignRequest')).to.have.length(1);
+ });
+ });
+
+ describe('signTransaction', () => {
+ beforeEach(() => {
+ render(PAYLOAD_SIGNTX);
+ });
+
+ it('renders defaults', () => {
+ expect(component).to.be.ok;
+ });
+
+ it('renders TransactionPending component', () => {
+ expect(component.find('Connect(TransactionPending)')).to.have.length(1);
+ });
+ });
+});
diff --git a/js/src/views/Signer/components/SignRequest/signRequest.js b/js/src/views/Signer/components/SignRequest/signRequest.js
index f3210c6fa3a..cc235aa581f 100644
--- a/js/src/views/Signer/components/SignRequest/signRequest.js
+++ b/js/src/views/Signer/components/SignRequest/signRequest.js
@@ -47,7 +47,7 @@ export default class SignRequest extends Component {
id: PropTypes.object.isRequired,
isFinished: PropTypes.bool.isRequired,
isTest: PropTypes.bool.isRequired,
- store: PropTypes.object.isRequired,
+ signerstore: PropTypes.object.isRequired,
className: PropTypes.string,
focus: PropTypes.bool,
@@ -67,9 +67,9 @@ export default class SignRequest extends Component {
};
componentWillMount () {
- const { address, store } = this.props;
+ const { address, signerstore } = this.props;
- store.fetchBalance(address);
+ signerstore.fetchBalance(address);
}
render () {
@@ -106,8 +106,8 @@ export default class SignRequest extends Component {
renderDetails () {
const { api } = this.context;
- const { address, isTest, store, data, origin } = this.props;
- const { balances, externalLink } = store;
+ const { address, isTest, signerstore, data, origin } = this.props;
+ const { balances, externalLink } = signerstore;
const balance = balances[address];
diff --git a/js/src/views/Signer/components/SignRequest/signRequest.spec.js b/js/src/views/Signer/components/SignRequest/signRequest.spec.js
index 67ca529ecc2..cf7d7e9f66e 100644
--- a/js/src/views/Signer/components/SignRequest/signRequest.spec.js
+++ b/js/src/views/Signer/components/SignRequest/signRequest.spec.js
@@ -28,7 +28,7 @@ const store = {
describe('views/Signer/components/SignRequest', () => {
it('renders', () => {
expect(shallow(
-
,
+
,
)).to.be.ok;
});
});
diff --git a/js/src/views/Signer/components/TransactionMainDetails/transactionMainDetails.js b/js/src/views/Signer/components/TransactionMainDetails/transactionMainDetails.js
index 26c00e6d496..c255fa0c308 100644
--- a/js/src/views/Signer/components/TransactionMainDetails/transactionMainDetails.js
+++ b/js/src/views/Signer/components/TransactionMainDetails/transactionMainDetails.js
@@ -30,6 +30,7 @@ import styles from './transactionMainDetails.css';
export default class TransactionMainDetails extends Component {
static propTypes = {
children: PropTypes.node,
+ disabled: PropTypes.bool,
externalLink: PropTypes.string.isRequired,
from: PropTypes.string.isRequired,
fromBalance: PropTypes.object,
@@ -62,7 +63,7 @@ export default class TransactionMainDetails extends Component {
}
render () {
- const { children, externalLink, from, fromBalance, gasStore, isTest, transaction, origin } = this.props;
+ const { children, disabled, externalLink, from, fromBalance, gasStore, isTest, transaction, origin } = this.props;
return (
@@ -71,6 +72,7 @@ export default class TransactionMainDetails extends Component {
diff --git a/js/src/views/Signer/components/TransactionPending/transactionPending.js b/js/src/views/Signer/components/TransactionPending/transactionPending.js
index 92481e24a34..49b9c3ef8c6 100644
--- a/js/src/views/Signer/components/TransactionPending/transactionPending.js
+++ b/js/src/views/Signer/components/TransactionPending/transactionPending.js
@@ -14,10 +14,12 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see
.
+import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
-import { observer } from 'mobx-react';
+import { connect } from 'react-redux';
+import HardwareStore from '~/mobx/hardwareStore';
import { Button, GasPriceEditor } from '~/ui';
import TransactionMainDetails from '../TransactionMainDetails';
@@ -28,12 +30,13 @@ import styles from './transactionPending.css';
import * as tUtil from '../util/transaction';
@observer
-export default class TransactionPending extends Component {
+class TransactionPending extends Component {
static contextTypes = {
api: PropTypes.object.isRequired
};
static propTypes = {
+ accounts: PropTypes.object.isRequired,
className: PropTypes.string,
date: PropTypes.instanceOf(Date).isRequired,
focus: PropTypes.bool,
@@ -45,7 +48,7 @@ export default class TransactionPending extends Component {
onConfirm: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
origin: PropTypes.any,
- store: PropTypes.object.isRequired,
+ signerstore: PropTypes.object.isRequired,
transaction: PropTypes.shape({
condition: PropTypes.object,
data: PropTypes.string,
@@ -72,8 +75,10 @@ export default class TransactionPending extends Component {
gasPrice: this.props.transaction.gasPrice.toFixed()
});
+ hwstore = HardwareStore.get(this.context.api);
+
componentWillMount () {
- const { store, transaction } = this.props;
+ const { signerstore, transaction } = this.props;
const { from, gas, gasPrice, to, value } = transaction;
const fee = tUtil.getFee(gas, gasPrice); // BigNumber object
@@ -83,7 +88,7 @@ export default class TransactionPending extends Component {
this.setState({ gasPriceEthmDisplay, totalValue, gasToDisplay });
this.gasStore.setEthValue(value);
- store.fetchBalances([from, to]);
+ signerstore.fetchBalances([from, to]);
}
render () {
@@ -93,17 +98,19 @@ export default class TransactionPending extends Component {
}
renderTransaction () {
- const { className, focus, id, isSending, isTest, store, transaction, origin } = this.props;
+ const { accounts, className, focus, id, isSending, isTest, signerstore, transaction, origin } = this.props;
const { totalValue } = this.state;
- const { balances, externalLink } = store;
+ const { balances, externalLink } = signerstore;
const { from, value } = transaction;
-
const fromBalance = balances[from];
+ const account = accounts[from] || {};
+ const disabled = account.hardware && !this.hwstore.isConnected(from);
return (
-
-
- )
- : null;
-
- const isWalletOk = !isExternal || (walletError === null && wallet !== null);
- const keyInput = isExternal
- ? this.renderKeyInput()
- : null;
+ const { account, address, disabled, isSending } = this.props;
+ const { wallet, walletError } = this.state;
+ const isWalletOk = account.hardware || account.uuid || (walletError === null && wallet !== null);
return (