From 0cb16ae5895b5457a4d43881c22f4a35a0a5939c Mon Sep 17 00:00:00 2001 From: Nicolas Gotchac Date: Mon, 12 Dec 2016 00:38:47 +0100 Subject: [PATCH] Add store for MethodDecoding (#3821) * Add Loader to Transactions * Add Method Decoding Store (better fetching of methods) * Load locally stored ABI in MethodDecodingStore * Fixes UI glitches along the way * Linting * Add method decoding from User Contracts --- js/src/redux/providers/personalActions.js | 5 + js/src/ui/Form/AddressSelect/addressSelect.js | 56 +++-- js/src/ui/Form/AutoComplete/autocomplete.css | 24 ++ js/src/ui/Form/AutoComplete/autocomplete.js | 22 +- js/src/ui/MethodDecoding/methodDecoding.js | 138 +++-------- .../ui/MethodDecoding/methodDecodingStore.js | 216 ++++++++++++++++++ .../Account/Transactions/transactions.js | 27 ++- js/src/views/WriteContract/writeContract.css | 4 + 8 files changed, 360 insertions(+), 132 deletions(-) create mode 100644 js/src/ui/Form/AutoComplete/autocomplete.css create mode 100644 js/src/ui/MethodDecoding/methodDecodingStore.js diff --git a/js/src/redux/providers/personalActions.js b/js/src/redux/providers/personalActions.js index b172aaf8d..7ca7c3374 100644 --- a/js/src/redux/providers/personalActions.js +++ b/js/src/redux/providers/personalActions.js @@ -19,6 +19,8 @@ import { isEqual } from 'lodash'; import { fetchBalances } from './balancesActions'; import { attachWallets } from './walletActions'; +import MethodDecodingStore from '~/ui/MethodDecoding/methodDecodingStore'; + export function personalAccountsInfo (accountsInfo) { const accounts = {}; const contacts = {}; @@ -41,6 +43,9 @@ export function personalAccountsInfo (accountsInfo) { } }); + // Load user contracts for Method Decoding + MethodDecodingStore.loadContracts(contracts); + return (dispatch) => { const data = { accountsInfo, diff --git a/js/src/ui/Form/AddressSelect/addressSelect.js b/js/src/ui/Form/AddressSelect/addressSelect.js index 3fb15cd86..c443ecaa2 100644 --- a/js/src/ui/Form/AddressSelect/addressSelect.js +++ b/js/src/ui/Form/AddressSelect/addressSelect.js @@ -16,6 +16,7 @@ import React, { Component, PropTypes } from 'react'; import { MenuItem } from 'material-ui'; +import { isEqual, pick } from 'lodash'; import AutoComplete from '../AutoComplete'; import IdentityIcon from '../../IdentityIcon'; @@ -31,19 +32,20 @@ export default class AddressSelect extends Component { } static propTypes = { - disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired, + accounts: PropTypes.object, + allowInput: PropTypes.bool, + balances: PropTypes.object, contacts: PropTypes.object, contracts: PropTypes.object, - wallets: PropTypes.object, - label: PropTypes.string, - hint: PropTypes.string, + disabled: PropTypes.bool, error: PropTypes.string, - value: PropTypes.string, + hint: PropTypes.string, + label: PropTypes.string, tokens: PropTypes.object, - onChange: PropTypes.func.isRequired, - allowInput: PropTypes.bool, - balances: PropTypes.object + value: PropTypes.string, + wallets: PropTypes.object } state = { @@ -53,6 +55,9 @@ export default class AddressSelect extends Component { value: '' } + // Cache autocomplete items + items = {} + entriesFromProps (props = this.props) { const { accounts = {}, contacts = {}, contracts = {}, wallets = {} } = props; @@ -76,6 +81,15 @@ export default class AddressSelect extends Component { return { autocompleteEntries, entries }; } + shouldComponentUpdate (nextProps, nextState) { + const keys = [ 'error', 'value' ]; + + const prevValues = pick(this.props, keys); + const nextValues = pick(nextProps, keys); + + return !isEqual(prevValues, nextValues); + } + componentWillMount () { const { value } = this.props; const { entries, autocompleteEntries } = this.entriesFromProps(); @@ -143,14 +157,21 @@ export default class AddressSelect extends Component { renderItem = (entry) => { const { address, name } = entry; - return { - text: name && name.toUpperCase() || address, - value: this.renderMenuItem(address), - address - }; + const _balance = this.getBalance(address); + const balance = _balance ? _balance.toNumber() : _balance; + + if (!this.items[address] || this.items[address].balance !== balance) { + this.items[address] = { + text: name && name.toUpperCase() || address, + value: this.renderMenuItem(address), + address, balance + }; + } + + return this.items[address]; } - renderBalance (address) { + getBalance (address) { const { balances = {} } = this.props; const balance = balances[address]; @@ -164,7 +185,12 @@ export default class AddressSelect extends Component { return null; } - const value = fromWei(ethToken.value); + return ethToken.value; + } + + renderBalance (address) { + const balance = this.getBalance(address); + const value = fromWei(balance); return (
diff --git a/js/src/ui/Form/AutoComplete/autocomplete.css b/js/src/ui/Form/AutoComplete/autocomplete.css new file mode 100644 index 000000000..9fad53edb --- /dev/null +++ b/js/src/ui/Form/AutoComplete/autocomplete.css @@ -0,0 +1,24 @@ +/* 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 . +*/ + +.item { + &:last-child { + &.divider { + display: none; + } + } +} diff --git a/js/src/ui/Form/AutoComplete/autocomplete.js b/js/src/ui/Form/AutoComplete/autocomplete.js index c98019009..3ebd59772 100644 --- a/js/src/ui/Form/AutoComplete/autocomplete.js +++ b/js/src/ui/Form/AutoComplete/autocomplete.js @@ -21,13 +21,18 @@ import { PopoverAnimationVertical } from 'material-ui/Popover'; import { isEqual } from 'lodash'; +import styles from './autocomplete.css'; + // Hack to prevent "Unknown prop `disableFocusRipple` on
tag" error class Divider extends Component { static muiName = MUIDivider.muiName; render () { return ( -
+
); @@ -143,11 +148,16 @@ export default class AutoComplete extends Component { if (renderItem && typeof renderItem === 'function') { item = renderItem(entry); + + // Add the item class to the entry + const classNames = [ styles.item ].concat(item.value.props.className); + item.value = React.cloneElement(item.value, { className: classNames.join(' ') }); } else { item = { text: entry, value: ( ) @@ -160,6 +170,7 @@ export default class AutoComplete extends Component { } item.divider = currentDivider; + item.entry = entry; return item; }).filter((item) => item !== undefined); @@ -215,13 +226,8 @@ export default class AutoComplete extends Component { return; } - const { entries } = this.props; - - const entriesArray = (entries instanceof Array) - ? entries - : Object.values(entries); - - const entry = entriesArray[idx]; + const { dataSource } = this.state; + const { entry } = dataSource[idx]; this.handleOnChange(entry); this.setState({ entry, open: false }); diff --git a/js/src/ui/MethodDecoding/methodDecoding.js b/js/src/ui/MethodDecoding/methodDecoding.js index e02355ffb..fcf7f7513 100644 --- a/js/src/ui/MethodDecoding/methodDecoding.js +++ b/js/src/ui/MethodDecoding/methodDecoding.js @@ -17,16 +17,14 @@ import BigNumber from 'bignumber.js'; import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import CircularProgress from 'material-ui/CircularProgress'; -import Contracts from '~/contracts'; import { Input, InputAddress } from '../Form'; +import MethodDecodingStore from './methodDecodingStore'; import styles from './methodDecoding.css'; const ASCII_INPUT = /^[a-z0-9\s,?;.:/!()-_@'"#]+$/i; -const CONTRACT_CREATE = '0x60606040'; const TOKEN_METHODS = { '0xa9059cbb': 'transfer(to,value)' }; @@ -38,19 +36,17 @@ class MethodDecoding extends Component { static propTypes = { address: PropTypes.string.isRequired, - tokens: PropTypes.object, + token: PropTypes.object, transaction: PropTypes.object, historic: PropTypes.bool } state = { contractAddress: null, - method: null, methodName: null, methodInputs: null, methodParams: null, methodSignature: null, - token: null, isContract: false, isDeploy: false, isReceived: false, @@ -59,14 +55,29 @@ class MethodDecoding extends Component { inputType: 'auto' } - componentWillMount () { - const lookupResult = this.lookup(); + methodDecodingStore = MethodDecodingStore.get(this.context.api); - if (typeof lookupResult === 'object' && typeof lookupResult.then === 'function') { - lookupResult.then(() => this.setState({ isLoading: false })); - } else { - this.setState({ isLoading: false }); - } + componentWillMount () { + const { address, transaction } = this.props; + + this + .methodDecodingStore + .lookup(address, transaction) + .then((lookup) => { + const newState = { + methodName: lookup.name, + methodInputs: lookup.inputs, + methodParams: lookup.params, + methodSignature: lookup.signature, + + isContract: lookup.contract, + isDeploy: lookup.deploy, + isLoading: false, + isReceived: lookup.received + }; + + this.setState(newState); + }); } render () { @@ -116,7 +127,8 @@ class MethodDecoding extends Component { } renderAction () { - const { methodName, methodInputs, methodSignature, token, isDeploy, isReceived, isContract } = this.state; + const { token } = this.props; + const { methodName, methodInputs, methodSignature, isDeploy, isReceived, isContract } = this.state; if (isDeploy) { return this.renderDeploy(); @@ -378,7 +390,7 @@ class MethodDecoding extends Component { } renderTokenValue (value) { - const { token } = this.state; + const { token } = this.props; return ( @@ -436,96 +448,18 @@ class MethodDecoding extends Component { }); } - lookup () { - const { transaction } = this.props; - - if (!transaction) { - return; - } - - const { api } = this.context; - const { address, tokens } = this.props; - - const isReceived = transaction.to === address; - const contractAddress = isReceived ? transaction.from : transaction.to; - const input = transaction.input || transaction.data; - - const token = (tokens || {})[contractAddress]; - this.setState({ token, isReceived, contractAddress }); - - if (!input || input === '0x') { - return; - } - - const { signature, paramdata } = api.util.decodeCallData(input); - this.setState({ methodSignature: signature, methodParams: paramdata }); - - if (!signature || signature === CONTRACT_CREATE || transaction.creates) { - this.setState({ isDeploy: true }); - return; - } - - if (contractAddress === '0x') { - return; - } - - return api.eth - .getCode(contractAddress || transaction.creates) - .then((bytecode) => { - const isContract = bytecode && /^(0x)?([0]*[1-9a-f]+[0]*)+$/.test(bytecode); - - this.setState({ isContract }); - - if (!isContract) { - return; - } - - return Contracts.get() - .signatureReg - .lookup(signature) - .then((method) => { - let methodInputs = null; - let methodName = null; - - if (method && method.length) { - const { methodParams } = this.state; - const abi = api.util.methodToAbi(method); - - methodName = abi.name; - methodInputs = api.util - .decodeMethodInput(abi, methodParams) - .map((value, index) => { - const type = abi.inputs[index].type; - - return { type, value }; - }); - } - - this.setState({ - method, - methodName, - methodInputs, - bytecode - }); - }); - }) - .catch((error) => { - console.warn('lookup', error); - }); - } } -function mapStateToProps (state) { - const { tokens } = state.balances; +function mapStateToProps (initState, initProps) { + const { tokens } = initState.balances; + const { address } = initProps; - return { tokens }; + const token = (tokens || {})[address]; + + return () => { + return { token }; + }; } - -function mapDispatchToProps (dispatch) { - return bindActionCreators({}, dispatch); -} - export default connect( - mapStateToProps, - mapDispatchToProps + mapStateToProps )(MethodDecoding); diff --git a/js/src/ui/MethodDecoding/methodDecodingStore.js b/js/src/ui/MethodDecoding/methodDecodingStore.js new file mode 100644 index 000000000..24433e541 --- /dev/null +++ b/js/src/ui/MethodDecoding/methodDecodingStore.js @@ -0,0 +1,216 @@ +// 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 Contracts from '~/contracts'; +import Abi from '~/abi'; +import * as abis from '~/contracts/abi'; + +const CONTRACT_CREATE = '0x60606040'; + +let instance = null; + +export default class MethodDecodingStore { + + api = null; + + _isContract = {}; + _methods = {}; + + constructor (api, contracts = {}) { + this.api = api; + + // Load the signatures from the local ABIs + Object.keys(abis).forEach((abiKey) => { + this.loadFromAbi(abis[abiKey]); + }); + + this.addContracts(contracts); + } + + addContracts (contracts = {}) { + // Load the User defined contracts + Object.values(contracts).forEach((contract) => { + if (!contract || !contract.meta || !contract.meta.abi) { + return; + } + this.loadFromAbi(contract.meta.abi); + }); + } + + loadFromAbi (_abi) { + const abi = new Abi(_abi); + abi + .functions + .map((f) => ({ sign: f.signature, abi: f.abi })) + .forEach((mapping) => { + const sign = (/^0x/.test(mapping.sign) ? '' : '0x') + mapping.sign; + this._methods[sign] = mapping.abi; + }); + } + + static get (api, contracts = {}) { + if (!instance) { + instance = new MethodDecodingStore(api, contracts); + } + + // Set API if not set yet + if (!instance.api) { + instance.api = api; + } + + return instance; + } + + static loadContracts (contracts = {}) { + if (!instance) { + // Just create the instance with null API + MethodDecodingStore.get(null, contracts); + } else { + instance.addContracts(contracts); + } + } + + /** + * Looks up a transaction in the context of the given + * address + * + * @param {String} address The address contract + * @param {Object} transaction The transaction to lookup + * @return {Promise} The result of the lookup. Resolves with: + * { + * contract: Boolean, + * deploy: Boolean, + * inputs: Array, + * name: String, + * params: Array, + * received: Boolean, + * signature: String + * } + */ + lookup (address, transaction) { + const result = {}; + + if (!transaction) { + return Promise.resolve(result); + } + + const isReceived = transaction.to === address; + const contractAddress = isReceived ? transaction.from : transaction.to; + const input = transaction.input || transaction.data; + + result.received = isReceived; + + // No input, should be a ETH transfer + if (!input || input === '0x') { + return Promise.resolve(result); + } + + const { signature, paramdata } = this.api.util.decodeCallData(input); + result.signature = signature; + result.params = paramdata; + + // Contract deployment + if (!signature || signature === CONTRACT_CREATE || transaction.creates) { + return Promise.resolve({ ...result, deploy: true }); + } + + return this + .isContract(contractAddress || transaction.creates) + .then((isContract) => { + result.contract = isContract; + + if (!isContract) { + return result; + } + + return this + .fetchMethodAbi(signature) + .then((abi) => { + let methodName = null; + let methodInputs = null; + + if (abi) { + methodName = abi.name; + methodInputs = this.api.util + .decodeMethodInput(abi, paramdata) + .map((value, index) => { + const type = abi.inputs[index].type; + return { type, value }; + }); + } + + return { + ...result, + name: methodName, + inputs: methodInputs + }; + }); + }) + .catch((error) => { + console.warn('lookup', error); + }); + } + + fetchMethodAbi (signature) { + if (this._methods[signature] !== undefined) { + return Promise.resolve(this._methods[signature]); + } + + this._methods[signature] = Contracts.get() + .signatureReg + .lookup(signature) + .then((method) => { + let abi = null; + + if (method && method.length) { + abi = this.api.util.methodToAbi(method); + } + + this._methods[signature] = abi; + return this._methods[signature]; + }); + + return Promise.resolve(this._methods[signature]); + } + + /** + * Checks (and caches) if the given address is a + * Contract or not, from its fetched bytecode + */ + isContract (contractAddress) { + // If zero address, it isn't a contract + if (/^(0x)?0*$/.test(contractAddress)) { + return Promise.resolve(false); + } + + if (this._isContract[contractAddress]) { + return Promise.resolve(this._isContract[contractAddress]); + } + + this._isContract[contractAddress] = this.api.eth + .getCode(contractAddress) + .then((bytecode) => { + // Is a contract if the address contains *valid* bytecode + const _isContract = bytecode && /^(0x)?([0]*[1-9a-f]+[0]*)+$/.test(bytecode); + + this._isContract[contractAddress] = _isContract; + return this._isContract[contractAddress]; + }); + + return Promise.resolve(this._isContract[contractAddress]); + } + +} diff --git a/js/src/views/Account/Transactions/transactions.js b/js/src/views/Account/Transactions/transactions.js index b8b28208f..eb11e8def 100644 --- a/js/src/views/Account/Transactions/transactions.js +++ b/js/src/views/Account/Transactions/transactions.js @@ -19,7 +19,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import etherscan from '~/3rdparty/etherscan'; -import { Container, TxList } from '~/ui'; +import { Container, TxList, Loading } from '~/ui'; import styles from './transactions.css'; @@ -60,19 +60,32 @@ class Transactions extends Component { } render () { - const { address } = this.props; - const { hashes } = this.state; - return ( - + { this.renderTransactionList() } { this.renderEtherscanFooter() } ); } + renderTransactionList () { + const { address } = this.props; + const { hashes, loading } = this.state; + + if (loading) { + return ( + + ); + } + + return ( + + ); + } + renderEtherscanFooter () { const { traceMode } = this.props; diff --git a/js/src/views/WriteContract/writeContract.css b/js/src/views/WriteContract/writeContract.css index ca47e7332..2502c4060 100644 --- a/js/src/views/WriteContract/writeContract.css +++ b/js/src/views/WriteContract/writeContract.css @@ -32,6 +32,10 @@ flex: 1; flex-direction: row; + // Fallback for browsers not supporting `calc` + min-height: 90vh; + min-height: calc(100vh - 8em); + > * { margin: 0;