Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]

### Added
- feat: support get last scanned l1 height in sequencer rpc ([#3298](https://github.com/chainwayxyz/citrea/pull/3298))

## [v2.5.0](2026-06-04)
### Added
- feat: Get raw transaction rpcs. ([#3201](https://github.com/chainwayxyz/citrea/pull/3201))
Expand Down
14 changes: 14 additions & 0 deletions bin/citrea/tests/bitcoin/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ impl TestCase for BackupSequencerTest {
assert_eq!(backup_info.backup_path, backup_path);
assert_eq!(backup_info.l2_block_height.unwrap(), start_height);

// Sequencer backups now also record the last scanned L1 height.
let scanned_l1_height = sequencer.client.ledger_get_last_scanned_l1_height().await?;
assert_eq!(backup_info.l1_block_height.unwrap_or(0), scanned_l1_height);

let validation = validate_backup(&client, Some(&backup_path)).await?;
assert!(validation.is_valid);
assert_eq!(validation.backup_path, backup_path);
Expand Down Expand Up @@ -293,12 +297,22 @@ impl TestCase for BackupSequencerTest {
let rolled_back_height = sequencer.client.ledger_get_head_l2_block_height().await?;
assert_eq!(rolled_back_height, start_height);

// Rolling back the sequencer also resets its last scanned L1 height.
let rolled_back_l1_height = sequencer.client.ledger_get_last_scanned_l1_height().await?;
assert_eq!(rolled_back_l1_height, rollback_target_l1);

let post_rollback_backup_path = sequencer.config.base.dir.join("post_rollback_backup");
let post_rollback_backup = create_backup(&client, Some(&post_rollback_backup_path)).await?;

let post_rollback_backup_height = post_rollback_backup.l2_block_height.unwrap();
assert_eq!(post_rollback_backup_height, rolled_back_height);

// The post-rollback backup reports the reset last scanned L1 height.
assert_eq!(
post_rollback_backup.l1_block_height.unwrap(),
rollback_target_l1
);

for _ in 0..block_to_generate {
sequencer.client.send_publish_batch_request().await?;
}
Expand Down
235 changes: 235 additions & 0 deletions bin/citrea/tests/bitcoin/sequencer_test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::net::SocketAddr;
use std::time::Duration;

use async_trait::async_trait;
use bitcoin::hashes::Hash;
Expand All @@ -12,6 +13,7 @@ use citrea_e2e::Result;
use citrea_evm::system_contracts::BitcoinLightClient;
use citrea_evm::BITCOIN_LIGHT_CLIENT_CONTRACT_ADDRESS;
use sov_ledger_rpc::LedgerRpcClient;
use tokio::time::sleep;

use super::get_citrea_path;
use crate::common::make_test_client;
Expand Down Expand Up @@ -229,3 +231,236 @@ async fn test_sequencer_l1_fee_params() -> Result<()> {
.run()
.await
}

/// 1. Generate DA blocks and (re)start the sequencer so it deterministically
/// folds every finalized DA block into L2 blocks.
/// 2. Assert the RPC reports the finalized L1 height the sequencer caught up to,
/// cross-checked against the Bitcoin light client contract.
/// 3. Generate more DA blocks and assert the reported height advances (and that
/// it persisted across the restart).
struct SequencerLastScannedL1HeightTest;

#[async_trait]
impl TestCase for SequencerLastScannedL1HeightTest {
fn sequencer_config() -> SequencerConfig {
// Avoid commitment churn interfering with DA block accounting during the test.
SequencerConfig {
max_l2_blocks_per_commitment: 1000,
..Default::default()
}
}

async fn run_test(&mut self, f: &mut TestFramework) -> Result<()> {
let sequencer = f.sequencer.as_mut().unwrap();
let da = f.bitcoin_nodes.get(0).unwrap();

let seq_test_client = make_test_client(SocketAddr::new(
sequencer.config.rpc_bind_host().parse()?,
sequencer.config.rpc_bind_port(),
))
.await?;

// Generate DA blocks, then restart the sequencer so that on startup it
// deterministically folds every finalized DA block into L2 blocks (avoids
// racing the DA block monitor's polling interval).
da.generate(5).await?;
let target_l1_height = da
.get_finalized_height(Some(DEFAULT_FINALITY_DEPTH))
.await?;

sequencer.wait_until_stopped().await?;
sequencer.start(None, None).await?;

// A single publish processes all missed DA blocks up to the finalized height.
sequencer.client.send_publish_batch_request().await?;
// `wait_for_l1_height` polls `getLastScannedL1Height` (the RPC under test).
sequencer.wait_for_l1_height(target_l1_height, None).await?;

let scanned = sequencer.client.ledger_get_last_scanned_l1_height().await?;
assert!(
scanned > 0,
"sequencer should report a non-zero last scanned L1 height"
);
assert_eq!(
scanned, target_l1_height,
"sequencer should have scanned up to the finalized L1 height"
);

// The reported height must be a real L1 block the sequencer referenced in
// the Bitcoin light client contract.
let res: String = seq_test_client
.contract_call(
BITCOIN_LIGHT_CLIENT_CONTRACT_ADDRESS,
BitcoinLightClient::get_block_hash(scanned).to_vec(),
None,
)
.await
.unwrap();
let l1_block_hash = da.get_block_hash(scanned).await?;
assert_eq!(
l1_block_hash.to_raw_hash().to_byte_array().to_vec(),
hex::decode(&res[2..]).unwrap(),
"scanned L1 height should match a referenced light client block hash"
);

// Generate more DA blocks and confirm the persisted height advances.
da.generate(5).await?;
let next_target = da
.get_finalized_height(Some(DEFAULT_FINALITY_DEPTH))
.await?;
assert!(next_target > target_l1_height);

sequencer.wait_until_stopped().await?;
sequencer.start(None, None).await?;
sequencer.client.send_publish_batch_request().await?;
sequencer.wait_for_l1_height(next_target, None).await?;

let scanned_after = sequencer.client.ledger_get_last_scanned_l1_height().await?;
assert_eq!(
scanned_after, next_target,
"sequencer should advance its scanned L1 height to the new finalized height"
);
assert!(
scanned_after > scanned,
"last scanned L1 height should advance as new DA blocks are produced"
);

Ok(())
}
}

#[tokio::test]
async fn test_sequencer_last_scanned_l1_height() -> Result<()> {
TestCaseRunner::new(SequencerLastScannedL1HeightTest)
.set_citrea_path(get_citrea_path())
.run()
.await
}

/// 1. Generate DA blocks while the sequencer is running.
/// 2. Drive L2 block production until the RPC reports the new finalized L1 height,
/// cross-checked against the Bitcoin light client contract.
/// 3. Generate more DA blocks and assert the reported height advances.
struct SequencerLastScannedL1HeightRunningTest;

#[async_trait]
impl TestCase for SequencerLastScannedL1HeightRunningTest {
fn sequencer_config() -> SequencerConfig {
// Avoid commitment churn interfering with DA block accounting during the test.
SequencerConfig {
max_l2_blocks_per_commitment: 1000,
..Default::default()
}
}

async fn run_test(&mut self, f: &mut TestFramework) -> Result<()> {
let sequencer = f.sequencer.as_ref().unwrap();
let da = f.bitcoin_nodes.get(0).unwrap();

let seq_test_client = make_test_client(SocketAddr::new(
sequencer.config.rpc_bind_host().parse()?,
sequencer.config.rpc_bind_port(),
))
.await?;

// Generate DA blocks while the sequencer is running. Its DA block monitor
// picks them up and they get folded into L2 blocks as we publish.
da.generate(5).await?;
let target_l1_height = da
.get_finalized_height(Some(DEFAULT_FINALITY_DEPTH))
.await?;

// Drive block production until the sequencer has scanned up to the newly
// finalized L1 height. Each published block folds in any newly detected DA
// blocks and persists the last scanned L1 height (no restart involved).
let scanned = drive_until_scanned(&sequencer.client, target_l1_height).await?;
assert!(
scanned > 0,
"sequencer should report a non-zero last scanned L1 height"
);
assert_eq!(
scanned, target_l1_height,
"sequencer should have scanned up to the finalized L1 height during normal operation"
);

// The reported height must be a real L1 block the sequencer referenced in
// the Bitcoin light client contract.
let res: String = seq_test_client
.contract_call(
BITCOIN_LIGHT_CLIENT_CONTRACT_ADDRESS,
BitcoinLightClient::get_block_hash(scanned).to_vec(),
None,
)
.await
.unwrap();
let l1_block_hash = da.get_block_hash(scanned).await?;
assert_eq!(
l1_block_hash.to_raw_hash().to_byte_array().to_vec(),
hex::decode(&res[2..]).unwrap(),
"scanned L1 height should match a referenced light client block hash"
);

// Generate more DA blocks and confirm the persisted height advances without
// restarting the sequencer.
da.generate(5).await?;
let next_target = da
.get_finalized_height(Some(DEFAULT_FINALITY_DEPTH))
.await?;
assert!(next_target > target_l1_height);

let scanned_after = drive_until_scanned(&sequencer.client, next_target).await?;
assert_eq!(
scanned_after, next_target,
"sequencer should advance its scanned L1 height to the new finalized height"
);
assert!(
scanned_after > scanned,
"last scanned L1 height should advance as new DA blocks are produced"
);

// Generate a single DA block, wait one second for the DA monitor to observe
// it, publish one L2 block, and confirm the scanned height increased by
// exactly 1.
let before_single = sequencer.client.ledger_get_last_scanned_l1_height().await?;
da.generate(1).await?;
sleep(Duration::from_secs(1)).await;

let head = sequencer.client.ledger_get_head_l2_block_height().await?;
sequencer.client.send_publish_batch_request().await?;
sequencer.client.wait_for_l2_block(head + 1, None).await?;

let after_single = sequencer.client.ledger_get_last_scanned_l1_height().await?;
assert_eq!(
after_single,
before_single + 1,
"a single new DA block should increase the scanned L1 height by exactly 1"
);

Ok(())
}
}

/// Publishes L2 blocks until the sequencer's reported last scanned L1 height
/// reaches `target`, returning the final scanned height.
async fn drive_until_scanned(client: &citrea_e2e::client::Client, target: u64) -> Result<u64> {
for _ in 0..60 {
let scanned = client.ledger_get_last_scanned_l1_height().await?;
if scanned >= target {
return Ok(scanned);
}
let head = client.ledger_get_head_l2_block_height().await?;
client.send_publish_batch_request().await?;
client.wait_for_l2_block(head + 1, None).await?;
// Give the DA block monitor a chance to observe the newly finalized blocks.
sleep(Duration::from_millis(200)).await;
}
anyhow::bail!("sequencer did not reach scanned L1 height {target}");
}

#[tokio::test]
async fn test_sequencer_last_scanned_l1_height_running() -> Result<()> {
TestCaseRunner::new(SequencerLastScannedL1HeightRunningTest)
.set_citrea_path(get_citrea_path())
.run()
.await
}
4 changes: 1 addition & 3 deletions crates/common/src/backup/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,11 @@ impl BackupManager {
let l2_lock = self.l2_processing_lock.lock().await;

let (l1_block_height, l2_block_height) = match self.node_type {
// Sequencer does not have L1 blocks, so we use L2 height
NodeType::Sequencer => (None, ledger_db.get_head_l2_block_height()?),
NodeType::LightClientProver => {
// Light client prover does not have L2 blocks, so we use L1 height
(ledger_db.get_last_scanned_l1_height()?.map(|h| h.0), None)
}
NodeType::FullNode | NodeType::BatchProver => (
NodeType::Sequencer | NodeType::FullNode | NodeType::BatchProver => (
ledger_db.get_last_scanned_l1_height()?.map(|h| h.0),
ledger_db.get_head_l2_block_height()?,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ impl LedgerMigration for DropFullnodeTables {
) -> anyhow::Result<()> {
let fullnode_tables_to_drop = vec![
"VerifiedBatchProofsBySlotNumber",
"ProverLastScannedSlot",
"SlotByHash",
"PendingSequencerCommitments",
"ShortHeaderProofBySlotHash",
Expand Down
14 changes: 13 additions & 1 deletion crates/sequencer/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use revm::state::AccountInfo as ReVmAccountInfo;
use sov_accounts::Accounts;
use sov_accounts::Response::{AccountEmpty, AccountExists};
use sov_db::ledger_db::{LedgerDB, SequencerLedgerOps, SharedLedgerOps};
use sov_db::schema::types::L2BlockNumber;
use sov_db::schema::types::{L2BlockNumber, SlotNumber};
use sov_keys::default_signature::k256_private_key::K256PrivateKey;
use sov_modules_api::hooks::HookL2BlockInfo;
use sov_modules_api::{
Expand Down Expand Up @@ -668,6 +668,18 @@ where
// Update last used l1 height if this is a new da block
if let Some(l1_height) = last_da_block_height {
*last_used_l1_height = l1_height;
// On the full node, "last scanned" means the L1 monitor processed that
// block; on the sequencer it means "last L1 block folded into an L2 block".
// This persisted height is informational only the sequencer recovers `last_used_l1_height`
// from the light client contract on restart.
// The L2 block is already committed at this point, so a
// failure here must not panic a successful block production.
if let Err(e) = self
.ledger_db
.set_last_scanned_l1_height(SlotNumber(l1_height))
{
error!("Failed to persist last scanned L1 height {l1_height}: {e}");
}
}

Ok(l2_height)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub const SEQUENCER_LEDGER_TABLES: &[&str] = &[
L2RangeByL1Height::table_name(),
LastPrunedBlock::table_name(),
MempoolTxs::table_name(),
ProverLastScannedSlot::table_name(),
SequencerCommitmentByIndex::table_name(),
ShortHeaderProofBySlotHash::table_name(),
StateDiffByBlockNumber::table_name(),
Expand Down
6 changes: 5 additions & 1 deletion crates/storage-ops/src/rollback/node/sequencer.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::sync::Arc;

use sov_db::schema::tables::{
CommitmentsByNumber, L2BlockByHash, L2BlockByNumber, L2RangeByL1Height,
CommitmentsByNumber, L2BlockByHash, L2BlockByNumber, L2RangeByL1Height, ProverLastScannedSlot,
SequencerCommitmentByIndex, StateDiffByBlockNumber,
};
use sov_db::schema::types::{L2BlockNumber, SlotNumber};
Expand Down Expand Up @@ -129,6 +129,10 @@ impl LedgerNodeRollback for SequencerLedgerRollback {

if let Some(l1_target) = context.l1_target {
rollback_result = self.rollback_slots(l1_target, rollback_result)?;

let _ = self
.ledger_db
.put::<ProverLastScannedSlot>(&(), &SlotNumber(l1_target));
}

let _ = self.ledger_db.flush();
Expand Down
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,6 @@ ignore = [
"RUSTSEC-2025-0161", # Ignore unmaintained libsecp256k1
"RUSTSEC-2026-0119", # Ignore p2p related message encoding vulnerability. Bounded by reth v1.3.7 → hickory-resolver ^0.25. Used only as stub resolver for peer discovery. Remove when reth tag is bumped.
"RUSTSEC-2026-0118", # Vulnerable code is behind the dnssec-ring/dnssec-aws-lc-rs feature, which is not enabled in our build. Bounded by reth v1.3.7 → hickory-resolver ^0.25. Remove when reth tag is bumped.
"RUSTSEC-2026-0173", # Ignore unmaintained proc-macro-error2
]
unsound = "all"
Loading