Skip to content

Commit

Permalink
Extend GasFeeController to poll for network status
Browse files Browse the repository at this point in the history
When a user is about to send a transaction or is looking at a swap
quote, we would like to inform them if the network is busy so that we
can push them to use a lower fee setting. The GasFeeController already
provides a way to poll for fee estimates, which we employ on transaction
preview screens. This commit updates the polling code so that we also
gauge network status as we pull estimates. This is done by hitting
another endpoint in the MetaSwap API which specifically gives us a base
fee threshold we can use to determine whether the network is busy
(falling back to using `eth_feeHistory`).
  • Loading branch information
mcmire committed Nov 1, 2021
1 parent 6873f34 commit a741072
Show file tree
Hide file tree
Showing 10 changed files with 492 additions and 71 deletions.
124 changes: 84 additions & 40 deletions src/gas/GasFeeController.test.ts

Large diffs are not rendered by default.

164 changes: 135 additions & 29 deletions src/gas/GasFeeController.ts
@@ -1,8 +1,9 @@
import type { Patch } from 'immer';

import { BN, isHexString } from 'ethereumjs-util';
import EthQuery from 'eth-query';
import { v1 as random } from 'uuid';
import { isHexString } from 'ethereumjs-util';

import { BaseController } from '../BaseControllerV2';
import { safelyExecute } from '../util';
import type { RestrictedControllerMessenger } from '../ControllerMessenger';
Expand All @@ -17,6 +18,9 @@ import {
calculateTimeEstimate,
} from './gas-util';
import determineGasFeeSuggestions from './determineGasFeeSuggestions';
import determineNetworkStatusInfo, {
NetworkStatusInfo,
} from './determineNetworkStatusInfo';
import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory';

const GAS_FEE_API = 'https://mock-gas-server.herokuapp.com/';
Expand Down Expand Up @@ -131,6 +135,7 @@ const metadata = {
gasFeeEstimates: { persist: true, anonymous: false },
estimatedGasFeeTimeBounds: { persist: true, anonymous: false },
gasEstimateType: { persist: true, anonymous: false },
isNetworkBusy: { persist: true, anonymous: false },
};

