import { uniqBy, cloneDeep, flatten } from 'lodash' import BigNumber from 'bignumber.js' import { getStorageItem, setStorageItem } from '../../../lib/storage-helpers' import { decGWEIToHexWEI } from '../../helpers/utils/conversions.util' import { isEthereumNetwork } from '../../selectors' // Actions const BASIC_GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_FINISHED' const BASIC_GAS_ESTIMATE_LOADING_STARTED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_STARTED' const GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/GAS_ESTIMATE_LOADING_FINISHED' const GAS_ESTIMATE_LOADING_STARTED = 'metamask/gas/GAS_ESTIMATE_LOADING_STARTED' const RESET_CUSTOM_GAS_STATE = 'metamask/gas/RESET_CUSTOM_GAS_STATE' const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA' const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA' const SET_CUSTOM_GAS_ERRORS = 'metamask/gas/SET_CUSTOM_GAS_ERRORS' const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT' const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE' const SET_CUSTOM_GAS_TOTAL = 'metamask/gas/SET_CUSTOM_GAS_TOTAL' const SET_PRICE_AND_TIME_ESTIMATES = 'metamask/gas/SET_PRICE_AND_TIME_ESTIMATES' const SET_API_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_API_ESTIMATES_LAST_RETRIEVED' const SET_BASIC_API_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_BASIC_API_ESTIMATES_LAST_RETRIEVED' const SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED = 'metamask/gas/SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED' const initState = { customData: { price: null, limit: null, }, basicEstimates: { average: null, fastestWait: null, fastWait: null, fast: null, safeLowWait: null, blockNum: null, avgWait: null, blockTime: null, speed: null, fastest: null, safeLow: null, }, basicEstimateIsLoading: true, gasEstimatesLoading: true, priceAndTimeEstimates: [], priceAndTimeEstimatesLastRetrieved: 0, basicPriceAndTimeEstimatesLastRetrieved: 0, basicPriceEstimatesLastRetrieved: 0, errors: {}, } // Reducer export default function reducer(state = initState, action) { switch (action.type) { case BASIC_GAS_ESTIMATE_LOADING_STARTED: return { ...state, basicEstimateIsLoading: true, } case BASIC_GAS_ESTIMATE_LOADING_FINISHED: return { ...state, basicEstimateIsLoading: false, } case GAS_ESTIMATE_LOADING_STARTED: return { ...state, gasEstimatesLoading: true, } case GAS_ESTIMATE_LOADING_FINISHED: return { ...state, gasEstimatesLoading: false, } case SET_BASIC_GAS_ESTIMATE_DATA: return { ...state, basicEstimates: action.value, } case SET_CUSTOM_GAS_PRICE: return { ...state, customData: { ...state.customData, price: action.value, }, } case SET_CUSTOM_GAS_LIMIT: return { ...state, customData: { ...state.customData, limit: action.value, }, } case SET_CUSTOM_GAS_TOTAL: return { ...state, customData: { ...state.customData, total: action.value, }, } case SET_PRICE_AND_TIME_ESTIMATES: return { ...state, priceAndTimeEstimates: action.value, } case SET_CUSTOM_GAS_ERRORS: return { ...state, errors: { ...state.errors, ...action.value, }, } case SET_API_ESTIMATES_LAST_RETRIEVED: return { ...state, priceAndTimeEstimatesLastRetrieved: action.value, } case SET_BASIC_API_ESTIMATES_LAST_RETRIEVED: return { ...state, basicPriceAndTimeEstimatesLastRetrieved: action.value, } case SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED: return { ...state, basicPriceEstimatesLastRetrieved: action.value, } case RESET_CUSTOM_DATA: return { ...state, customData: cloneDeep(initState.customData), } case RESET_CUSTOM_GAS_STATE: return cloneDeep(initState) default: return state } } // Action Creators export function basicGasEstimatesLoadingStarted() { return { type: BASIC_GAS_ESTIMATE_LOADING_STARTED, } } export function basicGasEstimatesLoadingFinished() { return { type: BASIC_GAS_ESTIMATE_LOADING_FINISHED, } } export function gasEstimatesLoadingStarted() { return { type: GAS_ESTIMATE_LOADING_STARTED, } } export function gasEstimatesLoadingFinished() { return { type: GAS_ESTIMATE_LOADING_FINISHED, } } async function queryEthGasStationBasic() { const apiKey = process.env.ETH_GAS_STATION_API_KEY ? `?api-key=${process.env.ETH_GAS_STATION_API_KEY}` : '' const url = `https://ethgasstation.info/json/ethgasAPI.json${apiKey}` return await window.fetch(url, { headers: {}, referrer: 'http://ethgasstation.info/json/', referrerPolicy: 'no-referrer-when-downgrade', body: null, method: 'GET', mode: 'cors', }) } async function queryEthGasStationPredictionTable() { const apiKey = process.env.ETH_GAS_STATION_API_KEY ? `?api-key=${process.env.ETH_GAS_STATION_API_KEY}` : '' const url = `https://ethgasstation.info/json/predictTable.json${apiKey}` return await window.fetch(url, { headers: {}, referrer: 'http://ethgasstation.info/json/', referrerPolicy: 'no-referrer-when-downgrade', body: null, method: 'GET', mode: 'cors', }) } export function fetchBasicGasEstimates() { return async (dispatch, getState) => { const { basicPriceEstimatesLastRetrieved } = getState().gas const timeLastRetrieved = basicPriceEstimatesLastRetrieved || (await getStorageItem('BASIC_PRICE_ESTIMATES_LAST_RETRIEVED')) || 0 dispatch(basicGasEstimatesLoadingStarted()) let basicEstimates if (Date.now() - timeLastRetrieved > 75000) { basicEstimates = await fetchExternalBasicGasEstimates(dispatch) } else { const cachedBasicEstimates = await getStorageItem('BASIC_PRICE_ESTIMATES') basicEstimates = cachedBasicEstimates || (await fetchExternalBasicGasEstimates(dispatch)) } dispatch(setBasicGasEstimateData(basicEstimates)) dispatch(basicGasEstimatesLoadingFinished()) return basicEstimates } } async function fetchExternalBasicGasEstimates(dispatch) { const response = await queryEthGasStationBasic() const { safeLow: safeLowTimes10, average: averageTimes10, fast: fastTimes10, fastest: fastestTimes10, block_time: blockTime, blockNum, } = await response.json() const [average, fast, fastest, safeLow] = [ averageTimes10, fastTimes10, fastestTimes10, safeLowTimes10, ].map((price) => new BigNumber(price).div(10).toNumber()) const basicEstimates = { safeLow, average, fast, fastest, blockTime, blockNum, } const timeRetrieved = Date.now() await Promise.all([ setStorageItem('BASIC_PRICE_ESTIMATES', basicEstimates), setStorageItem('BASIC_PRICE_ESTIMATES_LAST_RETRIEVED', timeRetrieved), ]) dispatch(setBasicPriceEstimatesLastRetrieved(timeRetrieved)) return basicEstimates } export function fetchBasicGasAndTimeEstimates() { return async (dispatch, getState) => { const { basicPriceAndTimeEstimatesLastRetrieved } = getState().gas const timeLastRetrieved = basicPriceAndTimeEstimatesLastRetrieved || (await getStorageItem( 'BASIC_GAS_AND_TIME_API_ESTIMATES_LAST_RETRIEVED', )) || 0 dispatch(basicGasEstimatesLoadingStarted()) let basicEstimates if (Date.now() - timeLastRetrieved > 75000) { basicEstimates = await fetchExternalBasicGasAndTimeEstimates(dispatch) } else { const cachedBasicEstimates = await getStorageItem( 'BASIC_GAS_AND_TIME_API_ESTIMATES', ) basicEstimates = cachedBasicEstimates || (await fetchExternalBasicGasAndTimeEstimates(dispatch)) } dispatch(setBasicGasEstimateData(basicEstimates)) dispatch(basicGasEstimatesLoadingFinished()) return basicEstimates } } async function fetchExternalBasicGasAndTimeEstimates(dispatch) { const response = await queryEthGasStationBasic() const { average: averageTimes10, avgWait, block_time: blockTime, blockNum, fast: fastTimes10, fastest: fastestTimes10, fastestWait, fastWait, safeLow: safeLowTimes10, safeLowWait, speed, } = await response.json() const [average, fast, fastest, safeLow] = [ averageTimes10, fastTimes10, fastestTimes10, safeLowTimes10, ].map((price) => new BigNumber(price).div(10).toNumber()) const basicEstimates = { average, avgWait, blockTime, blockNum, fast, fastest, fastestWait, fastWait, safeLow, safeLowWait, speed, } const timeRetrieved = Date.now() await Promise.all([ setStorageItem('BASIC_GAS_AND_TIME_API_ESTIMATES', basicEstimates), setStorageItem( 'BASIC_GAS_AND_TIME_API_ESTIMATES_LAST_RETRIEVED', timeRetrieved, ), ]) dispatch(setBasicApiEstimatesLastRetrieved(timeRetrieved)) return basicEstimates } function extrapolateY({ higherY, lowerY, higherX, lowerX, xForExtrapolation }) { /* eslint-disable no-param-reassign */ higherY = new BigNumber(higherY, 10) lowerY = new BigNumber(lowerY, 10) higherX = new BigNumber(higherX, 10) lowerX = new BigNumber(lowerX, 10) xForExtrapolation = new BigNumber(xForExtrapolation, 10) /* eslint-enable no-param-reassign */ const slope = higherY.minus(lowerY).div(higherX.minus(lowerX)) const newTimeEstimate = slope .times(higherX.minus(xForExtrapolation)) .minus(higherY) .negated() return Number(newTimeEstimate.toPrecision(10)) } function getRandomArbitrary(minStr, maxStr) { const min = new BigNumber(minStr, 10) const max = new BigNumber(maxStr, 10) const random = new BigNumber(String(Math.random()), 10) return new BigNumber(random.times(max.minus(min)).plus(min)).toPrecision(10) } function calcMedian(list) { const medianPos = (Math.floor(list.length / 2) + Math.ceil(list.length / 2)) / 2 return medianPos === Math.floor(medianPos) ? (list[medianPos - 1] + list[medianPos]) / 2 : list[Math.floor(medianPos)] } function quartiles(data) { const lowerHalf = data.slice(0, Math.floor(data.length / 2)) const upperHalf = data.slice( Math.floor(data.length / 2) + (data.length % 2 === 0 ? 0 : 1), ) const median = calcMedian(data) const lowerQuartile = calcMedian(lowerHalf) const upperQuartile = calcMedian(upperHalf) return { median, lowerQuartile, upperQuartile, } } function inliersByIQR(data, prop) { const { lowerQuartile, upperQuartile } = quartiles( data.map((d) => (prop ? d[prop] : d)), ) const IQR = upperQuartile - lowerQuartile const lowerBound = lowerQuartile - 1.5 * IQR const upperBound = upperQuartile + 1.5 * IQR return data.filter((d) => { const value = prop ? d[prop] : d return value >= lowerBound && value <= upperBound }) } export function fetchGasEstimates(blockTime) { return async (dispatch, getState) => { const state = getState() if (!isEthereumNetwork(state)) { return } const { priceAndTimeEstimatesLastRetrieved, priceAndTimeEstimates, } = state.gas const timeLastRetrieved = priceAndTimeEstimatesLastRetrieved || (await getStorageItem('GAS_API_ESTIMATES_LAST_RETRIEVED')) || 0 dispatch(gasEstimatesLoadingStarted()) const shouldGetFreshGasQuote = Date.now() - timeLastRetrieved > 75000 let estimates if (shouldGetFreshGasQuote) { const response = await queryEthGasStationPredictionTable() const tableJson = await response.json() const estimatedPricesAndTimes = tableJson.map( ({ expectedTime, expectedWait, gasprice }) => ({ expectedTime, expectedWait, gasprice, }), ) const estimatedTimeWithUniquePrices = uniqBy( estimatedPricesAndTimes, ({ expectedTime }) => expectedTime, ) const withSupplementalTimeEstimates = flatten( estimatedTimeWithUniquePrices.map( ({ expectedWait, gasprice }, i, arr) => { const next = arr[i + 1] if (!next) { return [{ expectedWait, gasprice }] } const supplementalPrice = getRandomArbitrary( gasprice, next.gasprice, ) const supplementalTime = extrapolateY({ higherY: next.expectedWait, lowerY: expectedWait, higherX: next.gasprice, lowerX: gasprice, xForExtrapolation: supplementalPrice, }) const supplementalPrice2 = getRandomArbitrary( supplementalPrice, next.gasprice, ) const supplementalTime2 = extrapolateY({ higherY: next.expectedWait, lowerY: supplementalTime, higherX: next.gasprice, lowerX: supplementalPrice, xForExtrapolation: supplementalPrice2, }) return [ { expectedWait, gasprice }, { expectedWait: supplementalTime, gasprice: supplementalPrice, }, { expectedWait: supplementalTime2, gasprice: supplementalPrice2, }, ] }, ), ) const withOutliersRemoved = inliersByIQR( withSupplementalTimeEstimates.slice(0).reverse(), 'expectedWait', ).reverse() const timeMappedToSeconds = withOutliersRemoved.map( ({ expectedWait, gasprice }) => { const expectedTime = new BigNumber(expectedWait) .times(Number(blockTime), 10) .toNumber() return { expectedTime, gasprice: new BigNumber(gasprice, 10).toNumber(), } }, ) const timeRetrieved = Date.now() dispatch(setApiEstimatesLastRetrieved(timeRetrieved)) await Promise.all([ setStorageItem('GAS_API_ESTIMATES_LAST_RETRIEVED', timeRetrieved), setStorageItem('GAS_API_ESTIMATES', timeMappedToSeconds), ]) estimates = timeMappedToSeconds } else if (priceAndTimeEstimates.length) { estimates = priceAndTimeEstimates } else { estimates = await getStorageItem('GAS_API_ESTIMATES') } dispatch(setPricesAndTimeEstimates(estimates)) dispatch(gasEstimatesLoadingFinished()) } } export function setCustomGasPriceForRetry(newPrice) { return async (dispatch) => { if (newPrice === '0x0') { const { fast } = await getStorageItem('BASIC_PRICE_ESTIMATES') dispatch(setCustomGasPrice(decGWEIToHexWEI(fast))) } else { dispatch(setCustomGasPrice(newPrice)) } } } export function setBasicGasEstimateData(basicGasEstimateData) { return { type: SET_BASIC_GAS_ESTIMATE_DATA, value: basicGasEstimateData, } } export function setPricesAndTimeEstimates(estimatedPricesAndTimes) { return { type: SET_PRICE_AND_TIME_ESTIMATES, value: estimatedPricesAndTimes, } } export function setCustomGasPrice(newPrice) { return { type: SET_CUSTOM_GAS_PRICE, value: newPrice, } } export function setCustomGasLimit(newLimit) { return { type: SET_CUSTOM_GAS_LIMIT, value: newLimit, } } export function setCustomGasTotal(newTotal) { return { type: SET_CUSTOM_GAS_TOTAL, value: newTotal, } } export function setCustomGasErrors(newErrors) { return { type: SET_CUSTOM_GAS_ERRORS, value: newErrors, } } export function setApiEstimatesLastRetrieved(retrievalTime) { return { type: SET_API_ESTIMATES_LAST_RETRIEVED, value: retrievalTime, } } export function setBasicApiEstimatesLastRetrieved(retrievalTime) { return { type: SET_BASIC_API_ESTIMATES_LAST_RETRIEVED, value: retrievalTime, } } export function setBasicPriceEstimatesLastRetrieved(retrievalTime) { return { type: SET_BASIC_PRICE_ESTIMATES_LAST_RETRIEVED, value: retrievalTime, } } export function resetCustomGasState() { return { type: RESET_CUSTOM_GAS_STATE } } export function resetCustomData() { return { type: RESET_CUSTOM_DATA } }