diff --git a/Cargo.lock b/Cargo.lock index c63107b..0c179ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,6 +308,7 @@ dependencies = [ "alloy-network", "alloy-network-primitives", "alloy-primitives", + "alloy-pubsub", "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-rpc-types-txpool", @@ -315,6 +316,7 @@ dependencies = [ "alloy-sol-types", "alloy-transport", "alloy-transport-http", + "alloy-transport-ws", "async-stream", "async-trait", "auto_impl", @@ -335,6 +337,28 @@ dependencies = [ "wasmtimer", ] +[[package]] +name = "alloy-pubsub" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9eb9c9371738ac47f589e40aae6e418cb5f7436ad25b87575a7f94a60ccf43b" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "auto_impl", + "bimap", + "futures", + "parking_lot", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "wasmtimer", +] + [[package]] name = "alloy-rlp" version = "0.3.13" @@ -365,8 +389,10 @@ checksum = "abe0addad5b8197e851062b49dc47157444bced173b601d91e3f9b561a060a50" dependencies = [ "alloy-json-rpc", "alloy-primitives", + "alloy-pubsub", "alloy-transport", "alloy-transport-http", + "alloy-transport-ws", "futures", "pin-project", "reqwest", @@ -591,6 +617,23 @@ dependencies = [ "url", ] +[[package]] +name = "alloy-transport-ws" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527a0d9c8bbc5c3215b03ad465d4ae8775384ff5faec7c41f033f087c851a9f9" +dependencies = [ + "alloy-pubsub", + "alloy-transport", + "futures", + "http", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "ws_stream_wasm", +] + [[package]] name = "alloy-trie" version = "0.9.4" @@ -936,6 +979,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version 0.4.1", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1020,6 +1074,8 @@ dependencies = [ "crossterm", "dirs", "futures-util", + "op-alloy-consensus", + "op-alloy-network", "ratatui", "serde", "serde_json", @@ -1030,6 +1086,12 @@ dependencies = [ "url", ] +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" + [[package]] name = "bit-set" version = "0.8.0" @@ -2969,6 +3031,59 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "op-alloy-consensus" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726da827358a547be9f1e37c2a756b9e3729cb0350f43408164794b370cad8ae" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-network", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-serde", + "derive_more", + "serde", + "thiserror", +] + +[[package]] +name = "op-alloy-network" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63f27e65be273ec8fcb0b6af0fd850b550979465ab93423705ceb3dfddbd2ab" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-signer", + "op-alloy-consensus", + "op-alloy-rpc-types", +] + +[[package]] +name = "op-alloy-rpc-types" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562dd4462562c41f9fdc4d860858c40e14a25df7f983ae82047f15f08fce4d19" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "derive_more", + "op-alloy-consensus", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "openssl" version = "0.10.75" @@ -3128,6 +3243,16 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version 0.4.1", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -3643,6 +3768,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3652,6 +3790,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3798,6 +3947,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -4347,6 +4502,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4368,9 +4533,13 @@ dependencies = [ "futures-util", "log", "native-tls", + "rustls", + "rustls-pki-types", "tokio", "tokio-native-tls", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -4568,6 +4737,8 @@ dependencies = [ "log", "native-tls", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -4831,6 +5002,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5104,6 +5293,25 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version 0.4.1", + "send_wrapper", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 9209364..919a716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ alloy-rpc-types-engine = { version = "1.5.2", default-features = false } alloy-rpc-types-eth = { version = "1.5.2" } alloy-consensus = { version = "1.5.2" } alloy-trie = { version = "0.9.1", default-features = false } -alloy-provider = { version = "1.5.2" } +alloy-provider = { version = "1.5.2", features = ["ws", "pubsub"] } alloy-hardforks = { version = "0.5" } alloy-rpc-client = { version = "1.5.2" } alloy-transport-http = { version = "1.5.2" } @@ -85,6 +85,7 @@ alloy-sol-types = { version = "1.5.2" } alloy-contract = { version = "1.5.2" } # op-alloy +op-alloy-network = { version = "0.22.0", default-features = false } op-alloy-rpc-types = { version = "0.22.0", default-features = false } op-alloy-rpc-types-engine = { version = "0.22.0", default-features = false } op-alloy-consensus = { version = "0.22.0", default-features = false } diff --git a/crates/basectl/Cargo.toml b/crates/basectl/Cargo.toml index 9f59718..3064f76 100644 --- a/crates/basectl/Cargo.toml +++ b/crates/basectl/Cargo.toml @@ -22,6 +22,8 @@ dirs = { workspace = true } alloy-provider = { workspace = true } alloy-rpc-types-eth = { workspace = true } alloy-eips = { workspace = true } +op-alloy-network = { workspace = true } +op-alloy-consensus = { workspace = true } base-flashtypes = { workspace = true } url = { workspace = true } alloy-primitives = { workspace = true } diff --git a/crates/basectl/src/app/resources.rs b/crates/basectl/src/app/resources.rs index 31393d3..ac74277 100644 --- a/crates/basectl/src/app/resources.rs +++ b/crates/basectl/src/app/resources.rs @@ -7,7 +7,7 @@ use crate::{ commands::common::{DaTracker, FlashblockEntry, LoadingState}, config::ChainConfig, l1_client::FullSystemConfig, - rpc::{BacklogFetchResult, BlobSubmission, BlockDaInfo, TimestampedFlashblock}, + rpc::{BacklogFetchResult, BlockDaInfo, L1BlockInfo, L1ConnectionMode, TimestampedFlashblock}, tui::ToastState, }; @@ -28,13 +28,17 @@ pub struct DaState { pub tracker: DaTracker, pub loading: Option, pub loaded: bool, + pub l1_connection_mode: Option, buffered_flashblocks: Vec, + buffered_safe_heads: Vec, + buffered_l1_blocks: Vec, fb_rx: Option>, sync_rx: Option>, backlog_rx: Option>, block_req_tx: Option>, block_res_rx: Option>, - blob_rx: Option>, + l1_block_rx: Option>, + l1_mode_rx: Option>, } #[derive(Debug)] @@ -44,16 +48,17 @@ pub struct FlashState { pub current_gas_limit: u64, pub current_base_fee: Option, pub message_count: u64, + pub missed_flashblocks: u64, pub paused: bool, + last_flashblock: Option<(u64, u64)>, fb_rx: Option>, } impl Resources { pub fn new(config: ChainConfig) -> Self { - let has_op_node = config.op_node_rpc.is_some(); Self { config, - da: DaState::new(has_op_node), + da: DaState::new(), flash: FlashState::new(), toasts: ToastState::new(), system_config: None, @@ -78,19 +83,29 @@ impl Resources { } } +impl Default for DaState { + fn default() -> Self { + Self::new() + } +} + impl DaState { - pub fn new(has_op_node: bool) -> Self { + pub fn new() -> Self { Self { tracker: DaTracker::new(), loading: None, - loaded: !has_op_node, + loaded: false, + l1_connection_mode: None, buffered_flashblocks: Vec::new(), + buffered_safe_heads: Vec::new(), + buffered_l1_blocks: Vec::new(), fb_rx: None, sync_rx: None, backlog_rx: None, block_req_tx: None, block_res_rx: None, - blob_rx: None, + l1_block_rx: None, + l1_mode_rx: None, } } @@ -101,14 +116,18 @@ impl DaState { backlog_rx: mpsc::Receiver, block_req_tx: mpsc::Sender, block_res_rx: mpsc::Receiver, - blob_rx: mpsc::Receiver, + l1_block_rx: mpsc::Receiver, ) { self.fb_rx = Some(fb_rx); self.sync_rx = Some(sync_rx); self.backlog_rx = Some(backlog_rx); self.block_req_tx = Some(block_req_tx); self.block_res_rx = Some(block_res_rx); - self.blob_rx = Some(blob_rx); + self.l1_block_rx = Some(l1_block_rx); + } + + pub fn set_l1_mode_channel(&mut self, rx: mpsc::Receiver) { + self.l1_mode_rx = Some(rx); } pub fn poll(&mut self) { @@ -126,17 +145,20 @@ impl DaState { total_blocks: progress.total_blocks, }); } + BacklogFetchResult::Block(block) => { + self.tracker.add_backlog_block( + block.block_number, + block.da_bytes, + block.timestamp, + ); + } BacklogFetchResult::Complete(initial) => { self.tracker.set_initial_backlog(initial.safe_block, initial.da_bytes); - for fb in std::mem::take(&mut self.buffered_flashblocks) { - self.process_flashblock(&fb); - } + self.flush_buffers(); self.loaded = true; } BacklogFetchResult::Error(_) => { - for fb in std::mem::take(&mut self.buffered_flashblocks) { - self.process_flashblock(&fb); - } + self.flush_buffers(); self.loaded = true; } } @@ -163,7 +185,7 @@ impl DaState { .unwrap_or_default(); for info in block_infos { - self.tracker.update_block_da(info.block_number, info.da_bytes); + self.tracker.update_block_info(info.block_number, info.da_bytes, info.timestamp); } let safe_blocks: Vec<_> = self @@ -173,36 +195,63 @@ impl DaState { .unwrap_or_default(); for safe_block in safe_blocks { - self.tracker.update_safe_head(safe_block); + if self.loaded { + self.tracker.update_safe_head(safe_block); + } else { + self.buffered_safe_heads.push(safe_block); + } } - let blob_subs: Vec<_> = self - .blob_rx + let l1_blocks: Vec<_> = self + .l1_block_rx .as_mut() .map(|rx| std::iter::from_fn(|| rx.try_recv().ok()).collect()) .unwrap_or_default(); - for blob_sub in blob_subs { - self.tracker.record_l1_blob_submission(&blob_sub); + for l1_block in l1_blocks { + if self.loaded { + self.tracker.record_l1_block(l1_block); + } else { + self.buffered_l1_blocks.push(l1_block); + } + } + + if let Some(mode) = self.l1_mode_rx.as_mut().and_then(|rx| rx.try_recv().ok()) { + self.l1_connection_mode = Some(mode); + } + } + + fn flush_buffers(&mut self) { + for fb in std::mem::take(&mut self.buffered_flashblocks) { + self.process_flashblock(&fb); + } + for safe_block in std::mem::take(&mut self.buffered_safe_heads) { + self.tracker.update_safe_head(safe_block); + } + for l1_block in std::mem::take(&mut self.buffered_l1_blocks) { + self.tracker.record_l1_block(l1_block); } } fn process_flashblock(&mut self, fb: &Flashblock) { let block_number = fb.metadata.block_number; let da_bytes: u64 = fb.diff.transactions.iter().map(|tx| tx.len() as u64).sum(); + let timestamp = fb.base.as_ref().map(|b| b.timestamp).unwrap_or(0); if fb.index == 0 { - let prev = self + let prev_block = self .tracker .block_contributions .front() .map(|c| c.block_number) .filter(|&prev| prev < block_number); - self.tracker.add_block(block_number, da_bytes); + self.tracker.add_block(block_number, da_bytes, timestamp); - if let (Some(prev_block), Some(tx)) = (prev, &self.block_req_tx) { - let _: Result<(), _> = tx.try_send(prev_block); + if let (Some(prev), Some(tx)) = (prev_block, &self.block_req_tx) { + for missing in (prev..block_number).rev() { + let _ = tx.try_send(missing); + } } } else if let Some(contrib) = self.tracker.block_contributions.iter_mut().find(|c| c.block_number == block_number) @@ -231,7 +280,9 @@ impl FlashState { current_gas_limit: 0, current_base_fee: None, message_count: 0, + missed_flashblocks: 0, paused: false, + last_flashblock: None, fb_rx: None, } } @@ -261,6 +312,17 @@ impl FlashState { self.message_count += 1; + let block_number = fb.metadata.block_number; + let index = fb.index; + if let Some((last_block, last_index)) = self.last_flashblock { + if block_number == last_block && index > last_index + 1 { + self.missed_flashblocks += index - last_index - 1; + } else if block_number > last_block && index > 0 { + self.missed_flashblocks += index; + } + } + self.last_flashblock = Some((block_number, index)); + let base_fee = fb.base.as_ref().map(|base| base.base_fee_per_gas.try_into().unwrap_or(u128::MAX)); diff --git a/crates/basectl/src/app/runner.rs b/crates/basectl/src/app/runner.rs index ea1a906..a596675 100644 --- a/crates/basectl/src/app/runner.rs +++ b/crates/basectl/src/app/runner.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use anyhow::Result; use base_flashtypes::Flashblock; use tokio::sync::mpsc; @@ -9,9 +7,9 @@ use crate::{ config::ChainConfig, l1_client::{FullSystemConfig, fetch_full_system_config}, rpc::{ - BacklogFetchResult, BlobSubmission, BlockDaInfo, TimestampedFlashblock, - fetch_initial_backlog_with_progress, fetch_sync_status, run_block_fetcher, - run_flashblock_ws, run_flashblock_ws_timestamped, run_l1_batcher_watcher, + BacklogFetchResult, BlockDaInfo, L1BlockInfo, L1ConnectionMode, TimestampedFlashblock, + fetch_initial_backlog_with_progress, run_block_fetcher, run_flashblock_ws, + run_flashblock_ws_timestamped, run_l1_blob_watcher, run_safe_head_poller, }, tui::Toast, }; @@ -38,58 +36,53 @@ fn start_background_services(config: &ChainConfig, resources: &mut Resources) { let (fb_tx, fb_rx) = mpsc::channel::(100); let (da_fb_tx, da_fb_rx) = mpsc::channel::(100); let (sync_tx, sync_rx) = mpsc::channel::(10); - let (backlog_tx, backlog_rx) = mpsc::channel::(100); + let (backlog_tx, backlog_rx) = mpsc::channel::(1000); let (block_req_tx, block_req_rx) = mpsc::channel::(100); let (block_res_tx, block_res_rx) = mpsc::channel::(100); - let (blob_tx, blob_rx) = mpsc::channel::(100); + let (l1_block_tx, l1_block_rx) = mpsc::channel::(100); let (toast_tx, toast_rx) = mpsc::channel::(50); resources.flash.set_channel(fb_rx); - resources.da.set_channels(da_fb_rx, sync_rx, backlog_rx, block_req_tx, block_res_rx, blob_rx); + resources.da.set_channels( + da_fb_rx, + sync_rx, + backlog_rx, + block_req_tx, + block_res_rx, + l1_block_rx, + ); resources.toasts.set_channel(toast_rx); - let ws_url = config.flashblocks_ws.to_string(); - let ws_url2 = config.flashblocks_ws.to_string(); - - let toast_tx_clone = toast_tx.clone(); - tokio::spawn(run_flashblock_ws_timestamped(ws_url, fb_tx, Some(toast_tx_clone))); + tokio::spawn(run_flashblock_ws_timestamped( + config.flashblocks_ws.to_string(), + fb_tx, + toast_tx.clone(), + )); - tokio::spawn(run_flashblock_ws(ws_url2, da_fb_tx, Some(toast_tx))); + tokio::spawn(run_flashblock_ws(config.flashblocks_ws.to_string(), da_fb_tx, toast_tx.clone())); - let rpc_url = config.rpc.to_string(); - tokio::spawn(async move { - run_block_fetcher(rpc_url, block_req_rx, block_res_tx).await; - }); + tokio::spawn(run_block_fetcher( + config.rpc.to_string(), + block_req_rx, + block_res_tx, + toast_tx.clone(), + )); if let Some(batcher_addr) = config.batcher_address { - let l1_rpc = config.l1_rpc.to_string(); - tokio::spawn(async move { - run_l1_batcher_watcher(l1_rpc, batcher_addr, blob_tx).await; - }); + let (l1_mode_tx, l1_mode_rx) = mpsc::channel::(1); + resources.da.set_l1_mode_channel(l1_mode_rx); + tokio::spawn(run_l1_blob_watcher( + config.l1_rpc.to_string(), + batcher_addr, + l1_block_tx, + l1_mode_tx, + toast_tx.clone(), + )); } - if let Some(ref op_node_rpc) = config.op_node_rpc { - let l2_rpc = config.rpc.to_string(); - let rpc_url = op_node_rpc.to_string(); - tokio::spawn(async move { - fetch_initial_backlog_with_progress(l2_rpc, rpc_url, backlog_tx).await; - }); - } + tokio::spawn(fetch_initial_backlog_with_progress(config.rpc.to_string(), backlog_tx)); - if let Some(ref op_node_rpc) = config.op_node_rpc { - let rpc_url = op_node_rpc.to_string(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(2)); - loop { - interval.tick().await; - if let Ok(status) = fetch_sync_status(&rpc_url).await - && sync_tx.send(status.safe_l2.number).await.is_err() - { - break; - } - } - }); - } + tokio::spawn(run_safe_head_poller(config.rpc.to_string(), sync_tx, toast_tx)); let (sys_config_tx, sys_config_rx) = mpsc::channel::(1); resources.set_sys_config_channel(sys_config_rx); diff --git a/crates/basectl/src/app/views/command_center.rs b/crates/basectl/src/app/views/command_center.rs index 6d8fbfa..1e6df10 100644 --- a/crates/basectl/src/app/views/command_center.rs +++ b/crates/basectl/src/app/views/command_center.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use arboard::Clipboard; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -9,9 +11,9 @@ use ratatui::{ use crate::{ app::{Action, Resources, View}, commands::common::{ - COLOR_BASE_BLUE, COLOR_BURN, COLOR_GROWTH, RATE_WINDOW_2M, backlog_size_color, - build_gas_bar, format_bytes, format_duration, format_gwei, format_rate, - render_batches_table, render_da_backlog_bar, time_diff_color, + COLOR_BASE_BLUE, COLOR_BURN, COLOR_GROWTH, L1BlockFilter, RATE_WINDOW_2M, + backlog_size_color, build_gas_bar, format_bytes, format_duration, format_gwei, format_rate, + render_da_backlog_bar, render_l1_blocks_table, time_diff_color, truncate_block_number, }, tui::Keybinding, }; @@ -23,13 +25,14 @@ const KEYBINDINGS: &[Keybinding] = &[ Keybinding { key: "↑/↓", description: "Navigate" }, Keybinding { key: "Space", description: "Pause flashblocks" }, Keybinding { key: "y", description: "Copy block number" }, + Keybinding { key: "f", description: "Filter L1 blocks" }, ]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Panel { Flashblocks, Da, - Batches, + L1Blocks, } #[derive(Debug)] @@ -37,8 +40,9 @@ pub struct CommandCenterView { focused_panel: Panel, da_selected_row: usize, flash_selected_row: usize, - batch_selected_row: usize, + l1_selected_row: usize, highlighted_block: Option, + l1_filter: L1BlockFilter, } impl Default for CommandCenterView { @@ -53,24 +57,25 @@ impl CommandCenterView { focused_panel: Panel::Flashblocks, da_selected_row: 0, flash_selected_row: 0, - batch_selected_row: 0, + l1_selected_row: 0, highlighted_block: None, + l1_filter: L1BlockFilter::All, } } const fn next_panel(&mut self) { self.focused_panel = match self.focused_panel { Panel::Flashblocks => Panel::Da, - Panel::Da => Panel::Batches, - Panel::Batches => Panel::Flashblocks, + Panel::Da => Panel::L1Blocks, + Panel::L1Blocks => Panel::Flashblocks, }; } const fn prev_panel(&mut self) { self.focused_panel = match self.focused_panel { - Panel::Flashblocks => Panel::Batches, + Panel::Flashblocks => Panel::L1Blocks, Panel::Da => Panel::Flashblocks, - Panel::Batches => Panel::Da, + Panel::L1Blocks => Panel::Da, }; } @@ -85,7 +90,7 @@ impl CommandCenterView { .block_contributions .get(self.da_selected_row) .map(|c| c.block_number), - Panel::Batches => None, + Panel::L1Blocks => None, }; } @@ -102,12 +107,12 @@ impl CommandCenterView { .block_contributions .get(self.da_selected_row) .map(|c| c.block_number.to_string()), - Panel::Batches => resources + Panel::L1Blocks => resources .da .tracker - .batch_submissions - .get(self.batch_selected_row) - .and_then(|b| b.l1_block_number.map(|n| n.to_string())), + .filtered_l1_blocks(self.l1_filter) + .nth(self.l1_selected_row) + .map(|b| b.block_number.to_string()), } } } @@ -140,10 +145,15 @@ impl View for CommandCenterView { Action::None } KeyCode::Char('3') => { - self.focused_panel = Panel::Batches; + self.focused_panel = Panel::L1Blocks; self.update_highlighted_block(resources); Action::None } + KeyCode::Char('f') => { + self.l1_filter = self.l1_filter.next(); + self.l1_selected_row = 0; + Action::None + } KeyCode::Char(' ') => { resources.flash.paused = !resources.flash.paused; Action::None @@ -160,9 +170,9 @@ impl View for CommandCenterView { self.flash_selected_row -= 1; } } - Panel::Batches => { - if self.batch_selected_row > 0 { - self.batch_selected_row -= 1; + Panel::L1Blocks => { + if self.l1_selected_row > 0 { + self.l1_selected_row -= 1; } } } @@ -183,10 +193,15 @@ impl View for CommandCenterView { self.flash_selected_row += 1; } } - Panel::Batches => { - let max = resources.da.tracker.batch_submissions.len().saturating_sub(1); - if self.batch_selected_row < max { - self.batch_selected_row += 1; + Panel::L1Blocks => { + let max = resources + .da + .tracker + .filtered_l1_blocks(self.l1_filter) + .count() + .saturating_sub(1); + if self.l1_selected_row < max { + self.l1_selected_row += 1; } } } @@ -209,7 +224,7 @@ impl View for CommandCenterView { let at_top = match self.focused_panel { Panel::Flashblocks => self.flash_selected_row == 0, Panel::Da => self.da_selected_row == 0, - Panel::Batches => self.batch_selected_row == 0, + Panel::L1Blocks => self.l1_selected_row == 0, }; if at_top { self.update_highlighted_block(resources); @@ -267,16 +282,15 @@ impl View for CommandCenterView { self.highlighted_block, ); - let has_op_node = resources.config.op_node_rpc.is_some(); - render_batches_table( + render_l1_blocks_table( frame, panel_chunks[2], - &resources.da.tracker.batch_submissions, - self.focused_panel == Panel::Batches, - self.batch_selected_row, - None, - has_op_node, - "L1 Batches", + resources.da.tracker.filtered_l1_blocks(self.l1_filter), + self.focused_panel == Panel::L1Blocks, + self.l1_selected_row, + self.l1_filter, + "L1 Blocks", + resources.da.l1_connection_mode, ); } } @@ -349,7 +363,9 @@ fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources) { let backlog_color = backlog_size_color(tracker.da_backlog_bytes); let growth_rate = tracker.growth_tracker.rate_over(RATE_WINDOW_2M); let burn_rate = tracker.burn_tracker.rate_over(RATE_WINDOW_2M); - let time_since = tracker.last_blob_time.map(|t| t.elapsed()); + let time_since = tracker.last_base_blob_time.map(|t| t.elapsed()); + let base_share = tracker.base_blob_share(RATE_WINDOW_2M); + let target_usage = tracker.blob_target_usage(RATE_WINDOW_2M, resources.config.l1_blob_target); let flash_status = if resources.flash.paused { " [PAUSED]" } else { "" }; @@ -366,9 +382,21 @@ fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources) { Span::raw(" "), Span::styled("↓", Style::default().fg(COLOR_BURN)), Span::styled(format_rate(burn_rate), Style::default().fg(COLOR_BURN)), + Span::raw(" "), + Span::styled("L1: ", Style::default().fg(Color::DarkGray)), + Span::styled( + target_usage.map_or_else(|| "-".to_string(), |u| format!("{:.0}%", u * 100.0)), + Style::default().fg(Color::Yellow), + ), + Span::raw(" "), + Span::styled("Base: ", Style::default().fg(Color::DarkGray)), + Span::styled( + base_share.map_or_else(|| "-".to_string(), |s| format!("{:.0}%", s * 100.0)), + Style::default().fg(COLOR_BASE_BLUE), + ), ]), Line::from(vec![ - Span::styled("Last batch: ", Style::default().fg(Color::DarkGray)), + Span::styled("Last: ", Style::default().fg(Color::DarkGray)), Span::styled( time_since.map(format_duration).unwrap_or_else(|| "-".to_string()), Style::default().fg(Color::White), @@ -379,6 +407,16 @@ fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources) { resources.flash.message_count.to_string(), Style::default().fg(Color::White), ), + Span::raw(" "), + Span::styled("Missed: ", Style::default().fg(Color::DarkGray)), + Span::styled( + resources.flash.missed_flashblocks.to_string(), + Style::default().fg(if resources.flash.missed_flashblocks > 0 { + Color::Red + } else { + Color::Green + }), + ), Span::styled(flash_status, Style::default().fg(Color::Yellow)), ]), ]; @@ -418,6 +456,10 @@ fn render_da_panel( let inner = block.inner(area); f.render_widget(block, area); + // DA(8) + Age(6) + spacing(3) = 17 + let fixed_cols_width = 8 + 6 + 3; + let block_col_width = inner.width.saturating_sub(fixed_cols_width).clamp(4, 10) as usize; + let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let header = Row::new(vec![ Cell::from("Block").style(header_style), @@ -450,15 +492,16 @@ fn render_da_panel( }; Row::new(vec![ - Cell::from(contrib.block_number.to_string()).style(block_style), + Cell::from(truncate_block_number(contrib.block_number, block_col_width)) + .style(block_style), Cell::from(fmt_bytes(contrib.da_bytes)), - Cell::from(fmt_dur(contrib.timestamp.elapsed())), + Cell::from(fmt_dur(Duration::from_secs(contrib.age_seconds()))), ]) .style(style) }) .collect(); - let widths = [Constraint::Length(10), Constraint::Length(8), Constraint::Min(6)]; + let widths = [Constraint::Max(10), Constraint::Length(8), Constraint::Min(6)]; let table = Table::new(rows, widths).header(header); f.render_widget(table, inner); } @@ -491,6 +534,10 @@ fn render_flash_panel( let inner = block.inner(area); f.render_widget(block, area); + // Idx(4) + Txs(4) + Gas(22) + BaseFee(14) + Dt(8) + spacing(6) = 58 + let fixed_cols_width = 4 + 4 + (GAS_BAR_CHARS as u16 + 2) + 14 + 8 + 6; + let block_col_width = inner.width.saturating_sub(fixed_cols_width).clamp(4, 10) as usize; + let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let header = Row::new(vec![ Cell::from("Block").style(header_style), @@ -545,7 +592,8 @@ fn render_flash_panel( }; Row::new(vec![ - Cell::from(entry.block_number.to_string()).style(first_fb_style), + Cell::from(truncate_block_number(entry.block_number, block_col_width)) + .style(first_fb_style), Cell::from(entry.index.to_string()).style(first_fb_style), Cell::from(entry.tx_count.to_string()).style(first_fb_style), Cell::from(gas_bar), @@ -557,7 +605,7 @@ fn render_flash_panel( .collect(); let widths = [ - Constraint::Length(10), + Constraint::Max(10), Constraint::Length(4), Constraint::Length(4), Constraint::Length(GAS_BAR_CHARS as u16 + 2), diff --git a/crates/basectl/src/app/views/da_monitor.rs b/crates/basectl/src/app/views/da_monitor.rs index 8d75cfb..8726784 100644 --- a/crates/basectl/src/app/views/da_monitor.rs +++ b/crates/basectl/src/app/views/da_monitor.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -8,8 +10,9 @@ use ratatui::{ use crate::{ app::{Action, Resources, View}, commands::common::{ - COLOR_BASE_BLUE, COLOR_BURN, COLOR_GROWTH, RATE_WINDOW_2M, RATE_WINDOW_5M, RATE_WINDOW_30S, - format_duration, format_rate, render_batches_table, render_da_backlog_bar, + COLOR_BASE_BLUE, COLOR_BURN, COLOR_GROWTH, L1BlockFilter, RATE_WINDOW_2M, RATE_WINDOW_5M, + RATE_WINDOW_30S, format_duration, format_rate, render_da_backlog_bar, + render_l1_blocks_table, truncate_block_number, }, tui::Keybinding, }; @@ -20,18 +23,20 @@ const KEYBINDINGS: &[Keybinding] = &[ Keybinding { key: "↑/k ↓/j", description: "Navigate" }, Keybinding { key: "←/h →/l", description: "Switch panel" }, Keybinding { key: "Tab", description: "Next panel" }, + Keybinding { key: "f", description: "Filter L1 blocks" }, ]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Panel { - Blocks, - Batches, + L2Blocks, + L1Blocks, } #[derive(Debug)] pub struct DaMonitorView { selected_panel: Panel, selected_row: usize, + l1_filter: L1BlockFilter, } impl Default for DaMonitorView { @@ -42,21 +47,22 @@ impl Default for DaMonitorView { impl DaMonitorView { pub const fn new() -> Self { - Self { selected_panel: Panel::Blocks, selected_row: 0 } + Self { selected_panel: Panel::L2Blocks, selected_row: 0, l1_filter: L1BlockFilter::All } } - const fn next_panel(&mut self) { + #[allow(clippy::missing_const_for_fn)] + fn next_panel(&mut self) { self.selected_panel = match self.selected_panel { - Panel::Blocks => Panel::Batches, - Panel::Batches => Panel::Blocks, + Panel::L2Blocks => Panel::L1Blocks, + Panel::L1Blocks => Panel::L2Blocks, }; self.selected_row = 0; } fn panel_len(&self, panel: Panel, resources: &Resources) -> usize { match panel { - Panel::Blocks => resources.da.tracker.block_contributions.len(), - Panel::Batches => resources.da.tracker.batch_submissions.len(), + Panel::L2Blocks => resources.da.tracker.block_contributions.len(), + Panel::L1Blocks => resources.da.tracker.filtered_l1_blocks(self.l1_filter).count(), } } } @@ -89,6 +95,11 @@ impl View for DaMonitorView { self.next_panel(); Action::None } + KeyCode::Char('f') => { + self.l1_filter = self.l1_filter.next(); + self.selected_row = 0; + Action::None + } _ => Action::None, } } @@ -99,7 +110,7 @@ impl View for DaMonitorView { .constraints([Constraint::Length(3), Constraint::Length(5), Constraint::Min(0)]) .split(area); - let highlighted_block = if self.selected_panel == Panel::Blocks { + let highlighted_block = if self.selected_panel == Panel::L2Blocks { resources.da.tracker.block_contributions.get(self.selected_row).map(|c| c.block_number) } else { None @@ -114,7 +125,7 @@ impl View for DaMonitorView { highlighted_block, ); - render_stats_panel(frame, chunks[1], resources); + render_stats_panel(frame, chunks[1], resources, self.l1_filter); let panel_chunks = Layout::default() .direction(Direction::Horizontal) @@ -125,25 +136,28 @@ impl View for DaMonitorView { frame, panel_chunks[0], resources, - self.selected_panel == Panel::Blocks, + self.selected_panel == Panel::L2Blocks, self.selected_row, ); - let has_op_node = resources.config.op_node_rpc.is_some(); - render_batches_table( + render_l1_blocks_table( frame, panel_chunks[1], - &resources.da.tracker.batch_submissions, - self.selected_panel == Panel::Batches, - if self.selected_panel == Panel::Batches { self.selected_row } else { 0 }, - None, - has_op_node, - "L1 Batch Submissions", + resources.da.tracker.filtered_l1_blocks(self.l1_filter), + self.selected_panel == Panel::L1Blocks, + if self.selected_panel == Panel::L1Blocks { self.selected_row } else { 0 }, + self.l1_filter, + "L1 Blocks", + resources.da.l1_connection_mode, ); } } -fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources) { +fn format_share(share: Option) -> String { + share.map_or_else(|| "-".to_string(), |s| format!("{:.0}%", s * 100.0)) +} + +fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources, filter: L1BlockFilter) { let tracker = &resources.da.tracker; let growth_30s = tracker.growth_tracker.rate_over(RATE_WINDOW_30S); @@ -154,7 +168,11 @@ fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources) { let burn_2m = tracker.burn_tracker.rate_over(RATE_WINDOW_2M); let burn_5m = tracker.burn_tracker.rate_over(RATE_WINDOW_5M); - let time_since = tracker.last_blob_time.map(|t| t.elapsed()); + let base_share_2m = tracker.base_blob_share(RATE_WINDOW_2M); + let target_usage_2m = + tracker.blob_target_usage(RATE_WINDOW_2M, resources.config.l1_blob_target); + + let time_since = tracker.last_base_blob_time.map(|t| t.elapsed()); let lines = vec![ Line::from(vec![ @@ -180,11 +198,20 @@ fn render_stats_panel(f: &mut Frame, area: Rect, resources: &Resources) { Span::styled(format_rate(burn_5m), Style::default().fg(COLOR_BURN)), ]), Line::from(vec![ - Span::styled("Last batch: ", Style::default().fg(Color::DarkGray)), + Span::styled("L1 Target: ", Style::default().fg(Color::DarkGray)), + Span::styled(format_share(target_usage_2m), Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled("Base: ", Style::default().fg(Color::DarkGray)), + Span::styled(format_share(base_share_2m), Style::default().fg(COLOR_BASE_BLUE)), + Span::raw(" "), + Span::styled("Last: ", Style::default().fg(Color::DarkGray)), Span::styled( time_since.map(format_duration).unwrap_or_else(|| "-".to_string()), Style::default().fg(Color::White), ), + Span::raw(" "), + Span::styled("Filter: ", Style::default().fg(Color::DarkGray)), + Span::styled(filter.label(), Style::default().fg(Color::Cyan)), ]), ]; @@ -214,13 +241,17 @@ fn render_blocks_panel( let border_color = if is_active { Color::Rgb(100, 255, 100) } else { Color::Green }; let block = Block::default() - .title(" L2 Blocks (DA↑) ") + .title(format!(" L2 Blocks (DA↑) [{}] ", tracker.block_contributions.len())) .borders(Borders::ALL) .border_style(Style::default().fg(border_color)); let inner = block.inner(area); f.render_widget(block, area); + // DA(8) + Age(6) + spacing(3) = 17 + let fixed_cols_width = 8 + 6 + 3; + let block_col_width = inner.width.saturating_sub(fixed_cols_width).clamp(4, 10) as usize; + let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let header = Row::new(vec![ Cell::from("Block").style(header_style), @@ -250,15 +281,16 @@ fn render_blocks_panel( }; Row::new(vec![ - Cell::from(contrib.block_number.to_string()).style(block_style), + Cell::from(truncate_block_number(contrib.block_number, block_col_width)) + .style(block_style), Cell::from(fmt_bytes(contrib.da_bytes)), - Cell::from(fmt_dur(contrib.timestamp.elapsed())), + Cell::from(fmt_dur(Duration::from_secs(contrib.age_seconds()))), ]) .style(style) }) .collect(); - let widths = [Constraint::Length(10), Constraint::Length(8), Constraint::Min(6)]; + let widths = [Constraint::Max(10), Constraint::Length(8), Constraint::Min(6)]; let table = Table::new(rows, widths).header(header); f.render_widget(table, inner); } diff --git a/crates/basectl/src/app/views/flashblocks.rs b/crates/basectl/src/app/views/flashblocks.rs index 78f9193..e664519 100644 --- a/crates/basectl/src/app/views/flashblocks.rs +++ b/crates/basectl/src/app/views/flashblocks.rs @@ -10,7 +10,7 @@ use crate::{ app::{Action, Resources, View}, commands::common::{ COLOR_ACTIVE_BORDER, COLOR_ROW_HIGHLIGHTED, COLOR_ROW_SELECTED, build_gas_bar, format_gas, - format_gwei, time_diff_color, + format_gwei, time_diff_color, truncate_block_number, }, tui::Keybinding, }; @@ -133,10 +133,12 @@ impl View for FlashblocksView { fn render(&mut self, frame: &mut Frame, area: Rect, resources: &Resources) { let flash = &resources.flash; + let missed = resources.flash.missed_flashblocks; + let missed_str = if missed > 0 { format!(" | {missed} missed") } else { String::new() }; let title = if flash.paused { - format!(" Flashblocks [PAUSED] - {} msgs ", flash.message_count) + format!(" Flashblocks [PAUSED] - {} msgs{missed_str} ", flash.message_count) } else { - format!(" Flashblocks - {} msgs ", flash.message_count) + format!(" Flashblocks - {} msgs{missed_str} ", flash.message_count) }; let border_color = if flash.paused { Color::Yellow } else { COLOR_ACTIVE_BORDER }; @@ -155,6 +157,10 @@ impl View for FlashblocksView { .and_then(|idx| flash.entries.get(idx)) .map(|e| e.block_number); + // Idx(4) + Txs(4) + Gas(7) + BaseFee(12) + Delta(8) + Dt(8) + Fill(42) + Time(8) + spacing = ~100 + let fixed_cols_width = 4 + 4 + 7 + 12 + 8 + 8 + (GAS_BAR_CHARS as u16 + 2) + 8 + 9; + let block_col_width = inner.width.saturating_sub(fixed_cols_width).clamp(4, 10) as usize; + let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let header = Row::new(vec![ Cell::from("Block").style(header_style), @@ -236,7 +242,8 @@ impl View for FlashblocksView { let time_str = entry.timestamp.format("%H:%M:%S").to_string(); Row::new(vec![ - Cell::from(entry.block_number.to_string()).style(first_fb_style), + Cell::from(truncate_block_number(entry.block_number, block_col_width)) + .style(first_fb_style), Cell::from(entry.index.to_string()).style(first_fb_style), Cell::from(entry.tx_count.to_string()).style(first_fb_style), Cell::from(format_gas(entry.gas_used)), @@ -251,7 +258,7 @@ impl View for FlashblocksView { .collect(); let widths = [ - Constraint::Length(10), + Constraint::Max(10), Constraint::Length(4), Constraint::Length(4), Constraint::Length(7), diff --git a/crates/basectl/src/commands/common.rs b/crates/basectl/src/commands/common.rs index 187beee..ee85a40 100644 --- a/crates/basectl/src/commands/common.rs +++ b/crates/basectl/src/commands/common.rs @@ -10,7 +10,7 @@ use ratatui::{ widgets::{Block, Borders, Cell, Paragraph, Row, Table}, }; -use crate::rpc::BlobSubmission; +use crate::rpc::{L1BlockInfo, L1ConnectionMode}; pub const BLOB_SIZE: u64 = 128 * 1024; pub const MAX_HISTORY: usize = 1000; @@ -92,63 +92,114 @@ pub struct FlashblockEntry { pub struct BlockContribution { pub block_number: u64, pub da_bytes: u64, - pub timestamp: Instant, + pub timestamp: u64, +} + +impl BlockContribution { + pub fn age_seconds(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(self.timestamp) + } } #[derive(Clone, Debug)] -pub struct BatchSubmission { - pub da_bytes: u64, - pub estimated_blobs: u64, - pub actual_blobs: Option, - pub l1_blob_bytes: Option, - pub blocks_submitted: u64, - pub timestamp: Instant, - pub block_range: (u64, u64), - pub l1_block_number: Option, - pub l1_block_hash: Option, +pub struct L1Block { + pub block_number: u64, + pub block_hash: B256, + pub timestamp: u64, + pub total_blobs: u64, + pub base_blobs: u64, + pub l2_blocks_submitted: Option, + pub l2_da_bytes: Option, + pub l2_block_range: Option<(u64, u64)>, } -impl BatchSubmission { - pub fn new(da_bytes: u64, blocks_submitted: u64, block_range: (u64, u64)) -> Self { +impl L1Block { + pub const fn from_info(info: L1BlockInfo) -> Self { Self { - da_bytes, - estimated_blobs: da_bytes.div_ceil(BLOB_SIZE), - actual_blobs: None, - l1_blob_bytes: None, - blocks_submitted, - timestamp: Instant::now(), - block_range, - l1_block_number: None, - l1_block_hash: None, + block_number: info.block_number, + block_hash: info.block_hash, + timestamp: info.timestamp, + total_blobs: info.total_blobs, + base_blobs: info.base_blobs, + l2_blocks_submitted: None, + l2_da_bytes: None, + l2_block_range: None, } } - pub const fn record_l1_submission(&mut self, blob_sub: &BlobSubmission) { - self.actual_blobs = Some(blob_sub.blob_count); - self.l1_blob_bytes = Some(blob_sub.l1_blob_bytes); - self.l1_block_number = Some(blob_sub.block_number); - self.l1_block_hash = Some(blob_sub.block_hash); + pub const fn has_blobs(&self) -> bool { + self.total_blobs > 0 } - pub const fn has_l1_data(&self) -> bool { - self.actual_blobs.is_some() + pub const fn has_base_blobs(&self) -> bool { + self.base_blobs > 0 } - pub fn compression_ratio(&self) -> Option { - self.l1_blob_bytes.filter(|&b| b > 0).map(|l1_bytes| self.da_bytes as f64 / l1_bytes as f64) + pub fn blobs_display(&self) -> String { + format!("{}/{}", self.base_blobs, self.total_blobs) } - pub fn blob_count_display(&self) -> String { - self.actual_blobs - .map_or_else(|| format!("~{}", self.estimated_blobs), |actual| actual.to_string()) + pub fn block_display(&self, max_width: usize) -> String { + truncate_block_number(self.block_number, max_width) } - pub fn l1_block_display(&self) -> String { - self.l1_block_number.map(|n| n.to_string()).unwrap_or_else(|| "-".to_string()) + pub fn l2_blocks_display(&self) -> String { + self.l2_blocks_submitted.map_or_else(|| "-".to_string(), |n| n.to_string()) + } + + pub fn compression_ratio(&self) -> Option { + let da_bytes = self.l2_da_bytes?; + if self.base_blobs == 0 { + return None; + } + let l1_bytes = self.base_blobs * BLOB_SIZE; + Some(da_bytes as f64 / l1_bytes as f64) } pub fn compression_display(&self) -> String { - self.compression_ratio().map(|r| format!("{r:.2}x")).unwrap_or_else(|| "-".to_string()) + self.compression_ratio().map_or_else(|| "-".to_string(), |r| format!("{r:.2}x")) + } + + pub fn age_seconds(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now.saturating_sub(self.timestamp) + } + + pub fn age_display(&self) -> String { + format_duration(Duration::from_secs(self.age_seconds())) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum L1BlockFilter { + #[default] + All, + WithBlobs, + WithBaseBlobs, +} + +impl L1BlockFilter { + pub const fn next(self) -> Self { + match self { + Self::All => Self::WithBlobs, + Self::WithBlobs => Self::WithBaseBlobs, + Self::WithBaseBlobs => Self::All, + } + } + + pub const fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::WithBlobs => "Blobs", + Self::WithBaseBlobs => "Base", + } } } @@ -180,14 +231,23 @@ impl RateTracker { pub fn rate_over(&self, duration: Duration) -> Option { let now = Instant::now(); let cutoff = now - duration; - let samples_in_window: Vec<_> = self.samples.iter().filter(|(t, _)| *t >= cutoff).collect(); - if samples_in_window.len() < 2 { + let (count, total, earliest) = self.samples.iter().filter(|(t, _)| *t >= cutoff).fold( + (0usize, 0u64, None::), + |(count, total, earliest), (t, b)| { + (count + 1, total + b, Some(earliest.map_or(*t, |e: Instant| e.min(*t)))) + }, + ); + + if count < 2 { + return None; + } + + let elapsed = now.duration_since(earliest?).as_secs_f64(); + if elapsed <= 0.0 { return None; } - let total: u64 = samples_in_window.iter().map(|(_, b)| *b).sum(); - let elapsed = duration.as_secs_f64(); Some(total as f64 / elapsed) } } @@ -207,10 +267,15 @@ pub struct DaTracker { pub safe_l2_block: u64, pub da_backlog_bytes: u64, pub block_contributions: VecDeque, - pub batch_submissions: VecDeque, + pub l1_blocks: VecDeque, pub growth_tracker: RateTracker, pub burn_tracker: RateTracker, - pub last_blob_time: Option, + pub total_blob_tracker: RateTracker, + pub base_blob_tracker: RateTracker, + pub last_base_blob_time: Option, + /// Safe L2 block at the time of last L1→L2 attribution. + /// Used to compute the delta of L2 blocks to attribute to the next L1 blob block. + last_attributed_safe_l2: u64, } impl Default for DaTracker { @@ -225,19 +290,31 @@ impl DaTracker { safe_l2_block: 0, da_backlog_bytes: 0, block_contributions: VecDeque::with_capacity(MAX_HISTORY), - batch_submissions: VecDeque::with_capacity(MAX_HISTORY), + l1_blocks: VecDeque::with_capacity(MAX_HISTORY), growth_tracker: RateTracker::new(), burn_tracker: RateTracker::new(), - last_blob_time: None, + total_blob_tracker: RateTracker::new(), + base_blob_tracker: RateTracker::new(), + last_base_blob_time: None, + last_attributed_safe_l2: 0, } } pub const fn set_initial_backlog(&mut self, safe_block: u64, da_bytes: u64) { self.safe_l2_block = safe_block; self.da_backlog_bytes = da_bytes; + self.last_attributed_safe_l2 = safe_block; } - pub fn add_block(&mut self, block_number: u64, da_bytes: u64) { + pub fn add_backlog_block(&mut self, block_number: u64, da_bytes: u64, timestamp: u64) { + let contribution = BlockContribution { block_number, da_bytes, timestamp }; + self.block_contributions.push_front(contribution); + if self.block_contributions.len() > MAX_HISTORY { + self.block_contributions.pop_back(); + } + } + + pub fn add_block(&mut self, block_number: u64, da_bytes: u64, timestamp: u64) { if block_number <= self.safe_l2_block { return; } @@ -245,18 +322,19 @@ impl DaTracker { self.da_backlog_bytes = self.da_backlog_bytes.saturating_add(da_bytes); self.growth_tracker.add_sample(da_bytes); - let contribution = BlockContribution { block_number, da_bytes, timestamp: Instant::now() }; + let contribution = BlockContribution { block_number, da_bytes, timestamp }; self.block_contributions.push_front(contribution); if self.block_contributions.len() > MAX_HISTORY { self.block_contributions.pop_back(); } } - pub fn update_block_da(&mut self, block_number: u64, accurate_da_bytes: u64) { + pub fn update_block_info(&mut self, block_number: u64, accurate_da_bytes: u64, timestamp: u64) { for contrib in &mut self.block_contributions { if contrib.block_number == block_number { let diff = accurate_da_bytes as i64 - contrib.da_bytes as i64; contrib.da_bytes = accurate_da_bytes; + contrib.timestamp = timestamp; if block_number > self.safe_l2_block { if diff > 0 { @@ -266,55 +344,177 @@ impl DaTracker { self.da_backlog_bytes.saturating_sub((-diff) as u64); } } - break; + return; } } + + // Block not found - insert it in sorted position (gap fill) + let contribution = + BlockContribution { block_number, da_bytes: accurate_da_bytes, timestamp }; + + if block_number > self.safe_l2_block { + self.da_backlog_bytes = self.da_backlog_bytes.saturating_add(accurate_da_bytes); + } + + let insert_pos = self + .block_contributions + .iter() + .position(|c| c.block_number < block_number) + .unwrap_or(self.block_contributions.len()); + self.block_contributions.insert(insert_pos, contribution); + + if self.block_contributions.len() > MAX_HISTORY { + self.block_contributions.pop_back(); + } } - pub fn update_safe_head(&mut self, safe_block: u64) -> Option { + pub fn update_safe_head(&mut self, safe_block: u64) { if safe_block <= self.safe_l2_block { - return None; + return; } let old_safe = self.safe_l2_block; self.safe_l2_block = safe_block; - let mut submitted_bytes: u64 = 0; - let mut blocks_submitted: u64 = 0; - let mut min_block = u64::MAX; - let mut max_block = 0u64; - - for contrib in &self.block_contributions { - if contrib.block_number > old_safe && contrib.block_number <= safe_block { - submitted_bytes = submitted_bytes.saturating_add(contrib.da_bytes); - blocks_submitted += 1; - min_block = min_block.min(contrib.block_number); - max_block = max_block.max(contrib.block_number); - } + let submitted_bytes: u64 = self + .block_contributions + .iter() + .filter(|c| c.block_number > old_safe && c.block_number <= safe_block) + .map(|c| c.da_bytes) + .sum(); + + self.da_backlog_bytes = self.da_backlog_bytes.saturating_sub(submitted_bytes); + self.burn_tracker.add_sample(submitted_bytes); + + self.try_attribute_l2_to_l1(); + } + + pub fn record_l1_block(&mut self, info: L1BlockInfo) { + if self.l1_blocks.iter().any(|b| b.block_number == info.block_number) { + return; } - if submitted_bytes > 0 { - self.da_backlog_bytes = self.da_backlog_bytes.saturating_sub(submitted_bytes); - self.burn_tracker.add_sample(submitted_bytes); - self.last_blob_time = Some(Instant::now()); + let l1_block = L1Block::from_info(info); - let submission = - BatchSubmission::new(submitted_bytes, blocks_submitted, (min_block, max_block)); - self.batch_submissions.push_front(submission.clone()); - if self.batch_submissions.len() > MAX_HISTORY { - self.batch_submissions.pop_back(); - } - Some(submission) - } else { - None + if l1_block.total_blobs > 0 { + self.total_blob_tracker.add_sample(l1_block.total_blobs); } + if l1_block.base_blobs > 0 { + self.base_blob_tracker.add_sample(l1_block.base_blobs); + self.last_base_blob_time = Some(Instant::now()); + } + + self.l1_blocks.push_front(l1_block); + if self.l1_blocks.len() > MAX_HISTORY { + self.l1_blocks.pop_back(); + } + + self.try_attribute_l2_to_l1(); } - pub fn record_l1_blob_submission(&mut self, blob_sub: &BlobSubmission) { - if let Some(submission) = self.batch_submissions.iter_mut().rev().find(|s| !s.has_l1_data()) - { - submission.record_l1_submission(blob_sub); + fn try_attribute_l2_to_l1(&mut self) { + if self.safe_l2_block <= self.last_attributed_safe_l2 { + return; + } + + let mut unmatched: Vec = self + .l1_blocks + .iter() + .enumerate() + .filter(|(_, b)| b.base_blobs > 0 && b.l2_blocks_submitted.is_none()) + .map(|(i, _)| i) + .collect(); + + if unmatched.is_empty() { + return; + } + + // Process oldest first (l1_blocks is newest-first, so reverse) + unmatched.reverse(); + + let total_blobs: u64 = unmatched.iter().map(|&i| self.l1_blocks[i].base_blobs).sum(); + if total_blobs == 0 { + return; + } + + let l2_delta = self.safe_l2_block - self.last_attributed_safe_l2; + let mut cursor = self.last_attributed_safe_l2; + + // Integer apportionment: each entry gets floor(l2_delta * blobs / total_blobs), + // then distribute remainders by largest fractional part. + let mut shares: Vec = Vec::with_capacity(unmatched.len()); + let mut remainders: Vec<(usize, u64)> = Vec::with_capacity(unmatched.len()); + let mut allocated: u64 = 0; + + for (nth, &idx) in unmatched.iter().enumerate() { + let blobs = self.l1_blocks[idx].base_blobs; + let floor = l2_delta * blobs / total_blobs; + // Fractional remainder scaled by total_blobs to avoid floats: + // remainder = (l2_delta * blobs) % total_blobs + let frac = (l2_delta * blobs) % total_blobs; + shares.push(floor); + remainders.push((nth, frac)); + allocated += floor; + } + + // Distribute the leftover (l2_delta - allocated) to entries with largest remainders + let mut leftover = l2_delta - allocated; + remainders.sort_by(|a, b| b.1.cmp(&a.1)); + for &(nth, _) in &remainders { + if leftover == 0 { + break; + } + shares[nth] += 1; + leftover -= 1; + } + + for (nth, &idx) in unmatched.iter().enumerate() { + let share = shares[nth]; + if share == 0 { + // Skip zero-share entries — don't write invalid ranges + continue; + } + + let range_start = cursor + 1; + let range_end = cursor + share; + + let da_bytes: u64 = self + .block_contributions + .iter() + .filter(|c| c.block_number >= range_start && c.block_number <= range_end) + .map(|c| c.da_bytes) + .sum(); + + let block = &mut self.l1_blocks[idx]; + block.l2_blocks_submitted = Some(share); + block.l2_da_bytes = Some(da_bytes); + block.l2_block_range = Some((range_start, range_end)); + + cursor += share; } + + self.last_attributed_safe_l2 = self.safe_l2_block; + } + + pub fn filtered_l1_blocks(&self, filter: L1BlockFilter) -> impl Iterator { + self.l1_blocks.iter().filter(move |b| match filter { + L1BlockFilter::All => true, + L1BlockFilter::WithBlobs => b.has_blobs(), + L1BlockFilter::WithBaseBlobs => b.has_base_blobs(), + }) + } + + pub fn base_blob_share(&self, window: Duration) -> Option { + let total = self.total_blob_tracker.rate_over(window)?; + let base = self.base_blob_tracker.rate_over(window)?; + if total > 0.0 { Some(base / total) } else { None } + } + + pub fn blob_target_usage(&self, window: Duration, l1_blob_target: u64) -> Option { + let blob_rate = self.total_blob_tracker.rate_over(window)?; + let blocks_per_sec = 1.0 / 12.0; + let target_rate = l1_blob_target as f64 * blocks_per_sec; + Some(blob_rate / target_rate) } } @@ -344,6 +544,11 @@ pub fn format_gas(gas: u64) -> String { } } +pub fn truncate_block_number(block_number: u64, max_width: usize) -> String { + let s = block_number.to_string(); + if s.len() <= max_width { s } else { format!("…{}", &s[s.len() - (max_width - 1)..]) } +} + pub fn format_duration(d: Duration) -> String { let secs = d.as_secs(); if secs >= 3600 { @@ -443,85 +648,82 @@ pub fn build_gas_bar( } #[allow(clippy::too_many_arguments)] -pub fn render_batches_table( +pub fn render_l1_blocks_table<'a>( f: &mut Frame, area: Rect, - batches: &VecDeque, + l1_blocks: impl Iterator, is_active: bool, selected_row: usize, - highlighted_batch_idx: Option, - has_op_node: bool, + filter: L1BlockFilter, title: &str, + connection_mode: Option, ) { let border_color = if is_active { Color::Rgb(255, 100, 100) } else { Color::Red }; + let filter_label = filter.label(); + let mode_label = match connection_mode { + Some(L1ConnectionMode::WebSocket) => " WS", + Some(L1ConnectionMode::Polling) => " Poll", + None => "", + }; let block = Block::default() - .title(format!(" {title} ")) + .title(format!(" {title} [{filter_label}]{mode_label} ")) .borders(Borders::ALL) .border_style(Style::default().fg(border_color)); let inner = block.inner(area); f.render_widget(block, area); - if !has_op_node { - let lines = vec![ - Line::from(""), - Line::from(Span::styled("op_node_rpc required", Style::default().fg(Color::Yellow))), - Line::from(Span::styled("Set in config file:", Style::default().fg(Color::DarkGray))), - Line::from(Span::styled( - "~/.base/config/.yaml", - Style::default().fg(Color::Yellow), - )), - ]; - let para = Paragraph::new(lines).alignment(Alignment::Center); - f.render_widget(para, inner); - return; - } - let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let header = Row::new(vec![ Cell::from("L1 Blk").style(header_style), - Cell::from("DA").style(header_style), Cell::from("Blobs").style(header_style), + Cell::from("L2").style(header_style), Cell::from("Ratio").style(header_style), - Cell::from("Blks").style(header_style), Cell::from("Age").style(header_style), ]); - let rows: Vec = batches - .iter() + // Calculate available width for L1 block column + // Other columns need: Blobs(5) + L2(4) + Ratio(6) + Age(5) + spacing(4) = 24 + let fixed_cols_width = 5 + 4 + 6 + 5 + 4; + let l1_col_width = inner.width.saturating_sub(fixed_cols_width).clamp(4, 9) as usize; + + let rows: Vec = l1_blocks .take(inner.height.saturating_sub(1) as usize) .enumerate() - .map(|(idx, batch)| { + .map(|(idx, l1_block)| { let is_selected = is_active && idx == selected_row; - let is_highlighted = highlighted_batch_idx == Some(idx); let style = if is_selected { Style::default().fg(Color::White).bg(COLOR_ROW_SELECTED) - } else if is_highlighted { - Style::default().fg(Color::White).bg(COLOR_ROW_HIGHLIGHTED) } else { Style::default().fg(Color::White) }; + let blobs_style = if l1_block.base_blobs > 0 { + Style::default().fg(COLOR_BASE_BLUE) + } else if l1_block.total_blobs > 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + Row::new(vec![ - Cell::from(batch.l1_block_display()), - Cell::from(format_bytes(batch.da_bytes)), - Cell::from(batch.blob_count_display()), - Cell::from(batch.compression_display()), - Cell::from(batch.blocks_submitted.to_string()), - Cell::from(format_duration(batch.timestamp.elapsed())), + Cell::from(l1_block.block_display(l1_col_width)), + Cell::from(l1_block.blobs_display()).style(blobs_style), + Cell::from(l1_block.l2_blocks_display()), + Cell::from(l1_block.compression_display()), + Cell::from(l1_block.age_display()), ]) .style(style) }) .collect(); let widths = [ - Constraint::Length(9), - Constraint::Length(7), - Constraint::Length(6), - Constraint::Length(6), + Constraint::Max(9), Constraint::Length(5), + Constraint::Length(4), + Constraint::Length(6), Constraint::Min(5), ]; diff --git a/crates/basectl/src/config.rs b/crates/basectl/src/config.rs index acb5df3..f03df55 100644 --- a/crates/basectl/src/config.rs +++ b/crates/basectl/src/config.rs @@ -18,6 +18,12 @@ pub struct ChainConfig { pub system_config: Address, #[serde(default, skip_serializing_if = "Option::is_none", with = "option_address_serde")] pub batcher_address: Option
, + #[serde(default = "default_blob_target")] + pub l1_blob_target: u64, +} + +const fn default_blob_target() -> u64 { + 14 } #[derive(Debug, Clone, Deserialize, Default)] @@ -31,6 +37,7 @@ struct ChainConfigOverride { system_config: Option
, #[serde(default, with = "option_address_serde")] batcher_address: Option
, + l1_blob_target: Option, } mod address_serde { @@ -113,6 +120,7 @@ impl ChainConfig { op_node_rpc: None, system_config: "0x73a79Fab69143498Ed3712e519A88a918e1f4072".parse().unwrap(), batcher_address: Some("0x5050F69a9786F081509234F1a7F4684b5E5b76C9".parse().unwrap()), + l1_blob_target: 14, } } @@ -124,7 +132,8 @@ impl ChainConfig { l1_rpc: Url::parse("https://ethereum-sepolia-rpc.publicnode.com").unwrap(), op_node_rpc: None, system_config: "0xf272670eb55e895584501d564AfEB048bEd26194".parse().unwrap(), - batcher_address: Some("0x6CDEbe940BC0F26850285cacA097C11c33103E47".parse().unwrap()), + batcher_address: Some("0xfc56E7272EEBBBA5bC6c544e159483C4a38f8bA3".parse().unwrap()), + l1_blob_target: 14, } } @@ -146,6 +155,7 @@ impl ChainConfig { // These will be populated by fetch_rollup_config system_config: Address::ZERO, batcher_address: None, + l1_blob_target: 14, } } @@ -247,6 +257,7 @@ impl ChainConfig { op_node_rpc: overrides.op_node_rpc.or(base.op_node_rpc), system_config: overrides.system_config.unwrap_or(base.system_config), batcher_address: overrides.batcher_address.or(base.batcher_address), + l1_blob_target: overrides.l1_blob_target.unwrap_or(base.l1_blob_target), }) } diff --git a/crates/basectl/src/rpc.rs b/crates/basectl/src/rpc.rs index 34c5958..2957a4d 100644 --- a/crates/basectl/src/rpc.rs +++ b/crates/basectl/src/rpc.rs @@ -7,55 +7,97 @@ use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionTrait}; use anyhow::Result; use base_flashtypes::Flashblock; use futures_util::{StreamExt, stream}; -use serde::Deserialize; +use op_alloy_network::Optimism; use tokio::sync::mpsc; use tokio_tungstenite::connect_async; use tracing::warn; -const CONCURRENT_BLOCK_FETCHES: usize = 16; - use crate::{config::ChainConfig, l1_client::fetch_system_config_params, tui::Toast}; +const CONCURRENT_BLOCK_FETCHES: usize = 16; const DEFAULT_ELASTICITY: u64 = 6; - -/// Initial delay before reconnecting after a WebSocket failure const WS_RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(1); -/// Maximum delay between reconnection attempts const WS_RECONNECT_MAX_DELAY: Duration = Duration::from_secs(30); -#[derive(Debug, Clone, Deserialize)] -pub struct L2BlockRef { - pub hash: B256, - pub number: u64, - #[serde(rename = "parentHash")] - pub parent_hash: B256, - pub timestamp: u64, +pub async fn fetch_safe_and_latest(l2_rpc: &str) -> Result<(u64, u64)> { + let provider = ProviderBuilder::new().connect(l2_rpc).await?; + + let safe_block = provider + .get_block_by_number(BlockNumberOrTag::Safe) + .await? + .ok_or_else(|| anyhow::anyhow!("Safe block not found"))?; + + let latest_block = provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .ok_or_else(|| anyhow::anyhow!("Latest block not found"))?; + + Ok((safe_block.header.number, latest_block.header.number)) } -#[derive(Debug, Clone, Deserialize)] -pub struct SyncStatus { - pub unsafe_l2: L2BlockRef, - pub safe_l2: L2BlockRef, - pub finalized_l2: L2BlockRef, +struct RawBlockInfo { + da_bytes: u64, + tx_count: usize, + gas_used: u64, + timestamp: u64, } -pub async fn fetch_sync_status(op_node_rpc: &str) -> Result { - let provider = ProviderBuilder::new().connect(op_node_rpc).await?; - let status: SyncStatus = provider.raw_request("optimism_syncStatus".into(), ()).await?; - Ok(status) +async fn fetch_raw_block_info>( + provider: &P, + block_num: u64, +) -> Option { + let block = + provider.get_block_by_number(BlockNumberOrTag::Number(block_num)).full().await.ok()??; + + let tx_count = block.transactions.len(); + let da_bytes: u64 = + block.transactions.txns().map(|tx| tx.inner.inner.encode_2718_len() as u64).sum(); + + Some(RawBlockInfo { + da_bytes, + tx_count, + gas_used: block.header.gas_used, + timestamp: block.header.timestamp, + }) } -pub async fn run_flashblock_ws( - url: String, - tx: mpsc::Sender, - toast_tx: Option>, +pub async fn run_safe_head_poller( + l2_rpc: String, + tx: mpsc::Sender, + toast_tx: mpsc::Sender, +) { + let provider = match ProviderBuilder::new().connect(&l2_rpc).await { + Ok(p) => p, + Err(e) => { + warn!("Failed to connect to L2 RPC for safe head polling: {e}"); + let _ = toast_tx.try_send(Toast::warning("Safe head poller connection failed")); + return; + } + }; + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + interval.tick().await; + if let Ok(Some(block)) = provider.get_block_by_number(BlockNumberOrTag::Safe).await + && tx.send(block.header.number).await.is_err() + { + break; + } + } +} + +async fn run_flashblock_ws_inner( + url: &str, + tx: &mpsc::Sender, + toast_tx: &mpsc::Sender, + map_fb: impl Fn(Flashblock) -> T, ) { let mut delay = WS_RECONNECT_INITIAL_DELAY; loop { - match connect_async(&url).await { + match connect_async(url).await { Ok((ws_stream, _)) => { - delay = WS_RECONNECT_INITIAL_DELAY; // Reset delay on successful connect + delay = WS_RECONNECT_INITIAL_DELAY; let (_, mut read) = ws_stream.split(); while let Some(msg) = read.next().await { @@ -63,10 +105,8 @@ pub async fn run_flashblock_ws( Ok(m) => m, Err(e) => { warn!("Flashblock WebSocket connection error: {e}"); - if let Some(ref tx) = toast_tx { - let _ = tx.try_send(Toast::warning("WebSocket disconnected")); - } - break; // Connection error, reconnect + let _ = toast_tx.try_send(Toast::warning("WebSocket disconnected")); + break; } }; if !msg.is_binary() && !msg.is_text() { @@ -74,30 +114,35 @@ pub async fn run_flashblock_ws( } let fb = match Flashblock::try_decode_message(msg.into_data()) { Ok(fb) => fb, - Err(_) => continue, // Skip malformed messages + Err(_) => continue, }; - if tx.send(fb).await.is_err() { - return; // Channel closed, exit completely + if tx.send(map_fb(fb)).await.is_err() { + return; } } } Err(e) => { warn!("Failed to connect to flashblock WebSocket: {e}"); - if let Some(ref tx) = toast_tx { - let _ = tx.try_send(Toast::warning(format!( - "WebSocket connection failed, retrying in {}s", - delay.as_secs() - ))); - } + let _ = toast_tx.try_send(Toast::warning(format!( + "WebSocket connection failed, retrying in {}s", + delay.as_secs() + ))); } } - // Wait before reconnecting with exponential backoff tokio::time::sleep(delay).await; delay = (delay * 2).min(WS_RECONNECT_MAX_DELAY); } } +pub async fn run_flashblock_ws( + url: String, + tx: mpsc::Sender, + toast_tx: mpsc::Sender, +) { + run_flashblock_ws_inner(&url, &tx, &toast_tx, |fb| fb).await; +} + #[derive(Debug)] pub struct TimestampedFlashblock { pub flashblock: Flashblock, @@ -107,56 +152,13 @@ pub struct TimestampedFlashblock { pub async fn run_flashblock_ws_timestamped( url: String, tx: mpsc::Sender, - toast_tx: Option>, + toast_tx: mpsc::Sender, ) { - let mut delay = WS_RECONNECT_INITIAL_DELAY; - - loop { - match connect_async(&url).await { - Ok((ws_stream, _)) => { - delay = WS_RECONNECT_INITIAL_DELAY; // Reset delay on successful connect - let (_, mut read) = ws_stream.split(); - - while let Some(msg) = read.next().await { - let msg = match msg { - Ok(m) => m, - Err(e) => { - warn!("Flashblock WebSocket connection error: {e}"); - if let Some(ref tx) = toast_tx { - let _ = tx.try_send(Toast::warning("WebSocket disconnected")); - } - break; // Connection error, reconnect - } - }; - if !msg.is_binary() && !msg.is_text() { - continue; - } - let fb = match Flashblock::try_decode_message(msg.into_data()) { - Ok(fb) => fb, - Err(_) => continue, // Skip malformed messages - }; - let timestamped = - TimestampedFlashblock { flashblock: fb, received_at: chrono::Local::now() }; - if tx.send(timestamped).await.is_err() { - return; // Channel closed, exit completely - } - } - } - Err(e) => { - warn!("Failed to connect to flashblock WebSocket: {e}"); - if let Some(ref tx) = toast_tx { - let _ = tx.try_send(Toast::warning(format!( - "WebSocket connection failed, retrying in {}s", - delay.as_secs() - ))); - } - } - } - - // Wait before reconnecting with exponential backoff - tokio::time::sleep(delay).await; - delay = (delay * 2).min(WS_RECONNECT_MAX_DELAY); - } + run_flashblock_ws_inner(&url, &tx, &toast_tx, |fb| TimestampedFlashblock { + flashblock: fb, + received_at: chrono::Local::now(), + }) + .await; } #[derive(Debug, Clone)] @@ -174,111 +176,82 @@ pub struct BacklogProgress { pub da_bytes_so_far: u64, } +/// Individual block data from backlog fetch +#[derive(Debug, Clone)] +pub struct BacklogBlock { + pub block_number: u64, + pub da_bytes: u64, + pub timestamp: u64, +} + /// Result of initial backlog fetch - either progress or complete #[derive(Debug, Clone)] pub enum BacklogFetchResult { Progress(BacklogProgress), + Block(BacklogBlock), Complete(InitialBacklog), Error(String), } -pub async fn fetch_initial_backlog(l2_rpc: &str, op_node_rpc: &str) -> Result { - let status = fetch_sync_status(op_node_rpc).await?; - let safe_block = status.safe_l2.number; - let unsafe_block = status.unsafe_l2.number; - - if unsafe_block <= safe_block { - return Ok(InitialBacklog { safe_block, unsafe_block, da_bytes: 0 }); - } - - let provider = Arc::new(ProviderBuilder::new().connect(l2_rpc).await?); - - let block_numbers: Vec = ((safe_block + 1)..=unsafe_block).collect(); - let total_da_bytes: u64 = stream::iter(block_numbers) - .map(|block_num| { - let provider = Arc::clone(&provider); - async move { - provider - .get_block_by_number(BlockNumberOrTag::Number(block_num)) - .full() - .await - .ok() - .flatten() - .map(|block| { - block - .transactions - .txns() - .map(|tx| tx.inner.encode_2718_len() as u64) - .sum::() - }) - .unwrap_or(0) - } - }) - .buffer_unordered(CONCURRENT_BLOCK_FETCHES) - .fold(0u64, |acc, bytes| async move { acc.saturating_add(bytes) }) - .await; - - Ok(InitialBacklog { safe_block, unsafe_block, da_bytes: total_da_bytes }) -} - -/// Fetch initial backlog with progress updates via channel pub async fn fetch_initial_backlog_with_progress( l2_rpc: String, - op_node_rpc: String, progress_tx: tokio::sync::mpsc::Sender, ) { let result = async { - let status = fetch_sync_status(&op_node_rpc).await?; - let safe_block = status.safe_l2.number; - let unsafe_block = status.unsafe_l2.number; + let (safe_block, unsafe_block) = fetch_safe_and_latest(&l2_rpc).await?; if unsafe_block <= safe_block { return Ok(InitialBacklog { safe_block, unsafe_block, da_bytes: 0 }); } let total_blocks = unsafe_block - safe_block; - let provider = Arc::new(ProviderBuilder::new().connect(&l2_rpc).await?); - let mut total_da_bytes: u64 = 0; - let mut blocks_processed: u64 = 0; + let provider = Arc::new( + ProviderBuilder::new() + .disable_recommended_fillers() + .network::() + .connect(&l2_rpc) + .await?, + ); let block_numbers: Vec = ((safe_block + 1)..=unsafe_block).collect(); - for chunk in block_numbers.chunks(CONCURRENT_BLOCK_FETCHES) { - let chunk_results: Vec = stream::iter(chunk.iter().copied()) - .map(|block_num| { - let provider = Arc::clone(&provider); - async move { - provider - .get_block_by_number(BlockNumberOrTag::Number(block_num)) - .full() - .await - .ok() - .flatten() - .map(|block| { - block - .transactions - .txns() - .map(|tx| tx.inner.encode_2718_len() as u64) - .sum::() - }) - .unwrap_or(0) + + let mut total_da_bytes: u64 = 0; + let mut blocks_fetched: u64 = 0; + let mut blocks: Vec = Vec::with_capacity(block_numbers.len()); + + let mut fetch_stream = stream::iter(block_numbers) + .map(|block_num| { + let provider = Arc::clone(&provider); + async move { + let info = fetch_raw_block_info(&*provider, block_num).await; + BacklogBlock { + block_number: block_num, + da_bytes: info.as_ref().map(|i| i.da_bytes).unwrap_or(0), + timestamp: info.as_ref().map(|i| i.timestamp).unwrap_or(0), } - }) - .buffer_unordered(CONCURRENT_BLOCK_FETCHES) - .collect() - .await; - - for bytes in chunk_results { - total_da_bytes = total_da_bytes.saturating_add(bytes); - blocks_processed += 1; + } + }) + .buffer_unordered(CONCURRENT_BLOCK_FETCHES); + + while let Some(block) = fetch_stream.next().await { + total_da_bytes = total_da_bytes.saturating_add(block.da_bytes); + blocks.push(block); + blocks_fetched += 1; + + if blocks_fetched.is_multiple_of(10) { + let _ = progress_tx + .send(BacklogFetchResult::Progress(BacklogProgress { + current_block: blocks_fetched, + total_blocks, + da_bytes_so_far: total_da_bytes, + })) + .await; } + } - let _ = progress_tx - .send(BacklogFetchResult::Progress(BacklogProgress { - current_block: blocks_processed, - total_blocks, - da_bytes_so_far: total_da_bytes, - })) - .await; + blocks.sort_by_key(|b| b.block_number); + for block in blocks { + let _ = progress_tx.send(BacklogFetchResult::Block(block)).await; } Ok::<_, anyhow::Error>(InitialBacklog { @@ -305,33 +278,40 @@ pub struct BlockDaInfo { pub da_bytes: u64, pub tx_count: usize, pub gas_used: u64, + pub timestamp: u64, } pub async fn run_block_fetcher( l2_rpc: String, mut request_rx: mpsc::Receiver, result_tx: mpsc::Sender, + toast_tx: mpsc::Sender, ) { - let provider = match ProviderBuilder::new().connect(&l2_rpc).await { + let provider = match ProviderBuilder::new() + .disable_recommended_fillers() + .network::() + .connect(&l2_rpc) + .await + { Ok(p) => p, - Err(_) => return, + Err(e) => { + warn!("Failed to connect to L2 RPC for block fetcher: {e}"); + let _ = toast_tx.try_send(Toast::warning("Block fetcher connection failed")); + return; + } }; while let Some(block_num) = request_rx.recv().await { - if let Ok(Some(block)) = - provider.get_block_by_number(BlockNumberOrTag::Number(block_num)).full().await - { - let da_bytes: u64 = - block.transactions.txns().map(|tx| tx.inner.encode_2718_len() as u64).sum(); - - let info = BlockDaInfo { + if let Some(info) = fetch_raw_block_info(&provider, block_num).await { + let block_info = BlockDaInfo { block_number: block_num, - da_bytes, - tx_count: block.transactions.len(), - gas_used: block.header.gas_used, + da_bytes: info.da_bytes, + tx_count: info.tx_count, + gas_used: info.gas_used, + timestamp: info.timestamp, }; - if result_tx.send(info).await.is_err() { + if result_tx.send(block_info).await.is_err() { break; } } @@ -385,28 +365,104 @@ pub async fn fetch_elasticity(rpc_url: &str) -> Result { } } -pub const BYTES_PER_BLOB: u64 = 131_072; - #[derive(Debug, Clone)] -pub struct BlobSubmission { +pub struct L1BlockInfo { pub block_number: u64, pub block_hash: B256, - pub blob_count: u64, - pub l1_blob_bytes: u64, + pub timestamp: u64, + pub total_blobs: u64, + pub base_blobs: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum L1ConnectionMode { + WebSocket, + Polling, } -pub async fn run_l1_batcher_watcher( +fn http_to_ws(url: &str) -> String { + url.replacen("http://", "ws://", 1).replacen("https://", "wss://", 1) +} + +pub async fn run_l1_blob_watcher( l1_rpc: String, batcher_address: Address, - result_tx: mpsc::Sender, + result_tx: mpsc::Sender, + mode_tx: mpsc::Sender, + toast_tx: mpsc::Sender, ) { - let provider = match ProviderBuilder::new().connect(&l1_rpc).await { + let ws_url = http_to_ws(&l1_rpc); + + if let Err(()) = + run_l1_blob_watcher_ws(&ws_url, batcher_address, result_tx.clone(), &mode_tx, &toast_tx) + .await + { + let _ = mode_tx.send(L1ConnectionMode::Polling).await; + run_l1_blob_watcher_poll(&l1_rpc, batcher_address, result_tx, &toast_tx).await; + } +} + +async fn run_l1_blob_watcher_ws( + ws_url: &str, + batcher_address: Address, + result_tx: mpsc::Sender, + mode_tx: &mpsc::Sender, + toast_tx: &mpsc::Sender, +) -> Result<(), ()> { + let provider = ProviderBuilder::new().connect(ws_url).await.map_err(|e| { + warn!("Failed to connect to L1 WebSocket: {e}"); + let _ = toast_tx.try_send(Toast::warning("L1 WebSocket connection failed")); + })?; + + let sub = provider.subscribe_blocks().await.map_err(|e| { + warn!("Failed to subscribe to L1 blocks: {e}"); + let _ = toast_tx.try_send(Toast::warning("L1 block subscription failed")); + })?; + let mut stream = sub.into_stream(); + + let _ = mode_tx.send(L1ConnectionMode::WebSocket).await; + + if let Ok(Some(block)) = provider.get_block_by_number(BlockNumberOrTag::Latest).full().await { + let info = extract_l1_block_info(&block, batcher_address); + let _ = result_tx.send(info).await; + } + + while let Some(header) = stream.next().await { + let block_num = header.number; + + if let Ok(Some(block)) = + provider.get_block_by_number(BlockNumberOrTag::Number(block_num)).full().await + { + let info = extract_l1_block_info(&block, batcher_address); + if result_tx.send(info).await.is_err() { + return Ok(()); + } + } + } + + warn!("L1 WebSocket stream ended"); + let _ = toast_tx.try_send(Toast::warning("L1 WebSocket disconnected")); + + Err(()) +} + +async fn run_l1_blob_watcher_poll( + l1_rpc: &str, + batcher_address: Address, + result_tx: mpsc::Sender, + toast_tx: &mpsc::Sender, +) { + let provider = match ProviderBuilder::new().connect(l1_rpc).await { Ok(p) => p, - Err(_) => return, + Err(e) => { + warn!("Failed to connect to L1 RPC for polling: {e}"); + let _ = toast_tx.try_send(Toast::warning("L1 poller connection failed")); + return; + } }; let mut last_block: Option = None; - let mut interval = tokio::time::interval(Duration::from_secs(12)); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); loop { interval.tick().await; @@ -416,30 +472,15 @@ pub async fn run_l1_batcher_watcher( Err(_) => continue, }; - let start_block = last_block.map(|b| b + 1).unwrap_or_else(|| latest.saturating_sub(5)); + let start_block = last_block.map_or(latest, |b| b + 1); for block_num in start_block..=latest { if let Ok(Some(block)) = provider.get_block_by_number(BlockNumberOrTag::Number(block_num)).full().await { - let block_hash = block.header.hash; - for tx in block.transactions.txns() { - if tx.inner.signer() == batcher_address - && let Some(blob_hashes) = tx.blob_versioned_hashes() - { - let blob_count = blob_hashes.len() as u64; - if blob_count > 0 { - let submission = BlobSubmission { - block_number: block_num, - block_hash, - blob_count, - l1_blob_bytes: blob_count * BYTES_PER_BLOB, - }; - if result_tx.send(submission).await.is_err() { - return; - } - } - } + let info = extract_l1_block_info(&block, batcher_address); + if result_tx.send(info).await.is_err() { + return; } } } @@ -447,3 +488,29 @@ pub async fn run_l1_batcher_watcher( last_block = Some(latest); } } + +fn extract_l1_block_info( + block: &alloy_rpc_types_eth::Block, + batcher_address: Address, +) -> L1BlockInfo { + let mut total_blobs: u64 = 0; + let mut base_blobs: u64 = 0; + + for tx in block.transactions.txns() { + if let Some(blob_hashes) = tx.blob_versioned_hashes() { + let blob_count = blob_hashes.len() as u64; + total_blobs += blob_count; + if tx.inner.signer() == batcher_address { + base_blobs += blob_count; + } + } + } + + L1BlockInfo { + block_number: block.header.number, + block_hash: block.header.hash, + timestamp: block.header.timestamp, + total_blobs, + base_blobs, + } +}