Skip to content

Commit aaea7a8

Browse files
committed
feat: implement nicer batch limit and fees
fix bach too large
1 parent 79072b6 commit aaea7a8

10 files changed

Lines changed: 210 additions & 89 deletions

File tree

sequencer-core/src/batch.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ use ssz_derive::{Decode, Encode};
88
/// L1/app must post such inputs as `0x00 || body`. Only these are stored (body only) and executed.
99
pub const INPUT_TAG_DIRECT_INPUT: u8 = 0x00;
1010

11+
// ---------------------------------------------------------------------------
12+
// Gas-economics-derived batch sizing
13+
//
14+
// The InputBox contract charges roughly:
15+
// total_gas ≈ base_gas + delta × payload_bytes
16+
//
17+
// We charge each user-op a DA fee of (1 + α) × δ per byte, where α amortizes
18+
// the base cost across the batch:
19+
//
20+
// α × δ × n = base_gas ⟹ n = base_gas / (α × δ)
21+
//
22+
// Choosing α (the overhead fraction) determines the batch size n in bytes.
23+
// All parameters live in the `batch_policy` SQLite singleton table so they
24+
// can be hot-swapped at runtime (see 0001_schema.sql). A CHECK constraint
25+
// on that table ensures batch_size_target < const_max_batch_bytes.
26+
// ---------------------------------------------------------------------------
27+
1128
/// Batch submissions are sent as raw `ssz(Batch)` with no tag; classification at L1 is by
1229
/// attempting SSZ decode, and at the rollup by msg_sender.
1330

sequencer/src/inclusion_lane/config.rs

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@
44
use std::time::Duration;
55

66
use alloy_primitives::Address;
7-
use sequencer_core::application::Application;
8-
use sequencer_core::user_op::SignedUserOp;
97

10-
const DEFAULT_MAX_USER_OPS_PER_CHUNK: usize = 1024;
8+
const DEFAULT_MAX_USER_OPS_PER_CHUNK: usize = 64;
119
const DEFAULT_SAFE_INPUT_BUFFER_CAPACITY: usize = 2048;
1210
const DEFAULT_MAX_BATCH_OPEN: Duration = Duration::from_secs(2 * 60 * 60);
13-
const DEFAULT_MAX_BATCH_USER_OP_BYTES: usize = 1_048_576; // 1 MiB
1411
const DEFAULT_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(2);
1512

1613
#[derive(Debug, Clone, Copy)]
@@ -19,28 +16,16 @@ pub struct InclusionLaneConfig {
1916
pub max_user_ops_per_chunk: usize,
2017
pub safe_input_buffer_capacity: usize,
2118
pub max_batch_open: Duration,
22-
23-
// Soft threshold for batch rotation.
24-
//
25-
// We intentionally check this between chunks (not per user-op) to keep the hot path
26-
// simple and low-latency. This means batches can overshoot the threshold by at most
27-
// one processed chunk. API ingress bounds each user-op size, so this overshoot is
28-
// bounded by:
29-
// max_user_ops_per_chunk * (SignedUserOp::max_batch_metadata() + A::MAX_METHOD_PAYLOAD_BYTES)
30-
pub max_batch_user_op_bytes: usize,
31-
3219
pub idle_poll_interval: Duration,
3320
}
3421

