diff --git a/package.json b/package.json index 4f501a195e..615254a794 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", "@types/jest": "^26.0.22", + "@types/jest-when": "^2.7.3", "@types/node": "^14.14.31", "@types/punycode": "^2.1.0", "@types/sinon": "^9.0.10", @@ -93,6 +94,7 @@ "ethjs-provider-http": "^0.1.6", "jest": "^26.4.2", "jest-environment-jsdom": "^25.0.0", + "jest-when": "^3.4.2", "nock": "^13.0.7", "prettier": "^2.2.1", "prettier-plugin-packagejson": "^2.2.11", diff --git a/src/gas/GasFeeController.ts b/src/gas/GasFeeController.ts index 8fe9c02119..b3aaab9b8f 100644 --- a/src/gas/GasFeeController.ts +++ b/src/gas/GasFeeController.ts @@ -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`; @@ -376,6 +377,7 @@ export class GasFeeController extends BaseController< '', `${chainId}`, ), + fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: this.legacyAPIEndpoint.replace( '', diff --git a/src/gas/determineGasFeeSuggestions.test.ts b/src/gas/determineGasFeeSuggestions.test.ts index fe2c3968eb..a64db90338 100644 --- a/src/gas/determineGasFeeSuggestions.test.ts +++ b/src/gas/determineGasFeeSuggestions.test.ts @@ -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( @@ -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 @@ -102,6 +108,7 @@ describe('determineGasFeeSuggestions', () => { isEIP1559Compatible: false, isLegacyGasAPICompatible: false, fetchGasEstimates: mockedFetchGasEstimates, + fetchGasEstimatesViaEthFeeHistory: mockedFetchGasEstimatesViaEthFeeHistory, fetchGasEstimatesUrl: 'http://doesnt-matter', fetchLegacyGasPriceEstimates: mockedFetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: 'http://doesnt-matter', @@ -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(); diff --git a/src/gas/determineGasFeeSuggestions.ts b/src/gas/determineGasFeeSuggestions.ts index ebb7dc555a..d45b9cdce8 100644 --- a/src/gas/determineGasFeeSuggestions.ts +++ b/src/gas/determineGasFeeSuggestions.ts @@ -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 @@ -36,6 +38,7 @@ export default async function determineGasFeeSuggestions({ isLegacyGasAPICompatible, fetchGasEstimates, fetchGasEstimatesUrl, + fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl, fetchEthGasPriceEstimate, @@ -50,6 +53,9 @@ export default async function determineGasFeeSuggestions({ clientId?: string, ) => Promise; fetchGasEstimatesUrl: string; + fetchGasEstimatesViaEthFeeHistory: ( + ethQuery: any, + ) => Promise; fetchLegacyGasPriceEstimates: ( url: string, clientId?: string, @@ -66,7 +72,12 @@ export default async function determineGasFeeSuggestions({ }): Promise { 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, diff --git a/src/gas/fetchBlockFeeHistory.test.ts b/src/gas/fetchBlockFeeHistory.test.ts new file mode 100644 index 0000000000..6d67fb35e6 --- /dev/null +++ b/src/gas/fetchBlockFeeHistory.test.ts @@ -0,0 +1,323 @@ +import { BN } from 'ethereumjs-util'; +import { mocked } from 'ts-jest/utils'; +import { when, WhenMock } from 'jest-when'; +import { query, fromHex, toHex } from '../util'; +import fetchBlockFeeHistory from './fetchBlockFeeHistory'; + +jest.mock('../util', () => { + return { + ...jest.requireActual('../util'), + __esModule: true, + query: jest.fn(), + }; +}); + +const mockedQuery = mocked(query, true); + +/** + * Calls the given function the given number of times, collecting the results from each call. + * + * @param n - The number of times you want to call the function. + * @param fn - The function to call. + * @returns An array of values gleaned from the results of each call to the function. + */ +function times(n: number, fn: (n: number) => T): T[] { + const values = []; + for (let i = 0; i < n; i++) { + values.push(fn(i)); + } + return values; +} + +describe('fetchBlockFeeHistory', () => { + let queryMock: WhenMock; + const ethQuery = { eth: 'query' }; + + beforeEach(() => { + queryMock = when(mockedQuery).mockImplementation((...args: any[]) => { + return Promise.reject( + new Error( + 'None of the calls you mocked for this function matched the actual calls. ' + + `Given args: ${JSON.stringify(args)}`, + ), + ); + }); + }); + + describe('with a minimal set of arguments', () => { + const latestBlockNumber = 3; + const numberOfRequestedBlocks = 3; + + beforeEach(() => { + queryMock + .calledWith(ethQuery, 'blockNumber') + .mockResolvedValue(new BN(latestBlockNumber)); + }); + + it('should return a representation of fee history from the Ethereum network, organized by block rather than type of data', async () => { + queryMock + .calledWith(ethQuery, 'eth_feeHistory', [ + toHex(numberOfRequestedBlocks), + toHex(latestBlockNumber), + [], + ]) + .mockResolvedValue({ + oldestBlock: toHex(1), + // Note that this array contains 6 items when we requested 5. Per + // , + // baseFeePerGas will always include an extra item which is the calculated base fee for the + // next (future) block. + baseFeePerGas: [ + toHex(10_000_000_000), + toHex(20_000_000_000), + toHex(30_000_000_000), + toHex(40_000_000_000), + ], + gasUsedRatio: [0.1, 0.2, 0.3], + }); + + const feeHistory = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: numberOfRequestedBlocks, + }); + + expect(feeHistory).toStrictEqual([ + { + number: new BN(1), + baseFeePerGas: new BN(10_000_000_000), + gasUsedRatio: 0.1, + priorityFeesByPercentile: {}, + }, + { + number: new BN(2), + baseFeePerGas: new BN(20_000_000_000), + gasUsedRatio: 0.2, + priorityFeesByPercentile: {}, + }, + { + number: new BN(3), + baseFeePerGas: new BN(30_000_000_000), + gasUsedRatio: 0.3, + priorityFeesByPercentile: {}, + }, + ]); + }); + + it('should be able to handle an "empty" response from eth_feeHistory', async () => { + queryMock + .calledWith(ethQuery, 'eth_feeHistory', [ + toHex(numberOfRequestedBlocks), + toHex(latestBlockNumber), + [], + ]) + .mockResolvedValue({ + oldestBlock: toHex(0), + baseFeePerGas: [], + gasUsedRatio: [], + }); + + const feeHistory = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: numberOfRequestedBlocks, + }); + + expect(feeHistory).toStrictEqual([]); + }); + }); + + describe('given a numberOfBlocks that exceeds the max limit that the EVM returns', () => { + it('divides the number into chunks and calls eth_feeHistory for each chunk', async () => { + const latestBlockNumber = 2348; + const numberOfRequestedBlocks = 2348; + const expectedChunks = [ + { startBlockNumber: 1, endBlockNumber: 1024 }, + { startBlockNumber: 1025, endBlockNumber: 2048 }, + { startBlockNumber: 2049, endBlockNumber: 2348 }, + ]; + const expectedBlocks = times(numberOfRequestedBlocks, (i) => { + return { + number: i + 1, + baseFeePerGas: toHex(1_000_000_000 * (i + 1)), + gasUsedRatio: (i + 1) / numberOfRequestedBlocks, + }; + }); + + queryMock + .calledWith(ethQuery, 'blockNumber') + .mockResolvedValue(new BN(latestBlockNumber)); + + expectedChunks.forEach(({ startBlockNumber, endBlockNumber }) => { + const baseFeePerGas = expectedBlocks + .slice(startBlockNumber - 1, endBlockNumber + 1) + .map((block) => block.baseFeePerGas); + const gasUsedRatio = expectedBlocks + .slice(startBlockNumber - 1, endBlockNumber) + .map((block) => block.gasUsedRatio); + + queryMock + .calledWith(ethQuery, 'eth_feeHistory', [ + toHex(endBlockNumber - startBlockNumber + 1), + toHex(endBlockNumber), + [], + ]) + .mockResolvedValue({ + oldestBlock: toHex(startBlockNumber), + baseFeePerGas, + gasUsedRatio, + }); + }); + + const feeHistory = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: numberOfRequestedBlocks, + }); + + expect(feeHistory).toStrictEqual( + expectedBlocks.map((block) => { + return { + number: new BN(block.number), + baseFeePerGas: fromHex(block.baseFeePerGas), + gasUsedRatio: block.gasUsedRatio, + priorityFeesByPercentile: {}, + }; + }), + ); + }); + }); + + describe('given an endBlock of a BN', () => { + it('should pass it to the eth_feeHistory call', async () => { + const latestBlockNumber = 3; + const numberOfRequestedBlocks = 3; + const endBlock = new BN(latestBlockNumber); + when(mockedQuery) + .calledWith(ethQuery, 'eth_feeHistory', [ + toHex(numberOfRequestedBlocks), + toHex(endBlock), + [], + ]) + .mockResolvedValue({ + oldestBlock: toHex(0), + baseFeePerGas: [], + gasUsedRatio: [], + }); + + const feeHistory = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: numberOfRequestedBlocks, + endBlock, + }); + + expect(feeHistory).toStrictEqual([]); + }); + }); + + describe('given percentiles', () => { + const latestBlockNumber = 3; + const numberOfRequestedBlocks = 3; + + beforeEach(() => { + queryMock + .calledWith(ethQuery, 'blockNumber') + .mockResolvedValue(new BN(latestBlockNumber)); + }); + + it('should match each item in the "reward" key from the response to its percentile', async () => { + when(mockedQuery) + .calledWith(ethQuery, 'eth_feeHistory', [ + toHex(numberOfRequestedBlocks), + toHex(latestBlockNumber), + [10, 20, 30], + ]) + .mockResolvedValue({ + oldestBlock: toHex(1), + // Note that this array contains 6 items when we requested 5. Per + // , + // baseFeePerGas will always include an extra item which is the calculated base fee for the + // next (future) block. + baseFeePerGas: [ + toHex(100_000_000_000), + toHex(200_000_000_000), + toHex(300_000_000_000), + toHex(400_000_000_000), + ], + gasUsedRatio: [0.1, 0.2, 0.3], + reward: [ + [ + toHex(10_000_000_000), + toHex(15_000_000_000), + toHex(20_000_000_000), + ], + [toHex(0), toHex(10_000_000_000), toHex(15_000_000_000)], + [ + toHex(20_000_000_000), + toHex(20_000_000_000), + toHex(30_000_000_000), + ], + ], + }); + + const feeHistory = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: numberOfRequestedBlocks, + percentiles: [10, 20, 30], + }); + + expect(feeHistory).toStrictEqual([ + { + number: new BN(1), + baseFeePerGas: new BN(100_000_000_000), + gasUsedRatio: 0.1, + priorityFeesByPercentile: { + 10: new BN(10_000_000_000), + 20: new BN(15_000_000_000), + 30: new BN(20_000_000_000), + }, + }, + { + number: new BN(2), + baseFeePerGas: new BN(200_000_000_000), + gasUsedRatio: 0.2, + priorityFeesByPercentile: { + 10: new BN(0), + 20: new BN(10_000_000_000), + 30: new BN(15_000_000_000), + }, + }, + { + number: new BN(3), + baseFeePerGas: new BN(300_000_000_000), + gasUsedRatio: 0.3, + priorityFeesByPercentile: { + 10: new BN(20_000_000_000), + 20: new BN(20_000_000_000), + 30: new BN(30_000_000_000), + }, + }, + ]); + }); + + it('should be able to handle an "empty" response from eth_feeHistory including an empty "reward" array', async () => { + queryMock + .calledWith(ethQuery, 'eth_feeHistory', [ + toHex(numberOfRequestedBlocks), + toHex(latestBlockNumber), + [10, 20, 30], + ]) + .mockResolvedValue({ + oldestBlock: toHex(0), + baseFeePerGas: [], + gasUsedRatio: [], + reward: [], + }); + + const feeHistory = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: numberOfRequestedBlocks, + percentiles: [10, 20, 30], + }); + + expect(feeHistory).toStrictEqual([]); + }); + }); +}); diff --git a/src/gas/fetchBlockFeeHistory.ts b/src/gas/fetchBlockFeeHistory.ts new file mode 100644 index 0000000000..c2c43baa99 --- /dev/null +++ b/src/gas/fetchBlockFeeHistory.ts @@ -0,0 +1,235 @@ +import { BN } from 'ethereumjs-util'; +import { query, fromHex, toHex } from '../util'; + +type EthQuery = any; + +/** + * @type RequestChunkSpecifier + * Arguments to `eth_feeHistory` that can be used to fetch a set of historical data. + * @param blockCount - The number of blocks requested. + * @param endBlockNumber - The number of the block at the end of the requested range. + */ +type RequestChunkSpecifier = { + numberOfBlocks: number; + endBlockNumber: BN; +}; + +/** + * @type EthFeeHistoryResponse + * + * Response data for `eth_feeHistory`. + * @property oldestBlock - The id of the oldest block (in hex format) in the range of blocks + * requested. + * @property baseFeePerGas - Base fee per gas for each block in the range of blocks requested. + * @property gasUsedRatio - A number between 0 and 1 that represents the gas used vs. gas limit for + * each block in the range of blocks requested. + * @property reward - The priority fee at the percentiles requested for each block in the range of + * blocks requested. + */ + +export type EthFeeHistoryResponse = { + oldestBlock: string; + baseFeePerGas: string[]; + gasUsedRatio: number[]; + reward?: string[][]; +}; + +/** + * @type Block + * + * Historical data for a particular block. + * @property number - The number of the block, as a BN. + * @property baseFeePerGas - The base fee per gas for the block in WEI, as a BN. + * @property gasUsedRatio - A number between 0 and 1 that represents the ratio between the gas paid + * for the block and its set gas limit. + * @property priorityFeesByPercentile - The priority fees paid for the transactions in the block + * that occurred at particular levels at which those transactions contributed to the overall gas + * used for the block, indexed by those percentiles. (See docs for {@link fetchBlockFeeHistory} for more + * on how this works.) + */ +type Block = { + number: BN; + baseFeePerGas: BN; + gasUsedRatio: number; + priorityFeesByPercentile: Record; +}; + +const MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL = 1024; + +/** + * Uses `eth_feeHistory` (an EIP-1559 feature) to obtain information about gas fees from a range of + * blocks that have occurred recently on a network. + * + * To learn more, see these resources: + * + * - + * - + * - + * - + * - + * + * @param args - The arguments to this function. + * @param args.ethQuery - An EthQuery instance that wraps a provider for the network in question. + * @param args.endBlock - The desired end of the requested block range. Can be "latest" if you want + * to start from the latest successful block or the number of a known past block. + * @param args.numberOfBlocks - How many total blocks to fetch. + * @param args.percentiles - A set of numbers between 1 and 100 which will dictate how + * `priorityFeesByPercentile` in each returned block will be formed. When Ethereum runs the + * `eth_feeHistory` method, for each block it is considering, it will first sort all transactions by + * the priority fee. It will then go through each transaction and add the total amount of gas paid + * for that transaction to a bucket which maxes out at the total gas used for the whole block. As + * the bucket fills, it will cross percentages which correspond to the percentiles specified here, + * and the priority fees of the first transactions which cause it to reach those percentages will be + * recorded. Hence, `priorityFeesByPercentile` represents the priority fees of transactions at key + * gas used contribution levels, where earlier levels have smaller contributions and later levels + * have higher contributions. + * @returns The list of blocks and their fee data, sorted from oldest to newest. + */ +export default async function fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: totalNumberOfBlocks, + endBlock: givenEndBlock = 'latest', + percentiles: givenPercentiles = [], +}: { + ethQuery: EthQuery; + numberOfBlocks: number; + endBlock?: 'latest' | BN; + percentiles?: readonly Percentile[]; +}): Promise[]> { + const percentiles = + givenPercentiles.length > 0 + ? Array.from(new Set(givenPercentiles)).sort((a, b) => a - b) + : []; + + const finalEndBlockNumber = + givenEndBlock === 'latest' + ? await query(ethQuery, 'blockNumber') + : givenEndBlock; + + const requestChunkSpecifiers = determineRequestChunkSpecifiers( + finalEndBlockNumber, + totalNumberOfBlocks, + ); + + const historicalBlockChunks = await Promise.all( + requestChunkSpecifiers.map(({ numberOfBlocks, endBlockNumber }) => { + return makeRequestForChunk({ + ethQuery, + numberOfBlocks, + endBlockNumber, + percentiles, + }); + }), + ); + + return historicalBlockChunks.reduce( + (array, historicalBlocks) => [...array, ...historicalBlocks], + [] as Block[], + ); +} + +/** + * Uses eth_feeHistory to request historical data about a group of blocks (max size 1024). + * + * @param args - The arguments + * @param args.ethQuery - An EthQuery instance. + * @param args.numberOfBlocks - The number of blocks in the chunk. Must be at most 1024, as this is + * the maximum that `eth_feeHistory` can return in one call. + * @param args.endBlockNumber - The end of the requested block range. + * @param args.percentiles - A set of numbers betwen 1 and 100 that will be used to pull priority + * fees for each block. + * @returns A list of block data. + */ +async function makeRequestForChunk({ + ethQuery, + numberOfBlocks, + endBlockNumber, + percentiles, +}: { + ethQuery: EthQuery; + numberOfBlocks: number; + endBlockNumber: BN; + percentiles: readonly Percentile[]; +}): Promise[]> { + const response: EthFeeHistoryResponse = await query( + ethQuery, + 'eth_feeHistory', + [toHex(numberOfBlocks), toHex(endBlockNumber), percentiles], + ); + + const startBlockNumber = fromHex(response.oldestBlock); + + if ( + response.baseFeePerGas.length > 0 && + response.gasUsedRatio.length > 0 && + (response.reward === undefined || response.reward.length > 0) + ) { + // Per + // , + // baseFeePerGas will always include an extra item which is the calculated base fee for the + // next (future) block. We don't care about this, so chop it off. + const baseFeesPerGasAsHex = response.baseFeePerGas.slice(0, numberOfBlocks); + const gasUsedRatios = response.gasUsedRatio; + const priorityFeePercentileGroups = response.reward ?? []; + + return baseFeesPerGasAsHex.map((baseFeePerGasAsHex, blockIndex) => { + const baseFeePerGas = fromHex(baseFeePerGasAsHex); + const gasUsedRatio = gasUsedRatios[blockIndex]; + const number = startBlockNumber.addn(blockIndex); + + const priorityFeesForEachPercentile = + priorityFeePercentileGroups[blockIndex]; + + const priorityFeesByPercentile = percentiles.reduce( + (obj, percentile, percentileIndex) => { + const priorityFee = priorityFeesForEachPercentile[percentileIndex]; + return { ...obj, [percentile]: fromHex(priorityFee) }; + }, + {} as Record, + ); + + return { + number, + baseFeePerGas, + gasUsedRatio, + priorityFeesByPercentile, + }; + }); + } + + return []; +} + +/** + * Divides a block range (specified by a range size and the end of the range) into chunks based on + * the maximum number of blocks that `eth_feeHistory` can return in a single call. + * + * @param endBlockNumber - The final block in the complete desired block range after all + * `eth_feeHistory` requests have been made. + * @param totalNumberOfBlocks - The total number of desired blocks after all `eth_feeHistory` + * requests have been made. + * @returns A set of arguments that can be used to make requests to `eth_feeHistory` in order to + * retrieve all of the requested blocks, sorted from oldest block to newest block. + */ +function determineRequestChunkSpecifiers( + endBlockNumber: BN, + totalNumberOfBlocks: number, +): RequestChunkSpecifier[] { + const specifiers = []; + for ( + let chunkStartBlockNumber = endBlockNumber.subn(totalNumberOfBlocks); + chunkStartBlockNumber.lt(endBlockNumber); + chunkStartBlockNumber = chunkStartBlockNumber.addn( + MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL, + ) + ) { + const distanceToEnd = endBlockNumber.sub(chunkStartBlockNumber).toNumber(); + const numberOfBlocks = + distanceToEnd < MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL + ? distanceToEnd + : MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL; + const chunkEndBlockNumber = chunkStartBlockNumber.addn(numberOfBlocks); + specifiers.push({ numberOfBlocks, endBlockNumber: chunkEndBlockNumber }); + } + return specifiers; +} diff --git a/src/gas/fetchGasEstimatesViaEthFeeHistory.test.ts b/src/gas/fetchGasEstimatesViaEthFeeHistory.test.ts new file mode 100644 index 0000000000..ad63d37c75 --- /dev/null +++ b/src/gas/fetchGasEstimatesViaEthFeeHistory.test.ts @@ -0,0 +1,70 @@ +import { BN } from 'ethereumjs-util'; +import { mocked } from 'ts-jest/utils'; +import fetchBlockFeeHistory from './fetchBlockFeeHistory'; +import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory'; + +jest.mock('./fetchBlockFeeHistory'); + +const mockedFetchFeeHistory = mocked(fetchBlockFeeHistory, true); + +describe('fetchGasEstimatesViaEthFeeHistory', () => { + it('calculates target fees for low, medium, and high transaction priority levels', async () => { + const ethQuery = {}; + mockedFetchFeeHistory.mockResolvedValue([ + { + number: new BN(1), + baseFeePerGas: new BN(0), + gasUsedRatio: 1, + priorityFeesByPercentile: { + 10: new BN(0), + 20: new BN(1_000_000_000), + 30: new BN(0), + }, + }, + { + number: new BN(2), + baseFeePerGas: new BN(0), + gasUsedRatio: 1, + priorityFeesByPercentile: { + 10: new BN(500_000_000), + 20: new BN(1_600_000_000), + 30: new BN(3_000_000_000), + }, + }, + { + number: new BN(3), + baseFeePerGas: new BN(100_000_000_000), + gasUsedRatio: 1, + priorityFeesByPercentile: { + 10: new BN(500_000_000), + 20: new BN(2_000_000_000), + 30: new BN(3_000_000_000), + }, + }, + ]); + + const gasFeeEstimates = await fetchGasEstimatesViaEthFeeHistory(ethQuery); + + expect(gasFeeEstimates).toStrictEqual({ + low: { + minWaitTimeEstimate: 15_000, + maxWaitTimeEstimate: 30_000, + suggestedMaxPriorityFeePerGas: '1', + suggestedMaxFeePerGas: '121', + }, + medium: { + minWaitTimeEstimate: 15_000, + maxWaitTimeEstimate: 45_000, + suggestedMaxPriorityFeePerGas: '1.552', + suggestedMaxFeePerGas: '131.552', + }, + high: { + minWaitTimeEstimate: 15_000, + maxWaitTimeEstimate: 60_000, + suggestedMaxPriorityFeePerGas: '2.94', + suggestedMaxFeePerGas: '142.94', + }, + estimatedBaseFee: '100', + }); + }); +}); diff --git a/src/gas/fetchGasEstimatesViaEthFeeHistory.ts b/src/gas/fetchGasEstimatesViaEthFeeHistory.ts new file mode 100644 index 0000000000..ffb98c5003 --- /dev/null +++ b/src/gas/fetchGasEstimatesViaEthFeeHistory.ts @@ -0,0 +1,164 @@ +import { BN } from 'ethereumjs-util'; +import { fromWei } from 'ethjs-unit'; +import fetchBlockFeeHistory from './fetchBlockFeeHistory'; +import { Eip1559GasFee, GasFeeEstimates } from './GasFeeController'; + +type EthQuery = any; +type PriorityLevel = typeof PRIORITY_LEVELS[number]; +type Percentile = typeof PRIORITY_LEVEL_PERCENTILES[number]; + +// This code is translated from the MetaSwap API: +// + +const NUMBER_OF_RECENT_BLOCKS_TO_FETCH = 5; +const PRIORITY_LEVELS = ['low', 'medium', 'high'] as const; +const PRIORITY_LEVEL_PERCENTILES = [10, 20, 30] as const; +const SETTINGS_BY_PRIORITY_LEVEL = { + low: { + percentile: 10 as Percentile, + baseFeePercentageMultiplier: new BN(120), + priorityFeePercentageMultiplier: new BN(94), + minSuggestedMaxPriorityFeePerGas: new BN(1_000_000_000), + estimatedWaitTimes: { + minWaitTimeEstimate: 15_000, + maxWaitTimeEstimate: 30_000, + }, + }, + medium: { + percentile: 20 as Percentile, + baseFeePercentageMultiplier: new BN(130), + priorityFeePercentageMultiplier: new BN(97), + minSuggestedMaxPriorityFeePerGas: new BN(1_500_000_000), + estimatedWaitTimes: { + minWaitTimeEstimate: 15_000, + maxWaitTimeEstimate: 45_000, + }, + }, + high: { + percentile: 30 as Percentile, + baseFeePercentageMultiplier: new BN(140), + priorityFeePercentageMultiplier: new BN(98), + minSuggestedMaxPriorityFeePerGas: new BN(2_000_000_000), + estimatedWaitTimes: { + minWaitTimeEstimate: 15_000, + maxWaitTimeEstimate: 60_000, + }, + }, +}; + +/** + * Finds the "median" among a list of numbers. Note that this is different from the implementation + * in the MetaSwap API, as we want to hold to using BN as much as possible. + * + * @param numbers - A list of numbers, as BNs. Will be sorted automatically if unsorted. + * @returns The median number. + */ +function medianOf(numbers: BN[]): BN { + const sortedNumbers = numbers.slice().sort((a, b) => a.cmp(b)); + const len = sortedNumbers.length; + const index = Math.floor((len - 1) / 2); + return sortedNumbers[index]; +} + +/** + * Calculates a set of estimates assigned to a particular priority level based on the data returned + * by `eth_feeHistory`. + * + * @param priorityLevel - The level of fees that dictates how soon a transaction may go through + * ("low", "medium", or "high"). + * @param latestBaseFeePerGas - The base fee per gas recorded for the latest block in WEI, as a BN. + * @param blocks - More information about blocks we can use to calculate estimates. + * @returns The estimates. + */ +function calculateGasEstimatesForPriorityLevel( + priorityLevel: PriorityLevel, + latestBaseFeePerGas: BN, + blocks: { priorityFeesByPercentile: Record }[], +): Eip1559GasFee { + const settings = SETTINGS_BY_PRIORITY_LEVEL[priorityLevel]; + + const adjustedBaseFee = latestBaseFeePerGas + .mul(settings.baseFeePercentageMultiplier) + .divn(100); + const priorityFees = blocks.map((block) => { + return block.priorityFeesByPercentile[settings.percentile]; + }); + const medianPriorityFee = medianOf(priorityFees); + const adjustedPriorityFee = medianPriorityFee + .mul(settings.priorityFeePercentageMultiplier) + .divn(100); + const suggestedMaxPriorityFeePerGas = BN.max( + adjustedPriorityFee, + settings.minSuggestedMaxPriorityFeePerGas, + ); + const suggestedMaxFeePerGas = adjustedBaseFee.add( + suggestedMaxPriorityFeePerGas, + ); + + return { + ...settings.estimatedWaitTimes, + suggestedMaxPriorityFeePerGas: fromWei( + suggestedMaxPriorityFeePerGas, + 'gwei', + ), + suggestedMaxFeePerGas: fromWei(suggestedMaxFeePerGas, 'gwei'), + }; +} + +/** + * Calculates a set of estimates suitable for different priority levels based on the data returned + * by `eth_feeHistory`. + * + * @param latestBaseFeePerGas - The base fee per gas recorded for the latest block in WEI, as a BN. + * @param blocks - More information about blocks we can use to calculate estimates. + * @returns The estimates. + */ +function calculateGasEstimatesForAllPriorityLevels( + latestBaseFeePerGas: BN, + blocks: { priorityFeesByPercentile: Record }[], +) { + return PRIORITY_LEVELS.reduce((obj, priorityLevel) => { + const gasEstimatesForPriorityLevel = calculateGasEstimatesForPriorityLevel( + priorityLevel, + latestBaseFeePerGas, + blocks, + ); + return { ...obj, [priorityLevel]: gasEstimatesForPriorityLevel }; + }, {} as Pick); +} + +/** + * Generates gas fee estimates based on gas fees that have been used in the recent past so that + * those estimates can be displayed to users. + * + * To produce the estimates, the last 5 blocks are read from the network, and for each block, the + * priority fees for transactions at the 10th, 20th, and 30th percentiles are also read (here + * "percentile" signifies the level at which those transactions contribute to the overall gas used + * for the block, where higher percentiles correspond to higher fees). This information is used to + * calculate reasonable max priority and max fees for three different priority levels (higher + * priority = higher fee). + * + * @param ethQuery - An EthQuery instance. + * @returns Base and priority fee estimates, categorized by priority level, as well as an estimate + * for the next block's base fee. + */ +export default async function fetchGasEstimatesViaEthFeeHistory( + ethQuery: EthQuery, +): Promise { + const blocks = await fetchBlockFeeHistory({ + ethQuery, + numberOfBlocks: NUMBER_OF_RECENT_BLOCKS_TO_FETCH, + percentiles: PRIORITY_LEVEL_PERCENTILES, + }); + const latestBlock = blocks[blocks.length - 1]; + const levelSpecificGasEstimates = calculateGasEstimatesForAllPriorityLevels( + latestBlock.baseFeePerGas, + blocks, + ); + const estimatedBaseFee = fromWei(latestBlock.baseFeePerGas, 'gwei'); + + return { + ...levelSpecificGasEstimates, + estimatedBaseFee, + }; +} diff --git a/src/util.test.ts b/src/util.test.ts index e052ea49f9..3062877484 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1,8 +1,6 @@ import 'isomorphic-fetch'; import { BN } from 'ethereumjs-util'; import nock from 'nock'; -import HttpProvider from 'ethjs-provider-http'; -import EthQuery from 'eth-query'; import * as util from './util'; import { Transaction, @@ -23,50 +21,6 @@ const GAS_PRICE = 'gasPrice'; const FAIL = 'lol'; const PASS = '0x1'; -const mockFlags: { [key: string]: any } = { - estimateGas: null, - gasPrice: null, -}; -const PROVIDER = new HttpProvider( - 'https://ropsten.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', -); - -jest.mock('eth-query', () => - jest.fn().mockImplementation(() => { - return { - estimateGas: (_transaction: any, callback: any) => { - callback(undefined, '0x0'); - }, - gasPrice: (callback: any) => { - if (mockFlags.gasPrice) { - callback(new Error(mockFlags.gasPrice)); - return; - } - callback(undefined, '0x0'); - }, - getBlockByNumber: ( - _blocknumber: any, - _fetchTxs: boolean, - callback: any, - ) => { - callback(undefined, { gasLimit: '0x0' }); - }, - getCode: (_to: any, callback: any) => { - callback(undefined, '0x0'); - }, - getTransactionByHash: (_hash: any, callback: any) => { - callback(undefined, { blockNumber: '0x1' }); - }, - getTransactionCount: (_from: any, _to: any, callback: any) => { - callback(undefined, '0x0'); - }, - sendRawTransaction: (_transaction: any, callback: any) => { - callback(undefined, '1337'); - }, - }; - }), -); - describe('util', () => { beforeEach(() => { nock.cleanAll(); @@ -101,6 +55,51 @@ describe('util', () => { expect(util.hexToBN('0x1337').toNumber()).toBe(4919); }); + describe('fromHex', () => { + it('converts a string that represents a number in hexadecimal format with leading "0x" into a BN', () => { + expect(util.fromHex('0x1337')).toStrictEqual(new BN(4919)); + }); + + it('converts a string that represents a number in hexadecimal format without leading "0x" into a BN', () => { + expect(util.fromHex('1337')).toStrictEqual(new BN(4919)); + }); + + it('does nothing to a BN', () => { + const bn = new BN(4919); + expect(util.fromHex(bn)).toBe(bn); + }); + }); + + describe('toHex', () => { + it('converts a BN to a hex string prepended with "0x"', () => { + expect(util.toHex(new BN(4919))).toStrictEqual('0x1337'); + }); + + it('parses a string as a number in decimal format and converts it to a hex string prepended with "0x"', () => { + expect(util.toHex('4919')).toStrictEqual('0x1337'); + }); + + it('throws an error if given a string with decimals', () => { + expect(() => util.toHex('4919.3')).toThrow('Invalid character'); + }); + + it('converts a number to a hex string prepended with "0x"', () => { + expect(util.toHex(4919)).toStrictEqual('0x1337'); + }); + + it('throws an error if given a float', () => { + expect(() => util.toHex(4919.3)).toThrow('Invalid character'); + }); + + it('does nothing to a string that is already a "0x"-prepended hex value', () => { + expect(util.toHex('0x1337')).toStrictEqual('0x1337'); + }); + + it('throws an error if given a non-"0x"-prepended string that is not a valid hex value', () => { + expect(() => util.toHex('zzzz')).toThrow('Invalid character'); + }); + }); + it('normalizeTransaction', () => { const normalized = util.normalizeTransaction({ data: 'data', @@ -934,18 +933,52 @@ describe('util', () => { }); describe('query', () => { - it('should query and resolve', async () => { - const ethQuery = new EthQuery(PROVIDER); - const gasPrice = await util.query(ethQuery, 'gasPrice', []); - expect(gasPrice).toStrictEqual('0x0'); + describe('when the given method exists directly on the EthQuery', () => { + it('should call the method on the EthQuery and, if it is successful, return a promise that resolves to the result', async () => { + const ethQuery = { + getBlockByHash: (blockId: any, cb: any) => cb(null, { id: blockId }), + }; + const result = await util.query(ethQuery, 'getBlockByHash', ['0x1234']); + expect(result).toStrictEqual({ id: '0x1234' }); + }); + + it('should call the method on the EthQuery and, if it errors, return a promise that is rejected with the error', async () => { + const ethQuery = { + getBlockByHash: (_blockId: any, cb: any) => + cb(new Error('uh oh'), null), + }; + await expect( + util.query(ethQuery, 'getBlockByHash', ['0x1234']), + ).rejects.toThrow('uh oh'); + }); }); - it('should query and reject if error', async () => { - const ethQuery = new EthQuery(PROVIDER); - mockFlags.gasPrice = 'Uh oh'; - await expect(util.query(ethQuery, 'gasPrice', [])).rejects.toThrow( - 'Uh oh', - ); + describe('when the given method does not exist directly on the EthQuery', () => { + it('should use sendAsync to call the RPC endpoint and, if it is successful, return a promise that resolves to the result', async () => { + const ethQuery = { + sendAsync: ({ method, params }: any, cb: any) => { + if (method === 'eth_getBlockByHash') { + return cb(null, { id: params[0] }); + } + throw new Error(`Unsupported method ${method}`); + }, + }; + const result = await util.query(ethQuery, 'eth_getBlockByHash', [ + '0x1234', + ]); + expect(result).toStrictEqual({ id: '0x1234' }); + }); + + it('should use sendAsync to call the RPC endpoint and, if it errors, return a promise that is rejected with the error', async () => { + const ethQuery = { + sendAsync: (_args: any, cb: any) => { + cb(new Error('uh oh'), null); + }, + }; + await expect( + util.query(ethQuery, 'eth_getBlockByHash', ['0x1234']), + ).rejects.toThrow('uh oh'); + }); }); }); diff --git a/src/util.ts b/src/util.ts index 905f6820bc..d4a1b0f0d4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -261,6 +261,36 @@ export function hexToText(hex: string) { } } +/** + * Parses a hex string and converts it into a number that can be operated on in a bignum-safe, + * base-10 way. + * + * @param value - A base-16 number encoded as a string. + * @returns The number as a BN object in base-16 mode. + */ +export function fromHex(value: string | BN): BN { + if (BN.isBN(value)) { + return value; + } + return new BN(hexToBN(value).toString(10)); +} + +/** + * Converts an integer to a hexadecimal representation. + * + * @param value - An integer, an integer encoded as a base-10 string, or a BN. + * @returns The integer encoded as a hex string. + */ +export function toHex(value: number | string | BN): string { + if (typeof value === 'string' && isHexString(value)) { + return value; + } + const hexString = BN.isBN(value) + ? value.toString(16) + : new BN(value.toString(), 10).toString(16); + return `0x${hexString}`; +} + /** * Normalizes properties on a Transaction object. * @@ -687,13 +717,19 @@ export function query( args: any[] = [], ): Promise { return new Promise((resolve, reject) => { - ethQuery[method](...args, (error: Error, result: any) => { + const cb = (error: Error, result: any) => { if (error) { reject(error); return; } resolve(result); - }); + }; + + if (typeof ethQuery[method] === 'function') { + ethQuery[method](...args, cb); + } else { + ethQuery.sendAsync({ method, params: args }, cb); + } }); } diff --git a/yarn.lock b/yarn.lock index a1d33f62e8..7788efa71b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1038,6 +1038,17 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^27.2.5": + version "27.2.5" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.2.5.tgz#420765c052605e75686982d24b061b4cbba22132" + integrity sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^16.0.0" + chalk "^4.0.0" + "@lavamoat/allow-scripts@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@lavamoat/allow-scripts/-/allow-scripts-1.0.6.tgz#fbdf7c35a5c2c2cff05ba002b7bc8f3355bda22c" @@ -1276,6 +1287,21 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest-when@^2.7.3": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@types/jest-when/-/jest-when-2.7.3.tgz#40735b320d8655ebff01123cb58afbdaf8274658" + integrity sha512-BdDZnKj3ZO1VsRlJFyRx6yLa0hG9++qetnBKhESjCGRVAm6S4aaKXXLm9xGFmtAQpzuMC44wxhvkG2cl6axvyQ== + dependencies: + "@types/jest" "*" + +"@types/jest@*": + version "27.0.2" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.0.2.tgz#ac383c4d4aaddd29bbf2b916d8d105c304a5fcd7" + integrity sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA== + dependencies: + jest-diff "^27.0.0" + pretty-format "^27.0.0" + "@types/jest@26.x": version "26.0.13" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.13.tgz#5a7b9d5312f5dd521a38329c38ee9d3802a0b85e" @@ -1393,6 +1419,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^16.0.0": + version "16.0.4" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" + integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw== + dependencies: + "@types/yargs-parser" "*" + "@typescript-eslint/eslint-plugin@^4.22.0": version "4.22.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.22.0.tgz#3d5f29bb59e61a9dba1513d491b059e536e16dbc" @@ -1673,6 +1706,11 @@ ansi-regex@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -1688,6 +1726,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: "@types/color-name" "^1.1.1" color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -2622,6 +2665,11 @@ diff-sequences@^26.6.2: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff-sequences@^27.0.6: + version "27.0.6" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.0.6.tgz#3305cb2e55a033924054695cc66019fd7f8e5723" + integrity sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ== + diff@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -4743,6 +4791,16 @@ jest-diff@^26.4.2: jest-get-type "^26.3.0" pretty-format "^26.4.2" +jest-diff@^27.0.0: + version "27.3.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.3.1.tgz#d2775fea15411f5f5aeda2a5e02c2f36440f6d55" + integrity sha512-PCeuAH4AWUo2O5+ksW4pL9v5xJAcIKPUPfIhZBcG1RKv/0+dvaWTQK1Nrau8d67dp65fOqbeMdoil+6PedyEPQ== + dependencies: + chalk "^4.0.0" + diff-sequences "^27.0.6" + jest-get-type "^27.3.1" + pretty-format "^27.3.1" + jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" @@ -4808,6 +4866,11 @@ jest-get-type@^26.3.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== +jest-get-type@^27.3.1: + version "27.3.1" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.3.1.tgz#a8a2b0a12b50169773099eee60a0e6dd11423eff" + integrity sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg== + jest-haste-map@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.3.0.tgz#c51a3b40100d53ab777bfdad382d2e7a00e5c726" @@ -5094,6 +5157,11 @@ jest-watcher@^26.3.0: jest-util "^26.3.0" string-length "^4.0.1" +jest-when@^3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/jest-when/-/jest-when-3.4.2.tgz#720f19e0ab3a7d55a45a915663ca2b1bd3a9ec1a" + integrity sha512-vO1r+1XsyeavhoSapj7q4xD5xuM9i+UdopfhmJJK/aKaDpzDesxZ6hreLSO1JUZhZInqdM7CCn+At7c0SI2EEw== + jest-worker@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f" @@ -6363,6 +6431,16 @@ pretty-format@^26.4.2: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-format@^27.0.0, pretty-format@^27.3.1: + version "27.3.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.3.1.tgz#7e9486365ccdd4a502061fa761d3ab9ca1b78df5" + integrity sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA== + dependencies: + "@jest/types" "^27.2.5" + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + printj@~1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222"