Merge a8797d8b373d2dd8a65a170cf6dcdf0db2d2ba23 into 041cf68cd63acd9c4c2d57492a0a0590396c27de

This commit is contained in:
erhant 2026-07-17 07:09:58 +02:00 committed by GitHub
commit a101965c74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 3402 additions and 348 deletions

24
Cargo.lock generated
View File

@ -1368,6 +1368,23 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chain_state"
version = "0.1.0"
dependencies = [
"anyhow",
"borsh",
"common",
"lee",
"log",
"logos-blockchain-core",
"logos-blockchain-zone-sdk",
"serde",
"serde_json",
"testnet_initial_state",
"thiserror 2.0.18",
]
[[package]]
name = "chkstk_stub"
version = "0.1.0"
@ -3939,6 +3956,7 @@ dependencies = [
"arc-swap",
"async-stream",
"borsh",
"chain_state",
"common",
"cross_zone",
"cross_zone_inbox_core",
@ -3958,7 +3976,6 @@ dependencies = [
"storage",
"tempfile",
"testnet_initial_state",
"thiserror 2.0.18",
"tokio",
"url",
]
@ -4132,12 +4149,14 @@ dependencies = [
"reqwest",
"risc0-zkvm",
"sequencer_core",
"sequencer_service_protocol",
"sequencer_service_rpc",
"serde_json",
"system_accounts",
"tempfile",
"test_fixtures",
"test_programs",
"testnet_initial_state",
"token_core",
"tokio",
"vault_core",
@ -9023,6 +9042,7 @@ dependencies = [
"borsh",
"bridge_core",
"bytesize",
"chain_state",
"chrono",
"common",
"cross_zone",
@ -9068,6 +9088,7 @@ dependencies = [
"common",
"env_logger",
"futures",
"hex",
"jsonrpsee",
"lee",
"log",
@ -9088,6 +9109,7 @@ dependencies = [
"hex",
"lee",
"lee_core",
"serde",
"serde_with",
]

View File

@ -16,6 +16,7 @@ members = [
"lez",
"lez/system_accounts",
"lez/chain_state",
"lez/sequencer/core",
"lez/sequencer/service",
"lez/sequencer/service/protocol",
@ -72,6 +73,7 @@ members = [
lee = { path = "lee/state_machine" }
lee_core = { path = "lee/state_machine/core" }
common = { path = "lez/common" }
chain_state = { path = "lez/chain_state" }
mempool = { path = "lez/mempool" }
storage = { path = "lez/storage" }
key_protocol = { path = "lee/key_protocol" }

View File

@ -58,15 +58,17 @@ run-bedrock:
docker compose up
# Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.
# Optional home/port let a second instance run off the same config, e.g.
# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo.
[working-directory: 'lez/sequencer/service']
run-sequencer standalone="":
run-sequencer standalone="" home="" port="3040":
@echo "🧠 Running sequencer"
@if [ "{{standalone}}" = "standalone" ]; then \
echo "🧪 Running in standalone mode"; \
RUST_LOG=info cargo run --features standalone --release -p sequencer_service configs/debug/sequencer_config.json; \
RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \
else \
echo "🚀 Running in normal mode"; \
RUST_LOG=info cargo run --release -p sequencer_service configs/debug/sequencer_config.json; \
RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \
fi
# Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration.

View File

@ -29,6 +29,7 @@ bridge_lock_core.workspace = true
wrapped_token_core.workspace = true
risc0-zkvm.workspace = true
indexer_service_rpc = { workspace = true, features = ["client"] }
sequencer_service_protocol.workspace = true
sequencer_service_rpc = { workspace = true, features = ["client"] }
wallet-ffi.workspace = true
indexer_ffi.workspace = true
@ -36,6 +37,7 @@ indexer_service_protocol.workspace = true
system_accounts.workspace = true
programs.workspace = true
test_programs.workspace = true
testnet_initial_state.workspace = true
logos-blockchain-http-api-common.workspace = true
logos-blockchain-core.workspace = true

View File

@ -0,0 +1,251 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Two sequencers share one channel: A starts solo as channel admin, live-
//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both
//! produce on their turns, and A, B and an indexer converge on the same chain.
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result, ensure};
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer_with_bedrock_key},
};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use sequencer_service_protocol::ConfigureChannelRequest;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
use tokio::test;
/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled
/// turn after ~30 s (bounds the stall while B is accredited but not started).
const POSTING_TIMEFRAME_SLOTS: u32 = 20;
const POSTING_TIMEOUT_SLOTS: u32 = 30;
const PHASE_TIMEOUT: Duration = Duration::from_secs(360);
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const TRANSFER_AMOUNT: u128 = 10;
/// ≈4 turn windows past B's join (5 s blocks, ~20 s turns → ~4 blocks/window).
const ROTATION_BLOCKS: u64 = 8;
#[test]
async fn multi_sequencer_committee_converges() -> Result<()> {
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up Bedrock node")?;
// Fixed seeds so A can accredit B's public key before B exists.
let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE];
let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE];
let pub_a = Ed25519Key::from_bytes(&key_a).public_key();
let pub_b = Ed25519Key::from_bytes(&key_b).public_key();
let partial = SequencerPartialConfig {
block_create_timeout: Duration::from_secs(5),
..SequencerPartialConfig::default()
};
// Phase 1: A solo (its first inscription creates the channel), plus an indexer.
let (seq_a, _a_home) = setup_sequencer_with_bedrock_key(
partial,
bedrock_addr,
vec![],
config::bedrock_channel_id(),
None,
key_a,
)
.await
.context("Failed to set up sequencer A")?;
let a = sequencer_client(seq_a.addr())?;
let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
.await
.context("Failed to set up indexer")?;
let indexer = indexer_client(idx.addr()).await?;
wait_for_height(&a, 2, "sequencer A to produce past genesis").await?;
// Phase 2: live roster change to [A, B] with rotation enabled.
a.admin_configure_channel(ConfigureChannelRequest {
keys: vec![hex::encode(pub_a.to_bytes()), hex::encode(pub_b.to_bytes())],
posting_timeframe: POSTING_TIMEFRAME_SLOTS,
posting_timeout: POSTING_TIMEOUT_SLOTS,
configuration_threshold: 1,
withdraw_threshold: 1,
})
.await
.context("Failed to configure the channel committee")?;
let height_at_config = a.get_last_block_id().await?;
wait_for_height(
&a,
height_at_config + 1,
"A to produce after the roster change",
)
.await?;
// Phase 3: B joins live and syncs the existing chain.
let (seq_b, _b_home) = setup_sequencer_with_bedrock_key(
partial,
bedrock_addr,
vec![],
config::bedrock_channel_id(),
None,
key_b,
)
.await
.context("Failed to set up sequencer B")?;
let b = sequencer_client(seq_b.addr())?;
let join_height = a.get_last_block_id().await?;
wait_for_height(&b, join_height, "B to sync to A's height at join").await?;
// Phase 4: rotation + convergence over ≈4 turn windows.
let rotation_target = join_height + ROTATION_BLOCKS;
wait_for_height(
&a,
rotation_target,
"the chain to advance across turn windows",
)
.await?;
wait_for_height(&b, rotation_target, "B to follow across turn windows").await?;
assert_same_chain(&a, &b).await?;
// Phase 5: a tx submitted only to B is included by B and visible on A.
let accounts = initial_public_user_accounts();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
let to_balance_before = a.get_account_balance(to).await?;
let nonce = b.get_accounts_nonces(vec![from]).await?[0];
let tx = common::test_utils::create_transaction_native_token_transfer(
from,
nonce.0,
to,
TRANSFER_AMOUNT,
&sign_key,
);
b.send_transaction(tx)
.await
.context("Failed to submit the transfer to B")?;
wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?;
// Phase 6: the indexer finalizes the same chain, with no stall.
wait_for_finalized(&indexer, join_height).await?;
let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0);
for id in 1..=finalized {
let block_i = indexer
.get_block_by_id(id)
.await?
.with_context(|| format!("Indexer is missing finalized block {id}"))?;
let block_a = a
.get_block(id)
.await?
.with_context(|| format!("A is missing block {id}"))?;
ensure!(
block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash),
"Indexer diverges from A at block {id}"
);
}
let status = indexer.get_status().await?;
ensure!(
status.stall_reason.is_none(),
"Indexer is stalled: {:?}",
status.stall_reason
);
Ok(())
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
async fn indexer_client(addr: SocketAddr) -> Result<IndexerClient> {
let url = config::addr_to_url(config::UrlProtocol::Ws, addr)
.context("Failed to build indexer URL")?;
IndexerClient::new(&url).await
}
/// Polls the sequencer until its chain height reaches `target`.
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
let wait = async {
loop {
if client.get_last_block_id().await? >= target {
return Ok::<(), anyhow::Error>(());
}
tokio::time::sleep(POLL_INTERVAL).await;
}
};
tokio::time::timeout(PHASE_TIMEOUT, wait)
.await
.with_context(|| format!("Timed out waiting for {what} (target height {target})"))?
}
/// Polls the sequencer until `account`'s balance reaches `expected`.
async fn wait_for_balance(
client: &SequencerClient,
account: lee::AccountId,
expected: u128,
) -> Result<()> {
let wait = async {
loop {
if client.get_account_balance(account).await? == expected {
return Ok::<(), anyhow::Error>(());
}
tokio::time::sleep(POLL_INTERVAL).await;
}
};
tokio::time::timeout(PHASE_TIMEOUT, wait)
.await
.context("Timed out waiting for the cross-sequencer transfer to reach A")?
}
/// Polls the indexer until its finalized height reaches `target`.
async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> {
let wait = async {
loop {
if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target {
return Ok::<(), anyhow::Error>(());
}
tokio::time::sleep(POLL_INTERVAL).await;
}
};
tokio::time::timeout(PHASE_TIMEOUT, wait)
.await
.context("Timed out waiting for the indexer to finalize")?
}
/// Asserts A and B hold byte-identical block hashes over their common prefix.
async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> {
let common = a
.get_last_block_id()
.await?
.min(b.get_last_block_id().await?);
for id in 1..=common {
let block_a = a
.get_block(id)
.await?
.with_context(|| format!("A is missing block {id}"))?;
let block_b = b
.get_block(id)
.await?
.with_context(|| format!("B is missing block {id}"))?;
ensure!(
block_a.header.hash == block_b.header.hash,
"Chain divergence at block {id}: A {:?} vs B {:?}",
block_a.header.hash,
block_b.header.hash
);
}
Ok(())
}

View File

@ -0,0 +1,26 @@
[package]
name = "chain_state"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
common.workspace = true
lee.workspace = true
logos-blockchain-core.workspace = true
logos-blockchain-zone-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
serde.workspace = true
thiserror.workspace = true
[dev-dependencies]
testnet_initial_state.workspace = true
# we use borsh to compare byte-to-byte matching within tests
# (it sorts hashmap's before serialization, so we can compare two states for equality)
borsh.workspace = true
serde_json.workspace = true

View File

@ -0,0 +1,289 @@
//! The single validate-then-apply entry point shared by the sequencer and the
//! indexer. Pure and storage-free: callers apply on a scratch clone of state and
//! commit only on `Ok`.
use common::{
HashType,
block::Block,
transaction::{LeeTransaction, clock_invocation},
};
use lee::{GENESIS_BLOCK_ID, V03State};
use crate::ingest_error::BlockIngestError;
/// The parent the next block must chain on.
// `l1_slot` will be added here when the `ChainState` anchor layer lands.
#[derive(Debug, Clone)]
pub struct Tip {
pub block_id: u64,
pub hash: HashType,
}
/// Outcome of feeding a parsed L2 block to a validated tip.
pub enum AcceptOutcome {
/// Chained and applied; the tip advances.
Applied,
/// A duplicate re-delivery of an already-applied block. No state change.
AlreadyApplied,
/// Did not chain or failed to apply; the tip stays frozen.
Parked(BlockIngestError),
/// Chained but failed to apply, possibly transiently
/// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state
/// untouched. The caller retries and parks once it gives up.
///
/// TODO: Only the indexer's `accept_block` emits this today; the sequencer's
/// `ChainState` parks on all failures without retrying (see `on_follow`).
RetryableFailure(BlockIngestError),
}
/// Validates `block` against `tip`, then applies it to `state`.
///
/// Mutates `state` in place, so callers pass a scratch clone and commit on `Ok`.
pub fn apply_block(
tip: Option<&Tip>,
block: &Block,
state: &mut V03State,
) -> Result<(), BlockIngestError> {
validate_against_tip(tip, block)?;
apply_block_to_state(block, state)?;
Ok(())
}
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
/// (cold state) expects the genesis block.
pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
let computed = block.recompute_hash();
if computed != block.header.hash {
return Err(BlockIngestError::HashMismatch {
computed,
header: block.header.hash,
});
}
match tip {
None => {
if block.header.block_id != GENESIS_BLOCK_ID {
return Err(BlockIngestError::UnexpectedBlockId {
expected: GENESIS_BLOCK_ID,
got: block.header.block_id,
});
}
}
Some(tip) => {
let expected = tip
.block_id
.checked_add(1)
.expect("block id should not overflow");
if block.header.block_id != expected {
return Err(BlockIngestError::UnexpectedBlockId {
expected,
got: block.header.block_id,
});
}
if block.header.prev_block_hash != tip.hash {
return Err(BlockIngestError::BrokenChainLink {
expected_prev: tip.hash,
got_prev: block.header.prev_block_hash,
});
}
}
}
Ok(())
}
/// Applies a block's transactions to `state`, mapping every failure to a
/// [`BlockIngestError`] so the caller can park rather than crash. Operates in
/// place; the caller commits only on `Ok`.
pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
let (clock_tx, user_txs) = block
.body
.transactions
.split_last()
.ok_or(BlockIngestError::EmptyBlock)?;
let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp));
if *clock_tx != expected_clock {
return Err(BlockIngestError::InvalidClockTransaction);
}
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
for (tx_index, transaction) in user_txs.iter().enumerate() {
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
tx_index: tx_index.try_into().expect("tx index fits in u64"),
reason: format!("{err:#}"),
};
if is_genesis {
let LeeTransaction::Public(public_tx) = transaction else {
return Err(BlockIngestError::NonPublicGenesisTransaction);
};
state
.transition_from_public_transaction(
public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| state_transition(err.into()))?;
} else {
transaction
.clone()
.execute_on_state(state, block.header.block_id, block.header.timestamp)
.map_err(|err| state_transition(err.into()))?;
}
}
let LeeTransaction::Public(clock_public_tx) = clock_tx else {
return Err(BlockIngestError::InvalidClockTransaction);
};
state
.transition_from_public_transaction(
clock_public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| BlockIngestError::StateTransition {
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
reason: format!("{:#}", anyhow::Error::from(err)),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use common::{
block::HashableBlockData,
test_utils::{
create_transaction_native_token_transfer, produce_dummy_block,
produce_dummy_empty_transaction, sequencer_sign_key_for_testing,
},
};
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state};
use super::*;
fn tip_of(block: &Block) -> Tip {
Tip {
block_id: block.header.block_id,
hash: block.header.hash,
}
}
#[test]
fn genesis_applies_on_empty_tip() {
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
}
#[test]
fn non_genesis_first_block_is_unexpected_id() {
let mut state = initial_state();
let block = produce_dummy_block(2, None, vec![]);
let err = apply_block(None, &block, &mut state).expect_err("should reject");
assert!(matches!(
err,
BlockIngestError::UnexpectedBlockId {
expected: 1,
got: 2
}
));
}
#[test]
fn skip_ahead_block_is_unexpected_id() {
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
// Tip is at 1; a block with id 3 skips ahead.
let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
let err =
apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject");
assert!(matches!(
err,
BlockIngestError::UnexpectedBlockId {
expected: 2,
got: 3
}
));
}
#[test]
fn broken_chain_link_detected() {
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
// Correct id (2), wrong parent hash.
let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]);
let err =
apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::BrokenChainLink { .. }));
}
#[test]
fn hash_mismatch_detected() {
let mut state = initial_state();
let mut genesis = produce_dummy_block(1, None, vec![]);
// Tampering with the header invalidates the stored hash.
genesis.header.timestamp = 999;
let err = apply_block(None, &genesis, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::HashMismatch { .. }));
}
#[test]
fn empty_block_rejected() {
let mut state = initial_state();
// A block with no transactions at all (not even the mandatory clock tx).
let block = HashableBlockData {
block_id: 1,
prev_block_hash: HashType([0_u8; 32]),
timestamp: 0,
transactions: vec![],
}
.into_pending_block(&sequencer_sign_key_for_testing());
let err = apply_block(None, &block, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::EmptyBlock));
}
#[test]
fn missing_clock_tail_is_invalid_clock() {
let mut state = initial_state();
// Last tx is not the expected clock invocation for the timestamp.
let block = HashableBlockData {
block_id: 1,
prev_block_hash: HashType([0_u8; 32]),
timestamp: 50,
transactions: vec![produce_dummy_empty_transaction()],
}
.into_pending_block(&sequencer_sign_key_for_testing());
let err = apply_block(None, &block, &mut state).expect_err("should reject");
assert!(matches!(err, BlockIngestError::InvalidClockTransaction));
}
#[test]
fn applies_transfers_and_advances_state() {
let mut state = initial_state();
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
// Genesis (block 1): clock-only.
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
let mut tip = tip_of(&genesis);
// Blocks 2..=11: one native transfer of 10 each (nonces 0..=9).
for i in 0..10_u64 {
let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key);
let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]);
apply_block(Some(&tip), &block, &mut state).expect("transfer applies");
tip = tip_of(&block);
}
assert_eq!(state.get_account_by_id(from).balance, 9900);
assert_eq!(state.get_account_by_id(to).balance, 20100);
}
}

