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 11, 2021
1 parent a56e38a commit cbcf79f
Show file tree
Hide file tree
Showing 10 changed files with 692 additions and 73 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
96 changes: 82 additions & 14 deletions src/gas/determineGasFeeSuggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function buildMockDataForCalculateTimeEstimate(): EstimatedGasFeeTimeBounds {
describe('determineGasFeeSuggestions', () => {
let options: any;
let fetchGasEstimates: jest.SpyInstance<any>;
let fetchGasEstimatesViaEthFeeHistory: jest.SpyInstance<any>;
let calculateTimeEstimate: jest.SpyInstance<any>;
let fetchLegacyGasPriceEstimates: jest.SpyInstance<any>;
let fetchEthGasPriceEstimate: jest.SpyInstance<any>;
Expand All @@ -92,6 +93,10 @@ describe('determineGasFeeSuggestions', () => {
.fn()
.mockResolvedValue(buildMockDataForFetchGasEstimates());

fetchGasEstimatesViaEthFeeHistory = jest
.fn()
.mockResolvedValue(buildMockDataForFetchGasEstimates());

calculateTimeEstimate = jest
.fn()
.mockReturnValue(buildMockDataForCalculateTimeEstimate());
Expand All @@ -112,6 +117,7 @@ describe('determineGasFeeSuggestions', () => {
isLegacyGasAPICompatible: false,
fetchGasEstimates,
fetchGasEstimatesUrl: 'http://some-fetch-gas-estimates-url',
fetchGasEstimatesViaEthFeeHistory,
fetchLegacyGasPriceEstimates,
fetchLegacyGasPriceEstimatesUrl: 'http://doesnt-matter',
fetchEthGasPriceEstimate,
Expand Down Expand Up @@ -145,32 +151,94 @@ describe('determineGasFeeSuggestions', () => {
});
});

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();
fetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates);
describe('assuming neither fetchGasEstimatesViaEthFeeHistory nor calculateTimeEstimate throws errors', () => {
it('returns a combination of the fetched fee and time estimates', async () => {
const gasFeeEstimates = buildMockDataForFetchGasEstimates();
fetchGasEstimatesViaEthFeeHistory.mockResolvedValue(gasFeeEstimates);
const estimatedGasFeeTimeBounds = buildMockDataForCalculateTimeEstimate();
calculateTimeEstimate.mockReturnValue(estimatedGasFeeTimeBounds);

const gasFeeSuggestions = await determineGasFeeSuggestions(options);

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

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

const promise = determineGasFeeSuggestions(options);
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();
fetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates);

await expect(promise).rejects.toThrow(
'Could not generate gas fee suggestions: fetchEthGasPriceEstimate failed',
);
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 () => {
fetchEthGasPriceEstimate.mockImplementation(() => {
throw new Error('fetchEthGasPriceEstimate failed');
});

const promise = determineGasFeeSuggestions(options);

await expect(promise).rejects.toThrow(
'Could not generate gas fee suggestions: fetchEthGasPriceEstimate failed',
);
});
});
});

describe('when fetchGasEstimatesViaEthFeeHistory does not throw an error, but calculateTimeEstimate throws an error', () => {
beforeEach(() => {
calculateTimeEstimate.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();
fetchEthGasPriceEstimate.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 () => {
fetchEthGasPriceEstimate.mockImplementation(() => {
throw new Error('fetchEthGasPriceEstimate failed');
});

const promise = determineGasFeeSuggestions(options);

await expect(promise).rejects.toThrow(
'Could not generate gas fee suggestions: fetchEthGasPriceEstimate failed',
);
});
});
});
});
Expand Down
13 changes: 12 additions & 1 deletion src/gas/determineGasFeeSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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 @@ -37,6 +39,7 @@ export default async function determineGasFeeSuggestions({
isLegacyGasAPICompatible,
fetchGasEstimates,
fetchGasEstimatesUrl,
fetchGasEstimatesViaEthFeeHistory,
fetchLegacyGasPriceEstimates,
fetchLegacyGasPriceEstimatesUrl,
fetchEthGasPriceEstimate,
Expand All @@ -51,6 +54,9 @@ export default async function determineGasFeeSuggestions({
clientId?: string,
) => Promise<GasFeeEstimates>;
fetchGasEstimatesUrl: string;
fetchGasEstimatesViaEthFeeHistory: (
ethQuery: any,
) => Promise<GasFeeEstimates>;
fetchLegacyGasPriceEstimates: (
url: string,
clientId?: string,
Expand All @@ -69,7 +75,12 @@ export default async function determineGasFeeSuggestions({

if (isEIP1559Compatible) {
strategies.push(async () => {
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: [],
});
});
});

0 comments on commit cbcf79f

Please sign in to comment.