feat(sequencer): two-tier chain state and multi-sequencer support

Decentralized-sequencing foundation: a shared chain_state crate (two-tier
head/final ChainState, apply_block, AcceptOutcome, StallReason, and the
absorbed channel-consistency machinery), turn-gated block production, the
publisher follow path for adopted/orphaned/finalized peer blocks, and
persistence that keeps disk order equal to apply order under the chain lock.

Rebased onto dev after #600/#606: chain_consistency is absorbed into
chain_state, the sequencer bootstrap's verify_and_reconstruct is re-wired
onto the two-tier ChainState (reconstruction applies channel history
through the final tier and persists via the follow-path primitives), and
test fixtures adopt the SequencerSetup builder extended with
with_bedrock_signing_key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
erhant 2026-07-22 19:33:13 +03:00
parent b31ae10ee5
commit f80bbe6200
46 changed files with 3768 additions and 698 deletions

11
Cargo.lock generated
View File

@ -1369,7 +1369,7 @@ dependencies = [
]
[[package]]
name = "chain_consistency"
name = "chain_state"
version = "0.1.0"
dependencies = [
"anyhow",
@ -1383,6 +1383,7 @@ dependencies = [
"logos-blockchain-zone-sdk",
"serde",
"serde_json",
"testnet_initial_state",
"thiserror 2.0.18",
"tokio",
]
@ -3958,7 +3959,7 @@ dependencies = [
"arc-swap",
"async-stream",
"borsh",
"chain_consistency",
"chain_state",
"common",
"cross_zone",
"cross_zone_inbox_core",
@ -4151,12 +4152,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",
@ -9042,7 +9045,7 @@ dependencies = [
"borsh",
"bridge_core",
"bytesize",
"chain_consistency",
"chain_state",
"chrono",
"common",
"cross_zone",
@ -9073,6 +9076,7 @@ dependencies = [
"testnet_initial_state",
"token_core",
"tokio",
"tokio-util",
"url",
"vault_core",
]
@ -9088,6 +9092,7 @@ dependencies = [
"common",
"env_logger",
"futures",
"hex",
"jsonrpsee",
"lee",
"log",

View File

@ -16,6 +16,7 @@ members = [
"lez",
"lez/system_accounts",
"lez/chain_state",
"lez/sequencer/core",
"lez/sequencer/service",
"lez/sequencer/service/protocol",
@ -32,7 +33,6 @@ members = [
"lez/wallet",
"lez/wallet-ffi",
"lez/common",
"lez/chain_consistency",
"lez/programs",
"lez/programs/amm",
"lez/programs/associated_token_account",
@ -73,7 +73,7 @@ members = [
lee = { path = "lee/state_machine" }
lee_core = { path = "lee/state_machine/core" }
common = { path = "lez/common" }
chain_consistency = { path = "lez/chain_consistency" }
chain_state = { path = "lez/chain_state" }
mempool = { path = "lez/mempool" }
storage = { path = "lez/storage" }
key_protocol = { path = "lee/key_protocol" }

View File

@ -64,15 +64,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

@ -9,7 +9,7 @@
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
use std::{net::SocketAddr, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
@ -17,14 +17,14 @@ use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
};
use lee::{
AccountId, PrivateKey, PublicKey, PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
@ -88,9 +88,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
// Wait until zone B's indexer reflects the verified mint.
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
let indexer = indexer_client(idx_b.addr())
.await
.context("Failed to build indexer client")?;
@ -168,14 +166,6 @@ fn build_lock_tx(
LeeTransaction::Public(PublicTransaction::new(message, witness))
}
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")
}
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
let account_id = indexer_service_protocol::AccountId {

View File

@ -9,8 +9,6 @@
//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the
//! sequencer ingress guard is the only thing that stops this.
use std::net::SocketAddr;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_inbox_core::{
@ -18,13 +16,13 @@ use cross_zone_inbox_core::{
};
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{SequencerSetup, setup_bedrock_node},
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
};
use lee::{
PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
#[test]
@ -73,11 +71,3 @@ async fn user_origin_inbox_call_rejected() -> Result<()> {
);
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")
}

View File

@ -11,20 +11,20 @@
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
//! sequencer-trusted, with no indexer re-derivation (that is M4).
use std::{net::SocketAddr, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{SequencerSetup, setup_bedrock_node},
setup::{SequencerSetup, sequencer_client, setup_bedrock_node},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
@ -123,14 +123,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
))
}
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")
}
/// Polls zone B's sequencer until the ping record PDA holds a payload.
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
let wait = async {

View File

@ -9,7 +9,7 @@
//! payload landing in the indexer's state proves verification passed; a forgery
//! would have halted the indexer instead.
use std::{net::SocketAddr, time::Duration};
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
@ -17,13 +17,13 @@ use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
@ -83,9 +83,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
// Wait until zone B's indexer records the delivered payload. The indexer only
// applies the dispatch after re-deriving and verifying it.
let record_id = ping_record_pda(receiver_id);
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
let indexer = indexer_client(idx_b.addr())
.await
.context("Failed to build indexer client")?;
@ -130,14 +128,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
))
}
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")
}
/// Polls zone B's indexer until the ping record PDA holds a payload.
async fn wait_for_indexer_delivery(
indexer: &IndexerClient,

View File

@ -0,0 +1,236 @@
#![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::time::Duration;
use anyhow::{Context as _, Result, ensure};
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer},
};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
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) = SequencerSetup::new(partial, bedrock_addr)
.with_genesis(vec![])
.with_bedrock_signing_key(key_a)
.setup()
.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, posted
// straight to bedrock with A's admin key (the operator one-shot path).
post_channel_config(
&BedrockConfig {
channel_id: config::bedrock_channel_id(),
node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?,
auth: None,
},
&Ed25519Key::from_bytes(&key_a),
vec![pub_a, pub_b],
POSTING_TIMEFRAME_SLOTS,
POSTING_TIMEOUT_SLOTS,
1,
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) = SequencerSetup::new(partial, bedrock_addr)
.with_genesis(vec![])
.with_bedrock_signing_key(key_b)
.setup()
.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(())
}
/// 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

@ -8,7 +8,7 @@
reason = "Integration tests live at crate root and don't care about these lints"
)]
use std::{net::SocketAddr, path::Path, time::Duration};
use std::{path::Path, time::Duration};
use anyhow::{Context as _, Result, bail};
use indexer_service_rpc::RpcClient as _;
@ -16,11 +16,11 @@ use integration_tests::L2_TO_L1_TIMEOUT;
use lee::{AccountId, PrivateKey, PublicKey};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use sequencer_core::config::GenesisAction;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use test_fixtures::{
config::{SequencerPartialConfig, UrlProtocol, addr_to_url},
indexer_client::IndexerClient,
setup::{SequencerSetup, setup_bedrock_node, setup_indexer},
setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer},
};
use tokio::test;
@ -41,13 +41,6 @@ fn slow_blocks() -> SequencerPartialConfig {
}
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = addr_to_url(UrlProtocol::Http, addr).context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls the indexer's last finalized block id until it reaches `target`. The
/// indexer reads finalized channel history, so this is our oracle for "block
/// `target` is finalized on Bedrock" — exactly what a reconstructing sequencer

View File

