Edit Multisig Wallet settings (#3740)

* WIP Sending tokens in multi-sig wallet

* Working Token transfer for multi-sig wallet #3282

* Add operation hash to transfer modal

* Add existing wallet from address #3282

* Wallet delete redirect to Wallets/Accounts #3282

* Rightly check balance in Transfer // Get all accounts balances #3282

* Fix linting

* Better Header UI for Wallet

* Use the `~` webpack alias

* Use Webpack `~` alias

* Add `ETH` format to number typed input

* Fix wallet creation hint && eth input type

* Update dailylimit #3282

* Fix too long copied message

* WIP Wallet settings modification #3282

* WIP edit contract parameters #3282

* Edit Wallet parameters #3282

* Don't show wallets if none

* Fix Transfer for Wallet #3282

* Optimized version of contract code

* Fix wrong max in Wallet creation // Round gas in API
This commit is contained in:
Nicolas Gotchac 2016-12-08 15:53:29 +01:00 committed by Jaco Greeff
parent 69e010bbf9
commit 715761a714
23 changed files with 1034 additions and 118 deletions

View File

@ -112,11 +112,15 @@ export function inNumber10 (number) {
} }
export function inNumber16 (number) { export function inNumber16 (number) {
if (isInstanceOf(number, BigNumber)) { const bn = isInstanceOf(number, BigNumber)
return inHex(number.toString(16)); ? number
: (new BigNumber(number || 0));
if (!bn.isInteger()) {
throw new Error(`[format/input::inNumber16] the given number is not an integer: ${bn.toFormat()}`);
} }
return inHex((new BigNumber(number || 0)).toString(16)); return inHex(bn.toString(16));
} }
export function inOptions (options) { export function inOptions (options) {
@ -130,6 +134,9 @@ export function inOptions (options) {
case 'gas': case 'gas':
case 'gasPrice': case 'gasPrice':
options[key] = inNumber16((new BigNumber(options[key])).round());
break;
case 'value': case 'value':
case 'nonce': case 'nonce':
options[key] = inNumber16(options[key]); options[key] = inNumber16(options[key]);

File diff suppressed because one or more lines are too long

View File

@ -117,15 +117,17 @@ export default class WalletDetails extends Component {
onChange={ this.onRequiredChange } onChange={ this.onRequiredChange }
param={ parseAbiType('uint') } param={ parseAbiType('uint') }
min={ 1 } min={ 1 }
max={ wallet.owners.length + 1 }
/> />
<TypedInput <TypedInput
label='wallet day limit' label='wallet day limit'
hint='number of days to wait for other owners confirmation' hint='amount of ETH spendable without confirmations'
value={ wallet.daylimit } value={ wallet.daylimit }
error={ errors.daylimit } error={ errors.daylimit }
onChange={ this.onDaylimitChange } onChange={ this.onDaylimitChange }
param={ parseAbiType('uint') } param={ parseAbiType('uint') }
isEth
/> />
</div> </div>
</Form> </Form>

View File

@ -17,6 +17,7 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { CompletedStep, IdentityIcon, CopyToClipboard } from '~/ui'; import { CompletedStep, IdentityIcon, CopyToClipboard } from '~/ui';
import { fromWei } from '~/api/util/wei';
import styles from '../createWallet.css'; import styles from '../createWallet.css';
@ -62,7 +63,7 @@ export default class WalletInfo extends Component {
<code>{ required }</code> owners are required to confirm a transaction. <code>{ required }</code> owners are required to confirm a transaction.
</p> </p>
<p> <p>
The daily limit is set to <code>{ daylimit }</code>. The daily limit is set to <code>{ fromWei(daylimit).toFormat() }</code> ETH.
</p> </p>
</CompletedStep> </CompletedStep>
); );

View File

@ -43,7 +43,13 @@ export default class WalletType extends Component {
return [ return [
{ {
label: 'Multi-Sig wallet', key: 'MULTISIG', label: 'Multi-Sig wallet', key: 'MULTISIG',
description: 'A standard multi-signature Wallet' description: (
<span>
<span>Create/Deploy a </span>
<a href='https://github.com/ethereum/dapp-bin/blob/master/wallet/wallet.sol' target='_blank'>standard multi-signature </a>
<span> Wallet</span>
</span>
)
}, },
{ {
label: 'Watch a wallet', key: 'WATCH', label: 'Watch a wallet', key: 'WATCH',

View File

@ -16,7 +16,7 @@
import { observable, computed, action, transaction } from 'mobx'; import { observable, computed, action, transaction } from 'mobx';
import { validateUint, validateAddress, validateName } from '../../util/validation'; import { validateUint, validateAddress, validateName } from '~/util/validation';
import { ERROR_CODES } from '~/api/transport/error'; import { ERROR_CODES } from '~/api/transport/error';
import Contract from '~/api/contract'; import Contract from '~/api/contract';

View File

@ -23,7 +23,7 @@ import { bytesToHex } from '~/api/util/format';
import Contract from '~/api/contract'; import Contract from '~/api/contract';
import ERRORS from './errors'; import ERRORS from './errors';
import { ERROR_CODES } from '~/api/transport/error'; import { ERROR_CODES } from '~/api/transport/error';
import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '../../util/constants'; import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '~/util/constants';
const TITLES = { const TITLES = {
transfer: 'transfer details', transfer: 'transfer details',
@ -116,7 +116,6 @@ export default class TransferStore {
this.api = api; this.api = api;
const { account, balance, gasLimit, senders, onClose, newError, sendersBalances } = props; const { account, balance, gasLimit, senders, onClose, newError, sendersBalances } = props;
this.account = account; this.account = account;
this.balance = balance; this.balance = balance;
this.gasLimit = gasLimit; this.gasLimit = gasLimit;
@ -412,34 +411,38 @@ export default class TransferStore {
return; return;
} }
const { gas, gasPrice, tag, valueAll, isEth } = this; const { gas, gasPrice, tag, valueAll, isEth, isWallet } = this;
const gasTotal = new BigNumber(gasPrice || 0).mul(new BigNumber(gas || 0)); const gasTotal = new BigNumber(gasPrice || 0).mul(new BigNumber(gas || 0));
const availableEth = new BigNumber(balance.tokens[0].value); const availableEth = new BigNumber(balance.tokens[0].value);
const senderBalance = this.balance.tokens.find((b) => tag === b.token.tag); const senderBalance = this.balance.tokens.find((b) => tag === b.token.tag);
const available = new BigNumber(senderBalance.value);
const format = new BigNumber(senderBalance.token.format || 1); const format = new BigNumber(senderBalance.token.format || 1);
const available = isWallet
? this.api.util.fromWei(new BigNumber(senderBalance.value))
: (new BigNumber(senderBalance.value)).div(format);
let { value, valueError } = this; let { value, valueError } = this;
let totalEth = gasTotal; let totalEth = gasTotal;
let totalError = null; let totalError = null;
if (valueAll) { if (valueAll) {
if (isEth) { if (isEth && !isWallet) {
const bn = this.api.util.fromWei(availableEth.minus(gasTotal)); const bn = this.api.util.fromWei(availableEth.minus(gasTotal));
value = (bn.lt(0) ? new BigNumber(0.0) : bn).toString(); value = (bn.lt(0) ? new BigNumber(0.0) : bn).toString();
} else if (isEth) {
value = (available.lt(0) ? new BigNumber(0.0) : available).toString();
} else { } else {
value = available.div(format).toString(); value = available.toString();
} }
} }
if (isEth) { if (isEth && !isWallet) {
totalEth = totalEth.plus(this.api.util.toWei(value || 0)); totalEth = totalEth.plus(this.api.util.toWei(value || 0));
} }
if (new BigNumber(value || 0).gt(available.div(format))) { if (new BigNumber(value || 0).gt(available)) {
valueError = ERRORS.largeAmount; valueError = ERRORS.largeAmount;
} else if (valueError === ERRORS.largeAmount) { } else if (valueError === ERRORS.largeAmount) {
valueError = null; valueError = null;

View File

@ -139,8 +139,8 @@ class Transfer extends Component {
? ( ? (
<div> <div>
<br /> <br />
<p> <div>
This transaction needs confirmation from other owners. <p>This transaction needs confirmation from other owners.</p>
<Input <Input
style={ { width: '50%', margin: '0 auto' } } style={ { width: '50%', margin: '0 auto' } }
value={ this.store.operation } value={ this.store.operation }
@ -148,7 +148,7 @@ class Transfer extends Component {
readOnly readOnly
allowCopy allowCopy
/> />
</p> </div>
</div> </div>
) )
: null : null
@ -298,7 +298,6 @@ function mapStateToProps (initState, initProps) {
return (state) => { return (state) => {
const { gasLimit } = state.nodeStatus; const { gasLimit } = state.nodeStatus;
const sendersBalances = senders ? pick(state.balances.balances, Object.keys(senders)) : null; const sendersBalances = senders ? pick(state.balances.balances, Object.keys(senders)) : null;
return { gasLimit, wallet, senders, sendersBalances }; return { gasLimit, wallet, senders, sendersBalances };
}; };
} }

View File

@ -0,0 +1,17 @@
// Copyright 2015, 2016 Ethcore (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/>.
export default from './walletSettings';

View File

@ -0,0 +1,63 @@
/* Copyright 2015, 2016 Ethcore (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/>.
*/
.splitInput {
display: flex;
flex-direction: row;
> * {
flex: 1;
margin: 0 0.25em;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
}
.change {
background-color: rgba(255, 255, 255, 0.1);
padding: 0.75em 1.75em;
margin-bottom: 1em;
&.add {
background-color: rgba(139, 195, 74, 0.5);
}
&.remove {
background-color: rgba(244, 67, 54, 0.5);
}
.label {
text-transform: uppercase;
margin-bottom: 0.5em;
margin-left: -1em;
font-size: 0.8em;
}
}
.eth:after {
content: 'ETH';
font-size: 0.75em;
margin-left: 0.125em;
}

View File

@ -0,0 +1,321 @@
// Copyright 2015, 2016 Ethcore (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 React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { observer } from 'mobx-react';
import { pick } from 'lodash';
import ActionDone from 'material-ui/svg-icons/action/done';
import ContentClear from 'material-ui/svg-icons/content/clear';
import NavigationArrowForward from 'material-ui/svg-icons/navigation/arrow-forward';
import { parseAbiType } from '~/util/abi';
import { Button, Modal, TxHash, BusyStep, Form, TypedInput, InputAddress, AddressSelect } from '~/ui';
import { fromWei } from '~/api/util/wei';
import WalletSettingsStore from './walletSettingsStore.js';
import styles from './walletSettings.css';
@observer
class WalletSettings extends Component {
static contextTypes = {
api: PropTypes.object.isRequired
};
static propTypes = {
accounts: PropTypes.object.isRequired,
wallet: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
senders: PropTypes.object.isRequired
};
store = new WalletSettingsStore(this.context.api, this.props.wallet);
render () {
const { stage, steps, waiting, rejected } = this.store;
if (rejected) {
return (
<Modal
visible
title='rejected'
actions={ this.renderDialogActions() }
>
<BusyStep
title='The modifications have been rejected'
state='The wallet settings will not be modified. You can safely close this window.'
/>
</Modal>
);
}
return (
<Modal
visible
actions={ this.renderDialogActions() }
current={ stage }
steps={ steps.map((s) => s.title) }
waiting={ waiting }
>
{ this.renderPage() }
</Modal>
);
}
renderPage () {
const { step } = this.store;
switch (step) {
case 'SENDING':
return (
<BusyStep
title='The modifications are currently being sent'
state={ this.store.deployState }
>
{
this.store.requests.map((req) => {
const key = req.id;
if (req.txhash) {
return (<TxHash key={ key } hash={ req.txhash } />);
}
if (req.rejected) {
return (<p key={ key }>The transaction #{parseInt(key, 16)} has been rejected</p>);
}
})
}
</BusyStep>
);
case 'CONFIRMATION':
const { changes } = this.store;
return (
<div>
<p>You are about to make the following modifications</p>
<div>
{ this.renderChanges(changes) }
</div>
</div>
);
default:
case 'EDIT':
const { wallet, errors } = this.store;
const { accounts, senders } = this.props;
return (
<Form>
<p>
In order to edit this contract's settings, at
least { this.store.initialWallet.require.toNumber() } owners have to
send the very same modifications.
Otherwise, no modification will be taken into account...
</p>
<AddressSelect
label='from account (wallet owner)'
hint='send modifications as this owner'
value={ wallet.sender }
error={ errors.sender }
onChange={ this.store.onSenderChange }
accounts={ senders }
/>
<TypedInput
label='other wallet owners'
value={ wallet.owners.slice() }
onChange={ this.store.onOwnersChange }
accounts={ accounts }
param={ parseAbiType('address[]') }
/>
<div className={ styles.splitInput }>
<TypedInput
label='required owners'
hint='number of required owners to accept a transaction'
value={ wallet.require }
error={ errors.require }
onChange={ this.store.onRequireChange }
param={ parseAbiType('uint') }
min={ 1 }
max={ wallet.owners.length }
/>
<TypedInput
label='wallet day limit'
hint='amount of ETH spendable without confirmations'
value={ wallet.dailylimit }
error={ errors.dailylimit }
onChange={ this.store.onDailylimitChange }
param={ parseAbiType('uint') }
isEth
/>
</div>
</Form>
);
}
}
renderChanges (changes) {
return changes.map((change, index) => (
<div key={ `${change.type}_${index}` }>
{ this.renderChange(change) }
</div>
));
}
renderChange (change) {
const { accounts } = this.props;
switch (change.type) {
case 'dailylimit':
return (
<div className={ styles.change }>
<div className={ styles.label }>Change Daily Limit</div>
<div>
<span> from </span>
<code> { fromWei(change.initial).toFormat() }</code>
<span className={ styles.eth } />
<span> to </span>
<code> { fromWei(change.value).toFormat() }</code>
<span className={ styles.eth } />
</div>
</div>
);
case 'require':
return (
<div className={ styles.change }>
<div className={ styles.label }>Change Required Owners</div>
<div>
<span> from </span>
<code> { change.initial.toNumber() }</code>
<span> to </span>
<code> { change.value.toNumber() }</code>
</div>
</div>
);
case 'add_owner':
return (
<div className={ [ styles.change, styles.add ].join(' ') }>
<div className={ styles.label }>Add Owner</div>
<div>
<InputAddress
disabled
value={ change.value }
accounts={ accounts }
/>
</div>
</div>
);
case 'remove_owner':
return (
<div className={ [ styles.change, styles.remove ].join(' ') }>
<div className={ styles.label }>Remove Owner</div>
<div>
<InputAddress
disabled
value={ change.value }
accounts={ accounts }
/>
</div>
</div>
);
}
}
renderDialogActions () {
const { onClose } = this.props;
const { step, hasErrors, rejected, onNext, send, done } = this.store;
const cancelBtn = (
<Button
icon={ <ContentClear /> }
label='Cancel'
onClick={ onClose }
/>
);
const closeBtn = (
<Button
icon={ <ContentClear /> }
label='Close'
onClick={ onClose }
/>
);
const sendingBtn = (
<Button
icon={ <ActionDone /> }
label='Sending...'
disabled
/>
);
const nextBtn = (
<Button
icon={ <NavigationArrowForward /> }
label='Next'
onClick={ onNext }
disabled={ hasErrors }
/>
);
const sendBtn = (
<Button
icon={ <NavigationArrowForward /> }
label='Send'
onClick={ send }
disabled={ hasErrors }
/>
);
if (rejected) {
return [ closeBtn ];
}
switch (step) {
case 'SENDING':
return done ? [ closeBtn ] : [ closeBtn, sendingBtn ];
case 'CONFIRMATION':
return [ cancelBtn, sendBtn ];
default:
case 'TYPE':
return [ cancelBtn, nextBtn ];
}
}
}
function mapStateToProps (initState, initProps) {
const { accountsInfo, accounts } = initState.personal;
const { owners } = initProps.wallet;
const senders = pick(accounts, owners);
return () => {
return { accounts: accountsInfo, senders };
};
}
export default connect(mapStateToProps)(WalletSettings);

View File

@ -0,0 +1,306 @@
// Copyright 2015, 2016 Ethcore (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 { observable, computed, action, transaction } from 'mobx';
import BigNumber from 'bignumber.js';
import { validateUint, validateAddress } from '~/util/validation';
import { DEFAULT_GAS, MAX_GAS_ESTIMATION } from '~/util/constants';
import { ERROR_CODES } from '~/api/transport/error';
const STEPS = {
EDIT: { title: 'wallet settings' },
CONFIRMATION: { title: 'confirmation' },
SENDING: { title: 'sending transaction', waiting: true }
};
export default class WalletSettingsStore {
@observable step = null;
@observable requests = [];
@observable deployState = '';
@observable done = false;
@observable wallet = {
owners: null,
require: null,
dailylimit: null,
sender: ''
};
@observable errors = {
owners: null,
require: null,
dailylimit: null,
sender: null
};
@computed get stage () {
return this.stepsKeys.findIndex((k) => k === this.step);
}
@computed get hasErrors () {
return !!Object.keys(this.errors).find((key) => !!this.errors[key]);
}
@computed get stepsKeys () {
return this.steps.map((s) => s.key);
}
@computed get steps () {
return Object
.keys(STEPS)
.map((key) => {
return {
...STEPS[key],
key
};
});
}
@computed get waiting () {
this.steps
.map((s, idx) => ({ idx, waiting: s.waiting }))
.filter((s) => s.waiting)
.map((s) => s.idx);
}
get changes () {
const changes = [];
const prevDailylimit = new BigNumber(this.initialWallet.dailylimit);
const nextDailylimit = new BigNumber(this.wallet.dailylimit);
const prevRequire = new BigNumber(this.initialWallet.require);
const nextRequire = new BigNumber(this.wallet.require);
if (!prevDailylimit.equals(nextDailylimit)) {
changes.push({
type: 'dailylimit',
initial: prevDailylimit,
value: nextDailylimit
});
}
if (!prevRequire.equals(nextRequire)) {
changes.push({
type: 'require',
initial: prevRequire,
value: nextRequire
});
}
const prevOwners = this.initialWallet.owners;
const nextOwners = this.wallet.owners;
const ownersToRemove = prevOwners.filter((owner) => !nextOwners.includes(owner));
const ownersToAdd = nextOwners.filter((owner) => !prevOwners.includes(owner));
ownersToRemove.forEach((owner) => {
changes.push({
type: 'remove_owner',
value: owner
});
});
ownersToAdd.forEach((owner) => {
changes.push({
type: 'add_owner',
value: owner
});
});
return changes;
}
constructor (api, wallet) {
this.api = api;
this.step = this.stepsKeys[0];
this.walletInstance = wallet.instance;
this.initialWallet = {
address: wallet.address,
owners: wallet.owners,
require: wallet.require,
dailylimit: wallet.dailylimit.limit
};
transaction(() => {
this.wallet.owners = wallet.owners;
this.wallet.require = wallet.require;
this.wallet.dailylimit = wallet.dailylimit.limit;
this.validateWallet(this.wallet);
});
}
@action onNext = () => {
const stepIndex = this.stepsKeys.findIndex((k) => k === this.step) + 1;
this.step = this.stepsKeys[stepIndex];
}
@action onChange = (_wallet) => {
const newWallet = Object.assign({}, this.wallet, _wallet);
this.validateWallet(newWallet);
}
@action onOwnersChange = (owners) => {
this.onChange({ owners });
}
@action onRequireChange = (require) => {
this.onChange({ require });
}
@action onSenderChange = (_, sender) => {
this.onChange({ sender });
}
@action onDailylimitChange = (dailylimit) => {
this.onChange({ dailylimit });
}
@action send = () => {
const changes = this.changes;
const walletInstance = this.walletInstance;
this.step = 'SENDING';
this.onTransactionsState('postTransaction');
Promise
.all(changes.map((change) => this.sendChange(change, walletInstance)))
.then((requestIds) => {
this.onTransactionsState('checkRequest');
this.requests = requestIds.map((id) => ({ id, rejected: false, txhash: null }));
return Promise
.all(requestIds.map((id) => {
return this.api
.pollMethod('parity_checkRequest', id)
.then((txhash) => {
const index = this.requests.findIndex((r) => r.id === id);
this.requests[index].txhash = txhash;
})
.catch((e) => {
if (e.code === ERROR_CODES.REQUEST_REJECTED) {
const index = this.requests.findIndex((r) => r.id === id);
this.requests[index].rejected = true;
return false;
}
throw e;
});
}));
})
.then(() => {
this.done = true;
this.onTransactionsState('completed');
});
}
@action sendChange = (change, walletInstance) => {
const { method, values } = this.getChangeMethod(change, walletInstance);
const options = {
from: this.wallet.sender,
to: this.initialWallet.address,
gas: MAX_GAS_ESTIMATION
};
return method
.estimateGas(options, values)
.then((gasEst) => {
let gas = gasEst;
if (gas.gt(DEFAULT_GAS)) {
gas = gas.mul(1.2);
}
options.gas = gas;
return method.postTransaction(options, values);
});
}
getChangeMethod = (change, walletInstance) => {
if (change.type === 'require') {
return {
method: walletInstance.changeRequirement,
values: [ change.value ]
};
}
if (change.type === 'dailylimit') {
return {
method: walletInstance.setDailyLimit,
values: [ change.value ]
};
}
if (change.type === 'add_owner') {
return {
method: walletInstance.addOwner,
values: [ change.value ]
};
}
if (change.type === 'remove_owner') {
return {
method: walletInstance.removeOwner,
values: [ change.value ]
};
}
}
@action onTransactionsState = (state) => {
switch (state) {
case 'estimateGas':
case 'postTransaction':
this.deployState = 'Preparing transaction for network transmission';
return;
case 'checkRequest':
this.deployState = 'Waiting for confirmation of the transaction in the Parity Secure Signer';
return;
case 'completed':
this.deployState = '';
return;
}
}
@action validateWallet = (_wallet) => {
const senderValidation = validateAddress(_wallet.sender);
const requireValidation = validateUint(_wallet.require);
const dailylimitValidation = validateUint(_wallet.dailylimit);
const errors = {
sender: senderValidation.addressError,
require: requireValidation.valueError,
dailylimit: dailylimitValidation.valueError
};
const wallet = {
..._wallet,
sender: senderValidation.address,
require: requireValidation.value,
dailylimit: dailylimitValidation.value
};
transaction(() => {
this.wallet = wallet;
this.errors = errors;
});
}
}

View File

@ -29,6 +29,7 @@ import Transfer from './Transfer';
import PasswordManager from './PasswordManager'; import PasswordManager from './PasswordManager';
import SaveContract from './SaveContract'; import SaveContract from './SaveContract';
import LoadContract from './LoadContract'; import LoadContract from './LoadContract';
import WalletSettings from './WalletSettings';
export { export {
AddAddress, AddAddress,
@ -45,5 +46,6 @@ export {
Transfer, Transfer,
PasswordManager, PasswordManager,
LoadContract, LoadContract,
SaveContract SaveContract,
WalletSettings
}; };

View File

@ -228,7 +228,7 @@ function fetchWalletInfo (contract, update, getState) {
const owners = ownersUpdate && ownersUpdate.value || null; const owners = ownersUpdate && ownersUpdate.value || null;
const transactions = transactionsUpdate && transactionsUpdate.value || null; const transactions = transactionsUpdate && transactionsUpdate.value || null;
return fetchWalletConfirmations(contract, owners, transactions, getState) return fetchWalletConfirmations(contract, update[UPDATE_CONFIRMATIONS], owners, transactions, getState)
.then((update) => { .then((update) => {
updates.push(update); updates.push(update);
return updates; return updates;
@ -292,17 +292,37 @@ function fetchWalletDailylimit (contract) {
}); });
} }
function fetchWalletConfirmations (contract, _owners = null, _transactions = null, getState) { function fetchWalletConfirmations (contract, _operations, _owners = null, _transactions = null, getState) {
const walletInstance = contract.instance; const walletInstance = contract.instance;
const wallet = getState().wallet.wallets[contract.address]; const wallet = getState().wallet.wallets[contract.address];
const owners = _owners || (wallet && wallet.owners) || null; const owners = _owners || (wallet && wallet.owners) || null;
const transactions = _transactions || (wallet && wallet.transactions) || null; const transactions = _transactions || (wallet && wallet.transactions) || null;
// Full load if no operations given, or if the one given aren't loaded yet
const fullLoad = !Array.isArray(_operations) || _operations
.filter((op) => !wallet.confirmations.find((conf) => conf.operation === op))
.length > 0;
return walletInstance let promise;
if (fullLoad) {
promise = walletInstance
.ConfirmationNeeded .ConfirmationNeeded
.getAllLogs() .getAllLogs()
.then((logs) => {
return logs.map((log) => ({
initiator: log.params.initiator.value,
to: log.params.to.value,
data: log.params.data.value,
value: log.params.value.value,
operation: bytesToHex(log.params.operation.value),
transactionIndex: log.transactionIndex,
transactionHash: log.transactionHash,
blockNumber: log.blockNumber,
confirmedBy: []
}));
})
.then((logs) => { .then((logs) => {
return logs.sort((logA, logB) => { return logs.sort((logA, logB) => {
const comp = logA.blockNumber.comparedTo(logB.blockNumber); const comp = logA.blockNumber.comparedTo(logB.blockNumber);
@ -314,23 +334,13 @@ function fetchWalletConfirmations (contract, _owners = null, _transactions = nul
return logA.transactionIndex.comparedTo(logB.transactionIndex); return logA.transactionIndex.comparedTo(logB.transactionIndex);
}); });
}) })
.then((logs) => {
return logs.map((log) => ({
initiator: log.params.initiator.value,
to: log.params.to.value,
data: log.params.data.value,
value: log.params.value.value,
operation: bytesToHex(log.params.operation.value),
transactionHash: log.transactionHash,
blockNumber: log.blockNumber,
confirmedBy: []
}));
})
.then((confirmations) => { .then((confirmations) => {
if (confirmations.length === 0) { if (confirmations.length === 0) {
return confirmations; return confirmations;
} }
// Only fetch confirmations for operations not
// yet confirmed (ie. not yet a transaction)
if (transactions) { if (transactions) {
const operations = transactions const operations = transactions
.filter((t) => t.operation) .filter((t) => t.operation)
@ -342,27 +352,53 @@ function fetchWalletConfirmations (contract, _owners = null, _transactions = nul
} }
return confirmations; return confirmations;
}) });
} else {
const { confirmations } = wallet;
const nextConfirmations = confirmations
.filter((conf) => _operations.includes(conf.operation));
promise = Promise.resolve(nextConfirmations);
}
return promise
.then((confirmations) => { .then((confirmations) => {
if (confirmations.length === 0) { if (confirmations.length === 0) {
return confirmations; return confirmations;
} }
const operations = confirmations.map((conf) => conf.operation); const uniqConfirmations = Object.values(
confirmations.reduce((confirmations, confirmation) => {
confirmations[confirmation.operation] = confirmation;
return confirmations;
}, {})
);
const operations = uniqConfirmations.map((conf) => conf.operation);
return Promise return Promise
.all(operations.map((op) => fetchOperationConfirmations(contract, op, owners))) .all(operations.map((op) => fetchOperationConfirmations(contract, op, owners)))
.then((confirmedBys) => { .then((confirmedBys) => {
confirmations.forEach((_, index) => { uniqConfirmations.forEach((_, index) => {
confirmations[index].confirmedBy = confirmedBys[index]; uniqConfirmations[index].confirmedBy = confirmedBys[index];
}); });
return confirmations; return uniqConfirmations;
}); });
}) })
.then((confirmations) => { .then((confirmations) => {
const prevConfirmations = wallet.confirmations || [];
const nextConfirmations = prevConfirmations
.filter((conA) => !confirmations.find((conB) => conB.operation === conA.operation))
.concat(confirmations)
.map((conf) => ({
...conf,
pending: false
}));
return { return {
key: UPDATE_CONFIRMATIONS, key: UPDATE_CONFIRMATIONS,
value: confirmations value: nextConfirmations
}; };
}); });
} }
@ -417,7 +453,10 @@ function parseLogs (logs) {
logs.forEach((log) => { logs.forEach((log) => {
const { address, topics } = log; const { address, topics } = log;
const eventSignature = toHex(topics[0]); const eventSignature = toHex(topics[0]);
const prev = updates[address] || { address }; const prev = updates[address] || {
[ UPDATE_DAILYLIMIT ]: true,
address
};
switch (eventSignature) { switch (eventSignature) {
case signatures.OwnerChanged: case signatures.OwnerChanged:
@ -436,16 +475,18 @@ function parseLogs (logs) {
}; };
return; return;
case signatures.ConfirmationNeeded:
case signatures.Confirmation: case signatures.Confirmation:
case signatures.Revoke: case signatures.Revoke:
const operation = log.params.operation.value; const operation = bytesToHex(log.params.operation.value);
updates[address] = { updates[address] = {
...prev, ...prev,
[ UPDATE_CONFIRMATIONS ]: uniq( [ UPDATE_CONFIRMATIONS ]: uniq(
(prev.operations || []).concat(operation) (prev[UPDATE_CONFIRMATIONS] || []).concat(operation)
) )
}; };
return; return;
case signatures.Deposit: case signatures.Deposit:
@ -456,17 +497,6 @@ function parseLogs (logs) {
[ UPDATE_TRANSACTIONS ]: true [ UPDATE_TRANSACTIONS ]: true
}; };
return; return;
case signatures.ConfirmationNeeded:
const op = log.params.operation.value;
updates[address] = {
...prev,
[ UPDATE_CONFIRMATIONS ]: uniq(
(prev.operations || []).concat(op)
)
};
return;
} }
}); });

View File

@ -20,5 +20,14 @@
} }
.data { .data {
flex: 1;
font-family: monospace; font-family: monospace;
padding: 0 0.5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.container {
display: flex;
} }

View File

@ -80,7 +80,13 @@ class CopyToClipboard extends Component {
onCopy = () => { onCopy = () => {
const { data, onCopy, cooldown, showSnackbar } = this.props; const { data, onCopy, cooldown, showSnackbar } = this.props;
const message = (<div>copied <code className={ styles.data }>{ data }</code> to clipboard</div>); const message = (
<div className={ styles.container }>
<span>copied </span>
<code className={ styles.data }> { data } </code>
<span> to clipboard</span>
</div>
);
this.setState({ this.setState({
copied: true, copied: true,

View File

@ -170,7 +170,7 @@ export default class AddressSelect extends Component {
handleFilter = (searchText, name, item) => { handleFilter = (searchText, name, item) => {
const { address } = item; const { address } = item;
const entry = this.state.entries[address]; const entry = this.state.entries[address];
const lowCaseSearch = searchText.toLowerCase(); const lowCaseSearch = (searchText || '').toLowerCase();
return [entry.name, entry.address] return [entry.name, entry.address]
.some(text => text.toLowerCase().indexOf(lowCaseSearch) !== -1); .some(text => text.toLowerCase().indexOf(lowCaseSearch) !== -1);

View File

@ -29,3 +29,30 @@
position: relative; position: relative;
} }
} }
.ethInput {
display: flex;
flex-direction: row;
align-items: flex-end;
.input {
flex: 1;
position: relative;
.label {
position: absolute;
right: 1.5em;
bottom: 1em;
font-size: 0.85em;
margin-left: 0.5em;
}
}
.toggle {
margin-bottom: 0.5em;
display: flex;
flex-direction: row;
align-items: center;
}
}

View File

@ -15,7 +15,7 @@
// along with Parity. If not, see <http://www.gnu.org/licenses/>. // along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { MenuItem } from 'material-ui'; import { MenuItem, Toggle } from 'material-ui';
import { range } from 'lodash'; import { range } from 'lodash';
import IconButton from 'material-ui/IconButton'; import IconButton from 'material-ui/IconButton';
@ -26,7 +26,8 @@ import Input from '~/ui/Form/Input';
import InputAddressSelect from '~/ui/Form/InputAddressSelect'; import InputAddressSelect from '~/ui/Form/InputAddressSelect';
import Select from '~/ui/Form/Select'; import Select from '~/ui/Form/Select';
import { ABI_TYPES } from '../../../util/abi'; import { ABI_TYPES } from '~/util/abi';
import { fromWei, toWei } from '~/api/util/wei';
import styles from './typedInput.css'; import styles from './typedInput.css';
@ -42,16 +43,29 @@ export default class TypedInput extends Component {
label: PropTypes.string, label: PropTypes.string,
hint: PropTypes.string, hint: PropTypes.string,
min: PropTypes.number, min: PropTypes.number,
max: PropTypes.number max: PropTypes.number,
isEth: PropTypes.bool
}; };
static defaultProps = { static defaultProps = {
min: null, min: null,
max: null max: null,
isEth: false
}; };
state = {
isEth: true,
ethValue: 0
};
componentDidMount () {
if (this.props.isEth && this.props.value) {
this.setState({ ethValue: fromWei(this.props.value) });
}
}
render () { render () {
const { param } = this.props; const { param, isEth } = this.props;
const { type } = param; const { type } = param;
if (type === ABI_TYPES.ARRAY) { if (type === ABI_TYPES.ARRAY) {
@ -87,6 +101,10 @@ export default class TypedInput extends Component {
); );
} }
if (isEth) {
return this.renderEth();
}
return this.renderType(type); return this.renderType(type);
} }
@ -157,16 +175,43 @@ export default class TypedInput extends Component {
return this.renderDefault(); return this.renderDefault();
} }
renderNumber () { renderEth () {
const { label, value, error, param, hint, min, max } = this.props; const { ethValue } = this.state;
const value = ethValue && typeof ethValue.toNumber === 'function'
? ethValue.toNumber()
: ethValue;
return (
<div className={ styles.ethInput }>
<div className={ styles.input }>
{ this.renderNumber(value, this.onEthValueChange) }
{ this.state.isEth ? (<div className={ styles.label }>ETH</div>) : null }
</div>
<div className={ styles.toggle }>
<Toggle
toggled={ this.state.isEth }
onToggle={ this.onEthTypeChange }
style={ { width: 46 } }
/>
</div>
</div>
);
}
renderNumber (value = this.props.value, onChange = this.onChange) {
const { label, error, param, hint, min, max } = this.props;
const realValue = value && typeof value.toNumber === 'function'
? value.toNumber()
: value;
return ( return (
<Input <Input
label={ label } label={ label }
hint={ hint } hint={ hint }
value={ value } value={ realValue }
error={ error } error={ error }
onChange={ this.onChange } onChange={ onChange }
type='number' type='number'
min={ min !== null ? min : (param.signed ? null : 0) } min={ min !== null ? min : (param.signed ? null : 0) }
max={ max !== null ? max : null } max={ max !== null ? max : null }
@ -236,6 +281,28 @@ export default class TypedInput extends Component {
this.props.onChange(value === 'true'); this.props.onChange(value === 'true');
} }
onEthTypeChange = () => {
const { isEth, ethValue } = this.state;
if (ethValue === '' || ethValue === undefined) {
return this.setState({ isEth: !isEth });
}
const value = isEth ? toWei(ethValue) : fromWei(ethValue);
this.setState({ isEth: !isEth, ethValue: value }, () => {
this.onEthValueChange(null, value);
});
}
onEthValueChange = (event, value) => {
const realValue = this.state.isEth && value !== '' && value !== undefined
? toWei(value)
: value;
this.setState({ ethValue: value });
this.props.onChange(realValue);
}
onChange = (event, value) => { onChange = (event, value) => {
this.props.onChange(value); this.props.onChange(value);
} }

View File

@ -140,7 +140,7 @@ export function validateUint (value) {
const bn = new BigNumber(value); const bn = new BigNumber(value);
if (bn.lt(0)) { if (bn.lt(0)) {
valueError = ERRORS.negativeNumber; valueError = ERRORS.negativeNumber;
} else if (bn.toString().indexOf('.') !== -1) { } else if (!bn.isInteger()) {
valueError = ERRORS.decimalNumber; valueError = ERRORS.decimalNumber;
} }
} catch (e) { } catch (e) {

View File

@ -140,6 +140,10 @@ class Accounts extends Component {
const { wallets, hasWallets, balances } = this.props; const { wallets, hasWallets, balances } = this.props;
const { searchValues, sortOrder } = this.state; const { searchValues, sortOrder } = this.state;
if (!wallets || Object.keys(wallets).length === 0) {
return null;
}
return ( return (
<List <List
link='wallet' link='wallet'

View File

@ -60,12 +60,14 @@ class WalletConfirmations extends Component {
} }
renderConfirmations () { renderConfirmations () {
const { confirmations, ...others } = this.props; const { confirmations, ...others } = this.props;
const realConfirmations = confirmations && confirmations
.filter((conf) => conf.confirmedBy.length > 0);
if (!confirmations) { if (!realConfirmations) {
return null; return null;
} }
if (confirmations.length === 0) { if (realConfirmations.length === 0) {
return ( return (
<div> <div>
<p>No transactions needs confirmation right now.</p> <p>No transactions needs confirmation right now.</p>
@ -73,7 +75,8 @@ class WalletConfirmations extends Component {
); );
} }
return confirmations.map((confirmation) => ( return realConfirmations
.map((confirmation) => (
<WalletConfirmation <WalletConfirmation
key={ confirmation.operation } key={ confirmation.operation }
confirmation={ confirmation } confirmation={ confirmation }
@ -320,6 +323,7 @@ class WalletConfirmation extends Component {
const { address, isTest } = this.props; const { address, isTest } = this.props;
const { operation, transactionHash, blockNumber, value, to, data } = confirmation; const { operation, transactionHash, blockNumber, value, to, data } = confirmation;
if (value && to && data) {
return ( return (
<TxRow <TxRow
className={ className } className={ className }
@ -339,6 +343,18 @@ class WalletConfirmation extends Component {
); );
} }
return (
<tr
key={ operation }
className={ className }
>
<td colSpan={ 5 }>
<code>{ operation }</code>
</td>
</tr>
);
}
renderConfirmedBy (confirmation, className) { renderConfirmedBy (confirmation, className) {
const { operation, confirmedBy } = confirmation; const { operation, confirmedBy } = confirmation;

View File

@ -22,9 +22,10 @@ import moment from 'moment';
import ContentCreate from 'material-ui/svg-icons/content/create'; import ContentCreate from 'material-ui/svg-icons/content/create';
import ActionDelete from 'material-ui/svg-icons/action/delete'; import ActionDelete from 'material-ui/svg-icons/action/delete';
import ContentSend from 'material-ui/svg-icons/content/send'; import ContentSend from 'material-ui/svg-icons/content/send';
import SettingsIcon from 'material-ui/svg-icons/action/settings';
import { nullableProptype } from '~/util/proptypes'; import { nullableProptype } from '~/util/proptypes';
import { EditMeta, Transfer } from '~/modals'; import { EditMeta, Transfer, WalletSettings } from '~/modals';
import { Actionbar, Button, Page, Loading } from '~/ui'; import { Actionbar, Button, Page, Loading } from '~/ui';
import Delete from '../Address/Delete'; import Delete from '../Address/Delete';
@ -74,6 +75,7 @@ class Wallet extends Component {
state = { state = {
showEditDialog: false, showEditDialog: false,
showSettingsDialog: false,
showTransferDialog: false, showTransferDialog: false,
showDeleteDialog: false showDeleteDialog: false
}; };
@ -115,6 +117,7 @@ class Wallet extends Component {
return ( return (
<div className={ styles.wallet }> <div className={ styles.wallet }>
{ this.renderEditDialog(wallet) } { this.renderEditDialog(wallet) }
{ this.renderSettingsDialog() }
{ this.renderTransferDialog() } { this.renderTransferDialog() }
{ this.renderDeleteDialog(wallet) } { this.renderDeleteDialog(wallet) }
{ this.renderActionbar() } { this.renderActionbar() }
@ -212,13 +215,18 @@ class Wallet extends Component {
<Button <Button
key='delete' key='delete'
icon={ <ActionDelete /> } icon={ <ActionDelete /> }
label='delete wallet' label='delete'
onClick={ this.showDeleteDialog } />, onClick={ this.showDeleteDialog } />,
<Button <Button
key='editmeta' key='editmeta'
icon={ <ContentCreate /> } icon={ <ContentCreate /> }
label='edit' label='edit'
onClick={ this.onEditClick } /> onClick={ this.onEditClick } />,
<Button
key='settings'
icon={ <SettingsIcon /> }
label='settings'
onClick={ this.onSettingsClick } />
]; ];
return ( return (
@ -250,11 +258,27 @@ class Wallet extends Component {
return ( return (
<EditMeta <EditMeta
account={ wallet } account={ wallet }
keys={ ['description', 'passwordHint'] } keys={ ['description'] }
onClose={ this.onEditClick } /> onClose={ this.onEditClick } />
); );
} }
renderSettingsDialog () {
const { wallet } = this.props;
const { showSettingsDialog } = this.state;
if (!showSettingsDialog) {
return null;
}
return (
<WalletSettings
wallet={ wallet }
onClose={ this.onSettingsClick }
/>
);
}
renderTransferDialog () { renderTransferDialog () {
const { showTransferDialog } = this.state; const { showTransferDialog } = this.state;
@ -281,6 +305,12 @@ class Wallet extends Component {
}); });
} }
onSettingsClick = () => {
this.setState({
showSettingsDialog: !this.state.showSettingsDialog
});
}
onTransferClick = () => { onTransferClick = () => {
this.setState({ this.setState({
showTransferDialog: !this.state.showTransferDialog showTransferDialog: !this.state.showTransferDialog