View File

@ -0,0 +1,872 @@
//! Two-tier chain state: a reorg-able `head` the sequencer builds on, plus an
//! irreversible `final` tier.
use common::{HashType, block::Block};
use lee::V03State;
use log::warn;
use logos_blockchain_core::mantle::ops::channel::MsgId;
use logos_blockchain_zone_sdk::Slot;
use crate::{
AcceptOutcome, BlockIngestError, StallReason,
apply::{Tip, apply_block},
};
/// A head block plus the channel message that carried it.
pub struct HeadEntry {
pub this_msg: MsgId,
pub block: Block,
}
/// The head tier (reorg-able, from `adopted`/`orphaned`) over the final tier
/// (irreversible, from `finalized`).
///
/// `head_state` is given by `final_state` replayed through `head_blocks`.
///
/// Only the final tier stalls: an invalid `adopted` block just freezes the
/// head tip and self-heals via reorg or finalization.
pub struct ChainState {
final_state: V03State,
final_tip: Option<Tip>,
final_stall: Option<StallReason>,
head_state: V03State,
head_blocks: Vec<HeadEntry>,
}
impl ChainState {
/// Fresh state anchored at the genesis/initial state, no blocks applied.
#[must_use]
pub fn new(initial_state: V03State) -> Self {
Self::from_final(initial_state, None)
}
/// State restored from a persisted final tier; head mirrors final.
#[must_use]
pub fn from_final(final_state: V03State, final_tip: Option<Tip>) -> Self {
Self {
head_state: final_state.clone(),
final_state,
final_tip,
head_blocks: Vec::new(),
final_stall: None,
}
}
/// State the sequencer builds its next block on.
#[must_use]
pub const fn head_state(&self) -> &V03State {
&self.head_state
}
/// Mutable access to the head state. Bypasses the `head_blocks` invariant, so
/// it is meant for tests and low-level callers.
#[must_use]
pub const fn head_state_mut(&mut self) -> &mut V03State {
&mut self.head_state
}
#[must_use]
pub const fn final_state(&self) -> &V03State {
&self.final_state
}
/// Parent the next produced block must chain on.
#[must_use]
pub fn head_tip(&self) -> Option<Tip> {
self.head_blocks
.last()
.map(|entry| tip_of(&entry.block))
.or_else(|| self.final_tip.clone())
}
#[must_use]
pub fn final_tip(&self) -> Option<Tip> {
self.final_tip.clone()
}
#[must_use]
pub const fn final_stall(&self) -> Option<&StallReason> {
self.final_stall.as_ref()
}
/// Position of a head entry, matched by `MsgId` or block hash (restored
/// entries carry sentinel `MsgId`s; re-inscriptions arrive under fresh ones).
fn head_position_of(&self, this_msg: MsgId, block_hash: HashType) -> Option<usize> {
self.head_blocks
.iter()
.position(|entry| entry.this_msg == this_msg || entry.block.header.hash == block_hash)
}
/// Hash of the block we hold at `block_id` (final tip or head entry), if any.
fn hash_at(&self, block_id: u64) -> Option<HashType> {
if let Some(tip) = &self.final_tip
&& tip.block_id == block_id
{
return Some(tip.hash);
}
self.head_blocks
.iter()
.find(|entry| entry.block.header.block_id == block_id)
.map(|entry| entry.block.header.hash)
}
/// Applies an adopted head block. On failure the head tip stays at the last
/// valid block and no stall is recorded; the head self-heals.
pub fn apply_adopted(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome {
let tip = self.head_tip();
if self.head_position_of(this_msg, block.header.hash).is_some() {
return AcceptOutcome::AlreadyApplied;
}
if tip
.as_ref()
.is_some_and(|current| block.header.block_id <= current.block_id)
{
// Re-delivery of a block we already hold; a different hash means an
// adopted competitor arrived without its orphan sibling — warn, keep ours.
if let Some(known) = self.hash_at(block.header.block_id)
&& known != block.header.hash
{
warn!(
"Ignoring adopted block {} with hash {} — a different block ({}) \
holds this height and no orphan event preceded it",
block.header.block_id, block.header.hash, known
);
}
return AcceptOutcome::AlreadyApplied;
}
let mut scratch = self.head_state.clone();
match apply_block(tip.as_ref(), block, &mut scratch) {
Ok(()) => {
self.head_state = scratch;
self.head_blocks.push(HeadEntry {
this_msg,
block: block.clone(),
});
AcceptOutcome::Applied
}
Err(err) => AcceptOutcome::Parked(err),
}
}
/// Reverts an orphaned head block and everything after it, then re-derives head.
pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) {
if let Some(idx) = self.head_position_of(this_msg, block.header.hash) {
self.head_blocks.truncate(idx);
self.rederive_head();
}
}
/// One channel update: revert every `orphaned` (one truncate + re-derive),
/// then apply every `adopted` in order. Outcomes align with `adopted`.
pub fn apply_channel_update(
&mut self,
orphaned: &[(MsgId, Block)],
adopted: &[(MsgId, Block)],
) -> Vec<AcceptOutcome> {
let earliest = orphaned
.iter()
.filter_map(|(msg, block)| self.head_position_of(*msg, block.header.hash))
.min();
if let Some(idx) = earliest {
self.head_blocks.truncate(idx);
self.rederive_head();
}
adopted
.iter()
.map(|(msg, block)| self.apply_adopted(*msg, block))
.collect()
}
/// Rebuilds one head entry from a persisted block, applying it in place (the
/// caller treats `Err` as fatal).
///
/// Pass a hash-derived sentinel for `this_msg`; orphan/finalize events then correlate by block
/// hash.
pub fn restore_head_block(
&mut self,
this_msg: MsgId,
block: Block,
) -> Result<(), BlockIngestError> {
apply_block(self.head_tip().as_ref(), &block, &mut self.head_state)?;
self.head_blocks.push(HeadEntry { this_msg, block });
Ok(())
}
/// A finalized inscription. In steady state the block is already in head and is
/// moved into `final`; on backfill (not in head) it is applied directly and may
/// set `final_stall`.
pub fn apply_finalized(
&mut self,
this_msg: MsgId,
block: &Block,
l1_slot: Slot,
) -> AcceptOutcome {
// Match by `MsgId` or block hash (re-inscriptions, restored entries).
if let Some(idx) = self.head_position_of(this_msg, block.header.hash) {
self.finalize_through(idx);
return AcceptOutcome::Applied;
}
// Finality is prefix-monotone: a finalized block chaining on an
// unfinalized head entry finalizes that prefix too.
if let Some(idx) = self
.head_blocks
.iter()
.position(|entry| entry.block.header.hash == block.header.prev_block_hash)
{
self.finalize_through(idx);
}
self.apply_finalized_direct(block, l1_slot)
}
/// Moves `head_blocks[0..=idx]` into the final tier (already validated in head).
fn finalize_through(&mut self, idx: usize) {
let finalized: Vec<HeadEntry> = self.head_blocks.drain(0..=idx).collect();
for entry in finalized {
apply_block(self.final_tip.as_ref(), &entry.block, &mut self.final_state)
.expect("validated head block must apply to the final tier");
self.final_tip = Some(tip_of(&entry.block));
}
self.final_stall = None;
}
/// Applies a finalized block straight to the final tier. On success the
/// finalized chain is authoritative, so head rebases onto it.
fn apply_finalized_direct(&mut self, block: &Block, l1_slot: Slot) -> AcceptOutcome {
// A finalized block at or below the final tip is a re-delivery:
// idempotent. A *different* block at the tip height falls through
// to validation and parks.
if let Some(tip) = &self.final_tip
&& (block.header.block_id < tip.block_id
|| (block.header.block_id == tip.block_id && block.header.hash == tip.hash))
{
return AcceptOutcome::AlreadyApplied;
}
let mut scratch = self.final_state.clone();
match apply_block(self.final_tip.as_ref(), block, &mut scratch) {
Ok(()) => {
self.final_state = scratch;
self.final_tip = Some(tip_of(block));
self.final_stall = None;
self.head_blocks.clear();
self.head_state = self.final_state.clone();
AcceptOutcome::Applied
}
Err(err) => {
self.record_final_stall(block, l1_slot, err.clone());
AcceptOutcome::Parked(err)
}
}
}
/// Rebuilds `head_state` from the final tier plus the current `head_blocks`.
fn rederive_head(&mut self) {
let mut state = self.final_state.clone();
let mut tip = self.final_tip.clone();
for entry in &self.head_blocks {
apply_block(tip.as_ref(), &entry.block, &mut state)
.expect("validated head blocks must replay");
tip = Some(tip_of(&entry.block));
}
self.head_state = state;
}
/// First stall is stored verbatim; later ones only bump `orphans_since`.
fn record_final_stall(&mut self, block: &Block, l1_slot: Slot, error: BlockIngestError) {
self.final_stall = Some(match self.final_stall.take() {
Some(mut existing) => {
existing.orphans_since = existing.orphans_since.saturating_add(1);
existing
}
None => stall_for(block, l1_slot, error),
});
}
}
const fn tip_of(block: &Block) -> Tip {
Tip {
block_id: block.header.block_id,
hash: block.header.hash,
}
}
const fn stall_for(block: &Block, l1_slot: Slot, error: BlockIngestError) -> StallReason {
StallReason {
block_id: Some(block.header.block_id),
block_hash: Some(block.header.hash),
prev_block_hash: Some(block.header.prev_block_hash),
l1_slot,
error,
first_seen: Some(block.header.timestamp),
orphans_since: 0,
}
}
#[cfg(test)]
mod tests {
use common::{
HashType,
test_utils::{create_transaction_native_token_transfer, produce_dummy_block},
};
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state};
use super::*;
fn msg(n: u8) -> MsgId {
MsgId::from([n; 32])
}
fn slot(n: u64) -> Slot {
Slot::from(n)
}
/// `head_state` equals `final_state` replayed through `head_blocks`.
fn assert_head_matches_replay(chain: &ChainState) {
let mut state = chain.final_state.clone();
let mut tip = chain.final_tip.clone();
for entry in &chain.head_blocks {
apply_block(tip.as_ref(), &entry.block, &mut state).expect("head blocks must replay");
tip = Some(tip_of(&entry.block));
}
assert_eq!(
borsh::to_vec(&state).expect("state serializes"),
borsh::to_vec(chain.head_state()).expect("state serializes"),
"head_state must equal final_state replayed through head_blocks"
);
}
#[test]
fn adopted_blocks_advance_head() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
assert!(matches!(
chain.apply_adopted(msg(1), &genesis),
AcceptOutcome::Applied
));
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(2), &block2),
AcceptOutcome::Applied
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 2);
// Nothing finalized yet.
assert!(chain.final_tip().is_none());
}
#[test]
fn adopted_bad_block_freezes_head_without_stall() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
// Skips ahead (id 3 while head tip is 1).
let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(3), &bad),
AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId {
expected: 2,
got: 3
})
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 1);
assert!(
chain.final_stall().is_none(),
"head freeze records no stall"
);
}
#[test]
fn adopted_is_idempotent() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
assert!(matches!(
chain.apply_adopted(msg(1), &genesis),
AcceptOutcome::AlreadyApplied
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 1);
}
#[test]
fn orphan_reverts_head() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
chain.apply_adopted(msg(3), &block3);
chain.revert_orphan(msg(3), &block3);
assert_eq!(chain.head_tip().expect("head tip").block_id, 2);
// A competing block 3 now applies cleanly on block 2.
let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(13), &block3_prime),
AcceptOutcome::Applied
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
}
#[test]
fn channel_update_reverts_then_applies() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
chain.apply_adopted(msg(3), &block3);
let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]);
let outcomes = chain.apply_channel_update(&[(msg(3), block3)], &[(msg(13), block3_prime)]);
assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied]));
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
}
#[test]
fn finalize_moves_head_into_final() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
chain.apply_adopted(msg(3), &block3);
// Finalize through block 2.
assert!(matches!(
chain.apply_finalized(msg(2), &block2, slot(100)),
AcceptOutcome::Applied
));
assert_eq!(chain.final_tip().expect("final tip").block_id, 2);
// Head tip unchanged; head still ends at 3.
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
}
#[test]
fn backfill_applies_directly_to_final() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
assert!(matches!(
chain.apply_finalized(msg(1), &genesis, slot(10)),
AcceptOutcome::Applied
));
assert_eq!(chain.final_tip().expect("final tip").block_id, 1);
// Head mirrors final during backfill.
assert_eq!(chain.head_tip().expect("head tip").block_id, 1);
}
#[test]
fn invalid_finalized_block_sets_final_stall() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_finalized(msg(1), &genesis, slot(10));
// Skip-ahead finalized block, not in head: parks the final tier.
let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_finalized(msg(3), &bad, slot(20)),
AcceptOutcome::Parked(_)
));
let stall = chain.final_stall().expect("final stall recorded");
assert_eq!(stall.block_id, Some(3));
}
#[test]
fn orphaning_a_suffix_rederives_head_state() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]);
chain.apply_adopted(msg(2), &block2);
let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]);
chain.apply_adopted(msg(3), &block3);
let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key);
let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]);
chain.apply_adopted(msg(4), &block4);
// Orphaning block 3 drops the whole suffix (3 and 4).
chain.revert_orphan(msg(3), &block3);
assert_eq!(chain.head_tip().expect("head tip").block_id, 2);
assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010);
assert_head_matches_replay(&chain);
}
#[test]
fn channel_update_replaces_multi_block_suffix() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]);
chain.apply_adopted(msg(2), &block2);
let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]);
chain.apply_adopted(msg(3), &block3);
let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key);
let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]);
chain.apply_adopted(msg(4), &block4);
// A competing branch replaces blocks 3 and 4; orphans arrive unordered.
let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 20, &sign_key);
let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![tx3_prime]);
let tx4_prime = create_transaction_native_token_transfer(from, 2, to, 30, &sign_key);
let block4_prime = produce_dummy_block(4, Some(block3_prime.header.hash), vec![tx4_prime]);
let outcomes = chain.apply_channel_update(
&[(msg(4), block4), (msg(3), block3)],
&[(msg(13), block3_prime), (msg(14), block4_prime)],
);
assert!(matches!(
outcomes.as_slice(),
[AcceptOutcome::Applied, AcceptOutcome::Applied]
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 4);
assert_eq!(chain.head_state().get_account_by_id(from).balance, 9940);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20060);
assert_head_matches_replay(&chain);
}
#[test]
fn channel_update_ignores_unknown_orphan() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
let unknown = produce_dummy_block(9, Some(HashType([7; 32])), vec![]);
let outcomes = chain.apply_channel_update(&[(msg(99), unknown)], &[(msg(3), block3)]);
assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied]));
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
assert_head_matches_replay(&chain);
}
#[test]
fn same_height_adopted_competitor_without_orphan_is_ignored() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
// Same height, different hash, no preceding orphan event: an SDK
// contract breach. We keep the block we already applied (and warn).
let block2_prime = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
assert!(matches!(
chain.apply_adopted(msg(12), &block2_prime),
AcceptOutcome::AlreadyApplied
));
assert_eq!(chain.head_tip().expect("head tip").hash, block2.header.hash);
assert_head_matches_replay(&chain);
}
#[test]
fn restore_head_block_rebuilds_head_and_correlates_by_hash() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
// Restart shape: final tier from a persisted snapshot, head rebuilt from
// stored blocks under hash-derived sentinel MsgIds.
let mut state = initial_state();
let genesis = produce_dummy_block(1, None, vec![]);
apply_block(None, &genesis, &mut state).expect("genesis applies");
let mut chain = ChainState::from_final(state, Some(tip_of(&genesis)));
let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]);
let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]);
for block in [&block2, &block3] {
chain
.restore_head_block(MsgId::from(block.header.hash.0), block.clone())
.expect("stored blocks must replay");
}
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
assert_head_matches_replay(&chain);
// The L1 orphans restored block 3 under its real (unknown-to-us) MsgId:
// correlated by hash, the revert works and a competitor applies.
chain.revert_orphan(msg(33), &block3);
assert_eq!(chain.head_tip().expect("head tip").block_id, 2);
let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(13), &block3_prime),
AcceptOutcome::Applied
));
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010);
assert_head_matches_replay(&chain);
}
#[test]
fn restore_head_block_rejects_non_chaining_block() {
let mut chain = ChainState::new(initial_state());
let skipped = produce_dummy_block(3, Some(HashType([9; 32])), vec![]);
assert!(
chain
.restore_head_block(MsgId::from(skipped.header.hash.0), skipped)
.is_err()
);
}
#[test]
fn finalized_reinscription_matches_by_block_hash() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
chain.apply_adopted(msg(3), &block3);
// Block 2 finalizes re-inscribed under a fresh MsgId: matched by hash,
// finalized through, and the head above it survives.
assert!(matches!(
chain.apply_finalized(msg(42), &block2, slot(5)),
AcceptOutcome::Applied
));
assert_eq!(chain.final_tip().expect("final tip").block_id, 2);
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
assert!(chain.final_stall().is_none());
assert_head_matches_replay(&chain);
}
#[test]
fn finalize_through_preserves_head_state_and_advances_final_state() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]);
chain.apply_adopted(msg(2), &block2);
let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key);
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]);
chain.apply_adopted(msg(3), &block3);
chain.apply_finalized(msg(2), &block2, slot(10));
// Head still reflects both transfers
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20020);
// ...while final reflects only the finalized prefix.
assert_eq!(chain.final_state().get_account_by_id(to).balance, 20010);
assert_head_matches_replay(&chain);
}
#[test]
fn head_self_heals_with_valid_competitor_after_park() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
// Correct id, wrong parent: parked, head frozen at 1, no stall.
let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
assert!(matches!(
chain.apply_adopted(msg(2), &bad),
AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. })
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 1);
assert!(chain.final_stall().is_none());
// A valid competitor at the same height applies without any reorg event.
let good = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(12), &good),
AcceptOutcome::Applied
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 2);
assert_head_matches_replay(&chain);
}
#[test]
fn repeated_invalid_finalized_bumps_orphans_since() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_finalized(msg(1), &genesis, slot(10));
let bad3 = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
chain.apply_finalized(msg(3), &bad3, slot(20));
let bad5 = produce_dummy_block(5, Some(bad3.header.hash), vec![]);
assert!(matches!(
chain.apply_finalized(msg(5), &bad5, slot(30)),
AcceptOutcome::Parked(_)
));
let stall = chain.final_stall().expect("final stall recorded");
assert_eq!(stall.block_id, Some(3), "first stall reason is preserved");
assert_eq!(stall.orphans_since, 1);
}
#[test]
fn valid_finalized_successor_clears_final_stall() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_finalized(msg(1), &genesis, slot(10));
let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]);
chain.apply_finalized(msg(3), &bad, slot(20));
assert!(chain.final_stall().is_some());
// The valid successor of the frozen final tip finalizes: stall clears.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_finalized(msg(2), &block2, slot(30)),
AcceptOutcome::Applied
));
assert!(chain.final_stall().is_none());
assert_eq!(chain.final_tip().expect("final tip").block_id, 2);
assert_head_matches_replay(&chain);
}
#[test]
fn finalized_successor_of_head_entry_finalizes_the_prefix() {
// Head holds unfinalized blocks 1..=2 (e.g. restored after a restart);
// a peer block 3 we never saw adopted arrives finalized. Its ancestry
// finalizes our prefix implicitly, then 3 applies to final directly.
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_adopted(msg(2), &block2);
assert!(chain.final_tip().is_none());
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
assert!(matches!(
chain.apply_finalized(msg(3), &block3, slot(10)),
AcceptOutcome::Applied
));
assert_eq!(chain.final_tip().expect("final tip").block_id, 3);
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
assert!(chain.final_stall().is_none());
assert_head_matches_replay(&chain);
}
#[test]
fn finalized_redelivery_at_or_below_final_tip_is_already_applied() {
// Restart shape: the store's tip (incl. not-yet-finalized blocks) is
// restored as the final tier, so their later finalization arrives for
// blocks that were never in `head_blocks`.
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_finalized(msg(1), &genesis, slot(10));
chain.apply_finalized(msg(2), &block2, slot(20));
// Below the tip, and at the tip with a matching hash: idempotent.
assert!(matches!(
chain.apply_finalized(msg(41), &genesis, slot(30)),
AcceptOutcome::AlreadyApplied
));
assert!(matches!(
chain.apply_finalized(msg(42), &block2, slot(30)),
AcceptOutcome::AlreadyApplied
));
assert!(chain.final_stall().is_none());
assert_eq!(chain.final_tip().expect("final tip").block_id, 2);
assert_head_matches_replay(&chain);
}
#[test]
fn conflicting_finalized_at_final_tip_parks() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_finalized(msg(1), &genesis, slot(10));
chain.apply_finalized(msg(2), &block2, slot(20));
// A different finalized block at the final height: finalized is
// irreversible, so this is a genuine stall, not a re-delivery.
let block2_prime = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
assert!(matches!(
chain.apply_finalized(msg(22), &block2_prime, slot(30)),
AcceptOutcome::Parked(_)
));
assert!(chain.final_stall().is_some());
assert_eq!(chain.final_tip().expect("final tip").block_id, 2);
}
#[test]
fn finalized_unknown_block_rebases_head() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
chain.apply_finalized(msg(1), &genesis, slot(10));
// Head advances on a competing branch…
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_adopted(msg(2), &block2a);
// …but a different block 2 finalizes. The finalized chain is
// authoritative, so head rebases onto it.
let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]);
assert!(matches!(
chain.apply_finalized(msg(22), &block2b, slot(20)),
AcceptOutcome::Applied
));
assert_eq!(chain.final_tip().expect("final tip").block_id, 2);
assert_eq!(chain.head_tip().expect("head tip").block_id, 2);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010);
assert_head_matches_replay(&chain);
}
#[test]
fn head_state_reflects_applied_transfers() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]);
chain.apply_adopted(msg(2), &block2);
assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010);
}
}

