diff --git a/main/chains/gas/index.ts b/main/chains/gas/index.ts new file mode 100644 index 000000000..dd2cba70c --- /dev/null +++ b/main/chains/gas/index.ts @@ -0,0 +1,123 @@ +import { intToHex } from '@ethereumjs/util' + +interface GasCalculator { + calculateGas: (blocks: Block[]) => GasFees +} + +type RawGasFees = { + nextBaseFee: number + maxBaseFeePerGas: number + maxPriorityFeePerGas: number + maxFeePerGas: number +} + +export type Block = { + baseFee: number + rewards: number[] + gasUsedRatio: number +} + +function feesToHex(fees: RawGasFees) { + return { + nextBaseFee: intToHex(fees.nextBaseFee), + maxBaseFeePerGas: intToHex(fees.maxBaseFeePerGas), + maxPriorityFeePerGas: intToHex(fees.maxPriorityFeePerGas), + maxFeePerGas: intToHex(fees.maxFeePerGas) + } +} + +function calculateReward(blocks: Block[]) { + const recentBlocks = 10 + const allBlocks = blocks.length + + // these strategies will be tried in descending order until one finds + // at least 1 eligible block from which to calculate the reward + const rewardCalculationStrategies = [ + // use recent blocks that weren't almost empty or almost full + { minRatio: 0.1, maxRatio: 0.9, blockSampleSize: recentBlocks }, + // include recent blocks that were full + { minRatio: 0.1, maxRatio: 1.05, blockSampleSize: recentBlocks }, + // use the entire block sample but still limit to blocks that were not almost empty + { minRatio: 0.1, maxRatio: 1.05, blockSampleSize: allBlocks }, + // use any recent block with transactions + { minRatio: 0, maxRatio: Number.MAX_SAFE_INTEGER, blockSampleSize: recentBlocks }, + // use any block with transactions + { minRatio: 0, maxRatio: Number.MAX_SAFE_INTEGER, blockSampleSize: allBlocks } + ] + + const eligibleRewardsBlocks = rewardCalculationStrategies.reduce((foundBlocks, strategy) => { + if (foundBlocks.length === 0) { + const blockSample = blocks.slice(blocks.length - Math.min(strategy.blockSampleSize, blocks.length)) + const eligibleBlocks = blockSample.filter( + (block) => block.gasUsedRatio > strategy.minRatio && block.gasUsedRatio <= strategy.maxRatio + ) + + if (eligibleBlocks.length > 0) return eligibleBlocks + } + + return foundBlocks + }, [] as Block[]) + + // use the median reward from the block sample or use the fee from the last block as a last resort + const lastBlockFee = blocks[blocks.length - 1].rewards[0] + return ( + eligibleRewardsBlocks.map((block) => block.rewards[0]).sort()[ + Math.floor(eligibleRewardsBlocks.length / 2) + ] || lastBlockFee + ) +} + +function estimateGasFees(blocks: Block[]) { + // plan for max fee of 2 full blocks, each one increasing the fee by 12.5% + const nextBlockFee = blocks[blocks.length - 1].baseFee // base fee for next block + const calculatedFee = Math.ceil(nextBlockFee * 1.125 * 1.125) + + // the last block contains only the base fee for the next block but no fee history, so + // don't use it in the block reward calculation + const medianBlockReward = calculateReward(blocks.slice(0, blocks.length - 1)) + + const estimatedGasFees = { + nextBaseFee: nextBlockFee, + maxBaseFeePerGas: calculatedFee, + maxPriorityFeePerGas: medianBlockReward, + maxFeePerGas: calculatedFee + medianBlockReward + } + + return estimatedGasFees +} + +function DefaultGasCalculator() { + return { + calculateGas: (blocks: Block[]) => { + const estimatedGasFees = estimateGasFees(blocks) + + return feesToHex(estimatedGasFees) + } + } +} + +function PolygonGasCalculator() { + return { + calculateGas: (blocks: Block[]) => { + const fees = estimateGasFees(blocks) + + const maxPriorityFeePerGas = Math.max(fees.maxPriorityFeePerGas, 30e9) + + return feesToHex({ + ...fees, + maxPriorityFeePerGas, + maxFeePerGas: fees.maxBaseFeePerGas + maxPriorityFeePerGas + }) + } + } +} + +export function createGasCalculator(chainId: number): GasCalculator { + // TODO: maybe this can be tied into chain config somehow + if (chainId === 137 || chainId === 80001) { + // Polygon and Mumbai testnet + return PolygonGasCalculator() + } + + return DefaultGasCalculator() +} diff --git a/main/chains/index.js b/main/chains/index.js index 13a74546d..bdae8ac64 100644 --- a/main/chains/index.js +++ b/main/chains/index.js @@ -9,7 +9,8 @@ const log = require('electron-log') const store = require('../store').default const { default: BlockMonitor } = require('./blocks') const { default: chainConfig } = require('./config') -const { default: GasCalculator } = require('../transaction/gasCalculator') +const { default: GasMonitor } = require('../transaction/gasMonitor') +const { createGasCalculator } = require('./gas') // These chain IDs are known to not support EIP-1559 and will be forced // not to use that mechanism @@ -36,6 +37,9 @@ class ChainConnection extends EventEmitter { // to update it to london this.chainConfig = chainConfig(parseInt(this.chainId), 'istanbul') + // TODO: maybe this can be tied into chain config somehow + this.gasCalculator = createGasCalculator(this.chainId) + this.primary = { status: 'off', network: '', @@ -81,12 +85,13 @@ class ChainConnection extends EventEmitter { monitor.on('data', async (block) => { let feeMarket = null - const gasCalculator = new GasCalculator(provider) + const gasMonitor = new GasMonitor(provider) if (allowEip1559 && 'baseFeePerGas' in block) { try { // only consider this an EIP-1559 block if fee market can be loaded - feeMarket = await gasCalculator.getFeePerGas() + const feeHistory = await gasMonitor.getFeeHistory(10, [10]) + feeMarket = this.gasCalculator.calculateGas(feeHistory) this.chainConfig.setHardforkByBlockNumber(block.number) @@ -108,7 +113,7 @@ class ChainConnection extends EventEmitter { store.setGasPrices(this.type, this.chainId, { fast: addHexPrefix(gasPrice.toString(16)) }) store.setGasDefault(this.type, this.chainId, 'fast') } else { - const gas = await gasCalculator.getGasPrices() + const gas = await gasMonitor.getGasPrices() const customLevel = store('main.networksMeta', this.type, this.chainId, 'gas.price.levels.custom') store.setGasPrices(this.type, this.chainId, { diff --git a/main/transaction/gasCalculator.ts b/main/transaction/gasCalculator.ts deleted file mode 100644 index 0d5fa5da9..000000000 --- a/main/transaction/gasCalculator.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { intToHex } from '@ethereumjs/util' - -interface FeeHistoryResponse { - baseFeePerGas: string[] - gasUsedRatio: number[] - reward: Array - oldestBlock: string -} - -interface Block { - baseFee: number - rewards: number[] - gasUsedRatio: number -} - -interface GasPrices { - slow: string - standard: string - fast: string - asap: string -} - -export default class GasCalculator { - private connection - - constructor(connection: any /* Chains */) { - this.connection = connection - } - - private async getFeeHistory( - numBlocks: number, - rewardPercentiles: number[], - newestBlock = 'latest' - ): Promise { - const blockCount = intToHex(numBlocks) - const payload = { method: 'eth_feeHistory', params: [blockCount, newestBlock, rewardPercentiles] } - - const feeHistory: FeeHistoryResponse = await this.connection.send(payload) - - const feeHistoryBlocks = feeHistory.baseFeePerGas.map((baseFee, i) => { - return { - baseFee: parseInt(baseFee, 16), - gasUsedRatio: feeHistory.gasUsedRatio[i], - rewards: (feeHistory.reward[i] || []).map((reward) => parseInt(reward, 16)) - } - }) - - return feeHistoryBlocks - } - - private calculateReward(blocks: Block[]) { - const recentBlocks = 10 - const allBlocks = blocks.length - - // these strategies will be tried in descending order until one finds - // at least 1 eligible block from which to calculate the reward - const rewardCalculationStrategies = [ - // use recent blocks that weren't almost empty or almost full - { minRatio: 0.1, maxRatio: 0.9, blockSampleSize: recentBlocks }, - // include recent blocks that were full - { minRatio: 0.1, maxRatio: 1.05, blockSampleSize: recentBlocks }, - // use the entire block sample but still limit to blocks that were not almost empty - { minRatio: 0.1, maxRatio: 1.05, blockSampleSize: allBlocks }, - // use any recent block with transactions - { minRatio: 0, maxRatio: Number.MAX_SAFE_INTEGER, blockSampleSize: recentBlocks }, - // use any block with transactions - { minRatio: 0, maxRatio: Number.MAX_SAFE_INTEGER, blockSampleSize: allBlocks } - ] - - const eligibleRewardsBlocks = rewardCalculationStrategies.reduce((foundBlocks, strategy) => { - if (foundBlocks.length === 0) { - const blockSample = blocks.slice(blocks.length - Math.min(strategy.blockSampleSize, blocks.length)) - const eligibleBlocks = blockSample.filter( - (block) => block.gasUsedRatio > strategy.minRatio && block.gasUsedRatio <= strategy.maxRatio - ) - - if (eligibleBlocks.length > 0) return eligibleBlocks - } - - return foundBlocks - }, [] as Block[]) - - // use the median reward from the block sample or use the fee from the last block as a last resort - const lastBlockFee = blocks[blocks.length - 1].rewards[0] - return ( - eligibleRewardsBlocks.map((block) => block.rewards[0]).sort()[ - Math.floor(eligibleRewardsBlocks.length / 2) - ] || lastBlockFee - ) - } - - async getGasPrices(): Promise { - const gasPrice = await this.connection.send({ method: 'eth_gasPrice' }) - - // in the future we may want to have specific calculators to calculate variations - // in the gas price or eliminate this structure altogether - return { - slow: gasPrice, - standard: gasPrice, - fast: gasPrice, - asap: gasPrice - } - } - - async getFeePerGas(): Promise { - // fetch the last 30 blocks and the bottom 10% of priority fees paid for each block - const blocks = await this.getFeeHistory(30, [10]) - - // plan for max fee of 2 full blocks, each one increasing the fee by 12.5% - const nextBlockFee = blocks[blocks.length - 1].baseFee // base fee for next block - const calculatedFee = Math.ceil(nextBlockFee * 1.125 * 1.125) - - // the last block contains only the base fee for the next block but no fee history, so - // don't use it in the block reward calculation - const medianBlockReward = this.calculateReward(blocks.slice(0, blocks.length - 1)) - - return { - nextBaseFee: intToHex(nextBlockFee), - maxBaseFeePerGas: intToHex(calculatedFee), - maxPriorityFeePerGas: intToHex(medianBlockReward), - maxFeePerGas: intToHex(calculatedFee + medianBlockReward) - } - } -} diff --git a/main/transaction/gasMonitor.ts b/main/transaction/gasMonitor.ts new file mode 100644 index 000000000..6c8fbd0a8 --- /dev/null +++ b/main/transaction/gasMonitor.ts @@ -0,0 +1,59 @@ +import { intToHex } from '@ethereumjs/util' + +import type { Block } from '../chains/gas' + +interface FeeHistoryResponse { + baseFeePerGas: string[] + gasUsedRatio: number[] + reward: Array + oldestBlock: string +} + +interface GasPrices { + slow: string + standard: string + fast: string + asap: string +} + +export default class GasMonitor { + private connection + + constructor(connection: any /* Chains */) { + this.connection = connection + } + + async getFeeHistory( + numBlocks: number, + rewardPercentiles: number[], + newestBlock = 'latest' + ): Promise { + const blockCount = intToHex(numBlocks) + const payload = { method: 'eth_feeHistory', params: [blockCount, newestBlock, rewardPercentiles] } + + const feeHistory: FeeHistoryResponse = await this.connection.send(payload) + + const feeHistoryBlocks = feeHistory.baseFeePerGas.map((baseFee, i) => { + return { + baseFee: parseInt(baseFee, 16), + gasUsedRatio: feeHistory.gasUsedRatio[i], + rewards: (feeHistory.reward[i] || []).map((reward) => parseInt(reward, 16)) + } + }) + + return feeHistoryBlocks + } + + async getGasPrices(): Promise { + const gasPrice = await this.connection.send({ method: 'eth_gasPrice' }) + + // in the future we may want to have specific calculators to calculate variations + // in the gas price or eliminate this structure altogether + return { + slow: gasPrice, + standard: gasPrice, + fast: gasPrice, + asap: gasPrice + } + } +} diff --git a/test/main/chains/gas/index.test.js b/test/main/chains/gas/index.test.js new file mode 100644 index 000000000..0a4bc0f93 --- /dev/null +++ b/test/main/chains/gas/index.test.js @@ -0,0 +1,169 @@ +import { intToHex } from '@ethereumjs/util' +import { createGasCalculator } from '../../../../main/chains/gas' +import { gweiToHex } from '../../../util' + +describe('#createGasCalculator', () => { + describe('default gas calculator', () => { + const gasCalculator = createGasCalculator() + + it('calculates the base fee for the next block', async () => { + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0, rewards: [0] }, + { baseFee: 182, rewards: [] } + ] + + const { maxBaseFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxBaseFeePerGas).toBe('0xe7') + }) + + it('calculates the priority fee for the next block based on normal blocks', async () => { + // all blocks with gas ratios between 0.1 and 0.9 will be considered for calculating the median priority fee + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0, rewards: [0] }, + { baseFee: 8, gasUsedRatio: 0.1801134637893198, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.23114498292513627, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.17942918604838942, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.12024061496050893, rewards: [4000000000] }, + { baseFee: 182, rewards: [] } + ] + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxPriorityFeePerGas).toBe('0x77359400') + }) + + it('excludes full blocks from the priority fee calculation', async () => { + // all full blocks (gas ratios above 0.9) will be excluded from calculating the median priority fee + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0, rewards: [0] }, + { baseFee: 8, gasUsedRatio: 0.1801134637893198, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 1, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.23114498292513627, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.17942918604838942, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.9102406149605089, rewards: [4000000000] }, + { baseFee: 182, rewards: [] } + ] + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxPriorityFeePerGas).toBe('0x3b9aca00') + }) + + it('excludes "empty" blocks from the priority fee calculation', async () => { + // all empty blocks (gas ratios below 0.1) will be excluded from calculating the median priority fee + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0.1801134637893198, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.0801134637893198, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.23114498292513627, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.17942918604838942, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.01024061496050893, rewards: [4000000000] }, + { baseFee: 182, rewards: [] } + ] + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxPriorityFeePerGas).toBe('0x3b9aca00') + }) + + it('considers full blocks if no partial blocks are eligible', async () => { + // full blocks (gas ratios above 0.9) will be considered only if no blocks with a ratio between 0.1 and 0.9 are available + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0.9801134637893198, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 1, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.03114498292513627, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.07942918604838942, rewards: [1000000001] }, + { baseFee: 8, gasUsedRatio: 0.990240614960509, rewards: [4000000000] }, + { baseFee: 182, rewards: [] } + ] + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxPriorityFeePerGas).toBe('0x77359400') + }) + + it('considers blocks from the entire sample if none of the last 10 blocks are eligible', async () => { + // index in array represents distance away from current block + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0.73, rewards: [2587202560] }, + { baseFee: 8, gasUsedRatio: 0.02, rewards: [2000000000] }, + { baseFee: 182, rewards: [] } + ] + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxPriorityFeePerGas).toBe('0x9a359400') + }) + + it('uses any recent blocks if no blocks in the sample have the qualifying gas ratios', async () => { + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0.0801134637893198, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 1.1, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 0.03114498292513627, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.07942918604838942, rewards: [1000000001] }, + { baseFee: 8, gasUsedRatio: 1.0902406149605088, rewards: [4000000000] }, + { baseFee: 182, rewards: [] } + ] + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + + expect(maxPriorityFeePerGas).toBe('0x3b9aca01') + }) + + it('uses any block in the sample if no other blocks are eligible', async () => { + // index in array represents distance away from current block + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0.073, rewards: [2587202560] }, + { baseFee: 8, rewards: [] }, + { baseFee: 8, gasUsedRatio: 1.122, rewards: [2000000000] }, + { baseFee: 8, gasUsedRatio: 1.2239, rewards: [1000000000] }, + { baseFee: 182, rewards: [] } + ] + + const rewards = feeHistory.reduce( + (acc, { rewards }) => (rewards.length ? acc.concat(intToHex(rewards[0])) : acc), + [] + ) + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + expect(rewards.includes(maxPriorityFeePerGas)).toBe(true) + }) + + it('uses the priority fee from the latest block when no eligible blocks are available', async () => { + const feeHistory = [ + { baseFee: 8, gasUsedRatio: 0, rewards: [0] }, + { baseFee: 182, rewards: [] } + ] + + const { maxBaseFeePerGas, maxPriorityFeePerGas } = await gasCalculator.calculateGas(feeHistory) + expect(maxBaseFeePerGas).toBe('0xe7') + expect(maxPriorityFeePerGas).toBe('0x0') + }) + }) + + describe('polygon gas calculator', () => { + const gasCalculator = createGasCalculator(137) + + it('should enforce a minimum of 30 gwei for the priority fee', async () => { + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas([ + { baseFee: 8, gasUsedRatio: 0.07942918604838942, rewards: [1000000000] }, + { baseFee: 8, gasUsedRatio: 0.990240614960509, rewards: [1000000000] }, + { baseFee: 182, rewards: [] } + ]) + + expect(maxPriorityFeePerGas).toBe(gweiToHex(30)) + }) + + it('does not change the priority fee if above 30 gwei', async () => { + const gasCalculator = createGasCalculator(137) + + const { maxPriorityFeePerGas } = await gasCalculator.calculateGas([ + { baseFee: 8, gasUsedRatio: 0.07942918604838942, rewards: [45000000000] }, + { baseFee: 8, gasUsedRatio: 0.990240614960509, rewards: [45000000000] }, + { baseFee: 182, rewards: [] } + ]) + + expect(maxPriorityFeePerGas).toBe(gweiToHex(45)) + }) + }) +}) diff --git a/test/main/chains/index.test.js b/test/main/chains/index.test.js index 684e661db..2e88545db 100644 --- a/test/main/chains/index.test.js +++ b/test/main/chains/index.test.js @@ -37,7 +37,7 @@ class MockConnection extends EventEmitter { return resolve({ baseFeePerGas: [gweiToHex(15), gweiToHex(8), gweiToHex(9), gweiToHex(8), gweiToHex(7)], gasUsedRatio: [0.11, 0.8, 0.2, 0.5], - reward: [[gweiToHex(1)], [gweiToHex(1)], [gweiToHex(1)], [gweiToHex(1)]] + reward: [[gweiToHex(32)], [gweiToHex(32)], [gweiToHex(32)], [gweiToHex(32)]] }) } @@ -246,7 +246,7 @@ Object.values(mockConnections).forEach((chain) => { } const expectedBaseFee = 7e9 * 1.125 * 1.125 - const expectedPriorityFee = 1e9 + const expectedPriorityFee = 32e9 observer = store.observer(() => { const gas = store(`main.networksMeta.ethereum.${chain.id}.gas.price`) diff --git a/test/main/transaction/gasCalculator.test.js b/test/main/transaction/gasCalculator.test.js deleted file mode 100644 index 3637f3be5..000000000 --- a/test/main/transaction/gasCalculator.test.js +++ /dev/null @@ -1,211 +0,0 @@ -import GasCalculator from '../../../main/transaction/gasCalculator' - -let requestHandlers -let testConnection = { - send: jest.fn((payload) => { - if (payload.method in requestHandlers) { - return Promise.resolve(requestHandlers[payload.method](payload.params)) - } - - return Promise.reject('unsupported method: ' + payload.method) - }) -} - -describe('#getGasPrices', () => { - const gasPrice = '0x3baa1028' - - beforeEach(() => { - requestHandlers = { - eth_gasPrice: () => gasPrice - } - }) - - it('sets the slow gas price', async () => { - const calculator = new GasCalculator(testConnection) - - const gas = await calculator.getGasPrices() - - expect(gas.slow).toBe(gasPrice) - }) - - it('sets the standard gas price', async () => { - const calculator = new GasCalculator(testConnection) - - const gas = await calculator.getGasPrices() - - expect(gas.standard).toBe(gasPrice) - }) - - it('sets the fast gas price', async () => { - const calculator = new GasCalculator(testConnection) - - const gas = await calculator.getGasPrices() - - expect(gas.fast).toBe(gasPrice) - }) - - it('sets the asap gas price', async () => { - const calculator = new GasCalculator(testConnection) - - const gas = await calculator.getGasPrices() - - expect(gas.asap).toBe(gasPrice) - }) -}) - -describe('#getFeePerGas', () => { - const nextBlockBaseFee = '0xb6' - - let gasUsedRatios, blockRewards - - beforeEach(() => { - // default to all blocks being ineligible for priority fee calculation - gasUsedRatios = [] - blockRewards = [] - - requestHandlers = { - eth_feeHistory: (params) => { - const numBlocks = parseInt(params[0] || '0x', 16) - - return { - // base fees include the requested number of blocks plus the next block - baseFeePerGas: Array(numBlocks).fill('0x8').concat([nextBlockBaseFee]), - gasUsedRatio: fillEmptySlots(gasUsedRatios, numBlocks, 0).reverse(), - oldestBlock: '0x89502f', - reward: fillEmptySlots(blockRewards, numBlocks, ['0x0']).reverse() - } - } - } - }) - - it('calculates the base fee for the next couple blocks', async () => { - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxBaseFeePerGas).toBe('0xe7') - }) - - describe('calculating priority fee', () => { - it('calculates the priority fee for the next block based on normal blocks', async () => { - // all blocks with gas ratios between 0.1 and 0.9 will be considered for calculating the median priority fee - gasUsedRatios = [0.12024061496050893, 0.17942918604838942, 0.23114498292513627, 0.1801134637893198] - blockRewards = [['0xee6b2800'], ['0x3b9aca00'], ['0x77359400'], ['0x3b9aca00']] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x77359400') - }) - - it('excludes full blocks from the priority fee calculation', async () => { - // all full blocks (gas ratios above 0.9) will be excluded from calculating the median priority fee - gasUsedRatios = [0.91024061496050893, 0.17942918604838942, 0.23114498292513627, 1, 0.1801134637893198] - blockRewards = [['0xee6b2800'], ['0x3b9aca00'], ['0x77359400'], ['0x77359400'], ['0x3b9aca00']] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x3b9aca00') - }) - - it('excludes "empty" blocks from the priority fee calculation', async () => { - // all empty blocks (gas ratios below 0.1) will be excluded from calculating the median priority fee - gasUsedRatios = [ - 0.01024061496050893, 0.17942918604838942, 0.23114498292513627, 0.0801134637893198, 0.1801134637893198 - ] - blockRewards = [['0xee6b2800'], ['0x3b9aca00'], ['0x77359400'], ['0x77359400'], ['0x3b9aca00']] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x3b9aca00') - }) - - it('considers full blocks if no partial blocks are eligible', async () => { - // full blocks (gas ratios above 0.9) will be considered only if no blocks with a ratio between 0.1 and 0.9 are available - gasUsedRatios = [0.99024061496050893, 0.07942918604838942, 0.03114498292513627, 1, 0.9801134637893198] - blockRewards = [['0xee6b2800'], ['0x3b9aca01'], ['0x3b9aca00'], ['0x77359400'], ['0x3b9aca00']] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x77359400') - }) - - it('considers blocks from the entire sample if none of the last 10 blocks are eligible', async () => { - // index in array represents distance away from current block - gasUsedRatios[11] = 0.12 - gasUsedRatios[15] = 0.99 - gasUsedRatios[18] = 0.02 // this block should be ignored as the ratio is too low - gasUsedRatios[27] = 0.73 - - blockRewards[11] = ['0xee6b2800'] - blockRewards[15] = ['0x3b9aca00'] - blockRewards[18] = ['0x77359400'] // this block should be ignored as the ratio is too low - blockRewards[27] = ['0x9a359400'] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x9a359400') - }) - - it('uses any recent blocks if no blocks in the sample have the qualifying gas ratios', async () => { - gasUsedRatios = [1.09024061496050893, 0.07942918604838942, 0.03114498292513627, 1.1, 0.0801134637893198] - blockRewards = [['0xee6b2800'], ['0x3b9aca01'], ['0x3b9aca00'], ['0x77359400'], ['0x3b9aca00']] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x3b9aca01') - }) - - it('uses any block in the sample if no other blocks are eligible', async () => { - // index in array represents distance away from current block - gasUsedRatios[13] = 0.012 - gasUsedRatios[19] = 1.2239 - gasUsedRatios[26] = 1.122 - gasUsedRatios[28] = 0.073 - - blockRewards[13] = ['0xee6b2800'] - blockRewards[19] = ['0x3b9aca00'] - blockRewards[26] = ['0x77359400'] - blockRewards[28] = ['0x9a359400'] - - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxPriorityFeePerGas).toBe('0x9a359400') - }) - - it('uses the priority fee from the latest block when no eligible blocks are available', async () => { - const gas = new GasCalculator(testConnection) - - const fees = await gas.getFeePerGas() - - expect(fees.maxBaseFeePerGas).toBe('0xe7') - expect(fees.maxPriorityFeePerGas).toBe('0x0') - }) - }) -}) - -// helper functions -function fillEmptySlots(arr, targetLength, value) { - const target = arr.slice() - let i = 0 - - while (i < targetLength) { - target[i] = target[i] || value - i += 1 - } - - return target -} diff --git a/test/main/transaction/gasMonitor.test.js b/test/main/transaction/gasMonitor.test.js new file mode 100644 index 000000000..4c5df33f4 --- /dev/null +++ b/test/main/transaction/gasMonitor.test.js @@ -0,0 +1,119 @@ +import { intToHex } from '@ethereumjs/util' +import GasMonitor from '../../../main/transaction/gasMonitor' +import { gweiToHex } from '../../util' + +let requestHandlers +let testConnection = { + send: jest.fn((payload) => { + if (payload.method in requestHandlers) { + return Promise.resolve(requestHandlers[payload.method](payload.params)) + } + + return Promise.reject('unsupported method: ' + payload.method) + }) +} + +describe('#getGasPrices', () => { + const gasPrice = '0x3baa1028' + + beforeEach(() => { + requestHandlers = { + eth_gasPrice: () => gasPrice + } + }) + + it('sets the slow gas price', async () => { + const monitor = new GasMonitor(testConnection) + + const gas = await monitor.getGasPrices() + + expect(gas.slow).toBe(gasPrice) + }) + + it('sets the standard gas price', async () => { + const monitor = new GasMonitor(testConnection) + + const gas = await monitor.getGasPrices() + + expect(gas.standard).toBe(gasPrice) + }) + + it('sets the fast gas price', async () => { + const monitor = new GasMonitor(testConnection) + + const gas = await monitor.getGasPrices() + + expect(gas.fast).toBe(gasPrice) + }) + + it('sets the asap gas price', async () => { + const monitor = new GasMonitor(testConnection) + + const gas = await monitor.getGasPrices() + + expect(gas.asap).toBe(gasPrice) + }) +}) + +describe('#getFeeHistory', () => { + const nextBlockBaseFee = '0xb6' + + let gasUsedRatios, blockRewards + + beforeEach(() => { + // default to all blocks being ineligible for priority fee calculation + gasUsedRatios = [] + blockRewards = [] + + requestHandlers = { + eth_feeHistory: jest.fn((params) => { + const numBlocks = parseInt(params[0] || '0x', 16) + + return { + // base fees include the requested number of blocks plus the next block + baseFeePerGas: Array(numBlocks).fill('0x8').concat([nextBlockBaseFee]), + gasUsedRatio: fillEmptySlots(gasUsedRatios, numBlocks, 0).reverse(), + oldestBlock: '0x89502f', + reward: fillEmptySlots(blockRewards, numBlocks, ['0x0']).reverse() + } + }) + } + }) + + it('requests the correct percentiles with the eth_feeHistory RPC call', async () => { + const monitor = new GasMonitor(testConnection) + await monitor.getFeeHistory(10, [10, 20, 30]) + expect(requestHandlers['eth_feeHistory']).toBeCalledWith([intToHex(10), 'latest', [10, 20, 30]]) + }) + + it('return the correct number of fee history items', async () => { + const monitor = new GasMonitor(testConnection) + const feeHistory = await monitor.getFeeHistory(1, [10]) + expect(feeHistory.length).toBe(2) + }) + + it('return the correct baseFee for the next block', async () => { + const monitor = new GasMonitor(testConnection) + const feeHistory = await monitor.getFeeHistory(1, [10]) + expect(feeHistory[1].baseFee).toBe(182) + }) + + it('return the correct fee data for historical blocks', async () => { + const monitor = new GasMonitor(testConnection) + const feeHistory = await monitor.getFeeHistory(1, [10]) + expect(feeHistory[0]).toStrictEqual({ baseFee: 8, gasUsedRatio: 0, rewards: [0] }) + }) +}) + +// helper functions +function fillEmptySlots(arr, targetLength, value) { + const target = arr.slice() + let i = 0 + + while (i < targetLength) { + target[i] = target[i] || value + i += 1 + } + + return target +}