TxForm.js 13.1 KiB
Newer Older
// Copyright 2015-2018 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';
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';
import { chainId$, withoutLoading } from '@parity/light.js';
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';
Amaury Martiny's avatar
Amaury Martiny committed
import { withProps } from 'recompose';

import { estimateGas } from '../../utils/transaction';
import RequireHealth from '../../RequireHealthOverlay';
Amaury Martiny's avatar
Amaury Martiny committed
import TokenBalance from '../../Tokens/TokensList/TokenBalance';
import TxDetails from './TxDetails';
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

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
@light({
  chainId: () => chainId$().pipe(withoutLoading())
})
@withBalance // Balance of current token (can be ETH)
@withEthBalance // ETH balance
class TxForm extends Component {
Axel Chalon's avatar
Axel Chalon committed

  decorator = createDecorator({
    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) {
            console.error(error);
            throw new Error('Unable to estimate gas');
          }
  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;
Thibaut Sardan's avatar
Thibaut Sardan committed
    if (token.address === 'ETH') {
      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;
  };

  estimatedTxFee = values => {
    if (
      !values.amount ||
      !values.gas ||
      !values.gasPrice ||
      isNaN(values.amount) ||
      isNaN(values.gas) ||
      isNaN(values.gasPrice)
    ) {
      return null;
    }

    return values.gas.multipliedBy(toWei(values.gasPrice, 'shannon'));
  };
    const {
      account: { address, type, transactionCount },
      chainId,
      history,
      sendStore,
      token
    } = this.props;

    sendStore.setTx({ ...values, chainId, token, transactionCount });
    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'>
Amaury Martiny's avatar
Amaury Martiny committed
        <Clickable onClick={this.toggleDetails}>&darr; Details</Clickable>
      </span>
    );
  };

  showHideAnchor = () => {
    return (
      <span className='toggle-details'>
Amaury Martiny's avatar
Amaury Martiny committed
        <Clickable onClick={this.toggleDetails}>&uarr; 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 },
      token
    } = this.props;

