// Copyright 2015-2019 Parity Technologies (UK) Ltd. // This file is part of Parity. // // SPDX-License-Identifier: BSD-3-Clause import React, { Component } from 'react'; import BigNumber from 'bignumber.js'; // import { isHexString } from 'ethereumjs-util'; import { chainId$, transactionCountOf$ } from '@parity/light.js'; import { Clickable, Form as FetherForm, Header } from 'fether-ui'; import createDecorator from 'final-form-calculate'; import debounce from 'debounce-promise'; import { Field, Form } from 'react-final-form'; import { fromWei, toWei } from '@parity/api/lib/util/wei'; import { inject, observer } from 'mobx-react'; import { isAddress } from '@parity/api/lib/util/address'; import light from '@parity/light.js-react'; import { Link } from 'react-router-dom'; import { OnChange } from 'react-final-form-listeners'; import { startWith } from 'rxjs/operators'; import { withProps } from 'recompose'; import i18n, { packageNS } from '../../i18n'; import Debug from '../../utils/debug'; import { estimateGas } from '../../utils/transaction'; import RequireHealthOverlay from '../../RequireHealthOverlay'; import TokenBalance from '../../Tokens/TokensList/TokenBalance'; import TxDetails from './TxDetails'; import { chainIdToString, isEtcChainId, isNotErc20TokenAddress } from '../../utils/chain'; import withAccount from '../../utils/withAccount'; import withBalance, { withEthBalance } from '../../utils/withBalance'; import withTokens from '../../utils/withTokens'; const DEFAULT_AMOUNT_MAX_CHARS = 9; const MEDIUM_AMOUNT_MAX_CHARS = 14; const MAX_GAS_PRICE = 40; // In Gwei const MIN_GAS_PRICE = 3; // Safelow gas price from GasStation, in Gwei // Reference: https://ethereum.stackexchange.com/questions/1106/is-there-a-limit-for-transaction-size const ESTIMATED_MAX_TOTAL_FEE = 0.002; // In ETH const debug = Debug('TxForm'); // Luke's custom `isHexString`. // See https://github.com/MyCryptoHQ/MyCrypto/issues/2592#issuecomment-496432227 function isHexString (value, length) { if (typeof value !== 'string') { return false; } // Check for hex prefix if (value.substring(0, 2) !== '0x') { return false; } // Extract hex value without the 0x prefix // TODO - possible replace with `stripHexPrefix(value). // See https://github.com/ethjs/ethjs-util/blob/master/dist/ethjs-util.js#L2120 const postfixValue = value.slice(2); if (!postfixValue.length) { console.log('No characters found after 0x prefix'); return false; } // Check if non-hex character exists after 0x prefix const match = /[^0-9A-Fa-f]/.exec(postfixValue); if (match) { console.log('Non-hex character match found at ' + match.index); return false; } // Check if the hex string is the expected length if (length && postfixValue.length !== length) { console.log( `Hex value length is ${postfixValue.length} but should be ${length}` ); return false; } return true; } @inject('parityStore', 'sendStore') @withTokens @withProps(({ match: { params: { tokenAddress } }, tokens }) => ({ token: tokens[tokenAddress] })) @withAccount @light({ // We need to wait for 3 values that might take time: // - ethBalance: to check that we have enough balance to send amount+fees // It may be ETH or ETC corresponding to whether we are connected to the // 'foundation' or 'classic' chain. // - chainId & transactionCount: needed to construct the tx // For the three of them, we add the `startWith()` operator so that the UI is // not blocked while waiting for their first response. chainId: () => chainId$().pipe(startWith(undefined)), transactionCount: ({ account: { address } }) => transactionCountOf$(address).pipe(startWith(undefined)) }) @withBalance // Balance of current token (can be ETH or ETC) @withEthBalance // ETH or ETC balance @observer class TxForm extends Component { state = { maxSelected: false, showDetails: false }; decorator = createDecorator({ // FIXME - Why can't we estimate gas when we update the next line to // include the `data` (i.e. `field: /data|to|amount/,`). // If we do then it gives error in console: // ``` // eth_estimateGas([{"data":"0x0000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaffffffffffff", // ... "value":"0x0"}]): -32015: Transaction execution error. // ``` // // And in the UI's "Transaction Details" section it displays: // ``` // Gas Limit: -1 // Fee: -0.000000004 ETH (gas limit * gas price) // Total Amount: -4e-9 ETH // ``` field: /to|amount/, // when the value of these fields change... updates: { // ...set field "gas" gas: async (value, allValues) => { const { parityStore, token } = this.props; let newGasEstimate = null; if (this.preValidate(allValues) === true) { try { newGasEstimate = await estimateGas( allValues, token, parityStore.api ); } catch (error) { return new BigNumber(-1); } } return newGasEstimate; } } }); changeAmountFontSize = amount => { const amountLen = amount.toString().length; if (amountLen > MEDIUM_AMOUNT_MAX_CHARS) { return '-resize-font-small'; // Resize to fit an amount as small as one Wei } else if ( MEDIUM_AMOUNT_MAX_CHARS >= amountLen && amountLen > DEFAULT_AMOUNT_MAX_CHARS ) { return '-resize-font-medium'; } return '-resize-font-default'; }; calculateMax = (gas, gasPrice) => { const { token, balance } = this.props; const gasBn = gas ? new BigNumber(gas) : new BigNumber(21000); const gasPriceBn = new BigNumber(gasPrice); let output; if (isNotErc20TokenAddress(token.address)) { output = fromWei( toWei(balance).minus(gasBn.multipliedBy(toWei(gasPriceBn, 'shannon'))) ); output = output.isNegative() ? new BigNumber(0) : output; } else { output = balance; } return output; }; isEstimatedTxFee = values => { if ( // Allow estimating tx fee when the amount is zero // values.amount && values.gas && values.gasPrice && !isNaN(values.amount) && !values.gas.isNaN() && !isNaN(values.gasPrice) ) { return true; } return false; }; estimatedTxFee = values => { if (!this.isEstimatedTxFee(values)) { return null; } return values.gas.multipliedBy(toWei(values.gasPrice, 'shannon')); }; handleSubmit = values => { const { account: { address, type }, history, sendStore, token } = this.props; sendStore.setTx({ ...values, token }); if (type === 'signer') { history.push(`/send/${token.address}/from/${address}/txqrcode`); } else { history.push(`/send/${token.address}/from/${address}/unlock`); } }; recalculateMax = (args, state, { changeValue }) => { changeValue(state, 'amount', value => { return this.calculateMax( state.formState.values.gas, state.formState.values.gasPrice ); }); }; toggleMax = () => { this.setState({ maxSelected: !this.state.maxSelected }); }; showDetailsAnchor = () => { return ( ↓ {i18n.t(`${packageNS}:tx.form.details.details`)} ); }; showHideAnchor = () => { return ( ↑ {i18n.t(`${packageNS}:tx.form.details.hide`)} ); }; toggleDetails = () => { const { showDetails } = this.state; this.setState({ showDetails: !showDetails }); }; render () { const { account: { address, type }, chainId, ethBalance, sendStore: { tx }, token, transactionCount } = this.props; const { showDetails } = this.state; return (
{i18n.t(`${packageNS}:navigation.close`)} } title={ token && (

{i18n.t(`${packageNS}:tx.header_send_prefix`, { token: token.name })}

) } />
(
{/* Unfortunately, we need to set these hidden fields for the 3 values that come from props, even though they are already set in initialValues. */} {(value, previous) => { if (this.state.maxSelected) { mutators.recalculateMax(); } }} {(value, previous) => { if (this.state.maxSelected) { mutators.recalculateMax(); } }} {values.to === values.from && (

{i18n.t( `${packageNS}:tx.form.warning.title_same_sender_receiver` )}

{i18n.t( `${packageNS}:tx.form.warning.body_same_sender_receiver` )}

)}
)} /> ]} onClick={null} // To disable cursor:pointer on card // TODO Can this be done better? token={token} />
); } renderNull = () => null; /** * Prevalidate form on user's input. These validations are sync. */ preValidate = values => { const { balance, chainId: currentChainIdBN, token } = this.props; if (!values) { return; } const amountBn = new BigNumber(values.amount.toString()); if (amountBn.isNaN()) { return { amount: i18n.t(`${packageNS}:tx.form.validation.amount_invalid`) }; } else if (amountBn.isNegative()) { return { amount: i18n.t(`${packageNS}:tx.form.validation.positive_amount`) }; // Question: Why do we require a minimum value in wei when users may often // send transactions with a value of say 0 ETH and a "data" value instead. // For example: https://github.com/chainx-org/ChainX/issues/66 // } else if ( // isNotErc20TokenAddress(token.address) && // toWei(values.amount).lt(1) // ) { // return { // amount: i18n.t(`${packageNS}:tx.form.validation.min_wei`) // }; } else if (amountBn.dp() > token.decimals) { return { amount: i18n.t(`${packageNS}:tx.form.validation.min_decimals`, { token_name: token.name, token_decimals: token.decimals }) }; // Transaction without any "data" } else if (!values.data && balance && balance.lt(amountBn)) { return { amount: i18n.t( `${packageNS}:tx.form.validation.token_balance_too_low`, { token_symbol: token.symbol } ) }; // Transaction size may be large when user provides "data" // so we need to ensure they have sufficient balance to cover worst-case fee } else if (values.data && balance && balance.lt(ESTIMATED_MAX_TOTAL_FEE)) { return { amount: i18n.t( `${packageNS}:tx.form.validation.token_balance_too_low`, { token_symbol: token.symbol } ) }; } else if (!values.to || !isAddress(values.to)) { return { to: isEtcChainId(currentChainIdBN) ? i18n.t(`${packageNS}:tx.form.validation.invalid_etc_address`) : i18n.t(`${packageNS}:tx.form.validation.invalid_eth_address`) }; } else if (values.to === '0x0000000000000000000000000000000000000000') { return { to: i18n.t( `${packageNS}:tx.form.validation.prevent_send_zero_account`, { token_name: token.name } ) }; // "data" field should be a hex string (not a hash). // See https://github.com/chainx-org/ChainX/issues/66#issuecomment-496446585 } else if (values.data && !isHexString(values.data)) { return { amount: i18n.t(`${packageNS}:tx.form.validation.data_invalid`) }; } return true; }; /** * Estimate gas amount, and validate that the user has enough balance to make * the tx. */ validateForm = debounce(values => { if (!values) { return; } try { const { chainId: currentChainIdBN, token } = this.props; const preValidation = this.preValidate(values); // preValidate return an error if a field isn't valid if (preValidation !== true) { return preValidation; } // The 3 values below (`chainId`, `ethBalance`, and `transactionCount`) // come from props, and are passed into `values` via the form's // initialValues. As such, they don't have visible fields, so these // errors won't actually be shown on the UI. if (!values.chainId) { debug(i18n.t(`${packageNS}:tx.form.validation.fetching_chain_id`)); return { chainId: i18n.t(`${packageNS}:tx.form.validation.fetching_chain_id`) }; } if (!values.ethBalance) { debug(i18n.t(`${packageNS}:tx.form.validation.fetching_eth_balance`)); return { ethBalance: i18n.t( `${packageNS}:tx.form.validation.fetching_eth_balance` ) }; } if (!values.transactionCount) { debug(i18n.t(`${packageNS}:tx.form.validation.fetching_tx_count`)); return { transactionCount: i18n.t( `${packageNS}:tx.form.validation.fetching_tx_count` ) }; } if (values.gas && values.gas.eq(-1)) { debug(i18n.t(`${packageNS}:tx.form.validation.unable_estimate_gas`)); // Show this error on the `amount` field return { amount: i18n.t(`${packageNS}:tx.form.validation.unable_estimate_gas`) }; } if (!this.isEstimatedTxFee(values)) { debug(i18n.t(`${packageNS}:tx.form.validation.estimating_gas`)); return { gas: i18n.t(`${packageNS}:tx.form.validation.estimating_gas`) }; } // Verify that `gas + (eth amount if sending eth) <= ethBalance` if ( this.estimatedTxFee(values) .plus( isNotErc20TokenAddress(token.address) ? toWei(values.amount) : 0 ) .gt(toWei(values.ethBalance)) ) { return isNotErc20TokenAddress(token.address) ? { amount: i18n.t( `${packageNS}:tx.form.validation.eth_balance_too_low_for_gas`, { chain_id: chainIdToString(currentChainIdBN) } ) } : { amount: i18n.t( `${packageNS}:tx.form.validation.eth_balance_too_low`, { chain_id: chainIdToString(currentChainIdBN) } ) }; } debug(i18n.t(`${packageNS}:tx.form.validation.valid_tx`)); } catch (err) { console.error(err); return { amount: i18n.t( `${packageNS}:tx.form.validation.error_estimating_balance` ) }; } }, 1000); } export default TxForm;