Skip to content

Commit

Permalink
feat: implement eth_feeHistory endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
ptrus committed Jan 5, 2024
1 parent 07081eb commit 09f6075
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 3 deletions.
177 changes: 175 additions & 2 deletions gas/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ package gas
import (
"context"
"math"
"math/big"
"slices"
"sync"
"time"

"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/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"

Expand All @@ -25,19 +30,33 @@ var (
metricComputedPrice = promauto.NewGauge(prometheus.GaugeOpts{Name: "oasis_oasis_web3_gateway_gas_oracle_computed_price", Help: "Computed recommended gas price based on recent full blocks. -1 if none (no recent full blocks)."})
)

// 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"`
}

// Backend is the gas price oracle backend.
type Backend interface {
service.BackgroundService

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

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

const (
// windowSize is the number of recent blocks to use for calculating min gas price.
// NOTE: code assumes that this is relatively small.
windowSize = 12

// feeHistoryWindowSize is the number of recent blocks to store for the fee history query.
feeHistoryWindowSize = 20

// fullBlockThreshold is the percentage of block used gas over which a block should
// be considered full.
fullBlockThreshold = 0.8
Expand All @@ -57,6 +76,53 @@ var (
defaultGasPrice = *quantity.NewFromUint64(100_000_000_000) // 100 "gwei".
)

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

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

// Sorted list of transaction gas prices and rewards. This is used to
// compute the provided percentiles.
sortedTxs []*txGasAndReward
}

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

if len(percentiles) == 0 {
// No percentiles requested.
return rewards
}
if len(d.sortedTxs) == 0 {
// All zeros if there are no transactions in the block.
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
}

// gasPriceOracle implements the gas price backend by looking at transaction costs in previous blocks.
//
// The gas price oracle does roughly:
Expand Down Expand Up @@ -89,6 +155,12 @@ type gasPriceOracle struct {
// tracks the current index of the blockPrices rolling array.:w
blockPricesCurrentIdx int

// protects feeHistoryData.
feeHistoryLock sync.RWMutex
// feeHistoryData contains the data needed to compute the fee history for the
// last `feeHistoryWindowSize` blocks.
feeHistoryData []*feeHistoryData

blockWatcher indexer.BlockWatcher
coreClient core.V1
}
Expand All @@ -100,6 +172,7 @@ func New(ctx context.Context, blockWatcher indexer.BlockWatcher, coreClient core
ctx: ctxB,
cancelCtx: cancelCtx,
blockPrices: make([]*quantity.Quantity, 0, windowSize),
feeHistoryData: make([]*feeHistoryData, 0, feeHistoryWindowSize),
blockWatcher: blockWatcher,
coreClient: coreClient,
}
Expand Down Expand Up @@ -149,6 +222,63 @@ func (g *gasPriceOracle) GasPrice() *hexutil.Big {
return &price
}

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

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

var lastBlockIdx int
switch lastBlock {
case rpc.PendingBlockNumber, rpc.LatestBlockNumber, rpc.FinalizedBlockNumber, rpc.SafeBlockNumber:
// Take the 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)}
}
}

// Compute the oldest block to return.
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) nodeMinGasPriceFetcher() {
for {
// Fetch and update min gas price from the node.
Expand Down Expand Up @@ -193,12 +323,13 @@ func (g *gasPriceOracle) indexedBlockWatcher() {
case <-g.ctx.Done():
return
case blk := <-ch:
g.onBlock(blk.Block, blk.LastTransactionPrice)
g.trackMinPrice(blk.Block, blk.LastTransactionPrice)
g.trackFeeHistory(blk.Block, blk.UniqueTxes, blk.Receipts)
}
}
}

