Skip to content

Commit

Permalink
Use eth_feeHistory as a fallback for gas estimates
Browse files Browse the repository at this point in the history
If we are on an EIP-1559-supported network and the Metaswap API fails
for some reason, fall back to using `eth_feeHistory` to calculate gas
estimates (which the API uses anyway). This code is more or less taken
from the code for the API ([1]).

[1]: https://gitlab.com/ConsenSys/codefi/products/metaswap/gas-api/-/blob/eae6927b1a0c445e02cb3cba9e9e6b0f35857a12/src/eip1559/feeHistory.ts
  • Loading branch information
mcmire committed Oct 28, 2021
1 parent ecac47e commit 883b2fe
Show file tree
Hide file tree
Showing 10 changed files with 722 additions and 59 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ module.exports = {
'no-param-reassign': 'off',
radix: 'off',
'require-atomic-updates': 'off',
'jsdoc/match-description': [
'error',
{ matchDescription: '^([A-Z]|[`\\d_])[\\s\\S]*[.?!`>]$' },
],
},
settings: {
'import/resolver': {
Expand Down
2 changes: 2 additions & 0 deletions src/gas/GasFeeController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
calculateTimeEstimate,
} from './gas-util';
import determineGasFeeSuggestions from './determineGasFeeSuggestions';
import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory';

const GAS_FEE_API = 'https://mock-gas-server.herokuapp.com/';
export const LEGACY_GAS_PRICES_API_URL = `https://api.metaswap.codefi.network/gasPrices`;
Expand Down Expand Up @@ -376,6 +377,7 @@ export class GasFeeController extends BaseController<
'<chain_id>',
`${chainId}`,
),
fetchGasEstimatesViaEthFeeHistory,
fetchLegacyGasPriceEstimates,
fetchLegacyGasPriceEstimatesUrl: this.legacyAPIEndpoint.replace(
'<chain_id>',
Expand Down
110 changes: 110 additions & 0 deletions src/gas/determineGasFeeSuggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
fetchEthGasPriceEstimate,
calculateTimeEstimate,
} from './gas-util';
import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory';

jest.mock('./gas-util');
jest.mock('./fetchGasEstimatesViaEthFeeHistory');

