UI support for hardware wallets (#4539)

* Add parity_hardwareAccountsInfo

* Ledger Promise interface wrapper

* Initial hardwarestore

* Move ~/views/historyStore to ~/mobx

* split scanLedger

* test createEntry

* Also scan via parity_hardwareAccountsInfo

* Explanation for scanning options

* react-intl-inify tooltips

* add hwstore

* Listen for hw walet updates

* Return arrays from scanning

* Readability

* add u2f-api polyfill

* check response.errorCode

* Support hardware types in state.personal

* Tooltips (to be split into sep. PR)

* Tooltips support intl strings

* FormattedMessage for strings to Tooltip

* Fix TabBar tooltip display

* signLedger

* Use wallets as an object map

* PendingForm -> FormattedMessage

* Pending form doesn't render password for hardware

* Groundwork for JS API signing

* Show hardware accounts in list

* Cleanup rendering conditions

* Update RequestPending rendering tests (verification)

* Tests for extended signer middleware

* sign properly & handle response, error

* Align outputs between Parity & Ledger u2f

* Ledger returns checksummed addresses

* Update ethereum-tx for EIP155 support

* Update construction of tx

* Updates after sanity checks (thanks @tomusdrw)

* Allow display for disabled IdentityIcon

* Disabled accounts

* Disabled auto-disabling

* Password button ebaled for hardware

* Don't display password hint for hardware

* Disable non-applicable options when not connected

* Fix failing test

* Confirmation via ledger (u2f)

* Confirm on device message

* Cleanups & support checks

* Mark u2f as unsupported (until https)

* rewording

* Pass account & disabled flags

* Render attach device message

* Use isConnected for checking availability

* Show hardware accounts in defaults list

* Pass signerstore

* Update u2f to correct version

* remove debug u2f lib

* Update test (prop name change)

* Add ETC path (future work)

* new Buffer -> Buffer.from (thanks @derhuerst)
This commit is contained in:
Jaco Greeff 2017-03-02 23:51:56 +01:00 committed by Gav Wood
parent 96d74543fc
commit b11caaf071
44 changed files with 1650 additions and 260 deletions

View File

@ -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",

View File

@ -14,12 +14,4 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
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';

136
js/src/3rdparty/ledger/ledger.js vendored Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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
};

120
js/src/3rdparty/ledger/ledger.spec.js vendored Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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;
});
});
});

View File

@ -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);
}

View File

@ -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');

View File

@ -15,7 +15,7 @@
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
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);

View File

@ -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: [],

View File

@ -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 <http://www.gnu.org/licenses/>.
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
};

View File

@ -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 <http://www.gnu.org/licenses/>.
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');
});
});
});
});

View File

@ -29,7 +29,7 @@ function create () {
return store;
}
describe('views/HistoryStore', () => {
describe('mobx/HistoryStore', () => {
beforeEach(() => {
create();
});

View File

@ -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({

View File

@ -14,33 +14,36 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
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
});
},

View File

@ -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
createNoncePromise (transaction) {
return !transaction.nonce || transaction.nonce.isZero()
? this._api.parity.nextNonce(transaction.from)
: Promise.resolve(transaction.nonce);
}
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((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, 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 (wallet && transaction) {
const noncePromise = transaction.nonce.isZero()
? this._api.parity.nextNonce(transaction.from)
: Promise.resolve(transaction.nonce);
if (transaction) {
const hardwareAccount = this._hwstore.wallets[transaction.from];
const { worker } = store.getState().worker;
if (wallet) {
return this.confirmWalletTransaction(store, id, transaction, wallet, password);
} else if (hardwareAccount) {
switch (hardwareAccount.via) {
case 'ledger':
return this.confirmLedgerTransaction(store, id, transaction);
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);
})
.then((rawTx) => {
return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx));
})
.catch((error) => {
console.error(error.message);
store.dispatch(actions.errorConfirmRequest({ id, err: error.message }));
});
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 }));

View File

@ -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 <http://www.gnu.org/licenses/>.
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');
});
});
});

View File

@ -14,9 +14,10 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
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

View File

@ -14,6 +14,7 @@
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.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;
}
}
}
}

View File