View File

@ -1,10 +1,10 @@
use common::HashType;
use serde::{Deserialize, Serialize};
/// Why the indexer could not apply an L2 block from the channel.
/// Why an L2 block from the channel could not be applied.
///
/// Persisted in `RocksDB`, so every variant must have the following
/// traits: `Clone + Serialize + Deserialize`.
/// Persisted in `RocksDB` (as part of [`crate::StallReason`]), so every variant
/// must be `Clone + Serialize + Deserialize`.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum BlockIngestError {
#[error("Failed to deserialize L2 block: {0}")]

View File

@ -0,0 +1,13 @@
//! Storage-free chain-state core shared by the LEZ sequencer and indexer:
//! the [`apply_block`] entry point plus [`BlockIngestError`], [`StallReason`],
//! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model.
pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip};
pub use chain::{ChainState, HeadEntry};
pub use ingest_error::BlockIngestError;
pub use stall_reason::StallReason;
pub mod apply;
pub mod chain;
pub mod ingest_error;
pub mod stall_reason;

View File

@ -12,6 +12,7 @@ default = []
testnet = []
[dependencies]
chain_state.workspace = true
common.workspace = true
logos-blockchain-zone-sdk.workspace = true
lee.workspace = true
@ -32,7 +33,6 @@ futures.workspace = true
url.workspace = true
logos-blockchain-core.workspace = true
serde_json.workspace = true
thiserror.workspace = true
async-stream.workspace = true
tokio.workspace = true
risc0-zkvm.workspace = true

View File