@ -8,8 +8,8 @@ mod default_values;
type Value = [u8; 32];
type Node = [u8; 32];
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct MerkleTree {
nodes: Vec<Node>,
capacity: usize,

View File

@ -20,8 +20,8 @@ use crate::{
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug))]
pub struct CommitmentSet {
merkle_tree: MerkleTree,
commitments: HashMap<Commitment, usize>,
@ -67,8 +67,8 @@ impl CommitmentSet {
}
}
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone)]
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, PartialEq, Eq)]
struct NullifierSet(BTreeSet<Nullifier>);
impl NullifierSet {
@ -109,8 +109,8 @@ impl BorshDeserialize for NullifierSet {
}
}
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug))]
pub struct V03State {
public_state: HashMap<AccountId, Account>,
private_state: (CommitmentSet, NullifierSet),

View File

@ -1,196 +0,0 @@
//! Validating and applying channel L2 blocks to a `V03State`.
//!
//! These primitives are shared by the consumers that reconstruct L2 state from
//! the Bedrock channel.
use common::{
HashType,
block::Block,
transaction::{LeeTransaction, clock_invocation},
};
use lee::{GENESIS_BLOCK_ID, V03State};
use lee_core::BlockId;
use serde::{Deserialize, Serialize};
/// Why a channel L2 block could not be validated or applied.
///
/// Persisted in `RocksDB` (via the Indexer's stall reason), 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}")]
/// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`].
Deserialize(String),
#[error("Unexpected block id: expected {expected}, got {got}")]
UnexpectedBlockId { expected: BlockId, got: BlockId },
#[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")]
BrokenChainLink {
expected_prev: HashType,
got_prev: HashType,
},
#[error("Block hash mismatch: computed {computed}, header {header}")]
HashMismatch {
computed: HashType,
header: HashType,
},
#[error("Block has no transactions")]
EmptyBlock,
#[error("Last transaction must be the public clock invocation for the block timestamp")]
InvalidClockTransaction,
#[error("Genesis block must contain only public transactions")]
NonPublicGenesisTransaction,
#[error("State transition failed at transaction {tx_index}: {reason}")]
StateTransition {
/// Index of the failing transaction within the block body.
tx_index: u64,
/// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`.
///
/// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we
/// cannot store it directly.
reason: String,
},
}
impl BlockIngestError {
/// Whether the failure may be transient rather than a property of the block.
///
/// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine
/// state-transition rejections with infra failures (risc0 executor teardown,
/// storage errors). Once it carries a structured cause, narrow this so only
/// infra failures retry.
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(self, Self::StateTransition { .. })
}
}
/// The last successfully applied block a candidate must extend.
#[derive(Debug, Copy, Clone)]
pub struct Tip {
pub block_id: BlockId,
pub hash: HashType,
}
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
/// (cold store) expects the genesis block.
pub fn validate_against_tip(tip: Option<Tip>, block: &Block) -> Result<(), BlockIngestError> {
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`.
pub fn apply_block(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 super::*;
#[test]
fn serializes_and_round_trips_externally_tagged() {
let err = BlockIngestError::UnexpectedBlockId {
expected: 5,
got: 7,
};
let value = serde_json::to_value(&err).expect("serialize");
assert_eq!(
value,
serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } })
);
let back: BlockIngestError = serde_json::from_value(value).expect("deserialize");
assert!(matches!(
back,
BlockIngestError::UnexpectedBlockId {
expected: 5,
got: 7
}
));
}
}

View File

@ -1,9 +0,0 @@
//! Reconstructing and verifying L2 chain state from a Bedrock (L1) channel.
pub use apply::{BlockIngestError, Tip, apply_block, validate_against_tip};
pub use consistency::{
Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency,
};
pub mod apply;
pub mod consistency;

View File

@ -1,8 +1,8 @@
[package]
name = "chain_consistency"
name = "chain_state"
version = "0.1.0"
edition = "2024"
license.workspace = true
license = { workspace = true }
[lints]
workspace = true
@ -11,9 +11,9 @@ workspace = true
common.workspace = true
lee.workspace = true
lee_core.workspace = true
logos-blockchain-zone-sdk.workspace = true
logos-blockchain-core.workspace = true
logos-blockchain-zone-sdk.workspace = true
anyhow.workspace = true
borsh.workspace = true
futures.workspace = true
@ -23,4 +23,5 @@ thiserror.workspace = true
tokio.workspace = true
[dev-dependencies]
testnet_initial_state.workspace = true
serde_json.workspace = true

View File

@ -0,0 +1,308 @@
//! 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, BlockMeta},
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,
}
impl From<&Block> for Tip {
fn from(block: &Block) -> Self {
Self {
block_id: block.header.block_id,
hash: block.header.hash,
}
}
}
impl From<BlockMeta> for Tip {
fn from(meta: BlockMeta) -> Self {
Self {
block_id: meta.id,
hash: meta.hash,
}
}
}
impl From<&Tip> for BlockMeta {
fn from(tip: &Tip) -> Self {
Self {
id: tip.block_id,
hash: tip.hash,
}
}
}
/// 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 LeeTransaction::Public(clock_tx) = clock_tx else {
return Err(BlockIngestError::InvalidClockTransaction);
};
if *clock_tx != clock_invocation(block.header.timestamp) {
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()))?;
}
}
state
.transition_from_public_transaction(clock_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::from(block)
}
#[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,952 @@
//! 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::from(&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)
}
/// Applies an adopted head block.
///
/// The adopted stream is authoritative: a competitor at a height
/// the head already holds reorgs the head back to that height with
/// no orphan event required.
///
/// On failure the head stays unchanged and no stall is recorded.
pub fn apply_adopted(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome {
if self.head_position_of(this_msg, block.header.hash).is_some() {
return AcceptOutcome::AlreadyApplied;
}
// If we receive a pre-final adoption, its an SDK fault; just log and ignore it
if let Some(final_tip) = &self.final_tip
&& block.header.block_id <= final_tip.block_id
{
// The final tier is irreversible: a matching block here is a stale
// re-delivery, a conflicting one an SDK contract breach.
if block.header.block_id == final_tip.block_id && block.header.hash != final_tip.hash {
warn!(
"Ignoring adopted block {} with hash {} conflicting with the \
finalized block ({}) at this height",
block.header.block_id, block.header.hash, final_tip.hash
);
}
return AcceptOutcome::AlreadyApplied;
}
// A tip extension applies on the current head state, a lower-or-equal
// id rebuilds the state at the competitor's parent instead.
//
// If adoptions are over the current head, `reorg_at` is None.
let reorg_at = self
.head_blocks
.iter()
.position(|entry| entry.block.header.block_id >= block.header.block_id);
let (mut scratch, tip) = match reorg_at {
// continue from the tip
None => (self.head_state.clone(), self.head_tip()),
// reorg upto `idx`
Some(idx) => self.replay_head_prefix(idx),
};
match apply_block(tip.as_ref(), block, &mut scratch) {
Ok(()) => {
// now that `apply_block` succeeded, actually reorg the head
if let Some(idx) = reorg_at {
self.head_blocks.truncate(idx);
}
self.head_state = scratch;
self.head_blocks.push(HeadEntry {
this_msg,
block: block.to_owned(),
});
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).
///
/// The entry gets a hash-derived sentinel `MsgId` (the real one is not
/// persisted — that would need a sidecar `block_id -> MsgId` cell); later
/// orphan/finalize events correlate by block hash.
pub fn restore_head_block(&mut self, block: Block) -> Result<(), BlockIngestError> {
apply_block(self.head_tip().as_ref(), &block, &mut self.head_state)?;
let this_msg = MsgId::from(block.header.hash.0);
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::from(&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::from(block));
self.final_stall = None;
// Any head suffix dropped here was already reverted as
// `orphaned` earlier in the same channel update (the sdk
// orders orphans before their finalized replacement), so its
// txs are back in the caller's mempool.
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) {
self.head_state = self.replay_head_prefix(self.head_blocks.len()).0;
}
/// State and tip after replaying `head_blocks[..count]` on the final tier.
fn replay_head_prefix(&self, count: usize) -> (V03State, Option<Tip>) {
let mut state = self.final_state.clone();
let mut tip = self.final_tip.clone();
for entry in &self.head_blocks[..count] {
apply_block(tip.as_ref(), &entry.block, &mut state)
.expect("validated head blocks must replay");
tip = Some(Tip::from(&entry.block));
}
(state, tip)
}
/// 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(self.final_stall.take().map_or_else(
|| StallReason::new(Some(&block.header), l1_slot, error),
StallReason::escalate,
));
}
}
#[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::from(&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 adopted_only_channel_update_replaces_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);
// The replacement branch arrives with no orphan events: the adopted
// list alone reorgs the head.
let tx2_prime = create_transaction_native_token_transfer(from, 0, to, 20, &sign_key);
let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2_prime]);
let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 30, &sign_key);
let block3_prime = produce_dummy_block(3, Some(block2_prime.header.hash), vec![tx3_prime]);
let outcomes =
chain.apply_channel_update(&[], &[(msg(12), block2_prime), (msg(13), block3_prime)]);
assert!(matches!(
outcomes.as_slice(),
[AcceptOutcome::Applied, AcceptOutcome::Applied]
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20050);
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 adopted_competitor_reorgs_head_without_orphan_event() {
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);
// A valid competitor at height 2, no orphan events: the head reorgs
// back onto it, dropping the old 2..=3 suffix and its transfers.
let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(12), &block2_prime),
AcceptOutcome::Applied
));
let tip = chain.head_tip().expect("head tip");
assert_eq!(tip.block_id, 2);
assert_eq!(tip.hash, block2_prime.header.hash);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000);
assert_head_matches_replay(&chain);
}
#[test]
fn invalid_adopted_competitor_leaves_head_intact() {
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);
// A competitor at height 2 with a bogus parent parks; the truncation
// is not committed, so the 2..=3 suffix survives.
let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
assert!(matches!(
chain.apply_adopted(msg(12), &bad),
AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. })
));
assert_eq!(chain.head_tip().expect("head tip").block_id, 3);
assert_head_matches_replay(&chain);
}
#[test]
fn adopted_conflicting_with_final_tip_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_finalized(msg(1), &genesis, slot(10));
chain.apply_finalized(msg(2), &block2, slot(20));
// Finalized is irreversible: an adopted competitor at (or below) the
// final tip is ignored, not reorged onto.
let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_adopted(msg(12), &block2_prime),
AcceptOutcome::AlreadyApplied
));
assert_eq!(
chain.final_tip().expect("final tip").hash,
block2.header.hash
);
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::from(&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(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(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

@ -0,0 +1,80 @@
use common::HashType;
use serde::{Deserialize, Serialize};
/// Why an L2 block from the channel could not be applied.
///
/// 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}")]
/// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`].
Deserialize(String),
#[error("Unexpected block id: expected {expected}, got {got}")]
UnexpectedBlockId { expected: u64, got: u64 },
#[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")]
BrokenChainLink {
expected_prev: HashType,
got_prev: HashType,
},
#[error("Block hash mismatch: computed {computed}, header {header}")]
HashMismatch {
computed: HashType,
header: HashType,
},
#[error("Block has no transactions")]
EmptyBlock,
#[error("Last transaction must be the public clock invocation for the block timestamp")]
InvalidClockTransaction,
#[error("Genesis block must contain only public transactions")]
NonPublicGenesisTransaction,
#[error("State transition failed at transaction {tx_index}: {reason}")]
StateTransition {
/// Index of the failing transaction within the block body.
tx_index: u64,
/// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`.
///
/// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we
/// cannot store it directly.
reason: String,
},
}
impl BlockIngestError {
/// Whether the failure may be transient rather than a property of the block.
///
/// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine
/// state-transition rejections with infra failures (risc0 executor teardown,
/// storage errors). Once it carries a structured cause, narrow this so only
/// infra failures retry.
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(self, Self::StateTransition { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_and_round_trips_externally_tagged() {
let err = BlockIngestError::UnexpectedBlockId {
expected: 5,
got: 7,
};
let value = serde_json::to_value(&err).expect("serialize");
assert_eq!(
value,
serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } })
);
let back: BlockIngestError = serde_json::from_value(value).expect("deserialize");
assert!(matches!(
back,
BlockIngestError::UnexpectedBlockId {
expected: 5,
got: 7
}
));
}
}

