Skip to content

Commit bec55dd

Browse files
committed
ethmonitor: fee history approximation
1 parent 3494cf9 commit bec55dd

File tree

3 files changed

+327
-0
lines changed

3 files changed

+327
-0
lines changed

ethmonitor/chain.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
package ethmonitor
22

33
import (
4+
"context"
5+
"errors"
46
"fmt"
7+
"math/big"
8+
"slices"
59
"sync"
610

11+
"github.com/0xsequence/ethkit/go-ethereum"
712
"github.com/0xsequence/ethkit/go-ethereum/common"
813
"github.com/0xsequence/ethkit/go-ethereum/core/types"
14+
"github.com/0xsequence/ethkit/go-ethereum/params"
15+
"github.com/0xsequence/ethkit/go-ethereum/rpc"
916
)
1017

1118
type Chain struct {
@@ -225,6 +232,259 @@ type Block struct {
225232

226233
type Blocks []*Block
227234

235+
const feeHistoryMaxQueryLimit = 101
236+
237+
var (
238+
errInvalidRewardPercentile = errors.New("invalid reward percentile")
239+
errRequestBeyondHeadBlock = errors.New("request beyond head block")
240+
)
241+
242+
// FeeHistory approximates the behaviour of eth_feeHistory.
243+
// It intentionally deviates from standard to avoid fetching transaction receipts.
244+
// It uses transaction gas limits instead of actual gas used, and transaction priority fees instead of effective priority fees.
245+
func (b Blocks) FeeHistory(ctx context.Context, blockCount uint64, lastBlock *big.Int, rewardPercentiles []float64) (*ethereum.FeeHistory, error) {
246+
if ctx != nil {
247+
if err := ctx.Err(); err != nil {
248+
return nil, err
249+
}
250+
}
251+
252+
if blockCount < 1 {
253+
return &ethereum.FeeHistory{OldestBlock: new(big.Int)}, nil
254+
}
255+
256+
if len(rewardPercentiles) > feeHistoryMaxQueryLimit {
257+
return nil, fmt.Errorf("%w: over the query limit %d", errInvalidRewardPercentile, feeHistoryMaxQueryLimit)
258+
}
259+
for i, p := range rewardPercentiles {
260+
if p < 0 || p > 100 {
261+
return nil, fmt.Errorf("%w: %f", errInvalidRewardPercentile, p)
262+
}
263+
if i > 0 && p < rewardPercentiles[i-1] {
264+
return nil, fmt.Errorf("%w: #%d:%f >= #%d:%f", errInvalidRewardPercentile, i-1, rewardPercentiles[i-1], i, p)
265+
}
266+
}
267+
268+
blocksByNumber := make(map[uint64]*Block, len(b))
269+
var (
270+
headNum uint64
271+
tailNum uint64
272+
have bool
273+
)
274+
for _, block := range b {
275+
if block == nil || block.Block == nil || block.Event != Added {
276+
continue
277+
}
278+
num := block.NumberU64()
279+
blocksByNumber[num] = block
280+
if !have || num > headNum {
281+
headNum = num
282+
}
283+
if !have || num < tailNum {
284+
tailNum = num
285+
}
286+
have = true
287+
}
288+
if !have {
289+
return &ethereum.FeeHistory{OldestBlock: new(big.Int)}, nil
290+
}
291+
292+
requestedBlocks := blockCount
293+
var lastNum uint64
294+
switch {
295+
case lastBlock == nil:
296+
lastNum = headNum
297+
case lastBlock.Sign() >= 0:
298+
if !lastBlock.IsUint64() {
299+
return nil, fmt.Errorf("%w: requested %s, head %d", errRequestBeyondHeadBlock, lastBlock.String(), headNum)
300+
}
301+
lastNum = lastBlock.Uint64()
302+
default:
303+
if !lastBlock.IsInt64() {
304+
return nil, fmt.Errorf("invalid block number: %s", lastBlock.String())
305+
}
306+
switch rpc.BlockNumber(lastBlock.Int64()) {
307+
case rpc.PendingBlockNumber:
308+
if requestedBlocks > 0 {
309+
requestedBlocks--
310+
}
311+
lastNum = headNum
312+
case rpc.LatestBlockNumber, rpc.SafeBlockNumber, rpc.FinalizedBlockNumber:
313+
lastNum = headNum
314+
case rpc.EarliestBlockNumber:
315+
lastNum = tailNum
316+
default:
317+
return nil, fmt.Errorf("invalid block number: %s", lastBlock.String())
318+
}
319+
}
320+
321+
if requestedBlocks == 0 {
322+
return &ethereum.FeeHistory{OldestBlock: new(big.Int)}, nil
323+
}
324+
if lastNum > headNum {
325+
return nil, fmt.Errorf("%w: requested %d, head %d", errRequestBeyondHeadBlock, lastNum, headNum)
326+
}
327+
if lastNum < tailNum {
328+
return &ethereum.FeeHistory{OldestBlock: new(big.Int)}, nil
329+
}
330+
331+
maxAvailable := lastNum - tailNum + 1
332+
if requestedBlocks > maxAvailable {
333+
requestedBlocks = maxAvailable
334+
}
335+
if requestedBlocks == 0 {
336+
return &ethereum.FeeHistory{OldestBlock: new(big.Int)}, nil
337+
}
338+
339+
oldestNum := lastNum + 1 - requestedBlocks
340+
finalCount := requestedBlocks
341+
for i := uint64(0); i < requestedBlocks; i++ {
342+
if blocksByNumber[oldestNum+i] == nil {
343+
finalCount = i
344+
break
345+
}
346+
}
347+
if finalCount == 0 {
348+
return &ethereum.FeeHistory{OldestBlock: new(big.Int)}, nil
349+
}
350+
351+
var reward [][]*big.Int
352+
if len(rewardPercentiles) != 0 {
353+
reward = make([][]*big.Int, finalCount)
354+
}
355+
baseFee := make([]*big.Int, finalCount+1)
356+
gasUsedRatio := make([]float64, finalCount)
357+
358+
type txGasAndReward struct {
359+
gasLimit uint64
360+
reward *big.Int
361+
}
362+
363+
for i := uint64(0); i < finalCount; i++ {
364+
block := blocksByNumber[oldestNum+i]
365+
if block == nil {
366+
continue
367+
}
368+
369+
if bf := block.BaseFee(); bf != nil {
370+
baseFee[i] = new(big.Int).Set(bf)
371+
} else {
372+
baseFee[i] = new(big.Int)
373+
}
374+
375+
if gasLimit := block.GasLimit(); gasLimit > 0 {
376+
gasUsedRatio[i] = float64(block.GasUsed()) / float64(gasLimit)
377+
}
378+
379+
if len(rewardPercentiles) == 0 {
380+
continue
381+
}
382+
383+
txs := block.Transactions()
384+
rewards := make([]*big.Int, len(rewardPercentiles))
385+
if len(txs) == 0 {
386+
for j := range rewards {
387+
rewards[j] = new(big.Int)
388+
}
389+
reward[i] = rewards
390+
continue
391+
}
392+
393+
sorter := make([]txGasAndReward, len(txs))
394+
var totalGasLimit uint64
395+
for j, tx := range txs {
396+
gasLimit := tx.Gas()
397+
totalGasLimit += gasLimit
398+
sorter[j] = txGasAndReward{
399+
gasLimit: gasLimit,
400+
reward: tx.GasTipCap(),
401+
}
402+
}
403+
slices.SortStableFunc(sorter, func(a, b txGasAndReward) int {
404+
return a.reward.Cmp(b.reward)
405+
})
406+
407+
var txIndex int
408+
cumulativeGasLimit := sorter[0].gasLimit
409+
410+
for j, p := range rewardPercentiles {
411+
thresholdGasLimit := uint64(float64(totalGasLimit) * p / 100)
412+
for cumulativeGasLimit < thresholdGasLimit && txIndex < len(sorter)-1 {
413+
txIndex++
414+
cumulativeGasLimit += sorter[txIndex].gasLimit
415+
}
416+
rewards[j] = new(big.Int).Set(sorter[txIndex].reward)
417+
}
418+
reward[i] = rewards
419+
}
420+
421+
lastInRange := oldestNum + finalCount - 1
422+
nextBlock := blocksByNumber[lastInRange+1]
423+
switch {
424+
case nextBlock != nil && nextBlock.BaseFee() != nil:
425+
baseFee[finalCount] = new(big.Int).Set(nextBlock.BaseFee())
426+
case blocksByNumber[lastInRange] != nil && blocksByNumber[lastInRange].BaseFee() != nil:
427+
baseFee[finalCount] = calcNextBaseFee(blocksByNumber[lastInRange])
428+
default:
429+
baseFee[finalCount] = new(big.Int)
430+
}
431+
432+
if len(rewardPercentiles) == 0 {
433+
reward = nil
434+
}
435+
436+
return &ethereum.FeeHistory{
437+
OldestBlock: new(big.Int).SetUint64(oldestNum),
438+
Reward: reward,
439+
BaseFee: baseFee,
440+
GasUsedRatio: gasUsedRatio,
441+
}, nil
442+
}
443+
444+
func calcNextBaseFee(parent *Block) *big.Int {
445+
if parent == nil || parent.Block == nil {
446+
return new(big.Int)
447+
}
448+
449+
parentBaseFee := parent.BaseFee()
450+
if parentBaseFee == nil {
451+
return new(big.Int)
452+
}
453+
454+
parentGasTarget := parent.GasLimit() / params.DefaultElasticityMultiplier
455+
if parentGasTarget == 0 {
456+
return new(big.Int).Set(parentBaseFee)
457+
}
458+
459+
if parent.GasUsed() == parentGasTarget {
460+
return new(big.Int).Set(parentBaseFee)
461+
}
462+
463+
num := new(big.Int)
464+
denom := new(big.Int)
465+
466+
if parent.GasUsed() > parentGasTarget {
467+
num.SetUint64(parent.GasUsed() - parentGasTarget)
468+
num.Mul(num, parentBaseFee)
469+
num.Div(num, denom.SetUint64(parentGasTarget))
470+
num.Div(num, denom.SetUint64(params.DefaultBaseFeeChangeDenominator))
471+
if num.Sign() == 0 {
472+
num.SetUint64(1)
473+
}
474+
return num.Add(parentBaseFee, num)
475+
}
476+
477+
num.SetUint64(parentGasTarget - parent.GasUsed())
478+
num.Mul(num, parentBaseFee)
479+
num.Div(num, denom.SetUint64(parentGasTarget))
480+
num.Div(num, denom.SetUint64(params.DefaultBaseFeeChangeDenominator))
481+
baseFee := num.Sub(parentBaseFee, num)
482+
if baseFee.Sign() < 0 {
483+
return new(big.Int)
484+
}
485+
return baseFee
486+
}
487+
228488
func (b Blocks) LatestBlock() *Block {
229489
for i := len(b) - 1; i >= 0; i-- {
230490
if b[i].Event == Added {

ethmonitor/ethmonitor.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,13 @@ func (m *Monitor) GetAverageBlockTime() float64 {
11621162
return m.chain.GetAverageBlockTime()
11631163
}
11641164

1165+
// FeeHistory approximates the behaviour of eth_feeHistory.
1166+
// It intentionally deviates from standard to avoid fetching transaction receipts.
1167+
// It uses transaction gas limits instead of actual gas used, and transaction priority fees instead of effective priority fees.
1168+
func (m *Monitor) FeeHistory(ctx context.Context, blockCount uint64, lastBlock *big.Int, rewardPercentiles []float64) (*ethereum.FeeHistory, error) {
1169+
return m.chain.Blocks().FeeHistory(ctx, blockCount, lastBlock, rewardPercentiles)
1170+
}
1171+
11651172
func (m *Monitor) NumSubscribers() int {
11661173
m.mu.Lock()
11671174
defer m.mu.Unlock()

ethmonitor/ethmonitor_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ethmonitor_test
33
import (
44
"context"
55
"fmt"
6+
"math/big"
67
"net/http"
78
"os"
89
"os/exec"
@@ -14,6 +15,7 @@ import (
1415
"github.com/0xsequence/ethkit/ethrpc"
1516
"github.com/0xsequence/ethkit/go-ethereum/common"
1617
"github.com/0xsequence/ethkit/util"
18+
"github.com/davecgh/go-spew/spew"
1719
"github.com/go-chi/httpvcr"
1820
"github.com/stretchr/testify/assert"
1921
)
@@ -289,3 +291,61 @@ func TestMonitorWithReorgme(t *testing.T) {
289291

290292
monitor.Stop()
291293
}
294+
295+
func TestMonitorFeeHistory(t *testing.T) {
296+
const N = 1
297+
298+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
299+
defer cancel()
300+
301+
provider, err := ethrpc.NewProvider("https://nodes.sequence.app/mainnet")
302+
assert.NoError(t, err)
303+
304+
monitorOptions := ethmonitor.DefaultOptions
305+
monitor, err := ethmonitor.NewMonitor(provider, monitorOptions)
306+
assert.NoError(t, err)
307+
308+
runErr := make(chan error, 1)
309+
go func() {
310+
runErr <- monitor.Run(ctx)
311+
}()
312+
defer monitor.Stop()
313+
314+
sub := monitor.Subscribe("TestMonitorFeeHistory")
315+
defer sub.Unsubscribe()
316+
317+
addedBlocks := 0
318+
for addedBlocks < N {
319+
select {
320+
case blocks := <-sub.Blocks():
321+
for _, b := range blocks {
322+
if b.Event == ethmonitor.Added {
323+
addedBlocks++
324+
}
325+
}
326+
case err := <-runErr:
327+
if err != nil {
328+
t.Fatalf("monitor stopped early: %v", err)
329+
}
330+
t.Fatalf("monitor stopped early without error")
331+
case <-ctx.Done():
332+
t.Fatalf("timeout waiting for %v blocks: %v", N, ctx.Err())
333+
}
334+
}
335+
336+
blocks := monitor.Chain().Blocks()
337+
if len(blocks) < N {
338+
t.Fatalf("expected at least %v blocks, got %v", N, len(blocks))
339+
}
340+
341+
lastBlock := new(big.Int).Set(blocks[len(blocks)-1].Number())
342+
rewardPercentiles := []float64{0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
343+
344+
localFeeHistory, err := blocks.FeeHistory(ctx, N, lastBlock, rewardPercentiles)
345+
assert.NoError(t, err)
346+
spew.Dump("ethmonitor.FeeHistory", localFeeHistory)
347+
348+
rpcFeeHistory, err := provider.FeeHistory(ctx, N, lastBlock, rewardPercentiles)
349+
assert.NoError(t, err)
350+
spew.Dump("eth_feeHistory", rpcFeeHistory)
351+
}

0 commit comments

Comments
 (0)