export type GasFeeStateEthGasPrice = {
Expand Down Expand Up @@ -167,8 +172,13 @@ export type FetchGasFeeEstimateOptions = {
* Gas Fee controller state
* @property gasFeeEstimates - Gas fee estimate data based on new EIP-1559 properties
* @property estimatedGasFeeTimeBounds - Estimates representing the minimum and maximum
* @property gasEstimateType - Source of estimate data, if any
* @property isNetworkBusy - Whether or not there are a lot of transactions taking place within the
* network, causing high gas fees
*/
export type GasFeeState =
export type GasFeeState = GasFeeSuggestions & NetworkStatusInfo;

export type GasFeeSuggestions =
| GasFeeStateEthGasPrice
| GasFeeStateFeeMarket
| GasFeeStateLegacy
Expand Down Expand Up @@ -198,8 +208,34 @@ const defaultState: GasFeeState = {
gasFeeEstimates: {},
estimatedGasFeeTimeBounds: {},
gasEstimateType: GAS_ESTIMATE_TYPES.NONE,
isNetworkBusy: false,
};

export type ChainId = `0x${string}` | `${number}` | number;

/**
* Wraps the given function that is used to get the current chain id such that we guarantee that the
* chain id is a decimal number.
*
* @param getChainId - A function that returns the chain id of the currently selected network as
* a number expressed as a hex string, a decimal string, or a numeric value.
* @returns A function that returns the chain id as a numeric value.
*/
function withNormalizedChainId(getChainId: () => ChainId): () => number {
return () => {
const chainId = getChainId();
if (typeof chainId === 'string') {
if (isHexString(chainId)) {
return parseInt(chainId, 16);
}
return parseInt(chainId, 10);
} else if (typeof chainId === 'number') {
return chainId;
}
throw new Error(`Could not normalize chain id ${chainId}`);
};
}

/**
* Controller that retrieves gas fee estimate data and polls for updated data on a set interval
*/
Expand All @@ -218,6 +254,12 @@ export class GasFeeController extends BaseController<

private EIP1559APIEndpoint: string;

private determineNetworkStatusInfoUrlTemplate: ({
chainId,
}: {
chainId: ChainId;
}) => string;

private getCurrentNetworkEIP1559Compatibility;

private getCurrentNetworkLegacyGasAPICompatibility;
Expand All @@ -226,7 +268,7 @@ export class GasFeeController extends BaseController<

private getChainId;

private currentChainId;
private currentChainId: ChainId;

private ethQuery: any;

Expand All @@ -253,6 +295,8 @@ export class GasFeeController extends BaseController<
* testing purposes.
* @param options.EIP1559APIEndpoint - The EIP-1559 gas price API URL. This option is primarily
* for testing purposes.
* @param options.determineNetworkStatusInfoUrlTemplate - A function that returns a URL that will
* be used to retrieve information about the status of the network.
* @param options.clientId - The client ID used to identify to the gas estimation API who is
* asking for estimates.
*/
Expand All @@ -268,6 +312,7 @@ export class GasFeeController extends BaseController<
onNetworkStateChange,
legacyAPIEndpoint = LEGACY_GAS_PRICES_API_URL,
EIP1559APIEndpoint = GAS_FEE_API,
determineNetworkStatusInfoUrlTemplate,
clientId,
}: {
interval?: number;
Expand All @@ -276,11 +321,16 @@ export class GasFeeController extends BaseController<
getCurrentNetworkEIP1559Compatibility: () => Promise<boolean>;
getCurrentNetworkLegacyGasAPICompatibility: () => boolean;
getCurrentAccountEIP1559Compatibility?: () => boolean;
getChainId: () => `0x${string}` | `${number}` | number;
getChainId: () => ChainId;
getProvider: () => NetworkController['provider'];
onNetworkStateChange: (listener: (state: NetworkState) => void) => void;
legacyAPIEndpoint?: string;
EIP1559APIEndpoint?: string;
determineNetworkStatusInfoUrlTemplate: ({
chainId,
}: {
chainId: ChainId;
}) => string;
clientId?: string;
}) {
super({
Expand All @@ -296,7 +346,8 @@ export class GasFeeController extends BaseController<
this.getCurrentAccountEIP1559Compatibility = getCurrentAccountEIP1559Compatibility;
this.EIP1559APIEndpoint = EIP1559APIEndpoint;
this.legacyAPIEndpoint = legacyAPIEndpoint;
this.getChainId = getChainId;
this.determineNetworkStatusInfoUrlTemplate = determineNetworkStatusInfoUrlTemplate;
this.getChainId = withNormalizedChainId(getChainId);
this.currentChainId = this.getChainId();
const provider = getProvider();
this.ethQuery = new EthQuery(provider);
Expand Down Expand Up @@ -327,32 +378,45 @@ export class GasFeeController extends BaseController<
return await this._fetchGasFeeEstimateData(options);
}

/**
* Ensures that state is being continuously updated with gas fee estimate and network status data.
* More specifically, if this method has not been called before, it makes a request and uses the
* resulting data to update state appropriately, then creates a token that will represent that
* request and adds it to a "hat". As long as the hat is not empty, then the request will be
* re-run and state will be updated on a cadence.
*
* @param givenPollToken - Either a token that was obtained from a previous call to
* ensurePollingFor, or undefined (to represent no token).
* @returns A token to represent this particular request that can be used to decrease the size of
* the polling "hat" later.
*/
async getGasFeeEstimatesAndStartPolling(
pollToken: string | undefined,
givenPollToken: string | undefined,
): Promise<string> {
const _pollToken = pollToken || random();
const pollToken = givenPollToken || random();

this.pollTokens.add(_pollToken);
this.pollTokens.add(pollToken);

if (this.pollTokens.size === 1) {
await this._fetchGasFeeEstimateData();
this._poll();
await this.poll();
this.restartPolling();
}

return _pollToken;
return pollToken;
}

/**
* Gets and sets gasFeeEstimates in state.
* Fetches gas fee estimates using a variety of strategies and (optionally) updates state with the
* resulting data.
*
* @param options - The gas fee estimate options.
* @param options - Options for this method.
* @param options.shouldUpdateState - Determines whether the state should be updated with the
* updated gas estimates.
* fetched estimate data.
* @returns The gas fee estimates.
*/
async _fetchGasFeeEstimateData(
options: FetchGasFeeEstimateOptions = {},
): Promise<GasFeeState> {
): Promise<GasFeeSuggestions> {
const { shouldUpdateState = true } = options;
let isEIP1559Compatible;
const isLegacyGasAPICompatible = this.getCurrentNetworkLegacyGasAPICompatibility();
Expand Down Expand Up @@ -402,18 +466,23 @@ export class GasFeeController extends BaseController<
}

/**
* Remove the poll token, and stop polling if the set of poll tokens is empty.
* Removes the given token from a "hat" representing polling requests. If, before calling this
* method, there is only one token in the hat, then this effectively stops polling.
*
* @param pollToken - The poll token to disconnect.
* @param pollToken - A token returned from a previous call to getGasFeeEstimatesAndStartPolling.
*/
disconnectPoller(pollToken: string) {
disconnectPoller(pollToken: string): void {
this.pollTokens.delete(pollToken);
if (this.pollTokens.size === 0) {
this.stopPolling();
}
}

stopPolling() {
/**
* Removes all tokens representing polling requests such that state updates will no occur on a
* cadence.
*/
stopPolling(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
Expand All @@ -431,30 +500,48 @@ export class GasFeeController extends BaseController<
this.stopPolling();
}

private _poll() {
private restartPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
}

this.intervalId = setInterval(async () => {
await safelyExecute(() => this._fetchGasFeeEstimateData());
this.intervalId = setInterval(() => {
safelyExecute(() => this.poll());
}, this.intervalDelay);
}

private async poll() {
const isEIP1559Compatible = await this.getEIP1559Compatibility();

const gasFeeSuggestions = await this._fetchGasFeeEstimateData();

if (isEIP1559Compatible) {
// TODO: Any way we can avoid doing this?
const gasFeeEstimates = gasFeeSuggestions.gasFeeEstimates as GasFeeEstimates;
await this.fetchAndUpdateWithNetworkStatus(
new BN(gasFeeEstimates.estimatedBaseFee, 10),
);
}
}

private resetState() {
this.update(() => {
return defaultState;
});
}

private async getEIP1559Compatibility() {
const currentNetworkIsEIP1559Compatible = await this.getCurrentNetworkEIP1559Compatibility();
const currentAccountIsEIP1559Compatible =
this.getCurrentAccountEIP1559Compatibility?.() ?? true;
private async getEIP1559Compatibility(): Promise<boolean> {
try {
const currentNetworkIsEIP1559Compatible = await this.getCurrentNetworkEIP1559Compatibility();
const currentAccountIsEIP1559Compatible =
this.getCurrentAccountEIP1559Compatibility?.() ?? true;

return (
currentNetworkIsEIP1559Compatible && currentAccountIsEIP1559Compatible
);
return (
currentNetworkIsEIP1559Compatible && currentAccountIsEIP1559Compatible
);
} catch (e) {
return false;
}
}

getTimeEstimate(
Expand All @@ -473,6 +560,25 @@ export class GasFeeController extends BaseController<
this.state.gasFeeEstimates,
);
}

private async fetchAndUpdateWithNetworkStatus(
latestBaseFee: BN,
): Promise<NetworkStatusInfo> {
const chainId = this.getChainId();
const url = this.determineNetworkStatusInfoUrlTemplate({ chainId });
const networkStatusInfo = await determineNetworkStatusInfo({
latestBaseFee,
url,
ethQuery: this.ethQuery,
clientId: this.clientId,
});

this.update((state) => {
state.isNetworkBusy = networkStatusInfo.isNetworkBusy;
});

return networkStatusInfo;
}
}

export default GasFeeController;
44 changes: 44 additions & 0 deletions src/gas/calculateBusyThreshold.test.ts
@@ -0,0 +1,44 @@
import { BN } from 'ethereumjs-util';
import { mocked } from 'ts-jest/utils';
import calculateBusyThreshold from './calculateBusyThreshold';
import fetchBlockFeeHistory from './fetchBlockFeeHistory';

jest.mock('./fetchBlockFeeHistory');

const mockedFetchFeeHistory = mocked(fetchBlockFeeHistory, true);

describe('calculateBusyThreshold', () => {
const ethQuery = {};

beforeEach(() => {
const baseFeesPerGas = [
3_000_000_000,
8_000_000_000,
4_000_000_000,
6_000_000_000,
11_000_000_000,
5_000_000_000,
10_000_000_000,
2_000_000_000,
7_000_000_000,
1_000_000_000,
9_000_000_000,
];
mockedFetchFeeHistory.mockResolvedValue(
baseFeesPerGas.map((baseFeePerGas, i) => {
return {
number: new BN(i + 1),
baseFeePerGas: new BN(baseFeePerGas),
gasUsedRatio: 0,
priorityFeesByPercentile: {},
};
}),
);
});

it('sorts the base fees returned by eth_feeHistory, then returns the base fee 9/10 of the way through the list', async () => {
const busyThreshold = await calculateBusyThreshold(ethQuery);

expect(busyThreshold).toStrictEqual(new BN(9_000_000_000));
});
});
29 changes: 29 additions & 0 deletions src/gas/calculateBusyThreshold.ts
@@ -0,0 +1,29 @@
import { BN } from 'ethereumjs-util';
import fetchBlockFeeHistory from './fetchBlockFeeHistory';

type EthQuery = any;

const NUMBER_OF_BLOCKS_TO_FETCH = 20_000;

/**
* Uses historical base fees to determine a threshold we can use to determine whether the network is
* busy. Specifically, pulls the last 20,000 blocks (which at the time of this writing represents
* around 2 days), sorts the base fees of those blocks, then chooses the base fee which is 9/10 of
* the way into the list (i.e. the 90th percentile).
*
* @param ethQuery - An EthQuery instance.
* @returns A promise for the 90th percentile base fee in WEI, as a BN.
*/
export default async function calculateBusyThreshold(
ethQuery: EthQuery,
): Promise<BN> {
const blocks = await fetchBlockFeeHistory({
ethQuery,
numberOfBlocks: NUMBER_OF_BLOCKS_TO_FETCH,
});
const sortedBaseFeesPerGas = blocks
.map((block) => block.baseFeePerGas)
.sort((a, b) => a.cmp(b));
const indexAtPercentile90 = Math.floor(sortedBaseFeesPerGas.length * 0.9) - 1;
return sortedBaseFeesPerGas[indexAtPercentile90];
}
2 changes: 1 addition & 1 deletion src/gas/determineGasFeeSuggestions.ts
Expand Up @@ -3,7 +3,7 @@ import {
EstimatedGasFeeTimeBounds,
EthGasPriceEstimate,
GasFeeEstimates,
GasFeeState as GasFeeSuggestions,
GasFeeSuggestions,
LegacyGasPriceEstimate,
} from './GasFeeController';

Expand Down

0 comments on commit a741072

Please sign in to comment.