View File

@ -0,0 +1,17 @@
//! 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 consistency::{
Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency,
};
pub use ingest_error::BlockIngestError;
pub use stall_reason::StallReason;
pub mod apply;
pub mod chain;
pub mod consistency;
pub mod ingest_error;
pub mod stall_reason;

View File

@ -0,0 +1,50 @@
use common::{HashType, block::BlockHeader};
use logos_blockchain_zone_sdk::Slot;
use serde::{Deserialize, Serialize};
use crate::ingest_error::BlockIngestError;
/// Diagnostic record of the first block that broke the L2 chain.
///
/// The block-derived fields are `None` for a deserialize break (no header was
/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at.
/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StallReason {
pub block_id: Option<u64>,
pub block_hash: Option<HashType>,
pub prev_block_hash: Option<HashType>,
pub l1_slot: Slot,
pub error: BlockIngestError,
pub first_seen: Option<u64>,
/// Number of later non-chaining blocks (orphans, since the tip is frozen).
///
/// TODO: We could store a different "branch" of blocks following this break, but for now we
/// just count them.
pub orphans_since: u64,
}
impl StallReason {
/// First stall for a break, built from the breaking block's header
/// (`None` for a deserialize break).
#[must_use]
pub fn new(header: Option<&BlockHeader>, l1_slot: Slot, error: BlockIngestError) -> Self {
Self {
block_id: header.map(|header| header.block_id),
block_hash: header.map(|header| header.hash),
prev_block_hash: header.map(|header| header.prev_block_hash),
first_seen: header.map(|header| header.timestamp),
l1_slot,
error,
orphans_since: 0,
}
}
/// A later stall on the same break: bumps `orphans_since`, preserving the
/// original cause.
#[must_use]
pub const fn escalate(mut self) -> Self {
self.orphans_since = self.orphans_since.saturating_add(1);
self
}
}

View File

@ -13,6 +13,15 @@ pub struct BlockMeta {
pub hash: BlockHash,
}
impl From<&Block> for BlockMeta {
fn from(block: &Block) -> Self {
Self {
id: block.header.block_id,
hash: block.header.hash,
}
}
}
#[derive(Debug, Clone)]
/// Our own hasher.
/// Currently it is SHA256 hasher wrapper. May change in a future.

View File

@ -12,8 +12,8 @@ default = []
testnet = []
[dependencies]
chain_state.workspace = true
common.workspace = true
chain_consistency.workspace = true
logos-blockchain-zone-sdk.workspace = true
lee.workspace = true
lee_core.workspace = true

View File

