Improved Gas Estimate UX (#830)

This commit is contained in:
James Prado 2018-01-15 04:59:59 -05:00 committed by Daniel Ternyak
parent 67b2e6491c
commit 6108d08693
34 changed files with 560 additions and 284 deletions

View File

@ -9,6 +9,13 @@ export function toggleOfflineConfig(): interfaces.ToggleOfflineAction {
};
}
export type TToggleAutoGasLimit = typeof toggleAutoGasLimit;
export function toggleAutoGasLimit(): interfaces.ToggleAutoGasLimitAction {
return {
type: TypeKeys.CONFIG_TOGGLE_AUTO_GAS_LIMIT
};
}
export type TChangeLanguage = typeof changeLanguage;
export function changeLanguage(sign: string): interfaces.ChangeLanguageAction {
return {

View File

@ -6,6 +6,10 @@ export interface ToggleOfflineAction {
type: TypeKeys.CONFIG_TOGGLE_OFFLINE;
}
export interface ToggleAutoGasLimitAction {
type: TypeKeys.CONFIG_TOGGLE_AUTO_GAS_LIMIT;
}
/*** Change Language ***/
export interface ChangeLanguageAction {
type: TypeKeys.CONFIG_LANGUAGE_CHANGE;
@ -74,6 +78,7 @@ export type ConfigAction =
| ChangeNodeAction
| ChangeLanguageAction
| ToggleOfflineAction
| ToggleAutoGasLimitAction
| PollOfflineStatus
| ChangeNodeIntentAction
| AddCustomNodeAction

View File

@ -3,6 +3,7 @@ export enum TypeKeys {
CONFIG_NODE_CHANGE = 'CONFIG_NODE_CHANGE',
CONFIG_NODE_CHANGE_INTENT = 'CONFIG_NODE_CHANGE_INTENT',
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
CONFIG_TOGGLE_AUTO_GAS_LIMIT = 'CONFIG_TOGGLE_AUTO_GAS_LIMIT',
CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS',
CONFIG_ADD_CUSTOM_NODE = 'CONFIG_ADD_CUSTOM_NODE',
CONFIG_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE',

View File

@ -1,7 +1,8 @@
import {
TypeKeys,
EstimateGasFailedAction,
EstimateGasRequestedAction,
TypeKeys,
EstimateGasTimeoutAction,
EstimateGasSucceededAction,
GetFromRequestedAction,
GetFromSucceededAction,
@ -29,6 +30,11 @@ const estimateGasFailed = (): EstimateGasFailedAction => ({
type: TypeKeys.ESTIMATE_GAS_FAILED
});
type TEstimateGasTimedout = typeof estimateGasTimedout;
const estimateGasTimedout = (): EstimateGasTimeoutAction => ({
type: TypeKeys.ESTIMATE_GAS_TIMEDOUT
});
type TGetFromRequested = typeof getFromRequested;
const getFromRequested = (): GetFromRequestedAction => ({
type: TypeKeys.GET_FROM_REQUESTED
@ -63,6 +69,7 @@ const getNonceFailed = (): GetNonceFailedAction => ({
export {
estimateGasRequested,
estimateGasFailed,
estimateGasTimedout,
estimateGasSucceeded,
getFromRequested,
getFromSucceeded,
@ -73,6 +80,7 @@ export {
TEstimateGasRequested,
TEstimateGasFailed,
TEstimateGasSucceeded,
TEstimateGasTimedout,
TGetFromRequested,
TGetFromSucceeded,
TGetNonceRequested,

View File

@ -11,6 +11,9 @@ interface EstimateGasSucceededAction {
interface EstimateGasFailedAction {
type: TypeKeys.ESTIMATE_GAS_FAILED;
}
interface EstimateGasTimeoutAction {
type: TypeKeys.ESTIMATE_GAS_TIMEDOUT;
}
interface GetFromRequestedAction {
type: TypeKeys.GET_FROM_REQUESTED;
}
@ -36,6 +39,7 @@ type NetworkAction =
| EstimateGasFailedAction
| EstimateGasRequestedAction
| EstimateGasSucceededAction
| EstimateGasTimeoutAction
| GetFromRequestedAction
| GetFromSucceededAction
| GetFromFailedAction
@ -47,6 +51,7 @@ export {
EstimateGasRequestedAction,
EstimateGasSucceededAction,
EstimateGasFailedAction,
EstimateGasTimeoutAction,
GetFromRequestedAction,
GetFromSucceededAction,
GetFromFailedAction,

View File

@ -2,6 +2,7 @@ export enum TypeKeys {
ESTIMATE_GAS_REQUESTED = 'ESTIMATE_GAS_REQUESTED',
ESTIMATE_GAS_SUCCEEDED = 'ESTIMATE_GAS_SUCCEEDED',
ESTIMATE_GAS_FAILED = 'ESTIMATE_GAS_FAILED',
ESTIMATE_GAS_TIMEDOUT = 'ESTIMATE_GAS_TIMEDOUT',
GET_FROM_REQUESTED = 'GET_FROM_REQUESTED',
GET_FROM_SUCCEEDED = 'GET_FROM_SUCCEEDED',

View File

@ -1,32 +1,22 @@
import { DataFieldFactory } from './DataFieldFactory';
import React from 'react';
import { Expandable, ExpandHandler } from 'components/ui';
import translate from 'translations';
import { donationAddressMap } from 'config/data';
const expander = (expandHandler: ExpandHandler) => (
<a onClick={expandHandler}>
<p className="strong">{translate('TRANS_advanced')}</p>
</a>
);
export const DataField: React.SFC<{}> = () => (
<DataFieldFactory
withProps={({ data: { raw }, dataExists, onChange, readOnly }) => (
<Expandable expandLabel={expander}>
<div className="form-group">
<label>{translate('TRANS_data')}</label>
<input
className={`form-control ${dataExists ? 'is-valid' : 'is-invalid'}`}
type="text"
placeholder={donationAddressMap.ETH}
value={raw}
readOnly={!!readOnly}
onChange={onChange}
/>
</div>
</Expandable>
<>
<label>{translate('OFFLINE_Step2_Label_6')}</label>
<input
className={`form-control ${dataExists ? 'is-valid' : 'is-invalid'}`}
type="text"
placeholder={donationAddressMap.ETH}
value={raw}
readOnly={!!readOnly}
onChange={onChange}
/>
</>
)}
/>
);

View File

@ -1,19 +1,44 @@
import React from 'react';
import { GasLimitFieldFactory } from './GasLimitFieldFactory';
import translate from 'translations';
import { CSSTransition } from 'react-transition-group';
import { Spinner } from 'components/ui';
export const GasLimitField: React.SFC<{}> = () => (
interface Props {
includeLabel: boolean;
onlyIncludeLoader: boolean;
}
export const GaslimitLoading: React.SFC<{ gasEstimationPending: boolean }> = ({
gasEstimationPending
}) => (
<CSSTransition in={gasEstimationPending} timeout={300} classNames="fade">
<div className={`SimpleGas-estimating small ${gasEstimationPending ? 'active' : ''}`}>
Calculating gas limit
<Spinner />
</div>
</CSSTransition>
);
export const GasLimitField: React.SFC<Props> = ({ includeLabel, onlyIncludeLoader }) => (
<React.Fragment>
<label>{translate('TRANS_gas')} </label>
{includeLabel ? <label>{translate('TRANS_gas')} </label> : null}
<GasLimitFieldFactory
withProps={({ gasLimit: { raw, value }, onChange, readOnly }) => (
<input
className={`form-control ${!!value ? 'is-valid' : 'is-invalid'}`}
type="text"
readOnly={!!readOnly}
value={raw}
onChange={onChange}
/>
withProps={({ gasLimit: { raw, value }, onChange, readOnly, gasEstimationPending }) => (
<>
<GaslimitLoading gasEstimationPending={gasEstimationPending} />
{onlyIncludeLoader ? null : (
<input
className={`form-control ${!!value ? 'is-valid' : 'is-invalid'}`}
type="number"
placeholder="e.g. 21000"
readOnly={!!readOnly}
value={raw}
onChange={onChange}
/>
)}
</>
)}
/>
</React.Fragment>

View File

@ -10,6 +10,7 @@ const defaultGasLimit = '21000';
export interface CallBackProps {
readOnly: boolean;
gasLimit: AppState['transaction']['fields']['gasLimit'];
gasEstimationPending: boolean;
onChange(value: React.FormEvent<HTMLInputElement>): void;
}

View File

@ -2,11 +2,14 @@ import React, { Component } from 'react';
import { Query } from 'components/renderCbs';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
import { getGasLimit } from 'selectors/transaction';
import { getGasLimit, getGasEstimationPending } from 'selectors/transaction';
import { CallBackProps } from 'components/GasLimitFieldFactory';
import { getAutoGasLimitEnabled } from 'selectors/config';
interface StateProps {
gasLimit: AppState['transaction']['fields']['gasLimit'];
gasEstimationPending: boolean;
autoGasLimitEnabled: boolean;
}
interface OwnProps {
@ -17,18 +20,24 @@ interface OwnProps {
type Props = StateProps & OwnProps;
class GasLimitInputClass extends Component<Props> {
public render() {
const { gasLimit, onChange } = this.props;
const { gasLimit, onChange, gasEstimationPending, autoGasLimitEnabled } = this.props;
return (
<Query
params={['readOnly']}
withQuery={({ readOnly }) =>
this.props.withProps({ gasLimit, onChange, readOnly: !!readOnly })
this.props.withProps({
gasLimit,
onChange,
readOnly: !!(readOnly || autoGasLimitEnabled),
gasEstimationPending
})
}
/>
);
}
}
export const GasLimitInput = connect((state: AppState) => ({ gasLimit: getGasLimit(state) }))(
GasLimitInputClass
);
export const GasLimitInput = connect((state: AppState) => ({
gasLimit: getGasLimit(state),
gasEstimationPending: getGasEstimationPending(state),
autoGasLimitEnabled: getAutoGasLimitEnabled(state)
}))(GasLimitInputClass);

View File

@ -1,37 +1,32 @@
import React from 'react';
import { translateRaw } from 'translations';
import { connect } from 'react-redux';
import {
inputGasPrice,
TInputGasPrice,
inputGasLimit,
TInputGasLimit,
inputNonce,
TInputNonce
} from 'actions/transaction';
import { inputGasPrice, TInputGasPrice } from 'actions/transaction';
import { fetchCCRates, TFetchCCRates } from 'actions/rates';
import { getNetworkConfig } from 'selectors/config';
import { getNetworkConfig, getOffline } from 'selectors/config';
import { AppState } from 'reducers';
import SimpleGas from './components/SimpleGas';
import AdvancedGas from './components/AdvancedGas';
import './GasSlider.scss';
import { getGasPrice } from 'selectors/transaction';
interface Props {
// Component configuration
disableAdvanced?: boolean;
// Data
interface StateProps {
gasPrice: AppState['transaction']['fields']['gasPrice'];
gasLimit: AppState['transaction']['fields']['gasLimit'];
nonce: AppState['transaction']['fields']['nonce'];
offline: AppState['config']['offline'];
network: AppState['config']['network'];
// Actions
}
interface DispatchProps {
inputGasPrice: TInputGasPrice;
inputGasLimit: TInputGasLimit;
inputNonce: TInputNonce;
fetchCCRates: TFetchCCRates;
}
interface OwnProps {
disableAdvanced?: boolean;
}
type Props = DispatchProps & OwnProps & StateProps;
interface State {
showAdvanced: boolean;
}
@ -54,22 +49,15 @@ class GasSlider extends React.Component<Props, State> {
}
public render() {
const { gasPrice, gasLimit, nonce, offline, disableAdvanced } = this.props;
const { offline, disableAdvanced, gasPrice } = this.props;
const showAdvanced = (this.state.showAdvanced || offline) && !disableAdvanced;
return (
<div className="GasSlider">
{showAdvanced ? (
<AdvancedGas
gasPrice={gasPrice.raw}
gasLimit={gasLimit.raw}
nonce={nonce.raw}
changeGasPrice={this.props.inputGasPrice}
changeGasLimit={this.props.inputGasLimit}
changeNonce={this.props.inputNonce}
/>
<AdvancedGas gasPrice={gasPrice} inputGasPrice={this.props.inputGasPrice} />
) : (
<SimpleGas gasPrice={gasPrice.raw} changeGasPrice={this.props.inputGasPrice} />
<SimpleGas gasPrice={gasPrice} inputGasPrice={this.props.inputGasPrice} />
)}
{!offline &&
@ -79,7 +67,7 @@ class GasSlider extends React.Component<Props, State> {
<strong>
{showAdvanced
? `- ${translateRaw('Back to simple')}`
: `+ ${translateRaw('Advanced: Data, Gas Price, Gas Limit')}`}
: `+ ${translateRaw('Advanced Settings')}`}
</strong>
</a>
</div>
@ -93,19 +81,15 @@ class GasSlider extends React.Component<Props, State> {
};
}
function mapStateToProps(state: AppState) {
function mapStateToProps(state: AppState): StateProps {
return {
gasPrice: state.transaction.fields.gasPrice,
gasLimit: state.transaction.fields.gasLimit,
nonce: state.transaction.fields.nonce,
offline: state.config.offline,
gasPrice: getGasPrice(state),
offline: getOffline(state),
network: getNetworkConfig(state)
};
}
export default connect(mapStateToProps, {
inputGasPrice,
inputGasLimit,
inputNonce,
fetchCCRates
})(GasSlider);

View File

@ -1,4 +1,31 @@
.AdvancedGas {
margin-top: 0;
margin-bottom: 0;
.checkbox {
display: flex;
align-items: center;
width: fit-content;
input[type='checkbox'] {
position: initial;
margin: 0;
margin-right: 8px;
}
span {
font-size: 1rem;
font-weight: 400;
}
}
&-gasLimit {
display: flex;
flex-wrap: wrap;
align-items: baseline;
.flex-spacer {
flex-grow: 2;
}
input {
width: 100%;
margin-top: 0;
}
}
}

View File

@ -1,71 +1,69 @@
import React from 'react';
import classnames from 'classnames';
import translate from 'translations';
import { DataFieldFactory } from 'components/DataFieldFactory';
import FeeSummary from './FeeSummary';
import './AdvancedGas.scss';
import { TToggleAutoGasLimit, toggleAutoGasLimit } from 'actions/config';
import { AppState } from 'reducers';
import { TInputGasPrice } from 'actions/transaction';
import { NonceField, GasLimitField, DataField } from 'components';
import { connect } from 'react-redux';
import { getAutoGasLimitEnabled } from 'selectors/config';
interface Props {
gasPrice: string;
gasLimit: string;
nonce: string;
changeGasPrice(gwei: string): void;
changeGasLimit(wei: string): void;
changeNonce(nonce: string): void;
interface OwnProps {
inputGasPrice: TInputGasPrice;
gasPrice: AppState['transaction']['fields']['gasPrice'];
}
export default class AdvancedGas extends React.Component<Props> {
public render() {
// Can't shadow var names for data & fee summary
const vals = this.props;
interface StateProps {
autoGasLimitEnabled: AppState['config']['autoGasLimit'];
}
interface DispatchProps {
toggleAutoGasLimit: TToggleAutoGasLimit;
}
type Props = OwnProps & StateProps & DispatchProps;
class AdvancedGas extends React.Component<Props> {
public render() {
const { autoGasLimitEnabled, gasPrice } = this.props;
return (
<div className="AdvancedGas row form-group">
<div className="col-md-12">
<label className="checkbox">
<input
type="checkbox"
defaultChecked={autoGasLimitEnabled}
onChange={this.handleToggleAutoGasLimit}
/>
<span>Automatically Calculate Gas Limit</span>
</label>
</div>
<div className="col-md-4 col-sm-6 col-xs-12">
<label>{translate('OFFLINE_Step2_Label_3')} (gwei)</label>
<input
className={classnames('form-control', !vals.gasPrice && 'is-invalid')}
className={classnames('form-control', { 'is-invalid': !gasPrice.value })}
type="number"
placeholder="e.g. 40"
value={vals.gasPrice}
value={gasPrice.raw}
onChange={this.handleGasPriceChange}
/>
</div>
<div className="col-md-4 col-sm-6 col-xs-12">
<div className="col-md-4 col-sm-6 col-xs-12 AdvancedGas-gasLimit">
<label>{translate('OFFLINE_Step2_Label_4')}</label>
<input
className={classnames('form-control', !vals.gasLimit && 'is-invalid')}
type="number"
placeholder="e.g. 21000"
value={vals.gasLimit}
onChange={this.handleGasLimitChange}
/>
<div className="SimpleGas-flex-spacer" />
<GasLimitField includeLabel={false} onlyIncludeLoader={false} />
</div>
<div className="col-md-4 col-sm-12">
<label>{translate('OFFLINE_Step2_Label_5')}</label>
<input
className={classnames('form-control', !vals.nonce && 'is-invalid')}
type="number"
placeholder="e.g. 7"
value={vals.nonce}
onChange={this.handleNonceChange}
/>
<NonceField alwaysDisplay={true} />
</div>
<div className="col-md-12">
<label>{translate('OFFLINE_Step2_Label_6')}</label>
<DataFieldFactory
withProps={({ data, onChange }) => (
<input
className="form-control"
value={data.raw}
onChange={onChange}
placeholder="0x7cB57B5A..."
/>
)}
/>
<DataField />
</div>
<div className="col-sm-12">
@ -82,14 +80,15 @@ export default class AdvancedGas extends React.Component<Props> {
}
private handleGasPriceChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.props.changeGasPrice(ev.currentTarget.value);
this.props.inputGasPrice(ev.currentTarget.value);
};
private handleGasLimitChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.props.changeGasLimit(ev.currentTarget.value);
};
private handleNonceChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.props.changeNonce(ev.currentTarget.value);
private handleToggleAutoGasLimit = (_: React.FormEvent<HTMLInputElement>) => {
this.props.toggleAutoGasLimit();
};
}
export default connect(
(state: AppState) => ({ autoGasLimitEnabled: getAutoGasLimitEnabled(state) }),
{ toggleAutoGasLimit }
)(AdvancedGas);

View File

@ -4,8 +4,24 @@
margin-top: 0;
margin-bottom: 0;
&-label {
display: block;
&-flex-spacer {
flex-grow: 2;
}
&-title {
display: flex;
}
&-estimating {
color: rgba(51, 51, 51, 0.7);
display: flex;
align-items: baseline;
font-weight: 400;
opacity: 0;
&.active {
opacity: 1;
}
.Spinner {
margin-left: 8px;
}
}
&-slider {
@ -34,3 +50,26 @@
}
}
}
.fade {
&-enter,
&-exit {
transition: opacity 300ms;
}
&-enter {
opacity: 0;
&-active {
opacity: 1;
}
}
&-exit {
opacity: 1;
&-active {
opacity: 0;
}
}
}

View File

@ -3,30 +3,55 @@ import Slider from 'rc-slider';
import translate from 'translations';
import { gasPriceDefaults } from 'config/data';
import FeeSummary from './FeeSummary';
import { TInputGasPrice } from 'actions/transaction';
import './SimpleGas.scss';
import { AppState } from 'reducers';
import { getGasLimitEstimationTimedOut } from 'selectors/transaction';
import { connect } from 'react-redux';
import { GasLimitField } from 'components/GasLimitField';
import { getIsWeb3Node } from 'selectors/config';
interface Props {
gasPrice: string;
changeGasPrice(gwei: string): void;
interface OwnProps {
gasPrice: AppState['transaction']['fields']['gasPrice'];
inputGasPrice: TInputGasPrice;
}
export default class SimpleGas extends React.Component<Props> {
interface StateProps {
isWeb3Node: boolean;
gasLimitEstimationTimedOut: boolean;
}
type Props = OwnProps & StateProps;
class SimpleGas extends React.Component<Props> {
public render() {
const { gasPrice } = this.props;
const { gasPrice, gasLimitEstimationTimedOut, isWeb3Node } = this.props;
return (
<div className="SimpleGas row form-group">
<div className="col-md-12">
<div className="col-md-12 SimpleGas-title">
<label className="SimpleGas-label">{translate('Transaction Fee')}</label>
<div className="SimpleGas-flex-spacer" />
<GasLimitField includeLabel={false} onlyIncludeLoader={true} />
</div>
{gasLimitEstimationTimedOut && (
<div className="col-md-12 prompt-toggle-gas-limit">
<p className="small">
{isWeb3Node
? "Couldn't calculate gas limit, if you know what your doing, try setting manually in Advanced settings"
: "Couldn't calculate gas limit, try switching nodes"}
</p>
</div>
)}
<div className="col-md-8 col-sm-12">
<div className="SimpleGas-slider">
<Slider
onChange={this.handleSlider}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
value={parseFloat(gasPrice)}
value={parseFloat(gasPrice.raw)}
/>
<div className="SimpleGas-slider-labels">
<span>{translate('Cheap')}</span>
@ -49,6 +74,10 @@ export default class SimpleGas extends React.Component<Props> {
}
private handleSlider = (gasGwei: number) => {
this.props.changeGasPrice(gasGwei.toString());
this.props.inputGasPrice(gasGwei.toString());
};
}
export default connect((state: AppState) => ({
gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state),
isWeb3Node: getIsWeb3Node(state)
}))(SimpleGas);

View File

@ -0,0 +1,38 @@
import React from 'react';
import { NonceFieldFactory } from 'components/NonceFieldFactory';
import Help from 'components/ui/Help';
interface Props {
alwaysDisplay: boolean;
}
const nonceHelp = (
<Help
size={'x1'}
link={'https://myetherwallet.github.io/knowledge-base/transactions/what-is-nonce.html'}
/>
);
export const NonceField: React.SFC<Props> = ({ alwaysDisplay }) => (
<NonceFieldFactory
withProps={({ nonce: { raw, value }, onChange, readOnly, shouldDisplay }) => {
const content = (
<>
<label>Nonce</label>
{nonceHelp}
<input
className={`form-control ${!!value ? 'is-valid' : 'is-invalid'}`}
type="number"
placeholder="e.g. 7"
value={raw}
readOnly={readOnly}
onChange={onChange}
/>
</>
);
return alwaysDisplay || shouldDisplay ? content : null;
}}
/>
);

View File

@ -1,23 +0,0 @@
import { NonceInput } from './NonceInput';
import { inputNonce, TInputNonce } from 'actions/transaction';
import React, { Component } from 'react';
import { connect } from 'react-redux';
interface DispatchProps {
inputNonce: TInputNonce;
}
class NonceFieldClass extends Component<DispatchProps> {
public render() {
return <NonceInput onChange={this.setNonce} />;
}
private setNonce = (ev: React.FormEvent<HTMLInputElement>) => {
const { value } = ev.currentTarget;
this.props.inputNonce(value);
};
}
export const NonceField = connect(null, {
inputNonce
})(NonceFieldClass);

View File

@ -1,54 +0,0 @@
import React, { Component } from 'react';
import { Query } from 'components/renderCbs';
import Help from 'components/ui/Help';
import { getNonce, nonceRequestFailed } from 'selectors/transaction';
import { getOffline } from 'selectors/config';
import { AppState } from 'reducers';
import { connect } from 'react-redux';
const nonceHelp = (
<Help
size={'x1'}
link={'https://myetherwallet.github.io/knowledge-base/transactions/what-is-nonce.html'}
/>
);
interface OwnProps {
onChange(ev: React.FormEvent<HTMLInputElement>): void;
}
interface StateProps {
shouldDisplay: boolean;
nonce: AppState['transaction']['fields']['nonce'];
}
type Props = OwnProps & StateProps;
class NonceInputClass extends Component<Props> {
public render() {
const { nonce: { raw, value }, onChange, shouldDisplay } = this.props;
const content = (
<React.Fragment>
<label>Nonce</label>
{nonceHelp}
<Query
params={['readOnly']}
withQuery={({ readOnly }) => (
<input
className={`form-control ${!!value ? 'is-valid' : 'is-invalid'}`}
type="text"
value={raw}
readOnly={!!readOnly}
onChange={onChange}
/>
)}
/>
</React.Fragment>
);
return shouldDisplay ? content : null;
}
}
export const NonceInput = connect((state: AppState) => ({
shouldDisplay: getOffline(state) || nonceRequestFailed(state),
nonce: getNonce(state)
}))(NonceInputClass);

View File

@ -1 +0,0 @@
export * from './NonceField';

View File

@ -0,0 +1,37 @@
import { NonceInputFactory } from './NonceInputFactory';
import { inputNonce, TInputNonce } from 'actions/transaction';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
export interface CallbackProps {
nonce: AppState['transaction']['fields']['nonce'];
readOnly: boolean;
shouldDisplay: boolean;
onChange(ev: React.FormEvent<HTMLInputElement>): void;
}
interface DispatchProps {
inputNonce: TInputNonce;
}
interface OwnProps {
withProps(props: CallbackProps): React.ReactElement<any> | null;
}
type Props = OwnProps & DispatchProps;
class NonceFieldClass extends Component<Props> {
public render() {
return <NonceInputFactory onChange={this.setNonce} withProps={this.props.withProps} />;
}
private setNonce = (ev: React.FormEvent<HTMLInputElement>) => {
const { value } = ev.currentTarget;
this.props.inputNonce(value);
};
}
export const NonceFieldFactory = connect(null, {
inputNonce
})(NonceFieldClass);

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import { Query } from 'components/renderCbs';
import { getNonce, nonceRequestFailed } from 'selectors/transaction';
import { getOffline } from 'selectors/config';
import { AppState } from 'reducers';
import { connect } from 'react-redux';
import { CallbackProps } from 'components/NonceFieldFactory';
interface OwnProps {
onChange(ev: React.FormEvent<HTMLInputElement>): void;
withProps(props: CallbackProps): React.ReactElement<any> | null;
}
interface StateProps {
shouldDisplay: boolean;
nonce: AppState['transaction']['fields']['nonce'];
}
type Props = OwnProps & StateProps;
class NonceInputFactoryClass extends Component<Props> {
public render() {
const { nonce, onChange, shouldDisplay, withProps } = this.props;
return (
<Query
params={['readOnly']}
withQuery={({ readOnly }) =>
withProps({ nonce, onChange, readOnly: !!readOnly, shouldDisplay })
}
/>
);
}
}
export const NonceInputFactory = connect((state: AppState) => ({
shouldDisplay: getOffline(state) || nonceRequestFailed(state),
nonce: getNonce(state)
}))(NonceInputFactoryClass);

View File

@ -0,0 +1 @@
export * from './NonceFieldFactory';

View File

@ -64,7 +64,7 @@ class DeployClass extends Component<DispatchProps> {
</label>
<div className="row form-group">
<div className="col-xs-11">
<NonceField />
<NonceField alwaysDisplay={false} />
</div>
</div>
<div className="row form-group">

View File

@ -14,7 +14,7 @@ export class Fields extends Component<OwnProps> {
<React.Fragment>
<GasLimitField />
<AmountField />
<NonceField />
<NonceField alwaysDisplay={false} />
{this.props.button}
<SigningStatus />
<SendButton />

View File

@ -106,7 +106,7 @@ class RequestPayment extends React.Component<Props, {}> {
<div className="row form-group">
<div className="col-xs-11">
<GasLimitField />
<GasLimitField includeLabel={true} onlyIncludeLoader={false} />
</div>
</div>

View File

@ -46,10 +46,15 @@ export default class RpcNode implements INode {
}
public estimateGas(transaction: Partial<IHexStrTransaction>): Promise<Wei> {
// Timeout after 10 seconds
return this.client
.call(this.requests.estimateGas(transaction))
.then(isValidEstimateGas)
.then(({ result }) => Wei(result));
.then(({ result }) => Wei(result))
.catch(error => {
throw new Error(error.message);
});
}
public getTokenBalance(

View File

@ -28,6 +28,7 @@ export interface State {
network: NetworkConfig;
isChangingNode: boolean;
offline: boolean;
autoGasLimit: boolean;
customNodes: CustomNodeConfig[];
customNetworks: CustomNetworkConfig[];
latestBlock: string;
@ -41,6 +42,7 @@ export const INITIAL_STATE: State = {
network: NETWORKS[NODES[defaultNode].network],
isChangingNode: false,
offline: false,
autoGasLimit: true,
customNodes: [],
customNetworks: [],
latestBlock: '???'
@ -77,6 +79,13 @@ function toggleOffline(state: State): State {
};
}
function toggleAutoGasLimitEstimation(state: State): State {
return {
...state,
autoGasLimit: !state.autoGasLimit
};
}
function addCustomNode(state: State, action: AddCustomNodeAction): State {
const newId = makeCustomNodeId(action.payload);
return {
@ -132,6 +141,8 @@ export function config(state: State = INITIAL_STATE, action: ConfigAction): Stat
return changeNodeIntent(state);
case TypeKeys.CONFIG_TOGGLE_OFFLINE:
return toggleOffline(state);
case TypeKeys.CONFIG_TOGGLE_AUTO_GAS_LIMIT:
return toggleAutoGasLimitEstimation(state);
case TypeKeys.CONFIG_ADD_CUSTOM_NODE:
return addCustomNode(state, action);
case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE:

View File

@ -26,6 +26,8 @@ export const network = (state: State = INITIAL_STATE, action: NetworkAction | Re
return nextState('gasEstimationStatus')(state, action);
case TK.ESTIMATE_GAS_FAILED:
return nextState('gasEstimationStatus')(state, action);
case TK.ESTIMATE_GAS_TIMEDOUT:
return nextState('gasEstimationStatus')(state, action);
case TK.ESTIMATE_GAS_SUCCEEDED:
return nextState('gasEstimationStatus')(state, action);
case TK.GET_FROM_REQUESTED:

View File

@ -1,7 +1,8 @@
export enum RequestStatus {
REQUESTED = 'PENDING',
SUCCEEDED = 'SUCCESS',
FAILED = 'FAIL'
FAILED = 'FAIL',
TIMEDOUT = 'TIMEDOUT'
}
export interface State {
gasEstimationStatus: RequestStatus | null;

View File

@ -1,13 +1,14 @@
import { SagaIterator, buffers, delay } from 'redux-saga';
import { apply, put, select, take, actionChannel, call, fork } from 'redux-saga/effects';
import { apply, put, select, take, actionChannel, call, fork, race } from 'redux-saga/effects';
import { INode } from 'libs/nodes/INode';
import { getNodeLib, getOffline } from 'selectors/config';
import { getNodeLib, getOffline, getAutoGasLimitEnabled } from 'selectors/config';
import { getWalletInst } from 'selectors/wallet';
import { getTransaction, IGetTransaction } from 'selectors/transaction';
import {
EstimateGasRequestedAction,
setGasLimitField,
estimateGasFailed,
estimateGasTimedout,
estimateGasSucceeded,
TypeKeys,
estimateGasRequested,
@ -17,31 +18,36 @@ import {
SwapTokenToTokenAction,
SwapTokenToEtherAction
} from 'actions/transaction';
import { TypeKeys as ConfigTypeKeys, ToggleAutoGasLimitAction } from 'actions/config';
import { IWallet } from 'libs/wallet';
import { makeTransaction, getTransactionFields, IHexStrTransaction } from 'libs/transaction';
export function* shouldEstimateGas(): SagaIterator {
while (true) {
const isOffline = yield select(getOffline);
if (isOffline) {
continue;
}
const action:
| SetToFieldAction
| SetDataFieldAction
| SwapEtherToTokenAction
| SwapTokenToTokenAction
| SwapTokenToEtherAction = yield take([
| SwapTokenToEtherAction
| ToggleAutoGasLimitAction = yield take([
TypeKeys.TO_FIELD_SET,
TypeKeys.DATA_FIELD_SET,
TypeKeys.ETHER_TO_TOKEN_SWAP,
TypeKeys.TOKEN_TO_TOKEN_SWAP,
TypeKeys.TOKEN_TO_ETHER_SWAP
TypeKeys.TOKEN_TO_ETHER_SWAP,
ConfigTypeKeys.CONFIG_TOGGLE_AUTO_GAS_LIMIT
]);
// invalid field is a field that the value is null and the input box isnt empty
// reason being is an empty field is valid because it'll be null
const isOffline: boolean = yield select(getOffline);
const autoGasLimitEnabled: boolean = yield select(getAutoGasLimitEnabled);
if (isOffline || !autoGasLimitEnabled) {
continue;
}
const invalidField =
(action.type === TypeKeys.TO_FIELD_SET || action.type === TypeKeys.DATA_FIELD_SET) &&
!action.payload.value &&
@ -56,6 +62,7 @@ export function* shouldEstimateGas(): SagaIterator {
getTransactionFields,
transaction
);
yield put(estimateGasRequested(rest));
}
}
@ -64,8 +71,10 @@ export function* estimateGas(): SagaIterator {
const requestChan = yield actionChannel(TypeKeys.ESTIMATE_GAS_REQUESTED, buffers.sliding(1));
while (true) {
const autoGasLimitEnabled: boolean = yield select(getAutoGasLimitEnabled);
const isOffline = yield select(getOffline);
if (isOffline) {
if (isOffline || !autoGasLimitEnabled) {
continue;
}
@ -77,17 +86,28 @@ export function* estimateGas(): SagaIterator {
try {
const from: string = yield apply(walletInst, walletInst.getAddressString);
const txObj = { ...payload, from };
const gasLimit = yield apply(node, node.estimateGas, [txObj]);
yield put(setGasLimitField({ raw: gasLimit.toString(), value: gasLimit }));
yield put(estimateGasSucceeded());
const { gasLimit } = yield race({
gasLimit: apply(node, node.estimateGas, [txObj]),
timeout: call(delay, 10000)
});
if (gasLimit) {
yield put(setGasLimitField({ raw: gasLimit.toString(), value: gasLimit }));
yield put(estimateGasSucceeded());
} else {
yield put(estimateGasTimedout());
yield call(localGasEstimation, payload);
}
} catch (e) {
yield put(estimateGasFailed());
// fallback for estimating locally
const tx = yield call(makeTransaction, payload);
const gasLimit = yield apply(tx, tx.getBaseFee);
yield put(setGasLimitField({ raw: gasLimit.toString(), value: gasLimit }));
yield call(localGasEstimation, payload);
}
}
}
export function* localGasEstimation(payload: EstimateGasRequestedAction['payload']) {
const tx = yield call(makeTransaction, payload);
const gasLimit = yield apply(tx, tx.getBaseFee);
yield put(setGasLimitField({ raw: gasLimit.toString(), value: gasLimit }));
}
export const gas = [fork(shouldEstimateGas), fork(estimateGas)];

View File

@ -16,6 +16,10 @@ export function getNode(state: AppState): string {
return state.config.nodeSelection;
}
export function getIsWeb3Node(state: AppState): boolean {
return getNode(state) === 'web3';
}
export function getNodeConfig(state: AppState): NodeConfig {
return state.config.node;
}
@ -86,6 +90,10 @@ export function getOffline(state: AppState): boolean {
return state.config.offline;
}
export function getAutoGasLimitEnabled(state: AppState): boolean {
return state.config.autoGasLimit;
}
export function isSupportedUnit(state: AppState, unit: string) {
const isToken: boolean = tokenExists(state, unit);
const isEther: boolean = isEtherUnit(unit);

View File

@ -2,10 +2,12 @@ import { AppState } from 'reducers';
import { getTransactionState } from 'selectors/transaction';
import { RequestStatus } from 'reducers/transaction/network';
const getNetworkStatus = (state: AppState) => getTransactionState(state).network;
const nonceRequestFailed = (state: AppState) =>
export const getNetworkStatus = (state: AppState) => getTransactionState(state).network;
export const nonceRequestFailed = (state: AppState) =>
getNetworkStatus(state).getNonceStatus === RequestStatus.FAILED;
const isNetworkRequestPending = (state: AppState) => {
export const isNetworkRequestPending = (state: AppState) => {
const network = getNetworkStatus(state);
const states: RequestStatus[] = Object.values(network);
return states.reduce(
@ -14,4 +16,8 @@ const isNetworkRequestPending = (state: AppState) => {
);
};
export { nonceRequestFailed, isNetworkRequestPending };
export const getGasEstimationPending = (state: AppState) =>
getNetworkStatus(state).gasEstimationStatus === RequestStatus.REQUESTED;
export const getGasLimitEstimationTimedOut = (state: AppState) =>
getNetworkStatus(state).gasEstimationStatus === RequestStatus.TIMEDOUT;

View File

@ -133,7 +133,8 @@ const configureStore = () => {
nodeSelection: state.config.nodeSelection,
languageSelection: state.config.languageSelection,
customNodes: state.config.customNodes,
customNetworks: state.config.customNetworks
customNetworks: state.config.customNetworks,
setGasLimit: state.config.setGasLimit
},
transaction: {
fields: {

View File

@ -1,6 +1,6 @@
import { buffers, delay } from 'redux-saga';
import { apply, put, select, take, actionChannel, call } from 'redux-saga/effects';
import { getNodeLib, getOffline } from 'selectors/config';
import { apply, put, select, take, actionChannel, call, race } from 'redux-saga/effects';
import { getNodeLib, getOffline, getAutoGasLimitEnabled } from 'selectors/config';
import { getWalletInst } from 'selectors/wallet';
import { getTransaction } from 'selectors/transaction';
import {
@ -8,15 +8,18 @@ import {
estimateGasFailed,
estimateGasSucceeded,
TypeKeys,
estimateGasRequested
estimateGasRequested,
estimateGasTimedout
} from 'actions/transaction';
import { makeTransaction, getTransactionFields } from 'libs/transaction';
import { shouldEstimateGas, estimateGas } from 'sagas/transaction/network/gas';
import { shouldEstimateGas, estimateGas, localGasEstimation } from 'sagas/transaction/network/gas';
import { cloneableGenerator } from 'redux-saga/utils';
import { Wei } from 'libs/units';
import { TypeKeys as ConfigTypeKeys } from 'actions/config';
describe('shouldEstimateGas*', () => {
const offline = false;
const autoGasLimitEnabled = true;
const transaction: any = 'transaction';
const tx = { transaction };
const rest: any = {
@ -40,24 +43,29 @@ describe('shouldEstimateGas*', () => {
const gen = shouldEstimateGas();
it('should select getOffline', () => {
expect(gen.next().value).toEqual(select(getOffline));
});
it('should take expected types', () => {
expect(gen.next(offline).value).toEqual(
expect(gen.next().value).toEqual(
take([
TypeKeys.TO_FIELD_SET,
TypeKeys.DATA_FIELD_SET,
TypeKeys.ETHER_TO_TOKEN_SWAP,
TypeKeys.TOKEN_TO_TOKEN_SWAP,
TypeKeys.TOKEN_TO_ETHER_SWAP
TypeKeys.TOKEN_TO_ETHER_SWAP,
ConfigTypeKeys.CONFIG_TOGGLE_AUTO_GAS_LIMIT
])
);
});
it('should select getOffline', () => {
expect(gen.next(action).value).toEqual(select(getOffline));
});
it('should select autoGasLimitEnabled', () => {
expect(gen.next(offline).value).toEqual(select(getAutoGasLimitEnabled));
});
it('should select getTransaction', () => {
expect(gen.next(action).value).toEqual(select(getTransaction));
expect(gen.next(autoGasLimitEnabled).value).toEqual(select(getTransaction));
});
it('should call getTransactionFields with transaction', () => {
@ -71,6 +79,7 @@ describe('shouldEstimateGas*', () => {
describe('estimateGas*', () => {
const offline = false;
const autoGasLimitEnabled = true;
const requestChan = 'requestChan';
const payload: any = {
mock1: 'mock1',
@ -86,9 +95,16 @@ describe('estimateGas*', () => {
const from = '0xa';
const txObj = { ...payload, from };
const gasLimit = Wei('100');
const successfulGasEstimationResult = {
gasLimit
};
const gens: any = {};
gens.gen = cloneableGenerator(estimateGas)();
const unsuccessfulGasEstimationResult = {
gasLimit: null
};
const gens: { [name: string]: any } = {};
gens.successCase = cloneableGenerator(estimateGas)();
let random;
beforeAll(() => {
@ -104,41 +120,53 @@ describe('estimateGas*', () => {
const expected = JSON.stringify(
actionChannel(TypeKeys.ESTIMATE_GAS_REQUESTED, buffers.sliding(1))
);
const result = JSON.stringify(gens.gen.next().value);
const result = JSON.stringify(gens.successCase.next().value);
expect(expected).toEqual(result);
});
it('should select autoGasLimit', () => {
expect(gens.successCase.next(requestChan).value).toEqual(select(getAutoGasLimitEnabled));
});
it('should select getOffline', () => {
expect(gens.gen.next(requestChan).value).toEqual(select(getOffline));
expect(gens.successCase.next(autoGasLimitEnabled).value).toEqual(select(getOffline));
});
it('should take requestChan', () => {
expect(gens.gen.next(offline).value).toEqual(take(requestChan));
expect(gens.successCase.next(offline).value).toEqual(take(requestChan));
});
it('should call delay', () => {
expect(gens.gen.next(action).value).toEqual(call(delay, 250));
expect(gens.successCase.next(action).value).toEqual(call(delay, 250));
});
it('should select getNodeLib', () => {
expect(gens.gen.next().value).toEqual(select(getNodeLib));
expect(gens.successCase.next().value).toEqual(select(getNodeLib));
});
it('should select getWalletInst', () => {
expect(gens.gen.next(node).value).toEqual(select(getWalletInst));
expect(gens.successCase.next(node).value).toEqual(select(getWalletInst));
});
it('should apply walletInst', () => {
expect(gens.gen.next(walletInst).value).toEqual(apply(walletInst, walletInst.getAddressString));
expect(gens.successCase.next(walletInst).value).toEqual(
apply(walletInst, walletInst.getAddressString)
);
});
it('should apply node.estimateGas', () => {
gens.clone = gens.gen.clone();
expect(gens.gen.next(from).value).toEqual(apply(node, node.estimateGas, [txObj]));
it('should race between node.estimate gas and a 10 second timeout', () => {
gens.failCase = gens.successCase.clone();
expect(gens.successCase.next(from).value).toEqual(
race({
gasLimit: apply(node, node.estimateGas, [txObj]),
timeout: call(delay, 10000)
})
);
});
it('should put setGasLimitField', () => {
expect(gens.gen.next(gasLimit).value).toEqual(
gens.timeOutCase = gens.successCase.clone();
expect(gens.successCase.next(successfulGasEstimationResult).value).toEqual(
put(
setGasLimitField({
raw: gasLimit.toString(),
@ -149,35 +177,62 @@ describe('estimateGas*', () => {
});
it('should put estimateGasSucceeded', () => {
expect(gens.gen.next().value).toEqual(put(estimateGasSucceeded()));
expect(gens.successCase.next().value).toEqual(put(estimateGasSucceeded()));
});
describe('when it times out', () => {
it('should put estimateGasTimedout ', () => {
expect(gens.timeOutCase.next(unsuccessfulGasEstimationResult).value).toEqual(
put(estimateGasTimedout())
);
});
it('should call localGasEstimation', () => {
expect(gens.timeOutCase.next(estimateGasFailed()).value).toEqual(
call(localGasEstimation, payload)
);
});
});
describe('when it throws', () => {
const tx = {
getBaseFee: jest.fn()
};
it('should catch and put estimateGasFailed', () => {
expect(gens.clone.throw().value).toEqual(put(estimateGasFailed()));
expect(gens.failCase.throw().value).toEqual(put(estimateGasFailed()));
});
it('should call makeTransaction with payload', () => {
expect(gens.clone.next().value).toEqual(call(makeTransaction, payload));
});
it('should apply tx.getBaseFee', () => {
expect(gens.clone.next(tx).value).toEqual(apply(tx, tx.getBaseFee));
});
it('should put setGasLimitField', () => {
expect(gens.clone.next(gasLimit).value).toEqual(
put(
setGasLimitField({
raw: gasLimit.toString(),
value: gasLimit
})
)
it('should call localGasEstimation', () => {
expect(gens.failCase.next(estimateGasFailed()).value).toEqual(
call(localGasEstimation, payload)
);
});
});
});
describe('localGasEstimation', () => {
const payload: any = {
mock1: 'mock1',
mock2: 'mock2'
};
const tx = {
getBaseFee: jest.fn()
};
const gasLimit = Wei('100');
const gen = localGasEstimation(payload);
it('should call makeTransaction with payload', () => {
expect(gen.next().value).toEqual(call(makeTransaction, payload));
});
it('should apply tx.getBaseFee', () => {
expect(gen.next(tx).value).toEqual(apply(tx, tx.getBaseFee));
});
it('should put setGasLimitField', () => {
expect(gen.next(gasLimit).value).toEqual(
put(
setGasLimitField({
raw: gasLimit.toString(),
value: gasLimit
})
)
);
});
});