@ -14,9 +14,15 @@
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.icon {
border-radius: 50%;
margin: 0;
&.disabled {
filter: grayscale(100%);
opacity: 0.33;
}
}
.center {

View File

@ -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,

View File

@ -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 {
<IdentityIcon
address={ address }
className={ styles.identityIcon }
disabled={ disabled }
/>
<div className={ styles.info }>
{ this.renderName() }
<div className={ [ hideName ? styles.bigaddress : '', styles.addressline ].join(' ') }>
<CopyToClipboard data={ address } />
<div className={ styles.address }>{ address }</div>
<div className={ styles.address }>
{ address }
</div>
</div>
{ this.renderVault() }
{ this.renderUuid() }

View File

@ -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 (
<div>
{ 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) }
<Page padded>
<Header
account={ account }
balance={ balance }
disabled={ !isAvailable }
/>
<Transactions
accounts={ accounts }
@ -106,7 +117,7 @@ class Account extends Component {
);
}
renderActionbar (balance) {
renderActionbar (account, balance) {
const showTransferButton = !!(balance && balance.tokens);
const buttons = [
@ -160,17 +171,19 @@ class Account extends Component {
}
onClick={ this.store.toggleEditDialog }
/>,
<Button
icon={ <LockedIcon /> }
key='passwordManager'
label={
<FormattedMessage
id='account.button.password'
defaultMessage='password'
/>
}
onClick={ this.store.togglePasswordDialog }
/>,
!account.hardware && (
<Button
icon={ <LockedIcon /> }
key='passwordManager'
label={
<FormattedMessage
id='account.button.password'
defaultMessage='password'
/>
}
onClick={ this.store.togglePasswordDialog }
/>
),
<Button
icon={ <DeleteIcon /> }
key='delete'
@ -202,6 +215,23 @@ class Account extends Component {
return null;
}
if (account.hardware) {
return (
<DeleteAddress
account={ account }
confirmMessage={
<FormattedMessage
id='account.hardware.confirmDelete'
defaultMessage='Are you sure you want to remove the following hardware address from your account list?'
/>
}
visible
route='/accounts'
onClose={ this.store.toggleDeleteDialog }
/>
);
}
return (
<DeleteAccount
account={ account }

View File

@ -17,7 +17,7 @@
import { shallow } from 'enzyme';
import React from 'react';
import { ADDRESS, createRedux } from './account.test.js';
import { ACCOUNTS, ADDRESS, createRedux } from './account.test.js';
import Account from './';
@ -28,10 +28,15 @@ let store;
function render (props) {
component = shallow(
<Account
accounts={ ACCOUNTS }
params={ { address: ADDRESS } }
{ ...props }
/>,
{ 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/);
});
});

View File

@ -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
};

View File

@ -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 (
<Summary
account={ account }
balance={ balance }
disabled={ isDisabled }
handleAddSearchToken={ handleAddSearchToken }
link={ link }
owners={ owners }

View File

@ -37,6 +37,7 @@ class Summary extends Component {
account: PropTypes.object.isRequired,
accountsInfo: PropTypes.object.isRequired,
balance: PropTypes.object,
disabled: PropTypes.bool,
link: PropTypes.string,
name: PropTypes.string,
noLink: PropTypes.bool,
@ -52,15 +53,21 @@ class Summary extends Component {
shouldComponentUpdate (nextProps) {
const prev = {
link: this.props.link, name: this.props.name,
link: this.props.link,
disabled: this.props.disabled,
name: this.props.name,
noLink: this.props.noLink,
meta: this.props.account.meta, address: this.props.account.address
meta: this.props.account.meta,
address: this.props.account.address
};
const next = {
link: nextProps.link, name: nextProps.name,
link: nextProps.link,
disabled: nextProps.disabled,
name: nextProps.name,
noLink: nextProps.noLink,
meta: nextProps.account.meta, address: nextProps.account.address
meta: nextProps.account.meta,
address: nextProps.account.address
};
if (!isEqual(next, prev)) {
@ -92,7 +99,7 @@ class Summary extends Component {
}
render () {
const { account, handleAddSearchToken, noLink } = this.props;
const { account, disabled, handleAddSearchToken, noLink } = this.props;
const { tags } = account.meta;
if (!account) {
@ -122,6 +129,7 @@ class Summary extends Component {
<div className={ styles.heading }>
<IdentityIcon
address={ address }
disabled={ disabled }
/>
<ContainerTitle
byline={

View File

@ -14,34 +14,42 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
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() }
</Page>
@ -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 null;
}
return (
<List
link='wallet'
search={ searchValues }
accounts={ wallets }
balances={ balances }
empty={ !hasWallets }
order={ sortOrder }
handleAddSearchToken={ this.onAddSearchToken }
/>
);
}
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 (
<List
search={ searchValues }
accounts={ hardware }
balances={ balances }
disabled={ disabled }
order={ sortOrder }
handleAddSearchToken={ this.onAddSearchToken }
/>
@ -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
};
}

View File

@ -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 }
>
<div className={ styles.hero }>
<FormattedMessage
id='address.delete.confirmInfo'
defaultMessage='Are you sure you want to remove the following address from your addressbook?'
/>
{
confirmMessage || (
<FormattedMessage
id='address.delete.confirmInfo'
defaultMessage='Are you sure you want to remove the following address from your addressbook?'
/>
)
}
</div>
<div className={ styles.info }>
<IdentityIcon
@ -112,15 +117,11 @@ class Delete extends Component {
}
}
function mapStateToProps (state) {
return {};
}
function mapDispatchToProps (dispatch) {
return bindActionCreators({ newError }, dispatch);
}
export default connect(
mapStateToProps,
null,
mapDispatchToProps
)(Delete);

View File

@ -18,11 +18,11 @@ import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import HistoryStore from '~/mobx/historyStore';
import { Page } from '~/ui';
import DappsStore from '../Dapps/dappsStore';
import ExtensionStore from '../Application/Extension/store';
import HistoryStore from '../historyStore';
import WebStore from '../Web/store';
import Accounts from './Accounts';

View File

@ -81,7 +81,8 @@ export default class AccountStore {
Object
.keys(accounts)
.filter((address) => {
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;

View File

@ -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 (
<div className={ `${styles.acc} ${className}` }>
@ -63,6 +64,7 @@ export default class Account extends Component {
>
<IdentityIcon
center
disabled={ disabled }
address={ address }
/>
</AccountLink>

View File

@ -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);
};
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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(
<RequestPending
date={ new Date() }
gasLimit={ new BigNumber(100000) }
id={ new BigNumber(123) }
isTest={ false }
isSending={ false }
onConfirm={ onConfirm }
onReject={ onReject }
origin={ {} }
payload={ payload }
store={ {} }
/>
);
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);
});
});
});

View File

@ -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];

