TxForm.js 21.8 KiB
Newer Older
// Copyright 2015-2019 Parity Technologies (UK) Ltd.
// This file is part of Parity.
//
Amaury Martiny's avatar
Amaury Martiny committed
// SPDX-License-Identifier: BSD-3-Clause
Amaury Martiny's avatar
Amaury Martiny committed
import React, { Component } from 'react';
import BigNumber from 'bignumber.js';
// import { isHexString } from 'ethereumjs-util';
import { chainId$, transactionCountOf$ } from '@parity/light.js';
Amaury Martiny's avatar
Amaury Martiny committed
import { Clickable, Form as FetherForm, Header } from 'fether-ui';
Thibaut Sardan's avatar
Thibaut Sardan committed
import createDecorator from 'final-form-calculate';
Amaury Martiny's avatar
Amaury Martiny committed
import debounce from 'debounce-promise';
import { Field, Form } from 'react-final-form';
import { fromWei, toWei } from '@parity/api/lib/util/wei';
Amaury Martiny's avatar
Amaury Martiny committed
import { inject, observer } from 'mobx-react';
import { isAddress } from '@parity/api/lib/util/address';
import light from '@parity/light.js-react';
Amaury Martiny's avatar
Amaury Martiny committed
import { Link } from 'react-router-dom';
import { OnChange } from 'react-final-form-listeners';
import { startWith } from 'rxjs/operators';
Amaury Martiny's avatar
Amaury Martiny committed
import { withProps } from 'recompose';

import i18n, { packageNS } from '../../i18n';
import Debug from '../../utils/debug';
import { estimateGas } from '../../utils/transaction';
import RequireHealthOverlay from '../../RequireHealthOverlay';
Amaury Martiny's avatar
Amaury Martiny committed
import TokenBalance from '../../Tokens/TokensList/TokenBalance';
import TxDetails from './TxDetails';
import {
  chainIdToString,
  isEtcChainId,
  isNotErc20TokenAddress
} from '../../utils/chain';
import withAccount from '../../utils/withAccount';
Amaury Martiny's avatar
Amaury Martiny committed
import withBalance, { withEthBalance } from '../../utils/withBalance';
Amaury Martiny's avatar
Amaury Martiny committed
import withTokens from '../../utils/withTokens';
const DEFAULT_AMOUNT_MAX_CHARS = 9;
const MEDIUM_AMOUNT_MAX_CHARS = 14;
Amaury Martiny's avatar
Amaury Martiny committed
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;
}

