diff --git a/.changeset/grumpy-forks-listen.md b/.changeset/grumpy-forks-listen.md new file mode 100644 index 0000000000..5e61572eb3 --- /dev/null +++ b/.changeset/grumpy-forks-listen.md @@ -0,0 +1,5 @@ +--- +'@api3/airnode-utilities': minor +--- + +New strategy for providerRecommendedGasPrice diff --git a/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts b/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts index 58b9a37cf5..272e21d38e 100644 --- a/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts +++ b/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.test.ts @@ -245,6 +245,10 @@ describe('Gas oracle', () => { const getGasPriceMock = ethers.BigNumber.from(11); getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(10); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + const [_logs, gasTarget] = await gasOracle.getGasPrice(provider, defaultChainOptions); // Check that the function returned the same value as the strategy-specific function const providerRecommendedGasTarget = await gasOracle.fetchProviderRecommendedGasPrice( @@ -280,6 +284,10 @@ describe('Gas oracle', () => { const getGasPriceMock = ethers.BigNumber.from(11); getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(10); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + const [_logs, gasTarget] = await gasOracle.getGasPrice(provider, defaultChainOptions); // Check that the function returned the same value as the strategy-specific function const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( @@ -293,6 +301,69 @@ describe('Gas oracle', () => { expect(gasTarget).toEqual(gasOracle.getGasTargetWithGasLimit(providerRecommendedGasPrice, fulfillmentGasLimit)); }); + it('returns baseFeePerGas if getGasPrice is too high', async () => { + const getGasPriceSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice'); + const getGasPriceMock = ethers.BigNumber.from(1000); + getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(1); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + + const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( + provider, + providerRecommendedGasPriceStrategy, + startTime + ); + + const expectedGasTarget = baseFeePerGasMock.mul(2).add(3); + expect(providerRecommendedGasPrice.gasPrice).toEqual(expectedGasTarget); + }); + + it('returns getGasPrice if the multiplied gas price is less than a multiple of the baseFeePerGas', async () => { + const getGasPriceSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice'); + const getGasPriceMock = ethers.BigNumber.from(1); + getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(10); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + + const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( + provider, + providerRecommendedGasPriceStrategy, + startTime + ); + + const expectedGasTarget = gasOracle.multiplyGasPrice( + getGasPriceMock, + providerRecommendedGasPriceStrategy.recommendedGasPriceMultiplier + ); + expect(providerRecommendedGasPrice.gasPrice).toEqual(expectedGasTarget); + }); + + it('returns getGasPrice if baseFeePerGas is null', async () => { + const getGasPriceSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice'); + const getGasPriceMock = ethers.BigNumber.from(1); + getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = null; + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + + const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( + provider, + providerRecommendedGasPriceStrategy, + startTime + ); + + const expectedGasTarget = gasOracle.multiplyGasPrice( + getGasPriceMock, + providerRecommendedGasPriceStrategy.recommendedGasPriceMultiplier + ); + expect(providerRecommendedGasPrice.gasPrice).toEqual(expectedGasTarget); + }); + it('returns providerRecommendedGasPrice if not enough transactions with eip1559 gas prices', async () => { const getBlockWithTransactionsSpy = jest.spyOn( ethers.providers.StaticJsonRpcProvider.prototype, @@ -318,6 +389,10 @@ describe('Gas oracle', () => { const getGasPriceMock = ethers.BigNumber.from(11); getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(10); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + const [_logs, gasTarget] = await gasOracle.getGasPrice(provider, defaultChainOptions); // Check that the function returned the same value as the strategy-specific function const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( @@ -352,6 +427,10 @@ describe('Gas oracle', () => { const getGasPriceMock = ethers.BigNumber.from(11); getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(10); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + const [_logs, gasTarget] = await gasOracle.getGasPrice(provider, defaultChainOptions); // Check that the function returned the same value as the strategy-specific function const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( @@ -388,6 +467,10 @@ describe('Gas oracle', () => { const getGasPriceMock = ethers.BigNumber.from(11); getGasPriceSpy.mockImplementation(() => Promise.resolve(getGasPriceMock)); + const fetchBaseFeePerGasMock = jest.spyOn(gasOracle, 'fetchBaseFeePerGas'); + const baseFeePerGasMock = ethers.BigNumber.from(10); + fetchBaseFeePerGasMock.mockResolvedValue(baseFeePerGasMock); + const [_logs, gasTarget] = await gasOracle.getGasPrice(provider, defaultChainOptions); // Check that the function returned the same value as the strategy-specific function const providerRecommendedGasPrice = await gasOracle.fetchProviderRecommendedGasPrice( @@ -401,6 +484,50 @@ describe('Gas oracle', () => { expect(gasTarget).toEqual(gasOracle.getGasTargetWithGasLimit(providerRecommendedGasPrice, fulfillmentGasLimit)); }); + it('returns the correct baseFeePerGas value', async () => { + const getBlockSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getBlock'); + + const blockDataMock = { + number: 23, + baseFeePerGas: ethers.BigNumber.from(15), + }; + getBlockSpy.mockImplementationOnce(() => Promise.resolve(blockDataMock as any)); + + const startTime = Date.now(); + const baseFeePerGas = await gasOracle.fetchBaseFeePerGas(provider, startTime); + + expect(getBlockSpy).toHaveBeenCalledWith('latest'); + expect(baseFeePerGas).toEqual(blockDataMock.baseFeePerGas); + }); + + it('returns null if baseFeePerGas is not available', async () => { + const getBlockSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getBlock'); + + const blockDataMock = { + number: 23, + baseFeePerGas: null, + }; + getBlockSpy.mockImplementationOnce(() => Promise.resolve(blockDataMock as any)); + + const startTime = Date.now(); + const baseFeePerGas = await gasOracle.fetchBaseFeePerGas(provider, startTime); + + expect(getBlockSpy).toHaveBeenCalledWith('latest'); + expect(baseFeePerGas).toBeNull(); + }); + + it('throws an error if unable to get the latest block', async () => { + const getBlockSpy = jest.spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getBlock'); + getBlockSpy.mockImplementationOnce(() => Promise.reject(new Error('Unable to get the latest block.'))); + + const startTime = Date.now(); + await expect(gasOracle.fetchBaseFeePerGas(provider, startTime)).rejects.toThrow( + 'Unable to get the latest block.' + ); + + expect(getBlockSpy).toHaveBeenCalledWith('latest'); + }); + it('returns constantGasPrice if not enough blocks and fallback fails', async () => { const getBlockWithTransactionsSpy = jest.spyOn( ethers.providers.StaticJsonRpcProvider.prototype, diff --git a/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.ts b/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.ts index 79fc73f9bc..0aec483580 100644 --- a/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.ts +++ b/packages/airnode-utilities/src/evm/gas-prices/gas-oracle.ts @@ -90,12 +90,43 @@ export const fetchProviderRecommendedGasPrice = async ( ? multiplyGasPrice(goGasPrice.data, recommendedGasPriceMultiplier) : goGasPrice.data; + const baseFeePerGas = await fetchBaseFeePerGas(provider, startTime); + if (baseFeePerGas && multipliedGasPrice.gt(baseFeePerGas.mul(5))) { + return { + type: 0, + gasPrice: baseFeePerGas.mul(2).add(3), + }; + } + return { type: 0, gasPrice: multipliedGasPrice, }; }; +export const fetchBaseFeePerGas = async (provider: Provider, startTime: number): Promise => { + const goLatestBlock = await go(() => provider.getBlock('latest'), { + attemptTimeoutMs: GAS_ORACLE_STRATEGY_ATTEMPT_TIMEOUT_MS, + totalTimeoutMs: calculateTimeout(startTime, GAS_ORACLE_STRATEGY_MAX_TIMEOUT_MS), + retries: 1, + delay: { + type: 'random', + minDelayMs: GAS_ORACLE_RANDOM_BACKOFF_MIN_MS, + maxDelayMs: GAS_ORACLE_RANDOM_BACKOFF_MAX_MS, + }, + onAttemptError: (goError) => logger.warn(`Failed attempt to get latest block. Error: ${goError.error}.`), + }); + + if (!goLatestBlock.success) { + throw new Error('Unable to get the latest block.'); + } + + const latestBlock = goLatestBlock.data; + const baseFeePerGas = latestBlock.baseFeePerGas; + + return baseFeePerGas || null; +}; + export const fetchProviderRecommendedEip1559GasPrice = async ( provider: Provider, gasOracleOptions: config.ProviderRecommendedEip1559GasPriceStrategy,