Skip to content

Commit

Permalink
Merge pull request #502 from oasisprotocol/ptrus/feature/fee-history
Browse files Browse the repository at this point in the history
feat: implement eth_feeHistory endpoint
  • Loading branch information
ptrus authored Nov 20, 2024
2 parents dafd87a + 1ca1883 commit ae0573a
Show file tree
Hide file tree
Showing 13 changed files with 372 additions and 57 deletions.
2 changes: 2 additions & 0 deletions conf/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ type GasConfig struct {
WindowSize uint64 `koanf:"window_size"`
// ComputedPriceMargin is the gas price to add to the computed gas price.
ComputedPriceMargin uint64 `koanf:"computed_price_margin"`
// FeeHistorySize is the number of recent blocks to store for the fee history query.
FeeHistorySize uint64 `koanf:"fee_history_size"`
}

// Validate validates the gas configuration.
Expand Down
85 changes: 46 additions & 39 deletions gas/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/rpc"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"

Expand All @@ -21,7 +22,7 @@ import (
)

var (
metricNodeMinPrice = promauto.NewGauge(prometheus.GaugeOpts{Name: "oasis_web3_gateway_gas_orcale_node_min_price", Help: "Min gas price periodically queried from the node."})
metricNodeMinPrice = promauto.NewGauge(prometheus.GaugeOpts{Name: "oasis_web3_gateway_gas_oracle_node_min_price", Help: "Min gas price queried from the node."})
metricComputedPrice = promauto.NewGauge(prometheus.GaugeOpts{Name: "oasis_web3_gateway_gas_oracle_computed_price", Help: "Computed recommended gas price based on recent full blocks. -1 if none (no recent full blocks)."})
)

Expand All @@ -31,6 +32,9 @@ type Backend interface {

// GasPrice returns the currently recommended minimum gas price.
GasPrice() *hexutil.Big

// FeeHistory returns the fee history of the last blocks and percentiles.
FeeHistory(blockCount uint64, lastBlock rpc.BlockNumber, percentiles []float64) *FeeHistoryResult
}

