// 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 { 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 { estimateGas } from '../../utils/transaction'; import Debug from '../../utils/debug'; import RequireHealthOverlay from '../../RequireHealthOverlay'; import TokenBalance from '../../Tokens/TokensList/TokenBalance'; import TxDetails from './TxDetails'; 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 const debug = Debug('TxForm'); @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 to send amount+fees // - 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) @withEthBalance // ETH balance @observer class TxForm extends Component { state = { maxSelected: false, showDetails: false }; decorator = createDecorator({ 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 (token.address === 'ETH') { 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 ( 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 ( ↓ Details ); }; showHideAnchor = () => { return ( ↑ 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 (
Close } title={token &&

Send {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(); } }} {values.to === values.from && (

WARNING:

The sender and receiver addresses are the same.

)}
)} /> ]} 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, token } = this.props; if (!values) { return; } if (!values.amount) { 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' }; } else if (token.address === 'ETH' && toWei(values.amount).lt(1)) { return { amount: 'Please enter at least 1 Wei' }; } else if (amountBn.dp() > token.decimals) { return { amount: `Please enter a ${token.name} value of less than ${ token.decimals } decimal places` }; } else if (balance && balance.lt(amountBn)) { return { amount: `You don't have enough ${token.symbol} balance` }; } 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)` }; } 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 { 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('Fetching chain ID'); return { chainId: 'Fetching chain ID' }; } if (!values.ethBalance) { debug('Fetching Ether balance'); return { ethBalance: 'Fetching Ether balance' }; } if (!values.transactionCount) { debug('Fetching transaction count for nonce'); return { transactionCount: 'Fetching transaction count for nonce' }; } if (values.gas && values.gas.eq(-1)) { debug('Unable to estimate gas...'); // Show this error on the `amount` field return { amount: 'Unable to estimate gas...' }; } if (!this.isEstimatedTxFee(values)) { debug('Estimating gas...'); return { gas: 'Estimating gas...' }; } // Verify that `gas + (eth amount if sending eth) <= ethBalance` if ( this.estimatedTxFee(values) .plus(token.address === 'ETH' ? toWei(values.amount) : 0) .gt(toWei(values.ethBalance)) ) { return token.address !== 'ETH' ? { amount: 'ETH balance too low to pay for gas' } : { amount: "You don't have enough ETH balance" }; } debug('Transaction seems valid'); } catch (err) { console.error(err); return { amount: 'Failed estimating balance, please try again' }; } }, 1000); } export default TxForm;