diff --git a/op-batcher/batcher/channel_config_provider.go b/op-batcher/batcher/channel_config_provider.go index 35a57ade85a..6f27056abe1 100644 --- a/op-batcher/batcher/channel_config_provider.go +++ b/op-batcher/batcher/channel_config_provider.go @@ -16,7 +16,7 @@ type ( } GasPricer interface { - SuggestGasPriceCaps(ctx context.Context) (tipCap *big.Int, baseFee *big.Int, blobBaseFee *big.Int, err error) + SuggestGasPriceCaps(ctx context.Context) (tipCap *big.Int, baseFee *big.Int, blobTipCap *big.Int, blobBaseFee *big.Int, err error) } DynamicEthChannelConfig struct { @@ -61,7 +61,7 @@ func (dec *DynamicEthChannelConfig) ChannelConfig(isPectra, isThrottling bool) C } ctx, cancel := context.WithTimeout(context.Background(), dec.timeout) defer cancel() - tipCap, baseFee, blobBaseFee, err := dec.gasPricer.SuggestGasPriceCaps(ctx) + tipCap, baseFee, blobTipCap, blobBaseFee, err := dec.gasPricer.SuggestGasPriceCaps(ctx) if err != nil { dec.log.Warn("Error querying gas prices, returning last config", "err", err) return *dec.lastConfig @@ -81,8 +81,14 @@ func (dec *DynamicEthChannelConfig) ChannelConfig(isPectra, isThrottling bool) C numBlobsPerTx := dec.blobConfig.TargetNumFrames // Compute the total absolute cost of submitting either a single calldata tx or a single blob tx. - calldataCost, blobCost := computeSingleCalldataTxCost(tokensPerCalldataTx, baseFee, tipCap, isPectra), - computeSingleBlobTxCost(numBlobsPerTx, baseFee, tipCap, blobBaseFee) + calldataCost, blobCost, oracleBlobCost := + computeSingleCalldataTxCost(tokensPerCalldataTx, baseFee, tipCap, isPectra), + computeSingleBlobTxCost(numBlobsPerTx, baseFee, tipCap, blobBaseFee), + computeSingleBlobTxCost(numBlobsPerTx, baseFee, blobTipCap, blobBaseFee) + + // TODO(18618): before activating the blob tip oracle, confirm in prod that we mostly get newBlobSavings == true, otherwise + // it is not worth it using the oracle + oracleBlobSavings := oracleBlobCost.Cmp(blobCost) < 0 // Now we compare the absolute cost per tx divided by the number of bytes per tx: blobDataBytesPerTx := big.NewInt(eth.MaxBlobDataSize * int64(numBlobsPerTx)) @@ -97,6 +103,8 @@ func (dec *DynamicEthChannelConfig) ChannelConfig(isPectra, isThrottling bool) C lgr := dec.log.New("base_fee", baseFee, "blob_base_fee", blobBaseFee, "tip_cap", tipCap, "calldata_bytes", calldataBytesPerTx, "calldata_cost", calldataCost, "blob_data_bytes", blobDataBytesPerTx, "blob_cost", blobCost, + "oracle_blob_cost", oracleBlobCost, + "oracle_blob_savings", oracleBlobSavings, "cost_ratio", costRatio) if ay.Cmp(bx) == 1 { diff --git a/op-batcher/batcher/channel_config_provider_test.go b/op-batcher/batcher/channel_config_provider_test.go index 6f4ea7701c0..b85c17fb519 100644 --- a/op-batcher/batcher/channel_config_provider_test.go +++ b/op-batcher/batcher/channel_config_provider_test.go @@ -17,14 +17,15 @@ type mockGasPricer struct { err error tipCap int64 baseFee int64 + blobTipCap int64 blobBaseFee int64 } -func (gp *mockGasPricer) SuggestGasPriceCaps(context.Context) (tipCap *big.Int, baseFee *big.Int, blobBaseFee *big.Int, err error) { +func (gp *mockGasPricer) SuggestGasPriceCaps(context.Context) (tipCap *big.Int, baseFee *big.Int, blobTipCap *big.Int, blobBaseFee *big.Int, err error) { if gp.err != nil { - return nil, nil, nil, gp.err + return nil, nil, nil, nil, gp.err } - return big.NewInt(gp.tipCap), big.NewInt(gp.baseFee), big.NewInt(gp.blobBaseFee), nil + return big.NewInt(gp.tipCap), big.NewInt(gp.baseFee), big.NewInt(gp.blobTipCap), big.NewInt(gp.blobBaseFee), nil } func TestDynamicEthChannelConfig_ChannelConfig(t *testing.T) { diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index e253420b1ac..b4edbc30f4e 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "math/big" "sync/atomic" "time" @@ -19,7 +20,9 @@ import ( "github.com/ethereum-optimism/optimism/op-node/chaincfg" "github.com/ethereum-optimism/optimism/op-node/params" "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/bgpo" "github.com/ethereum-optimism/optimism/op-service/cliapp" + "github.com/ethereum-optimism/optimism/op-service/client" "github.com/ethereum-optimism/optimism/op-service/dial" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/httputil" @@ -80,6 +83,10 @@ type BatcherService struct { stopped atomic.Bool NotSubmittingOnStart bool + + // BlobGasPriceOracle tracks blob base gas prices for dynamic pricing + blobTipOracle *bgpo.BlobTipOracle + oracleStopCh chan struct{} } type DriverSetupOption func(setup *DriverSetup) @@ -169,7 +176,7 @@ func (bs *BatcherService) initFromCLIConfig(ctx context.Context, closeApp contex if err := bs.initRollupConfig(ctx); err != nil { return fmt.Errorf("failed to load rollup config: %w", err) } - if err := bs.initTxManager(cfg); err != nil { + if err := bs.initTxManager(ctx, cfg); err != nil { return fmt.Errorf("failed to init Tx manager: %w", err) } // must be init before driver and channel config @@ -254,6 +261,56 @@ func (bs *BatcherService) initRollupConfig(ctx context.Context) error { return nil } +func (bs *BatcherService) initBlobTipOracle(ctx context.Context, cfg *CLIConfig) error { + // Only initialize the oracle if we're using blobs or auto mode + if cfg.DataAvailabilityType != flags.BlobsType && cfg.DataAvailabilityType != flags.AutoType { + bs.Log.Debug("Skipping blob tip oracle initialization (not using blobs)") + return nil + } + + // Get RPC client from L1 client + // The ethclient.Client has a Client() method that returns the underlying *rpc.Client + rpcClient := bs.L1Client.Client() + if rpcClient == nil { + return fmt.Errorf("failed to get RPC client from L1 client") + } + + // Get L1 chain config from rollup config + l1ChainID := eth.ChainIDFromBig(bs.RollupConfig.L1ChainID) + l1ChainConfig := eth.L1ChainConfigByChainID(l1ChainID) + if l1ChainConfig == nil { + bs.Log.Info("Blob tip oracle not initialized when L1 chain ID is not known (Ethereum mainnet, Sepolia, Holesky, Hoodi)") + return nil + } + + // Wrap the RPC client to match the client.RPC interface + baseRPCClient := client.NewBaseRPCClient(rpcClient) + + // Create the oracle with default config + oracleConfig := bgpo.DefaultBlobTipOracleConfig() + oracleConfig.NetworkTimeout = bs.NetworkTimeout + minTipCap, err := eth.GweiToWei(cfg.TxMgrConfig.MinTipCapGwei) + if err != nil { + return fmt.Errorf("invalid min tip cap: %w", err) + } + oracleConfig.DefaultPriorityFee = minTipCap + bs.blobTipOracle = bgpo.NewBlobTipOracle(ctx, baseRPCClient, l1ChainConfig, bs.Log, oracleConfig) + bs.oracleStopCh = make(chan struct{}) + + bs.Log.Info("Initialized blob tip oracle") + + // Start the blob tip oracle if it's initialized + go func() { + if err := bs.blobTipOracle.Start(); err != nil { + bs.Log.Error("Blob tip oracle stopped with error", "err", err) + } + close(bs.oracleStopCh) + }() + bs.blobTipOracle.WaitCachePopulated() + bs.Log.Info("Started blob tip oracle") + return nil +} + func (bs *BatcherService) initChannelConfig(cfg *CLIConfig) error { channelTimeout := bs.RollupConfig.ChannelTimeoutBedrock // Use lower channel timeout if granite is scheduled. @@ -340,8 +397,52 @@ func (bs *BatcherService) initChannelConfig(cfg *CLIConfig) error { return nil } -func (bs *BatcherService) initTxManager(cfg *CLIConfig) error { - txManager, err := txmgr.NewSimpleTxManager("batcher", bs.Log, bs.Metrics, cfg.TxMgrConfig) +func (bs *BatcherService) initTxManager(ctx context.Context, cfg *CLIConfig) error { + // Initialize the blob tip oracle first + if err := bs.initBlobTipOracle(ctx, cfg); err != nil { + return fmt.Errorf("failed to init blob tip oracle: %w", err) + } + + // Create the base config from CLI config + txmgrConfig, err := txmgr.NewConfig(cfg.TxMgrConfig, bs.Log) + if err != nil { + return err + } + + // Create a custom gas price estimator that uses the blob tip oracle if available + if bs.blobTipOracle != nil { + txmgrConfig.GasPriceEstimatorFn = func(ctx context.Context, backend txmgr.ETHBackend) (*big.Int, *big.Int, *big.Int, *big.Int, error) { + // Get tip and base fee from backend (standard way for execution gas) + tip, err := backend.SuggestGasTipCap(ctx) + if err != nil { + return nil, nil, nil, nil, err + } + + head, err := backend.HeaderByNumber(ctx, nil) + if err != nil { + return nil, nil, nil, nil, err + } + if head.BaseFee == nil { + return nil, nil, nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a base fee") + } + + blobBaseFee, err := backend.BlobBaseFee(ctx) + if err != nil { + return nil, nil, nil, nil, err + } + + // Use the oracle's SuggestBlobTipCap for blob tip fee suggestion + // This analyzes recent blob transactions to suggest an appropriate blob tip fee + suggestedBlobFeeCap, err := bs.blobTipOracle.SuggestBlobTipCap(ctx, 0, 0) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("blob tip oracle failed to suggest blob tip fee: %w", err) + } + + return tip, head.BaseFee, suggestedBlobFeeCap, blobBaseFee, nil + } + } + + txManager, err := txmgr.NewSimpleTxManagerFromConfig("batcher", bs.Log, bs.Metrics, txmgrConfig) if err != nil { return err } @@ -439,7 +540,7 @@ func (bs *BatcherService) initAltDA(cfg *CLIConfig) error { // Start runs once upon start of the batcher lifecycle, // and starts batch-submission work if the batcher is configured to start submit data on startup. -func (bs *BatcherService) Start(_ context.Context) error { +func (bs *BatcherService) Start(ctx context.Context) error { bs.driver.Log.Info("Starting batcher", "notSubmittingOnStart", bs.NotSubmittingOnStart) if !bs.NotSubmittingOnStart { @@ -475,6 +576,20 @@ func (bs *BatcherService) Stop(ctx context.Context) error { bs.TxManager.Close() } + // Stop the blob tip oracle if it's running + if bs.blobTipOracle != nil { + bs.blobTipOracle.Close() + // Wait for the oracle goroutine to finish + if bs.oracleStopCh != nil { + select { + case <-bs.oracleStopCh: + // Oracle stopped + case <-ctx.Done(): + // Context cancelled, force stop + } + } + } + var result error if bs.driver != nil { if err := bs.driver.StopBatchSubmittingIfRunning(ctx); err != nil { diff --git a/op-challenger/sender/sender_test.go b/op-challenger/sender/sender_test.go index 54d12d45f47..ea5f0986d39 100644 --- a/op-challenger/sender/sender_test.go +++ b/op-challenger/sender/sender_test.go @@ -194,6 +194,6 @@ func (s *stubTxMgr) API() rpc.API { func (s *stubTxMgr) Close() { } -func (s *stubTxMgr) SuggestGasPriceCaps(context.Context) (*big.Int, *big.Int, *big.Int, error) { +func (s *stubTxMgr) SuggestGasPriceCaps(context.Context) (*big.Int, *big.Int, *big.Int, *big.Int, error) { panic("unimplemented") } diff --git a/op-deployer/pkg/deployer/broadcaster/gas_estimator.go b/op-deployer/pkg/deployer/broadcaster/gas_estimator.go index b04390fc8aa..4390678b3d0 100644 --- a/op-deployer/pkg/deployer/broadcaster/gas_estimator.go +++ b/op-deployer/pkg/deployer/broadcaster/gas_estimator.go @@ -16,6 +16,9 @@ var ( // dummyBlobFee is a dummy value for the blob fee. Since this gas estimator will never // post blobs, it's just set to 1. dummyBlobFee = big.NewInt(1) + // dummyBlobTipCap is a dummy value for the blob tip cap. Since this gas estimator will never + // post blobs, it's just set to 0. + dummyBlobTipCap = big.NewInt(0) // maxTip is the maximum tip that can be suggested by this estimator. maxTip = big.NewInt(50 * 1e9) // minTip is the minimum tip that can be suggested by this estimator. @@ -25,15 +28,15 @@ var ( // DeployerGasPriceEstimator is a custom gas price estimator for use with op-deployer. // It pads the base fee by 50% and multiplies the suggested tip by 5 up to a max of // 50 gwei. -func DeployerGasPriceEstimator(ctx context.Context, client txmgr.ETHBackend) (*big.Int, *big.Int, *big.Int, error) { +func DeployerGasPriceEstimator(ctx context.Context, client txmgr.ETHBackend) (*big.Int, *big.Int, *big.Int, *big.Int, error) { chainHead, err := client.HeaderByNumber(ctx, nil) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get block: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to get block: %w", err) } tip, err := client.SuggestGasTipCap(ctx) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get gas tip cap: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to get gas tip cap: %w", err) } baseFeePad := new(big.Int).Div(chainHead.BaseFee, baseFeePadFactor) @@ -48,5 +51,5 @@ func DeployerGasPriceEstimator(ctx context.Context, client txmgr.ETHBackend) (*b paddedTip.Set(maxTip) } - return paddedTip, paddedBaseFee, dummyBlobFee, nil + return paddedTip, paddedBaseFee, dummyBlobTipCap, dummyBlobFee, nil } diff --git a/op-e2e/actions/helpers/l2_proposer.go b/op-e2e/actions/helpers/l2_proposer.go index 2ba93d7e7c3..31dff4370cd 100644 --- a/op-e2e/actions/helpers/l2_proposer.go +++ b/op-e2e/actions/helpers/l2_proposer.go @@ -91,7 +91,7 @@ func (f fakeTxMgr) API() rpc.API { panic("unimplemented") } -func (f fakeTxMgr) SuggestGasPriceCaps(context.Context) (*big.Int, *big.Int, *big.Int, error) { +func (f fakeTxMgr) SuggestGasPriceCaps(context.Context) (*big.Int, *big.Int, *big.Int, *big.Int, error) { panic("unimplemented") } diff --git a/op-e2e/system/da/eip4844_test.go b/op-e2e/system/da/eip4844_test.go index 2f7c2ffe3e0..5406127006b 100644 --- a/op-e2e/system/da/eip4844_test.go +++ b/op-e2e/system/da/eip4844_test.go @@ -293,11 +293,11 @@ func TestBatcherAutoDA(t *testing.T) { // Helpers mustGetFees := func() (*big.Int, *big.Int, *big.Int, float64) { - tip, baseFee, blobFee, err := txmgr.DefaultGasPriceEstimatorFn(ctx, l1Client) + tip, baseFee, _, blobBaseFee, err := txmgr.DefaultGasPriceEstimatorFn(ctx, l1Client) require.NoError(t, err) - feeRatio := float64(blobFee.Int64()) / float64(baseFee.Int64()+tip.Int64()) - t.Logf("L1 fees are: baseFee(%d), tip(%d), blobBaseFee(%d). feeRatio: %f", baseFee, tip, blobFee, feeRatio) - return tip, baseFee, blobFee, feeRatio + feeRatio := float64(blobBaseFee.Int64()) / float64(baseFee.Int64()+tip.Int64()) + t.Logf("L1 fees are: baseFee(%d), tip(%d), blobBaseFee(%d). feeRatio: %f", baseFee, tip, blobBaseFee, feeRatio) + return tip, baseFee, blobBaseFee, feeRatio } requireEventualBatcherTxType := func(txType uint8, timeout time.Duration, strict bool) { var foundOtherTxType bool diff --git a/op-service/bgpo/oracle.go b/op-service/bgpo/oracle.go new file mode 100644 index 00000000000..99b1a2d5f4b --- /dev/null +++ b/op-service/bgpo/oracle.go @@ -0,0 +1,407 @@ +package bgpo + +import ( + "context" + "fmt" + "math/big" + "sort" + "sync" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/consensus/misc/eip4844" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/sources/caching" +) + +// BlobTipOracle tracks blob base gas prices by subscribing to new block headers +// and extracts the blob tip caps from blob txs from each block. +type BlobTipOracle struct { + sync.Mutex + + client *client.PollingClient + chainConfig *params.ChainConfig + log log.Logger + config *BlobTipOracleConfig + + // LRU cache for blob base fees by block number + baseFees *caching.LRUCache[uint64, *big.Int] + + // Cache for blob txs priority fees extracted from blocks (for SuggestBlobTipCap) + priorityFees *caching.LRUCache[uint64, []*big.Int] + + // Track the latest block number for GetLatestBlobBaseFee + latestBlock uint64 + + ctx context.Context + cancel context.CancelFunc + + sub ethereum.Subscription + + cachePopulated chan struct{} +} + +// rpcBlock structure for fetching blocks with transactions. +// When eth_getBlockByNumber is called with true, it returns full transaction objects. +type rpcBlock struct { + Number hexutil.Uint64 `json:"number"` + Hash hexutil.Bytes `json:"hash"` + Transactions []*types.Transaction `json:"transactions"` +} + +// BlobTipOracleConfig configures the blob tip oracle. +type BlobTipOracleConfig struct { + // NetworkTimeout is the timeout for network requests + NetworkTimeout time.Duration + // PricesCacheSize is the maximum number of blob base fees to cache + PricesCacheSize int + // BlockCacheSize is the maximum number of blocks to cache for RPC calls + BlockCacheSize int + // MaxBlocks is the default number of recent blocks to analyze in SuggestBlobTipCap + MaxBlocks int + // Percentile is the default percentile to use for blob tip cap suggestion + Percentile int + // Poll rate is the rate at which the oracle will poll for new blocks + PollRate time.Duration + // Metrics for cache tracking + Metrics caching.Metrics + // DefaultPriorityFee is the default priority fee to use for blob tip cap suggestion, if there are no recent blob txs + DefaultPriorityFee *big.Int +} + +// DefaultBlobTipOracleConfig returns a default configuration. +func DefaultBlobTipOracleConfig() *BlobTipOracleConfig { + return &BlobTipOracleConfig{ + PricesCacheSize: 1000, + BlockCacheSize: 100, + MaxBlocks: 20, + Percentile: 60, + PollRate: 2500 * time.Millisecond, + NetworkTimeout: 3 * time.Second, + Metrics: nil, + DefaultPriorityFee: big.NewInt(1), // 1 wei + } +} + +// NewBlobTipOracle creates a new blob tip oracle that will subscribe +// to newHeads and track blob base fees, and extract blob tip caps from blob txs. +func NewBlobTipOracle(ctx context.Context, rpcClient client.RPC, chainConfig *params.ChainConfig, log log.Logger, config *BlobTipOracleConfig) *BlobTipOracle { + defaultConfig := DefaultBlobTipOracleConfig() + if config == nil { + config = defaultConfig + } + if config.PricesCacheSize <= 0 { + config.PricesCacheSize = defaultConfig.PricesCacheSize + } + if config.BlockCacheSize <= 0 { + config.BlockCacheSize = defaultConfig.BlockCacheSize + } + if config.MaxBlocks <= 0 { + config.MaxBlocks = defaultConfig.MaxBlocks + } + if config.Percentile <= 0 || config.Percentile > 100 { + config.Percentile = defaultConfig.Percentile + } + + logger := log.With("module", "bgpo") + + pollClient := client.NewPollingClient(ctx, logger, rpcClient, client.WithPollRate(config.PollRate)) + + oracleCtx, cancel := context.WithCancel(ctx) + return &BlobTipOracle{ + config: config, + client: pollClient, + chainConfig: chainConfig, + log: log.With("module", "bgpo"), + baseFees: caching.NewLRUCache[uint64, *big.Int](config.Metrics, "bgpo_prices", config.PricesCacheSize), + priorityFees: caching.NewLRUCache[uint64, []*big.Int](config.Metrics, "bgpo_tips", config.BlockCacheSize), + ctx: oracleCtx, + cancel: cancel, + cachePopulated: make(chan struct{}), + } +} + +// WaitCachePopulated waits for the cache to be populated. +func (o *BlobTipOracle) WaitCachePopulated() { + select { + case <-o.cachePopulated: + o.log.Info("Done waiting for cache pre-population") + return + case <-o.ctx.Done(): + o.log.Error("Cache pre-population timed out", "ctx", o.ctx.Err()) + return + case <-time.After(o.config.NetworkTimeout * time.Duration(o.config.MaxBlocks)): + o.log.Error("Cache pre-population timed out after timeout", "timeout", o.config.NetworkTimeout, "maxBlocks", o.config.MaxBlocks) + return + } +} + +// Start begins subscribing to newHeads and processing headers. +// Before subscribing, it pre-populates the cache with the last MaxBlocks blocks. +// This method blocks until the context is canceled or an error occurs. +func (o *BlobTipOracle) Start() error { + // Pre-populate cache with recent blocks before subscribing + if err := o.prePopulateCache(); err != nil { + o.log.Warn("Failed to pre-populate cache, continuing anyway", "err", err) + } + + headers := make(chan *types.Header, 10) + + doSubscribe := func(ch chan<- *types.Header) (ethereum.Subscription, error) { + return o.client.Subscribe(o.ctx, "eth", ch, "newHeads") + } + + sub, err := doSubscribe(headers) + if err != nil { + return err + } + o.sub = sub + + o.log.Info("Blob tip oracle started, subscribed to newHeads") + + // Process headers as they arrive + for { + select { + case header := <-headers: + if err := o.processHeader(header); err != nil { + o.log.Error("Error processing header", "err", err, "block", header.Number.Uint64()) + } + case err := <-sub.Err(): + if err != nil { + o.log.Error("Subscription error", "err", err) + return err + } + return nil + case <-o.ctx.Done(): + o.log.Info("Blob tip oracle context canceled") + return nil + } + } +} + +// prePopulateCache fetches and processes the last MaxBlocks blocks to pre-populate the cache. +func (o *BlobTipOracle) prePopulateCache() error { + defer close(o.cachePopulated) // signal that the cache is populated and we can start using the oracle + now := time.Now() + + ctx, cancel := context.WithTimeout(o.ctx, o.config.NetworkTimeout) + defer cancel() + + // Get the latest block number + var latestBlockNum hexutil.Uint64 + if err := o.client.CallContext(ctx, &latestBlockNum, "eth_blockNumber"); err != nil { + return fmt.Errorf("failed to get latest block number: %w", err) + } + + latest := uint64(latestBlockNum) + var startBlock uint64 + if latest >= uint64(o.config.MaxBlocks) { + startBlock = latest - uint64(o.config.MaxBlocks) + 1 + } else { + startBlock = 0 + } + + o.log.Debug("Pre-populating cache", "from", startBlock, "to", latest, "blocks", latest-startBlock+1) + + // Fetch and process each block + for blockNum := startBlock; blockNum <= latest; blockNum++ { + // Fetch header + var header *types.Header + blockNumHex := hexutil.EncodeUint64(blockNum) + if err := o.client.CallContext(ctx, &header, "eth_getBlockByNumber", blockNumHex, false); err != nil { + o.log.Debug("Failed to fetch header for pre-population", "block", blockNum, "err", err) + continue + } + + // Process header (this will also trigger blob fee cap fetching) + if err := o.processHeader(header); err != nil { + o.log.Debug("Failed to process header for pre-population", "block", blockNum, "err", err) + continue + } + } + + o.log.Info("Cache pre-population complete", "blocks_processed", latest-startBlock+1, "took", time.Since(now)) + return nil +} + +// processHeader calculates and stores the blob base fee for the given header. +// It also triggers an asynchronous fetch of the full block to extract blob fee caps. +func (o *BlobTipOracle) processHeader(header *types.Header) error { + defer func(start time.Time) { + o.log.Debug("Processed header", "block", header.Number.Uint64(), "time", time.Since(start)) + }(time.Now()) + + o.Lock() + defer o.Unlock() + + blockNum := header.Number.Uint64() + + // Calculate blob base fee from the header + if _, ok := o.baseFees.Get(blockNum); ok { + o.log.Debug("Skipping blob base fee calculation, already processed", "block", blockNum, "latestBlock", o.latestBlock) + } else { + var blobBaseFee *big.Int + if header.ExcessBlobGas != nil { + blobBaseFee = eip4844.CalcBlobFee(o.chainConfig, header) + } + + if blobBaseFee != nil { + o.log.Debug("Adding blob base fee", "block", blockNum, "blobBaseFee", blobBaseFee.String()) + o.baseFees.Add(blockNum, blobBaseFee) + } else { + o.log.Debug("Block does not support blob transactions", "block", blockNum) + o.baseFees.Add(blockNum, big.NewInt(0)) + } + } + + // Fetch full block data and extract blob fee caps + o.fetchBlockBlobFeeCaps(blockNum, header.BaseFee) + + if blockNum > o.latestBlock { + o.latestBlock = blockNum + } + + return nil +} + +// fetchBlockBlobFeeCaps fetches a block and extracts blob fee caps, storing them in cache. +func (o *BlobTipOracle) fetchBlockBlobFeeCaps(blockNum uint64, baseFee *big.Int) { + // Check if we already have the blob fee caps cached + if _, ok := o.priorityFees.Get(blockNum); ok { + o.log.Debug("Skipping blob fee caps fetch, already processed", "block", blockNum) + return + } + + ctx, cancel := context.WithTimeout(o.ctx, o.config.NetworkTimeout) + defer cancel() + + // Fetch the block + var block rpcBlock + blockNumHex := hexutil.EncodeUint64(blockNum) + if err := o.client.CallContext(ctx, &block, "eth_getBlockByNumber", blockNumHex, true); err != nil { + o.log.Warn("Failed to fetch block for blob fee caps", "block", blockNum, "err", err) + return + } + + // Extract blob fee caps directly + tips := o.extractTipsForBlobTxs(block, baseFee) + + // Store in cache (even if empty, to avoid repeated fetches) + o.priorityFees.Add(blockNum, tips) +} + +// GetLatestBlobBaseFee returns the blob base fee for the most recently processed block. +// Returns (0, nil) if no blocks have been processed yet, the price was evicted from cache, +// or if the latest block doesn't support blob transactions. +func (o *BlobTipOracle) GetLatestBlobBaseFee() (uint64, *big.Int) { + o.Lock() + defer o.Unlock() + + if o.latestBlock == 0 { + return 0, nil + } + + price, ok := o.baseFees.Get(o.latestBlock) + if !ok { + // Price was evicted from cache or block was never processed + return 0, nil + } + if price == nil { + // Block doesn't contain blob transactions + return o.latestBlock, nil + } + // Return a copy to prevent external modification + return o.latestBlock, new(big.Int).Set(price) +} + +// SuggestBlobTipCap analyzes recent blocks to suggest an appropriate blob tip cap +// for blob transactions. It examines the last maxBlocks blocks and returns the +// percentile-th percentile of blob tip caps from blob transactions. +// This is similar to go-ethereum's oracle.SuggestTipCap but for tips solely on blob transactions (type 3). +// +// This method only reads from cache and does not make any RPC calls. Block data +// is fetched during block processing. +// +// If no blob transactions are found in recent blocks, it returns the current blob base fee +// plus a small buffer to ensure the transaction is competitive. +func (o *BlobTipOracle) SuggestBlobTipCap(ctx context.Context, maxBlocks int, percentile int) (*big.Int, error) { + if maxBlocks <= 0 { + maxBlocks = o.config.MaxBlocks + } + if percentile <= 0 || percentile > 100 { + percentile = o.config.Percentile + } + + // Get the latest block number from our tracked state (no RPC call) + o.Lock() + latestBlockNum := o.latestBlock + o.Unlock() + + if latestBlockNum == 0 { + return nil, fmt.Errorf("no blocks have been processed yet") + } + + // Collect blob fee caps from recent blocks (only from cache, no RPC calls) + var tips []*big.Int + startBlock := latestBlockNum + if startBlock >= uint64(maxBlocks) { + startBlock -= uint64(maxBlocks) + } else { + startBlock = 0 + } + + for blockNum := startBlock; blockNum <= latestBlockNum; blockNum++ { + // Only read from cache - no RPC calls + if t, ok := o.priorityFees.Get(blockNum); ok { + tips = append(tips, t...) + } + } + + // If we found blob transactions, calculate percentile + if len(tips) > 0 { + sort.Slice(tips, func(i, j int) bool { + return tips[i].Cmp(tips[j]) < 0 + }) + idx := (len(tips) - 1) * percentile / 100 + suggested := new(big.Int).Set(tips[idx]) + o.log.Debug("Suggested blob tip cap from recent transactions", "suggested", suggested.String(), "samples", len(tips), "percentile", percentile) + return suggested, nil + } + + // No blob transactions found, use the default priority fee - that should almost never happen, so we warn about it + o.log.Warn("No recent blob transactions found, using blob base fee + buffer", "block", latestBlockNum, "default_priority_fee", o.config.DefaultPriorityFee.String()) + return new(big.Int).Set(o.config.DefaultPriorityFee), nil +} + +// extractTipsForBlobTxs extracts tips for blob transactions from a block's transactions. +func (o *BlobTipOracle) extractTipsForBlobTxs(block rpcBlock, baseFee *big.Int) []*big.Int { + var tips []*big.Int + for _, tx := range block.Transactions { + // Check if it's a blob transaction (type 3) and has blob fee cap + if tx.Type() == types.BlobTxType { + tip, err := tx.EffectiveGasTip(baseFee) // tip calculated from execution gas, for a type 3 transaction + if err != nil { + o.log.Error("Failed to calculate effective gas tip", "block", uint64(block.Number), "err", err) + continue + } + + tips = append(tips, tip) + o.log.Debug("Extracted tip from blob tx", "block", uint64(block.Number), "tip", tip.String()) + } + } + return tips +} + +// Close stops the oracle and cleans up resources. +func (o *BlobTipOracle) Close() { + o.cancel() + if o.sub != nil { + o.sub.Unsubscribe() + } + o.log.Info("Blob tip oracle closed") +} diff --git a/op-service/bgpo/oracle_test.go b/op-service/bgpo/oracle_test.go new file mode 100644 index 00000000000..d281c92f706 --- /dev/null +++ b/op-service/bgpo/oracle_test.go @@ -0,0 +1,485 @@ +package bgpo + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/testlog" +) + +type mockRPC struct { + mock.Mock +} + +func (m *mockRPC) CallContext(ctx context.Context, result any, method string, args ...any) error { + callArgs := make([]any, 0, len(args)) + callArgs = append(callArgs, args...) + args_ := m.Called(ctx, result, method, callArgs) + return args_.Error(0) +} + +func (m *mockRPC) BatchCallContext(ctx context.Context, b []rpc.BatchElem) error { + args_ := m.Called(ctx, b) + return args_.Error(0) +} + +func (m *mockRPC) Subscribe(ctx context.Context, namespace string, channel any, args ...any) (ethereum.Subscription, error) { + args_ := m.Called(ctx, namespace, channel, args) + sub := args_.Get(0) + if sub == nil { + return nil, args_.Error(1) + } + return sub.(ethereum.Subscription), args_.Error(1) +} + +func (m *mockRPC) Close() { + m.Called() +} + +var _ client.RPC = (*mockRPC)(nil) + +func createHeader(blockNum uint64, excessBlobGas *uint64) *types.Header { + header := &types.Header{ + Number: big.NewInt(int64(blockNum)), + ParentHash: common.Hash{}, + Time: uint64(time.Now().Unix()), + BaseFee: big.NewInt(1000000000), // 1 gwei + } + if excessBlobGas != nil { + header.ExcessBlobGas = excessBlobGas + } + return header +} + +func createBlobTx(gasTip *big.Int, gasFeeCap *big.Int, blobFeeCap *big.Int) *types.Transaction { + // Create a minimal blob transaction + // Note: This is a simplified version for testing + tx := types.NewTx(&types.BlobTx{ + ChainID: uint256.NewInt(1), + Nonce: 0, + GasTipCap: uint256.MustFromBig(gasTip), + GasFeeCap: uint256.MustFromBig(gasFeeCap), + Gas: 21000, + To: common.Address{}, + Value: uint256.NewInt(0), + Data: []byte{}, + BlobFeeCap: uint256.MustFromBig(blobFeeCap), + BlobHashes: []common.Hash{common.Hash{}}, + }) + return tx +} + +func TestNewBlobGasPriceOracle(t *testing.T) { + ctx := context.Background() + mrpc := new(mockRPC) + chainConfig := params.MainnetChainConfig + logger := testlog.Logger(t, log.LevelInfo) + + t.Run("with nil config", func(t *testing.T) { + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, nil) + require.NotNil(t, oracle) + require.Equal(t, 20, oracle.config.MaxBlocks) + require.Equal(t, 60, oracle.config.Percentile) + }) + + t.Run("with custom config", func(t *testing.T) { + config := &BlobTipOracleConfig{ + PricesCacheSize: 500, + BlockCacheSize: 50, + MaxBlocks: 10, + Percentile: 70, + } + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, config) + require.NotNil(t, oracle) + require.Equal(t, 10, oracle.config.MaxBlocks) + require.Equal(t, 70, oracle.config.Percentile) + }) + + t.Run("with invalid config values", func(t *testing.T) { + config := &BlobTipOracleConfig{ + PricesCacheSize: -1, + BlockCacheSize: -1, + MaxBlocks: -1, + Percentile: 150, // Invalid + } + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, config) + require.NotNil(t, oracle) + // Should use defaults + require.Equal(t, 20, oracle.config.MaxBlocks) + require.Equal(t, 60, oracle.config.Percentile) + }) +} + +func TestProcessHeader(t *testing.T) { + ctx := context.Background() + mrpc := new(mockRPC) + chainConfig := params.MainnetChainConfig + logger := testlog.Logger(t, log.LevelError) + + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, &BlobTipOracleConfig{ + PricesCacheSize: 10, + BlockCacheSize: 10, + MaxBlocks: 5, + Percentile: 60, + }) + + t.Run("process header with excess blob gas", func(t *testing.T) { + excessBlobGas := uint64(1000000) + header := createHeader(100, &excessBlobGas) + + // Mock block fetch for blob fee caps + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[1] == true + })). + Run(func(args mock.Arguments) { + block := args[1].(*rpcBlock) + block.Number = hexutil.Uint64(100) + block.Hash = common.Hash{}.Bytes() + block.Transactions = []*types.Transaction{} + }). + Return(nil).Once() + + err := oracle.processHeader(header) + require.NoError(t, err) + + // Check latest block + latestBlock, latestFee := oracle.GetLatestBlobBaseFee() + require.Equal(t, uint64(100), latestBlock) + require.NotNil(t, latestFee) + }) + + t.Run("process header without excess blob gas", func(t *testing.T) { + header := createHeader(101, nil) + + // Mock block fetch + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[1] == true + })). + Run(func(args mock.Arguments) { + block := args[1].(*rpcBlock) + block.Number = hexutil.Uint64(101) + block.Hash = common.Hash{}.Bytes() + block.Transactions = []*types.Transaction{} + }). + Return(nil).Once() + + err := oracle.processHeader(header) + require.NoError(t, err) + + // Latest block should be updated + latestBlock, _ := oracle.GetLatestBlobBaseFee() + require.Equal(t, uint64(101), latestBlock) + }) + + mrpc.AssertExpectations(t) +} + +func TestGetLatestBlobBaseFee(t *testing.T) { + ctx := context.Background() + mrpc := new(mockRPC) + chainConfig := params.MainnetChainConfig + logger := testlog.Logger(t, log.LevelError) + + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, &BlobTipOracleConfig{ + PricesCacheSize: 10, + BlockCacheSize: 10, + }) + + t.Run("no blocks processed", func(t *testing.T) { + block, fee := oracle.GetLatestBlobBaseFee() + require.Equal(t, uint64(0), block) + require.Nil(t, fee) + }) + + t.Run("with processed blocks", func(t *testing.T) { + excessBlobGas := uint64(1000000) + header1 := createHeader(300, &excessBlobGas) + header2 := createHeader(301, &excessBlobGas) + + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[1] == true + })). + Return(nil).Twice(). + Run(func(args mock.Arguments) { + block := args[1].(*rpcBlock) + callArgs := args[3].([]any) + blockNumHex := callArgs[0].(string) + if blockNumHex == "0x12c" { // 300 + block.Number = hexutil.Uint64(300) + } else { + block.Number = hexutil.Uint64(301) + } + block.Hash = common.Hash{}.Bytes() + block.Transactions = []*types.Transaction{} + }) + + err := oracle.processHeader(header1) + require.NoError(t, err) + + err = oracle.processHeader(header2) + require.NoError(t, err) + + block, fee := oracle.GetLatestBlobBaseFee() + require.Equal(t, uint64(301), block) + require.NotNil(t, fee) + }) + + mrpc.AssertExpectations(t) +} + +func TestSuggestBlobTipCap(t *testing.T) { + ctx := context.Background() + mrpc := new(mockRPC) + chainConfig := params.MainnetChainConfig + logger := testlog.Logger(t, log.LevelError) + + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, &BlobTipOracleConfig{ + PricesCacheSize: 10, + BlockCacheSize: 10, + MaxBlocks: 5, + Percentile: 60, + }) + + t.Run("no blocks processed", func(t *testing.T) { + suggested, err := oracle.SuggestBlobTipCap(ctx, 0, 0) + require.Error(t, err) + require.Nil(t, suggested) + require.Contains(t, err.Error(), "no blocks have been processed") + }) + + t.Run("with_blob_transactions", func(t *testing.T) { + // Process blocks with blob transactions + excessBlobGas := uint64(1000000) + for i := uint64(400); i <= 404; i++ { + header := createHeader(i, &excessBlobGas) + + // Create blob transactions with different tip + gasFeeCap := big.NewInt(3000000000) + blobFeeCap := big.NewInt(3000000000) + tip := big.NewInt(int64((i-400)*1000000 + 1000000)) // 1M, 2M, 3M, 4M, 5M + blobTx := createBlobTx(tip, gasFeeCap, blobFeeCap) + + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[1] == true + })). + Run(func(args mock.Arguments) { + block := args[1].(*rpcBlock) + block.Number = hexutil.Uint64(i) + block.Hash = common.Hash{}.Bytes() + block.Transactions = []*types.Transaction{blobTx} + }). + Return(nil).Once() + + err := oracle.processHeader(header) + require.NoError(t, err) + } + + // Test with default parameters + suggested, err := oracle.SuggestBlobTipCap(ctx, 0, 0) + require.NoError(t, err) + require.NotNil(t, suggested) + // Should be 60th percentile of [1M, 2M, 3M, 4M, 5M] = 3M (index 2 of 4) + require.Equal(t, big.NewInt(3000000), suggested) + + // Test with custom percentile + suggested, err = oracle.SuggestBlobTipCap(ctx, 5, 80) + require.NoError(t, err) + require.NotNil(t, suggested) + // 80th percentile of [1M, 2M, 3M, 4M, 5M] = 4M (index 3 of 4) + require.Equal(t, big.NewInt(4000000), suggested) + }) + + t.Run("no blob transactions, fallback to base fee", func(t *testing.T) { + oracle2 := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, &BlobTipOracleConfig{ + PricesCacheSize: 10, + BlockCacheSize: 10, + MaxBlocks: 5, + Percentile: 60, + DefaultPriorityFee: big.NewInt(101), + }) + + excessBlobGas := uint64(1000000) + header := createHeader(500, &excessBlobGas) + + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[1] == true + })). + Run(func(args mock.Arguments) { + block := args[1].(*rpcBlock) + block.Number = hexutil.Uint64(500) + block.Hash = common.Hash{}.Bytes() + block.Transactions = []*types.Transaction{} // No blob transactions + }). + Return(nil).Once() + + err := oracle2.processHeader(header) + require.NoError(t, err) + + suggested, err := oracle2.SuggestBlobTipCap(ctx, 0, 0) + require.NoError(t, err) + require.Equal(t, big.NewInt(101), suggested) + }) + + mrpc.AssertExpectations(t) +} + +func TestPrePopulateCache(t *testing.T) { + ctx := context.Background() + mrpc := new(mockRPC) + chainConfig := params.MainnetChainConfig + logger := testlog.Logger(t, log.LevelError) + + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, &BlobTipOracleConfig{ + PricesCacheSize: 10, + BlockCacheSize: 10, + MaxBlocks: 3, + Percentile: 60, + }) + + t.Run("pre-populate with recent blocks", func(t *testing.T) { + latestBlock := uint64(1000) + + // Mock eth_blockNumber (called with no args - empty slice) + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_blockNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 0 + })). + Run(func(args mock.Arguments) { + result := args[1].(*hexutil.Uint64) + *result = hexutil.Uint64(latestBlock) + }). + Return(nil).Once() + + // Mock header fetches for blocks 998, 999, 1000 + excessBlobGas := uint64(1000000) + for i := uint64(998); i <= 1000; i++ { + header := createHeader(i, &excessBlobGas) + + // Mock header fetch (with false for full transactions) + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[0] == hexutil.EncodeUint64(i) && args[1] == false + })). + Run(func(args mock.Arguments) { + result := args[1].(**types.Header) + *result = header + }). + Return(nil).Once() + + // Mock block fetch for blob fee caps (with true for full transactions) + mrpc.On("CallContext", mock.Anything, mock.Anything, "eth_getBlockByNumber", mock.MatchedBy(func(args []any) bool { + return len(args) == 2 && args[0] == hexutil.EncodeUint64(i) && args[1] == true + })). + Run(func(args mock.Arguments) { + block := args[1].(*rpcBlock) + block.Number = hexutil.Uint64(i) + block.Hash = common.Hash{}.Bytes() + block.Transactions = []*types.Transaction{} + }). + Return(nil).Once() + } + + err := oracle.prePopulateCache() + require.NoError(t, err) + + latestBlockNum, _ := oracle.GetLatestBlobBaseFee() + require.Equal(t, uint64(1000), latestBlockNum) + }) + + mrpc.AssertExpectations(t) +} + +func TestExtractBlobFeeCaps(t *testing.T) { + ctx := context.Background() + mrpc := new(mockRPC) + chainConfig := params.MainnetChainConfig + logger := testlog.Logger(t, log.LevelError) + + oracle := NewBlobTipOracle(ctx, mrpc, chainConfig, logger, &BlobTipOracleConfig{ + PricesCacheSize: 10, + BlockCacheSize: 10, + }) + + t.Run("extract_from_blob_transactions", func(t *testing.T) { + baseFee := big.NewInt(2) // 2 wei + blobFeeCap := big.NewInt(300) // 300 wei + gasFeeCap := big.NewInt(300) // 300 wei + block := rpcBlock{ + Number: hexutil.Uint64(600), + Hash: common.Hash{}.Bytes(), + Transactions: []*types.Transaction{ + createBlobTx(big.NewInt(7), gasFeeCap, blobFeeCap), + createBlobTx(big.NewInt(8), gasFeeCap, blobFeeCap), + createBlobTx(big.NewInt(9), gasFeeCap, blobFeeCap), + createBlobTx(big.NewInt(400), gasFeeCap, blobFeeCap), + }, + } + + tips := oracle.extractTipsForBlobTxs(block, baseFee) + require.Len(t, tips, 4) + require.Equal(t, big.NewInt(7), tips[0]) + require.Equal(t, big.NewInt(8), tips[1]) + require.Equal(t, big.NewInt(9), tips[2]) + require.Equal(t, big.NewInt(298), tips[3]) // gasFeeCap - baseFee; limited to gasFeeCap, even though the blob tip cap is 400 wei + }) + + t.Run("extract ignores non-blob transactions", func(t *testing.T) { + baseFee := big.NewInt(1000000) + block := rpcBlock{ + Number: hexutil.Uint64(601), + Hash: common.Hash{}.Bytes(), + Transactions: []*types.Transaction{ + types.NewTx(&types.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(1000000), + Gas: 21000, + To: &common.Address{}, + Value: big.NewInt(0), + Data: []byte{}, + }), + }, + } + + feeCaps := oracle.extractTipsForBlobTxs(block, baseFee) + require.Len(t, feeCaps, 0) + }) + + t.Run("extract_from_mixed_transactions", func(t *testing.T) { + baseFee := big.NewInt(1000000) + blobFeeCap := big.NewInt(3000000000) + gasFeeCap := big.NewInt(3000000000) + block := rpcBlock{ + Number: hexutil.Uint64(602), + Hash: common.Hash{}.Bytes(), + Transactions: []*types.Transaction{ + types.NewTx(&types.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(1000000), + Gas: 21000, + To: &common.Address{}, + Value: big.NewInt(0), + Data: []byte{}, + }), + createBlobTx(big.NewInt(5000000), gasFeeCap, blobFeeCap), + createBlobTx(big.NewInt(6000000), gasFeeCap, blobFeeCap), + }, + } + + tips := oracle.extractTipsForBlobTxs(block, baseFee) + require.Len(t, tips, 2) + require.Equal(t, big.NewInt(5000000), tips[0]) + require.Equal(t, big.NewInt(6000000), tips[1]) + }) +} diff --git a/op-service/txmgr/estimator.go b/op-service/txmgr/estimator.go index 0a3aa026940..bef435c7238 100644 --- a/op-service/txmgr/estimator.go +++ b/op-service/txmgr/estimator.go @@ -6,26 +6,27 @@ import ( "math/big" ) -type GasPriceEstimatorFn func(ctx context.Context, backend ETHBackend) (*big.Int, *big.Int, *big.Int, error) +type GasPriceEstimatorFn func(ctx context.Context, backend ETHBackend) (*big.Int, *big.Int, *big.Int, *big.Int, error) -func DefaultGasPriceEstimatorFn(ctx context.Context, backend ETHBackend) (*big.Int, *big.Int, *big.Int, error) { +func DefaultGasPriceEstimatorFn(ctx context.Context, backend ETHBackend) (*big.Int, *big.Int, *big.Int, *big.Int, error) { tip, err := backend.SuggestGasTipCap(ctx) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } head, err := backend.HeaderByNumber(ctx, nil) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } if head.BaseFee == nil { - return nil, nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a base fee") + return nil, nil, nil, nil, errors.New("txmgr does not support pre-london blocks that do not have a base fee") } - blobFee, err := backend.BlobBaseFee(ctx) + blobBaseFee, err := backend.BlobBaseFee(ctx) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - return tip, head.BaseFee, blobFee, nil + blobTipFee := big.NewInt(0) // using zero value for the default gas price estimator (if bgpo is not available) + return tip, head.BaseFee, blobTipFee, blobBaseFee, nil } diff --git a/op-service/txmgr/metrics/noop.go b/op-service/txmgr/metrics/noop.go index 47b1a52c54f..446180ebe03 100644 --- a/op-service/txmgr/metrics/noop.go +++ b/op-service/txmgr/metrics/noop.go @@ -18,6 +18,7 @@ func (*NoopTxMetrics) TxPublished(string) {} func (*NoopTxMetrics) RecordBaseFee(*big.Int) {} func (*NoopTxMetrics) RecordBlobBaseFee(*big.Int) {} func (*NoopTxMetrics) RecordTipCap(*big.Int) {} +func (*NoopTxMetrics) RecordBlobTipCap(*big.Int) {} func (*NoopTxMetrics) RPCError() {} type FakeTxMetrics struct { diff --git a/op-service/txmgr/metrics/tx_metrics.go b/op-service/txmgr/metrics/tx_metrics.go index fe013a0b8d0..eaf8d54a920 100644 --- a/op-service/txmgr/metrics/tx_metrics.go +++ b/op-service/txmgr/metrics/tx_metrics.go @@ -20,6 +20,7 @@ type TxMetricer interface { RecordBaseFee(*big.Int) RecordBlobBaseFee(*big.Int) RecordTipCap(*big.Int) + RecordBlobTipCap(*big.Int) RPCError() } @@ -38,6 +39,7 @@ type TxMetrics struct { baseFee prometheus.Gauge blobBaseFee prometheus.Gauge tipCap prometheus.Gauge + blobTipCap prometheus.Gauge rpcError prometheus.Counter } @@ -131,6 +133,12 @@ func MakeTxMetrics(ns string, factory metrics.Factory) TxMetrics { Help: "Latest L1 suggested tip cap (in Wei)", Subsystem: "txmgr", }), + blobTipCap: factory.NewGauge(prometheus.GaugeOpts{ + Namespace: ns, + Name: "blob_tipcap_wei", + Help: "Latest Blob suggested tip cap (in Wei)", + Subsystem: "txmgr", + }), rpcError: factory.NewCounter(prometheus.CounterOpts{ Namespace: ns, Name: "rpc_error_count", @@ -189,6 +197,10 @@ func (t *TxMetrics) RecordTipCap(tipcap *big.Int) { t.tipCap.Set(tcf) } +func (t *TxMetrics) RecordBlobTipCap(blobTipCap *big.Int) { + bcf, _ := blobTipCap.Float64() + t.blobTipCap.Set(bcf) +} func (t *TxMetrics) RPCError() { t.rpcError.Inc() } diff --git a/op-service/txmgr/mocks/TxManager.go b/op-service/txmgr/mocks/TxManager.go index a87291d8319..db3acedf199 100644 --- a/op-service/txmgr/mocks/TxManager.go +++ b/op-service/txmgr/mocks/TxManager.go @@ -169,7 +169,7 @@ func (_m *TxManager) SendAsync(ctx context.Context, candidate txmgr.TxCandidate, } // SuggestGasPriceCaps provides a mock function with given fields: ctx -func (_m *TxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, *big.Int, error) { +func (_m *TxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, *big.Int, *big.Int, error) { ret := _m.Called(ctx) if len(ret) == 0 { @@ -179,8 +179,9 @@ func (_m *TxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *big.In var r0 *big.Int var r1 *big.Int var r2 *big.Int - var r3 error - if rf, ok := ret.Get(0).(func(context.Context) (*big.Int, *big.Int, *big.Int, error)); ok { + var r3 *big.Int + var r4 error + if rf, ok := ret.Get(0).(func(context.Context) (*big.Int, *big.Int, *big.Int, *big.Int, error)); ok { return rf(ctx) } if rf, ok := ret.Get(0).(func(context.Context) *big.Int); ok { @@ -207,13 +208,21 @@ func (_m *TxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *big.In } } - if rf, ok := ret.Get(3).(func(context.Context) error); ok { + if rf, ok := ret.Get(3).(func(context.Context) *big.Int); ok { r3 = rf(ctx) } else { - r3 = ret.Error(3) + if ret.Get(3) != nil { + r3 = ret.Get(3).(*big.Int) + } + } + + if rf, ok := ret.Get(4).(func(context.Context) error); ok { + r4 = rf(ctx) + } else { + r4 = ret.Error(4) } - return r0, r1, r2, r3 + return r0, r1, r2, r3, r4 } // NewTxManager creates a new instance of TxManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. diff --git a/op-service/txmgr/queue_test.go b/op-service/txmgr/queue_test.go index 81fd87aeaa6..5805d44a340 100644 --- a/op-service/txmgr/queue_test.go +++ b/op-service/txmgr/queue_test.go @@ -174,20 +174,16 @@ func TestQueue_Send(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() + backend := newMockBackendWithNonce(newGasPricer(3)) conf := configWithNumConfs(1) conf.ReceiptQueryInterval = 1 * time.Second // simulate a network send conf.RebroadcastInterval.Store(int64(2 * time.Second)) // possibly rebroadcast once before resubmission if unconfirmed conf.ResubmissionTimeout.Store(int64(3 * time.Second)) // resubmit to detect errors conf.SafeAbortNonceTooLowCount = 1 - backend := newMockBackendWithNonce(newGasPricer(3)) - mgr := &SimpleTxManager{ - chainID: conf.ChainID, - name: "TEST", - cfg: conf, - backend: backend, - l: testlog.Logger(t, log.LevelCrit), - metr: &metrics.NoopTxMetrics{}, - } + conf.Backend = backend + + mgr, err := NewSimpleTxManagerFromConfig("TEST", testlog.Logger(t, log.LevelCrit), &metrics.NoopTxMetrics{}, conf) + require.NoError(t, err) // track the nonces, and return any expected errors from tx sending var ( @@ -320,8 +316,6 @@ func TestQueue_Send_MaxPendingMetrics(t *testing.T) { metrics := metrics.FakeTxMetrics{} conf := configWithNumConfs(1) conf.Backend = backend - conf.NetworkTimeout = 1 * time.Second - conf.ChainID = big.NewInt(1) mgr, err := NewSimpleTxManagerFromConfig("TEST", testlog.Logger(t, log.LevelDebug), &metrics, conf) require.NoError(t, err) diff --git a/op-service/txmgr/rpc_test.go b/op-service/txmgr/rpc_test.go index 7b6f26bae91..ca5c1ef0ac2 100644 --- a/op-service/txmgr/rpc_test.go +++ b/op-service/txmgr/rpc_test.go @@ -11,14 +11,14 @@ import ( ) func TestTxmgrRPC(t *testing.T) { - minBaseFeeInit := big.NewInt(1000) - minPriorityFeeInit := big.NewInt(2000) + minBaseFeeInit := big.NewInt(2000) + minPriorityFeeInit := big.NewInt(1000) minBlobFeeInit := big.NewInt(3000) feeThresholdInit := big.NewInt(4000) rebroadcastIntervalInit := int64(25) bumpFeeRetryTimeInit := int64(100) - cfg := Config{} + cfg := configWithNumConfs(1) cfg.MinBaseFee.Store(minBaseFeeInit) cfg.MinTipCap.Store(minPriorityFeeInit) cfg.MinBlobTxFee.Store(minBlobFeeInit) @@ -26,7 +26,7 @@ func TestTxmgrRPC(t *testing.T) { cfg.RebroadcastInterval.Store(rebroadcastIntervalInit) cfg.ResubmissionTimeout.Store(bumpFeeRetryTimeInit) - h := newTestHarnessWithConfig(t, &cfg) + h := newTestHarnessWithConfig(t, cfg) appVersion := "test" server := oprpc.NewServer( diff --git a/op-service/txmgr/test_txmgr.go b/op-service/txmgr/test_txmgr.go index 9b885711d1d..f81d30552be 100644 --- a/op-service/txmgr/test_txmgr.go +++ b/op-service/txmgr/test_txmgr.go @@ -40,7 +40,7 @@ func (m *TestTxManager) WaitOnJammingTx(ctx context.Context) error { } func (m *TestTxManager) makeStuckTx(ctx context.Context, candidate TxCandidate) (*types.Transaction, error) { - gasTipCap, _, blobBaseFee, err := m.SuggestGasPriceCaps(ctx) + gasTipCap, _, gasBlobTipCap, blobBaseFee, err := m.SuggestGasPriceCaps(ctx) if err != nil { return nil, err } @@ -73,7 +73,7 @@ func (m *TestTxManager) makeStuckTx(ctx context.Context, candidate TxCandidate) Sidecar: sidecar, Nonce: nonce, } - if err := finishBlobTx(message, m.chainID, gasTipCap, gasFeeCap, blobFeeCap, candidate.Value); err != nil { + if err := finishBlobTx(message, m.chainID, gasBlobTipCap, gasFeeCap, blobFeeCap, candidate.Value); err != nil { return nil, err } txMessage = message diff --git a/op-service/txmgr/txmgr.go b/op-service/txmgr/txmgr.go index cac9ca891e3..fb3078d1e85 100644 --- a/op-service/txmgr/txmgr.go +++ b/op-service/txmgr/txmgr.go @@ -95,7 +95,7 @@ type TxManager interface { // SuggestGasPriceCaps suggests what the new tip, base fee, and blob base fee should be based on // the current L1 conditions. `blobBaseFee` will be nil if 4844 is not yet active. - SuggestGasPriceCaps(ctx context.Context) (tipCap *big.Int, baseFee *big.Int, blobBaseFee *big.Int, err error) + SuggestGasPriceCaps(ctx context.Context) (tipCap *big.Int, baseFee *big.Int, blobTipCap *big.Int, blobBaseFee *big.Int, err error) } // ETHBackend is the set of methods that the transaction manager uses to resubmit gas & determine @@ -168,6 +168,10 @@ func NewSimpleTxManagerFromConfig(name string, l log.Logger, m metrics.TxMetrice return nil, fmt.Errorf("invalid config: %w", err) } + if conf.GasPriceEstimatorFn == nil { + conf.GasPriceEstimatorFn = DefaultGasPriceEstimatorFn + } + return &SimpleTxManager{ chainID: conf.ChainID, name: name, @@ -349,15 +353,13 @@ func (m *SimpleTxManager) prepare(ctx context.Context, candidate TxCandidate) (* // NOTE: Otherwise, the [SimpleTxManager] will query the specified backend for an estimate. func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (*types.Transaction, error) { m.l.Debug("crafting Transaction", "blobs", len(candidate.Blobs), "calldata_size", len(candidate.TxData)) - gasTipCap, baseFee, blobBaseFee, err := m.SuggestGasPriceCaps(ctx) + gasTipCap, baseFee, blobTipCap, blobBaseFee, err := m.SuggestGasPriceCaps(ctx) if err != nil { m.metr.RPCError() return nil, fmt.Errorf("failed to get gas price info or it's too high: %w", err) } gasFeeCap := calcGasFeeCap(baseFee, gasTipCap) - gasLimit := candidate.GasLimit - var sidecar *types.BlobTxSidecar var blobHashes []common.Hash if len(candidate.Blobs) > 0 { @@ -376,32 +378,9 @@ func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (* } } - // Calculate the intrinsic gas for the transaction - callMsg := ethereum.CallMsg{ - From: m.cfg.From, - To: candidate.To, - GasTipCap: gasTipCap, - GasFeeCap: gasFeeCap, - Data: candidate.TxData, - Value: candidate.Value, - } - if len(blobHashes) > 0 { - callMsg.BlobGasFeeCap = blobBaseFee - callMsg.BlobHashes = blobHashes - } - // If the gas limit is set, we can use that as the gas - if gasLimit == 0 { - gas, err := m.backend.EstimateGas(ctx, callMsg) - if err != nil { - return nil, fmt.Errorf("failed to estimate gas: %w", errutil.TryAddRevertReason(err)) - } - gasLimit = gas - } else { - callMsg.Gas = gasLimit - _, err := m.backend.CallContract(ctx, callMsg, nil) - if err != nil { - return nil, fmt.Errorf("failed to call: %w", errutil.TryAddRevertReason(err)) - } + candidate.GasLimit, err = m.estimateOrValidateCandidateTxGas(ctx, candidate, gasTipCap, gasFeeCap, blobHashes, blobBaseFee) + if err != nil { + return nil, err } var txMessage types.TxData @@ -413,11 +392,24 @@ func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (* message := &types.BlobTx{ To: *candidate.To, Data: candidate.TxData, - Gas: gasLimit, + Gas: candidate.GasLimit, BlobHashes: blobHashes, Sidecar: sidecar, } - if err := finishBlobTx(message, m.chainID, gasTipCap, gasFeeCap, blobFeeCap, candidate.Value); err != nil { + + // graceful upgrade to using blob tip oracle, for now we just compare the fees based on current codebase and the new bgpo module + { + oracleSavings := blobTipCap.Cmp(gasTipCap) < 0 + + // TODO(18618): before activating the blob tip oracle, confirm in prod that we mostly get oracleSavings == true, otherwise + // it is not worth it using the oracle + m.l.Info("Comparison between blobTipCap and gasTipCap", "blobTipCap", blobTipCap, "gasTipCap", gasTipCap, "oracle_blob_savings", oracleSavings) + + // TODO(18618): when activating the blob tip oracle, we should remove the assignment and use the suggested blob tip cap from the oracle + blobTipCap = gasTipCap + } + + if err := finishBlobTx(message, m.chainID, blobTipCap, gasFeeCap, blobFeeCap, candidate.Value); err != nil { return nil, fmt.Errorf("failed to create blob transaction: %w", err) } txMessage = message @@ -429,12 +421,46 @@ func (m *SimpleTxManager) craftTx(ctx context.Context, candidate TxCandidate) (* GasFeeCap: gasFeeCap, Value: candidate.Value, Data: candidate.TxData, - Gas: gasLimit, + Gas: candidate.GasLimit, } } return m.signWithNextNonce(ctx, txMessage) // signer sets the nonce field of the tx } +// estimateOrValidateCandidateTxGas either: +// a) validates and returns the candidate.GasLimit (if set) using CallContract +// b) estimates the gas limit using backend.EstimatGas and returns it. +func (m *SimpleTxManager) estimateOrValidateCandidateTxGas(ctx context.Context, candidate TxCandidate, gasTipCap, gasFeeCap *big.Int, blobHashes []common.Hash, blobBaseFee *big.Int) (uint64, error) { + // Calculate the intrinsic gas for the transaction + callMsg := ethereum.CallMsg{ + From: m.cfg.From, + To: candidate.To, + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Data: candidate.TxData, + Value: candidate.Value, + } + if len(blobHashes) > 0 { + callMsg.BlobGasFeeCap = blobBaseFee + callMsg.BlobHashes = blobHashes + } + // If the gas limit is set, we can use that as the gas + if candidate.GasLimit == 0 { + gas, err := m.backend.EstimateGas(ctx, callMsg) + if err != nil { + return 0, fmt.Errorf("failed to estimate gas: %w", errutil.TryAddRevertReason(err)) + } + return gas, nil + } + + callMsg.Gas = candidate.GasLimit + _, err := m.backend.CallContract(ctx, callMsg, nil) + if err != nil { + return 0, fmt.Errorf("failed to call: %w", errutil.TryAddRevertReason(err)) + } + return candidate.GasLimit, nil +} + func (m *SimpleTxManager) GetMinBaseFee() *big.Int { return m.cfg.MinBaseFee.Load() } @@ -886,7 +912,9 @@ func (m *SimpleTxManager) queryReceipt(ctx context.Context, txHash common.Hash, // multiple of the suggested values. func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) { m.txLogger(tx, true).Info("bumping gas price for transaction") - tip, baseFee, blobBaseFee, err := m.SuggestGasPriceCaps(ctx) + tip, baseFee, blobTipCap, blobBaseFee, err := m.SuggestGasPriceCaps(ctx) + // TODO(18618): when activating the blob tip oracle, integrate blobTipCap into the rest of the logic around bumping the gas price when replacing txs + _ = blobTipCap if err != nil { m.txLogger(tx, false).Warn("failed to get suggested gas tip and base fee", "err", err) return nil, err @@ -985,24 +1013,20 @@ func (m *SimpleTxManager) increaseGasPrice(ctx context.Context, tx *types.Transa // SuggestGasPriceCaps suggests what the new tip, base fee, and blob base fee should be based on // the current L1 conditions. `blobBaseFee` will be nil if 4844 is not yet active. // Note that an error will be returned if MaxTipCap or MaxBaseFee is exceeded. -func (m *SimpleTxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, *big.Int, error) { +func (m *SimpleTxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *big.Int, *big.Int, *big.Int, error) { cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout) defer cancel() - estimatorFn := m.gasPriceEstimatorFn - if estimatorFn == nil { - estimatorFn = DefaultGasPriceEstimatorFn - } - - tip, baseFee, blobFee, err := estimatorFn(cCtx, m.backend) + tip, baseFee, blobTipCap, blobBaseFee, err := m.gasPriceEstimatorFn(cCtx, m.backend) if err != nil { m.metr.RPCError() - return nil, nil, nil, fmt.Errorf("failed to get gas price estimates: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to get gas price estimates: %w", err) } m.metr.RecordTipCap(tip) m.metr.RecordBaseFee(baseFee) - m.metr.RecordBlobBaseFee(blobFee) + m.metr.RecordBlobBaseFee(blobBaseFee) + m.metr.RecordBlobTipCap(blobTipCap) // Enforce minimum base fee and tip cap minTipCap := m.cfg.MinTipCap.Load() @@ -1010,12 +1034,21 @@ func (m *SimpleTxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *b minBaseFee := m.cfg.MinBaseFee.Load() maxBaseFee := m.cfg.MaxBaseFee.Load() + // Enforce minimum tip cap (for non-blob txs) if minTipCap != nil && tip.Cmp(minTipCap) == -1 { m.l.Debug("Enforcing min tip cap", "minTipCap", minTipCap, "origTipCap", tip) tip = new(big.Int).Set(minTipCap) } if maxTipCap != nil && tip.Cmp(maxTipCap) > 0 { - return nil, nil, nil, fmt.Errorf("tip is too high: %v, cap:%v", tip, maxTipCap) + return nil, nil, nil, nil, fmt.Errorf("tip is too high: %v, cap:%v", tip, maxTipCap) + } + + // Comparing if the configured min tip cap is higher than the suggested blob tip cap, and if so, it means we are overpaying for the transaction + if minTipCap != nil && blobTipCap.Cmp(minTipCap) == -1 { + m.l.Warn("Suggested blobTipCap is lower than the configured min tip cap for blob txs", "minTipCap", minTipCap, "blobTipCap", blobTipCap) + } + if maxTipCap != nil && blobTipCap.Cmp(maxTipCap) > 0 { + return nil, nil, nil, nil, fmt.Errorf("blob tip cap is too high: %v, cap:%v", blobTipCap, maxTipCap) } if minBaseFee != nil && baseFee.Cmp(minBaseFee) == -1 { @@ -1023,10 +1056,11 @@ func (m *SimpleTxManager) SuggestGasPriceCaps(ctx context.Context) (*big.Int, *b baseFee = new(big.Int).Set(minBaseFee) } if maxBaseFee != nil && baseFee.Cmp(maxBaseFee) > 0 { - return nil, nil, nil, fmt.Errorf("baseFee is too high: %v, cap:%v", baseFee, maxBaseFee) + return nil, nil, nil, nil, fmt.Errorf("baseFee is too high: %v, cap:%v", baseFee, maxBaseFee) } - return tip, baseFee, blobFee, nil + m.l.Info("Suggested gas price caps", "gasTipCap", tip, "baseFee", baseFee, "blobTipCap", blobTipCap, "blobBaseFee", blobBaseFee) + return tip, baseFee, blobTipCap, blobBaseFee, nil } // checkLimits checks that the tip and baseFee have not increased by more than the configured multipliers diff --git a/op-service/txmgr/txmgr_test.go b/op-service/txmgr/txmgr_test.go index 652576f401b..2e6d18ebd6b 100644 --- a/op-service/txmgr/txmgr_test.go +++ b/op-service/txmgr/txmgr_test.go @@ -57,15 +57,8 @@ func newTestHarnessWithConfig(t *testing.T, cfg *Config) *testHarness { g := newGasPricer(3) backend := newMockBackend(g) cfg.Backend = backend - mgr := &SimpleTxManager{ - chainID: cfg.ChainID, - name: "TEST", - cfg: cfg, - backend: cfg.Backend, - l: testlog.Logger(t, log.LevelCrit), - metr: &metrics.NoopTxMetrics{}, - } - + mgr, err := NewSimpleTxManagerFromConfig("TEST", testlog.Logger(t, log.LevelCrit), &metrics.NoopTxMetrics{}, cfg) + require.NoError(t, err) return &testHarness{ cfg: cfg, mgr: mgr, @@ -107,6 +100,7 @@ func (h testHarness) createBlobTxCandidate() TxCandidate { func configWithNumConfs(numConfirmations uint64) *Config { cfg := Config{ + ChainID: big.NewInt(1), ReceiptQueryInterval: 50 * time.Millisecond, NumConfirmations: numConfirmations, SafeAbortNonceTooLowCount: 3, @@ -114,10 +108,11 @@ func configWithNumConfs(numConfirmations uint64) *Config { Signer: func(ctx context.Context, from common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, - From: common.Address{}, - RetryInterval: 1 * time.Millisecond, - MaxRetries: 5, - CellProofTime: math.MaxUint64, + From: common.Address{}, + RetryInterval: 1 * time.Millisecond, + NetworkTimeout: 1 * time.Second, + MaxRetries: 5, + CellProofTime: math.MaxUint64, } cfg.RebroadcastInterval.Store(int64(time.Second / 2)) @@ -428,7 +423,6 @@ func TestTxMgrTxSendTimeout(t *testing.T) { testSendVariants(t, func(t *testing.T, send testSendVariantsFn) { conf := configWithNumConfs(1) conf.TxSendTimeout = 3 * time.Second - conf.NetworkTimeout = 1 * time.Second h := newTestHarnessWithConfig(t, conf) @@ -1262,8 +1256,8 @@ func TestIncreaseGasPrice(t *testing.T) { { name: "supports extension through custom estimator", run: func(t *testing.T) { - estimator := func(ctx context.Context, backend ETHBackend) (*big.Int, *big.Int, *big.Int, error) { - return big.NewInt(100), big.NewInt(3000), big.NewInt(100), nil + estimator := func(ctx context.Context, backend ETHBackend) (*big.Int, *big.Int, *big.Int, *big.Int, error) { + return big.NewInt(100), big.NewInt(3000), big.NewInt(100), big.NewInt(100), nil } _, newTx, err := doGasPriceIncrease(t, 70, 2000, 80, 2100, estimator) require.NoError(t, err) @@ -1319,6 +1313,9 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) { } cfg := Config{ + ChainID: big.NewInt(1), + NetworkTimeout: 1 * time.Second, + TxNotInMempoolTimeout: 1 * time.Second, ReceiptQueryInterval: 50 * time.Millisecond, NumConfirmations: 1, SafeAbortNonceTooLowCount: 3, @@ -1331,14 +1328,11 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) { cfg.FeeLimitMultiplier.Store(5) cfg.FeeLimitThreshold.Store(lt.thr) cfg.MinBlobTxFee.Store(defaultMinBlobTxFee) + cfg.Backend = &borkedBackend + + mgr, err := NewSimpleTxManagerFromConfig("TEST", testlog.Logger(t, log.LevelCrit), &metrics.NoopTxMetrics{}, &cfg) + require.NoError(t, err) - mgr := &SimpleTxManager{ - cfg: &cfg, - name: "TEST", - backend: &borkedBackend, - l: testlog.Logger(t, log.LevelCrit), - metr: &metrics.NoopTxMetrics{}, - } lastGoodTx := types.NewTx(&types.DynamicFeeTx{ GasTipCap: big.NewInt(10), GasFeeCap: big.NewInt(100), @@ -1347,7 +1341,6 @@ func testIncreaseGasPriceLimit(t *testing.T, lt gasPriceLimitTest) { // Run increaseGasPrice a bunch of times in a row to simulate a very fast resubmit loop to make // sure it errors out without a runaway fee increase. ctx := context.Background() - var err error for { var tmpTx *types.Transaction tmpTx, err = mgr.increaseGasPrice(ctx, lastGoodTx) @@ -1493,7 +1486,7 @@ func TestMinFees(t *testing.T) { conf.MinTipCap.Store(tt.minTipCap) h := newTestHarnessWithConfig(t, conf) - tip, baseFee, _, err := h.mgr.SuggestGasPriceCaps(context.Background()) + tip, baseFee, _, _, err := h.mgr.SuggestGasPriceCaps(context.Background()) require.NoError(err) if tt.expectMinBaseFee { @@ -1540,7 +1533,7 @@ func TestMaxFees(t *testing.T) { conf.MaxTipCap.Store(tt.maxTipCap) h := newTestHarnessWithConfig(t, conf) - tip, baseFee, _, err := h.mgr.SuggestGasPriceCaps(context.Background()) + tip, baseFee, _, _, err := h.mgr.SuggestGasPriceCaps(context.Background()) if tt.expectMaxBaseFee { require.Equal(err, fmt.Errorf("baseFee is too high: %v, cap:%v", h.gasPricer.baseBaseFee, tt.maxBaseFee), "expect baseFee is too high") }