Amaury Martiny's avatar
Amaury Martiny committed
@inject('parityStore', 'sendStore')
Amaury Martiny's avatar
Amaury Martiny committed
@withTokens
Axel Chalon's avatar
Axel Chalon committed
@withProps(({ match: { params: { tokenAddress } }, tokens }) => ({
  token: tokens[tokenAddress]
@withAccount
  // 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 {
Axel Chalon's avatar
Axel Chalon 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"
        const { parityStore, token } = this.props;
Thibaut Sardan's avatar
Thibaut Sardan committed

        if (this.preValidate(allValues) === true) {
          try {
            newGasEstimate = await estimateGas(
              allValues,
              token,
              parityStore.api
            );
          } catch (error) {
            return new BigNumber(-1);
  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';
  };
Thibaut Sardan's avatar
Thibaut Sardan committed
  calculateMax = (gas, gasPrice) => {
    const { token, balance } = this.props;
    const gasBn = gas ? new BigNumber(gas) : new BigNumber(21000);
    const gasPriceBn = new BigNumber(gasPrice);
Thibaut Sardan's avatar
Thibaut Sardan committed
    let output;
    if (isNotErc20TokenAddress(token.address)) {
Thibaut Sardan's avatar
Thibaut Sardan committed
      output = fromWei(
Amaury Martiny's avatar
Amaury Martiny committed
        toWei(balance).minus(gasBn.multipliedBy(toWei(gasPriceBn, 'shannon')))
Thibaut Sardan's avatar
Thibaut Sardan committed
      );
Thibaut Sardan's avatar
Thibaut Sardan committed
      output = output.isNegative() ? new BigNumber(0) : output;
Thibaut Sardan's avatar
Thibaut Sardan committed
    } else {
      output = balance;
    }
    return output;
  };

      // Allow estimating tx fee when the amount is zero
      // values.amount &&
      values.gas &&
      values.gasPrice &&
      !isNaN(values.amount) &&
      !values.gas.isNaN() &&
  estimatedTxFee = values => {
    if (!this.isEstimatedTxFee(values)) {
      return null;
    }

    return values.gas.multipliedBy(toWei(values.gasPrice, 'shannon'));
  };
      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`);
    }
Thibaut Sardan's avatar
Thibaut Sardan committed
  recalculateMax = (args, state, { changeValue }) => {
Thibaut Sardan's avatar
Thibaut Sardan committed
    changeValue(state, 'amount', value => {
      return this.calculateMax(
        state.formState.values.gas,
        state.formState.values.gasPrice
      );
    });
Thibaut Sardan's avatar
Thibaut Sardan committed
  };

  toggleMax = () => {
    this.setState({ maxSelected: !this.state.maxSelected });
  showDetailsAnchor = () => {
    return (
      <span className='toggle-details'>
        <Clickable onClick={this.toggleDetails}>
          &darr; {i18n.t(`${packageNS}:tx.form.details.details`)}
        </Clickable>
      </span>
    );
  };

  showHideAnchor = () => {
    return (
      <span className='toggle-details'>
        <Clickable onClick={this.toggleDetails}>
          &uarr; {i18n.t(`${packageNS}:tx.form.details.hide`)}
        </Clickable>
      </span>
    );
  };

  toggleDetails = () => {
    const { showDetails } = this.state;

    this.setState({ showDetails: !showDetails });
  };

Amaury Martiny's avatar
Amaury Martiny committed
  render () {
      account: { address, type },
      sendStore: { tx },
    } = this.props;

Amaury Martiny's avatar
Amaury Martiny committed
      <div>
        <Header
          left={
            <Link to={`/tokens/${address}`} className='icon -back'>
              {i18n.t(`${packageNS}:navigation.close`)}
Amaury Martiny's avatar
Amaury Martiny committed
            </Link>
          title={
            token && (
              <h1>
                {i18n.t(`${packageNS}:tx.header_send_prefix`, {
                  token: token.name
                })}
              </h1>
            )
          }
          <div className='window_content'>
            <div className='box -padded'>
              <TokenBalance
                decimals={6}
                drawers={[
                  <Form
                    decorators={[this.decorator]}
                    initialValues={{
                      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}
Thibaut Sardan's avatar
Thibaut Sardan committed
                    render={({
Thibaut Sardan's avatar
Thibaut Sardan committed
                      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}
                          />

                            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}
Thibaut Sardan's avatar
Thibaut Sardan committed
                            <button
                              type='button'
                              className={
                                this.state.maxSelected
Thibaut Sardan's avatar
Thibaut Sardan committed
                                  ? 'button -tiny active max'
                                  : 'button -tiny max'
Thibaut Sardan's avatar
Thibaut Sardan committed
                              }
Thibaut Sardan's avatar
Thibaut Sardan committed
                              onClick={() => {
Thibaut Sardan's avatar
Thibaut Sardan committed
                                this.toggleMax();
                                mutators.recalculateMax();
Thibaut Sardan's avatar
Thibaut Sardan committed
                              }}
Thibaut Sardan's avatar
Thibaut Sardan committed
                            >
                              {i18n.t(`${packageNS}:tx.form.button_max`)}
Thibaut Sardan's avatar
Thibaut Sardan committed
                            </button>
Thibaut Sardan's avatar
Thibaut Sardan committed
                          </Field>
                          <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
                          />
Thibaut Sardan's avatar
Thibaut Sardan committed

                          <TxDetails
                            estimatedTxFee={this.estimatedTxFee(values)}
                            showDetails={showDetails}
                            token={token}
                            values={values}
                          />
                          <OnChange name='gasPrice'>
                            {(value, previous) => {
                              if (this.state.maxSelected) {
Thibaut Sardan's avatar
Thibaut Sardan committed
                                mutators.recalculateMax();
                          <OnChange name='data'>
                            {(value, previous) => {
                              if (this.state.maxSelected) {
                                mutators.recalculateMax();
                              }
                            }}
                          </OnChange>

YJ's avatar
YJ committed
                          {values.to === values.from && (
YJ's avatar
YJ committed
                            <span>
                              <h3>
                                {i18n.t(
                                  `${packageNS}:tx.form.warning.title_same_sender_receiver`
                                )}
                              </h3>
YJ's avatar
YJ committed
                              <p>
                                {i18n.t(
                                  `${packageNS}:tx.form.warning.body_same_sender_receiver`
                                )}
YJ's avatar
YJ committed
                              </p>
                            </span>
YJ's avatar
YJ committed
                          )}
                        </fieldset>
                        <nav className='form-nav'>
                          <div className='form-details-buttons'>
                            {showDetails
                              ? this.showHideAnchor()
                              : this.showDetailsAnchor()}
                          </div>
                            disabled={!valid || validating}
                            className='button'
                          >
                            {validating ||
                            errors.chainId ||
                            errors.ethBalance ||
                            errors.gas ||
                            errors.transactionCount
                              ? i18n.t(`${packageNS}:tx.form.button_checking`)
                              : type === 'signer'
                                ? 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>
Amaury Martiny's avatar
Amaury Martiny committed
          </div>
  renderNull = () => null;

  /**
   * Prevalidate form on user's input. These validations are sync.
   */
Thibaut Sardan's avatar
Thibaut Sardan committed
  preValidate = values => {
    const { balance, chainId: currentChainIdBN, token } = this.props;
    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) {
        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
          }
        )
      };
Thibaut Sardan's avatar
Thibaut Sardan committed
    } 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`)
    } 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`)
      };
Thibaut Sardan's avatar
Thibaut Sardan committed
    return true;
  /**
   * Estimate gas amount, and validate that the user has enough balance to make
   * the tx.
   */
  validateForm = debounce(values => {
      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`)
        };
        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`)
        };
        debug(i18n.t(`${packageNS}:tx.form.validation.estimating_gas`));
        return {
          gas: i18n.t(`${packageNS}:tx.form.validation.estimating_gas`)
        };
Amaury Martiny's avatar
Amaury Martiny committed
      // Verify that `gas + (eth amount if sending eth) <= ethBalance`
          .plus(
            isNotErc20TokenAddress(token.address) ? toWei(values.amount) : 0
          )
          .gt(toWei(values.ethBalance))
        return isNotErc20TokenAddress(token.address)
              `${packageNS}:tx.form.validation.eth_balance_too_low_for_gas`,
              { chain_id: chainIdToString(currentChainIdBN) }
              `${packageNS}:tx.form.validation.eth_balance_too_low`,
              { chain_id: chainIdToString(currentChainIdBN) }
      debug(i18n.t(`${packageNS}:tx.form.validation.valid_tx`));
    } catch (err) {
Thibaut Sardan's avatar
Thibaut Sardan committed
      console.error(err);
        amount: i18n.t(
          `${packageNS}:tx.form.validation.error_estimating_balance`
        )
export default TxForm;