3522
impl InclusionLaneConfig {
36-
pub fn for_app<A: Application>(batch_submitter_address: Address) -> Self {
23+
pub fn new(batch_submitter_address: Address) -> Self {
3724
Self {
3825
batch_submitter_address,
3926
max_user_ops_per_chunk: DEFAULT_MAX_USER_OPS_PER_CHUNK,
4027
safe_input_buffer_capacity: DEFAULT_SAFE_INPUT_BUFFER_CAPACITY,
4128
max_batch_open: DEFAULT_MAX_BATCH_OPEN,
42-
max_batch_user_op_bytes: DEFAULT_MAX_BATCH_USER_OP_BYTES
43-
.max(SignedUserOp::max_batch_metadata() + A::MAX_METHOD_PAYLOAD_BYTES),
4429
idle_poll_interval: DEFAULT_IDLE_POLL_INTERVAL,
4530
}
4631
}

sequencer/src/inclusion_lane/lane.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ impl<A: Application + 'static> InclusionLane<A> {
297297
}
298298

299299
fn should_close_batch<A: Application>(head: &WriteHead, config: &InclusionLaneConfig) -> bool {
300-
should_close_batch_by_time(head, config) || should_close_batch_by_size::<A>(head, config)
300+
should_close_batch_by_time(head, config) || should_close_batch_by_size::<A>(head)
301301
}
302302

303303
fn should_close_batch_by_time(head: &WriteHead, config: &InclusionLaneConfig) -> bool {
@@ -307,11 +307,8 @@ fn should_close_batch_by_time(head: &WriteHead, config: &InclusionLaneConfig) ->
307307
age >= config.max_batch_open
308308
}
309309

310-
fn should_close_batch_by_size<A: Application>(
311-
head: &WriteHead,
312-
config: &InclusionLaneConfig,
313-
) -> bool {
314-
user_op_count_to_bytes::<A>(head.batch_user_op_count) >= config.max_batch_user_op_bytes as u64
310+
fn should_close_batch_by_size<A: Application>(head: &WriteHead) -> bool {
311+
user_op_count_to_bytes::<A>(head.batch_user_op_count) >= head.max_batch_user_op_bytes
315312
}
316313

317314
fn execute_user_op(

sequencer/src/inclusion_lane/tests.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,6 @@ fn default_test_config() -> InclusionLaneConfig {
200200
max_user_ops_per_chunk: 16,
201201
safe_input_buffer_capacity: 16,
202202
max_batch_open: Duration::MAX,
203-
max_batch_user_op_bytes: 1_000_000_000,
204203
idle_poll_interval: Duration::from_millis(2),
205204
}
206205
}
@@ -634,9 +633,13 @@ async fn empty_batches_close_when_max_open_time_is_reached() {
634633
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
635634
async fn batch_closes_when_max_user_op_bytes_is_reached() {
636635
let db = temp_db("batch-close-size");
637-
let mut config = default_test_config();
638-
config.max_batch_user_op_bytes =
639-
SignedUserOp::max_batch_metadata() + <TestApp as Application>::MAX_METHOD_PAYLOAD_BYTES;
636+
// Set alpha high enough that batch_size_target ≤ one user op (126 bytes).
637+
// 55000*1000/(17000*26) = 124 bytes < 126.
638+
{
639+
let mut storage = Storage::open(db.path.as_str(), "NORMAL").expect("open storage");
640+
storage.set_alpha(17000, 1000).expect("set alpha");
641+
}
642+
let config = default_test_config();
640643
let (tx, shutdown, lane_handle) = start_lane(db.path.as_str(), config).await;
641644
let (pending, recv) = make_pending_user_op(0x33);
642645

sequencer/src/runtime.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ where
147147
shutdown.clone(),
148148
app,
149149
storage,
150-
InclusionLaneConfig::for_app::<A>(l1_config.batch_submitter_address),
150+
InclusionLaneConfig::new(l1_config.batch_submitter_address),
151151
);
152152
let mut input_reader_handle = input_reader.start()?;
153153

sequencer/src/storage/db.rs

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ use super::sql::{
99
sql_count_user_ops_for_frame, sql_insert_open_batch, sql_insert_open_batch_with_index,
1010
sql_insert_open_frame, sql_insert_safe_inputs_batch,
1111
sql_insert_sequenced_direct_inputs_for_frame, sql_insert_user_ops_and_sequenced_batch,
12-
sql_select_frames_for_batch, sql_select_latest_batch_index,
12+
sql_select_batch_policy, sql_select_frames_for_batch, sql_select_latest_batch_index,
1313
sql_select_latest_batch_with_user_op_count, sql_select_latest_frame_in_batch_for_batch,
1414
sql_select_max_safe_input_index, sql_select_ordered_l2_tx_count,
1515
sql_select_ordered_l2_txs_for_batch, sql_select_ordered_l2_txs_from_offset,
16-
sql_select_ordered_l2_txs_page_from_offset, sql_select_recommended_fee, sql_select_safe_block,
16+
sql_select_ordered_l2_txs_page_from_offset, sql_select_safe_block,
1717
sql_select_safe_input_payloads_for_sender, sql_select_safe_inputs_range,
1818
sql_select_total_drained_direct_inputs, sql_select_user_ops_for_frame,
19-
sql_update_recommended_fee, sql_update_safe_block,
19+
sql_update_batch_policy_alpha, sql_update_batch_policy_gas_price, sql_update_safe_block,
2020
};
2121
use super::{
22-
FrameHeader, SafeFrontier, SafeInputRange, StorageOpenError, StoredSafeInput, WriteHead,
22+
BatchPolicy, FrameHeader, SafeFrontier, SafeInputRange, StorageOpenError, StoredSafeInput,
23+
WriteHead,
2324
};
2425
use crate::inclusion_lane::PendingUserOp;
2526
use alloy_primitives::Address;
@@ -247,30 +248,44 @@ impl Storage {
247248
);
248249

249250
let now_ms = now_unix_ms();
250-
let frame_fee = query_recommended_fee(&tx)?;
251+
let policy = query_batch_policy(&tx)?;
251252
insert_open_batch_with_index(&tx, 0, now_ms)?;
252-
insert_open_frame(&tx, 0, 0, now_ms, frame_fee, safe_block)?;
253+
insert_open_frame(&tx, 0, 0, now_ms, policy.recommended_fee, safe_block)?;
253254
persist_frame_direct_sequence(&tx, 0, 0, leading_direct_range)?;
254255
tx.commit()?;
255256

256257
Ok(WriteHead {
257258
batch_index: 0,
258259
batch_created_at: from_unix_ms(now_ms),
259-
frame_fee,
260+
frame_fee: policy.recommended_fee,
260261
safe_block,
261262
batch_user_op_count: 0,
262263
open_frame_user_op_count: 0,
263264
frame_in_batch: 0,
265+
max_batch_user_op_bytes: policy.batch_size_target,
264266
})
265267
}
266268

267-
pub fn recommended_fee(&mut self) -> Result<u64> {
268-
let value = sql_select_recommended_fee(&self.conn)?;
269-
Ok(i64_to_u64(value))
269+
pub fn batch_policy(&mut self) -> Result<BatchPolicy> {
270+
let (fee, target) = sql_select_batch_policy(&self.conn)?;
271+
Ok(BatchPolicy {
272+
recommended_fee: i64_to_u64(fee),
273+
batch_size_target: i64_to_u64(target),
274+
})
275+
}
276+
277+
pub fn set_gas_price(&mut self, gas_price: u64) -> Result<()> {
278+
let changed_rows =
279+
sql_update_batch_policy_gas_price(&self.conn, u64_to_i64(gas_price))?;
280+
if changed_rows != 1 {
281+
return Err(rusqlite::Error::StatementChangedRows(changed_rows));
282+
}
283+
Ok(())
270284
}
271285

272-
pub fn set_recommended_fee(&mut self, fee: u64) -> Result<()> {
273-
let changed_rows = sql_update_recommended_fee(&self.conn, u64_to_i64(fee))?;
286+
pub fn set_alpha(&mut self, num: u64, denom: u64) -> Result<()> {
287+
let changed_rows =
288+
sql_update_batch_policy_alpha(&self.conn, u64_to_i64(num), u64_to_i64(denom))?;
274289
if changed_rows != 1 {
275290
return Err(rusqlite::Error::StatementChangedRows(changed_rows));
276291
}
@@ -317,14 +332,14 @@ impl Storage {
317332
.transaction_with_behavior(TransactionBehavior::Immediate)?;
318333
assert_write_head_matches_open_state(&tx, head)?;
319334
let now_ms = now_unix_ms();
320-
let next_frame_fee = query_recommended_fee(&tx)?;
335+
let policy = query_batch_policy(&tx)?;
321336
let next_frame_in_batch = head.frame_in_batch.saturating_add(1);
322337
insert_open_frame(
323338
&tx,
324339
head.batch_index,
325340
next_frame_in_batch,
326341
now_ms,
327-
next_frame_fee,
342+
policy.recommended_fee,
328343
next_safe_block,
329344
)?;
330345
persist_frame_direct_sequence(
@@ -334,7 +349,7 @@ impl Storage {
334349
leading_direct_range,
335350
)?;
336351
tx.commit()?;
337-
head.advance_frame(next_frame_fee, next_safe_block);
352+
head.advance_frame(policy, next_safe_block);
338353
Ok(())
339354
}
340355

@@ -348,23 +363,23 @@ impl Storage {
348363
.transaction_with_behavior(TransactionBehavior::Immediate)?;
349364
assert_write_head_matches_open_state(&tx, head)?;
350365
let now_ms = now_unix_ms();
351-
// Frame fee is committed here: we sample the current recommendation once and
352-
// assign it to the newly opened frame.
353-
let next_frame_fee = query_recommended_fee(&tx)?;
366+
// Batch policy is sampled here: the derived fee is committed to the newly
367+
// opened frame, and the batch size target is stored on the write head.
368+
let policy = query_batch_policy(&tx)?;
354369
let next_batch_index = insert_open_batch(&tx, now_ms)?;
355370
insert_open_frame(
356371
&tx,
357372
next_batch_index,
358373
0,
359374
now_ms,
360-
next_frame_fee,
375+
policy.recommended_fee,
361376
next_safe_block,
362377
)?;
363378
tx.commit()?;
364379
head.move_to_next_batch(
365380
next_batch_index,
366381
from_unix_ms(now_ms),
367-
next_frame_fee,
382+
policy,
368383
next_safe_block,
369384
);
370385
Ok(())
@@ -516,6 +531,7 @@ fn load_current_write_head(tx: &Transaction<'_>) -> Result<Option<WriteHead>> {
516531
};
517532
let (frame_in_batch, frame_fee, safe_block) = query_latest_frame_in_batch(tx, batch_index)?;
518533
let open_frame_user_op_count = query_frame_user_op_count(tx, batch_index, frame_in_batch)?;
534+
let policy = query_batch_policy(tx)?;
519535
Ok(Some(WriteHead {
520536
batch_index,
521537
batch_created_at,
@@ -524,6 +540,7 @@ fn load_current_write_head(tx: &Transaction<'_>) -> Result<Option<WriteHead>> {
524540
batch_user_op_count,
525541
open_frame_user_op_count,
526542
frame_in_batch,
543+
max_batch_user_op_bytes: policy.batch_size_target,
527544
}))
528545
}
529546

@@ -606,9 +623,12 @@ fn query_current_safe_block(tx: &Connection) -> Result<u64> {
606623
Ok(i64_to_u64(value))
607624
}
608625

609-
fn query_recommended_fee(tx: &Transaction<'_>) -> Result<u64> {
610-
let value = sql_select_recommended_fee(tx)?;
611-
Ok(i64_to_u64(value))
626+
fn query_batch_policy(tx: &Transaction<'_>) -> Result<BatchPolicy> {
627+
let (fee, target) = sql_select_batch_policy(tx)?;
628+
Ok(BatchPolicy {
629+
recommended_fee: i64_to_u64(fee),
630+
batch_size_target: i64_to_u64(target),
631+
})
612632
}
613633

614634
fn persist_frame_direct_sequence(
@@ -761,12 +781,13 @@ mod tests {
761781
}
762782

763783
#[test]
764-
fn next_frame_fee_comes_from_recommended_fee_singleton() {
765-
let db = temp_db("recommended-fee");
784+
fn next_frame_fee_comes_from_batch_policy() {
785+
let db = temp_db("batch-policy-fee");
766786
let mut storage = Storage::open(db.path.as_str(), "NORMAL").expect("open storage");
767-
assert_eq!(storage.recommended_fee().expect("default recommended"), 0);
787+
let policy = storage.batch_policy().expect("default policy");
788+
assert_eq!(policy.recommended_fee, 0);
768789

769-
storage.set_recommended_fee(7).expect("set recommended fee");
790+
storage.set_gas_price(100).expect("set gas price");
770791

771792
let mut head = storage
772793
.initialize_open_state(0, SafeInputRange::empty_at(0))
@@ -776,8 +797,10 @@ mod tests {
776797
.close_frame_and_batch(&mut head, next_safe_block)
777798
.expect("rotate batch");
778799

779-
assert_eq!(head.frame_fee, 7);
780-
assert_eq!(storage.recommended_fee().expect("read recommended"), 7);
800+
let policy = storage.batch_policy().expect("read policy");
801+
assert!(head.frame_fee > 0, "frame fee should be derived from gas_price");
802+
assert_eq!(head.frame_fee, policy.recommended_fee);
803+
assert!(head.max_batch_user_op_bytes > 0, "batch size target should be set");
781804
}
782805

783806
#[test]

sequencer/src/storage/migrations/0001_schema.sql

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,68 @@ CREATE TABLE IF NOT EXISTS l1_safe_head (
8181
INSERT OR IGNORE INTO l1_safe_head (singleton_id, block_number)
8282
VALUES (0, 0);
8383

84-
CREATE TABLE IF NOT EXISTS recommended_fees (
85-
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 0),
86-
-- Mutable recommendation consumed when opening the next frame.
87-
fee INTEGER NOT NULL CHECK (fee >= 0)
84+
-- ---------------------------------------------------------------------------
85+
-- Batch policy singleton
86+
--
87+
-- Contains operator-tunable knobs (alpha, gas_price) and on-chain constants
88+
-- (delta, base_gas, etc.). A view derives `batch_size_target` and
89+
-- `recommended_fee` from these columns, and a CHECK constraint prevents
90+
-- updates that would violate the batch size limit.
91+
--
92+
-- Gas economics:
93+
-- batch_size_target = const_base_gas * alpha_denom / (alpha_num * const_delta)
94+
-- recommended_fee = gas_price * (alpha_num + alpha_denom)
95+
-- * const_delta * const_user_op_bytes / alpha_denom
96+
-- ---------------------------------------------------------------------------
97+
CREATE TABLE IF NOT EXISTS batch_policy (
98+
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 0),
99+
100+
-- Knobs (operator-tunable via sqlite3 CLI):
101+
alpha_num INTEGER NOT NULL CHECK (alpha_num > 0),
102+
alpha_denom INTEGER NOT NULL CHECK (alpha_denom > 0),
103+
gas_price INTEGER NOT NULL CHECK (gas_price >= 0),
104+
105+
-- Constants (in DB so the CHECK can reference them):
106+
const_delta INTEGER NOT NULL CHECK (const_delta > 0),
107+
const_base_gas INTEGER NOT NULL CHECK (const_base_gas > 0),
108+
const_user_op_bytes INTEGER NOT NULL CHECK (const_user_op_bytes > 0),
109+
-- Effective max batch payload. Already includes slack for chunk overshoot
110+
-- and SSZ framing, so the CHECK is simply batch_size_target < this value.
111+
const_max_batch_bytes INTEGER NOT NULL CHECK (const_max_batch_bytes > 0),
112+
113+
-- Safety: batch_size_target < const_max_batch_bytes.
114+
CHECK (
115+
const_base_gas * alpha_denom / (alpha_num * const_delta)
116+
< const_max_batch_bytes
117+
)
88118
);
89119

90-
INSERT OR IGNORE INTO recommended_fees (singleton_id, fee)
91-
VALUES (0, 0);
120+
INSERT OR IGNORE INTO batch_policy(
121+
singleton_id,
122+
123+
alpha_num, alpha_denom, gas_price,
124+
125+
const_delta, const_base_gas,
126+
const_user_op_bytes, const_max_batch_bytes
127+
)
128+
VALUES (
129+
-- Fixed id
130+
0,
131+
132+
-- Knobs
133+
168, 1000, 0,
134+
135+
-- Constants
136+
26, 55000,
137+
126, 32000
138+
);
139+
140+
-- Derived view for reads.
141+
CREATE VIEW IF NOT EXISTS batch_policy_derived AS
142+
SELECT *,
143+
const_base_gas * alpha_denom / (alpha_num * const_delta)
144+
AS batch_size_target,
145+
146+
gas_price * (alpha_num + alpha_denom) * const_delta * const_user_op_bytes / alpha_denom
147+
AS recommended_fee
148+
FROM batch_policy;

0 commit comments

Comments
 (0)