View File

@ -28,7 +28,7 @@ const store = {
describe('views/Signer/components/SignRequest', () => {
it('renders', () => {
expect(shallow(
<SignRequest store={ store } />,
<SignRequest signerstore={ store } />,
)).to.be.ok;
});
});

View File

@ -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 (
<div className={ styles.transaction }>
@ -71,6 +72,7 @@ export default class TransactionMainDetails extends Component {
<Account
address={ from }
balance={ fromBalance }
disabled={ disabled }
externalLink={ externalLink }
isTest={ isTest }
/>

View File

@ -14,10 +14,12 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
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 (
<div className={ `${styles.container} ${className}` }>
<TransactionMainDetails
className={ styles.transactionDetails }
disabled={ disabled }
externalLink={ externalLink }
from={ from }
fromBalance={ fromBalance }
@ -116,7 +123,9 @@ export default class TransactionPending extends Component {
value={ value }
/>
<TransactionPendingForm
account={ account }
address={ from }
disabled={ disabled }
focus={ focus }
isSending={ isSending }
onConfirm={ this.onConfirm }
@ -174,3 +183,16 @@ export default class TransactionPending extends Component {
this.gasStore.setEditing(false);
}
}
function mapStateToProps (state) {
const { accounts } = state.personal;
return {
accounts
};
}
export default connect(
mapStateToProps,
null
)(TransactionPending);

View File

@ -19,17 +19,17 @@ import RaisedButton from 'material-ui/RaisedButton';
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import ReactTooltip from 'react-tooltip';
import { Form, Input, IdentityIcon } from '~/ui';
import styles from './transactionPendingFormConfirm.css';
class TransactionPendingFormConfirm extends Component {
export default class TransactionPendingFormConfirm extends Component {
static propTypes = {
account: PropTypes.object.isRequired,
address: PropTypes.string.isRequired,
disabled: PropTypes.bool,
isSending: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired,
focus: PropTypes.bool
@ -93,74 +93,16 @@ class TransactionPendingFormConfirm extends Component {
}
render () {
const { account, address, isSending } = this.props;
const { password, wallet, walletError } = this.state;
const isExternal = !account.uuid;
const passwordHintText = this.getPasswordHint();
const passwordHint = passwordHintText
? (
<div>
<FormattedMessage
id='signer.txPendingConfirm.passwordHint'
defaultMessage='(hint) {hint}'
values={ {
hint: passwordHintText
} }
/>
</div>
)
: 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 (
<div className={ styles.confirmForm }>
<Form>
{ keyInput }
<Input
hint={
isExternal
? (
<FormattedMessage
id='signer.txPendingConfirm.decryptKey.hint'
defaultMessage='decrypt the key'
/>
)
: (
<FormattedMessage
id='signer.txPendingConfirm.unlockAccount.hint'
defaultMessage='unlock the account'
/>
)
}
label={
isExternal
? (
<FormattedMessage
id='signer.txPendingConfirm.decryptKey.label'
defaultMessage='Key Password'
/>
)
: (
<FormattedMessage
id='signer.txPendingConfirm.unlockAccount.label'
defaultMessage='Account Password'
/>
)
}
onChange={ this.onModifyPassword }
onKeyDown={ this.onKeyDown }
ref='input'
type='password'
value={ password }
/>
<div className={ styles.passwordHint }>
{ passwordHint }
</div>
{ this.renderKeyInput() }
{ this.renderPassword() }
{ this.renderHint() }
<div
data-effect='solid'
data-for={ `transactionConfirmForm${this.id}` }
@ -169,7 +111,7 @@ class TransactionPendingFormConfirm extends Component {
>
<RaisedButton
className={ styles.confirmButton }
disabled={ isSending || !isWalletOk }
disabled={ disabled || isSending || !isWalletOk }
fullWidth
icon={
<IdentityIcon
@ -203,16 +145,120 @@ class TransactionPendingFormConfirm extends Component {
);
}
renderPassword () {
const { account } = this.props;
const { password } = this.state;
if (account && account.hardware) {
return null;
}
return (
<Input
hint={
account.uuid
? (
<FormattedMessage
id='signer.txPendingConfirm.password.unlock.hint'
defaultMessage='unlock the account'
/>
)
: (
<FormattedMessage
id='signer.txPendingConfirm.password.decrypt.hint'
defaultMessage='decrypt the key'
/>
)
}
label={
account.uuid
? (
<FormattedMessage
id='signer.txPendingConfirm.password.unlock.label'
defaultMessage='Account Password'
/>
)
: (
<FormattedMessage
id='signer.txPendingConfirm.password.decrypt.label'
defaultMessage='Key Password'
/>
)
}
onChange={ this.onModifyPassword }
onKeyDown={ this.onKeyDown }
ref='input'
type='password'
value={ password }
/>
);
}
renderHint () {
const { account, disabled, isSending } = this.props;
if (account.hardware) {
if (isSending) {
return (
<div className={ styles.passwordHint }>
<FormattedMessage
id='signer.sending.hardware.confirm'
defaultMessage='Please confirm the transaction on your attached hardware device'
/>
</div>
);
} else if (disabled) {
return (
<div className={ styles.passwordHint }>
<FormattedMessage
id='signer.sending.hardware.connect'
defaultMessage='Please attach your hardware device before confirming the transaction'
/>
</div>
);
}
}
const passwordHint = this.getPasswordHint();
if (!passwordHint) {
return null;
}
return (
<div className={ styles.passwordHint }>
<FormattedMessage
id='signer.txPendingConfirm.passwordHint'
defaultMessage='(hint) {passwordHint}'
values={ {
passwordHint
} }
/>
</div>
);
}
renderKeyInput () {
const { account } = this.props;
const { walletError } = this.state;
if (account.uuid || account.wallet || account.hardware) {
return null;
}
return (
<Input
className={ styles.fileInput }
error={ walletError }
hint={
<FormattedMessage
id='signer.txPendingConfirm.selectKey.hint'
defaultMessage='The keyfile to use for this account'
/>
}
label={
<FormattedMessage
id='signer.txPendingConfirm.keySelect.label'
id='signer.txPendingConfirm.selectKey.label'
defaultMessage='Select Local Key'
/>
}
@ -223,7 +269,9 @@ class TransactionPendingFormConfirm extends Component {
}
renderTooltip () {
if (this.state.password.length) {
const { account } = this.props;
if (this.state.password.length || account.hardware) {
return;
}
@ -290,7 +338,8 @@ class TransactionPendingFormConfirm extends Component {
const { password, wallet } = this.state;
this.props.onConfirm({
password, wallet
password,
wallet
});
}
@ -304,20 +353,3 @@ class TransactionPendingFormConfirm extends Component {
this.onConfirm();
}
}
function mapStateToProps (_, initProps) {
const { address } = initProps;
return (state) => {
const { accounts } = state.personal;
let gotAddress = Object.keys(accounts).find(a => a.toLowerCase() === address.toLowerCase());
const account = gotAddress ? accounts[gotAddress] : {};
return { account };
};
}
export default connect(
mapStateToProps,
null
)(TransactionPendingFormConfirm);

View File

@ -0,0 +1,134 @@
// 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 <http://www.gnu.org/licenses/>.
import { shallow } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import TransactionPendingFormConfirm from './';
const ADDR_NORMAL = '0x0123456789012345678901234567890123456789';
const ADDR_WALLET = '0x1234567890123456789012345678901234567890';
const ADDR_HARDWARE = '0x2345678901234567890123456789012345678901';
const ADDR_SIGN = '0x3456789012345678901234567890123456789012';
const ACCOUNTS = {
[ADDR_NORMAL]: {
address: ADDR_NORMAL,
uuid: ADDR_NORMAL
},
[ADDR_WALLET]: {
address: ADDR_WALLET,
wallet: true
},
[ADDR_HARDWARE]: {
address: ADDR_HARDWARE,
hardware: true
}
};
let component;
let instance;
let onConfirm;
function render (address) {
onConfirm = sinon.stub();
component = shallow(
<TransactionPendingFormConfirm
account={ ACCOUNTS[address] || {} }
address={ address }
onConfirm={ onConfirm }
isSending={ false }
/>
);
instance = component.instance();
return component;
}
describe('views/Signer/TransactionPendingFormConfirm', () => {
describe('normal accounts', () => {
beforeEach(() => {
render(ADDR_NORMAL);
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
it('does not render the key input', () => {
expect(instance.renderKeyInput()).to.be.null;
});
it('renders the password', () => {
expect(instance.renderPassword()).not.to.be.null;
});
});
describe('hardware accounts', () => {
beforeEach(() => {
render(ADDR_HARDWARE);
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
it('does not render the key input', () => {
expect(instance.renderKeyInput()).to.be.null;
});
it('does not render the password', () => {
expect(instance.renderPassword()).to.be.null;
});
});
describe('wallet accounts', () => {
beforeEach(() => {
render(ADDR_WALLET);
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
it('does not render the key input', () => {
expect(instance.renderKeyInput()).to.be.null;
});
it('renders the password', () => {
expect(instance.renderPassword()).not.to.be.null;
});
});
describe('signing accounts', () => {
beforeEach(() => {
render(ADDR_SIGN);
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
it('renders the key input', () => {
expect(instance.renderKeyInput()).not.to.be.null;
});
it('renders the password', () => {
expect(instance.renderPassword()).not.to.be.null;
});
});
});

View File

@ -25,7 +25,9 @@ import styles from './transactionPendingForm.css';
export default class TransactionPendingForm extends Component {
static propTypes = {
account: PropTypes.object.isRequired,
address: PropTypes.string.isRequired,
disabled: PropTypes.bool,
isSending: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
@ -53,7 +55,7 @@ export default class TransactionPendingForm extends Component {
}
renderForm () {
const { address, focus, isSending, onConfirm, onReject } = this.props;
const { account, address, disabled, focus, isSending, onConfirm, onReject } = this.props;
if (this.state.isRejectOpen) {
return (
@ -64,6 +66,8 @@ export default class TransactionPendingForm extends Component {
return (
<TransactionPendingFormConfirm
address={ address }
account={ account }
disabled={ disabled }
focus={ focus }
isSending={ isSending }
onConfirm={ onConfirm }

View File

@ -97,7 +97,7 @@ class Embedded extends Component {
onReject={ actions.startRejectRequest }
origin={ origin }
payload={ payload }
store={ this.store }
signerstore={ this.store }
/>
);
}

View File

@ -123,7 +123,7 @@ class RequestsPage extends Component {
onReject={ actions.startRejectRequest }
origin={ origin }
payload={ payload }
store={ this.store }
signerstore={ this.store }
/>
);
}

View File

@ -23,7 +23,6 @@ export Contract from './Contract';
export Contracts from './Contracts';
export Dapp from './Dapp';
export Dapps from './Dapps';
export HistoryStore from './historyStore';
export Home from './Home';
export ParityBar from './ParityBar';
export Settings, { SettingsBackground, SettingsParity, SettingsProxy, SettingsViews } from './Settings';

View File

@ -43,6 +43,7 @@ global.WebSocket = WebSocket;
global.document = jsdom.jsdom('<!doctype html><html><body></body></html>');
global.window = document.defaultView;
global.navigator = global.window.navigator;
global.location = global.window.location;
// attach mocked localStorage onto the window as exposed by jsdom
global.window.localStorage = global.localStorage;