Newer
Older
// Copyright 2015-2019 Parity Technologies (UK) Ltd.
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 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 { OnChange } from 'react-final-form-listeners';
import { startWith } from 'rxjs/operators';
import i18n, { packageNS } from '../../i18n';
import Debug from '../../utils/debug';
import { estimateGas } from '../../utils/transaction';
Luke Schoen
committed
import RequireHealthOverlay from '../../RequireHealthOverlay';
import TokenBalance from '../../Tokens/TokensList/TokenBalance';
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');
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 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;
}
@withProps(({ match: { params: { tokenAddress } }, tokens }) => ({
token: tokens[tokenAddress]
// 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
class TxForm extends Component {
Luke Schoen
committed
state = {
Luke Schoen
committed
maxSelected: false,
showDetails: false
Luke Schoen
committed
};
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"
Luke Schoen
committed
gas: async (value, allValues) => {
const { parityStore, token } = this.props;
Luke Schoen
committed
let newGasEstimate = null;
Luke Schoen
committed
try {
newGasEstimate = await estimateGas(
allValues,
token,
parityStore.api
);
} catch (error) {
Luke Schoen
committed
}
Luke Schoen
committed
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);
if (isNotErc20TokenAddress(token.address)) {
toWei(balance).minus(gasBn.multipliedBy(toWei(gasPriceBn, 'shannon')))
output = output.isNegative() ? new BigNumber(0) : output;
} else {
output = balance;
}
return output;
};
Luke Schoen
committed
isEstimatedTxFee = values => {
Luke Schoen
committed
if (
// Allow estimating tx fee when the amount is zero
// values.amount &&
Luke Schoen
committed
values.gas &&
values.gasPrice &&
!isNaN(values.amount) &&
!values.gas.isNaN() &&
Luke Schoen
committed
!isNaN(values.gasPrice)
Luke Schoen
committed
) {
Luke Schoen
committed
return true;
}
return false;
};
Luke Schoen
committed
estimatedTxFee = values => {
if (!this.isEstimatedTxFee(values)) {
Luke Schoen
committed
return null;
}
return values.gas.multipliedBy(toWei(values.gasPrice, 'shannon'));
};
handleSubmit = values => {
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 });
Luke Schoen
committed
showDetailsAnchor = () => {
return (
<span className='toggle-details'>
<Clickable onClick={this.toggleDetails}>
↓ {i18n.t(`${packageNS}:tx.form.details.details`)}
</Clickable>
Luke Schoen
committed
</span>
);
};
showHideAnchor = () => {
return (
<span className='toggle-details'>
<Clickable onClick={this.toggleDetails}>
↑ {i18n.t(`${packageNS}:tx.form.details.hide`)}
</Clickable>
Luke Schoen
committed
</span>
);
};
toggleDetails = () => {
const { showDetails } = this.state;
this.setState({ showDetails: !showDetails });
};
chainId,
ethBalance,
token,
transactionCount
Luke Schoen
committed
const { showDetails } = this.state;
<Link to={`/tokens/${address}`} className='icon -back'>
{i18n.t(`${packageNS}:navigation.close`)}
title={
token && (
<h1>
{i18n.t(`${packageNS}:tx.header_send_prefix`, {
token: token.name
})}
</h1>
)
}
Luke Schoen
committed
<RequireHealthOverlay require='sync'>
<div className='window_content'>
<div className='box -padded'>
<TokenBalance
decimals={6}
drawers={[
<Form
decorators={[this.decorator]}
initialValues={{
amount: 0,
chainId,
ethBalance,
from: address,
gasPrice: 4,
transactionCount,
...tx
}}
keepDirtyOnReinitialize // Don't erase other fields when we get new initialValues
mutators={{
recalculateMax: this.recalculateMax
}}
onSubmit={this.handleSubmit}
validate={this.validateForm}
handleSubmit,
valid,
validating,
values,
form: { mutators }
}) => (
<form
className='send-form'
noValidate
onSubmit={handleSubmit}
>
<fieldset className='form_fields'>
{/* Unfortunately, we need to set these hidden fields
for the 3 values that come from props, even
though they are already set in initialValues. */}
<Field name='chainId' render={this.renderNull} />
<Field name='ethBalance' render={this.renderNull} />
<Field
name='transactionCount'
render={this.renderNull}
/>
<Field
as='textarea'
autoFocus
className='-sm'
label={i18n.t(`${packageNS}:tx.form.field.to`)}
name='to'
placeholder='0x...'
required
render={FetherForm.Field}
/>
className={`form_field_amount ${
!values.amount
? '-resize-font-default'
: this.changeAmountFontSize(values.amount)
}`}
disabled={this.state.maxSelected}
label={i18n.t(`${packageNS}:tx.form.field.amount`)}
name='amount'
placeholder='0.00'
render={FetherForm.Field}
<button
type='button'
className={
this.state.maxSelected
? 'button -tiny active max'
: 'button -tiny max'
{i18n.t(`${packageNS}:tx.form.button_max`)}
<Field
as='textarea'
className='-sm'
label={i18n.t(`${packageNS}:tx.form.field.data`)}
name='data'
placeholder='0x...'
render={FetherForm.Field}
/>
<Field
centerText={`${values.gasPrice} GWEI`}
className='-range'
label={i18n.t(
`${packageNS}:tx.form.field.tx_speed`
)}
leftText={i18n.t(`${packageNS}:tx.form.field.low`)}
max={MAX_GAS_PRICE}
min={MIN_GAS_PRICE}
name='gasPrice'
render={FetherForm.Slider}
required
rightText={i18n.t(
`${packageNS}:tx.form.field.high`
)}
step={0.5}
type='range' // In Gwei
/>
<TxDetails
estimatedTxFee={this.estimatedTxFee(values)}
showDetails={showDetails}
token={token}
values={values}
/>
Luke Schoen
committed
<OnChange name='gasPrice'>
{(value, previous) => {
if (this.state.maxSelected) {
<OnChange name='data'>
{(value, previous) => {
if (this.state.maxSelected) {
mutators.recalculateMax();
}
}}
</OnChange>
<h3>
{i18n.t(
`${packageNS}:tx.form.warning.title_same_sender_receiver`
)}
</h3>
{i18n.t(
`${packageNS}:tx.form.warning.body_same_sender_receiver`
)}
</fieldset>
<nav className='form-nav'>
<div className='form-details-buttons'>
{showDetails
? this.showHideAnchor()
: this.showDetailsAnchor()}
</div>
disabled={!valid || validating}
{validating ||
errors.chainId ||
errors.ethBalance ||
errors.gas ||
errors.transactionCount
? i18n.t(`${packageNS}:tx.form.button_checking`)
? i18n.t(`${packageNS}:tx.form.button_scan`)
: i18n.t(`${packageNS}:tx.form.button_send`)}
</button>
</nav>
</form>
)}
/>
]}
onClick={null} // To disable cursor:pointer on card // TODO Can this be done better?
token={token}
/>
</div>
Luke Schoen
committed
</RequireHealthOverlay>
renderNull = () => null;
/**
* Prevalidate form on user's input. These validations are sync.
*/
const { balance, chainId: currentChainIdBN, token } = this.props;
Luke Schoen
committed
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) {
Luke Schoen
committed
return {
amount: i18n.t(`${packageNS}:tx.form.validation.min_decimals`, {
token_name: token.name,
token_decimals: token.decimals
})
Luke Schoen
committed
};
// 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)) {
to: isEtcChainId(currentChainIdBN)
? i18n.t(`${packageNS}:tx.form.validation.invalid_etc_address`)
: i18n.t(`${packageNS}:tx.form.validation.invalid_eth_address`)
Luke Schoen
committed
} else if (values.to === '0x0000000000000000000000000000000000000000') {
return {
to: i18n.t(
`${packageNS}:tx.form.validation.prevent_send_zero_account`,
{
token_name: token.name
}
)
Luke Schoen
committed
};
// "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`)
};
/**
* Estimate gas amount, and validate that the user has enough balance to make
* the tx.
*/
validateForm = debounce(values => {
Luke Schoen
committed
if (!values) {
return;
}
const { chainId: currentChainIdBN, token } = this.props;
Luke Schoen
committed
const preValidation = this.preValidate(values);
Luke Schoen
committed
// preValidate return an error if a field isn't valid
if (preValidation !== true) {
return preValidation;
Luke Schoen
committed
}
// 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`)
};
Luke Schoen
committed
}
Luke Schoen
committed
if (!values.ethBalance) {
debug(i18n.t(`${packageNS}:tx.form.validation.fetching_eth_balance`));
return {
ethBalance: i18n.t(
`${packageNS}:tx.form.validation.fetching_eth_balance`
)
};
Luke Schoen
committed
}
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`)
};
Luke Schoen
committed
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`
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`));
amount: i18n.t(
`${packageNS}:tx.form.validation.error_estimating_balance`
)