@ -1,7 +1,9 @@
use std::{path::Path, sync::Arc};
use anyhow::{Context as _, Result};
use chain_consistency::{BlockIngestError, Tip};
use chain_state::{
AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip,
};
use common::{
block::{BedrockStatus, Block, BlockHeader},
transaction::LeeTransaction,
@ -14,23 +16,6 @@ use logos_blockchain_zone_sdk::Slot;
use storage::indexer::RocksDBIO;
use tokio::sync::RwLock;
use crate::status::StallReason;
/// 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>,
@ -202,10 +187,7 @@ impl IndexerStore {
let Some(block) = self.dbio.get_block(block_id)? else {
return Ok(None);
};
Ok(Some(Tip {
block_id,
hash: block.header.hash,
}))
Ok(Some(Tip::from(&block)))
}
/// Record the stall reason.
@ -218,22 +200,10 @@ impl IndexerStore {
l1_slot: Slot,
error: BlockIngestError,
) -> Result<()> {
let stall = match self.get_stall_reason()? {
Some(mut existing) => {
existing.orphans_since = existing.orphans_since.saturating_add(1);
existing
}
None => StallReason {
// need to map out of `header` because they are not ser/de
block_id: header.map(|h| h.block_id),
block_hash: header.map(|h| h.hash),
prev_block_hash: header.map(|h| h.prev_block_hash),
first_seen: header.map(|h| h.timestamp),
l1_slot,
error,
orphans_since: 0,
},
};
let stall = self.get_stall_reason()?.map_or_else(
|| StallReason::new(header, l1_slot, error),
StallReason::escalate,
);
self.set_stall_reason(&Some(stall))
}
@ -253,14 +223,16 @@ impl IndexerStore {
return Ok(AcceptOutcome::AlreadyApplied);
}
if let Err(err) = chain_consistency::validate_against_tip(tip, block) {
// 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));
}
// 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) = chain_consistency::apply_block(block, &mut scratch) {
if let Err(err) = apply_block_to_state(block, &mut scratch) {
if err.is_retryable() {
return Ok(AcceptOutcome::RetryableFailure(err));
}
@ -435,7 +407,6 @@ mod tests {
#[cfg(test)]
mod accept_tests {
use chain_consistency::BlockIngestError;
use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block};
use super::*;

View File

@ -2,8 +2,8 @@ use std::{path::Path, sync::Arc};
use anyhow::Result;
use arc_swap::ArcSwap;
pub use chain_consistency::BlockIngestError;
use chain_consistency::{Anchor, ChainConsistency};
pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason};
use chain_state::{Anchor, ChainConsistency};
use common::block::Block;
// TODO: Remove after testnet
use futures::StreamExt as _;
@ -12,10 +12,9 @@ use logos_blockchain_zone_sdk::{
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use retry::ApplyRetryGate;
pub use status::StallReason;
use crate::{
block_store::{AcceptOutcome, IndexerStore},
block_store::IndexerStore,
config::IndexerConfig,
cross_zone_verifier::CrossZoneVerifier,
status::{IndexerStatus, IndexerSyncStatus},
@ -136,8 +135,7 @@ impl IndexerCore {
return Ok(ChainConsistency::Inconclusive);
};
chain_consistency::verify_chain_consistency(&self.node, self.config.channel_id, &anchor)
.await
chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await
}
/// Builds the anchor for the startup check.

View File

@ -1,27 +1,5 @@
use chain_consistency::BlockIngestError;
use common::HashType;
use logos_blockchain_zone_sdk::Slot;
use serde::{Deserialize, Serialize};
/// Diagnostic record of the first block that broke the L2 chain.
///
/// The block-derived fields are `None` for a deserialize break (no header was
/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at.
/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StallReason {
pub block_id: Option<u64>,
pub block_hash: Option<HashType>,
pub prev_block_hash: Option<HashType>,
pub l1_slot: Slot,
pub error: BlockIngestError,
pub first_seen: Option<u64>,
/// Number of later non-chaining blocks (orphans, since the tip is frozen).
///
/// TODO: We could store a different "branch" of blocks following this break, but for now we
/// just count them.
pub orphans_since: u64,
}
use chain_state::StallReason;
use serde::Serialize;
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
/// "still catching up" apart from "something went wrong".
@ -138,6 +116,7 @@ mod tests {
#[test]
fn stalled_status_serializes_with_stall_reason() {
use chain_state::{BlockIngestError, StallReason};
use logos_blockchain_zone_sdk::Slot;
let status = IndexerStatus {

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,8 +10,8 @@ workspace = true
[dependencies]
lee.workspace = true
lee_core.workspace = true
chain_state.workspace = true
common.workspace = true
chain_consistency.workspace = true
storage.workspace = true
mempool.workspace = true
logos-blockchain-zone-sdk.workspace = true
@ -34,6 +34,7 @@ tempfile.workspace = true
chrono.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio-util.workspace = true
rand.workspace = true
borsh.workspace = true
bytesize.workspace = true

View File

@ -1,23 +1,44 @@
use std::{pin::Pin, sync::Arc, time::Duration};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context as _, Result, anyhow, ensure};
use common::block::Block;
use futures::Stream;
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::{
MantleTx, SignedMantleTx, Transaction as _,
channel::{SlotTimeframe, SlotTimeout},
ops::{
Op, OpProof,
channel::{
ChannelId,
config::{ChannelConfigOp, Keys},
inscribe::Inscription,
},
},
},
proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
};
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, Slot, ZoneMessage,
adapter::{Node as _, NodeHttpClient},
indexer::ZoneIndexer,
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 tokio_util::sync::CancellationToken;
use crate::config::BedrockConfig;
@ -43,6 +64,36 @@ 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 {
/// Inscriptions newly on the followed L1 branch, in channel order: they
/// extend (or, after a reorg, replace part of) the `head` tier.
pub adopted: Vec<(MsgId, Block)>,
/// Inscriptions dropped from the branch by an L1 reorg: their blocks are
/// reverted from the `head` and their user txs resubmitted to the mempool.
pub orphaned: Vec<(MsgId, Block)>,
/// Inscriptions whose containing L1 block reached finality: their blocks
/// move into the irreversible `final` tier.
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) + 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>>,
},
}
type CommandSender = mpsc::Sender<Command>;
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
pub trait BlockPublisherTrait: Sized {
#[expect(
@ -58,14 +109,23 @@ pub trait BlockPublisherTrait: Sized {
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>;
fn channel_id(&self) -> ChannelId;
/// Whether this sequencer is currently authorized to write to the channel.
fn is_our_turn(&self) -> bool;
/// A [`CancellationToken`] cancelled when the publisher's background driver
/// terminates (a panicked sink, an ended event stream). No channel events
/// are processed past that point, so the node must halt.
fn driver_cancellation(&self) -> CancellationToken;
/// Current channel frontier slot on the connected chain, or `None` if the
/// channel does not exist there. Drives the startup frontier check.
async fn channel_tip_slot(&self) -> Result<Option<Slot>>;
@ -85,7 +145,10 @@ pub struct ZoneSdkPublisher {
/// Direct node handle retained for channel reads (startup consistency check
/// and reconstruction); the sequencer itself lives in the drive task.
node: NodeHttpClient,
publish_tx: mpsc::Sender<(Inscription, Vec<WithdrawArg>)>,
command_tx: CommandSender,
turn_rx: watch::Receiver<TurnNotification>,
// Cancelled when the drive task ends for any reason, including a panic.
driver_cancellation: CancellationToken,
// Aborts the drive task when the last clone is dropped.
_drive_task: Arc<DriveTaskGuard>,
indexer: ZoneIndexer<NodeHttpClient>,
@ -109,6 +172,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());
@ -129,11 +193,18 @@ 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 driver_cancellation = CancellationToken::new();
let driver_guard = driver_cancellation.clone().drop_guard();
let drive_task = tokio::spawn(async move {
// Dropped when this task ends (including panics in the sinks),
// cancelling every `driver_cancellation`.
let _driver_guard = driver_guard;
loop {
#[expect(
clippy::integer_division_remainder_used,
@ -141,32 +212,38 @@ 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);
}
}
},
event = sequencer.next_event() => {
let Some(event) = event else {
continue;
@ -174,17 +251,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) => {
@ -195,8 +287,22 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
}
}
}
on_follow(FollowUpdate {
adopted,
orphaned,
finalized: finalized_blocks,
});
}
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 { .. } => {}
}
}
}
@ -215,29 +321,46 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
channel_id: config.channel_id,
indexer: ZoneIndexer::new(config.channel_id, node.clone()),
node,
publish_tx,
command_tx,
turn_rx,
driver_cancellation,
_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"))?
}
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn is_our_turn(&self) -> bool {
self.turn_rx.borrow().our_turn_to_write
}
fn driver_cancellation(&self) -> CancellationToken {
self.driver_cancellation.clone()
}
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
Ok(self
.node
@ -260,13 +383,87 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
}
}
/// 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,
}
}
/// Signs a `ChannelConfig` op (accredited keys + rotation params) with
/// `signing_key` and posts it straight to the bedrock node.
///
/// A standalone one-shot — no running sequencer involved, so authorization is
/// holding the admin key: the L1 rejects non-admin signers. `Ok(())` means the
/// node accepted the transaction; channel acceptance is asynchronous and a
/// rejection only shows up in node logs and on-chain behavior.
pub async fn post_channel_config(
config: &BedrockConfig,
signing_key: &Ed25519Key,
keys: Vec<Ed25519PublicKey>,
posting_timeframe: u32,
posting_timeout: u32,
configuration_threshold: u16,
withdraw_threshold: u16,
) -> Result<()> {
ensure!(!keys.is_empty(), "Channel key list must not be empty");
for (name, threshold) in [
("configuration_threshold", configuration_threshold),
("withdraw_threshold", withdraw_threshold),
] {
ensure!(
threshold >= 1 && usize::from(threshold) <= keys.len(),
"{name} must be between 1 and the key count ({}), got {threshold}",
keys.len()
);
}
ensure!(
posting_timeframe > 0 && posting_timeout >= posting_timeframe,
"posting_timeframe must be nonzero and posting_timeout at least as long, \
got {posting_timeframe} and {posting_timeout}"
);
let keys = Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
let config_op = ChannelConfigOp {
channel: config.channel_id,
keys,
posting_timeframe: SlotTimeframe::from(posting_timeframe),
posting_timeout: SlotTimeout::from(posting_timeout),
configuration_threshold,
withdraw_threshold,
};
let mantle_tx = MantleTx([Op::ChannelConfig(config_op)].into());
let tx_hash = mantle_tx.hash();
// The admin key is `keys[0]`, hence signature index 0.
let signature = IndexedSignature::new(
0,
signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()),
);
let proof = ChannelMultiSigProof::new(vec![signature])
.map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?;
let signed_tx = SignedMantleTx {
ops_proofs: vec![OpProof::ChannelMultiSigProof(proof)],
mantle_tx,
};
let node = NodeHttpClient::new(
CommonHttpClient::new(config.auth.clone().map(Into::into)),
config.node_url.clone(),
);
node.post_transaction(signed_tx)
.await
.context("Failed to post channel config transaction")
}

View File

