Skip to content

Commit cd564f4

Browse files
committed
op-service: new bgpo module
1 parent 8243561 commit cd564f4

File tree

2 files changed

+1142
-0
lines changed

2 files changed

+1142
-0
lines changed

op-service/bgpo/oracle.go

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
package bgpo
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math/big"
7+
"sort"
8+
"sync"
9+
"time"
10+
11+
"github.com/ethereum/go-ethereum"
12+
"github.com/ethereum/go-ethereum/common/hexutil"
13+
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
14+
"github.com/ethereum/go-ethereum/core/types"
15+
"github.com/ethereum/go-ethereum/log"
16+
"github.com/ethereum/go-ethereum/params"
17+
18+
"github.com/ethereum-optimism/optimism/op-service/client"
19+
"github.com/ethereum-optimism/optimism/op-service/sources/caching"
20+
)
21+
22+
// BlobGasPriceOracle tracks blob base gas prices by subscribing to new block headers
23+
// and calculating the blob base fee for each block.
24+
type BlobGasPriceOracle struct {
25+
sync.Mutex
26+
27+
client client.RPC
28+
chainConfig *params.ChainConfig
29+
log log.Logger
30+
31+
// LRU cache for blob base fees by block number
32+
prices *caching.LRUCache[uint64, *big.Int]
33+
34+
// Cache for blob fee caps extracted from blocks (for SuggestBlobTipCap)
35+
blobFeeCaps *caching.LRUCache[uint64, []*big.Int]
36+
37+
// Default values for SuggestBlobTipCap
38+
maxBlocks int
39+
percentile int
40+
41+
// Track the latest block number for GetLatestBlobBaseFee
42+
latestBlock uint64
43+
44+
ctx context.Context
45+
cancel context.CancelFunc
46+
47+
sub ethereum.Subscription
48+
}
49+
50+
// rpcBlock structure for fetching blocks with transactions.
51+
// When eth_getBlockByNumber is called with true, it returns full transaction objects.
52+
type rpcBlock struct {
53+
Number hexutil.Uint64 `json:"number"`
54+
Hash hexutil.Bytes `json:"hash"`
55+
Transactions []*types.Transaction `json:"transactions"`
56+
}
57+
58+
// BlobGasPriceOracleConfig configures the blob gas price oracle.
59+
type BlobGasPriceOracleConfig struct {
60+
// PricesCacheSize is the maximum number of blob base fees to cache (default: 1000)
61+
PricesCacheSize int
62+
// BlockCacheSize is the maximum number of blocks to cache for RPC calls (default: 100)
63+
BlockCacheSize int
64+
// MaxBlocks is the default number of recent blocks to analyze in SuggestBlobTipCap (default: 20)
65+
MaxBlocks int
66+
// Percentile is the default percentile to use for blob tip cap suggestion (default: 60)
67+
Percentile int
68+
// Metrics for cache tracking (optional)
69+
Metrics caching.Metrics
70+
}
71+
72+
// DefaultBlobGasPriceOracleConfig returns a default configuration.
73+
func DefaultBlobGasPriceOracleConfig() *BlobGasPriceOracleConfig {
74+
return &BlobGasPriceOracleConfig{
75+
PricesCacheSize: 1000,
76+
BlockCacheSize: 100,
77+
MaxBlocks: 20,
78+
Percentile: 60,
79+
Metrics: nil,
80+
}
81+
}
82+
83+
// NewBlobGasPriceOracle creates a new blob gas price oracle that will subscribe
84+
// to newHeads and track blob base fees.
85+
func NewBlobGasPriceOracle(ctx context.Context, rpcClient client.RPC, chainConfig *params.ChainConfig, log log.Logger, config *BlobGasPriceOracleConfig) *BlobGasPriceOracle {
86+
defaultConfig := DefaultBlobGasPriceOracleConfig()
87+
if config == nil {
88+
config = defaultConfig
89+
}
90+
if config.PricesCacheSize <= 0 {
91+
config.PricesCacheSize = defaultConfig.PricesCacheSize
92+
}
93+
if config.BlockCacheSize <= 0 {
94+
config.BlockCacheSize = defaultConfig.BlockCacheSize
95+
}
96+
if config.MaxBlocks <= 0 {
97+
config.MaxBlocks = defaultConfig.MaxBlocks
98+
}
99+
if config.Percentile <= 0 || config.Percentile > 100 {
100+
config.Percentile = defaultConfig.Percentile
101+
}
102+
103+
oracleCtx, cancel := context.WithCancel(ctx)
104+
return &BlobGasPriceOracle{
105+
client: rpcClient,
106+
chainConfig: chainConfig,
107+
log: log.With("module", "bgpo"),
108+
prices: caching.NewLRUCache[uint64, *big.Int](config.Metrics, "bgpo_prices", config.PricesCacheSize),
109+
blobFeeCaps: caching.NewLRUCache[uint64, []*big.Int](config.Metrics, "bgpo_fee_caps", config.BlockCacheSize),
110+
maxBlocks: config.MaxBlocks,
111+
percentile: config.Percentile,
112+
ctx: oracleCtx,
113+
cancel: cancel,
114+
}
115+
}
116+
117+
// Start begins subscribing to newHeads and processing headers.
118+
// Before subscribing, it pre-populates the cache with the last MaxBlocks blocks.
119+
// This method blocks until the context is canceled or an error occurs.
120+
func (o *BlobGasPriceOracle) Start() error {
121+
// Pre-populate cache with recent blocks before subscribing
122+
if err := o.prePopulateCache(); err != nil {
123+
o.log.Warn("Failed to pre-populate cache, continuing anyway", "err", err)
124+
}
125+
126+
headers := make(chan *types.Header, 10)
127+
128+
sub, err := o.client.Subscribe(o.ctx, "eth", headers, "newHeads")
129+
if err != nil {
130+
return err
131+
}
132+
o.sub = sub
133+
134+
o.log.Info("Blob gas price oracle started, subscribed to newHeads")
135+
136+
// Process headers as they arrive
137+
for {
138+
select {
139+
case header := <-headers:
140+
if err := o.processHeader(header); err != nil {
141+
o.log.Error("Error processing header", "err", err, "block", header.Number.Uint64())
142+
}
143+
case err := <-sub.Err():
144+
if err != nil {
145+
o.log.Error("Subscription error", "err", err)
146+
return err
147+
}
148+
return nil
149+
case <-o.ctx.Done():
150+
o.log.Info("Blob gas price oracle context canceled")
151+
return nil
152+
}
153+
}
154+
}
155+
156+
// prePopulateCache fetches and processes the last MaxBlocks blocks to pre-populate the cache.
157+
func (o *BlobGasPriceOracle) prePopulateCache() error {
158+
ctx, cancel := context.WithTimeout(o.ctx, 30*time.Second)
159+
defer cancel()
160+
161+
// Get the latest block number
162+
var latestBlockNum hexutil.Uint64
163+
if err := o.client.CallContext(ctx, &latestBlockNum, "eth_blockNumber"); err != nil {
164+
return fmt.Errorf("failed to get latest block number: %w", err)
165+
}
166+
167+
latest := uint64(latestBlockNum)
168+
startBlock := latest
169+
if latest >= uint64(o.maxBlocks) {
170+
startBlock = latest - uint64(o.maxBlocks) + 1
171+
} else {
172+
startBlock = 0
173+
}
174+
175+
o.log.Info("Pre-populating cache", "from", startBlock, "to", latest, "blocks", latest-startBlock+1)
176+
177+
// Fetch and process each block
178+
for blockNum := startBlock; blockNum <= latest; blockNum++ {
179+
// Fetch header
180+
var header *types.Header
181+
blockNumHex := hexutil.EncodeUint64(blockNum)
182+
if err := o.client.CallContext(ctx, &header, "eth_getBlockByNumber", blockNumHex, false); err != nil {
183+
o.log.Debug("Failed to fetch header for pre-population", "block", blockNum, "err", err)
184+
continue
185+
}
186+
187+
// Process header (this will also trigger blob fee cap fetching)
188+
if err := o.processHeader(header); err != nil {
189+
o.log.Debug("Failed to process header for pre-population", "block", blockNum, "err", err)
190+
continue
191+
}
192+
}
193+
194+
o.log.Info("Cache pre-population complete", "blocks_processed", latest-startBlock+1)
195+
return nil
196+
}
197+
198+
// processHeader calculates and stores the blob base fee for the given header.
199+
// It also triggers an asynchronous fetch of the full block to extract blob fee caps.
200+
func (o *BlobGasPriceOracle) processHeader(header *types.Header) error {
201+
defer func(now time.Time) {
202+
o.log.Debug("Processed header", "block", header.Number.Uint64(), "time", time.Since(now))
203+
}(time.Now())
204+
205+
o.Lock()
206+
defer o.Unlock()
207+
208+
blockNum := header.Number.Uint64()
209+
210+
// Calculate blob base fee from the header
211+
var blobBaseFee *big.Int
212+
if header.ExcessBlobGas != nil {
213+
blobBaseFee = eip4844.CalcBlobFee(o.chainConfig, header)
214+
}
215+
216+
o.prices.Add(blockNum, blobBaseFee)
217+
218+
if blockNum > o.latestBlock {
219+
o.latestBlock = blockNum
220+
}
221+
222+
// Fetch full block data and extract blob fee caps
223+
o.fetchBlockBlobFeeCaps(blockNum)
224+
225+
if blobBaseFee != nil {
226+
o.log.Debug("Recorded blob base fee", "block", blockNum, "blobBaseFee", blobBaseFee.String())
227+
} else {
228+
o.log.Debug("Block does not support blob transactions", "block", blockNum)
229+
}
230+
231+
return nil
232+
}
233+
234+
// fetchBlockBlobFeeCaps fetches a block and extracts blob fee caps, storing them in cache.
235+
func (o *BlobGasPriceOracle) fetchBlockBlobFeeCaps(blockNum uint64) {
236+
// Check if we already have the blob fee caps cached
237+
if _, ok := o.blobFeeCaps.Get(blockNum); ok {
238+
return
239+
}
240+
241+
ctx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
242+
defer cancel()
243+
244+
// Fetch the block
245+
var block rpcBlock
246+
blockNumHex := hexutil.EncodeUint64(blockNum)
247+
if err := o.client.CallContext(ctx, &block, "eth_getBlockByNumber", blockNumHex, true); err != nil {
248+
o.log.Debug("Failed to fetch block for blob fee caps", "block", blockNum, "err", err)
249+
return
250+
}
251+
252+
// Extract blob fee caps directly
253+
feeCaps := o.extractBlobFeeCaps(block)
254+
255+
// Store in cache (even if empty, to avoid repeated fetches)
256+
o.blobFeeCaps.Add(blockNum, feeCaps)
257+
}
258+
259+
// GetBlobBaseFee returns the blob base fee for the given block number.
260+
// Returns nil if the block number hasn't been processed yet or if the block
261+
// doesn't support blob transactions.
262+
func (o *BlobGasPriceOracle) GetBlobBaseFee(blockNum uint64) *big.Int {
263+
price, ok := o.prices.Get(blockNum)
264+
if !ok {
265+
return nil
266+
}
267+
if price == nil {
268+
return nil
269+
}
270+
// Return a copy to prevent external modification
271+
return new(big.Int).Set(price)
272+
}
273+
274+
// GetLatestBlobBaseFee returns the blob base fee for the most recently processed block.
275+
// Returns (0, nil) if no blocks have been processed yet, the price was evicted from cache,
276+
// or if the latest block doesn't support blob transactions.
277+
func (o *BlobGasPriceOracle) GetLatestBlobBaseFee() (uint64, *big.Int) {
278+
o.Lock()
279+
defer o.Unlock()
280+
281+
if o.latestBlock == 0 {
282+
return 0, nil
283+
}
284+
285+
price, ok := o.prices.Get(o.latestBlock)
286+
if !ok {
287+
// Price was evicted from cache or block was never processed
288+
return 0, nil
289+
}
290+
if price == nil {
291+
// Block doesn't contain blob transactions
292+
return o.latestBlock, nil
293+
}
294+
// Return a copy to prevent external modification
295+
return o.latestBlock, new(big.Int).Set(price)
296+
}
297+
298+
// SuggestBlobTipCap analyzes recent blocks to suggest an appropriate blob fee cap
299+
// for blob transactions. It examines the last maxBlocks blocks and returns the
300+
// percentile-th percentile of blob fee caps from blob transactions.
301+
// This is similar to go-ethereum's oracle.SuggestTipCap but for blob prices.
302+
//
303+
// This method only reads from cache and does not make any RPC calls. Block data
304+
// is fetched during block processing.
305+
//
306+
// If no blob transactions are found in recent blocks, it returns the current blob base fee
307+
// plus a small buffer to ensure the transaction is competitive.
308+
func (o *BlobGasPriceOracle) SuggestBlobTipCap(ctx context.Context, maxBlocks int, percentile int) (*big.Int, error) {
309+
if maxBlocks <= 0 {
310+
maxBlocks = o.maxBlocks
311+
}
312+
if percentile <= 0 || percentile > 100 {
313+
percentile = o.percentile
314+
}
315+
316+
// Get the latest block number from our tracked state (no RPC call)
317+
o.Lock()
318+
latestBlockNum := o.latestBlock
319+
o.Unlock()
320+
321+
if latestBlockNum == 0 {
322+
return nil, fmt.Errorf("no blocks have been processed yet")
323+
}
324+
325+
// Collect blob fee caps from recent blocks (only from cache, no RPC calls)
326+
var blobFeeCaps []*big.Int
327+
startBlock := latestBlockNum
328+
if startBlock >= uint64(maxBlocks) {
329+
startBlock -= uint64(maxBlocks)
330+
} else {
331+
startBlock = 0
332+
}
333+
334+
for blockNum := startBlock; blockNum <= latestBlockNum; blockNum++ {
335+
// Only read from cache - no RPC calls
336+
if feeCaps, ok := o.blobFeeCaps.Get(blockNum); ok {
337+
blobFeeCaps = append(blobFeeCaps, feeCaps...)
338+
}
339+
}
340+
341+
// If we found blob transactions, calculate percentile
342+
if len(blobFeeCaps) > 0 {
343+
sort.Slice(blobFeeCaps, func(i, j int) bool {
344+
return blobFeeCaps[i].Cmp(blobFeeCaps[j]) < 0
345+
})
346+
idx := (len(blobFeeCaps) - 1) * percentile / 100
347+
suggested := new(big.Int).Set(blobFeeCaps[idx])
348+
o.log.Debug("Suggested blob tip cap from recent transactions", "suggested", suggested.String(), "samples", len(blobFeeCaps), "percentile", percentile)
349+
return suggested, nil
350+
}
351+
352+
// No blob transactions found, use current blob base fee + buffer
353+
latestBlock, blobBaseFee := o.GetLatestBlobBaseFee()
354+
if blobBaseFee == nil {
355+
return nil, fmt.Errorf("no blob base fee available and no recent blob transactions found")
356+
}
357+
358+
// Add 10% buffer to the base fee to ensure competitiveness
359+
buffer := new(big.Int).Div(blobBaseFee, big.NewInt(10))
360+
suggested := new(big.Int).Add(blobBaseFee, buffer)
361+
o.log.Debug("No recent blob transactions found, using blob base fee + buffer", "block", latestBlock, "blobBaseFee", blobBaseFee.String(), "suggested", suggested.String())
362+
return suggested, nil
363+
}
364+
365+
// extractBlobFeeCaps extracts blob fee caps from a block's transactions.
366+
func (o *BlobGasPriceOracle) extractBlobFeeCaps(block rpcBlock) []*big.Int {
367+
var feeCaps []*big.Int
368+
for _, tx := range block.Transactions {
369+
// Check if it's a blob transaction (type 3) and has blob fee cap
370+
if tx.Type() == types.BlobTxType {
371+
if blobFeeCap := tx.BlobGasFeeCap(); blobFeeCap != nil {
372+
feeCaps = append(feeCaps, blobFeeCap)
373+
}
374+
}
375+
}
376+
return feeCaps
377+
}
378+
379+
// Close stops the oracle and cleans up resources.
380+
func (o *BlobGasPriceOracle) Close() {
381+
o.cancel()
382+
if o.sub != nil {
383+
o.sub.Unsubscribe()
384+
}
385+
o.log.Info("Blob gas price oracle closed")
386+
}

0 commit comments

Comments
 (0)