diff --git a/js/src/abi/util/slice.js b/js/src/abi/util/slice.js index 417efea54..f4bdf38e2 100644 --- a/js/src/abi/util/slice.js +++ b/js/src/abi/util/slice.js @@ -27,9 +27,5 @@ export function sliceData (_data) { data = padAddress(''); } - if (data.length % 64) { - throw new Error(`Invalid data length (not mod 64) passed to sliceData, ${data}, % 64 == ${data.length % 64}`); - } - return data.match(/.{1,64}/g); } diff --git a/js/src/api/contract/contract.js b/js/src/api/contract/contract.js index c1ba8498d..28bad8a3b 100644 --- a/js/src/api/contract/contract.js +++ b/js/src/api/contract/contract.js @@ -240,9 +240,29 @@ export default class Contract { return this.unsubscribe(subscriptionId); }; + event.getAllLogs = (options = {}) => { + return this.getAllLogs(event); + }; + return event; } + getAllLogs (event, _options) { + // Options as first parameter + if (!_options && event && event.topics) { + return this.getAllLogs(null, event); + } + + const options = this._getFilterOptions(event, _options); + return this._api.eth + .getLogs({ + fromBlock: 0, + toBlock: 'latest', + ...options + }) + .then((logs) => this.parseEventLogs(logs)); + } + _findEvent (eventName = null) { const event = eventName ? this._events.find((evt) => evt.name === eventName) @@ -256,7 +276,7 @@ export default class Contract { return event; } - _createEthFilter (event = null, _options) { + _getFilterOptions (event = null, _options = {}) { const optionTopics = _options.topics || []; const signature = event && event.signature || null; @@ -271,6 +291,11 @@ export default class Contract { topics }); + return options; + } + + _createEthFilter (event = null, _options) { + const options = this._getFilterOptions(event, _options); return this._api.eth.newFilter(options); } diff --git a/js/src/api/rpc/eth/eth.js b/js/src/api/rpc/eth/eth.js index 43f8025e1..8148f9385 100644 --- a/js/src/api/rpc/eth/eth.js +++ b/js/src/api/rpc/eth/eth.js @@ -146,7 +146,8 @@ export default class Eth { getLogs (options) { return this._transport - .execute('eth_getLogs', inFilter(options)); + .execute('eth_getLogs', inFilter(options)) + .then((logs) => logs.map(outLog)); } getLogsEx (options) { diff --git a/js/src/api/util/format.js b/js/src/api/util/format.js index 7f60357cd..d8cf74a8f 100644 --- a/js/src/api/util/format.js +++ b/js/src/api/util/format.js @@ -32,6 +32,10 @@ export function hex2Ascii (_hex) { return str; } +export function bytesToAscii (bytes) { + return bytes.map((b) => String.fromCharCode(b % 512)).join(''); +} + export function asciiToHex (string) { return '0x' + string.split('').map((s) => s.charCodeAt(0).toString(16)).join(''); } diff --git a/js/src/modals/Transfer/Details/details.js b/js/src/modals/Transfer/Details/details.js index decd69c3c..dcc786422 100644 --- a/js/src/modals/Transfer/Details/details.js +++ b/js/src/modals/Transfer/Details/details.js @@ -18,6 +18,8 @@ import BigNumber from 'bignumber.js'; import React, { Component, PropTypes } from 'react'; import { Checkbox, MenuItem } from 'material-ui'; +import { isEqual } from 'lodash'; + import Form, { Input, InputAddressSelect, Select } from '../../../ui/Form'; import imageUnknown from '../../../../assets/images/contracts/unknown-64x64.png'; @@ -29,11 +31,101 @@ const CHECK_STYLE = { left: '1em' }; -export default class Details extends Component { +class TokenSelect extends Component { static contextTypes = { api: PropTypes.object } + static propTypes = { + onChange: PropTypes.func.isRequired, + balance: PropTypes.object.isRequired, + images: PropTypes.object.isRequired, + tag: PropTypes.string.isRequired + }; + + componentWillMount () { + this.computeTokens(); + } + + componentWillReceiveProps (nextProps) { + const prevTokens = this.props.balance.tokens.map((t) => `${t.token.tag}_${t.value.toNumber()}`); + const nextTokens = nextProps.balance.tokens.map((t) => `${t.token.tag}_${t.value.toNumber()}`); + + if (!isEqual(prevTokens, nextTokens)) { + this.computeTokens(nextProps); + } + } + + computeTokens (props = this.props) { + const { api } = this.context; + const { balance, images } = this.props; + + const items = balance.tokens + .filter((token, index) => !index || token.value.gt(0)) + .map((balance, index) => { + const token = balance.token; + const isEth = index === 0; + let imagesrc = token.image; + if (!imagesrc) { + imagesrc = + images[token.address] + ? `${api.dappsUrl}${images[token.address]}` + : imageUnknown; + } + let value = 0; + + if (isEth) { + value = api.util.fromWei(balance.value).toFormat(3); + } else { + const format = balance.token.format || 1; + const decimals = format === 1 ? 0 : Math.min(3, Math.floor(format / 10)); + value = new BigNumber(balance.value).div(format).toFormat(decimals); + } + + const label = ( +
+ +
+ { token.name } +
+
+ { value } { token.tag } +
+
+ ); + + return ( + + { label } + + ); + }); + + this.setState({ items }); + } + + render () { + const { tag, onChange } = this.props; + const { items } = this.state; + + return ( + + ); + } +} + +export default class Details extends Component { static propTypes = { address: PropTypes.string, balance: PropTypes.object, @@ -115,62 +207,15 @@ export default class Details extends Component { } renderTokenSelect () { - const { api } = this.context; const { balance, images, tag } = this.props; - const items = balance.tokens - .filter((token, index) => !index || token.value.gt(0)) - .map((balance, index) => { - const token = balance.token; - const isEth = index === 0; - let imagesrc = token.image; - if (!imagesrc) { - imagesrc = - images[token.address] - ? `${api.dappsUrl}${images[token.address]}` - : imageUnknown; - } - let value = 0; - - if (isEth) { - value = api.util.fromWei(balance.value).toFormat(3); - } else { - const format = balance.token.format || 1; - const decimals = format === 1 ? 0 : Math.min(3, Math.floor(format / 10)); - value = new BigNumber(balance.value).div(format).toFormat(decimals); - } - - const label = ( -
- -
- { token.name } -
-
- { value } { token.tag } -
-
- ); - - return ( - - { label } - - ); - }); - return ( - + ); } diff --git a/js/src/modals/Transfer/errors.js b/js/src/modals/Transfer/errors.js index 3a6bd63ae..b06e91b5d 100644 --- a/js/src/modals/Transfer/errors.js +++ b/js/src/modals/Transfer/errors.js @@ -15,6 +15,7 @@ // along with Parity. If not, see . const ERRORS = { + requireSender: 'a valid sender is required for the transaction', requireRecipient: 'a recipient network address is required for the transaction', invalidAddress: 'the supplied address is an invalid network address', invalidAmount: 'the supplied amount should be a valid positive number', diff --git a/js/src/modals/Transfer/store.js b/js/src/modals/Transfer/store.js new file mode 100644 index 000000000..20dc52e5f --- /dev/null +++ b/js/src/modals/Transfer/store.js @@ -0,0 +1,463 @@ +// 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 . + +import { observable, computed, action, transaction } from 'mobx'; +import BigNumber from 'bignumber.js'; + +import ERRORS from './errors'; +import { ERROR_CODES } from '../../api/transport/error'; +import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '../../util/constants'; + +const TITLES = { + transfer: 'transfer details', + sending: 'sending', + complete: 'complete', + extras: 'extra information', + rejected: 'rejected' +}; +const STAGES_BASIC = [TITLES.transfer, TITLES.sending, TITLES.complete]; +const STAGES_EXTRA = [TITLES.transfer, TITLES.extras, TITLES.sending, TITLES.complete]; + +export default class TransferStore { + @observable stage = 0; + @observable data = ''; + @observable dataError = null; + @observable extras = false; + @observable gas = DEFAULT_GAS; + @observable gasEst = '0'; + @observable gasError = null; + @observable gasLimitError = null; + @observable gasPrice = DEFAULT_GASPRICE; + @observable gasPriceError = null; + @observable recipient = ''; + @observable recipientError = ERRORS.requireRecipient; + @observable sending = false; + @observable tag = 'ETH'; + @observable total = '0.0'; + @observable totalError = null; + @observable value = '0.0'; + @observable valueAll = false; + @observable valueError = null; + @observable isEth = true; + @observable busyState = null; + @observable rejected = false; + + gasPriceHistogram = {}; + + account = null; + balance = null; + gasLimit = null; + onClose = null; + + @computed get steps () { + const steps = [].concat(this.extras ? STAGES_EXTRA : STAGES_BASIC); + + if (this.rejected) { + steps[steps.length - 1] = TITLES.rejected; + } + + return steps; + } + + @computed get isValid () { + const detailsValid = !this.recipientError && !this.valueError && !this.totalError; + const extrasValid = !this.gasError && !this.gasPriceError && !this.totalError; + const verifyValid = !this.passwordError; + + switch (this.stage) { + case 0: + return detailsValid; + + case 1: + return this.extras ? extrasValid : verifyValid; + + case 2: + return verifyValid; + } + } + + constructor (api, props) { + this.api = api; + + const { account, balance, gasLimit, onClose } = props; + + this.account = account; + this.balance = balance; + this.gasLimit = gasLimit; + this.onClose = onClose; + } + + @action onNext = () => { + this.stage += 1; + } + + @action onPrev = () => { + this.stage -= 1; + } + + @action onClose = () => { + this.onClose && this.onClose(); + this.stage = 0; + } + + @action onUpdateDetails = (type, value) => { + switch (type) { + case 'all': + return this._onUpdateAll(value); + + case 'extras': + return this._onUpdateExtras(value); + + case 'data': + return this._onUpdateData(value); + + case 'gas': + return this._onUpdateGas(value); + + case 'gasPrice': + return this._onUpdateGasPrice(value); + + case 'recipient': + return this._onUpdateRecipient(value); + + case 'tag': + return this._onUpdateTag(value); + + case 'value': + return this._onUpdateValue(value); + } + } + + @action getDefaults = () => { + Promise + .all([ + this.api.parity.gasPriceHistogram(), + this.api.eth.gasPrice() + ]) + .then(([gasPriceHistogram, gasPrice]) => { + transaction(() => { + this.gasPrice = gasPrice.toString(); + this.gasPriceDefault = gasPrice.toFormat(); + this.gasPriceHistogram = gasPriceHistogram; + + this.recalculate(); + }); + }) + .catch((error) => { + console.warn('getDefaults', error); + }); + } + + @action onSend = () => { + this.onNext(); + this.sending = true; + + const promise = this.isEth ? this._sendEth() : this._sendToken(); + + promise + .then((requestId) => { + this.busyState = 'Waiting for authorization in the Parity Signer'; + + return this.api + .pollMethod('parity_checkRequest', requestId) + .catch((e) => { + if (e.code === ERROR_CODES.REQUEST_REJECTED) { + this.rejected = true; + return false; + } + + throw e; + }); + }) + .then((txhash) => { + transaction(() => { + this.onNext(); + + this.sending = false; + this.txhash = txhash; + this.busyState = 'Your transaction has been posted to the network'; + }); + }) + .catch((error) => { + this.sending = false; + this.newError(error); + }); + } + + @action _onUpdateAll = (valueAll) => { + this.valueAll = valueAll; + this.recalculateGas(); + } + + @action _onUpdateExtras = (extras) => { + this.extras = extras; + } + + @action _onUpdateData = (data) => { + this.data = data; + this.recalculateGas(); + } + + @action _onUpdateGas = (gas) => { + const gasError = this._validatePositiveNumber(gas); + + transaction(() => { + this.gas = gas; + this.gasError = gasError; + + this.recalculate(); + }); + } + + @action _onUpdateGasPrice = (gasPrice) => { + const gasPriceError = this._validatePositiveNumber(gasPrice); + + transaction(() => { + this.gasPrice = gasPrice; + this.gasPriceError = gasPriceError; + + this.recalculate(); + }); + } + + @action _onUpdateRecipient = (recipient) => { + let recipientError = null; + + if (!recipient || !recipient.length) { + recipientError = ERRORS.requireRecipient; + } else if (!this.api.util.isAddressValid(recipient)) { + recipientError = ERRORS.invalidAddress; + } + + transaction(() => { + this.recipient = recipient; + this.recipientError = recipientError; + + this.recalculateGas(); + }); + } + + @action _onUpdateTag = (tag) => { + transaction(() => { + this.tag = tag; + this.isEth = tag.toLowerCase().trim() === 'eth'; + + this.recalculateGas(); + }); + } + + @action _onUpdateValue = (value) => { + let valueError = this._validatePositiveNumber(value); + + if (!valueError) { + valueError = this._validateDecimals(value); + } + + transaction(() => { + this.value = value; + this.valueError = valueError; + + this.recalculateGas(); + }); + } + + @action recalculateGas = () => { + if (!this.isValid) { + this.gas = 0; + return this.recalculate(); + } + + const promise = this.isEth ? this._estimateGasEth() : this._estimateGasToken(); + + promise + .then((gasEst) => { + let gas = gasEst; + let gasLimitError = null; + + if (gas.gt(DEFAULT_GAS)) { + gas = gas.mul(1.2); + } + + if (gas.gte(MAX_GAS_ESTIMATION)) { + gasLimitError = ERRORS.gasException; + } else if (gas.gt(this.gasLimit)) { + gasLimitError = ERRORS.gasBlockLimit; + } + + transaction(() => { + this.gas = gas.toFixed(0); + this.gasEst = gasEst.toFormat(); + this.gasLimitError = gasLimitError; + + this.recalculate(); + }); + }) + .catch((error) => { + console.error('etimateGas', error); + this.recalculate(); + }); + } + + @action recalculate = () => { + const { account, balance } = this; + + if (!account || !balance) { + return; + } + + const { gas, gasPrice, tag, valueAll, isEth } = this; + + const gasTotal = new BigNumber(gasPrice || 0).mul(new BigNumber(gas || 0)); + const balance_ = balance.tokens.find((b) => tag === b.token.tag); + const availableEth = new BigNumber(balance.tokens[0].value); + const available = new BigNumber(balance_.value); + const format = new BigNumber(balance_.token.format || 1); + + let { value, valueError } = this; + let totalEth = gasTotal; + let totalError = null; + + if (valueAll) { + if (isEth) { + const bn = this.api.util.fromWei(availableEth.minus(gasTotal)); + value = (bn.lt(0) ? new BigNumber(0.0) : bn).toString(); + } else { + value = available.div(format).toString(); + } + } + + if (isEth) { + totalEth = totalEth.plus(this.api.util.toWei(value || 0)); + } + + if (new BigNumber(value || 0).gt(available.div(format))) { + valueError = ERRORS.largeAmount; + } else if (valueError === ERRORS.largeAmount) { + valueError = null; + } + + if (totalEth.gt(availableEth)) { + totalError = ERRORS.largeAmount; + } + + transaction(() => { + this.total = this.api.util.fromWei(totalEth).toString(); + this.totalError = totalError; + this.value = value; + this.valueError = valueError; + }); + } + + _sendEth () { + const { account, data, gas, gasPrice, recipient, value } = this; + + const options = { + from: account.address, + to: recipient, + gas, + gasPrice, + value: this.api.util.toWei(value || 0) + }; + + if (data && data.length) { + options.data = data; + } + + return this.api.parity.postTransaction(options); + } + + _sendToken () { + const { account, balance } = this; + const { gas, gasPrice, recipient, value, tag } = this; + + const token = balance.tokens.find((balance) => balance.token.tag === tag).token; + + return token.contract.instance.transfer + .postTransaction({ + from: account.address, + to: token.address, + gas, + gasPrice + }, [ + recipient, + new BigNumber(value).mul(token.format).toFixed(0) + ]); + } + + _estimateGasToken () { + const { account, balance } = this; + const { recipient, value, tag } = this; + + const token = balance.tokens.find((balance) => balance.token.tag === tag).token; + + return token.contract.instance.transfer + .estimateGas({ + gas: MAX_GAS_ESTIMATION, + from: account.address, + to: token.address + }, [ + recipient, + new BigNumber(value || 0).mul(token.format).toFixed(0) + ]); + } + + _estimateGasEth () { + const { account, data, recipient, value } = this; + + const options = { + gas: MAX_GAS_ESTIMATION, + from: account.address, + to: recipient, + value: this.api.util.toWei(value || 0) + }; + + if (data && data.length) { + options.data = data; + } + + return this.api.eth.estimateGas(options); + } + + _validatePositiveNumber (num) { + try { + const v = new BigNumber(num); + if (v.lt(0)) { + return ERRORS.invalidAmount; + } + } catch (e) { + return ERRORS.invalidAmount; + } + + return null; + } + + _validateDecimals (num) { + const { balance } = this; + + if (this.tag === 'ETH') { + return null; + } + + const token = balance.tokens.find((balance) => balance.token.tag === this.tag).token; + const s = new BigNumber(num).mul(token.format || 1).toFixed(); + + if (s.indexOf('.') !== -1) { + return ERRORS.invalidDecimals; + } + + return null; + } +} diff --git a/js/src/modals/Transfer/transfer.js b/js/src/modals/Transfer/transfer.js index c082a6e79..529cebd10 100644 --- a/js/src/modals/Transfer/transfer.js +++ b/js/src/modals/Transfer/transfer.js @@ -14,88 +14,50 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -import BigNumber from 'bignumber.js'; import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; +import { observer } from 'mobx-react'; + import ActionDoneAll from 'material-ui/svg-icons/action/done-all'; import ContentClear from 'material-ui/svg-icons/content/clear'; import NavigationArrowBack from 'material-ui/svg-icons/navigation/arrow-back'; import NavigationArrowForward from 'material-ui/svg-icons/navigation/arrow-forward'; +import { newError } from '../../ui/Errors/actions'; import { BusyStep, CompletedStep, Button, IdentityIcon, Modal, TxHash } from '../../ui'; -import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '../../util/constants'; import Details from './Details'; import Extras from './Extras'; -import ERRORS from './errors'; + +import TransferStore from './store'; import styles from './transfer.css'; -import { ERROR_CODES } from '../../api/transport/error'; - -const TITLES = { - transfer: 'transfer details', - sending: 'sending', - complete: 'complete', - extras: 'extra information', - rejected: 'rejected' -}; -const STAGES_BASIC = [TITLES.transfer, TITLES.sending, TITLES.complete]; -const STAGES_EXTRA = [TITLES.transfer, TITLES.extras, TITLES.sending, TITLES.complete]; - +@observer class Transfer extends Component { static contextTypes = { - api: PropTypes.object.isRequired, - store: PropTypes.object.isRequired + api: PropTypes.object.isRequired } static propTypes = { + newError: PropTypes.func.isRequired, + gasLimit: PropTypes.object.isRequired, + images: PropTypes.object.isRequired, + account: PropTypes.object, balance: PropTypes.object, balances: PropTypes.object, - gasLimit: PropTypes.object.isRequired, - images: PropTypes.object.isRequired, onClose: PropTypes.func } - state = { - stage: 0, - data: '', - dataError: null, - extras: false, - gas: DEFAULT_GAS, - gasEst: '0', - gasError: null, - gasLimitError: null, - gasPrice: DEFAULT_GASPRICE, - gasPriceHistogram: {}, - gasPriceError: null, - recipient: '', - recipientError: ERRORS.requireRecipient, - sending: false, - tag: 'ETH', - total: '0.0', - totalError: null, - value: '0.0', - valueAll: false, - valueError: null, - isEth: true, - busyState: null, - rejected: false - } + store = new TransferStore(this.context.api, this.props); componentDidMount () { - this.getDefaults(); + this.store.getDefaults(); } render () { - const { stage, extras, rejected } = this.state; - - const steps = [].concat(extras ? STAGES_EXTRA : STAGES_BASIC); - - if (rejected) { - steps[steps.length - 1] = TITLES.rejected; - } + const { stage, extras, steps } = this.store; return ( + recipient={ recipient } + recipientError={ recipientError } + tag={ tag } + total={ total } + totalError={ totalError } + value={ value } + valueError={ valueError } + onChange={ this.store.onUpdateDetails } /> ); } renderExtrasPage () { - if (!this.state.gasPriceHistogram) { + if (!this.store.gasPriceHistogram) { return null; } + const { isEth, data, dataError, gas, gasEst, gasError, gasPrice } = this.store; + const { gasPriceDefault, gasPriceError, gasPriceHistogram, total, totalError } = this.store; + return ( + isEth={ isEth } + data={ data } + dataError={ dataError } + gas={ gas } + gasEst={ gasEst } + gasError={ gasError } + gasPrice={ gasPrice } + gasPriceDefault={ gasPriceDefault } + gasPriceError={ gasPriceError } + gasPriceHistogram={ gasPriceHistogram } + total={ total } + totalError={ totalError } + onChange={ this.store.onUpdateDetails } /> ); } renderDialogActions () { const { account } = this.props; - const { extras, sending, stage } = this.state; + const { extras, sending, stage } = this.store; const cancelBtn = (