From e3442c695f0d478d1ee4f55fdf45dd34c0ae86b8 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Tue, 21 Jul 2026 22:15:20 +0300 Subject: [PATCH] fix(sequencer): fix silent reconstruction when only genesis was committed --- lez/chain_consistency/src/apply.rs | 3 +- lez/indexer/core/src/block_store.rs | 2 +- lez/sequencer/core/src/block_store.rs | 31 ++++++++++-------- lez/sequencer/core/src/lib.rs | 32 +++++++++++-------- lez/sequencer/core/src/tests.rs | 2 +- .../core/src/tests/reconstruction.rs | 20 ++++++------ lez/storage/src/sequencer/mod.rs | 5 +-- 7 files changed, 53 insertions(+), 42 deletions(-) diff --git a/lez/chain_consistency/src/apply.rs b/lez/chain_consistency/src/apply.rs index ea97d58a..28961502 100644 --- a/lez/chain_consistency/src/apply.rs +++ b/lez/chain_consistency/src/apply.rs @@ -65,6 +65,7 @@ impl BlockIngestError { } /// The last successfully applied block a candidate must extend. +#[derive(Debug, Copy, Clone)] pub struct Tip { pub block_id: BlockId, pub hash: HashType, @@ -73,7 +74,7 @@ pub struct Tip { /// Checks that `block` is the valid continuation of `tip`: hash integrity, /// then block-id continuity, then `prev_block_hash` linkage. A `None` tip /// (cold store) expects the genesis block. -pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> { +pub fn validate_against_tip(tip: Option, block: &Block) -> Result<(), BlockIngestError> { let computed = block.recompute_hash(); if computed != block.header.hash { return Err(BlockIngestError::HashMismatch { diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index 774aa87d..3d840f90 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -253,7 +253,7 @@ impl IndexerStore { return Ok(AcceptOutcome::AlreadyApplied); } - if let Err(err) = chain_consistency::validate_against_tip(tip.as_ref(), block) { + if let Err(err) = chain_consistency::validate_against_tip(tip, block) { self.record_stall(Some(&block.header), l1_slot, err.clone())?; return Ok(AcceptOutcome::Parked(err)); } diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 658c8e1f..e58ac644 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -68,21 +68,24 @@ impl SequencerStore { signing_key: lee::PrivateKey, ) -> DbResult { let genesis_id = dbio.get_meta_first_block_in_db()?; - let last_id = dbio.latest_block_meta()?.id; + let last_id = dbio.latest_block_meta()?.map(|meta| meta.id); - info!("Preparing block cache"); let mut tx_hash_to_block_map = HashMap::new(); - for i in genesis_id..=last_id { - let block = dbio - .get_block(i)? - .expect("Block should be present in the database"); - tx_hash_to_block_map.extend(block_to_transactions_map(&block)); + if let Some(last_id) = last_id { + info!("Preparing block cache"); + for i in genesis_id..=last_id { + let block = dbio + .get_block(i)? + .expect("Block should be present in the database"); + + tx_hash_to_block_map.extend(block_to_transactions_map(&block)); + } + info!( + "Block cache prepared. Total blocks in cache: {}", + tx_hash_to_block_map.len() + ); } - info!( - "Block cache prepared. Total blocks in cache: {}", - tx_hash_to_block_map.len() - ); Ok(Self { dbio, @@ -131,7 +134,7 @@ impl SequencerStore { ); } - pub fn latest_block_meta(&self) -> DbResult { + pub fn latest_block_meta(&self) -> DbResult> { self.dbio.latest_block_meta() } @@ -299,7 +302,7 @@ mod tests { .unwrap(); // Verify that initially the latest block hash equals genesis hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, genesis_hash); } @@ -337,7 +340,7 @@ mod tests { .unwrap(); // Verify that the latest block meta now equals the new block's hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, block_hash); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 29418919..9d1329a9 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -201,7 +201,8 @@ impl SequencerCore { let latest_block_meta = store .latest_block_meta() - .expect("Failed to read latest block meta from store"); + .expect("Failed to read latest block meta from store") + .expect("Sequencer store should have at least the genesis block after reconstruction"); let sequencer_core = Self { state, @@ -256,14 +257,16 @@ impl SequencerCore { let local_tip = store .latest_block_meta() .context("Failed to read latest block meta")? - .id; + .map(|meta| meta.id); let had_checkpoint_before_start = !is_fresh_start; - let has_committed_to_channel = local_tip > GENESIS_BLOCK_ID && had_checkpoint_before_start; - if has_committed_to_channel && channel_tip_slot.is_none() { + if let Some(local_tip) = local_tip + && had_checkpoint_before_start + && channel_tip_slot.is_none() + { return Err(anyhow!( "Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \ - no longer exists on the connected chain — the channel was wiped or the node \ - points at a different chain. Refusing to resume onto a foreign channel." + no longer exists on the connected chain — the channel was wiped or the node \ + points at a different chain. Refusing to resume onto a foreign channel." )); } @@ -348,7 +351,9 @@ impl SequencerCore { // A block at/below the tip must match what we already stored, otherwise // the channel is a different chain. - if block_id <= tip.id { + if let Some(tip) = &tip + && block_id <= tip.id + { match store .get_block_at_id(block_id) .context("Failed to read stored block")? @@ -375,14 +380,14 @@ impl SequencerCore { } // New continuation: validate it chains onto the tip, then apply. - let cc_tip = Tip { + let cc_tip = tip.as_ref().map(|tip| Tip { block_id: tip.id, hash: tip.hash, - }; - chain_consistency::validate_against_tip(Some(&cc_tip), block).map_err(|err| { + }); + chain_consistency::validate_against_tip(cc_tip, block).map_err(|err| { anyhow!( - "Channel block {block_id} does not extend local tip {}: {err}", - tip.id + "Channel block {block_id} does not extend local tip {:?}: {err}", + tip.map(|tip| tip.id) ) })?; @@ -650,7 +655,8 @@ impl SequencerCore { let latest_block_meta = self .store .latest_block_meta() - .context("Failed to get latest block meta from store")?; + .context("Failed to get latest block meta from store")? + .context("Sequencer store should have at least the genesis block")?; let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis()) .expect("Timestamp must be positive"); diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index bcc40a0a..b398e160 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -627,7 +627,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { sequencer.produce_new_block().await.unwrap(); // Get the metadata of the last block produced - sequencer.store.latest_block_meta().unwrap() + sequencer.store.latest_block_meta().unwrap().unwrap() }; // Step 2: Restart sequencer from the same storage diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index 4f08631f..18cff11c 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -27,7 +27,7 @@ fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) { /// one block per slot at `slot_step` spacing. fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> { let genesis_id = store.genesis_id(); - let tip_id = store.latest_block_meta().expect("tip").id; + let tip_id = store.latest_block_meta().expect("tip").expect("present").id; (genesis_id..=tip_id) .enumerate() .map(|(index, id)| { @@ -45,7 +45,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; seq_a.produce_new_block().await.unwrap(); seq_a.produce_new_block().await.unwrap(); - let tip_a = seq_a.block_store().latest_block_meta().unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); let messages = channel_from_store(seq_a.block_store(), 10); let tip_slot = messages.last().unwrap().1; @@ -67,7 +67,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { .expect("reconstruct"); assert!(!channel_was_empty); - let tip_b = store_b.latest_block_meta().unwrap(); + let tip_b = store_b.latest_block_meta().unwrap().unwrap(); assert_eq!(tip_b.id, tip_a.id); assert_eq!(tip_b.hash, tip_a.hash); @@ -93,7 +93,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { .await .expect("reconstruct idempotent"); assert!(!channel_was_empty); - assert_eq!(store_b.latest_block_meta().unwrap().id, tip_a.id); + assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id); } #[tokio::test] @@ -243,7 +243,7 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { .unwrap(); seq_a.produce_new_block().await.unwrap(); - let tip_a = seq_a.block_store().latest_block_meta().unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); let messages = channel_from_store(seq_a.block_store(), 10); let tip_slot = messages.last().unwrap().1; let channel_id = config_a.bedrock_config.channel_id; @@ -274,9 +274,9 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { ) .await .expect("reconstruct"); - seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().id; + seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().unwrap().id; - let tip_b = seq_b.block_store().latest_block_meta().unwrap(); + let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); assert_eq!(tip_b.id, tip_a.id); assert_eq!(tip_b.hash, tip_a.hash); @@ -412,7 +412,7 @@ async fn reconstruction_reconciles_already_finished_deposit() { .await .unwrap(); seq_a.produce_new_block().await.unwrap(); - let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().id; + let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id; let messages = channel_from_store(seq_a.block_store(), 10); let tip_slot = messages.last().unwrap().1; @@ -476,14 +476,14 @@ async fn committed_local_against_missing_channel_fails_without_anchor() { SequencerCoreWithMockClients::start_from_config(config.clone()).await; seq.produce_new_block().await.unwrap(); seq.produce_new_block().await.unwrap(); - assert!(seq.block_store().latest_block_meta().unwrap().id > 1); + assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1); } // drop releases the store so we can reopen it // Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in // for a checkpoint persisted by a prior sync (the mock never emits one). let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); assert!(store.get_zone_anchor().unwrap().is_none()); - assert!(store.latest_block_meta().unwrap().id > 1); + assert!(store.latest_block_meta().unwrap().unwrap().id > 1); // The channel is gone: no tip, no messages. let mock = diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 8d60ae18..8910b044 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -344,8 +344,9 @@ impl RocksDBIO { self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch) } - pub fn latest_block_meta(&self) -> DbResult { - self.get::(()).map(|val| val.0) + pub fn latest_block_meta(&self) -> DbResult> { + self.get_opt::(()) + .map(|val| val.map(|cell| cell.0)) } pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult>> {