From 478eb15edf19c8e90fc47b0bc6f253efe09c34c4 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Mon, 28 Sep 2026 18:05:20 -0300 Subject: [PATCH] test: count post-checkpoint batches in the rebuild anchor check setup_recovery_round_trip_test asserted that the rebuilt batch tree is anchored at the checkpoint's resume nonce N. The rebuild anchors at N', which folds every batch accepted in (B, C] on top of N. With a 5 s max_batch_open, the sequencer keeps closing batches while the test mines to finality, so a batch sometimes lands after B before the stop. CI then failed with "left: 2, right: 1" while the rebuild was correct (checkpoint_block=31, stop_block=34, resume_nonce=2). The assertion now derives N' independently: it reads the rebuilt stop block C from history_state and counts, from the InputBox logs, the submitter's consecutive batch nonces from N in (B, C]. The rebuilt database cannot supply this count, because its acceptance projection starts after C. --- tests/e2e/src/test_cases.rs | 22 ++++++++++-- tests/harness/src/sequencer.rs | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/tests/e2e/src/test_cases.rs b/tests/e2e/src/test_cases.rs index fa44043..bfd6ec4 100644 --- a/tests/e2e/src/test_cases.rs +++ b/tests/e2e/src/test_cases.rs @@ -1515,15 +1515,31 @@ async fn run_setup_recovery_round_trip_test( // Explicit recovery-correctness assertions, beyond the structural // `assert_schema_invariants` (which checks `0..`-from-anchor contiguity): - // 1. the rebuilt tree is anchored at the checkpoint's resume nonce N' (I16); + // 1. the rebuilt tree is anchored at N' (I16): the checkpoint's N plus the + // batches accepted in (B, C], counted from L1; // 2. recovery's resync did NOT spuriously freeze the frontier — the I15 // content-identity false-positive against below-anchor collapsed history // is otherwise a silent failure, invisible at the e2e level (the same // silence the (C, H1] bug shipped behind). + // The 5 s timer keeps closing batches until the stop, so whether one lands + // after B varies run to run. Counting consecutive nonces from N mirrors the + // scheduler's acceptance; batches this fresh cannot be stale. + let stop_block = runtime.baseline_safe_block()?; + let mut resume_nonce = checkpoint.resume_nonce; + for nonce in runtime + .l1_batch_nonces(checkpoint.checkpoint_block, stop_block) + .await? + { + if nonce == resume_nonce { + resume_nonce += 1; + } + } + eprintln!("recovery fold: C={stop_block} N'={resume_nonce}"); assert_eq!( runtime.batch_tree_anchor()?, - checkpoint.resume_nonce, - "the rebuilt tree must be anchored at the checkpoint resume nonce N'" + resume_nonce, + "the rebuilt tree must be anchored at N', the checkpoint nonce plus the \ + batches accepted after the checkpoint block" ); assert_eq!( runtime.canonical_divergence()?, diff --git a/tests/harness/src/sequencer.rs b/tests/harness/src/sequencer.rs index cb5fc62..e8b4ac6 100644 --- a/tests/harness/src/sequencer.rs +++ b/tests/harness/src/sequencer.rs @@ -605,6 +605,68 @@ impl ManagedSequencer { Ok(anchor as u64) } + /// The rebuilt baseline's L1 stop block `C` (`history_state.base_safe_block`), + /// read from the run DB read-only. + pub fn baseline_safe_block(&self) -> HarnessResult { + let db_path = self.data_dir_path.join("sequencer.db"); + let conn = rusqlite::Connection::open_with_flags( + db_path.as_path(), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .map_err(|err| io_other(format!("open DB read-only: {err}")))?; + let block: i64 = conn + .query_row( + "SELECT base_safe_block FROM history_state WHERE singleton_id = 0", + [], + |row| row.get(0), + ) + .map_err(|err| io_other(format!("read history_state: {err}")))?; + Ok(block as u64) + } + + /// Nonces of this app's batch submissions from the devnet submitter included + /// in L1 blocks `(after_block, through_block]`, in L1 order. Read from the + /// InputBox logs, independent of any sequencer database. + pub async fn l1_batch_nonces( + &self, + after_block: u64, + through_block: u64, + ) -> HarnessResult> { + use alloy::contract::Event; + use alloy::sol_types::{SolCall, SolEvent}; + use cartesi_rollups_contracts::input_box::InputBox::InputAdded; + use cartesi_rollups_contracts::inputs::Inputs::EvmAdvanceCall; + + let provider = alloy::providers::ProviderBuilder::new() + .connect(self.l1_endpoint()) + .await + .map_err(|err| io_other(format!("failed to connect anvil provider: {err}")))?; + let mut logs: Vec<(InputAdded, alloy::rpc::types::Log)> = + Event::new_sol(&provider, &self.input_box_address()) + .from_block(after_block + 1) + .to_block(through_block) + .event(InputAdded::SIGNATURE) + .topic1(self.app_address().into_word()) + .query() + .await + .map_err(|err| io_other(format!("query InputAdded logs: {err}")))?; + logs.sort_by_key(|(_, log)| (log.block_number, log.log_index)); + + let mut nonces = Vec::new(); + for (event, _) in logs { + let advance = EvmAdvanceCall::abi_decode(&event.input) + .map_err(|err| io_other(format!("decode EvmAdvance: {err}")))?; + if advance.msgSender != app_core::application::DEVNET_SEQUENCER_ADDRESS { + continue; + } + let batch = + ::from_ssz_bytes(&advance.payload) + .map_err(|err| io_other(format!("decode batch: {err:?}")))?; + nonces.push(batch.nonce); + } + Ok(nonces) + } + /// The canonical-divergence marker (I9/I15) from the run DB, or `None` /// when the frontier is healthy. A recovery/resync e2e asserts this is `None` /// to prove the content-identity check did NOT spuriously freeze the frontier