@ -218,6 +218,16 @@ impl SequencerStore {
pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult<bool> {
self.dbio.is_deposit_event_submitted(deposit_op_id)
}
/// Marks the given deposit events submitted in `block_id`, in one write.
pub fn mark_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
) -> DbResult<()> {
self.dbio
.mark_deposit_events_submitted(deposit_op_ids, submitted_block_id)
}
}
pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {

View File

@ -1,17 +1,23 @@
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_consistency::{Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, Tip};
use chain_state::{
AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip,
};
use common::{
HashType,
block::{BedrockStatus, Block, HashableBlockData},
block::{BedrockStatus, Block, BlockMeta, HashableBlockData},
transaction::{LeeTransaction, clock_invocation},
};
use config::{GenesisAction, SequencerConfig};
use futures::StreamExt as _;
use itertools::Itertools as _;
use lee::{AccountId, PublicTransaction, V03State, public_transaction::Message};
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};
@ -30,7 +36,7 @@ use storage::sequencer::{
};
use crate::{
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher},
block_store::SequencerStore,
};
@ -56,18 +62,13 @@ struct DepositMetadata {
recipient_id: lee::AccountId,
}
impl DepositMetadata {
fn decode(bytes: &[u8]) -> Result<Self, std::io::Error> {
Self::try_from_slice(bytes)
}
}
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,
}
@ -120,14 +121,66 @@ 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::from(meta))),
// 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;
chain.restore_head_block(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, else store
// and config disagree (e.g. edited genesis actions).
assert!(
chain.head_state() == stored_head_state,
"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 (mut store, mut state) = Self::open_or_create_store(&config);
let (store, state) = Self::open_or_create_store(&config);
let chain = Arc::new(Mutex::new(Self::restore_chain_state(
&config, &store, &state,
)));
let initial_checkpoint = store
.get_zone_checkpoint()
@ -146,6 +199,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");
@ -163,15 +217,15 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// Before producing, verify our local state still belongs to the chain
// the channel serves and replay any channel blocks we are missing
// (e.g. from other sequencers).
let chanel_was_empty =
Self::verify_and_reconstruct(&block_publisher, &mut store, &mut state, is_fresh_start)
let channel_was_empty =
Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start)
.await
.expect("Failed to verify/reconstruct sequencer state from Bedrock");
// Publish our blocks only when bootstrapping an empty channel. If the
// channel already has blocks (another sequencer bootstrapped it), we
// adopted them during reconstruction instead.
if is_fresh_start && chanel_was_empty {
if is_fresh_start && channel_was_empty {
let mut pending_blocks = store
.get_all_blocks()
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
@ -199,16 +253,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
let latest_block_meta = store
.latest_block_meta()
.expect("Failed to read latest block meta from store")
.expect("Sequencer store should have at least the genesis block after reconstruction");
let sequencer_core = Self {
state,
chain,
store,
mempool,
chain_height: latest_block_meta.id,
sequencer_config: config,
block_publisher,
};
@ -224,8 +272,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// Returns whatever channel was empty or not.
async fn verify_and_reconstruct(
publisher: &BP,
store: &mut SequencerStore,
state: &mut V03State,
store: &SequencerStore,
chain: &Mutex<ChainState>,
is_fresh_start: bool,
) -> Result<bool> {
let anchor_record = store
@ -322,7 +370,11 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
)
})?;
channel_is_empty = false;
Self::apply_reconstructed_block(store, state, &block, slot)?;
// Locked per message (not across the stream `await`): concurrent
// follow events interleave safely — both paths apply idempotently
// and persist under this same lock.
let mut chain = chain.lock().expect("chain state mutex poisoned");
Self::apply_reconstructed_block(store, &mut chain, &block, slot)?;
}
Ok(channel_is_empty)
@ -332,8 +384,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
/// blocks we already hold (verifying their hash), a validated continuation
/// for new ones. Advances the persisted anchor to the block's slot.
fn apply_reconstructed_block(
store: &mut SequencerStore,
state: &mut V03State,
store: &SequencerStore,
chain: &mut ChainState,
block: &Block,
slot: Slot,
) -> Result<()> {
@ -379,21 +431,31 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
// New continuation: validate it chains onto the tip, then apply.
let cc_tip = tip.as_ref().map(|tip| Tip {
block_id: tip.id,
hash: tip.hash,
});
chain_consistency::validate_against_tip(cc_tip, block).map_err(|err| {
anyhow!(
"Channel block {block_id} does not extend local tip {:?}: {err}",
tip.map(|tip| tip.id)
)
})?;
// New continuation: channel history is finalized, so it goes through
// the final tier — validation happens inside `apply_finalized`.
match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) {
AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {}
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
return Err(anyhow!(
"Channel block {block_id} does not extend local tip {:?}: {err}",
tip.map(|tip| tip.id)
));
}
}
let mut scratch = state.clone();
chain_consistency::apply_block(block, &mut scratch)
.map_err(|err| anyhow!("Failed to apply channel block {block_id}: {err}"))?;
// Persist like the follow path: the tip meta stays pinned to the head
// tip even when the reconstructed block lands below it.
let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head));
let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta));
store
.dbio()
.store_followed_blocks(
&[(block, true)],
head_tip.as_ref(),
chain.head_state(),
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
)
.context("Failed to persist reconstructed block")?;
// Mark the deposits' pending records submitted so the production-time
// guard drops the mints cold-start backfill re-queued for them. Withdraw
@ -406,11 +468,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.iter()
.filter_map(extract_bridge_deposit_id)
.collect();
store
.update(block, &deposit_event_ids, Vec::new(), &scratch)
.context("Failed to persist reconstructed block")?;
*state = scratch;
.mark_deposit_events_submitted(&deposit_event_ids, block_id)
.context("Failed to mark reconstructed deposits submitted")?;
store
.set_zone_anchor(&record)
.context("Failed to persist zone anchor")?;
@ -547,6 +608,17 @@ 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);
})
}
/// 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 {
@ -563,26 +635,77 @@ 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 mut chain = self.chain.lock().expect("chain state mutex poisoned");
match chain.apply_adopted(this_msg, block) {
AcceptOutcome::Applied => {
// Persisted under the lock so disk writes land in apply order
// with the follow path.
self.store.update(
block,
deposit_event_ids,
withdrawal_reconciliation_keys,
chain.head_state(),
)?;
}
AcceptOutcome::AlreadyApplied => {
warn!(
"Produced block {} lost a competing-write race, skipping persistence",
block.header.block_id
);
}
AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => {
warn!(
"Produced block {} no longer chains on the head, skipping persistence: {err}",
block.header.block_id
);
}
}
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.
#[expect(
clippy::too_many_arguments,
reason = "splitting the produce-path accumulators into a struct buys nothing"
)]
fn apply_mempool_transaction(
&mut self,
store: &SequencerStore,
state: &mut lee::V03State,
origin: TransactionOrigin,
tx: &LeeTransaction,
block_height: u64,
@ -593,11 +716,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!(
@ -611,7 +730,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 {
@ -619,8 +738,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
};
if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) {
if self
.store
if store
.is_deposit_event_submitted(deposit_op_id)
.context("Failed to check whether deposit was already submitted")?
{
@ -630,7 +748,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")?;
}
@ -643,7 +761,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();
@ -652,12 +781,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")?
.context("Sequencer store should have at least the genesis block")?;
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
.expect("Timestamp must be positive");
@ -676,7 +799,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,
};
@ -693,7 +816,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
break;
}
if self.apply_mempool_transaction(
if Self::apply_mempool_transaction(
&self.store,
&mut working_state,
origin,
&tx,
new_block_height,
@ -709,7 +834,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);
@ -717,7 +842,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,
};
@ -725,8 +850,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(),
@ -740,16 +863,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 {
@ -786,10 +920,17 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
&self.block_publisher
}
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()
}
/// Shared handle to the two-tier follow state, for tests to drive the
/// follow path directly.
#[cfg(all(test, feature = "mock"))]
fn chain(&self) -> Arc<Mutex<ChainState>> {
Arc::clone(&self.chain)
}
}
@ -799,6 +940,105 @@ 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;
// The lock is held across the persist below so disk writes land in apply
// order — the produce path persists under this same lock.
let resubmit_txs = {
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_meta = final_advanced.then(|| {
let tip = chain.final_tip().expect("advanced final tier has a tip");
BlockMeta::from(&tip)
});
let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip));
// 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. A persist
// failure is fatal: the in-memory chain has already advanced, and
// continuing would leave a permanent gap in the store.
//
// The `panic!` ends the drive task, whose cancellation halts the node.
//
// 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.
dbio.store_followed_blocks(
&to_persist,
head_tip.as_ref(),
chain.head_state(),
final_meta.as_ref().map(|meta| (chain.final_state(), meta)),
)
.unwrap_or_else(|err| panic!("Failed to persist followed blocks: {err:#}"));
resubmit_txs
};
// 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.
@ -848,41 +1088,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 {
@ -900,6 +1114,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)
}
@ -964,7 +1213,7 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec
}
fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Result<LeeTransaction> {
let metadata = DepositMetadata::decode(&event.metadata)
let metadata = DepositMetadata::try_from_slice(&event.metadata)
.context("Failed to decode finalized Bedrock deposit metadata")?;
let bridge_program_id = programs::bridge().id();
@ -992,6 +1241,26 @@ 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, sequencer-generated bridge deposits (replayed
/// from their own bedrock events) and sequencer-only cross-zone txs (replayed
/// by the watcher; the ingress guard rejects them as `User`).
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() && !is_sequencer_only_tx(tx))
.cloned()
.collect()
}
#[must_use]
fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool {
matches!(tx, LeeTransaction::Public(tx)
if is_sequencer_only_program(tx.message().program_id))
}
#[must_use]
fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option<HashType> {
let LeeTransaction::Public(tx) = tx else {
@ -1081,7 +1350,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

@ -3,13 +3,14 @@ use std::time::Duration;
use anyhow::Result;
use common::block::Block;
use futures::Stream;
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::{Slot, ZoneMessage, sequencer::WithdrawArg};
use tokio_util::sync::CancellationToken;
use crate::{
block_publisher::{
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink,
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
OnWithdrawEventSink, SequencerCheckpoint,
},
config::BedrockConfig,
@ -20,6 +21,8 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>
#[derive(Clone)]
pub struct MockBlockPublisher {
channel_id: ChannelId,
// Never cancelled: the mock driver never dies.
driver_cancellation: CancellationToken,
/// Canned channel frontier returned by [`Self::channel_tip_slot`].
tip_slot: Option<Slot>,
/// Canned finalized channel history returned by [`Self::read_channel_after`].
@ -31,13 +34,14 @@ impl MockBlockPublisher {
/// and consistency tests. The default (via [`BlockPublisherTrait::new`])
/// serves an empty channel.
#[must_use]
pub const fn with_canned_channel(
pub fn with_canned_channel(
channel_id: ChannelId,
tip_slot: Option<Slot>,
messages: Vec<(ZoneMessage, Slot)>,
) -> Self {
Self {
channel_id,
driver_cancellation: CancellationToken::new(),
tip_slot,
messages,
}
@ -54,9 +58,11 @@ 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,
driver_cancellation: CancellationToken::new(),
tip_slot: None,
messages: Vec::new(),
})
@ -64,16 +70,27 @@ impl BlockPublisherTrait for MockBlockPublisher {
async fn publish_block(
&self,
_block: &Block,
block: &Block,
_bridge_withdrawals: Vec<WithdrawArg>,
) -> Result<()> {
Ok(())
) -> 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))
}
fn channel_id(&self) -> ChannelId {
self.channel_id
}
fn is_our_turn(&self) -> bool {
true
}
fn driver_cancellation(&self) -> CancellationToken {
self.driver_cancellation.clone()
}
async fn channel_tip_slot(&self) -> Result<Option<Slot>> {
Ok(self.tip_slot)
}

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,21 @@ 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 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,
};
mod reconstruction;
@ -148,14 +150,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);
@ -188,7 +190,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());
}
@ -297,7 +299,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;
@ -311,7 +313,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,
@ -321,7 +331,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;
@ -337,9 +347,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(_)
@ -350,7 +366,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;
@ -361,11 +377,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);
@ -402,7 +426,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
@ -412,7 +436,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]
@ -444,7 +469,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();
@ -479,7 +504,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!(
@ -498,7 +523,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.
@ -540,7 +565,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!(
@ -556,8 +581,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!(
@ -655,7 +680,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();
@ -707,7 +732,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();
@ -762,7 +787,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();
@ -781,10 +806,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.
@ -1247,3 +1275,501 @@ 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()
.expect("genesis meta is set");
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()
.expect("genesis meta is set");
// 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 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()
.expect("genesis meta is set");
// 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()
.expect("genesis meta is set");
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()
.expect("meta is set");
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

@ -4,6 +4,9 @@
reason = "We don't care about it in tests"
)]
use std::sync::Mutex;
use chain_state::ChainState;
use common::block::Block;
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage};
@ -14,6 +17,16 @@ use crate::{
SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher,
};
/// Fresh `(store, chain)` pair for a reconstruction target, as
/// `start_from_config` would build them before the publisher starts.
fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex<ChainState>) {
let (store, state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(config);
let chain = Mutex::new(SequencerCore::<MockBlockPublisher>::restore_chain_state(
config, &store, &state,
));
(store, chain)
}
fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) {
let bytes = borsh::to_vec(block).expect("serialize block");
let message = ZoneMessage::Block(ZoneBlock {
@ -53,15 +66,11 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
// Sequencer B starts from a fresh store and reconstructs A's chain.
let config_b = setup_sequencer_config();
let (mut store_b, mut state_b) =
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&mut store_b,
&mut state_b,
true,
&mock_b, &store_b, &chain_b, true,
)
.await
.expect("reconstruct");
@ -72,10 +81,12 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
assert_eq!(tip_b.hash, tip_a.hash);
// State matches: initial account balances agree with sequencer A.
let state_b = chain_b.lock().unwrap().head_state().clone();
let state_a = seq_a.chain().lock().unwrap().head_state().clone();
for account in initial_public_user_accounts() {
assert_eq!(
state_b.get_account_by_id(account.account_id).balance,
seq_a.state().get_account_by_id(account.account_id).balance,
state_a.get_account_by_id(account.account_id).balance,
);
}
@ -85,10 +96,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
// Re-running is idempotent: everything is already applied, no error.
let channel_was_empty = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&mut store_b,
&mut state_b,
true,
&mock_b, &store_b, &chain_b, true,
)
.await
.expect("reconstruct idempotent");
@ -99,7 +107,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
#[tokio::test]
async fn fails_when_channel_serves_a_divergent_block() {
let config = setup_sequencer_config();
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
let (store, chain) = fresh_store_and_chain(&config);
// Anchor on the local genesis at some slot.
let genesis_id = store.genesis_id();
@ -123,17 +131,16 @@ async fn fails_when_channel_serves_a_divergent_block() {
messages,
);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &mut store, &mut state, true,
)
.await;
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(result.is_err(), "divergent channel must abort startup");
}
#[tokio::test]
async fn fails_when_channel_is_missing() {
let config = setup_sequencer_config();
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
let (store, chain) = fresh_store_and_chain(&config);
let genesis_id = store.genesis_id();
let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap();
store
@ -147,10 +154,9 @@ async fn fails_when_channel_is_missing() {
// Anchor present, but the channel does not exist on the connected chain.
let mock =
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &mut store, &mut state, true,
)
.await;
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(result.is_err(), "missing channel must abort startup");
}
@ -161,7 +167,7 @@ async fn fails_when_channel_is_missing() {
#[tokio::test]
async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
let config = setup_sequencer_config();
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
let (store, chain) = fresh_store_and_chain(&config);
// Fresh store, no anchor. The channel serves a genesis at the same id but a
// different hash — a foreign chain reinscribing genesis.
@ -174,10 +180,9 @@ async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() {
Some(Slot::from(10)),
messages,
);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &mut store, &mut state, true,
)
.await;
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(
result.is_err(),
"a reinscribed genesis with a different hash must abort startup"
@ -209,10 +214,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
messages,
);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock,
&mut seq.store,
&mut seq.state,
true,
&mock, &seq.store, &seq.chain, true,
)
.await;
assert!(
@ -224,7 +226,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() {
#[tokio::test]
async fn fails_when_a_channel_block_is_missing_locally() {
let config = setup_sequencer_config();
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
let (store, chain) = fresh_store_and_chain(&config);
// A block numbered below our genesis is at/below the local tip yet absent from
// the store — a foreign chain with a lower numbering.
@ -237,10 +239,9 @@ async fn fails_when_a_channel_block_is_missing_locally() {
Some(Slot::from(10)),
messages,
);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &mut store, &mut state, true,
)
.await;
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(
result.is_err(),
"a channel block below the local range must abort startup"
@ -250,7 +251,7 @@ async fn fails_when_a_channel_block_is_missing_locally() {
#[tokio::test]
async fn fails_when_a_channel_block_does_not_extend_the_tip() {
let config = setup_sequencer_config();
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
let (store, chain) = fresh_store_and_chain(&config);
// A block claiming an id far past genesis does not chain onto the local tip.
let mut orphan = store.get_block_at_id(store.genesis_id()).unwrap().unwrap();
@ -262,10 +263,9 @@ async fn fails_when_a_channel_block_does_not_extend_the_tip() {
Some(Slot::from(10)),
messages,
);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &mut store, &mut state, true,
)
.await;
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, true)
.await;
assert!(
result.is_err(),
"a non-contiguous channel block must abort startup"
@ -386,13 +386,12 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&mut seq_b.store,
&mut seq_b.state,
&seq_b.store,
&seq_b.chain,
true,
)
.await
.expect("reconstruct");
seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().unwrap().id;
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
assert_eq!(tip_b.id, tip_a.id);
@ -402,15 +401,17 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
let bridge_id = system_accounts::bridge_account_id();
let state_b = seq_b.chain().lock().unwrap().head_state().clone();
let state_a = seq_a.chain().lock().unwrap().head_state().clone();
for account in [vault_id, bridge_id, recipient] {
assert_eq!(
seq_b.state().get_account_by_id(account).balance,
seq_a.state().get_account_by_id(account).balance,
state_b.get_account_by_id(account).balance,
state_a.get_account_by_id(account).balance,
"reconstructed balance mismatch for {account:?}",
);
}
assert_eq!(
seq_b.state().get_account_by_id(vault_id).balance,
state_b.get_account_by_id(vault_id).balance,
u128::from(deposit_amount),
"deposit must mint into the recipient vault exactly once, not twice"
);
@ -489,17 +490,11 @@ async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() {
// Sequencer B reconstructs A's chain from a fresh store.
let config_b = bridge_funded_config();
let (mut store_b, mut state_b) =
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&mut store_b,
&mut state_b,
true,
)
.await
.expect("reconstruct");
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
.await
.expect("reconstruct");
assert!(
!store_b.dbio().consume_unseen_withdraw_count(key).unwrap(),
@ -540,8 +535,7 @@ async fn reconstruction_reconciles_already_finished_deposit() {
// pre-seeded, as the cold-start backfill would when it re-observes this
// already-finalized deposit.
let config_b = bridge_funded_config();
let (mut store_b, mut state_b) =
SequencerCore::<MockBlockPublisher>::open_or_create_store(&config_b);
let (store_b, chain_b) = fresh_store_and_chain(&config_b);
assert!(
store_b
.dbio()
@ -550,19 +544,19 @@ async fn reconstruction_reconciles_already_finished_deposit() {
);
let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages);
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock_b,
&mut store_b,
&mut state_b,
true,
)
.await
.expect("reconstruct");
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true)
.await
.expect("reconstruct");
// The mint was applied exactly once.
let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient);
assert_eq!(
state_b.get_account_by_id(vault_id).balance,
chain_b
.lock()
.unwrap()
.head_state()
.get_account_by_id(vault_id)
.balance,
u128::from(deposit_amount),
"already-finished deposit must be applied exactly once"
);
@ -599,17 +593,16 @@ async fn committed_local_against_missing_channel_fails_without_anchor() {
// Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in
// for a checkpoint persisted by a prior sync (the mock never emits one).
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
let (store, chain) = fresh_store_and_chain(&config);
assert!(store.get_zone_anchor().unwrap().is_none());
assert!(store.latest_block_meta().unwrap().unwrap().id > 1);
// The channel is gone: no tip, no messages.
let mock =
MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]);
let result = SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(
&mock, &mut store, &mut state, false,
)
.await;
let result =
SequencerCore::<MockBlockPublisher>::verify_and_reconstruct(&mock, &store, &chain, false)
.await;
assert!(
result.is_err(),
"committed blocks against a missing channel must abort startup"

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

@ -90,6 +90,4 @@ pub trait Rpc {
#[method(name = "getChannelId")]
async fn get_channel_id(&self) -> Result<ChannelId, 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

@ -0,0 +1,71 @@
//! Posts a `ChannelConfig` op (accredited keys + rotation params) to bedrock,
//! signed with `<home>/bedrock_signing_key`, without booting the sequencer.
//!
//! Authorization is holding the admin key file — the L1 rejects non-admin
//! signers. Acceptance is asynchronous: a rejection only shows up in node
//! logs and on-chain behavior.
use std::path::PathBuf;
use anyhow::{Context as _, Result, anyhow};
use clap::Parser;
use sequencer_core::block_publisher::{Ed25519PublicKey, post_channel_config};
#[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>,
/// Accredited ed25519 public keys (hex), admin (this node's key) first.
#[clap(long, required = true, value_delimiter = ',')]
keys: Vec<String>,
/// Slots a sequencer's posting turn lasts.
#[clap(long)]
posting_timeframe: u32,
/// Slots after which a stalled turn can be taken over.
#[clap(long)]
posting_timeout: u32,
/// Signatures required for future config changes.
#[clap(long, default_value_t = 1)]
configuration_threshold: u16,
/// Signatures required for channel withdrawals.
#[clap(long, default_value_t = 1)]
withdraw_threshold: u16,
}
#[tokio::main]
async fn main() -> Result<()> {
env_logger::init();
let args = Args::parse();
let config = sequencer_service::SequencerConfig::from_path(&args.config_path)?;
let home = args.home.unwrap_or(config.home);
let signing_key =
sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?;
let keys = args
.keys
.iter()
.map(|key| parse_key(key))
.collect::<Result<Vec<_>>>()?;
post_channel_config(
&config.bedrock_config,
&signing_key,
keys,
args.posting_timeframe,
args.posting_timeout,
args.configuration_threshold,
args.withdraw_threshold,
)
.await
}
fn parse_key(hex_key: &str) -> Result<Ed25519PublicKey> {
let mut bytes = [0_u8; 32];
hex::decode_to_slice(hex_key, &mut bytes)
.with_context(|| format!("Invalid hex-encoded key {hex_key}"))?;
Ed25519PublicKey::from_bytes(&bytes).map_err(|err| anyhow!("Invalid Ed25519 public key: {err}"))
}

View File

@ -11,10 +11,11 @@ use mempool::MemPoolHandle;
use sequencer_core::SequencerCore;
#[cfg(feature = "standalone")]
use sequencer_core::SequencerCoreWithMockClients as SequencerCore;
use sequencer_core::TransactionOrigin;
pub use sequencer_core::config::*;
use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _};
use sequencer_service_rpc::RpcServer as _;
use tokio::{sync::Mutex, task::JoinHandle};
use tokio_util::sync::CancellationToken;
pub mod service;
@ -28,6 +29,9 @@ pub struct SequencerHandle {
/// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`.
server_handle: Option<ServerHandle>,
main_loop_handle: JoinHandle<Result<Never>>,
/// Cancelled when the publisher's drive task terminates (e.g. a panicked
/// persist sink); no channel events are processed past that point.
driver_cancellation: CancellationToken,
}
impl SequencerHandle {
@ -35,11 +39,13 @@ impl SequencerHandle {
addr: SocketAddr,
server_handle: ServerHandle,
main_loop_handle: JoinHandle<Result<Never>>,
driver_cancellation: CancellationToken,
) -> Self {
Self {
addr,
server_handle: Some(server_handle),
main_loop_handle,
driver_cancellation,
}
}
@ -53,6 +59,7 @@ impl SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation,
} = &mut self;
let server_handle = server_handle.take().expect("Server handle is set");
@ -65,6 +72,9 @@ impl SequencerHandle {
.context("Main loop task panicked")?
.context("Main loop exited unexpectedly")
}
() = driver_cancellation.cancelled() => {
Err(anyhow!("Publisher drive task terminated"))
}
}
}
@ -78,10 +88,12 @@ impl SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation,
} = self;
let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped)
|| main_loop_handle.is_finished();
|| main_loop_handle.is_finished()
|| driver_cancellation.is_cancelled();
!stopped
}
@ -97,6 +109,7 @@ impl Drop for SequencerHandle {
addr: _,
server_handle,
main_loop_handle,
driver_cancellation: _,
} = self;
main_loop_handle.abort();
@ -111,21 +124,23 @@ impl Drop for SequencerHandle {
}
}
pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle> {
pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<SequencerHandle> {
let block_timeout = config.block_create_timeout;
let max_block_size = config.max_block_size;
let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await;
let (sequencer_core, mempool_handle): (SequencerCore, _) =
SequencerCore::start_from_config(config).await;
info!("Sequencer core set up");
let driver_cancellation = sequencer_core.block_publisher().driver_cancellation();
let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core));
let mempool_handle_for_server = mempool_handle.clone();
let (server_handle, addr) = run_server(
Arc::clone(&seq_core_wrapped),
mempool_handle_for_server,
port,
listen_addr,
max_block_size.as_u64(),
)
.await?;
@ -136,13 +151,18 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result<SequencerHandle>
let _ = mempool_handle;
Ok(SequencerHandle::new(addr, server_handle, main_loop_handle))
Ok(SequencerHandle::new(
addr,
server_handle,
main_loop_handle,
driver_cancellation,
))
}
async fn run_server(
sequencer: Arc<Mutex<SequencerCore>>,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
port: u16,
listen_addr: SocketAddr,
max_block_size: u64,
) -> Result<(ServerHandle, SocketAddr)> {
let server = jsonrpsee::server::ServerBuilder::with_config(
@ -153,7 +173,7 @@ async fn run_server(
)
.build(),
)
.build(SocketAddr::from(([0, 0, 0, 0], port)))
.build(listen_addr)
.await
.context("Failed to build RPC server")?;
@ -172,16 +192,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

@ -1,4 +1,7 @@
use std::path::PathBuf;
use std::{
net::{IpAddr, SocketAddr},
path::PathBuf,
};
use anyhow::Result;
use clap::Parser;
@ -12,6 +15,14 @@ struct Args {
config_path: PathBuf,
#[clap(short, long, default_value = "3040")]
port: u16,
/// Interface the RPC server binds to. The RPC has no caller auth —
/// bind loopback unless the port is firewalled.
#[clap(long, default_value = "0.0.0.0")]
listen_address: IpAddr,
/// 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,14 +33,23 @@ struct Args {
async fn main() -> Result<()> {
env_logger::init();
let Args { config_path, port } = Args::parse();
let Args {
config_path,
port,
listen_address,
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 sequencer_handle = sequencer_service::run(config, port).await?;
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, SocketAddr::new(listen_address, port)).await?;
tokio::select! {
() = cancellation_token.cancelled() => {

View File

@ -40,7 +40,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 +138,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 +155,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 +169,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> {

View File

@ -19,10 +19,12 @@ use crate::{
},
error::DbError,
sequencer::sequencer_cells::{
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned,
FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell,
LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord,
PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell,
WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned,
ZoneSdkCheckpointCellRef,
},
};
@ -46,6 +48,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";
@ -403,6 +409,25 @@ impl RocksDBIO {
Ok(true)
}
/// Marks the given deposit events submitted in `block_id`, in one write.
pub fn mark_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
) -> DbResult<()> {
if deposit_op_ids.is_empty() {
return Ok(());
}
let mut batch = WriteBatch::default();
self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?;
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to mark deposit events submitted".to_owned()),
)
})
}
fn mark_pending_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
@ -503,23 +528,36 @@ impl RocksDBIO {
}
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
if !first {
// A produced block is the new head tip by construction: pin the
// tip meta and drop any stale higher blocks a preceding reorg left
// behind (mirrors `store_followed_blocks`).
let last_curr_block = self.get_meta_last_block_in_db()?;
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 {
id: block.header.block_id,
hash: block.header.hash,
},
batch,
)?;
for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block {
self.delete_block_payload(stale_id, batch)?;
}
self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?;
self.put_meta_latest_block_meta_batch(&BlockMeta::from(block), batch)?;
}
self.put_block_payload(block, batch)
}
/// Stages deletion of a block payload into `batch`.
fn delete_block_payload(&self, block_id: u64, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
batch.delete_cf(
&cf_block,
borsh::to_vec(&block_id).map_err(|err| {
DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned()))
})?,
);
Ok(())
}
/// 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| {
@ -537,6 +575,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)
}
@ -633,6 +691,92 @@ impl RocksDBIO {
Ok(())
}
/// One-block form of [`Self::store_followed_blocks`], with the block as the
/// head tip and no final snapshot. Production always uses the batch form.
#[cfg(test)]
fn store_followed_block(
&self,
block: &Block,
state: &V03State,
finalized: bool,
) -> DbResult<()> {
self.store_followed_blocks(
&[(block, finalized)],
Some(&BlockMeta::from(block)),
state,
None,
)
}
/// Persists a batch of followed blocks, the caller's head-tip `state`, and
/// the optional final-tier snapshot in one atomic write.
///
/// The tip meta is pinned to `head_tip`, and blocks stored above it (left
/// behind by a net-shortening reorg) are deleted in the same write, so
/// restart replay never walks past the tip.
///
/// 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. A no-op update (nothing to write, tip unchanged)
/// writes nothing.
///
/// 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)],
head_tip: Option<&BlockMeta>,
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();
for (block, finalized) in blocks {
let already_stored = self
.get_block(block.header.block_id)?
.filter(|stored| stored.header.hash == block.header.hash);
let mut to_write = match already_stored {
Some(_) if !finalized => continue,
Some(stored) => stored,
None => (*block).clone(),
};
if *finalized {
to_write.bedrock_status = BedrockStatus::Finalized;
}
self.put_block_payload(&to_write, &mut batch)?;
}
// A shrink-only update (orphans without adopted replacements) has no
// payloads to write but must still rewind the tip meta, or the stored
// state tears against the stale disk head on the next produce.
let tip_rewound = head_tip.is_some_and(|tip| tip.id < last_block_in_db);
if batch.is_empty() && final_snapshot.is_none() && !tip_rewound {
return Ok(());
}
if let Some(tip) = head_tip {
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
self.delete_block_payload(stale_id, &mut batch)?;
}
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
@ -682,3 +826,6 @@ impl RocksDBIO {
})
}
}
#[cfg(test)]
mod tests;