Amaury Martiny's avatar
Amaury Martiny committed
      <div>
        <Header
          left={
            <Link to={`/tokens/${address}`} className='icon -back'>
Amaury Martiny's avatar
Amaury Martiny committed
              Close
            </Link>
          title={token && <h1>Send {token.name}</h1>}
        <RequireHealth require='sync'>
          <div className='window_content'>
            <div className='box -padded'>
              <TokenBalance
                decimals={6}
                drawers={[
                  <Form
                    key='txForm'
                    initialValues={{ from: address, gasPrice: 4, ...tx }}
                    onSubmit={this.handleSubmit}
                    validate={this.validateForm}
                    decorators={[this.decorator]}
Thibaut Sardan's avatar
Thibaut Sardan committed
                    mutators={{ recalculateMax: this.recalculateMax }}
Thibaut Sardan's avatar
Thibaut Sardan committed
                    render={({
                      handleSubmit,
                      valid,
                      validating,
                      values,
                      form: { mutators }
                    }) => (
                      <form className='send-form' onSubmit={handleSubmit}>
                        <fieldset className='form_fields'>
                            className='-sm'
                            label='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}
                            formNoValidate
                            label='Amount'
                            name='amount'
                            placeholder='0.00'
                            render={FetherForm.Field}
                            required
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
                            >
Thibaut Sardan's avatar
Thibaut Sardan committed
                              Max
Thibaut Sardan's avatar
Thibaut Sardan committed
                            </button>
Thibaut Sardan's avatar
Thibaut Sardan committed
                          </Field>
                          <Field
                            centerText={`${values.gasPrice} GWEI`}
                            className='-range'
                            label='Transaction Speed'
Thibaut Sardan's avatar
Thibaut Sardan committed
                            leftText='Low'
                            max={MAX_GAS_PRICE}
                            min={MIN_GAS_PRICE}
                            name='gasPrice'
                            render={FetherForm.Slider}
                            required
Thibaut Sardan's avatar
Thibaut Sardan committed
                            rightText='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();
YJ's avatar
YJ committed
                          {values.to === values.from && (
YJ's avatar
YJ committed
                            <span>
                              <h3>WARNING:</h3>
                              <p>
                                The sender and receiver addresses are the same.
                              </p>
                            </span>
YJ's avatar
YJ committed
                          )}
                        </fieldset>
                        <nav className='form-nav'>
                          <div className='form-details-buttons'>
                            {showDetails
                              ? this.showHideAnchor()
                              : this.showDetailsAnchor()}
                          </div>
                          <button
                            disabled={!valid || validating}
                            className='button'
                          >
                            {validating
                              ? 'Checking...'
                              : type === 'signer'
                                ? 'Scan'
                                : '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>
        </RequireHealth>
Thibaut Sardan's avatar
Thibaut Sardan committed
  preValidate = values => {
Thibaut Sardan's avatar
Thibaut Sardan committed
    const { balance, token } = this.props;
Thibaut Sardan's avatar
Thibaut Sardan committed
      return { amount: 'Please enter a valid amount' };
    }

    const amountBn = new BigNumber(values.amount.toString());

    if (amountBn.isNaN()) {
      return { amount: 'Please enter a valid amount' };
    } else if (amountBn.isZero()) {
      if (this.state.maxSelected) {
        return { amount: 'ETH balance too low to pay for gas.' };
      }
      return { amount: 'Please enter a non-zero amount' };
    } else if (amountBn.isNegative()) {
      return { amount: 'Please enter a positive amount' };
Thibaut Sardan's avatar
Thibaut Sardan committed
    } else if (token.address === 'ETH' && toWei(values.amount).lt(1)) {
      return { amount: 'Please enter at least 1 Wei' };
    } else if (amountBn.dp() > token.decimals) {
        amount: `Please enter a ${token.name} value of less than ${
    } else if (balance && balance.lt(amountBn)) {
Thibaut Sardan's avatar
Thibaut Sardan committed
      return { amount: `You don't have enough ${token.symbol} balance` };
Thibaut Sardan's avatar
Thibaut Sardan committed
    } else if (!values.to || !isAddress(values.to)) {
      return { to: 'Please enter a valid Ethereum address' };
    } else if (values.to === '0x0000000000000000000000000000000000000000') {
      return {
        to: `You are not permitted to send ${
          token.name
        } to the zero account (0x0)`
      };
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 => {
Thibaut Sardan's avatar
Thibaut Sardan committed
      const { ethBalance, token } = this.props;
Thibaut Sardan's avatar
Thibaut Sardan committed
      const preValidation = this.preValidate(values);
Thibaut Sardan's avatar
Thibaut Sardan committed
      // preValidate return an error if a field isn't valid
Thibaut Sardan's avatar
Thibaut Sardan committed
      if (preValidation !== true) {
Thibaut Sardan's avatar
Thibaut Sardan committed
        return preValidation;
      // If the gas hasn't been calculated yet, then we don't show any errors,
      // just wait a bit more
Amaury Martiny's avatar
Amaury Martiny committed
      // Verify that `gas + (eth amount if sending eth) <= ethBalance`
Amaury Martiny's avatar
Amaury Martiny committed
          .plus(token.address === 'ETH' ? toWei(values.amount) : 0)
Amaury Martiny's avatar
Amaury Martiny committed
          .gt(toWei(ethBalance))
Thibaut Sardan's avatar
Thibaut Sardan committed
        return token.address !== 'ETH'
          ? { amount: 'ETH balance too low to pay for gas' }
Luke Schoen's avatar
Luke Schoen committed
          : { amount: "You don't have enough ETH balance" };
Thibaut Sardan's avatar
Thibaut Sardan committed
      console.error(err);
Amaury Martiny's avatar
Amaury Martiny committed
        amount: 'Failed estimating balance, please try again'
export default TxForm;