Skip to content

Commit a12175d

Browse files
ranchalpjonastheis
andauthored
perf(relayer): add sequencer submission strategy with blob‐fee history and target price (#1659)
Co-authored-by: jonastheis <[email protected]> Co-authored-by: jonastheis <[email protected]>
1 parent fedfa04 commit a12175d

File tree

6 files changed

+250
-11
lines changed

6 files changed

+250
-11
lines changed

common/version/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"runtime/debug"
66
)
77

8-
var tag = "v4.5.10"
8+
var tag = "v4.5.11"
99

1010
var commit = func() string {
1111
if info, ok := debug.ReadBuildInfo(); ok {

rollup/conf/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"batch_submission": {
5555
"min_batches": 1,
5656
"max_batches": 6,
57-
"timeout": 300
57+
"timeout": 7200,
58+
"backlog_max": 75
5859
},
5960
"gas_oracle_config": {
6061
"min_gas_price": 0,

rollup/internal/config/relayer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ type BatchSubmission struct {
3838
MaxBatches int `json:"max_batches"`
3939
// The time in seconds after which a batch is considered stale and should be submitted ignoring the min batch count.
4040
TimeoutSec int64 `json:"timeout"`
41+
// The maximum number of pending batches to keep in the backlog.
42+
BacklogMax int64 `json:"backlog_max"`
4143
}
4244

4345
// ChainMonitor this config is used to get batch status from chain_monitor API.

rollup/internal/controller/relayer/l2_relayer.go

Lines changed: 219 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"math"
78
"math/big"
89
"sort"
910
"strings"
@@ -33,6 +34,32 @@ import (
3334
rutils "scroll-tech/rollup/internal/utils"
3435
)
3536

37+
// RelaxType enumerates the relaxation functions we support when
38+
// turning a baseline fee into a “target” fee.
39+
type RelaxType int
40+
41+
const (
42+
// NoRelaxation means “don’t touch the baseline” (i.e. fallback/default).
43+
NoRelaxation RelaxType = iota
44+
Exponential
45+
Sigmoid
46+
)
47+
48+
const secondsPerBlock = 12
49+
50+
// BaselineType enumerates the baseline types we support when
51+
// turning a baseline fee into a “target” fee.
52+
type BaselineType int
53+
54+
const (
55+
// PctMin means “take the minimum of the last N blocks’ fees, then
56+
// take the PCT of that”.
57+
PctMin BaselineType = iota
58+
// EWMA means “take the exponentially‐weighted moving average of
59+
// the last N blocks’ fees”.
60+
EWMA
61+
)
62+
3663
// Layer2Relayer is responsible for:
3764
// i. committing and finalizing L2 blocks on L1.
3865
// ii. updating L2 gas price oracle contract on L1.
@@ -46,6 +73,7 @@ type Layer2Relayer struct {
4673
batchOrm *orm.Batch
4774
chunkOrm *orm.Chunk
4875
l2BlockOrm *orm.L2Block
76+
l1BlockOrm *orm.L1Block
4977

5078
cfg *config.RelayerConfig
5179

@@ -61,6 +89,26 @@ type Layer2Relayer struct {
6189
metrics *l2RelayerMetrics
6290

6391
chainCfg *params.ChainConfig
92+
93+
lastFetchedBlock uint64 // highest block number ever pulled
94+
feeHistory []*big.Int // sliding window of blob fees
95+
batchStrategy StrategyParams
96+
}
97+
98+
// StrategyParams holds the per‐window fee‐submission rules.
99+
type StrategyParams struct {
100+
BaselineType BaselineType // "pct_min" or "ewma"
101+
BaselineParam float64 // percentile (0–1) or α for EWMA
102+
Gamma float64 // relaxation γ
103+
Beta float64 // relaxation β
104+
RelaxType RelaxType // Exponential or Sigmoid
105+
}
106+
107+
// bestParams maps your 2h/5h/12h windows to their best rules.
108+
var bestParams = map[uint64]StrategyParams{
109+
2 * 3600: {BaselineType: PctMin, BaselineParam: 0.10, Gamma: 0.4, Beta: 8, RelaxType: Exponential},
110+
5 * 3600: {BaselineType: PctMin, BaselineParam: 0.30, Gamma: 0.6, Beta: 20, RelaxType: Sigmoid},
111+
12 * 3600: {BaselineType: PctMin, BaselineParam: 0.50, Gamma: 0.5, Beta: 20, RelaxType: Sigmoid},
64112
}
65113

66114
// NewLayer2Relayer will return a new instance of Layer2RelayerClient
@@ -106,6 +154,7 @@ func NewLayer2Relayer(ctx context.Context, l2Client *ethclient.Client, db *gorm.
106154

107155
bundleOrm: orm.NewBundle(db),
108156
batchOrm: orm.NewBatch(db),
157+
l1BlockOrm: orm.NewL1Block(db),
109158
l2BlockOrm: orm.NewL2Block(db),
110159
chunkOrm: orm.NewChunk(db),
111160

@@ -116,9 +165,9 @@ func NewLayer2Relayer(ctx context.Context, l2Client *ethclient.Client, db *gorm.
116165
l1RollupABI: bridgeAbi.ScrollChainABI,
117166

118167
l2GasOracleABI: bridgeAbi.L2GasPriceOracleABI,
119-
120-
cfg: cfg,
121-
chainCfg: chainCfg,
168+
batchStrategy: bestParams[uint64(cfg.BatchSubmission.TimeoutSec)],
169+
cfg: cfg,
170+
chainCfg: chainCfg,
122171
}
123172

124173
// chain_monitor client
@@ -266,6 +315,10 @@ func (r *Layer2Relayer) commitGenesisBatch(batchHash string, batchHeader []byte,
266315
}
267316

268317
// ProcessPendingBatches processes the pending batches by sending commitBatch transactions to layer 1.
318+
// Pending batchess are submitted if one of the following conditions is met:
319+
// - the first batch is too old -> forceSubmit
320+
// - backlogCount > r.cfg.BatchSubmission.BacklogMax -> forceSubmit
321+
// - we have at least minBatches AND price hits a desired target price
269322
func (r *Layer2Relayer) ProcessPendingBatches() {
270323
// get pending batches from database in ascending order by their index.
271324
dbBatches, err := r.batchOrm.GetFailedAndPendingBatches(r.ctx, r.cfg.BatchSubmission.MaxBatches)
@@ -274,8 +327,40 @@ func (r *Layer2Relayer) ProcessPendingBatches() {
274327
return
275328
}
276329

277-
var batchesToSubmit []*dbBatchWithChunksAndParent
330+
// if backlog outgrow max size, force‐submit enough oldest batches
331+
backlogCount, err := r.batchOrm.GetFailedAndPendingBatchesCount(r.ctx)
332+
if err != nil {
333+
log.Error("Failed to fetch pending L2 batches", "err", err)
334+
return
335+
}
336+
278337
var forceSubmit bool
338+
339+
oldestBatchTimestamp := dbBatches[0].CreatedAt
340+
// if the batch with the oldest index is too old, we force submit all batches that we have so far in the next step
341+
if r.cfg.BatchSubmission.TimeoutSec > 0 && time.Since(oldestBatchTimestamp) > time.Duration(r.cfg.BatchSubmission.TimeoutSec)*time.Second {
342+
forceSubmit = true
343+
}
344+
345+
// force submit if backlog is too big
346+
if backlogCount > r.cfg.BatchSubmission.BacklogMax {
347+
forceSubmit = true
348+
}
349+
350+
if !forceSubmit {
351+
// check if we should skip submitting the batch based on the fee target
352+
skip, err := r.skipSubmitByFee(oldestBatchTimestamp)
353+
// return if not hitting target price
354+
if skip {
355+
log.Debug("Skipping batch submission", "reason", err)
356+
return
357+
}
358+
if err != nil {
359+
log.Warn("Failed to check if we should skip batch submission, fallback to immediate submission", "err", err)
360+
}
361+
}
362+
363+
var batchesToSubmit []*dbBatchWithChunksAndParent
279364
for i, dbBatch := range dbBatches {
280365
if i == 0 && encoding.CodecVersion(dbBatch.CodecVersion) < encoding.CodecV7 {
281366
// if the first batch is not >= V7 then we need to submit batches one by one
@@ -336,11 +421,6 @@ func (r *Layer2Relayer) ProcessPendingBatches() {
336421
break
337422
}
338423

339-
// if one of the batches is too old, we force submit all batches that we have so far in the next step
340-
if r.cfg.BatchSubmission.TimeoutSec > 0 && !forceSubmit && time.Since(dbBatch.CreatedAt) > time.Duration(r.cfg.BatchSubmission.TimeoutSec)*time.Second {
341-
forceSubmit = true
342-
}
343-
344424
if batchesToSubmitLen < r.cfg.BatchSubmission.MaxBatches {
345425
batchesToSubmit = append(batchesToSubmit, &dbBatchWithChunksAndParent{
346426
Batch: dbBatch,
@@ -1118,6 +1198,136 @@ func (r *Layer2Relayer) StopSenders() {
11181198
}
11191199
}
11201200

1201+
// fetchBlobFeeHistory returns the last WindowSec seconds of blob‐fee samples,
1202+
// by reading L1Block table’s BlobBaseFee column.
1203+
func (r *Layer2Relayer) fetchBlobFeeHistory(windowSec uint64) ([]*big.Int, error) {
1204+
latest, err := r.l1BlockOrm.GetLatestL1BlockHeight(r.ctx)
1205+
if err != nil {
1206+
return nil, fmt.Errorf("GetLatestL1BlockHeight: %w", err)
1207+
}
1208+
// bootstrap on first call
1209+
if r.lastFetchedBlock == 0 {
1210+
// start window
1211+
r.lastFetchedBlock = latest - windowSec/secondsPerBlock
1212+
}
1213+
from := r.lastFetchedBlock + 1
1214+
//if new blocks
1215+
if from <= latest {
1216+
raw, err := r.l1BlockOrm.GetBlobFeesInRange(r.ctx, from, latest)
1217+
if err != nil {
1218+
return nil, fmt.Errorf("GetBlobFeesInRange: %w", err)
1219+
}
1220+
// append them
1221+
for _, v := range raw {
1222+
r.feeHistory = append(r.feeHistory, new(big.Int).SetUint64(v))
1223+
r.lastFetchedBlock++
1224+
}
1225+
}
1226+
1227+
maxLen := int(windowSec / secondsPerBlock)
1228+
if len(r.feeHistory) > maxLen {
1229+
r.feeHistory = r.feeHistory[len(r.feeHistory)-maxLen:]
1230+
}
1231+
1232+
return r.feeHistory, nil
1233+
}
1234+
1235+
// calculateTargetPrice applies pct_min/ewma + relaxation to get a BigInt target
1236+
func calculateTargetPrice(windowSec uint64, strategy StrategyParams, firstTime time.Time, history []*big.Int) *big.Int {
1237+
var baseline float64 // baseline in Gwei (converting to float, small loss of precision)
1238+
n := len(history)
1239+
if n == 0 {
1240+
return big.NewInt(0)
1241+
}
1242+
switch strategy.BaselineType {
1243+
case PctMin:
1244+
// make a copy, sort by big.Int.Cmp, then pick the percentile element
1245+
sorted := make([]*big.Int, n)
1246+
copy(sorted, history)
1247+
sort.Slice(sorted, func(i, j int) bool {
1248+
return sorted[i].Cmp(sorted[j]) < 0
1249+
})
1250+
idx := int(strategy.BaselineParam * float64(n-1))
1251+
if idx < 0 {
1252+
idx = 0
1253+
}
1254+
baseline, _ = new(big.Float).
1255+
Quo(new(big.Float).SetInt(sorted[idx]), big.NewFloat(1e9)).
1256+
Float64()
1257+
1258+
case EWMA:
1259+
one := big.NewFloat(1)
1260+
alpha := big.NewFloat(strategy.BaselineParam)
1261+
oneMinusAlpha := new(big.Float).Sub(one, alpha)
1262+
1263+
// start from first history point
1264+
ewma := new(big.Float).
1265+
Quo(new(big.Float).SetInt(history[0]), big.NewFloat(1e9))
1266+
1267+
for i := 1; i < n; i++ {
1268+
curr := new(big.Float).
1269+
Quo(new(big.Float).SetInt(history[i]), big.NewFloat(1e9))
1270+
term1 := new(big.Float).Mul(alpha, curr)
1271+
term2 := new(big.Float).Mul(oneMinusAlpha, ewma)
1272+
ewma = new(big.Float).Add(term1, term2)
1273+
}
1274+
baseline, _ = ewma.Float64()
1275+
1276+
default:
1277+
// fallback to last element
1278+
baseline, _ = new(big.Float).
1279+
Quo(new(big.Float).SetInt(history[n-1]), big.NewFloat(1e9)).
1280+
Float64()
1281+
} // now baseline holds our baseline in float64 Gwei
1282+
1283+
// relaxation
1284+
age := time.Since(firstTime).Seconds()
1285+
frac := age / float64(windowSec)
1286+
var adjusted float64
1287+
switch strategy.RelaxType {
1288+
case Exponential:
1289+
adjusted = baseline * (1 + strategy.Gamma*math.Exp(strategy.Beta*(frac-1)))
1290+
case Sigmoid:
1291+
adjusted = baseline * (1 + strategy.Gamma/(1+math.Exp(-strategy.Beta*(frac-0.5))))
1292+
default:
1293+
adjusted = baseline
1294+
}
1295+
// back to wei
1296+
f := new(big.Float).Mul(big.NewFloat(adjusted), big.NewFloat(1e9))
1297+
out, _ := f.Int(nil)
1298+
return out
1299+
}
1300+
1301+
// skipSubmitByFee returns (true, nil) when submission should be skipped right now
1302+
// because the blob‐fee is above target and the timeout window hasn’t yet elapsed.
1303+
// Otherwise returns (false, err)
1304+
func (r *Layer2Relayer) skipSubmitByFee(oldest time.Time) (bool, error) {
1305+
windowSec := uint64(r.cfg.BatchSubmission.TimeoutSec)
1306+
1307+
hist, err := r.fetchBlobFeeHistory(windowSec)
1308+
if err != nil || len(hist) == 0 {
1309+
return false, fmt.Errorf(
1310+
"blob-fee history unavailable or empty: %w (history_length=%d)",
1311+
err, len(hist),
1312+
)
1313+
}
1314+
1315+
// calculate target & get current (in wei)
1316+
target := calculateTargetPrice(windowSec, r.batchStrategy, oldest, hist)
1317+
current := hist[len(hist)-1]
1318+
1319+
// if current fee > target and still inside the timeout window, skip
1320+
if current.Cmp(target) > 0 && time.Since(oldest) < time.Duration(windowSec)*time.Second {
1321+
return true, fmt.Errorf(
1322+
"blob-fee above target & window not yet passed; current=%s target=%s age=%s",
1323+
current.String(), target.String(), time.Since(oldest),
1324+
)
1325+
}
1326+
1327+
// otherwise proceed with submission
1328+
return false, nil
1329+
}
1330+
11211331
func addrFromSignerConfig(config *config.SignerConfig) (common.Address, error) {
11221332
switch config.SignerType {
11231333
case sender.PrivateKeySignerType:

rollup/internal/orm/batch.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ func (o *Batch) GetRollupStatusByHashList(ctx context.Context, hashes []string)
218218
return statuses, nil
219219
}
220220

221+
func (o *Batch) GetFailedAndPendingBatchesCount(ctx context.Context) (int64, error) {
222+
db := o.db.WithContext(ctx)
223+
db = db.Model(&Batch{})
224+
db = db.Where("rollup_status = ? OR rollup_status = ?", types.RollupCommitFailed, types.RollupPending)
225+
226+
var count int64
227+
if err := db.Count(&count).Error; err != nil {
228+
return 0, fmt.Errorf("Batch.GetFailedAndPendingBatchesCount error: %w", err)
229+
}
230+
return count, nil
231+
}
232+
221233
// GetFailedAndPendingBatches retrieves batches with failed or pending status up to the specified limit.
222234
// The returned batches are sorted in ascending order by their index.
223235
func (o *Batch) GetFailedAndPendingBatches(ctx context.Context, limit int) ([]*Batch, error) {

rollup/internal/orm/l1_block.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ func (o *L1Block) GetL1Blocks(ctx context.Context, fields map[string]interface{}
7171
return l1Blocks, nil
7272
}
7373

74+
// GetBlobFeesInRange returns all blob_base_fee values for blocks
75+
// with number ∈ [startBlock..endBlock], ordered by block number ascending.
76+
func (o *L1Block) GetBlobFeesInRange(ctx context.Context, startBlock, endBlock uint64) ([]uint64, error) {
77+
var fees []uint64
78+
db := o.db.WithContext(ctx).
79+
Model(&L1Block{}).
80+
Where("number >= ? AND number <= ?", startBlock, endBlock).
81+
Order("number ASC")
82+
if err := db.Pluck("blob_base_fee", &fees).Error; err != nil {
83+
return nil, fmt.Errorf("L1Block.GetBlobFeesInRange error: %w", err)
84+
}
85+
return fees, nil
86+
}
87+
7488
// InsertL1Blocks batch inserts l1 blocks.
7589
// If there's a block number conflict (e.g., due to reorg), soft deletes the existing block and inserts the new one.
7690
func (o *L1Block) InsertL1Blocks(ctx context.Context, blocks []L1Block) error {

0 commit comments

Comments
 (0)