View File

@ -7,10 +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_CURSOR_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_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
},
};
@ -44,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,283 @@
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().expect("meta is set").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![]);
let head_tip = BlockMeta {
id: 3,
hash: block3.header.hash,
};
dbio.store_followed_blocks(
&[(&block2, true), (&block3, false)],
Some(&head_tip),
&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().expect("meta is set");
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)],
Some(&final_meta),
&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().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
}
#[test]
fn net_shortening_reorg_drops_stale_blocks() {
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();
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// A shorter competing chain wins: block 2 is replaced, block 3 gets no
// replacement.
let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]);
let head_tip = BlockMeta {
id: 2,
hash: block2b.header.hash,
};
dbio.store_followed_blocks(
&[(&block2b, false)],
Some(&head_tip),
&state_with_balance(400),
None,
)
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored2.header.hash, block2b.header.hash);
assert!(
dbio.get_block(3).unwrap().is_none(),
"stale block above the new head must be deleted, or restart replay panics on its broken link"
);
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
assert_eq!(stored_balance(&dbio), 400);
}
#[test]
fn shrink_only_reorg_rewinds_tip_meta() {
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 block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Orphan-only update: block 3 falls off the branch with no replacement.
let head_tip = BlockMeta {
id: 2,
hash: block2.header.hash,
};
dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None)
.unwrap();
assert!(
dbio.get_block(3).unwrap().is_none(),
"the orphaned block must not survive the tip rewind"
);
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2.header.hash);
assert_eq!(stored_balance(&dbio), 200);
}
#[test]
fn produced_block_below_disk_head_pins_meta_and_prunes() {
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();
let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]);
dbio.store_followed_block(&block3, &state_with_balance(300), false)
.unwrap();
// Producing at height 2 while the disk head is still 3: the produce path
// pins the tip meta to the produced block and drops the stale suffix in
// the same write, mirroring the follow path.
let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400))
.unwrap();
let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored");
assert_eq!(stored2.header.hash, block2b.header.hash);
assert!(dbio.get_block(3).unwrap().is_none());
let meta = dbio.latest_block_meta().unwrap().expect("meta is set");
assert_eq!(meta.id, 2);
assert_eq!(meta.hash, block2b.header.hash);
assert_eq!(stored_balance(&dbio), 400);
}