func (g *gasPriceOracle) onBlock(b *model.Block, lastTxPrice *quantity.Quantity) {
func (g *gasPriceOracle) trackMinPrice(b *model.Block, lastTxPrice *quantity.Quantity) {
// Consider block full if block gas used is greater than `fullBlockThreshold` of gas limit.
blockFull := (float64(b.Header.GasLimit) * fullBlockThreshold) <= float64(b.Header.GasUsed)
if !blockFull {
Expand Down Expand Up @@ -255,3 +386,45 @@ func (g *gasPriceOracle) trackPrice(price *quantity.Quantity) {
}
g.blockPrices[g.blockPricesCurrentIdx] = price
}

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 := &feeHistoryData{
height: block.Round,
// Base fee is always zero.
// https://github.com/oasisprotocol/oasis-sdk/blob/da9d86d52abca27930ec4e63c7735ca30f2f16b8/runtime-sdk/modules/evm/src/raw_tx.rs#L102-L105
baseFee: (*hexutil.Big)(common.Big0),
gasUsed: block.Header.GasUsed,
gasUsedRatio: float64(block.Header.GasUsed) / float64(block.Header.GasLimit),
sortedTxs: make([]*txGasAndReward, len(receipts)),
}
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) == feeHistoryWindowSize {
g.feeHistoryData = g.feeHistoryData[1:]
}
g.feeHistoryData = append(g.feeHistoryData, d)
}
8 changes: 8 additions & 0 deletions gas/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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(fh.OldestBlock, 0, "fee history should be empty")
require.Empty(fh.GasUsedRatio, 0, "fee history should be empty")
require.Empty(fh.Reward, 0, "fee history should be empty")

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