@ -1,12 +1,14 @@
use std::{path::Path, sync::Arc};
use anyhow::{Context as _, Result};
use common::{
HashType,
block::{BedrockStatus, Block, BlockHeader},
transaction::{LeeTransaction, clock_invocation},
use chain_state::{
AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip,
};
use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State};
use common::{
block::{BedrockStatus, Block, BlockHeader},
transaction::LeeTransaction,
};
use lee::{Account, AccountId, V03State};
use lee_core::BlockId;
use log::warn;
use logos_blockchain_core::header::HeaderId;
@ -14,28 +16,6 @@ use logos_blockchain_zone_sdk::Slot;
use storage::indexer::RocksDBIO;
use tokio::sync::RwLock;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
struct Tip {
block_id: u64,
hash: HashType,
}
/// Outcome of feeding a parsed L2 block to the validated tip.
pub enum AcceptOutcome {
/// Chained and applied; tip and L1 read cursor both advance.
Applied,
/// A duplicate re-delivery of the current tip. Just L2 advances.
AlreadyApplied,
/// Did not chain or failed to apply; tip stays frozen, stall recorded.
Parked(BlockIngestError),
/// Chained but failed to apply, possibly transiently
/// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state
/// untouched. The caller retries and parks via
/// [`IndexerStore::record_stall`] once it gives up.
RetryableFailure(BlockIngestError),
}
#[derive(Clone)]
pub struct IndexerStore {
dbio: Arc<RocksDBIO>,
@ -258,6 +238,8 @@ impl IndexerStore {
return Ok(AcceptOutcome::AlreadyApplied);
}
// Validate before paying for the scratch clone; validation failures
// are never retryable, so parking immediately is exact.
if let Err(err) = validate_against_tip(tip.as_ref(), block) {
self.record_stall(Some(&block.header), l1_slot, err.clone())?;
return Ok(AcceptOutcome::Parked(err));
@ -265,7 +247,7 @@ impl IndexerStore {
// TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is
let mut scratch = self.current_state.read().await.clone();
if let Err(err) = apply_block_to_scratch(block, &mut scratch) {
if let Err(err) = apply_block_to_state(block, &mut scratch) {
if err.is_retryable() {
return Ok(AcceptOutcome::RetryableFailure(err));
}
@ -290,112 +272,11 @@ impl IndexerStore {
}
}
/// 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.
fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
let computed = block.recompute_hash();
if computed != block.header.hash {
return Err(BlockIngestError::HashMismatch {
computed,
header: block.header.hash,
});
}
match tip {
None => {
if block.header.block_id != GENESIS_BLOCK_ID {
return Err(BlockIngestError::UnexpectedBlockId {
expected: GENESIS_BLOCK_ID,
got: block.header.block_id,
});
}
}
Some(tip) => {
let expected = tip
.block_id
.checked_add(1)
.expect("block id should not overflow");
if block.header.block_id != expected {
return Err(BlockIngestError::UnexpectedBlockId {
expected,
got: block.header.block_id,
});
}
if block.header.prev_block_hash != tip.hash {
return Err(BlockIngestError::BrokenChainLink {
expected_prev: tip.hash,
got_prev: block.header.prev_block_hash,
});
}
}
}
Ok(())
}
/// Applies a block's transactions to `state`, mapping every failure to a
/// [`BlockIngestError`] so the caller can park rather than crash. Operates on a
/// scratch state; the caller commits only on `Ok`.
fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
let (clock_tx, user_txs) = block
.body
.transactions
.split_last()
.ok_or(BlockIngestError::EmptyBlock)?;
let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp));
if *clock_tx != expected_clock {
return Err(BlockIngestError::InvalidClockTransaction);
}
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
for (tx_index, transaction) in user_txs.iter().enumerate() {
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
tx_index: tx_index.try_into().expect("tx index fits in u64"),
reason: format!("{err:#}"),
};
if is_genesis {
let LeeTransaction::Public(public_tx) = transaction else {
return Err(BlockIngestError::NonPublicGenesisTransaction);
};
state
.transition_from_public_transaction(
public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| state_transition(err.into()))?;
} else {
transaction
.clone()
.execute_on_state(state, block.header.block_id, block.header.timestamp)
.map_err(|err| state_transition(err.into()))?;
}
}
let LeeTransaction::Public(clock_public_tx) = clock_tx else {
return Err(BlockIngestError::InvalidClockTransaction);
};
state
.transition_from_public_transaction(
clock_public_tx,
block.header.block_id,
block.header.timestamp,
)
.map_err(|err| BlockIngestError::StateTransition {
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
reason: format!("{:#}", anyhow::Error::from(err)),
})?;
Ok(())
}
#[cfg(test)]
mod stall_reason_tests {
use common::HashType;
use super::*;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
#[tokio::test]
async fn stall_reason_roundtrips_and_clears() {
@ -544,7 +425,6 @@ mod accept_tests {
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
use super::*;
use crate::ingest_error::BlockIngestError;
fn signing_key() -> lee::PrivateKey {
lee::PrivateKey::try_new([7_u8; 32]).expect("valid key")

View File

@ -305,8 +305,7 @@ mod tests {
use super::*;
use crate::{
BlockIngestError,
block_store::AcceptOutcome,
AcceptOutcome, BlockIngestError,
config::{ChannelId, ClientConfig, IndexerConfig},
};

View File

@ -2,19 +2,18 @@ use std::{path::Path, sync::Arc};
use anyhow::Result;
use arc_swap::ArcSwap;
pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason};
use common::block::Block;
// TODO: Remove after testnet
use futures::StreamExt as _;
pub use ingest_error::BlockIngestError;
use log::{error, info, warn};
use logos_blockchain_zone_sdk::{
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use retry::ApplyRetryGate;
pub use stall_reason::StallReason;
use crate::{
block_store::{AcceptOutcome, IndexerStore},
block_store::IndexerStore,
chain_consistency::ChainConsistency,
config::IndexerConfig,
cross_zone_verifier::CrossZoneVerifier,
@ -24,9 +23,7 @@ pub mod block_store;
pub mod chain_consistency;
pub mod config;
pub mod cross_zone_verifier;
pub mod ingest_error;
mod retry;
pub mod stall_reason;
pub mod status;
/// Consecutive failed apply attempts of the same block before parking.

View File

@ -1,7 +1,6 @@
use chain_state::StallReason;
use serde::Serialize;
use crate::stall_reason::StallReason;
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
/// "still catching up" apart from "something went wrong".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@ -117,10 +116,9 @@ mod tests {
#[test]
fn stalled_status_serializes_with_stall_reason() {
use chain_state::{BlockIngestError, StallReason};
use logos_blockchain_zone_sdk::Slot;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
let status = IndexerStatus {
sync: IndexerSyncStatus::stalled("broken chain link".to_owned()),
indexed_block_id: Some(41),

View File

@ -65,6 +65,11 @@ impl<T> MemPoolHandle<T> {
pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError<T>> {
self.sender.send(item).await
}
/// Send an item to the mempool, failing _immediately_ if it is full.
pub fn try_push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::TrySendError<T>> {
self.sender.try_send(item)
}
}
#[cfg(test)]
@ -123,6 +128,19 @@ mod tests {
assert_eq!(pool.pop(), Some(2));
}
#[test]
async fn try_push_fails_when_full_without_blocking() {
let (mut pool, handle) = MemPool::new(1);
handle.try_push(1).unwrap();
assert!(handle.try_push(2).is_err(), "full mempool must not accept");
// Popping frees capacity again.
assert_eq!(pool.pop(), Some(1));
handle.try_push(2).unwrap();
assert_eq!(pool.pop(), Some(2));
}
#[test]
async fn push_front() {
let (mut pool, handle) = MemPool::new(10);

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
lee.workspace = true
lee_core.workspace = true
chain_state.workspace = true
common.workspace = true
storage.workspace = true
mempool.workspace = true

View File

@ -3,19 +3,28 @@ use std::{pin::Pin, sync::Arc, time::Duration};
use anyhow::{Context as _, Result, anyhow};
use common::block::Block;
use log::{info, warn};
pub use logos_blockchain_core::mantle::ops::channel::MsgId;
use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription};
pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey};
pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId};
use logos_blockchain_core::mantle::{
channel::{SlotTimeframe, SlotTimeout},
ops::channel::{ChannelId, config::Keys, inscribe::Inscription},
};
pub use logos_blockchain_key_management_system_service::keys::{
ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey,
};
pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
use logos_blockchain_zone_sdk::{
CommonHttpClient,
adapter::NodeHttpClient,
sequencer::{
DepositInfo, Event, FinalizedOp, InscriptionInfo,
SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer,
DepositInfo, Event, FinalizedOp, InscriptionInfo, OrphanedTx,
SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, WithdrawInfo,
ZoneSequencer,
},
};
use tokio::{sync::mpsc, task::JoinHandle};
use tokio::{
sync::{mpsc, oneshot, watch},
task::JoinHandle,
};
use crate::config::BedrockConfig;
@ -41,6 +50,40 @@ pub type OnDepositEventSink =
pub type OnWithdrawEventSink =
Box<dyn Fn(WithdrawInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
/// The channel delta the follow path consumes from one `Event::BlocksProcessed`,
/// with inscription payloads decoded into `(MsgId, Block)` pairs.
pub struct FollowUpdate {
pub adopted: Vec<(MsgId, Block)>,
pub orphaned: Vec<(MsgId, Block)>,
pub finalized: Vec<(MsgId, Block)>,
}
/// Sink for the follow path: apply adopted/finalized blocks to chain state and
/// revert orphaned ones.
pub type OnFollowSink =
Box<dyn Fn(FollowUpdate) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
/// Commands the drive task executes with `&mut sequencer`.
enum Command {
/// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`.
Publish {
inscription: Inscription,
withdrawals: Vec<WithdrawArg>,
resp: oneshot::Sender<Result<MsgId>>,
},
/// Post a `ChannelConfig` op replacing the accredited keys / rotation params.
ConfigureChannel {
keys: Keys,
posting_timeframe: SlotTimeframe,
posting_timeout: SlotTimeout,
configuration_threshold: u16,
withdraw_threshold: u16,
resp: oneshot::Sender<Result<()>>,
},
}
type CommandSender = mpsc::Sender<Command>;
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
pub trait BlockPublisherTrait: Clone {
#[expect(
@ -56,20 +99,44 @@ pub trait BlockPublisherTrait: Clone {
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
on_follow: OnFollowSink,
) -> Result<Self>;
/// Fire-and-forget publish. Zone-sdk drives the actual submission and
/// retries internally; this just hands the payload off.
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()>;
/// Publish a block and return the `MsgId` zone-sdk assigned its inscription.
/// Zone-sdk drives the actual submission and retries internally.
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
/// Update the channel's accredited key set and rotation parameters via a
/// `ChannelConfig` op. The sequencer's bedrock key must be the channel
/// admin (`keys[0]`); the L1 rejects non-admin signers, so this is not
/// re-validated here.
///
/// `Ok(())` only means the signed op was queued locally, not that the
/// L1 accepted it — acceptance is asynchronous.
///
/// Desugared (not `async fn`) so the returned future is provably `Send` —
/// generic callers awaiting it inside jsonrpsee handlers require that.
fn configure_channel(
&self,
keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
withdraw_threshold: u16,
) -> impl Future<Output = Result<()>> + Send;
fn channel_id(&self) -> ChannelId;
/// Whether this sequencer is currently authorized to write to the channel.
fn is_our_turn(&self) -> bool;
}
/// Real block publisher backed by zone-sdk's `ZoneSequencer`.
#[derive(Clone)]
pub struct ZoneSdkPublisher {
channel_id: ChannelId,
publish_tx: mpsc::Sender<(Inscription, Vec<WithdrawArg>)>,
command_tx: CommandSender,
turn_rx: watch::Receiver<TurnNotification>,
// Aborts the drive task when the last clone is dropped.
_drive_task: Arc<DriveTaskGuard>,
}
@ -92,6 +159,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
on_follow: OnFollowSink,
) -> Result<Self> {
let basic_auth = config.auth.clone().map(Into::into);
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone());
@ -112,9 +180,11 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
// Grab readiness receiver before moving the sequencer into the drive
// task so we can await cold-start completion below.
let mut ready_rx = sequencer.subscribe_ready();
// Grab the turn watch before the move; the sdk actor keeps it current.
let turn_rx = sequencer.subscribe_turn_to_write();
let (publish_tx, mut publish_rx) =
mpsc::channel::<(Inscription, Vec<WithdrawArg>)>(PUBLISH_INBOX_CAPACITY);
let (command_tx, mut command_rx): (CommandSender, _) =
mpsc::channel(PUBLISH_INBOX_CAPACITY);
let drive_task = tokio::spawn(async move {
loop {
@ -124,32 +194,62 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
)]
{
tokio::select! {
// Drain external publish requests by calling the
// borrowing handle — `&mut sequencer` is only
// available here.
Some((data_bounded, withdrawals)) = publish_rx.recv() => {
let data_byte_size = data_bounded.len();
if withdrawals.is_empty() {
if let Err(e) = sequencer.handle()
.publish(data_bounded)
.context("Failed to publish block") {
warn!("zone-sdk publish failed: {e:?}");
}
info!("Published block with the size of {data_byte_size} bytes");
} else {
// Drain external commands by calling the borrowing
// handle — `&mut sequencer` is only available here.
Some(command) = command_rx.recv() => match command {
Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => {
let data_byte_size = data_bounded.len();
let withdraw_count = withdrawals.len();
if let Err(e) = sequencer.handle()
.publish_atomic_withdraw(data_bounded, withdrawals)
.context("Failed to publish block with withdrawals") {
warn!("zone-sdk publish failed: {e:?}");
}
let published = if withdrawals.is_empty() {
sequencer.handle()
.publish(data_bounded)
.context("Failed to publish block")
} else {
sequencer.handle()
.publish_atomic_withdraw(data_bounded, withdrawals)
.context("Failed to publish block with withdrawals")
};
info!(
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
);
let msg_result = published
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
match &msg_result {
Ok(_) if withdraw_count == 0 => {
info!("Published block with the size of {data_byte_size} bytes");
}
Ok(_) => {
info!(
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
);
}
Err(e) => warn!("zone-sdk publish failed: {e:?}"),
}
let _dontcare = resp_tx.send(msg_result);
}
}
Command::ConfigureChannel {
keys,
posting_timeframe,
posting_timeout,
configuration_threshold,
withdraw_threshold,
resp,
} => {
let result = sequencer
.handle()
.channel_config(
keys,
posting_timeframe,
posting_timeout,
configuration_threshold,
withdraw_threshold,
)
.map(|_queued| ())
.context("Failed to post channel config");
if let Err(err) = &result {
warn!("zone-sdk channel config failed: {err:?}");
}
let _dontcare = resp.send(result);
}
},
event = sequencer.next_event() => {
let Some(event) = event else {
continue;
@ -157,17 +257,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
match event {
Event::BlocksProcessed {
checkpoint,
channel_update: _,
channel_update,
finalized,
} => {
on_checkpoint(checkpoint);
let adopted = channel_update
.adopted
.iter()
.filter_map(block_from_inscription)
.collect();
let orphaned = channel_update
.orphaned
.iter()
.map(orphan_inscription)
.filter_map(block_from_inscription)
.collect();
let mut finalized_blocks = Vec::new();
for op in finalized.into_iter().flat_map(|item| item.ops) {
match op {
FinalizedOp::Inscription(inscription) => {
if let Some(block_id) =
block_id_from_inscription(&inscription)
if let Some((msg, block)) =
block_from_inscription(&inscription)
{
on_finalized_block(block_id);
on_finalized_block(block.header.block_id);
finalized_blocks.push((msg, block));
}
}
FinalizedOp::Deposit(deposit) => {
@ -178,8 +293,23 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
}
}
}
on_follow(FollowUpdate {
adopted,
orphaned,
finalized: finalized_blocks,
})
.await;
}
Event::Ready => {}
Event::TurnNotification { notification } => {
info!(
"Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}",
notification.our_turn_to_write,
notification.starting_slot,
notification.ends_at_slot
);
}
Event::Ready | Event::TurnNotification { .. } => {}
}
}
}
@ -196,37 +326,84 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
Ok(Self {
channel_id: config.channel_id,
publish_tx,
command_tx,
turn_rx,
_drive_task: Arc::new(DriveTaskGuard(drive_task)),
})
}
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()> {
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId> {
let data = borsh::to_vec(block).context("Failed to serialize block")?;
let data_bounded: Inscription = data
.try_into()
.context("Block data exceeds maximum allowed size")?;
self.publish_tx
.send((data_bounded, withdrawals))
let (resp_tx, resp_rx) = oneshot::channel();
self.command_tx
.send(Command::Publish {
inscription: data_bounded,
withdrawals,
resp: resp_tx,
})
.await
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
Ok(())
resp_rx
.await
.map_err(|_closed| anyhow!("Drive task dropped the publish response"))?
}
async fn configure_channel(
&self,
keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
withdraw_threshold: u16,
) -> Result<()> {
let keys =
Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
let (resp_tx, resp_rx) = oneshot::channel();
self.command_tx
.send(Command::ConfigureChannel {
keys,
posting_timeframe: posting_timeframe.into(),
posting_timeout: posting_timeout.into(),
configuration_threshold,
withdraw_threshold,
resp: resp_tx,
})
.await
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
resp_rx
.await
.map_err(|_closed| anyhow!("Drive task dropped the config response"))?
}
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn is_our_turn(&self) -> bool {
self.turn_rx.borrow().our_turn_to_write
}
}
/// Deserialize inscription payload as a `Block` and return it's`block_id`.
/// Bad payloads are logged and skipped.
fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option<u64> {
/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are
/// logged and skipped.
fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> {
borsh::from_slice::<Block>(&inscription.payload)
.inspect_err(|err| {
warn!("Failed to deserialize block from inscription: {err:?}");
})
.ok()
.map(|block| block.header.block_id)
.map(|block| (inscription.this_msg, block))
}
/// The inscription carried by an orphaned tx (plain or atomic-withdraw bundle).
const fn orphan_inscription(orphan: &OrphanedTx) -> &InscriptionInfo {
match orphan {
OrphanedTx::Inscription(info) => info,
OrphanedTx::AtomicWithdraw(bundle) => &bundle.inscription,
}
}

View File

@ -1,10 +1,15 @@
use std::{path::Path, sync::Arc, time::Instant};
use std::{
path::Path,
sync::{Arc, Mutex},
time::Instant,
};
use anyhow::{Context as _, Result, anyhow};
use borsh::BorshDeserialize;
use chain_state::{AcceptOutcome, ChainState, Tip};
use common::{
HashType,
block::{BedrockStatus, Block, HashableBlockData},
block::{BedrockStatus, Block, BlockMeta, HashableBlockData},
transaction::{LeeTransaction, clock_invocation},
};
use config::{GenesisAction, SequencerConfig};
@ -13,7 +18,10 @@ use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::GENESIS_BLOCK_ID;
use log::{error, info, warn};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use logos_blockchain_zone_sdk::sequencer::{DepositInfo, WithdrawArg};
use logos_blockchain_zone_sdk::{
Slot,
sequencer::{DepositInfo, WithdrawArg},
};
use mempool::{MemPool, MemPoolHandle};
#[cfg(feature = "mock")]
pub use mock::SequencerCoreWithMockClients;
@ -25,7 +33,7 @@ use storage::sequencer::{
};
use crate::{
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
block_publisher::{BlockPublisherTrait, Ed25519PublicKey, MsgId, ZoneSdkPublisher},
block_store::SequencerStore,
};
@ -58,11 +66,12 @@ impl DepositMetadata {
}
pub struct SequencerCore<BP: BlockPublisherTrait = ZoneSdkPublisher> {
state: lee::V03State,
/// Two-tier chain state: production builds on its head; the publisher's
/// `on_follow` sink feeds adopted/orphaned/finalized peer blocks into it.
chain: Arc<Mutex<ChainState>>,
store: SequencerStore,
mempool: MemPool<(TransactionOrigin, LeeTransaction)>,
sequencer_config: SequencerConfig,
chain_height: u64,
block_publisher: BP,
}
@ -119,18 +128,83 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
/// Rebuilds the two-tier [`ChainState`]: the final tier from the persisted
/// final snapshot (pre-genesis state when absent), the head tier by replaying
/// every stored block above it, so a post-restart orphan can still revert.
fn restore_chain_state(
config: &SequencerConfig,
store: &SequencerStore,
stored_head_state: &lee::V03State,
) -> ChainState {
let final_snapshot = store
.dbio()
.get_final_snapshot()
.expect("Failed to read final snapshot from store");
let (final_state, final_tip) = match final_snapshot {
Some((state, meta)) => (
state,
Some(Tip {
block_id: meta.id,
hash: meta.hash,
}),
),
// Nothing finalized yet: replay the whole stored chain.
None => (build_initial_state(config), None),
};
let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id);
let mut head_blocks = store
.get_all_blocks()
.filter_ok(|block| block.header.block_id > boundary)
.collect::<Result<Vec<_>, _>>()
.expect("Failed to read blocks from store while restoring chain state");
head_blocks.sort_unstable_by_key(|block| block.header.block_id);
let mut chain = ChainState::from_final(final_state, final_tip);
for block in head_blocks {
let block_id = block.header.block_id;
// NOTE: sentinel for the real (unpersisted) `MsgId`; never leaves the
// process, events correlate by hash. Persisting the real one needs a
// sidecar cell (block_id -> MsgId), since growing the shared `Block`
// type would ripple through every consumer's serialization.
let sentinel = MsgId::from(block.header.hash.0);
chain
.restore_head_block(sentinel, block)
.unwrap_or_else(|err| {
panic!(
"Stored block {block_id} does not replay while restoring chain state: {err}"
)
});
}
// The replayed head must reproduce the persisted state byte-for-byte,
// else store and config disagree (e.g. edited genesis actions).
let replayed = borsh::to_vec(chain.head_state()).expect("state serializes");
let stored = borsh::to_vec(stored_head_state).expect("state serializes");
assert_eq!(
replayed, stored,
"Persisted state does not match the replayed chain; reset the store or restore the original config"
);
chain
}
pub async fn start_from_config(
config: SequencerConfig,
) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) {
let bedrock_signing_key =
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
.expect("Failed to load or create bedrock signing key");
info!(
"Bedrock signing public key: {}",
hex::encode(bedrock_signing_key.public_key().to_bytes())
);
let (store, state, _genesis_block) = Self::open_or_create_store(&config);
let latest_block_meta = store
.latest_block_meta()
.expect("Failed to read latest block meta from store");
let chain = Arc::new(Mutex::new(Self::restore_chain_state(
&config, &store, &state,
)));
let initial_checkpoint = store
.get_zone_checkpoint()
@ -149,6 +223,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
Self::on_finalized_block(store.dbio()),
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
Self::on_withdraw_event(store.dbio()),
Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()),
)
.await
.expect("Failed to initialize Block Publisher");
@ -194,10 +269,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
let sequencer_core = Self {
state,
chain,
store,
mempool,
chain_height: latest_block_meta.id,
sequencer_config: config,
block_publisher,
};
@ -335,6 +409,18 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
})
}
/// Publisher sink adapter over [`apply_follow_update`].
fn on_follow(
dbio: Arc<RocksDBIO>,
chain: Arc<Mutex<ChainState>>,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) -> block_publisher::OnFollowSink {
Box::new(move |update: block_publisher::FollowUpdate| {
apply_follow_update(&dbio, &chain, &mempool_handle, update);
Box::pin(std::future::ready(()))
})
}
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
pub async fn produce_new_block(&mut self) -> Result<u64> {
let BlockWithMeta {
@ -351,26 +437,76 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.collect::<Result<_>>()
.context("Failed to build reconciliation keys for block withdrawals")?;
self.block_publisher
let this_msg = self
.block_publisher
.publish_block(&block, withdrawals)
.await
.context("Failed to publish block to Bedrock")?;
self.store.update(
self.record_produced_block(
this_msg,
&block,
&deposit_event_ids,
withdrawal_reconciliation_keys,
&self.state,
)?;
Ok(self.chain_height)
Ok(block.header.block_id)
}
/// Applies our own freshly-published block to the head with the [`MsgId`] the
/// publish assigned it, so the head advances and the later adopted
/// redelivery dedups, then persists it.
///
/// Persistence is gated on the block actually becoming the head: if a peer
/// block won this height while we were publishing (`AlreadyApplied`, or
/// `Parked` when the head reorged to a different parent), the canonical
/// block is persisted by the follow path instead, and our invalidated
/// inscription comes back via `orphaned`.
fn record_produced_block(
&mut self,
this_msg: MsgId,
block: &Block,
deposit_event_ids: &[HashType],
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
) -> Result<()> {
let head_state = {
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
match chain.apply_adopted(this_msg, block) {
AcceptOutcome::Applied => Some(chain.head_state().clone()),
AcceptOutcome::AlreadyApplied => {
warn!(
"Produced block {} lost a competing-write race, skipping persistence",
block.header.block_id
);
None
}
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
warn!(
"Produced block {} no longer chains on the head, skipping persistence: {err}",
block.header.block_id
);
None
}
}
};
if let Some(head_state) = head_state {
self.store.update(
block,
deposit_event_ids,
withdrawal_reconciliation_keys,
&head_state,
)?;
}
Ok(())
}
/// Validates and applies a single mempool transaction to the current state.
/// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if
/// it was skipped due to validation failure.
fn apply_mempool_transaction(
&mut self,
state: &mut lee::V03State,
origin: TransactionOrigin,
tx: &LeeTransaction,
block_height: u64,
@ -381,11 +517,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let tx_hash = tx.hash();
match origin {
TransactionOrigin::User => {
let validated_diff = match tx.validate_on_state(
&self.state,
block_height,
timestamp,
) {
let validated_diff = match tx.validate_on_state(state, block_height, timestamp) {
Ok(diff) => diff,
Err(err) => {
error!(
@ -399,7 +531,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
withdrawals.push(withdraw_data);
}
self.state.apply_state_diff(validated_diff);
state.apply_state_diff(validated_diff);
}
TransactionOrigin::Sequencer => {
let LeeTransaction::Public(public_tx) = tx else {
@ -410,7 +542,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
deposit_event_ids.push(deposit_op_id);
}
self.state
state
.transition_from_public_transaction(public_tx, block_height, timestamp)
.context("Failed to execute sequencer-generated transaction")?;
}
@ -423,7 +555,18 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
fn build_block_from_mempool(&mut self) -> Result<BlockWithMeta> {
let now = Instant::now();
let new_block_height = self.next_block_id();
// Build on the head: its tip is the parent, its state the validation base.
let (prev_block_hash, new_block_height, mut working_state) = {
let chain = self.chain.lock().expect("chain state mutex poisoned");
let tip = chain.head_tip();
let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| {
head.block_id
.checked_add(1)
.expect("block id should not overflow")
});
let prev = tip.map_or(HashType([0; 32]), |head| head.hash);
(prev, height, chain.head_state().clone())
};
let mut valid_transactions = Vec::new();
let mut deposit_event_ids = Vec::new();
@ -432,11 +575,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64())
.expect("`max_block_size` should fit into usize");
let latest_block_meta = self
.store
.latest_block_meta()
.context("Failed to get latest block meta from store")?;
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
.expect("Timestamp must be positive");
@ -455,7 +593,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let temp_hashable_data = HashableBlockData {
block_id: new_block_height,
transactions: temp_valid_transactions,
prev_block_hash: latest_block_meta.hash,
prev_block_hash,
timestamp: new_block_timestamp,
};
@ -472,7 +610,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
break;
}
if self.apply_mempool_transaction(
if Self::apply_mempool_transaction(
&mut working_state,
origin,
&tx,
new_block_height,
@ -488,7 +627,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
self.state
working_state
.transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp)
.context("Clock transaction failed. Aborting block production.")?;
valid_transactions.push(clock_lee_tx);
@ -496,7 +635,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let hashable_data = HashableBlockData {
block_id: new_block_height,
transactions: valid_transactions,
prev_block_hash: latest_block_meta.hash,
prev_block_hash,
timestamp: new_block_timestamp,
};
@ -504,8 +643,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.clone()
.into_pending_block(self.store.signing_key());
self.chain_height = new_block_height;
log::info!(
"Created block with {} transactions in {} seconds",
hashable_data.transactions.len(),
@ -519,16 +656,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
})
}
pub const fn state(&self) -> &lee::V03State {
&self.state
/// Reads the current head state under the lock without cloning it, so callers
/// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy.
pub fn with_state<R>(&self, f: impl FnOnce(&lee::V03State) -> R) -> R {
f(self
.chain
.lock()
.expect("chain state mutex poisoned")
.head_state())
}
pub const fn block_store(&self) -> &SequencerStore {
&self.store
}
pub const fn chain_height(&self) -> u64 {
self.chain_height
#[must_use]
pub fn chain_height(&self) -> u64 {
self.chain
.lock()
.expect("chain state mutex poisoned")
.head_tip()
.map_or(0, |tip| tip.block_id)
}
pub const fn sequencer_config(&self) -> &SequencerConfig {
@ -565,10 +713,37 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
self.block_publisher.clone()
}
fn next_block_id(&self) -> u64 {
self.chain_height
.checked_add(1)
.unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height))
/// Whether this sequencer is currently authorized to write to the channel.
#[must_use]
pub fn is_our_turn(&self) -> bool {
self.block_publisher.is_our_turn()
}
/// Update the channel's accredited key set and rotation parameters.
/// This sequencer's bedrock key must be the channel admin (`keys[0]`).
pub async fn configure_channel(
&self,
keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
withdraw_threshold: u16,
) -> Result<()> {
self.block_publisher
.configure_channel(
keys,
posting_timeframe,
posting_timeout,
configuration_threshold,
withdraw_threshold,
)
.await
}
/// Shared handle to the two-tier follow state.
#[must_use]
pub fn chain(&self) -> Arc<Mutex<ChainState>> {
Arc::clone(&self.chain)
}
}
@ -578,6 +753,111 @@ struct BlockWithMeta {
withdrawals: Vec<WithdrawArg>,
}
/// Feed one channel delta into the follow state and mirror it to the store:
/// revert orphaned, then apply and persist adopted and finalized blocks.
/// Production builds on this same head. Wired to the publisher via
/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly.
///
/// TODO: unlike the indexer's ingest loop, this path does not retry
/// `is_retryable` (transient) apply failures — a failed block just parks and
/// relies on a valid successor or a restart. `ChainState` never emits
/// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a
/// follow-up.
fn apply_follow_update(
dbio: &RocksDBIO,
chain: &Mutex<ChainState>,
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
update: block_publisher::FollowUpdate,
) {
let block_publisher::FollowUpdate {
adopted,
orphaned,
finalized,
} = update;
// Apply under the lock and collect what to persist; release it before
// touching disk so the producer is never blocked on follow I/O.
let (to_persist, resubmit_txs, head_snapshot, final_snapshot) = {
let mut chain = chain.lock().expect("chain state mutex poisoned");
// User txs of orphaned blocks, returned to the mempool below.
let resubmit_txs: Vec<LeeTransaction> = orphaned
.iter()
.flat_map(|(_, block)| resubmittable_txs(block))
.collect();
// Outcomes align with `adopted`.
let outcomes = chain.apply_channel_update(&orphaned, &adopted);
let mut to_persist: Vec<(&Block, bool)> = adopted
.iter()
.zip(&outcomes)
.filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied))
.map(|((_, block), _)| (block, false))
.collect();
let mut final_advanced = false;
for (this_msg, block) in &finalized {
// FIXME: thread the finalized inscription's L1 slot once the
// sdk surfaces it; only used for the invalid-finalized stall.
if matches!(
chain.apply_finalized(*this_msg, block, Slot::from(0)),
AcceptOutcome::Applied
) {
to_persist.push((block, true));
final_advanced = true;
}
}
// Snapshot the advanced final tier so a restart re-anchors on it.
let final_snapshot = final_advanced.then(|| {
let tip = chain.final_tip().expect("advanced final tier has a tip");
(
chain.final_state().clone(),
BlockMeta {
id: tip.block_id,
hash: tip.hash,
},
)
});
(
to_persist,
resubmit_txs,
chain.head_state().clone(),
final_snapshot,
)
};
// One atomic write for the whole update: blocks, tip meta and the state
// after the last block land together, so a crash can never leave the
// stored state ahead of the stored blocks.
//
// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` before
// this write; a crash in between resumes past these blocks without them
// ever landing in the store. Full `BlocksProcessed` atomicity (checkpoint
// + blocks + state in one batch, per the sdk's event contract) is a
// follow-up.
if let Err(err) = dbio.store_followed_blocks(
&to_persist,
&head_snapshot,
final_snapshot.as_ref().map(|(state, meta)| (state, meta)),
) {
error!("Failed to persist followed blocks: {err:#}");
}
// Rebuild orphaned work: return its user txs to the mempool so the
// next on-turn production re-includes them on the new head.
//
// We use [`try_push`] here because this is called from the
// publisher's drive task, and only the block production drains the mempool.
// A blocking push on a full mempool would deadlock here.
for tx in resubmit_txs {
let tx_hash = tx.hash();
if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) {
warn!("Dropping orphaned transaction {tx_hash} on resubmit: {err}");
}
}
}
/// Checks the database for any pending deposit events that have not yet been marked as submitted in
/// a block, and re-queues them in the mempool in a separate async task for inclusion in the next
/// block.
@ -627,41 +907,15 @@ fn replay_unfulfilled_deposit_events(
});
}
/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis
/// transactions. Returns the final state and the list of [`LeeTransaction`]s that should be
/// committed to the genesis block so external observers can replay them.
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside
/// any transaction (bridge-lock holdings, the cross-zone inbox config).
fn build_initial_state(config: &SequencerConfig) -> lee::V03State {
#[cfg(not(feature = "testnet"))]
let mut state = testnet_initial_state::initial_state();
#[cfg(feature = "testnet")]
let mut state = testnet_initial_state::initial_state_testnet();
let genesis_txs = config
.genesis
.iter()
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted below: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
state
.transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0)
.expect("Failed to execute genesis transaction");
})
.map(LeeTransaction::Public)
.collect();
// Seed bridge-lock holder balances directly: they are not produced by any tx.
for action in &config.genesis {
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
@ -679,6 +933,41 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
state.insert_genesis_account(config_id, config_account);
}
state
}
/// Builds the initial genesis state from [`build_initial_state`] plus configured
/// genesis transactions. Returns the final state and the list of
/// [`LeeTransaction`]s that should be committed to the genesis block so external
/// observers can replay them.
fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTransaction>) {
let mut state = build_initial_state(config);
let genesis_txs = config
.genesis
.iter()
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted in `build_initial_state`: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
state
.transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0)
.expect("Failed to execute genesis transaction");
})
.map(LeeTransaction::Public)
.collect();
(state, genesis_txs)
}
@ -771,6 +1060,19 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu
)))
}
/// User transactions of an orphaned block to return to the mempool: everything
/// except the trailing clock tx and sequencer-generated bridge deposits (those are
/// replayed from their own bedrock events, not the mempool).
fn resubmittable_txs(block: &Block) -> Vec<LeeTransaction> {
let Some((_clock, rest)) = block.body.transactions.split_last() else {
return Vec::new();
};
rest.iter()
.filter(|tx| extract_bridge_deposit_id(tx).is_none())
.cloned()
.collect()
}
#[must_use]
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
let LeeTransaction::Public(tx) = tx else {
@ -860,7 +1162,7 @@ fn withdraw_event_reconciliation_key(
}
/// Load signing key from file or generate a new one if it doesn't exist.
fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
pub fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
if path.exists() {
let key_bytes = std::fs::read(path)?;

View File

@ -1,24 +1,48 @@
use std::time::Duration;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::Result;
use common::block::Block;
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
use logos_blockchain_zone_sdk::sequencer::WithdrawArg;
use crate::{
block_publisher::{
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink,
OnWithdrawEventSink, SequencerCheckpoint,
BlockPublisherTrait, CheckpointSink, Ed25519PublicKey, FinalizedBlockSink,
OnDepositEventSink, OnFollowSink, OnWithdrawEventSink, SequencerCheckpoint,
},
config::BedrockConfig,
};
pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>;
/// One recorded `configure_channel` invocation.
#[derive(Clone)]
pub struct ConfigureChannelCall {
pub keys: Vec<Ed25519PublicKey>,
pub posting_timeframe: u32,
pub posting_timeout: u32,
pub configuration_threshold: u16,
pub withdraw_threshold: u16,
}
#[derive(Clone)]
pub struct MockBlockPublisher {
channel_id: ChannelId,
configure_channel_calls: Arc<Mutex<Vec<ConfigureChannelCall>>>,
}
impl MockBlockPublisher {
#[must_use]
pub fn configure_channel_calls(&self) -> Vec<ConfigureChannelCall> {
self.configure_channel_calls
.lock()
.expect("mock mutex poisoned")
.clone()
}
}
impl BlockPublisherTrait for MockBlockPublisher {
@ -31,21 +55,51 @@ impl BlockPublisherTrait for MockBlockPublisher {
_on_finalized_block: FinalizedBlockSink,
_on_deposit_event: OnDepositEventSink,
_on_withdraw_event: OnWithdrawEventSink,
_on_follow: OnFollowSink,
) -> Result<Self> {
Ok(Self {
channel_id: config.channel_id,
configure_channel_calls: Arc::default(),
})
}
async fn publish_block(
&self,
_block: &Block,
block: &Block,
_bridge_withdrawals: Vec<WithdrawArg>,
) -> Result<MsgId> {
// Deterministic per-block id so head dedup behaves in tests.
//
// TODO: should we allow more "mockability" here?
Ok(MsgId::from(block.header.hash.0))
}
async fn configure_channel(
&self,
keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
withdraw_threshold: u16,
) -> Result<()> {
self.configure_channel_calls
.lock()
.expect("mock mutex poisoned")
.push(ConfigureChannelCall {
keys,
posting_timeframe,
posting_timeout,
configuration_threshold,
withdraw_threshold,
});
Ok(())
}
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn is_our_turn(&self) -> bool {
true
}
}

View File

@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration};
use common::{
HashType,
block::HashableBlockData,
block::{BedrockStatus, HashableBlockData},
test_utils::sequencer_sign_key_for_testing,
transaction::{LeeTransaction, clock_invocation},
};
@ -22,19 +22,22 @@ use lee_core::{
account::{AccountWithMetadata, Nonce},
program::PdaSeed,
};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use mempool::MemPoolHandle;
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
use tempfile::tempdir;
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
use crate::{
TransactionOrigin,
TransactionOrigin, apply_follow_update,
block_publisher::FollowUpdate,
block_store::SequencerStore,
build_genesis_state,
build_bridge_deposit_tx_from_event, build_genesis_state,
config::{BedrockConfig, SequencerConfig},
is_sequencer_only_program,
mock::SequencerCoreWithMockClients,
resubmittable_txs,
};
#[derive(borsh::BorshSerialize)]
@ -146,14 +149,14 @@ async fn start_from_config() {
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
assert_eq!(sequencer.chain_height, 1);
assert_eq!(sequencer.chain_height(), 1);
assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10);
let acc1_account_id = initial_public_user_accounts()[0].account_id;
let acc2_account_id = initial_public_user_accounts()[1].account_id;
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance;
let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance;
let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance);
let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
assert_eq!(10000, balance_acc_1);
assert_eq!(20000, balance_acc_2);
@ -186,7 +189,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() {
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
assert_eq!(sequencer.chain_height, 1);
assert_eq!(sequencer.chain_height(), 1);
assert!(sequencer.store.latest_block_meta().is_ok());
}
@ -295,7 +298,7 @@ async fn transaction_pre_check_native_transfer_valid() {
#[tokio::test]
async fn transaction_pre_check_native_transfer_other_signature() {
let (mut sequencer, _mempool_handle) = common_setup().await;
let (sequencer, _mempool_handle) = common_setup().await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
@ -309,7 +312,15 @@ async fn transaction_pre_check_native_transfer_other_signature() {
let tx = tx.transaction_stateless_check().unwrap();
// Signature is not from sender. Execution fails
let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0);
let result = tx.execute_check_on_state(
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut(),
0,
0,
);
assert!(matches!(
result,
@ -319,7 +330,7 @@ async fn transaction_pre_check_native_transfer_other_signature() {
#[tokio::test]
async fn transaction_pre_check_native_transfer_sent_too_much() {
let (mut sequencer, _mempool_handle) = common_setup().await;
let (sequencer, _mempool_handle) = common_setup().await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
@ -335,9 +346,15 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
// Passed pre-check
assert!(result.is_ok());
let result = result
.unwrap()
.execute_check_on_state(&mut sequencer.state, 0, 0);
let result = result.unwrap().execute_check_on_state(
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut(),
0,
0,
);
let is_failed_at_balance_mismatch = matches!(
result.err().unwrap(),
lee::error::LeeError::ProgramExecutionFailed(_)
@ -348,7 +365,7 @@ async fn transaction_pre_check_native_transfer_sent_too_much() {
#[tokio::test]
async fn transaction_execute_native_transfer() {
let (mut sequencer, _mempool_handle) = common_setup().await;
let (sequencer, _mempool_handle) = common_setup().await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
@ -359,11 +376,19 @@ async fn transaction_execute_native_transfer() {
acc1, 0, acc2, 100, &sign_key1,
);
tx.execute_check_on_state(&mut sequencer.state, 0, 0)
.unwrap();
tx.execute_check_on_state(
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut(),
0,
0,
)
.unwrap();
let bal_from = sequencer.state.get_account_by_id(acc1).balance;
let bal_to = sequencer.state.get_account_by_id(acc2).balance;
let bal_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance);
let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance);
assert_eq!(bal_from, 9900);
assert_eq!(bal_to, 20100);
@ -400,7 +425,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() {
#[tokio::test]
async fn build_block_from_mempool() {
let (mut sequencer, mempool_handle) = common_setup().await;
let genesis_height = sequencer.chain_height;
let genesis_height = sequencer.chain_height();
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
@ -410,7 +435,8 @@ async fn build_block_from_mempool() {
let result = sequencer.build_block_from_mempool();
assert!(result.is_ok());
assert_eq!(sequencer.chain_height, genesis_height + 1);
// Building itself does not advance the head; only apply-after-publish does.
assert_eq!(sequencer.chain_height(), genesis_height);
}
#[tokio::test]
@ -442,7 +468,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -477,7 +503,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
assert_eq!(
@ -496,7 +522,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
// The replay is rejected, so only the clock tx is in the block.
@ -538,7 +564,7 @@ async fn restart_from_storage() {
sequencer.produce_new_block().await.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
assert_eq!(
@ -554,8 +580,8 @@ async fn restart_from_storage() {
// with the above transaction and update the state to reflect that.
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance;
let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance;
let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance);
let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance);
// Balances should be consistent with the stored block
assert_eq!(
@ -653,7 +679,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() {
// Step 5: Verify the new block has correct previous block metadata
let new_block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -705,7 +731,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() {
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -760,7 +786,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() {
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.get_block_at_id(sequencer.chain_height())
.unwrap()
.unwrap();
@ -779,10 +805,13 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() {
// Corrupt the clock 01 account data so the clock program panics on deserialization.
let clock_account_id = system_accounts::clock_account_ids()[0];
let mut corrupted = sequencer.state.get_account_by_id(clock_account_id);
let mut corrupted = sequencer.with_state(|s| s.get_account_by_id(clock_account_id));
corrupted.data = vec![0xff; 3].try_into().unwrap();
sequencer
.state
.chain()
.lock()
.expect("chain mutex poisoned")
.head_state_mut()
.force_insert_account(clock_account_id, corrupted);
// Push a dummy transaction so the mempool is non-empty.
@ -1245,3 +1274,504 @@ fn pda_mechanism_with_pinata_token_program() {
expected_winner_token_holding_post
);
}
#[test]
fn resubmittable_txs_drops_clock_and_bridge_deposits() {
let user_tx = common::test_utils::produce_dummy_empty_transaction();
let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord {
deposit_op_id: HashType([13; 32]),
source_tx_hash: HashType([7; 32]),
amount: 1,
metadata: borsh::to_vec(&DepositMetadataForEncoding {
recipient_id: initial_public_user_accounts()[0].account_id,
})
.unwrap(),
submitted_in_block_id: None,
})
.unwrap();
let withdraw_tx = {
let message = lee::public_transaction::Message::try_new(
programs::bridge().id(),
vec![system_accounts::bridge_account_id()],
vec![],
bridge_core::Instruction::Withdraw {
amount: 1,
bedrock_account_pk: [0; 32],
},
)
.unwrap();
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
};
let block = common::test_utils::produce_dummy_block(
2,
Some(HashType([1; 32])),
vec![user_tx.clone(), deposit_tx, withdraw_tx.clone()],
);
// The trailing clock tx and the sequencer-generated deposit are dropped;
// user txs (withdrawals included) are returned.
assert_eq!(resubmittable_txs(&block), vec![user_tx, withdraw_tx]);
}
#[test]
fn resubmittable_txs_of_blocks_without_user_txs_is_empty() {
// No transactions at all (not even the mandatory clock tx).
let empty = HashableBlockData {
block_id: 1,
prev_block_hash: HashType([0; 32]),
timestamp: 0,
transactions: vec![],
}
.into_pending_block(&sequencer_sign_key_for_testing());
assert!(resubmittable_txs(&empty).is_empty());
let clock_only = common::test_utils::produce_dummy_block(1, None, vec![]);
assert!(resubmittable_txs(&clock_only).is_empty());
}
#[tokio::test]
async fn follow_adopted_peer_block_applies_and_persists() {
let config = setup_sequencer_config();
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer.store.latest_block_meta().unwrap();
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from([1; 32]), peer_block.clone())],
orphaned: vec![],
finalized: vec![],
},
);
assert_eq!(sequencer.chain_height(), 2);
let stored = sequencer
.store
.get_block_at_id(2)
.unwrap()
.expect("adopted peer block should be persisted");
assert_eq!(stored.header.hash, peer_block.header.hash);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc2).balance),
20010
);
}
#[tokio::test]
async fn follow_redelivery_of_own_block_is_deduped() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
// The channel redelivers our own block under the MsgId the mock publisher
// assigned at publish time.
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from(block2.header.hash.0), block2)],
orphaned: vec![],
finalized: vec![],
},
);
assert_eq!(sequencer.chain_height(), 2);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc2).balance),
20010,
"the transfer must not be double-applied"
);
}
#[tokio::test]
async fn follow_orphan_reverts_head_and_requeues_user_txs() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![(MsgId::from(block2.header.hash.0), block2)],
finalized: vec![],
},
);
assert_eq!(sequencer.chain_height(), 1);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc1).balance),
10000,
"the orphaned transfer must be reverted from the head"
);
let (origin, requeued) = sequencer
.mempool
.pop()
.expect("orphaned user tx should be requeued");
assert!(matches!(origin, TransactionOrigin::User));
assert_eq!(requeued, tx);
assert!(
sequencer.mempool.pop().is_none(),
"the clock tx must not be requeued"
);
}
#[tokio::test]
async fn follow_finalized_own_block_moves_final_tier_and_marks_store() {
let config = setup_sequencer_config();
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
},
);
let final_tip = sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.final_tip()
.expect("final tip set");
assert_eq!(final_tip.block_id, 2);
assert_eq!(sequencer.chain_height(), 2, "head is unchanged");
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[tokio::test]
async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
let config = setup_sequencer_config();
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer.store.latest_block_meta().unwrap();
// A peer block we never saw as adopted arrives straight from the
// finalized (backfill) stream.
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from([2; 32]), peer_block.clone())],
},
);
assert_eq!(
sequencer.chain_height(),
2,
"head mirrors final on backfill"
);
let stored = sequencer
.store
.get_block_at_id(2)
.unwrap()
.expect("backfilled block should be persisted");
assert_eq!(stored.header.hash, peer_block.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[tokio::test]
async fn restart_restores_head_tier_and_recovers_from_orphan() {
let config = setup_sequencer_config();
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
// Produce block 2 (a user transfer), then "crash" before it finalizes.
let (tx, block2) = {
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
mempool_handle
.push((TransactionOrigin::User, tx.clone()))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
(tx, sequencer.store.get_block_at_id(2).unwrap().unwrap())
};
// Restart: nothing is finalized, so block 2 must come back as *head*, not
// final — the L1 can still orphan it.
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
assert_eq!(sequencer.chain_height(), 2);
// The L1 orphans block 2 under its real MsgId (which we never persisted)
// and adopts a competing empty block 2'.
let genesis = sequencer.store.get_block_at_id(1).unwrap().unwrap();
let block2_prime =
common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())],
orphaned: vec![(MsgId::from([20; 32]), block2)],
finalized: vec![],
},
);
// The head reorged onto 2': transfer reverted, store overwritten, and the
// orphaned user tx returned to the mempool.
assert_eq!(sequencer.chain_height(), 2);
let head_tip = sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_tip()
.expect("head tip set");
assert_eq!(head_tip.hash, block2_prime.header.hash);
assert_eq!(
sequencer.with_state(|s| s.get_account_by_id(acc1).balance),
10000,
"the orphaned transfer must be reverted"
);
let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert_eq!(stored.header.hash, block2_prime.header.hash);
let (origin, requeued) = sequencer
.mempool
.pop()
.expect("orphaned user tx should be requeued");
assert!(matches!(origin, TransactionOrigin::User));
assert_eq!(requeued, tx);
}
#[tokio::test]
async fn restart_reanchors_on_the_persisted_final_snapshot() {
let config = setup_sequencer_config();
// Produce block 2 and follow its finalization, which persists the final
// snapshot; then "crash".
{
let (mut sequencer, mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle
.push((TransactionOrigin::User, tx))
.await
.unwrap();
sequencer.produce_new_block().await.unwrap();
let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![],
orphaned: vec![],
finalized: vec![(MsgId::from(block2.header.hash.0), block2)],
},
);
}
// Restart: the final tier re-anchors on the snapshot instead of treating
// the whole stored chain as final.
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
let chain = sequencer.chain();
let chain = chain.lock().expect("chain mutex poisoned");
assert_eq!(chain.final_tip().expect("final tip set").block_id, 2);
assert_eq!(chain.head_tip().expect("head tip set").block_id, 2);
}
#[tokio::test]
async fn configure_channel_delegates_to_publisher() {
let config = setup_sequencer_config();
let (sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let admin = Ed25519Key::from_bytes(&[0xA1; ED25519_SECRET_KEY_SIZE]).public_key();
let peer = Ed25519Key::from_bytes(&[0xB2; ED25519_SECRET_KEY_SIZE]).public_key();
sequencer
.configure_channel(vec![admin, peer], 20, 30, 1, 1)
.await
.unwrap();
let calls = sequencer.block_publisher().configure_channel_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].keys.len(), 2);
assert_eq!(calls[0].keys[0], admin);
assert_eq!(calls[0].posting_timeframe, 20);
assert_eq!(calls[0].posting_timeout, 30);
assert_eq!(calls[0].configuration_threshold, 1);
assert_eq!(calls[0].withdraw_threshold, 1);
}
#[tokio::test]
async fn record_produced_block_skips_persistence_on_lost_race() {
let config = setup_sequencer_config();
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer.store.latest_block_meta().unwrap();
// A peer block wins height 2 while "our" block is in flight.
let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]);
sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.apply_adopted(MsgId::from([9; 32]), &peer_block);
// Our competing block at the same height: same parent, different content.
let tx = common::test_utils::produce_dummy_empty_transaction();
let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
sequencer
.record_produced_block(
MsgId::from(our_block.header.hash.0),
&our_block,
&[],
vec![],
)
.unwrap();
// The lost-race block must not reach the store; the head keeps the peer block.
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
let head_tip = sequencer
.chain()
.lock()
.expect("chain mutex poisoned")
.head_tip()
.expect("head tip");
assert_eq!(head_tip.hash, peer_block.header.hash);
}
#[tokio::test]
async fn record_produced_block_skips_persistence_when_block_no_longer_chains() {
let config = setup_sequencer_config();
let (mut sequencer, _mempool_handle) =
SequencerCoreWithMockClients::start_from_config(config).await;
// The head reorged under us: our block's parent is no longer the tip.
let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
sequencer
.record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![])
.unwrap();
assert!(sequencer.store.get_block_at_id(2).unwrap().is_none());
assert_eq!(sequencer.chain_height(), 1, "head is unchanged");
}
#[tokio::test]
async fn follow_update_persists_blocks_meta_and_state_atomically() {
let config = setup_sequencer_config();
let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await;
let genesis_meta = sequencer.store.latest_block_meta().unwrap();
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
// One update carrying several blocks: both adopted, block 2 also finalized.
apply_follow_update(
&sequencer.store.dbio(),
&sequencer.chain(),
&mempool_handle,
FollowUpdate {
adopted: vec![
(MsgId::from([2; 32]), block2.clone()),
(MsgId::from([3; 32]), block3.clone()),
],
orphaned: vec![],
finalized: vec![(MsgId::from([2; 32]), block2)],
},
);
// Blocks, tip meta and state all reflect the end of the batch: a late
// finalized entry for an earlier block must not drag the tip meta back.
let meta = sequencer.store.latest_block_meta().unwrap();
assert_eq!(meta.id, 3);
assert_eq!(meta.hash, block3.header.hash);
let stored2 = sequencer.store.get_block_at_id(2).unwrap().unwrap();
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
let stored_balance = sequencer
.store
.get_lee_state()
.unwrap()
.get_account_by_id(acc2)
.balance;
assert_eq!(stored_balance, 20010);
}

