Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gas): gas price oracle improvements #519

Merged
merged 1 commit into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions conf/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type Config struct {
Database *DatabaseConfig `koanf:"database"`
Gateway *GatewayConfig `koanf:"gateway"`

Gas *GasConfig `koanf:"gas"`

// ArchiveURI is the URI of an archival web3 gateway instance
// for servicing historical queries.
ArchiveURI string `koanf:"archive_uri"`
Expand Down Expand Up @@ -67,6 +69,11 @@ func (cfg *Config) Validate() error {
return fmt.Errorf("gateway: %w", err)
}
}
if cfg.Gas != nil {
if err := cfg.Gas.Validate(); err != nil {
return fmt.Errorf("gas: %w", err)
}
}

return nil
}
Expand Down Expand Up @@ -226,6 +233,26 @@ type MethodLimits struct {
GetLogsMaxRounds uint64 `koanf:"get_logs_max_rounds"`
}

// GasConfig is the gas price oracle configuration.
type GasConfig struct {
// MinGasPrice is the minimum gas price to accept for transactions.
MinGasPrice uint64 `koanf:"min_gas_price"`
// BlockFullThreshold is the percentage block gas used threshold to consider a block full.
BlockFullThreshold float64 `koanf:"block_full_threshold"`
// WindowSize is the number of past blocks to consider for gas price computation.
WindowSize uint64 `koanf:"window_size"`
// ComputedPriceMargin is the gas price to add to the computed gas price.
ComputedPriceMargin uint64 `koanf:"computed_price_margin"`
}

// Validate validates the gas configuration.
func (cfg *GasConfig) Validate() error {
if cfg.BlockFullThreshold < 0 || cfg.BlockFullThreshold > 1 {
return fmt.Errorf("block full threshold must be in range [0, 1]")
}
return nil
}

