Gas Price Estimates API (#1050)

* Setup api / reducers / actions for gas.

* Implement gas price saga, fetch from component, and loading states. Blocked on CORS.

* Implement caching mechanism.

* Add tests for gas saga and reducer.

* More testing.

* Indicate that gas price is recommended when fetched from API.

* Hide track while loading.

* Fix tscheck.

* Check gas estimate before assuming its ok.

* Check for correct logical order of gas prices.

* Tscheck fixes.
This commit is contained in:
William O'Beirne 2018-02-16 14:01:39 -05:00 committed by Daniel Ternyak
parent cec0d690c7
commit 31912c0f83
19 changed files with 446 additions and 27 deletions

View File

@ -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
};
}

View File

@ -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;

View File

@ -0,0 +1,4 @@
export enum TypeKeys {
GAS_FETCH_ESTIMATES = 'GAS_FETCH_ESTIMATES',
GAS_SET_ESTIMATES = 'GAS_SET_ESTIMATES'
}

View File

@ -0,0 +1,3 @@
export * from './actionCreators';
export * from './actionTypes';
export * from './constants';

71
common/api/gas.ts Normal file
View File

@ -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<GasEstimates> {
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
}));
}

View File

@ -53,8 +53,8 @@ class GasPriceDropdown extends Component<Props> {
<input
type="range"
value={this.props.gasPrice.raw}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
min={gasPriceDefaults.minGwei}
max={gasPriceDefaults.maxGwei}
onChange={this.handleGasPriceChange}
/>
<p className="small col-xs-4 text-left GasPrice-padding-reset">Not So Fast</p>

View File

@ -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<Props> {
public render() {
const { gasPrice, gasLimit, rates, network, isOffline } = this.props;
const { gasPrice, gasLimit, rates, network, isOffline, isGasEstimating } = this.props;
if (isGasEstimating) {
return (
<div className="FeeSummary is-loading">
<Spinner />
</div>
);
}
const feeBig = gasPrice.value && gasLimit.value && gasPrice.value.mul(gasLimit.value);
const fee = (
@ -73,10 +84,11 @@ class FeeSummary extends React.Component<Props> {
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)
};
}

View File

@ -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<Props> {
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<Props> {
gasLimitPending
} = this.props;
const bounds = {
max: gasEstimates ? gasEstimates.fastest : gasPriceDefaults.minGwei,
min: gasEstimates ? gasEstimates.safeLow : gasPriceDefaults.maxGwei
};
return (
<div className="SimpleGas row form-group">
<div className="SimpleGas-title">
@ -69,14 +91,14 @@ class SimpleGas extends React.Component<Props> {
<div className="SimpleGas-slider">
<SliderWithTooltip
onChange={this.handleSlider}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
min={bounds.min}
max={bounds.max}
value={this.getGasPriceGwei(gasPrice.value)}
tipFormatter={gas => `${gas} Gwei`}
tipFormatter={this.formatTooltip}
disabled={isGasEstimating}
/>
<div className="SimpleGas-slider-labels">
<span>{translate('Cheap')}</span>
<span>{translate('Balanced')}</span>
<span>{translate('Fast')}</span>
</div>
</div>
@ -100,21 +122,38 @@ class SimpleGas extends React.Component<Props> {
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);

View File

@ -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;

View File

@ -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;

38
common/reducers/gas.ts Normal file
View File

@ -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;
}
}

View File

@ -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<AppState>({
deterministicWallets,
transaction,
transactions,
gas,
routing: routerReducer
});

View File

@ -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: State, action: FieldAction) => ({

53
common/sagas/gas.ts Normal file
View File

@ -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);
}

View File

@ -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
};

View File

@ -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;
}
}
}
}

5
common/selectors/gas.ts Normal file
View File

@ -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;

30
spec/reducers/gas.spec.ts Normal file
View File

@ -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
});
});
});

94
spec/sagas/gas.spec.ts Normal file
View File

@ -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 theyre 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 didnt 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
})
)
);
});
});