View File

@ -1,6 +1,7 @@
[package]
name = "sequencer_service"
version = "0.1.0"
default-run = "sequencer_service"
edition = "2024"
license = { workspace = true }
@ -19,6 +20,7 @@ programs.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
anyhow.workspace = true
env_logger.workspace = true
hex.workspace = true
log.workspace = true
tokio.workspace = true
tokio-util.workspace = true

View File

@ -13,4 +13,5 @@ lee.workspace = true
lee_core.workspace = true
hex.workspace = true
serde.workspace = true
serde_with.workspace = true

View File

@ -26,3 +26,95 @@ impl FromStr for ChannelId {
Ok(Self(bytes))
}
}
/// Request for `adminConfigureChannel`: replaces the channel's accredited key
/// set and rotation parameters.
///
/// - `keys` are hex-encoded 32-byte Ed25519 public keys
/// - `keys[0]` must be this sequencer's (admin) key.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConfigureChannelRequest {
pub keys: Vec<String>,
pub posting_timeframe: u32,
pub posting_timeout: u32,
pub configuration_threshold: u16,
pub withdraw_threshold: u16,
}
impl ConfigureChannelRequest {
/// Structural sanity checks for the request.
///
/// The L1 validates this too, but its async so we don't immediately
/// know about them when we submit. Checking this here instead gives
/// immediate feedback to the caller.
///
/// We don't need a particular error type here, it's going to be logged only.
pub fn validate(&self) -> Result<(), String> {
let key_count = self.keys.len();
if key_count == 0 {
return Err("Channel key list must not be empty".to_owned());
}
for (name, threshold) in [
("configuration_threshold", self.configuration_threshold),
("withdraw_threshold", self.withdraw_threshold),
] {
if threshold == 0 || usize::from(threshold) > key_count {
return Err(format!(
"{name} must be between 1 and the key count ({key_count}), got {threshold}"
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configure_channel_request_validate_rejects_static_garbage() {
// `validate` is structural: key contents are not parsed here.
let base = ConfigureChannelRequest {
keys: vec!["unparsed".to_owned(), "unparsed".to_owned()],
posting_timeframe: 20,
posting_timeout: 30,
configuration_threshold: 1,
withdraw_threshold: 2,
};
assert!(base.validate().is_ok());
assert!(
ConfigureChannelRequest {
keys: vec![],
..base
}
.validate()
.is_err()
);
assert!(
ConfigureChannelRequest {
configuration_threshold: 0,
..base.clone()
}
.validate()
.is_err()
);
assert!(
ConfigureChannelRequest {
configuration_threshold: 3,
..base.clone()
}
.validate()
.is_err()
);
assert!(
ConfigureChannelRequest {
withdraw_threshold: 3,
..base
}
.validate()
.is_err()
);
}
}

View File

@ -6,8 +6,8 @@ use jsonrpsee::types::ErrorObjectOwned;
#[cfg(feature = "client")]
pub use jsonrpsee::{core::ClientError, http_client::HttpClientBuilder as SequencerClientBuilder};
use sequencer_service_protocol::{
Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType,
LeeTransaction, MembershipProof, Nonce, ProgramId,
Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest,
ConfigureChannelRequest, HashType, LeeTransaction, MembershipProof, Nonce, ProgramId,
};
#[cfg(all(not(feature = "server"), not(feature = "client")))]
@ -92,4 +92,17 @@ pub trait Rpc {
async fn get_channel_id(&self) -> Result<ChannelId, ErrorObjectOwned>;
// =============================================================================================
/// Admin-only in effect: the L1 rejects the config op unless this
/// sequencer's key is the channel admin (`keys[0]` of the current roster).
///
/// `Ok(())` only means the signed config op was queued locally (like
/// block publishing), not that L1 accepted it: acceptance is asynchronous,
/// and a rejection (e.g. non-admin signer) is not reported here — it only
/// shows up in node logs and on-chain behavior.
#[method(name = "adminConfigureChannel")]
async fn admin_configure_channel(
&self,
request: ConfigureChannelRequest,
) -> Result<(), ErrorObjectOwned>;
}

View File

@ -0,0 +1,35 @@
//! Prints the sequencer's bedrock signing public key (hex) without booting it.
//!
//! Loads `<home>/bedrock_signing_key` from the given sequencer config, creating
//! the key if it doesn't exist yet, so that a node can be accredited into the
//! channel committee before its first boot.
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
#[clap(name = "config")]
config_path: PathBuf,
/// Override the config's home directory, matching the sequencer's --home.
#[clap(long)]
home: Option<PathBuf>,
}
#[expect(
clippy::print_stdout,
reason = "the hex pubkey on stdout is this binary's output"
)]
fn main() -> Result<()> {
let Args { config_path, home } = Args::parse();
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
let home = home.unwrap_or(config.home);
let key = sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?;
println!("{}", hex::encode(key.public_key().to_bytes()));
Ok(())
}

View File

@ -172,16 +172,15 @@ async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration)
loop {
tokio::time::sleep(block_timeout).await;
info!("Collecting transactions from mempool, block creation");
let mut state = seq_core.lock().await;
let id = {
let mut state = seq_core.lock().await;
state.produce_new_block().await?
};
// Only produce on our turn.
if !state.is_our_turn() {
continue;
}
info!("Our turn: collecting transactions from mempool, creating block");
let id = state.produce_new_block().await?;
info!("Block with id {id} created");
info!("Waiting for new transactions");
}
}

