From 6108d08693cbabbcfd68cf7bc8557597acab45fa Mon Sep 17 00:00:00 2001 From: James Prado Date: Mon, 15 Jan 2018 04:59:59 -0500 Subject: [PATCH] Improved Gas Estimate UX (#830) --- common/actions/config/actionCreators.ts | 7 + common/actions/config/actionTypes.ts | 5 + common/actions/config/constants.ts | 1 + .../transaction/actionCreators/network.ts | 10 +- .../transaction/actionTypes/network.ts | 5 + common/actions/transaction/constants.ts | 1 + common/components/DataField.tsx | 32 ++-- common/components/GasLimitField.tsx | 45 ++++-- .../GasLimitFieldFactory.tsx | 1 + .../GasLimitInputFactory.tsx | 23 ++- common/components/GasSlider/GasSlider.tsx | 56 +++---- .../GasSlider/components/AdvancedGas.scss | 27 ++++ .../GasSlider/components/AdvancedGas.tsx | 95 ++++++----- .../GasSlider/components/SimpleGas.scss | 43 ++++- .../GasSlider/components/SimpleGas.tsx | 45 +++++- common/components/NonceField.tsx | 38 +++++ common/components/NonceField/NonceField.tsx | 23 --- common/components/NonceField/NonceInput.tsx | 54 ------- common/components/NonceField/index.ts | 1 - .../NonceFieldFactory/NonceFieldFactory.tsx | 37 +++++ .../NonceFieldFactory/NonceInputFactory.tsx | 39 +++++ common/components/NonceFieldFactory/index.ts | 1 + .../Tabs/Contracts/components/Deploy.tsx | 2 +- .../InteractExplorer/components/Fields.tsx | 2 +- .../components/RequestPayment.tsx | 2 +- common/libs/nodes/rpc/index.ts | 7 +- common/reducers/config.ts | 11 ++ .../reducers/transaction/network/network.ts | 2 + .../reducers/transaction/network/typings.ts | 3 +- common/sagas/transaction/network/gas.ts | 54 +++++-- common/selectors/config.ts | 8 + common/selectors/transaction/network.ts | 14 +- common/store.ts | 3 +- spec/sagas/transaction/network/gas.spec.ts | 147 ++++++++++++------ 34 files changed, 560 insertions(+), 284 deletions(-) create mode 100644 common/components/NonceField.tsx delete mode 100644 common/components/NonceField/NonceField.tsx delete mode 100644 common/components/NonceField/NonceInput.tsx delete mode 100644 common/components/NonceField/index.ts create mode 100644 common/components/NonceFieldFactory/NonceFieldFactory.tsx create mode 100644 common/components/NonceFieldFactory/NonceInputFactory.tsx create mode 100644 common/components/NonceFieldFactory/index.ts diff --git a/common/actions/config/actionCreators.ts b/common/actions/config/actionCreators.ts index 2e80892c..f1cb8b2a 100644 --- a/common/actions/config/actionCreators.ts +++ b/common/actions/config/actionCreators.ts @@ -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 { diff --git a/common/actions/config/actionTypes.ts b/common/actions/config/actionTypes.ts index 3dc3a336..cd147b74 100644 --- a/common/actions/config/actionTypes.ts +++ b/common/actions/config/actionTypes.ts @@ -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 diff --git a/common/actions/config/constants.ts b/common/actions/config/constants.ts index 0e8981a4..58fa8e15 100644 --- a/common/actions/config/constants.ts +++ b/common/actions/config/constants.ts @@ -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', diff --git a/common/actions/transaction/actionCreators/network.ts b/common/actions/transaction/actionCreators/network.ts index f6c5fd28..94aa6f25 100644 --- a/common/actions/transaction/actionCreators/network.ts +++ b/common/actions/transaction/actionCreators/network.ts @@ -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, diff --git a/common/actions/transaction/actionTypes/network.ts b/common/actions/transaction/actionTypes/network.ts index eee14f59..ac660fa2 100644 --- a/common/actions/transaction/actionTypes/network.ts +++ b/common/actions/transaction/actionTypes/network.ts @@ -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, diff --git a/common/actions/transaction/constants.ts b/common/actions/transaction/constants.ts index 4988575c..8c8008d7 100644 --- a/common/actions/transaction/constants.ts +++ b/common/actions/transaction/constants.ts @@ -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', diff --git a/common/components/DataField.tsx b/common/components/DataField.tsx index 61d3438a..c2bd4b92 100644 --- a/common/components/DataField.tsx +++ b/common/components/DataField.tsx @@ -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) => ( - -

{translate('TRANS_advanced')}

-
-); - export const DataField: React.SFC<{}> = () => ( ( - -
- - - -
-
+ <> + + + )} /> ); diff --git a/common/components/GasLimitField.tsx b/common/components/GasLimitField.tsx index a70780fc..0a7cf272 100644 --- a/common/components/GasLimitField.tsx +++ b/common/components/GasLimitField.tsx @@ -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 +}) => ( + +
+ Calculating gas limit + +
+
+); + +export const GasLimitField: React.SFC = ({ includeLabel, onlyIncludeLoader }) => ( - + {includeLabel ? : null} + ( - + withProps={({ gasLimit: { raw, value }, onChange, readOnly, gasEstimationPending }) => ( + <> + + {onlyIncludeLoader ? null : ( + + )} + )} /> diff --git a/common/components/GasLimitFieldFactory/GasLimitFieldFactory.tsx b/common/components/GasLimitFieldFactory/GasLimitFieldFactory.tsx index ee811466..98d9957f 100644 --- a/common/components/GasLimitFieldFactory/GasLimitFieldFactory.tsx +++ b/common/components/GasLimitFieldFactory/GasLimitFieldFactory.tsx @@ -10,6 +10,7 @@ const defaultGasLimit = '21000'; export interface CallBackProps { readOnly: boolean; gasLimit: AppState['transaction']['fields']['gasLimit']; + gasEstimationPending: boolean; onChange(value: React.FormEvent): void; } diff --git a/common/components/GasLimitFieldFactory/GasLimitInputFactory.tsx b/common/components/GasLimitFieldFactory/GasLimitInputFactory.tsx index 727ed644..cc5dc6d3 100644 --- a/common/components/GasLimitFieldFactory/GasLimitInputFactory.tsx +++ b/common/components/GasLimitFieldFactory/GasLimitInputFactory.tsx @@ -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 { public render() { - const { gasLimit, onChange } = this.props; + const { gasLimit, onChange, gasEstimationPending, autoGasLimitEnabled } = this.props; return ( - 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); diff --git a/common/components/GasSlider/GasSlider.tsx b/common/components/GasSlider/GasSlider.tsx index 86bbd4ea..0d7dbdcc 100644 --- a/common/components/GasSlider/GasSlider.tsx +++ b/common/components/GasSlider/GasSlider.tsx @@ -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 { } public render() { - const { gasPrice, gasLimit, nonce, offline, disableAdvanced } = this.props; + const { offline, disableAdvanced, gasPrice } = this.props; const showAdvanced = (this.state.showAdvanced || offline) && !disableAdvanced; return (
{showAdvanced ? ( - + ) : ( - + )} {!offline && @@ -79,7 +67,7 @@ class GasSlider extends React.Component { {showAdvanced ? `- ${translateRaw('Back to simple')}` - : `+ ${translateRaw('Advanced: Data, Gas Price, Gas Limit')}`} + : `+ ${translateRaw('Advanced Settings')}`}
@@ -93,19 +81,15 @@ class GasSlider extends React.Component { }; } -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); diff --git a/common/components/GasSlider/components/AdvancedGas.scss b/common/components/GasSlider/components/AdvancedGas.scss index 8776d0fe..244d4a0e 100644 --- a/common/components/GasSlider/components/AdvancedGas.scss +++ b/common/components/GasSlider/components/AdvancedGas.scss @@ -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; + } + } } diff --git a/common/components/GasSlider/components/AdvancedGas.tsx b/common/components/GasSlider/components/AdvancedGas.tsx index 2af3438b..72fc20af 100644 --- a/common/components/GasSlider/components/AdvancedGas.tsx +++ b/common/components/GasSlider/components/AdvancedGas.tsx @@ -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 { - 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 { + public render() { + const { autoGasLimitEnabled, gasPrice } = this.props; return (
+
+ +
+
-
+
- +
+
- - +
- - ( - - )} - /> +
@@ -82,14 +80,15 @@ export default class AdvancedGas extends React.Component { } private handleGasPriceChange = (ev: React.FormEvent) => { - this.props.changeGasPrice(ev.currentTarget.value); + this.props.inputGasPrice(ev.currentTarget.value); }; - private handleGasLimitChange = (ev: React.FormEvent) => { - this.props.changeGasLimit(ev.currentTarget.value); - }; - - private handleNonceChange = (ev: React.FormEvent) => { - this.props.changeNonce(ev.currentTarget.value); + private handleToggleAutoGasLimit = (_: React.FormEvent) => { + this.props.toggleAutoGasLimit(); }; } + +export default connect( + (state: AppState) => ({ autoGasLimitEnabled: getAutoGasLimitEnabled(state) }), + { toggleAutoGasLimit } +)(AdvancedGas); diff --git a/common/components/GasSlider/components/SimpleGas.scss b/common/components/GasSlider/components/SimpleGas.scss index cc48f426..cff478f5 100644 --- a/common/components/GasSlider/components/SimpleGas.scss +++ b/common/components/GasSlider/components/SimpleGas.scss @@ -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; + } + } +} diff --git a/common/components/GasSlider/components/SimpleGas.tsx b/common/components/GasSlider/components/SimpleGas.tsx index 4e0eaa06..82bb6f54 100644 --- a/common/components/GasSlider/components/SimpleGas.tsx +++ b/common/components/GasSlider/components/SimpleGas.tsx @@ -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 { +interface StateProps { + isWeb3Node: boolean; + gasLimitEstimationTimedOut: boolean; +} + +type Props = OwnProps & StateProps; + +class SimpleGas extends React.Component { public render() { - const { gasPrice } = this.props; + const { gasPrice, gasLimitEstimationTimedOut, isWeb3Node } = this.props; return (
-
+
+
+
+ {gasLimitEstimationTimedOut && ( +
+

+ {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"} +

+
+ )} +
{translate('Cheap')} @@ -49,6 +74,10 @@ export default class SimpleGas extends React.Component { } 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); diff --git a/common/components/NonceField.tsx b/common/components/NonceField.tsx new file mode 100644 index 00000000..2a560182 --- /dev/null +++ b/common/components/NonceField.tsx @@ -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 = ( + +); + +export const NonceField: React.SFC = ({ alwaysDisplay }) => ( + { + const content = ( + <> + + {nonceHelp} + + + + ); + + return alwaysDisplay || shouldDisplay ? content : null; + }} + /> +); diff --git a/common/components/NonceField/NonceField.tsx b/common/components/NonceField/NonceField.tsx deleted file mode 100644 index d71a5b9d..00000000 --- a/common/components/NonceField/NonceField.tsx +++ /dev/null @@ -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 { - public render() { - return ; - } - - private setNonce = (ev: React.FormEvent) => { - const { value } = ev.currentTarget; - this.props.inputNonce(value); - }; -} - -export const NonceField = connect(null, { - inputNonce -})(NonceFieldClass); diff --git a/common/components/NonceField/NonceInput.tsx b/common/components/NonceField/NonceInput.tsx deleted file mode 100644 index 34b404d6..00000000 --- a/common/components/NonceField/NonceInput.tsx +++ /dev/null @@ -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 = ( - -); - -interface OwnProps { - onChange(ev: React.FormEvent): void; -} -interface StateProps { - shouldDisplay: boolean; - nonce: AppState['transaction']['fields']['nonce']; -} -type Props = OwnProps & StateProps; - -class NonceInputClass extends Component { - public render() { - const { nonce: { raw, value }, onChange, shouldDisplay } = this.props; - const content = ( - - - {nonceHelp} - - ( - - )} - /> - - ); - - return shouldDisplay ? content : null; - } -} - -export const NonceInput = connect((state: AppState) => ({ - shouldDisplay: getOffline(state) || nonceRequestFailed(state), - nonce: getNonce(state) -}))(NonceInputClass); diff --git a/common/components/NonceField/index.ts b/common/components/NonceField/index.ts deleted file mode 100644 index b0b6b9cd..00000000 --- a/common/components/NonceField/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './NonceField'; diff --git a/common/components/NonceFieldFactory/NonceFieldFactory.tsx b/common/components/NonceFieldFactory/NonceFieldFactory.tsx new file mode 100644 index 00000000..c473b76c --- /dev/null +++ b/common/components/NonceFieldFactory/NonceFieldFactory.tsx @@ -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): void; +} + +interface DispatchProps { + inputNonce: TInputNonce; +} + +interface OwnProps { + withProps(props: CallbackProps): React.ReactElement | null; +} + +type Props = OwnProps & DispatchProps; + +class NonceFieldClass extends Component { + public render() { + return ; + } + + private setNonce = (ev: React.FormEvent) => { + const { value } = ev.currentTarget; + this.props.inputNonce(value); + }; +} + +export const NonceFieldFactory = connect(null, { + inputNonce +})(NonceFieldClass); diff --git a/common/components/NonceFieldFactory/NonceInputFactory.tsx b/common/components/NonceFieldFactory/NonceInputFactory.tsx new file mode 100644 index 00000000..57349135 --- /dev/null +++ b/common/components/NonceFieldFactory/NonceInputFactory.tsx @@ -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): void; + withProps(props: CallbackProps): React.ReactElement | null; +} + +interface StateProps { + shouldDisplay: boolean; + nonce: AppState['transaction']['fields']['nonce']; +} + +type Props = OwnProps & StateProps; + +class NonceInputFactoryClass extends Component { + public render() { + const { nonce, onChange, shouldDisplay, withProps } = this.props; + + return ( + + withProps({ nonce, onChange, readOnly: !!readOnly, shouldDisplay }) + } + /> + ); + } +} + +export const NonceInputFactory = connect((state: AppState) => ({ + shouldDisplay: getOffline(state) || nonceRequestFailed(state), + nonce: getNonce(state) +}))(NonceInputFactoryClass); diff --git a/common/components/NonceFieldFactory/index.ts b/common/components/NonceFieldFactory/index.ts new file mode 100644 index 00000000..43f0432e --- /dev/null +++ b/common/components/NonceFieldFactory/index.ts @@ -0,0 +1 @@ +export * from './NonceFieldFactory'; diff --git a/common/containers/Tabs/Contracts/components/Deploy.tsx b/common/containers/Tabs/Contracts/components/Deploy.tsx index 2672b2a8..fff1eec6 100644 --- a/common/containers/Tabs/Contracts/components/Deploy.tsx +++ b/common/containers/Tabs/Contracts/components/Deploy.tsx @@ -64,7 +64,7 @@ class DeployClass extends Component {
- +
diff --git a/common/containers/Tabs/Contracts/components/Interact/components/InteractExplorer/components/Fields.tsx b/common/containers/Tabs/Contracts/components/Interact/components/InteractExplorer/components/Fields.tsx index 21b6722c..f30dbe31 100644 --- a/common/containers/Tabs/Contracts/components/Interact/components/InteractExplorer/components/Fields.tsx +++ b/common/containers/Tabs/Contracts/components/Interact/components/InteractExplorer/components/Fields.tsx @@ -14,7 +14,7 @@ export class Fields extends Component { - + {this.props.button} diff --git a/common/containers/Tabs/SendTransaction/components/RequestPayment.tsx b/common/containers/Tabs/SendTransaction/components/RequestPayment.tsx index bb04e1b3..dfd6b7a7 100644 --- a/common/containers/Tabs/SendTransaction/components/RequestPayment.tsx +++ b/common/containers/Tabs/SendTransaction/components/RequestPayment.tsx @@ -106,7 +106,7 @@ class RequestPayment extends React.Component {
- +
diff --git a/common/libs/nodes/rpc/index.ts b/common/libs/nodes/rpc/index.ts index 038a64a6..ac4eb764 100644 --- a/common/libs/nodes/rpc/index.ts +++ b/common/libs/nodes/rpc/index.ts @@ -46,10 +46,15 @@ export default class RpcNode implements INode { } public estimateGas(transaction: Partial): Promise { + // 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( diff --git a/common/reducers/config.ts b/common/reducers/config.ts index e218c2eb..a03a8dda 100644 --- a/common/reducers/config.ts +++ b/common/reducers/config.ts @@ -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: diff --git a/common/reducers/transaction/network/network.ts b/common/reducers/transaction/network/network.ts index 8658ddd0..565da9a1 100644 --- a/common/reducers/transaction/network/network.ts +++ b/common/reducers/transaction/network/network.ts @@ -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: diff --git a/common/reducers/transaction/network/typings.ts b/common/reducers/transaction/network/typings.ts index 57add313..6a830461 100644 --- a/common/reducers/transaction/network/typings.ts +++ b/common/reducers/transaction/network/typings.ts @@ -1,7 +1,8 @@ export enum RequestStatus { REQUESTED = 'PENDING', SUCCEEDED = 'SUCCESS', - FAILED = 'FAIL' + FAILED = 'FAIL', + TIMEDOUT = 'TIMEDOUT' } export interface State { gasEstimationStatus: RequestStatus | null; diff --git a/common/sagas/transaction/network/gas.ts b/common/sagas/transaction/network/gas.ts index 997b71d9..fdfd5565 100644 --- a/common/sagas/transaction/network/gas.ts +++ b/common/sagas/transaction/network/gas.ts @@ -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)]; diff --git a/common/selectors/config.ts b/common/selectors/config.ts index a684d721..9039d386 100644 --- a/common/selectors/config.ts +++ b/common/selectors/config.ts @@ -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); diff --git a/common/selectors/transaction/network.ts b/common/selectors/transaction/network.ts index 5a6be4d6..d6ec4890 100644 --- a/common/selectors/transaction/network.ts +++ b/common/selectors/transaction/network.ts @@ -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; diff --git a/common/store.ts b/common/store.ts index 6bc9a7e9..4827592f 100644 --- a/common/store.ts +++ b/common/store.ts @@ -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: { diff --git a/spec/sagas/transaction/network/gas.spec.ts b/spec/sagas/transaction/network/gas.spec.ts index 0f6aaadf..4469b659 100644 --- a/spec/sagas/transaction/network/gas.spec.ts +++ b/spec/sagas/transaction/network/gas.spec.ts @@ -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 + }) + ) + ); + }); +});