const (
Expand Down Expand Up @@ -94,6 +98,12 @@ type gasPriceOracle struct {
// tracks the current index of the blockPrices rolling array.:w
blockPricesCurrentIdx int

// protects feeHistoryData.
feeHistoryLock sync.RWMutex
// feeHistoryData contains the per block data needed to compute the fee history.
feeHistoryData []*feeHistoryBlockData
feeHistorySize uint64

// Configuration parameters.
windowSize uint64
fullBlockThreshold float64
Expand All @@ -111,7 +121,8 @@ func New(ctx context.Context, cfg *conf.GasConfig, blockWatcher indexer.BlockWat
blockFullThreshold := defaultFullBlockThreshold
minGasPrice := defaultGasPrice
computedPriceMargin := defaultComputedPriceMargin
if cfg != nil {
feeHistorySize := defaultFeeHistorySize
if cfg != nil { // nolint: nestif
if cfg.WindowSize != 0 {
windowSize = cfg.WindowSize
}
Expand All @@ -124,13 +135,17 @@ func New(ctx context.Context, cfg *conf.GasConfig, blockWatcher indexer.BlockWat
if cfg.ComputedPriceMargin != 0 {
computedPriceMargin = *quantity.NewFromUint64(cfg.ComputedPriceMargin)
}
if cfg.FeeHistorySize != 0 {
feeHistorySize = cfg.FeeHistorySize
}
}

g := &gasPriceOracle{
BaseBackgroundService: *service.NewBaseBackgroundService("gas-price-oracle"),
ctx: ctxB,
cancelCtx: cancelCtx,
blockPrices: make([]*quantity.Quantity, 0, windowSize),
feeHistoryData: make([]*feeHistoryBlockData, 0, feeHistorySize),
feeHistorySize: feeHistorySize,
windowSize: windowSize,
fullBlockThreshold: blockFullThreshold,
defaultGasPrice: minGasPrice,
Expand All @@ -139,6 +154,11 @@ func New(ctx context.Context, cfg *conf.GasConfig, blockWatcher indexer.BlockWat
coreClient: coreClient,
}

g.blockPrices = make([]*quantity.Quantity, windowSize)
for i := range windowSize {
g.blockPrices[i] = quantity.NewQuantity()
}

return g
}

Expand All @@ -154,6 +174,7 @@ func (g *gasPriceOracle) Stop() {
g.cancelCtx()
}

// Implements Backend.
func (g *gasPriceOracle) GasPrice() *hexutil.Big {
g.priceLock.RLock()
defer g.priceLock.RUnlock()
Expand Down Expand Up @@ -231,6 +252,8 @@ func (g *gasPriceOracle) indexedBlockWatcher() {

// Track price for the block.
g.onBlock(blk.Block, blk.MedianTransactionGasPrice)
// Track fee history.
g.trackFeeHistory(blk.Block, blk.UniqueTxes, blk.Receipts)
}
}
}
Expand All @@ -240,58 +263,42 @@ func (g *gasPriceOracle) onBlock(b *model.Block, medTxPrice *quantity.Quantity)
blockFull := (float64(b.Header.GasLimit) * g.fullBlockThreshold) <= float64(b.Header.GasUsed)
if !blockFull {
// Track 0 for non-full blocks.
g.trackPrice(quantity.NewFromUint64(0))
g.trackPrice(quantity.NewQuantity())
return
}

if medTxPrice == nil {
g.Logger.Error("no med tx gas price for block", "block", b)
return
}

trackPrice := medTxPrice.Clone()
if err := trackPrice.Add(&g.computedPriceMargin); err != nil {
g.Logger.Error("failed to add minPriceEps to medTxPrice", "err", err)
}

g.trackPrice(trackPrice)
}

func (g *gasPriceOracle) trackPrice(price *quantity.Quantity) {
// One item always gets added added to the prices array.
// Bump the current index for next iteration.
defer func() {
g.blockPricesCurrentIdx = (g.blockPricesCurrentIdx + 1) % int(g.windowSize)
}()

// Recalculate the maximum median-price over the block window.
defer func() {
// Find maximum gas price.
maxPrice := quantity.NewFromUint64(0)
for _, price := range g.blockPrices {
if price.Cmp(maxPrice) > 0 {
maxPrice = price
}
}

// No full blocks among last `windowSize` blocks.
if maxPrice.IsZero() {
g.priceLock.Lock()
g.computedGasPrice = nil
g.priceLock.Unlock()
metricComputedPrice.Set(float64(-1))
g.blockPrices[g.blockPricesCurrentIdx] = price
g.blockPricesCurrentIdx = (g.blockPricesCurrentIdx + 1) % int(g.windowSize)

return
// Find maximum gas price.
maxPrice := quantity.NewQuantity()
for _, price := range g.blockPrices {
if price.Cmp(maxPrice) > 0 {
maxPrice = price
}
}

g.priceLock.Lock()
g.computedGasPrice = maxPrice
g.priceLock.Unlock()
metricComputedPrice.Set(float64(maxPrice.ToBigInt().Int64()))
}()

if len(g.blockPrices) < int(g.windowSize) {
g.blockPrices = append(g.blockPrices, price)
return
reportedPrice := float64(maxPrice.ToBigInt().Int64())
// No full blocks among last `windowSize` blocks.
if maxPrice.IsZero() {
maxPrice = nil
reportedPrice = float64(-1)
}
g.blockPrices[g.blockPricesCurrentIdx] = price

g.priceLock.Lock()
g.computedGasPrice = maxPrice
g.priceLock.Unlock()
metricComputedPrice.Set(reportedPrice)
}
7 changes: 6 additions & 1 deletion gas/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ func TestGasPriceOracle(t *testing.T) {
// Default gas price should be returned by the oracle.
require.EqualValues(defaultGasPrice.ToBigInt(), gasPriceOracle.GasPrice(), "oracle should return default gas price")

fh := gasPriceOracle.FeeHistory(10, 10, []float64{0.25, 0.5})
require.EqualValues(0, fh.OldestBlock.ToInt().Int64(), "fee history should be empty")
require.Empty(0, fh.GasUsedRatio, "fee history should be empty")
require.Empty(0, fh.Reward, "fee history should be empty")

// Emit a non-full block.
emitBlock(&emitter, false, nil)

Expand All @@ -136,7 +141,7 @@ func TestGasPriceOracle(t *testing.T) {
emitBlock(&emitter, true, quantity.NewFromUint64(1_000_000_000_000)) // 1000 gwei.

// 1001 gwei should be returned.
require.EqualValues(big.NewInt(1_001_000_000_000), gasPriceOracle.GasPrice(), "oracle should return correct gas price")
require.EqualValues(big.NewInt(1_001_000_000_000), gasPriceOracle.GasPrice().ToInt(), "oracle should return correct gas price")

// Emit a non-full block.
emitBlock(&emitter, false, nil)
Expand Down
181 changes: 181 additions & 0 deletions gas/fee_history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package gas

import (
"math/big"
"slices"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
ethMath "github.com/ethereum/go-ethereum/common/math"
"github.com/ethereum/go-ethereum/rpc"

"github.com/oasisprotocol/oasis-web3-gateway/db/model"
)

// defaultFeeHistorySize is the default number of recent blocks to store for the fee history query.
const defaultFeeHistorySize uint64 = 20

// FeeHistoryResult is the result of a fee history query.
type FeeHistoryResult struct {
OldestBlock *hexutil.Big `json:"oldestBlock"`
Reward [][]*hexutil.Big `json:"reward,omitempty"`
BaseFee []*hexutil.Big `json:"baseFeePerGas,omitempty"`
GasUsedRatio []float64 `json:"gasUsedRatio"`
}

type txGasAndReward struct {
gasUsed uint64
reward *hexutil.Big
}

type feeHistoryBlockData struct {
height uint64
baseFee *hexutil.Big
gasUsedRatio float64
gasUsed uint64

// Sorted list of transaction by reward. This is used to
// compute the queried percentiles in the fee history query.
sortedTxs []*txGasAndReward
}

// rewards computes the queried reward percentiles for the given block data.
func (d *feeHistoryBlockData) rewards(percentiles []float64) []*hexutil.Big {
rewards := make([]*hexutil.Big, len(percentiles))

// No percentiles requested.
if len(percentiles) == 0 {
return rewards
}

// No transactions in the block.
if len(d.sortedTxs) == 0 {
for i := range rewards {
rewards[i] = (*hexutil.Big)(common.Big0)
}
return rewards
}

// Compute the requested percentiles.
var txIndex int
sumGasUsed := d.sortedTxs[0].gasUsed
for i, p := range percentiles {
thresholdGasUsed := uint64(float64(d.gasUsed) * p / 100)
for sumGasUsed < thresholdGasUsed && txIndex < len(d.sortedTxs)-1 {
txIndex++
sumGasUsed += d.sortedTxs[txIndex].gasUsed
}
rewards[i] = d.sortedTxs[txIndex].reward
}

return rewards
}

// Implements Backend.
func (g *gasPriceOracle) FeeHistory(blockCount uint64, lastBlock rpc.BlockNumber, percentiles []float64) *FeeHistoryResult {
g.feeHistoryLock.RLock()
defer g.feeHistoryLock.RUnlock()

// No history available.
if len(g.feeHistoryData) == 0 {
return &FeeHistoryResult{OldestBlock: (*hexutil.Big)(common.Big0)}
}

// Find the latest block index.
var lastBlockIdx int
switch lastBlock {
case rpc.PendingBlockNumber, rpc.LatestBlockNumber, rpc.FinalizedBlockNumber, rpc.SafeBlockNumber:
// Latest available block.
lastBlockIdx = len(g.feeHistoryData) - 1
case rpc.EarliestBlockNumber:
// Doesn't make sense to start at earliest block.
return &FeeHistoryResult{OldestBlock: (*hexutil.Big)(common.Big0)}
default:
// Check if the requested block number is available.
var found bool
for i, d := range g.feeHistoryData {
if d.height == uint64(lastBlock) {
lastBlockIdx = i
found = true
break
}
}
// Data for requested block number not available.
if !found {
return &FeeHistoryResult{OldestBlock: (*hexutil.Big)(common.Big0)}
}
}

// Find the oldest block index.
var oldestBlockIdx int
if blockCount > uint64(lastBlockIdx) {
// Not enough blocks available, return all available blocks.
oldestBlockIdx = 0
} else {
oldestBlockIdx = lastBlockIdx + 1 - int(blockCount)
}

// Return the requested fee history.
result := &FeeHistoryResult{
OldestBlock: (*hexutil.Big)(big.NewInt(int64(g.feeHistoryData[oldestBlockIdx].height))),
Reward: make([][]*hexutil.Big, lastBlockIdx-oldestBlockIdx+1),
BaseFee: make([]*hexutil.Big, lastBlockIdx-oldestBlockIdx+1),
GasUsedRatio: make([]float64, lastBlockIdx-oldestBlockIdx+1),
}
for i := oldestBlockIdx; i <= lastBlockIdx; i++ {
result.Reward[i-oldestBlockIdx] = g.feeHistoryData[i].rewards(percentiles)
result.BaseFee[i-oldestBlockIdx] = g.feeHistoryData[i].baseFee
result.GasUsedRatio[i-oldestBlockIdx] = g.feeHistoryData[i].gasUsedRatio
}

return result
}

func (g *gasPriceOracle) trackFeeHistory(block *model.Block, txs []*model.Transaction, receipts []*model.Receipt) {
// TODO: could populate old blocks on first received block (if available).

d := &feeHistoryBlockData{
height: block.Round,
gasUsed: block.Header.GasUsed,
gasUsedRatio: float64(block.Header.GasUsed) / float64(block.Header.GasLimit),
sortedTxs: make([]*txGasAndReward, len(receipts)),
}

// Base fee.
var baseFee big.Int
if err := baseFee.UnmarshalText([]byte(block.Header.BaseFee)); err != nil {
g.Logger.Error("unmarshal base fee", "base_fee", block.Header.BaseFee, "block", block, "err", err)
return
}
d.baseFee = (*hexutil.Big)(&baseFee)

// Transactions.
for i, tx := range txs {
var tipGas, feeCap big.Int
if err := feeCap.UnmarshalText([]byte(tx.GasFeeCap)); err != nil {
g.Logger.Error("unmarshal gas fee cap", "fee_cap", tx.GasFeeCap, "block", block, "tx", tx, "err", err)
return
}
if err := tipGas.UnmarshalText([]byte(tx.GasTipCap)); err != nil {
g.Logger.Error("unmarshal gas tip cap", "tip_cap", tx.GasTipCap, "block", block, "tx", tx, "err", err)
return
}

d.sortedTxs[i] = &txGasAndReward{
gasUsed: receipts[i].GasUsed,
reward: (*hexutil.Big)(ethMath.BigMin(&tipGas, &feeCap)),
}
}
slices.SortStableFunc(d.sortedTxs, func(a, b *txGasAndReward) int {
return a.reward.ToInt().Cmp(b.reward.ToInt())
})

// Add new data to the history.
g.feeHistoryLock.Lock()
defer g.feeHistoryLock.Unlock()
// Delete oldest entry if we are at capacity.
if len(g.feeHistoryData) == int(g.feeHistorySize) {
g.feeHistoryData = g.feeHistoryData[1:]
}
g.feeHistoryData = append(g.feeHistoryData, d)
}
Loading

0 comments on commit ae0573a

Please sign in to comment.