View File

@ -12,6 +12,10 @@ struct Args {
config_path: PathBuf,
#[clap(short, long, default_value = "3040")]
port: u16,
/// Override the config's home directory (`RocksDB` + bedrock signing key),
/// so multiple instances can share one config file.
#[clap(long)]
home: Option<PathBuf>,
}
#[tokio::main]
@ -22,13 +26,20 @@ struct Args {
async fn main() -> Result<()> {
env_logger::init();
let Args { config_path, port } = Args::parse();
let Args {
config_path,
port,
home,
} = Args::parse();
// TODO: handle this cancellation token more gracefully within Sequencer service
// similar to how we do in Indexer
let cancellation_token = listen_for_shutdown_signal();
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;
let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?;
if let Some(home) = home {
config.home = home;
}
let sequencer_handle = sequencer_service::run(config, port).await?;
tokio::select! {

View File

@ -9,11 +9,12 @@ use lee;
use log::warn;
use mempool::MemPoolHandle;
use sequencer_core::{
DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait,
DbError, SequencerCore, TransactionOrigin,
block_publisher::{BlockPublisherTrait, Ed25519PublicKey},
};
use sequencer_service_protocol::{
Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType,
MembershipProof, Nonce, ProgramId,
Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest,
ConfigureChannelRequest, HashType, MembershipProof, Nonce, ProgramId,
};
use tokio::sync::Mutex;
@ -40,7 +41,7 @@ impl<BC: BlockPublisherTrait> SequencerService<BC> {
}
#[async_trait]
impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
impl<BC: BlockPublisherTrait + Send + Sync + 'static> sequencer_service_rpc::RpcServer
for SequencerService<BC>
{
async fn send_transaction(&self, tx: LeeTransaction) -> Result<HashType, ErrorObjectOwned> {
@ -138,8 +139,8 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
async fn get_account_balance(&self, account_id: AccountId) -> Result<u128, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
let account = sequencer.state().get_account_by_id(account_id);
Ok(account.balance)
let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance);
Ok(balance)
}
async fn get_transaction(
@ -155,10 +156,12 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
account_ids: Vec<AccountId>,
) -> Result<Vec<Nonce>, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
let nonces = account_ids
.into_iter()
.map(|account_id| sequencer.state().get_account_by_id(account_id).nonce)
.collect();
let nonces = sequencer.with_state(|state| {
account_ids
.into_iter()
.map(|account_id| state.get_account_by_id(account_id).nonce)
.collect()
});
Ok(nonces)
}
@ -167,17 +170,18 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
commitments: Vec<Commitment>,
) -> Result<(Vec<Option<MembershipProof>>, CommitmentSetDigest), ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
let state = sequencer.state();
let proofs = commitments
.iter()
.map(|commitment| state.get_proof_for_commitment(commitment))
.collect();
Ok((proofs, state.commitment_root()))
Ok(sequencer.with_state(|state| {
let proofs = commitments
.iter()
.map(|commitment| state.get_proof_for_commitment(commitment))
.collect();
(proofs, state.commitment_root())
}))
}
async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
Ok(sequencer.state().get_account_by_id(account_id))
Ok(sequencer.with_state(|state| state.get_account_by_id(account_id)))
}
async fn get_program_ids(&self) -> Result<BTreeMap<String, ProgramId>, ErrorObjectOwned> {
@ -201,8 +205,68 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
let channel_id = self.sequencer.lock().await.block_publisher().channel_id();
Ok(ChannelId(*channel_id.as_ref()))
}
async fn admin_configure_channel(
&self,
request: ConfigureChannelRequest,
) -> Result<(), ErrorObjectOwned> {
request.validate().map_err(invalid_params)?;
let keys = request
.keys
.iter()
.map(|hex_key| parse_channel_key(hex_key))
.collect::<Result<Vec<_>, _>>()?;
let sequencer = self.sequencer.lock().await;
sequencer
.configure_channel(
keys,
request.posting_timeframe,
request.posting_timeout,
request.configuration_threshold,
request.withdraw_threshold,
)
.await
.map_err(|err| {
ErrorObjectOwned::owned(
ErrorCode::InternalError.code(),
format!("{err:#}"),
None::<()>,
)
})
}
}
fn internal_error(err: &DbError) -> ErrorObjectOwned {
ErrorObjectOwned::owned(ErrorCode::InternalError.code(), err.to_string(), None::<()>)
}
fn invalid_params(detail: String) -> ErrorObjectOwned {
ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), detail, None::<()>)
}
/// Parses one hex-encoded 32-byte Ed25519 public key from an RPC request.
fn parse_channel_key(hex_key: &str) -> Result<Ed25519PublicKey, ErrorObjectOwned> {
let mut bytes = [0_u8; 32];
hex::decode_to_slice(hex_key, &mut bytes)
.map_err(|err| invalid_params(format!("Invalid hex-encoded key: {err}")))?;
Ed25519PublicKey::from_bytes(&bytes)
.map_err(|err| invalid_params(format!("Invalid Ed25519 public key: {err}")))
}
#[cfg(test)]
mod tests {
use sequencer_core::block_publisher::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use super::*;
#[test]
fn parse_channel_key_roundtrips_and_rejects_garbage() {
let key = Ed25519Key::from_bytes(&[7; ED25519_SECRET_KEY_SIZE]).public_key();
let parsed = parse_channel_key(&hex::encode(key.to_bytes())).unwrap();
assert_eq!(parsed.to_bytes(), key.to_bytes());
assert!(parse_channel_key("not-hex").is_err());
assert!(parse_channel_key("abcd").is_err());
}
}