// InitConfig initializes configuration from file.
func InitConfig(f string) (*Config, error) {
var config Config
Expand Down
124 changes: 76 additions & 48 deletions gas/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package gas

import (
"context"
"math"
"sync"
"time"

Expand All @@ -16,6 +15,7 @@ import (
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/core"
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/types"

"github.com/oasisprotocol/oasis-web3-gateway/conf"
"github.com/oasisprotocol/oasis-web3-gateway/db/model"
"github.com/oasisprotocol/oasis-web3-gateway/indexer"
)
Expand All @@ -34,18 +34,18 @@ type Backend interface {
}

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

// fullBlockThreshold is the percentage of block used gas over which a block should
// be considered full.
fullBlockThreshold = 0.8
// defaultFullBlockThreshold is the default percentage of block used gas over which a block
// should be considered full.
defaultFullBlockThreshold float64 = 0.5
)

var (
// minPriceEps is a constant added to the cheapest transaction executed in last windowSize blocks.
minPriceEps = *quantity.NewFromUint64(1_000_000_000) // 1 "gwei".
// defaultComputedPriceMargin is the default constant added to the median transaction gas price.
defaultComputedPriceMargin = *quantity.NewFromUint64(1_000_000_000) // 1 "gwei".

// defaultGasPrice is the default gas price reported if no better estimate can be returned.
//
Expand All @@ -60,14 +60,14 @@ var (
// (a) Compute the recommended gas price based on recent blocks:
// - look at the most recent block(s) (controlled by `windowSize` parameter)
// - if block gas used is greater than some threshold, consider it "full" (controlled by `fullBlockThresholdParameter`)
// - set recommended gas price to the lowest-priced transaction from the full blocks + a small constant (controlled by `minPriceEps`)
// - set recommended gas price to the maximum of the median-priced transactions from recent full blocks + a small constant (controlled by `computedPriceMargin`)
//
// This handles the case when most blocks are full and the cheapest transactions are higher than the configured min gas price.
// This can be the case if dynamic min gas price is disabled for the runtime, or the transactions are increasing in price faster
// than the dynamic gas price adjustments.
//
// (b) Query gas price configured on the oasis-node:
// - after every block, query the oasis-node for it's configured gas price
// - after every block, query the oasis-node for its configured gas price
//
// This handles the case when min gas price is higher than the heuristically computed price in (a).
// This can happen if node has overridden the min gas price configuration or if dynamic min gas price is enabled and
Expand All @@ -85,26 +85,56 @@ type gasPriceOracle struct {
// nodeMinGasPrice is the minimum gas price as reported by the oasis node.
// This is queried from the node and updated periodically.
nodeMinGasPrice *quantity.Quantity
// computedMinGasPrice is the computed min gas price by observing recent blocks.
computedMinGasPrice *quantity.Quantity
// computedGasPrice is the computed suggested gas price by observing recent blocks.
computedGasPrice *quantity.Quantity

// blockPrices is a rolling-array containing minimum transaction prices for
// last up to `windowSize` blocks.
blockPrices []*quantity.Quantity
// tracks the current index of the blockPrices rolling array.:w
blockPricesCurrentIdx int

// Configuration parameters.
windowSize uint64
fullBlockThreshold float64
defaultGasPrice quantity.Quantity
computedPriceMargin quantity.Quantity

blockWatcher indexer.BlockWatcher
coreClient core.V1
}

func New(ctx context.Context, blockWatcher indexer.BlockWatcher, coreClient core.V1) Backend {
func New(ctx context.Context, cfg *conf.GasConfig, blockWatcher indexer.BlockWatcher, coreClient core.V1) Backend {
ctxB, cancelCtx := context.WithCancel(ctx)

windowSize := defaultWindowSize
blockFullThreshold := defaultFullBlockThreshold
minGasPrice := defaultGasPrice
computedPriceMargin := defaultComputedPriceMargin
if cfg != nil {
if cfg.WindowSize != 0 {
windowSize = cfg.WindowSize
}
if cfg.BlockFullThreshold != 0 {
blockFullThreshold = cfg.BlockFullThreshold
}
if cfg.MinGasPrice != 0 {
minGasPrice = *quantity.NewFromUint64(cfg.MinGasPrice)
}
if cfg.ComputedPriceMargin != 0 {
computedPriceMargin = *quantity.NewFromUint64(cfg.ComputedPriceMargin)
}
}

g := &gasPriceOracle{
BaseBackgroundService: *service.NewBaseBackgroundService("gas-price-oracle"),
ctx: ctxB,
cancelCtx: cancelCtx,
blockPrices: make([]*quantity.Quantity, 0, windowSize),
windowSize: windowSize,
fullBlockThreshold: blockFullThreshold,
defaultGasPrice: minGasPrice,
computedPriceMargin: computedPriceMargin,
blockWatcher: blockWatcher,
coreClient: coreClient,
}
Expand All @@ -128,28 +158,23 @@ func (g *gasPriceOracle) GasPrice() *hexutil.Big {
g.priceLock.RLock()
defer g.priceLock.RUnlock()

if g.computedMinGasPrice == nil && g.nodeMinGasPrice == nil {
if g.computedGasPrice == nil && g.nodeMinGasPrice == nil {
// No blocks tracked yet and no min gas price from the node,
// default to a default value.
price := hexutil.Big(*defaultGasPrice.ToBigInt())
price := hexutil.Big(*g.defaultGasPrice.ToBigInt())
return &price
}

// Set minPrice to the larger of the `nodeMinGasPrice` and `computedMinGasPrice`.
minPrice := quantity.NewQuantity()
// Set maxPrice to the larger of the `nodeMinGasPrice` and `computedGasPrice`.
maxPrice := quantity.NewQuantity()
if g.nodeMinGasPrice != nil {
minPrice = g.nodeMinGasPrice.Clone()
maxPrice = g.nodeMinGasPrice.Clone()
}
if g.computedMinGasPrice != nil && g.computedMinGasPrice.Cmp(minPrice) > 0 {
minPrice = g.computedMinGasPrice.Clone()
// Add small constant to the min price.
if err := minPrice.Add(&minPriceEps); err != nil {
g.Logger.Error("failed to add minPriceEps to minPrice", "err", err, "min_price", minPrice, "min_price_eps", minPriceEps)
minPrice = &defaultGasPrice
}
if g.computedGasPrice != nil && g.computedGasPrice.Cmp(maxPrice) > 0 {
maxPrice = g.computedGasPrice.Clone()
}

price := hexutil.Big(*minPrice.ToBigInt())
price := hexutil.Big(*maxPrice.ToBigInt())
return &price
}

Expand All @@ -172,7 +197,7 @@ func (g *gasPriceOracle) fetchMinGasPrice(ctx context.Context) {
}

func (g *gasPriceOracle) indexedBlockWatcher() {
ch, sub, err := g.blockWatcher.WatchBlocks(g.ctx, windowSize)
ch, sub, err := g.blockWatcher.WatchBlocks(g.ctx, int64(g.windowSize))
if err != nil {
g.Logger.Error("indexed block watcher failed to watch blocks", "err", err)
return
Expand Down Expand Up @@ -205,63 +230,66 @@ func (g *gasPriceOracle) indexedBlockWatcher() {
queryLock.Unlock()

// Track price for the block.
g.onBlock(blk.Block, blk.LastTransactionPrice)
g.onBlock(blk.Block, blk.MedianTransactionGasPrice)
}
}
}

func (g *gasPriceOracle) onBlock(b *model.Block, lastTxPrice *quantity.Quantity) {
func (g *gasPriceOracle) onBlock(b *model.Block, medTxPrice *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)
blockFull := (float64(b.Header.GasLimit) * g.fullBlockThreshold) <= float64(b.Header.GasUsed)
if !blockFull {
// Track 0 for non-full blocks.
g.trackPrice(quantity.NewFromUint64(0))
return
}

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

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) % windowSize
g.blockPricesCurrentIdx = (g.blockPricesCurrentIdx + 1) % int(g.windowSize)
}()

// Recalculate min-price over the block window.
// Recalculate the maximum median-price over the block window.
defer func() {
minPrice := quantity.NewFromUint64(math.MaxUint64)
// Find smallest non-zero gas price.
// Find maximum gas price.
maxPrice := quantity.NewFromUint64(0)
for _, price := range g.blockPrices {
if price.IsZero() {
continue
}
if price.Cmp(minPrice) < 0 {
minPrice = price
if price.Cmp(maxPrice) > 0 {
maxPrice = price
}
}

// No full blocks among last `windowSize` blocks.
if minPrice.Cmp(quantity.NewFromUint64(math.MaxUint64)) == 0 {
if maxPrice.IsZero() {
g.priceLock.Lock()
g.computedMinGasPrice = nil
g.computedGasPrice = nil
g.priceLock.Unlock()
metricComputedPrice.Set(float64(-1))

return
}

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

if len(g.blockPrices) < windowSize {
if len(g.blockPrices) < int(g.windowSize) {
g.blockPrices = append(g.blockPrices, price)
return
}
Expand Down
16 changes: 11 additions & 5 deletions gas/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/core"
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/types"

"github.com/oasisprotocol/oasis-web3-gateway/conf"
"github.com/oasisprotocol/oasis-web3-gateway/db/model"
"github.com/oasisprotocol/oasis-web3-gateway/indexer"
)
Expand Down Expand Up @@ -85,31 +86,36 @@ func (b *mockBlockEmitter) WatchBlocks(_ context.Context, buffer int64) (<-chan
return typedCh, sub, nil
}

func emitBlock(emitter *mockBlockEmitter, fullBlock bool, lastTxPrice *quantity.Quantity) {
func emitBlock(emitter *mockBlockEmitter, fullBlock bool, txPrice *quantity.Quantity) {
// Wait a bit after emitting so that a the block is processed.
defer time.Sleep(100 * time.Millisecond)

if fullBlock {
emitter.Emit(&indexer.BlockData{Block: &model.Block{Header: &model.Header{GasLimit: 10_000, GasUsed: 10_000}}, LastTransactionPrice: lastTxPrice})
emitter.Emit(&indexer.BlockData{Block: &model.Block{Header: &model.Header{GasLimit: 10_000, GasUsed: 10_000}}, MedianTransactionGasPrice: txPrice})
return
}
emitter.Emit(&indexer.BlockData{Block: &model.Block{Header: &model.Header{GasLimit: 10_000, GasUsed: 10}}})
}

func TestGasPriceOracle(t *testing.T) {
windowSize := 5
cfg := &conf.GasConfig{
BlockFullThreshold: 0.8,
WindowSize: uint64(windowSize),
}
require := require.New(t)

emitter := mockBlockEmitter{
notifier: pubsub.NewBroker(false),
}

coreClient := mockCoreClient{
minGasPrice: *quantity.NewFromUint64(42),
minGasPrice: *quantity.NewFromUint64(142_000_000_000), // 142 gwei.
shouldFail: true,
}

// Gas price oracle with failing core client.
gasPriceOracle := New(context.Background(), &emitter, &coreClient)
gasPriceOracle := New(context.Background(), cfg, &emitter, &coreClient)
require.NoError(gasPriceOracle.Start())

// Default gas price should be returned by the oracle.
Expand Down Expand Up @@ -144,7 +150,7 @@ func TestGasPriceOracle(t *testing.T) {

// Create a new gas price oracle with working coreClient.
coreClient.shouldFail = false
gasPriceOracle = New(context.Background(), &emitter, &coreClient)
gasPriceOracle = New(context.Background(), cfg, &emitter, &coreClient)
require.NoError(gasPriceOracle.Start())

// Emit a non-full block.
Expand Down
6 changes: 3 additions & 3 deletions indexer/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,13 @@ type BlockData struct {
Block *model.Block
Receipts []*model.Receipt
UniqueTxes []*model.Transaction
// LastTransactionPrice is the price of the last transaction in the runtime block in base units.
// This can be different than the price of the last transaction in the `BlockData.Block`
// MedianTransactionGasPrice is the price of the median transaction in the runtime block in base units.
// This can be different than the price of the median transaction in the `BlockData.Block`
// as `BlockData.Block` contains only EVM transactions.
// When https://github.com/oasisprotocol/oasis-web3-gateway/issues/84 is implemented
// this will need to be persisted in the DB, so that instances without the indexer can
// obtain this as well.
LastTransactionPrice *quantity.Quantity
MedianTransactionGasPrice *quantity.Quantity
}

// BackendObserver is the intrusive backend observer interaface.
Expand Down
Loading
Loading