Expand Down Expand Up @@ -169,4 +174,7 @@ func TestGasPriceOracle(t *testing.T) {

require.EqualValues(coreClient.minGasPrice.ToBigInt(), gasPriceOracle.GasPrice(), "oracle should return gas reported by the node query")
gasPriceOracle.Stop()

// Fee history should return zeroes (no transactions in the blocks).
// TODO: emit blocks with transactions, to test fee history.
}
27 changes: 27 additions & 0 deletions rpc/eth/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/common/math"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/eth/filters"
"github.com/ethereum/go-ethereum/rlp"
Expand Down Expand Up @@ -43,6 +44,7 @@ var (
ErrMalformedTransaction = errors.New("malformed transaction")
ErrMalformedBlockNumber = errors.New("malformed blocknumber")
ErrInvalidRequest = errors.New("invalid request")
ErrInvalidPercentile = errors.New("invalid reward percentile")

// estimateGasSigSpec is a dummy signature spec used by the estimate gas method, as
// otherwise transactions without signature would be underestimated.
Expand All @@ -63,6 +65,8 @@ type API interface {
ChainId() (*hexutil.Big, error)
// GasPrice returns a suggestion for a gas price for legacy transactions.
GasPrice(ctx context.Context) (*hexutil.Big, error)
// FeeHistory returns the transaction base fee per gas and effective priority fee per gas for the requested/supported block range.
FeeHistory(ctx context.Context, blockCount math.HexOrDecimal64, lastBlock ethrpc.BlockNumber, rewardPercentiles []float64) (*gas.FeeHistoryResult, error)
// GetBlockTransactionCountByHash returns the number of transactions in the block identified by hash.
GetBlockTransactionCountByHash(ctx context.Context, blockHash common.Hash) (hexutil.Uint, error)
// GetTransactionCount returns the number of transactions the given address has sent for the given block number.
Expand Down Expand Up @@ -291,6 +295,29 @@ func (api *publicAPI) GasPrice(_ context.Context) (*hexutil.Big, error) {
return api.gasPriceOracle.GasPrice(), nil
}

func (api *publicAPI) FeeHistory(_ context.Context, blockCount math.HexOrDecimal64, lastBlock ethrpc.BlockNumber, rewardPercentiles []float64) (*gas.FeeHistoryResult, error) {
logger := api.Logger.With("method", "eth_feeHistory", "block_count", blockCount, "last_block", lastBlock, "reward_percentiles", rewardPercentiles)
logger.Debug("request")

// Validate blockCount.
if blockCount < 1 {
// Returning with no data and no error means there are no retrievable blocks.
return &gas.FeeHistoryResult{OldestBlock: (*hexutil.Big)(common.Big0)}, nil
}

// Validate reward percentiles.
for i, p := range rewardPercentiles {
if p < 0 || p > 100 {
return nil, fmt.Errorf("%w: %f", ErrInvalidPercentile, p)
}
if i > 0 && p < rewardPercentiles[i-1] {
return nil, fmt.Errorf("%w: #%d:%f > #%d:%f", ErrInvalidPercentile, i-1, rewardPercentiles[i-1], i, p)
}
}

return api.gasPriceOracle.FeeHistory(uint64(blockCount), lastBlock, rewardPercentiles), nil
}

func (api *publicAPI) GetBlockTransactionCountByHash(ctx context.Context, blockHash common.Hash) (hexutil.Uint, error) {
logger := api.Logger.With("method", "eth_getBlockTransactionCountByHash", "block_hash", blockHash.Hex())
logger.Debug("request")
Expand Down
11 changes: 11 additions & 0 deletions rpc/eth/metrics/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/common/math"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/eth/filters"
ethrpc "github.com/ethereum/go-ethereum/rpc"
Expand All @@ -14,6 +15,7 @@ import (

"github.com/oasisprotocol/oasis-core/go/common/logging"

"github.com/oasisprotocol/oasis-web3-gateway/gas"
"github.com/oasisprotocol/oasis-web3-gateway/indexer"
"github.com/oasisprotocol/oasis-web3-gateway/rpc/eth"
"github.com/oasisprotocol/oasis-web3-gateway/rpc/metrics"
Expand Down Expand Up @@ -146,6 +148,15 @@ func (m *metricsWrapper) GasPrice(ctx context.Context) (res *hexutil.Big, err er
return
}

// FeeHistory implements eth.API.
func (m *metricsWrapper) FeeHistory(ctx context.Context, blockCount math.HexOrDecimal64, lastBlock ethrpc.BlockNumber, rewardPercentiles []float64) (res *gas.FeeHistoryResult, err error) {
r, s, f, i, d := metrics.GetAPIMethodMetrics("eth_feeHistory")
defer metrics.InstrumentCaller(r, s, f, i, d, &err)()

res, err = m.api.FeeHistory(ctx, blockCount, lastBlock, rewardPercentiles)
return
}

// GetBalance implements eth.API.
func (m *metricsWrapper) GetBalance(ctx context.Context, address common.Address, blockNrOrHash ethrpc.BlockNumberOrHash) (res *hexutil.Big, err error) {
r, s, f, i, d := metrics.GetAPIMethodMetrics("eth_getBalance")
Expand Down
2 changes: 1 addition & 1 deletion rpc/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func GetAPIMethodMetrics(method string) (prometheus.Counter, prometheus.Counter,

// InstrumentCaller instruments the caller method.
//
// Use InstrumentCaller is usually used the following way:
// The InstrumentCaller should be used the following way:
//
// func InstrumentMe() (err error) {
// r, s, f, i, d := metrics.GetAPIMethodMetrics("method")
Expand Down
9 changes: 9 additions & 0 deletions tests/rpc/rpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ func TestEth_GasPrice(t *testing.T) {
t.Logf("gas price: %v", price)
}

func TestEth_FeeHistory(t *testing.T) {
ec := localClient(t, false)

feeHistory, err := ec.FeeHistory(context.Background(), 10, nil, []float64{0.25, 0.5, 0.75, 1})
require.NoError(t, err, "get fee history")

t.Logf("fee history: %v", feeHistory)
}

// TestEth_SendRawTransaction post eth raw transaction with ethclient from go-ethereum.
func TestEth_SendRawTransaction(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), OasisBlockTimeout)
Expand Down

0 comments on commit 09f6075

Please sign in to comment.