View File

@ -19,10 +19,11 @@ use crate::{
},
error::DbError,
sequencer::sequencer_cells::{
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell,
WithdrawalReconciliationKey, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
},
};
@ -42,6 +43,10 @@ pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count";
/// Key base for storing the LEE state.
pub const DB_LEE_STATE_KEY: &str = "lee_state";
/// Key base for storing the LEE state at the last L1-finalized block.
pub const DB_FINAL_LEE_STATE_KEY: &str = "final_lee_state";
/// Key base for storing `(id, hash)` of the last L1-finalized block.
pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta";
/// Name of state column family.
pub const CF_LEE_STATE_NAME: &str = "cf_lee_state";
@ -482,12 +487,12 @@ impl RocksDBIO {
}
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
if !first {
let last_curr_block = self.get_meta_last_block_in_db()?;
if block.header.block_id > last_curr_block {
// `>=` so a same-height overwrite (a reorg replacing the tip block)
// also refreshes the tip hash in `latest_block_meta`.
if block.header.block_id >= last_curr_block {
self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?;
self.put_meta_latest_block_meta_batch(
&BlockMeta {
@ -499,6 +504,12 @@ impl RocksDBIO {
}
}
self.put_block_payload(block, batch)
}
/// Stages just the block payload into `batch`, without touching the tip meta.
fn put_block_payload(&self, block: &Block, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
batch.put_cf(
&cf_block,
borsh::to_vec(&block.header.block_id).map_err(|err| {
@ -516,6 +527,26 @@ impl RocksDBIO {
.map(|opt| opt.map(|val| val.0))
}
/// `(state, meta)` at the last L1-finalized block; `None` until the first
/// finalization is observed.
pub fn get_final_snapshot(&self) -> DbResult<Option<(V03State, BlockMeta)>> {
let Some(meta) = self.get_opt::<FinalBlockMetaCellOwned>(())? else {
return Ok(None);
};
let state = self.get::<FinalLeeStateCellOwned>(())?;
Ok(Some((state.0, meta.0)))
}
fn put_final_snapshot_batch(
&self,
state: &V03State,
meta: &BlockMeta,
batch: &mut WriteBatch,
) -> DbResult<()> {
self.put_batch(&FinalLeeStateCellRef(state), (), batch)?;
self.put_batch(&FinalBlockMetaCellRef(meta), (), batch)
}
pub fn get_lee_state(&self) -> DbResult<V03State> {
self.get::<LEEStateCellOwned>(()).map(|val| val.0)
}
@ -612,6 +643,92 @@ impl RocksDBIO {
Ok(())
}
/// Persists a followed (peer) block so the store mirrors the canonical chain.
/// One-block form of [`Self::store_followed_blocks`], without a final snapshot.
pub fn store_followed_block(
&self,
block: &Block,
state: &V03State,
finalized: bool,
) -> DbResult<()> {
self.store_followed_blocks(&[(block, finalized)], state, None)
}
/// Persists a batch of followed blocks, the caller's head-tip `state`, and
/// the optional final-tier snapshot in one atomic write.
///
/// Per block: skips the payload write when the store already holds it (by
/// id and hash), unless `finalized` is set, which rewrites it with the
/// finalized status. When nothing needs writing, nothing is written —
/// an orphan-only update keeps the orphaned block and pre-revert state on
/// disk until the replacement overwrites them.
///
/// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before*
/// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state
/// and orphan reverts in one batch) is a follow-up.
pub fn store_followed_blocks(
&self,
blocks: &[(&Block, bool)],
state: &V03State,
final_snapshot: Option<(&V03State, &BlockMeta)>,
) -> DbResult<()> {
let last_block_in_db = self.get_meta_last_block_in_db()?;
let mut batch = WriteBatch::default();
let mut batch_tip: Option<BlockMeta> = None;
for (block, finalized) in blocks {
let stored = self.get_block(block.header.block_id)?;
let already_stored = stored
.as_ref()
.is_some_and(|stored| stored.header.hash == block.header.hash);
if already_stored && !finalized {
continue;
}
let mut to_write = if already_stored {
stored.expect("`already_stored` implies a stored block")
} else {
(*block).clone()
};
if *finalized {
to_write.bedrock_status = BedrockStatus::Finalized;
}
self.put_block_payload(&to_write, &mut batch)?;
let id = to_write.header.block_id;
if batch_tip.as_ref().is_none_or(|tip| id >= tip.id) {
batch_tip = Some(BlockMeta {
id,
hash: to_write.header.hash,
});
}
}
if batch.is_empty() && final_snapshot.is_none() {
return Ok(());
}
// Meta is staged once for the batch's highest block.
// (`>=` so a same-height overwrite refreshes the tip hash, matching `put_block`)
if let Some(tip) = batch_tip
&& tip.id >= last_block_in_db
{
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
self.put_meta_latest_block_meta_batch(&tip, &mut batch)?;
}
self.put_lee_state_in_db_batch(state, &mut batch)?;
if let Some((final_state, final_meta)) = final_snapshot {
self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;
}
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to write followed blocks batch".to_owned()),
)
})
}
pub fn get_all_blocks(&self) -> impl Iterator<Item = DbResult<Block>> {
let cf_block = self.block_column();
self.db
@ -661,3 +778,6 @@ impl RocksDBIO {
})
}
}
#[cfg(test)]
mod tests;

View File

@ -7,9 +7,10 @@ use crate::{
cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell},
error::DbError,
sequencer::{
CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID,
DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY,
DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY,
DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY,
DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY,
DB_META_ZONE_SDK_CHECKPOINT_KEY,
},
};
@ -43,6 +44,72 @@ impl SimpleWritableCell for LEEStateCellRef<'_> {
}
}
/// State at the last L1-finalized block, written atomically with
/// [`FinalBlockMetaCellRef`].
#[derive(BorshDeserialize)]
pub struct FinalLeeStateCellOwned(pub V03State);
impl SimpleStorableCell for FinalLeeStateCellOwned {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY;
const CF_NAME: &'static str = CF_LEE_STATE_NAME;
}
impl SimpleReadableCell for FinalLeeStateCellOwned {}
#[derive(BorshSerialize)]
pub struct FinalLeeStateCellRef<'state>(pub &'state V03State);
impl SimpleStorableCell for FinalLeeStateCellRef<'_> {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY;
const CF_NAME: &'static str = CF_LEE_STATE_NAME;
}
impl SimpleWritableCell for FinalLeeStateCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(err, Some("Failed to serialize final state".to_owned()))
})
}
}
/// `(id, hash)` of the last L1-finalized block, paired with [`FinalLeeStateCellRef`].
#[derive(BorshDeserialize)]
pub struct FinalBlockMetaCellOwned(pub BlockMeta);
impl SimpleStorableCell for FinalBlockMetaCellOwned {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for FinalBlockMetaCellOwned {}
#[derive(BorshSerialize)]
pub struct FinalBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta);
impl SimpleStorableCell for FinalBlockMetaCellRef<'_> {
type KeyParams = ();
const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleWritableCell for FinalBlockMetaCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize final block meta".to_owned()),
)
})
}
}
#[derive(Debug, BorshSerialize, BorshDeserialize)]
pub struct LastFinalizedBlockIdCell(pub Option<u64>);

