diff --git a/common/actions/gas/actionCreators.ts b/common/actions/gas/actionCreators.ts new file mode 100644 index 00000000..c09b1bfe --- /dev/null +++ b/common/actions/gas/actionCreators.ts @@ -0,0 +1,19 @@ +import * as interfaces from './actionTypes'; +import { TypeKeys } from './constants'; + +export type TFetchGasEstimates = typeof fetchGasEstimates; +export function fetchGasEstimates(): interfaces.FetchGasEstimatesAction { + return { + type: TypeKeys.GAS_FETCH_ESTIMATES + }; +} + +export type TSetGasEstimates = typeof setGasEstimates; +export function setGasEstimates( + payload: interfaces.SetGasEstimatesAction['payload'] +): interfaces.SetGasEstimatesAction { + return { + type: TypeKeys.GAS_SET_ESTIMATES, + payload + }; +} diff --git a/common/actions/gas/actionTypes.ts b/common/actions/gas/actionTypes.ts new file mode 100644 index 00000000..a66b86c0 --- /dev/null +++ b/common/actions/gas/actionTypes.ts @@ -0,0 +1,14 @@ +import { TypeKeys } from './constants'; +import { GasEstimates } from 'api/gas'; + +export interface FetchGasEstimatesAction { + type: TypeKeys.GAS_FETCH_ESTIMATES; +} + +export interface SetGasEstimatesAction { + type: TypeKeys.GAS_SET_ESTIMATES; + payload: GasEstimates; +} + +/*** Union Type ***/ +export type GasAction = FetchGasEstimatesAction | SetGasEstimatesAction; diff --git a/common/actions/gas/constants.ts b/common/actions/gas/constants.ts new file mode 100644 index 00000000..569bcb6b --- /dev/null +++ b/common/actions/gas/constants.ts @@ -0,0 +1,4 @@ +export enum TypeKeys { + GAS_FETCH_ESTIMATES = 'GAS_FETCH_ESTIMATES', + GAS_SET_ESTIMATES = 'GAS_SET_ESTIMATES' +} diff --git a/common/actions/gas/index.ts b/common/actions/gas/index.ts new file mode 100644 index 00000000..51fcd517 --- /dev/null +++ b/common/actions/gas/index.ts @@ -0,0 +1,3 @@ +export * from './actionCreators'; +export * from './actionTypes'; +export * from './constants'; diff --git a/common/api/gas.ts b/common/api/gas.ts new file mode 100644 index 00000000..d61df701 --- /dev/null +++ b/common/api/gas.ts @@ -0,0 +1,71 @@ +import { checkHttpStatus, parseJSON } from './utils'; + +const MAX_GAS_FAST = 250; + +interface RawGasEstimates { + safeLow: number; + standard: number; + fast: number; + fastest: number; + block_time: number; + blockNum: number; +} + +export interface GasEstimates { + safeLow: number; + standard: number; + fast: number; + fastest: number; + time: number; + isDefault: boolean; +} + +export function fetchGasEstimates(): Promise { + return fetch('https://dev.blockscale.net/api/gasexpress.json', { + mode: 'cors' + }) + .then(checkHttpStatus) + .then(parseJSON) + .then((res: object) => { + // Make sure it looks like a raw gas estimate, and it has valid values + const keys = ['safeLow', 'standard', 'fast', 'fastest']; + keys.forEach(key => { + if (typeof res[key] !== 'number') { + throw new Error( + `Gas estimate API has invalid shape: Expected numeric key '${key}' in response, got '${ + res[key] + }' instead` + ); + } + }); + + // Make sure the estimate isn't totally crazy + const estimateRes = res as RawGasEstimates; + if (estimateRes.fast > MAX_GAS_FAST) { + throw new Error( + `Gas estimate response estimate too high: Max fast is ${MAX_GAS_FAST}, was given ${ + estimateRes.fast + }` + ); + } + + if ( + estimateRes.safeLow > estimateRes.standard || + estimateRes.standard > estimateRes.fast || + estimateRes.fast > estimateRes.fastest + ) { + throw new Error( + `Gas esimates are in illogical order: should be safeLow < standard < fast < fastest, received ${ + estimateRes.safeLow + } < ${estimateRes.standard} < ${estimateRes.fast} < ${estimateRes.fastest}` + ); + } + + return estimateRes; + }) + .then((res: RawGasEstimates) => ({ + ...res, + time: Date.now(), + isDefault: false + })); +} diff --git a/common/components/Header/components/GasPriceDropdown.tsx b/common/components/Header/components/GasPriceDropdown.tsx index d4b1fd94..dc355042 100644 --- a/common/components/Header/components/GasPriceDropdown.tsx +++ b/common/components/Header/components/GasPriceDropdown.tsx @@ -53,8 +53,8 @@ class GasPriceDropdown extends Component {

Not So Fast

diff --git a/common/components/TXMetaDataPanel/components/FeeSummary.tsx b/common/components/TXMetaDataPanel/components/FeeSummary.tsx index 0dcd532e..615162a2 100644 --- a/common/components/TXMetaDataPanel/components/FeeSummary.tsx +++ b/common/components/TXMetaDataPanel/components/FeeSummary.tsx @@ -3,7 +3,9 @@ import BN from 'bn.js'; import { connect } from 'react-redux'; import { AppState } from 'reducers'; import { getNetworkConfig, getOffline } from 'selectors/config'; -import { UnitDisplay } from 'components/ui'; +import { getIsEstimating } from 'selectors/gas'; +import { getGasLimit } from 'selectors/transaction'; +import { UnitDisplay, Spinner } from 'components/ui'; import { NetworkConfig } from 'types/network'; import './FeeSummary.scss'; @@ -20,6 +22,7 @@ interface ReduxStateProps { rates: AppState['rates']['rates']; network: NetworkConfig; isOffline: AppState['config']['meta']['offline']; + isGasEstimating: AppState['gas']['isEstimating']; } interface OwnProps { @@ -31,7 +34,15 @@ type Props = OwnProps & ReduxStateProps; class FeeSummary extends React.Component { public render() { - const { gasPrice, gasLimit, rates, network, isOffline } = this.props; + const { gasPrice, gasLimit, rates, network, isOffline, isGasEstimating } = this.props; + + if (isGasEstimating) { + return ( +
+ +
+ ); + } const feeBig = gasPrice.value && gasLimit.value && gasPrice.value.mul(gasLimit.value); const fee = ( @@ -73,10 +84,11 @@ class FeeSummary extends React.Component { function mapStateToProps(state: AppState): ReduxStateProps { return { - gasLimit: state.transaction.fields.gasLimit, + gasLimit: getGasLimit(state), rates: state.rates.rates, network: getNetworkConfig(state), - isOffline: getOffline(state) + isOffline: getOffline(state), + isGasEstimating: getIsEstimating(state) }; } diff --git a/common/components/TXMetaDataPanel/components/SimpleGas.tsx b/common/components/TXMetaDataPanel/components/SimpleGas.tsx index 7dd88361..6ae51845 100644 --- a/common/components/TXMetaDataPanel/components/SimpleGas.tsx +++ b/common/components/TXMetaDataPanel/components/SimpleGas.tsx @@ -11,33 +11,50 @@ import { nonceRequestPending } from 'selectors/transaction'; import { connect } from 'react-redux'; +import { fetchGasEstimates, TFetchGasEstimates } from 'actions/gas'; import { getIsWeb3Node } from 'selectors/config'; +import { getEstimates, getIsEstimating } from 'selectors/gas'; import { Wei, fromWei } from 'libs/units'; import { InlineSpinner } from 'components/ui/InlineSpinner'; const SliderWithTooltip = Slider.createSliderWithTooltip(Slider); interface OwnProps { gasPrice: AppState['transaction']['fields']['gasPrice']; - noncePending: boolean; - gasLimitPending: boolean; inputGasPrice(rawGas: string); setGasPrice(rawGas: string); } interface StateProps { + gasEstimates: AppState['gas']['estimates']; + isGasEstimating: AppState['gas']['isEstimating']; + noncePending: boolean; + gasLimitPending: boolean; isWeb3Node: boolean; gasLimitEstimationTimedOut: boolean; } -type Props = OwnProps & StateProps; +interface ActionProps { + fetchGasEstimates: TFetchGasEstimates; +} + +type Props = OwnProps & StateProps & ActionProps; class SimpleGas extends React.Component { public componentDidMount() { this.fixGasPrice(this.props.gasPrice); + this.props.fetchGasEstimates(); + } + + public componentWillReceiveProps(nextProps: Props) { + if (!this.props.gasEstimates && nextProps.gasEstimates) { + this.props.setGasPrice(nextProps.gasEstimates.fast.toString()); + } } public render() { const { + isGasEstimating, + gasEstimates, gasPrice, gasLimitEstimationTimedOut, isWeb3Node, @@ -45,6 +62,11 @@ class SimpleGas extends React.Component { gasLimitPending } = this.props; + const bounds = { + max: gasEstimates ? gasEstimates.fastest : gasPriceDefaults.minGwei, + min: gasEstimates ? gasEstimates.safeLow : gasPriceDefaults.maxGwei + }; + return (
@@ -69,14 +91,14 @@ class SimpleGas extends React.Component {
`${gas} Gwei`} + tipFormatter={this.formatTooltip} + disabled={isGasEstimating} />
{translate('Cheap')} - {translate('Balanced')} {translate('Fast')}
@@ -100,21 +122,38 @@ class SimpleGas extends React.Component { private fixGasPrice(gasPrice: AppState['transaction']['fields']['gasPrice']) { // If the gas price is above or below our minimum, bring it in line const gasPriceGwei = this.getGasPriceGwei(gasPrice.value); - if (gasPriceGwei > gasPriceDefaults.gasPriceMaxGwei) { - this.props.setGasPrice(gasPriceDefaults.gasPriceMaxGwei.toString()); - } else if (gasPriceGwei < gasPriceDefaults.gasPriceMinGwei) { - this.props.setGasPrice(gasPriceDefaults.gasPriceMinGwei.toString()); + if (gasPriceGwei > gasPriceDefaults.maxGwei) { + this.props.setGasPrice(gasPriceDefaults.maxGwei.toString()); + } else if (gasPriceGwei < gasPriceDefaults.minGwei) { + this.props.setGasPrice(gasPriceDefaults.minGwei.toString()); } } private getGasPriceGwei(gasPriceValue: Wei) { return parseFloat(fromWei(gasPriceValue, 'gwei')); } + + private formatTooltip = (gas: number) => { + const { gasEstimates } = this.props; + let recommended = ''; + if (gasEstimates && !gasEstimates.isDefault && gas === gasEstimates.fast) { + recommended = '(Recommended)'; + } + + return `${gas} Gwei ${recommended}`; + }; } -export default connect((state: AppState) => ({ - noncePending: nonceRequestPending(state), - gasLimitPending: getGasEstimationPending(state), - gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state), - isWeb3Node: getIsWeb3Node(state) -}))(SimpleGas); +export default connect( + (state: AppState): StateProps => ({ + gasEstimates: getEstimates(state), + isGasEstimating: getIsEstimating(state), + noncePending: nonceRequestPending(state), + gasLimitPending: getGasEstimationPending(state), + gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state), + isWeb3Node: getIsWeb3Node(state) + }), + { + fetchGasEstimates + } +)(SimpleGas); diff --git a/common/config/constants.ts b/common/config/constants.ts index 631f6f78..b1a932bb 100644 --- a/common/config/constants.ts +++ b/common/config/constants.ts @@ -5,3 +5,4 @@ export const GAS_LIMIT_UPPER_BOUND = 8000000; // Lower/upper ranges for gas price in gwei export const GAS_PRICE_GWEI_LOWER_BOUND = 1; export const GAS_PRICE_GWEI_UPPER_BOUND = 10000; +export const GAS_PRICE_GWEI_DEFAULT = 40; diff --git a/common/config/data.tsx b/common/config/data.tsx index 3d786dea..4356b39a 100644 --- a/common/config/data.tsx +++ b/common/config/data.tsx @@ -42,9 +42,11 @@ export const donationAddressMap = { }; export const gasPriceDefaults = { - gasPriceMinGwei: 1, - gasPriceMaxGwei: 60 + minGwei: 1, + maxGwei: 60, + default: 21 }; +export const gasEstimateCacheTime = 60000; export const MINIMUM_PASSWORD_LENGTH = 12; diff --git a/common/reducers/gas.ts b/common/reducers/gas.ts new file mode 100644 index 00000000..17756295 --- /dev/null +++ b/common/reducers/gas.ts @@ -0,0 +1,38 @@ +import { SetGasEstimatesAction, GasAction, TypeKeys } from 'actions/gas'; +import { GasEstimates } from 'api/gas'; + +export interface State { + estimates: GasEstimates | null; + isEstimating: boolean; +} + +export const INITIAL_STATE: State = { + estimates: null, + isEstimating: false +}; + +function fetchGasEstimates(state: State): State { + return { + ...state, + isEstimating: true + }; +} + +function setGasEstimates(state: State, action: SetGasEstimatesAction): State { + return { + ...state, + estimates: action.payload, + isEstimating: false + }; +} + +export function gas(state: State = INITIAL_STATE, action: GasAction): State { + switch (action.type) { + case TypeKeys.GAS_FETCH_ESTIMATES: + return fetchGasEstimates(state); + case TypeKeys.GAS_SET_ESTIMATES: + return setGasEstimates(state, action); + default: + return state; + } +} diff --git a/common/reducers/index.ts b/common/reducers/index.ts index b4cb3cb5..d342f1a4 100644 --- a/common/reducers/index.ts +++ b/common/reducers/index.ts @@ -9,6 +9,7 @@ import { rates, State as RatesState } from './rates'; import { State as SwapState, swap } from './swap'; import { State as WalletState, wallet } from './wallet'; import { State as TransactionState, transaction } from './transaction'; +import { State as GasState, gas } from './gas'; import { onboardStatus, State as OnboardStatusState } from './onboardStatus'; import { State as TransactionsState, transactions } from './transactions'; @@ -25,6 +26,7 @@ export interface AppState { swap: SwapState; transaction: TransactionState; transactions: TransactionsState; + gas: GasState; // Third party reducers (TODO: Fill these out) routing: any; } @@ -41,5 +43,6 @@ export default combineReducers({ deterministicWallets, transaction, transactions, + gas, routing: routerReducer }); diff --git a/common/reducers/transaction/fields/fields.ts b/common/reducers/transaction/fields/fields.ts index 9cb9c4d0..4bba6fc3 100644 --- a/common/reducers/transaction/fields/fields.ts +++ b/common/reducers/transaction/fields/fields.ts @@ -11,6 +11,7 @@ import { import { Reducer } from 'redux'; import { State } from './typings'; import { gasPricetoBase } from 'libs/units'; +import { gasPriceDefaults } from 'config'; const INITIAL_STATE: State = { to: { raw: '', value: null }, @@ -18,7 +19,10 @@ const INITIAL_STATE: State = { nonce: { raw: '', value: null }, value: { raw: '', value: null }, gasLimit: { raw: '21000', value: new BN(21000) }, - gasPrice: { raw: '21', value: gasPricetoBase(21) } + gasPrice: { + raw: gasPriceDefaults.default.toString(), + value: gasPricetoBase(gasPriceDefaults.default) + } }; const updateField = (key: keyof State): Reducer => (state: State, action: FieldAction) => ({ diff --git a/common/sagas/gas.ts b/common/sagas/gas.ts new file mode 100644 index 00000000..35e7dfe6 --- /dev/null +++ b/common/sagas/gas.ts @@ -0,0 +1,53 @@ +import { setGasEstimates, TypeKeys } from 'actions/gas'; +import { SagaIterator } from 'redux-saga'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; +import { AppState } from 'reducers'; +import { fetchGasEstimates, GasEstimates } from 'api/gas'; +import { gasPriceDefaults, gasEstimateCacheTime } from 'config'; +import { getEstimates } from 'selectors/gas'; +import { getOffline } from 'selectors/config'; + +export function* setDefaultEstimates(): SagaIterator { + // Must yield time for testability + const time = yield call(Date.now); + + yield put( + setGasEstimates({ + safeLow: gasPriceDefaults.minGwei, + standard: gasPriceDefaults.default, + fast: gasPriceDefaults.default, + fastest: gasPriceDefaults.maxGwei, + isDefault: true, + time + }) + ); +} + +export function* fetchEstimates(): SagaIterator { + // Don't even try offline + const isOffline: boolean = yield select(getOffline); + if (isOffline) { + yield call(setDefaultEstimates); + return; + } + + // Cache estimates for a bit + const oldEstimates: AppState['gas']['estimates'] = yield select(getEstimates); + if (oldEstimates && oldEstimates.time + gasEstimateCacheTime > Date.now()) { + yield put(setGasEstimates(oldEstimates)); + return; + } + + // Try to fetch new estimates + try { + const estimates: GasEstimates = yield call(fetchGasEstimates); + yield put(setGasEstimates(estimates)); + } catch (err) { + console.warn('Failed to fetch gas estimates:', err); + yield call(setDefaultEstimates); + } +} + +export default function* gas(): SagaIterator { + yield takeLatest(TypeKeys.GAS_FETCH_ESTIMATES, fetchEstimates); +} diff --git a/common/sagas/index.ts b/common/sagas/index.ts index 19d00e8f..4c716762 100644 --- a/common/sagas/index.ts +++ b/common/sagas/index.ts @@ -16,6 +16,7 @@ import wallet from './wallet'; import { ens } from './ens'; import { transaction } from './transaction'; import transactions from './transactions'; +import gas from './gas'; export default { ens, @@ -35,5 +36,6 @@ export default { deterministicWallets, swapProviderSaga, rates, - transactions + transactions, + gas }; diff --git a/common/sass/styles/overrides/rc-slider.scss b/common/sass/styles/overrides/rc-slider.scss index cdc9cf0c..abcdb223 100644 --- a/common/sass/styles/overrides/rc-slider.scss +++ b/common/sass/styles/overrides/rc-slider.scss @@ -5,6 +5,15 @@ $handle-size: 22px; $speed: 70ms; $tooltip-bg: rgba(#222, 0.95); +@keyframes slider-loading { + 0%, 100% { + opacity: 0.8; + } + 50% { + opacity: 0.4; + } +} + .rc-slider { &-rail { background: $gray-lighter; @@ -39,4 +48,20 @@ $tooltip-bg: rgba(#222, 0.95); border-radius: 3px; } } + + // Disabled styles + &-disabled { + background: none; + + .rc-slider { + &-handle, + &-track { + display: none; + } + + &-rail { + animation: slider-loading 1s ease infinite; + } + } + } } diff --git a/common/selectors/gas.ts b/common/selectors/gas.ts new file mode 100644 index 00000000..84ccfbdc --- /dev/null +++ b/common/selectors/gas.ts @@ -0,0 +1,5 @@ +import { AppState } from 'reducers'; + +const getGas = (state: AppState) => state.gas; +export const getEstimates = (state: AppState) => getGas(state).estimates; +export const getIsEstimating = (state: AppState) => getGas(state).isEstimating; diff --git a/spec/reducers/gas.spec.ts b/spec/reducers/gas.spec.ts new file mode 100644 index 00000000..bb814627 --- /dev/null +++ b/spec/reducers/gas.spec.ts @@ -0,0 +1,30 @@ +import { gas, INITIAL_STATE } from 'reducers/gas'; +import { fetchGasEstimates, setGasEstimates } from 'actions/gas'; +import { GasEstimates } from 'api/gas'; + +describe('gas reducer', () => { + it('should handle GAS_FETCH_ESTIMATES', () => { + const state = gas(undefined, fetchGasEstimates()); + expect(state).toEqual({ + ...INITIAL_STATE, + isEstimating: true + }); + }); + + it('should handle GAS_SET_ESTIMATES', () => { + const estimates: GasEstimates = { + safeLow: 1, + standard: 1, + fast: 4, + fastest: 20, + time: Date.now(), + isDefault: false + }; + const state = gas(undefined, setGasEstimates(estimates)); + expect(state).toEqual({ + ...INITIAL_STATE, + estimates, + isEstimating: false + }); + }); +}); diff --git a/spec/sagas/gas.spec.ts b/spec/sagas/gas.spec.ts new file mode 100644 index 00000000..4042ce7c --- /dev/null +++ b/spec/sagas/gas.spec.ts @@ -0,0 +1,94 @@ +import { fetchEstimates, setDefaultEstimates } from 'sagas/gas'; +import { call, put, select } from 'redux-saga/effects'; +import { cloneableGenerator } from 'redux-saga/utils'; +import { fetchGasEstimates, GasEstimates } from 'api/gas'; +import { setGasEstimates } from 'actions/gas'; +import { getEstimates } from 'selectors/gas'; +import { getOffline } from 'selectors/config'; +import { gasPriceDefaults, gasEstimateCacheTime } from 'config'; + +describe('fetchEstimates*', () => { + const gen = cloneableGenerator(fetchEstimates)(); + const offline = false; + const oldEstimates: GasEstimates = { + safeLow: 1, + standard: 1, + fast: 4, + fastest: 20, + time: Date.now() - gasEstimateCacheTime - 1000, + isDefault: false + }; + const newEstimates: GasEstimates = { + safeLow: 2, + standard: 2, + fast: 8, + fastest: 80, + time: Date.now(), + isDefault: false + }; + + it('Should select getOffline', () => { + expect(gen.next().value).toEqual(select(getOffline)); + }); + + it('Should use default estimates if offline', () => { + const offlineGen = gen.clone(); + expect(offlineGen.next(true).value).toEqual(call(setDefaultEstimates)); + expect(offlineGen.next().done).toBeTruthy(); + }); + + it('Should select getEstimates', () => { + expect(gen.next(offline).value).toEqual(select(getEstimates)); + }); + + it('Should use cached estimates if they’re recent', () => { + const cachedGen = gen.clone(); + const cacheEstimate = { + ...oldEstimates, + time: Date.now() - gasEstimateCacheTime + 1000 + }; + expect(cachedGen.next(cacheEstimate).value).toEqual(put(setGasEstimates(cacheEstimate))); + expect(cachedGen.next().done).toBeTruthy(); + }); + + it('Should fetch new estimates', () => { + expect(gen.next(oldEstimates).value).toEqual(call(fetchGasEstimates)); + }); + + it('Should use default estimates if request fails', () => { + const failedReqGen = gen.clone(); + // Not sure why, but typescript seems to think throw might be missing. + if (failedReqGen.throw) { + expect(failedReqGen.throw('test').value).toEqual(call(setDefaultEstimates)); + expect(failedReqGen.next().done).toBeTruthy(); + } else { + throw new Error('SagaIterator didn’t have throw'); + } + }); + + it('Should use fetched estimates', () => { + expect(gen.next(newEstimates).value).toEqual(put(setGasEstimates(newEstimates))); + expect(gen.next().done).toBeTruthy(); + }); +}); + +describe('setDefaultEstimates*', () => { + const gen = cloneableGenerator(setDefaultEstimates)(); + + it('Should put setGasEstimates with config defaults', () => { + const time = Date.now(); + gen.next(); + expect(gen.next(time).value).toEqual( + put( + setGasEstimates({ + safeLow: gasPriceDefaults.minGwei, + standard: gasPriceDefaults.default, + fast: gasPriceDefaults.default, + fastest: gasPriceDefaults.maxGwei, + isDefault: true, + time + }) + ) + ); + }); +});