View File

@ -12,7 +12,7 @@ use lee_core::Commitment;
use log::{debug, error};
use sequencer_core::config::GenesisAction;
use sequencer_service::SequencerHandle;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
use serde::Serialize;
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
@ -340,9 +340,7 @@ impl TestContextBuilder {
setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
.await
.context("Failed to setup Indexer")?;
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to convert indexer addr to URL")?;
let indexer_client = IndexerClient::new(&indexer_url)
let indexer_client = setup::indexer_client(indexer_handle.addr())
.await
.context("Failed to create indexer client")?;
Some(IndexerComponents {
@ -404,10 +402,7 @@ impl TestContextBuilder {
.context("Failed to initialize private accounts in wallet")?;
}
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr())
.context("Failed to convert sequencer addr to URL")?;
let sequencer_client = SequencerClientBuilder::default()
.build(sequencer_url)
let sequencer_client = setup::sequencer_client(sequencer_handle.addr())
.context("Failed to create sequencer client")?;
Ok(TestContext {

View File

@ -7,8 +7,12 @@ 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 sequencer_service_rpc::{SequencerClient, SequencerClientBuilder};
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
use wallet::{
@ -20,6 +24,7 @@ use wallet::{
use crate::{
BEDROCK_SERVICE_PORT, BEDROCK_SERVICE_WITH_OPEN_PORT,
config::{self, InitialPrivateAccountForWallet},
indexer_client::IndexerClient,
private_mention, public_mention,
};
@ -29,6 +34,7 @@ pub struct SequencerSetup {
channel_id: ChannelId,
genesis_transactions: Option<Vec<GenesisAction>>,
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
bedrock_signing_key: Option<[u8; ED25519_SECRET_KEY_SIZE]>,
}
impl SequencerSetup {
@ -40,6 +46,7 @@ impl SequencerSetup {
channel_id: config::bedrock_channel_id(),
genesis_transactions: None,
cross_zone: None,
bedrock_signing_key: None,
}
}
@ -67,6 +74,15 @@ impl SequencerSetup {
self
}
/// Pre-write a bedrock (Ed25519, 32-byte seed) signing key into the home
/// before boot, so tests know the sequencer's public key in advance (e.g.
/// to accredit a committee member that has not started yet).
#[must_use]
pub const fn with_bedrock_signing_key(mut self, key: [u8; ED25519_SECRET_KEY_SIZE]) -> Self {
self.bedrock_signing_key = Some(key);
self
}
/// Set up the sequencer in a fresh temporary home directory, returning the
/// owning [`TempDir`] alongside the handle.
pub async fn setup(self) -> Result<(SequencerHandle, TempDir)> {
@ -88,10 +104,16 @@ impl SequencerSetup {
channel_id,
genesis_transactions,
cross_zone,
bedrock_signing_key,
} = self;
debug!("Using sequencer home at {}", home.display());
if let Some(key_bytes) = bedrock_signing_key {
std::fs::write(home.join("bedrock_signing_key"), key_bytes)
.context("Failed to write pre-generated bedrock signing key")?;
}
let genesis_transactions = if let Some(genesis) = genesis_transactions {
genesis
} else {
@ -119,7 +141,7 @@ impl SequencerSetup {
)
.context("Failed to create Sequencer config")?;
sequencer_service::run(config, 0)
sequencer_service::run(config, SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.context("Failed to run Sequencer Service")
}
@ -139,6 +161,24 @@ fn load_prebuilt_dump() -> Result<DbDump> {
DbDump::from_bytes(&bytes).context("Failed to deserialize prebuilt db dump")
}
/// Builds an HTTP RPC client for the sequencer at `addr`.
pub 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")
}
/// Builds a WebSocket RPC client for the indexer at `addr`.
pub 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
.context("Failed to build indexer client")
}
pub async fn setup_bedrock_node() -> Result<(DockerCompose, SocketAddr)> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let bedrock_compose_path = PathBuf::from(manifest_dir).join("../bedrock/docker-compose.yml");