View File

@ -0,0 +1,177 @@
use common::test_utils::produce_dummy_block;
use lee::{Account, AccountId};
use tempfile::tempdir;
use super::*;
fn marker_id() -> AccountId {
AccountId::new([1; 32])
}
/// A state distinguishable by the marker account's balance, so tests can tell
/// which snapshot a write persisted.
///
/// TODO: is this a bit too much of a hot-fix for test snapshot?
fn state_with_balance(balance: u128) -> V03State {
V03State::new().with_public_accounts([(
marker_id(),
Account {
balance,
..Account::default()
},
)])
}
fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) {
let genesis = produce_dummy_block(1, None, vec![]);
let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap();
(dbio, genesis)
}
fn stored_balance(dbio: &RocksDBIO) -> u128 {
dbio.get_lee_state()
.unwrap()
.get_account_by_id(marker_id())
.balance
}
#[test]
fn store_followed_block_persists_new_block_and_state() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored.header.hash, block2.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
assert_eq!(dbio.latest_block_meta().unwrap().id, 2);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn store_followed_block_finalized_marks_block() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), true)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
}
#[test]
fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), true)
.unwrap();
dbio.store_followed_block(&block2, &state_with_balance(300), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(
matches!(stored.bedrock_status, BedrockStatus::Finalized),
"re-delivery must not demote a finalized block"
);
assert_eq!(
stored_balance(&dbio),
200,
"re-delivery must not overwrite the persisted state"
);
}
#[test]
fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
// Block 2 is already stored (own production); one update then finalizes it
// and adopts block 3.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2, &state_with_balance(200), false)
.unwrap();
let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
dbio.store_followed_blocks(
&[(&block2, true), (&block3, false)],
&state_with_balance(300),
None,
)
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized));
let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored");
assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending));
// Meta and state land together on the last block of the batch.
let meta = dbio.latest_block_meta().unwrap();
assert_eq!(meta.id, 3);
assert_eq!(meta.hash, block3.header.hash);
assert_eq!(stored_balance(&dbio), 300);
}
#[test]
fn final_snapshot_round_trips_and_is_absent_on_fresh_store() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
// Fresh store: no finalization observed yet.
assert!(dbio.get_final_snapshot().unwrap().is_none());
// A follow update that finalizes block 2 lands the snapshot in the same batch.
let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
let final_meta = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_followed_blocks(
&[(&block2, true)],
&state_with_balance(300),
Some((&state_with_balance(200), &final_meta)),
)
.unwrap();
let (final_state, meta) = dbio
.get_final_snapshot()
.unwrap()
.expect("final snapshot is stored");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2.header.hash);
assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200);
// The head state is stored independently of the final snapshot.
assert_eq!(stored_balance(&dbio), 300);
}
#[test]
fn store_followed_block_overwrites_competing_block_at_same_id() {
let temp_dir = tempdir().unwrap();
let (dbio, genesis) = dbio_with_genesis(temp_dir.path());
let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.store_followed_block(&block2a, &state_with_balance(200), false)
.unwrap();
// A reorg replaces block 2: the competing block wins the slot.
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
dbio.store_followed_block(&block2b, &state_with_balance(300), false)
.unwrap();
let stored = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored.header.hash, block2b.header.hash);
assert!(matches!(stored.bedrock_status, BedrockStatus::Pending));
assert_eq!(stored_balance(&dbio), 300);
// The tip meta must follow the reorg winner, or a restart seeds the chain
// from the orphaned block's hash.
let meta = dbio.latest_block_meta().unwrap();
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
}

View File

@ -4,7 +4,10 @@ use anyhow::{Context as _, Result, bail};
use indexer_service::{ChannelId, IndexerHandle};
use lee::{AccountId, PrivateKey, PublicKey};
use log::{debug, warn};
use sequencer_core::block_store::{DbDump, SequencerStore};
use sequencer_core::{
block_publisher::ED25519_SECRET_KEY_SIZE,
block_store::{DbDump, SequencerStore},
};
use sequencer_service::{GenesisAction, SequencerHandle};
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
@ -152,6 +155,7 @@ pub async fn setup_sequencer(
SequencerInit::Genesis(genesis_transactions),
channel_id,
cross_zone,
None,
)
.await
}
@ -169,6 +173,31 @@ pub async fn setup_sequencer_from_prebuilt(
SequencerInit::Prebuilt(&dump),
config::bedrock_channel_id(),
None,
None,
)
.await
}
/// Like [`setup_sequencer`], but with a pre-generated bedrock (Ed25519, 32-byte
/// seed) signing key.
///
/// This allows the tests to know the sequencer's public key before it boots which
/// is required to accredit a committee member that has not started yet.
pub async fn setup_sequencer_with_bedrock_key(
partial: config::SequencerPartialConfig,
bedrock_addr: SocketAddr,
genesis_transactions: Vec<GenesisAction>,
channel_id: ChannelId,
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
bedrock_signing_key: [u8; ED25519_SECRET_KEY_SIZE],
) -> Result<(SequencerHandle, TempDir)> {
setup_sequencer_inner(
partial,
bedrock_addr,
SequencerInit::Genesis(genesis_transactions),
channel_id,
cross_zone,
Some(bedrock_signing_key),
)
.await
}
@ -179,6 +208,7 @@ async fn setup_sequencer_inner(
init: SequencerInit<'_>,
channel_id: ChannelId,
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
bedrock_signing_key: Option<[u8; ED25519_SECRET_KEY_SIZE]>,
) -> Result<(SequencerHandle, TempDir)> {
let temp_sequencer_dir =
tempfile::tempdir().context("Failed to create temp dir for sequencer home")?;
@ -188,6 +218,14 @@ async fn setup_sequencer_inner(
temp_sequencer_dir.path().display()
);
if let Some(key_bytes) = bedrock_signing_key {
std::fs::write(
temp_sequencer_dir.path().join("bedrock_signing_key"),
key_bytes,
)
.context("Failed to write pre-generated bedrock signing key")?;
}
let genesis_transactions = match init {
SequencerInit::Genesis(genesis) => genesis,
SequencerInit::Prebuilt(dump) => {