const mockedFetchGasEstimates = mocked(fetchGasEstimates, true);
const mockedFetchLegacyGasPriceEstimates = mocked(
Expand All @@ -23,6 +25,10 @@ const mockedFetchLegacyGasPriceEstimates = mocked(
);
const mockedFetchEthGasPriceEstimate = mocked(fetchEthGasPriceEstimate, true);
const mockedCalculateTimeEstimate = mocked(calculateTimeEstimate, true);
const mockedFetchGasEstimatesViaEthFeeHistory = mocked(
fetchGasEstimatesViaEthFeeHistory,
true,
);

/**
* Builds mock data for the `fetchGasEstimates` function. All of the data here is filled in to make
Expand Down Expand Up @@ -102,6 +108,7 @@ describe('determineGasFeeSuggestions', () => {
isEIP1559Compatible: false,
isLegacyGasAPICompatible: false,
fetchGasEstimates: mockedFetchGasEstimates,
fetchGasEstimatesViaEthFeeHistory: mockedFetchGasEstimatesViaEthFeeHistory,
fetchGasEstimatesUrl: 'http://doesnt-matter',
fetchLegacyGasPriceEstimates: mockedFetchLegacyGasPriceEstimates,
fetchLegacyGasPriceEstimatesUrl: 'http://doesnt-matter',
Expand Down Expand Up @@ -144,6 +151,109 @@ describe('determineGasFeeSuggestions', () => {
});
});

describe('assuming neither fetchGasEstimatesViaEthFeeHistory nor calculateTimeEstimate throws errors', () => {
it('returns a combination of the fetched fee and time estimates', async () => {
const gasFeeEstimates = buildMockDataForFetchGasEstimates();
mockedFetchGasEstimatesViaEthFeeHistory.mockResolvedValue(
gasFeeEstimates,
);
const estimatedGasFeeTimeBounds = buildMockDataForCalculateTimeEstimate();
mockedCalculateTimeEstimate.mockReturnValue(
estimatedGasFeeTimeBounds,
);

const gasFeeSuggestions = await determineGasFeeSuggestions(options);

expect(gasFeeSuggestions).toStrictEqual({
gasFeeEstimates,
estimatedGasFeeTimeBounds,
gasEstimateType: 'fee-market',
});
});
});

describe('when fetchGasEstimatesViaEthFeeHistory throws an error', () => {
beforeEach(() => {
mockedFetchGasEstimatesViaEthFeeHistory.mockImplementation(() => {
throw new Error('Some API failure');
});
});

describe('assuming fetchEthGasPriceEstimate does not throw an error', () => {
it('returns the fetched fee estimates and an empty set of time estimates', async () => {
const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate();
mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates);

const gasFeeSuggestions = await determineGasFeeSuggestions(options);

expect(gasFeeSuggestions).toStrictEqual({
gasFeeEstimates,
estimatedGasFeeTimeBounds: {},
gasEstimateType: 'eth_gasPrice',
});
});
});

describe('when fetchEthGasPriceEstimate throws an error', () => {
it('throws an error that wraps that error', async () => {
mockedFetchEthGasPriceEstimate.mockImplementation(() => {
throw new Error('fetchEthGasPriceEstimate failed');
});

const promise = determineGasFeeSuggestions(options);

await expect(promise).rejects.toThrow(
'Gas fee/price estimation failed. Message: fetchEthGasPriceEstimate failed',
);
});
});
});

describe('when fetchGasEstimatesViaEthFeeHistory does not throw an error, but calculateTimeEstimate throws an error', () => {
beforeEach(() => {
mockedCalculateTimeEstimate.mockImplementation(() => {
throw new Error('Some API failure');
});
});

describe('assuming fetchEthGasPriceEstimate does not throw an error', () => {
it('returns the fetched fee estimates and an empty set of time estimates', async () => {
const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate();
mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates);

const gasFeeSuggestions = await determineGasFeeSuggestions(options);

expect(gasFeeSuggestions).toStrictEqual({
gasFeeEstimates,
estimatedGasFeeTimeBounds: {},
gasEstimateType: 'eth_gasPrice',
});
});
});

describe('when fetchEthGasPriceEstimate throws an error', () => {
it('throws an error that wraps that error', async () => {
mockedFetchEthGasPriceEstimate.mockImplementation(() => {
throw new Error('fetchEthGasPriceEstimate failed');
});

const promise = determineGasFeeSuggestions(options);

await expect(promise).rejects.toThrow(
'Gas fee/price estimation failed. Message: fetchEthGasPriceEstimate failed',
);
});
});
});
});

describe('when fetchGasEstimates does not throw an error, but calculateTimeEstimate throws an error', () => {
beforeEach(() => {
mockedCalculateTimeEstimate.mockImplementation(() => {
throw new Error('Some API failure');
});
});

describe('assuming fetchEthGasPriceEstimate does not throw an error', () => {
it('returns the fetched fee estimates and an empty set of time estimates', async () => {
const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate();
Expand Down
13 changes: 12 additions & 1 deletion src/gas/determineGasFeeSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
* API.
* @param args.fetchGasEstimatesUrl - The URL for the API we can use to obtain EIP-1559-specific
* estimates.
* @param args.fetchGasEstimatesViaEthFeeHistory - A function that fetches gas estimates using
* `eth_feeHistory` (an EIP-1559 feature).
* @param args.fetchLegacyGasPriceEstimates - A function that fetches gas estimates using an
* non-EIP-1559-specific API.
* @param args.fetchLegacyGasPriceEstimatesUrl - The URL for the API we can use to obtain
Expand All @@ -36,6 +38,7 @@ export default async function determineGasFeeSuggestions({
isLegacyGasAPICompatible,
fetchGasEstimates,
fetchGasEstimatesUrl,
fetchGasEstimatesViaEthFeeHistory,
fetchLegacyGasPriceEstimates,
fetchLegacyGasPriceEstimatesUrl,
fetchEthGasPriceEstimate,
Expand All @@ -50,6 +53,9 @@ export default async function determineGasFeeSuggestions({
clientId?: string,
) => Promise<GasFeeEstimates>;
fetchGasEstimatesUrl: string;
fetchGasEstimatesViaEthFeeHistory: (
ethQuery: any,
) => Promise<GasFeeEstimates>;
fetchLegacyGasPriceEstimates: (
url: string,
clientId?: string,
Expand All @@ -66,7 +72,12 @@ export default async function determineGasFeeSuggestions({
}): Promise<GasFeeSuggestions> {
try {
if (isEIP1559Compatible) {
const estimates = await fetchGasEstimates(fetchGasEstimatesUrl, clientId);
let estimates: GasFeeEstimates;
try {
estimates = await fetchGasEstimates(fetchGasEstimatesUrl, clientId);
} catch {
estimates = await fetchGasEstimatesViaEthFeeHistory(ethQuery);
}
const {
suggestedMaxPriorityFeePerGas,
suggestedMaxFeePerGas,
Expand Down
135 changes: 135 additions & 0 deletions src/gas/fetchFeeHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import fetchFeeHistory, { EthFeeHistoryResponse } from './fetchFeeHistory';

describe('fetchFeeHistory', () => {
/**
* Builds an EthQuery double that returns a mock response for `eth_feeHistory`.
*
* @param ethFeeHistoryResponse - The response for `eth_feeHistory`
* @returns The EthQuery double.
*/
function buildEthQuery(ethFeeHistoryResponse: EthFeeHistoryResponse) {
return {
eth_feeHistory: (...args: any[]) => {
const cb = args.pop();
cb(null, ethFeeHistoryResponse);
},
};
}

it('should return a representation of fee history from the Ethereum network, organized by block rather than type of data', async () => {
// To reproduce:
//
// curl -X POST --data '{
// "id": 1,
// "jsonrpc": "2.0",
// "method": "eth_feeHistory",
// "params": ["0x5", "latest", [10, 20, 30]]
// }' https://mainnet.infura.io/v3/<PROJECT_ID>
const ethQuery = buildEthQuery({
oldestBlock: '0xcb1939',
// Note that this array contains 6 items when we requested 5. Per
// <https://github.com/ethereum/go-ethereum/blob/57a3fab8a75eeb9c2f4fab770b73b51b9fe672c5/eth/gasprice/feehistory.go#L191-L192>,
// baseFeePerGas will always include an extra item which is the calculated base fee for the
// next (future) block.
baseFeePerGas: [
'0x16eb46a3bb',
'0x14cd6f0628',
'0x1763700ef2',
'0x1477020d14',
'0x129c9eb46b',
'0x134002f480',
],
gasUsedRatio: [
0.13060046666666666,
0.9972395333333334,
0,
0.13780313333333333,
0.6371707333333333,
],
reward: [
['0x59682f00', '0x59682f00', '0x59682f00'],
['0x540ae480', '0x59682f00', '0x59682f00'],
['0x0', '0x0', '0x0'],
['0x3b9aca00', '0x3b9aca00', '0x3b9aca00'],
['0x59682f00', '0x59682f00', '0x59682f00'],
],
});

const feeHistory = await fetchFeeHistory({
ethQuery,
numberOfBlocks: 5,
percentiles: [10, 20, 30],
});

expect(feeHistory).toStrictEqual({
startBlockId: '0xcb1939',
blocks: [
{
baseFeePerGas: 98436555707,
gasUsedRatio: 0.13060046666666666,
priorityFeesByPercentile: {
10: 1500000000,
20: 1500000000,
30: 1500000000,
},
},
{
baseFeePerGas: 89345951272,
gasUsedRatio: 0.9972395333333334,
priorityFeesByPercentile: {
10: 1410000000,
20: 1500000000,
30: 1500000000,
},
},
{
baseFeePerGas: 100452536050,
gasUsedRatio: 0,
priorityFeesByPercentile: {
10: 0,
20: 0,
30: 0,
},
},
{
baseFeePerGas: 87895969044,
gasUsedRatio: 0.13780313333333333,
priorityFeesByPercentile: {
10: 1000000000,
20: 1000000000,
30: 1000000000,
},
},
{
baseFeePerGas: 79937057899,
gasUsedRatio: 0.6371707333333333,
priorityFeesByPercentile: {
10: 1500000000,
20: 1500000000,
30: 1500000000,
},
},
],
});
});

it('should handle an "empty" response from eth_feeHistory', async () => {
const ethQuery = buildEthQuery({
oldestBlock: '0x0',
baseFeePerGas: [],
gasUsedRatio: [],
reward: [],
});

const feeHistory = await fetchFeeHistory({
ethQuery,
numberOfBlocks: 5,
percentiles: [10, 20, 30],
});

expect(feeHistory).toStrictEqual({
startBlockId: '0x0',
blocks: [],
});
});
});
Loading

0 comments on commit 883b2fe

Please sign in to comment.