From 80e0af241b36aa2cbd7296b15068063c45ff5ae8 Mon Sep 17 00:00:00 2001 From: erhant Date: Wed, 8 Jul 2026 17:40:20 +0300 Subject: [PATCH] feat(chain_state): initial preps, one more design fix --- Cargo.lock | 14 ++ Cargo.toml | 2 + lez/chain_state/Cargo.toml | 21 +++ lez/chain_state/DESIGN.md | 99 +++++----- lez/chain_state/src/apply.rs | 280 ++++++++++++++++++++++++++++ lez/chain_state/src/ingest_error.rs | 67 +++++++ lez/chain_state/src/lib.rs | 17 ++ lez/chain_state/src/stall_reason.rs | 25 +++ 8 files changed, 480 insertions(+), 45 deletions(-) create mode 100644 lez/chain_state/Cargo.toml create mode 100644 lez/chain_state/src/apply.rs create mode 100644 lez/chain_state/src/ingest_error.rs create mode 100644 lez/chain_state/src/lib.rs create mode 100644 lez/chain_state/src/stall_reason.rs diff --git a/Cargo.lock b/Cargo.lock index 0bd194bb..954d98fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,6 +1349,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chain_state" +version = "0.1.0" +dependencies = [ + "anyhow", + "common", + "lee", + "logos-blockchain-zone-sdk", + "serde", + "serde_json", + "testnet_initial_state", + "thiserror 2.0.18", +] + [[package]] name = "chkstk_stub" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3e71f293..c9471681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "lez", "lez/system_accounts", + "lez/chain_state", "lez/sequencer/core", "lez/sequencer/service", "lez/sequencer/service/protocol", @@ -64,6 +65,7 @@ members = [ lee = { path = "lee/state_machine" } lee_core = { path = "lee/state_machine/core" } common = { path = "lez/common" } +chain_state = { path = "lez/chain_state" } mempool = { path = "lez/mempool" } storage = { path = "lez/storage" } key_protocol = { path = "lee/key_protocol" } diff --git a/lez/chain_state/Cargo.toml b/lez/chain_state/Cargo.toml new file mode 100644 index 00000000..337367cc --- /dev/null +++ b/lez/chain_state/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "chain_state" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +common.workspace = true +lee.workspace = true +logos-blockchain-zone-sdk.workspace = true + +anyhow.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +testnet_initial_state.workspace = true +serde_json.workspace = true diff --git a/lez/chain_state/DESIGN.md b/lez/chain_state/DESIGN.md index 3e0bc384..600fb23f 100644 --- a/lez/chain_state/DESIGN.md +++ b/lez/chain_state/DESIGN.md @@ -86,8 +86,7 @@ struct ChainState { final_tip: Option, head_state: V03State, // final_state + applied head blocks head_blocks: Vec, // ordered, above final_tip - final_stall: Option, // persisted to RocksDB — see §4a - head_stall: Option, // in-memory only — recomputed from the stream on restart + final_stall: Option, // the one stall — persisted to RocksDB. See §4a } struct HeadEntry { this_msg: MsgId, block: Block } @@ -103,8 +102,8 @@ Operations: - `apply_adopted(inscription) -> AcceptOutcome` — dedup by `this_msg` against our outbox, else `apply_block` on the head tip; on success push `HeadEntry`. On - failure, set **`head_stall`** (in-memory `StallReason`) and freeze the head tip - at the last valid block — do **not** persist. + failure, **do nothing durable**: the head tip simply stays at the last valid + block. No stall is recorded (see §4a) — the head self-heals from the stream. - `apply_channel_update(orphaned, adopted)` — revert every `orphaned` by `this_msg`, re-derive `head_state` (clone `final_state`, replay survivors), then apply every `adopted` in order. Atomic per event. @@ -113,52 +112,62 @@ Operations: - `apply_finalized(inscription)` — steady state: if present in head by `this_msg`, `finalize_up_to`; cold-start backfill (not in head): `apply_block` directly to `final_state`, mirror into head. If a finalized block - fails to apply, set **`final_stall`** and persist it — this is the **only** - `StallReason` written to disk (see §4a). -- `rollback_orphan(this_msg)` — drop from that entry forward, re-derive head; - clears `head_stall` if the re-derived head is clean. -- `status() -> { final_height, head_height, head_stall, final_stall }` — for RPC/UI. + fails to apply, set **`final_stall`** and persist it — this is the **only** stall + (see §4a). +- `rollback_orphan(this_msg)` — drop from that entry forward, re-derive head. +- `status() -> { final_height, head_height, final_stall }` — for RPC/UI. A derived + "head blocked" indicator can be computed on demand (see §4a) without persisting. For the **indexer** (finalized-only `next_messages` stream), `head_blocks` stays empty and `head == final`; it exercises only `apply_finalized`. The **sequencer** uses both tiers from day one. -### 4a. Two stalls — persisted vs in-memory +### 4a. One stall — `final_stall`, persisted -Both tiers carry a `StallReason` (`final_stall`, `head_stall`); they are equally -informative. The difference is **persistence**, which follows from durability. L1 -finality is about inscription **canonicality**, not LEZ-block **content** -validity, so an authorized sequencer can get a content-invalid block finalized — -which is why both tiers can meet an invalid block in the first place. +There is a single stall, `final_stall`, on the final tier. The head tier does +**not** carry its own stall, and this is deliberate. -- **`head_stall` — in-memory only.** The head is reorg-able and re-derived from - every `channel_update`. An invalid adopted block is transient: it is either - orphaned (a competing valid block at the same height wins) or it finalizes. We - set `head_stall` for observability (RPC/UI can show "head blocked at `N`: - StateTransition at tx 3") and freeze the head tip at the last valid block, but - we do **not** write it to disk — on restart the head is rebuilt from the stream, - so a persisted head stall would be redundant and could go stale. Because the - next adopted block chains on the bad one, it fails validation too, so the head - stays frozen until a valid block (competing or post-reorg) is adopted. -- **`final_stall` — persisted.** The final tier is irreversible. If an invalid - block *finalizes*, the node is durably stuck until a valid successor (built by - honest sequencers on the last valid parent) finalizes. This must survive - restart: the startup chain-consistency / anchor check reads it, it is what we - surface as `Stalled`, and it is the signal the committee acts on to evict a bad - sequencer. +The head and the final tier never represent two independent problems: a block +always reaches the head first (as `adopted`) and only later the final tier (as +`finalized`), so a would-be "head stall" is just the earlier, provisional sighting +of the exact block that `final_stall` records durably if it finalizes — the same +event modeled twice. -An invalid block **migrates the problem head→final** when it finalizes: `head_stall` -is set at `N` first; once `N+1(bad)` finalizes, `apply_finalized` fails and records -the persisted `final_stall`. Both tiers end up stuck at `N` consistently, and both -recover when `N+1′` finalizes. The indexer (final tier only) never sets -`head_stall`, so it behaves exactly as it does today. +And the head does not need a recorded reason to freeze. **The tip-freeze is +intrinsic**: not applying a bad block is what freezes the tip; no marker is +required. The head's freeze is also **transient and self-healing** — the bad block +is either orphaned (a competing valid block at the same height wins and applies on +its own) or it finalizes. Subsequent adopted blocks that chain on the bad one fail +validation by themselves, and the producer builds on the head tip regardless of +any marker. So a persisted head stall would be redundant (re-derived from the +stream on restart) and buys no behavior. + +`final_stall` is the stall that does real work: + +- The **indexer already requires it** and ships it today. The indexer has only a + final tier (finalized-only stream, no head); its startup chain-consistency / + anchor check reads the persisted stall to know where it is parked. The shared + `final_stall` serves that unchanged. +- It **survives restart** and is what we surface as `Stalled`. +- "A bad block **finalized**" is the only irreversible, actionable condition — the + signal the committee acts on to evict a bad sequencer. A provisional head block + that may vanish on the next reorg is not something to evict over. + +The one thing we forgo is an *early warning* that a sequencer is posting garbage +before it finalizes. That condition frequently self-heals via reorg, so alarming on +it is mostly noise; if wanted, it is a **derived, non-persisted** indicator (e.g. +"k adopted inscriptions above the head tip remain unapplied"), computed on demand — +not a second `StallReason` in the struct. + +So the sequencer and the indexer share exactly one stall concept, keeping the two +consumers uniform. ### 4b. Producer contract — write on turn, build on last valid The sequencer publishes **only on its own turn** (the SDK queues out-of-turn publishes). When it is our turn we build the next block on the **current head tip**, which is by construction the last validly-applied block. So if the head is -frozen (`head_stall` set) on a peer's bad block, we build on that frozen valid +frozen on a peer's bad block, we build on that frozen valid tip — the same parent every honest sequencer chooses — and thereby skip the bad block rather than extend it. A parked node keeps following peers' valid blocks as they arrive; the moment it also gets a turn, it produces the next valid block on @@ -203,7 +212,7 @@ flowchart TD VAL --> OUT{"AcceptOutcome"} OUT -->|Applied| APP["append this_msg+block to head,
advance head tip, clear stall"] OUT -->|AlreadyApplied| SKIP - OUT -->|"Parked(err)"| PARK["set head_stall (in-memory),
freeze head tip — do NOT apply.
Not persisted"] + OUT -->|"Parked(err)"| PARK["freeze head tip — do NOT apply.
No stall recorded (self-heals
via reorg/finalization)"] SKIP --> FIN APP --> FIN PARK --> FIN @@ -231,11 +240,11 @@ stateDiagram-v2 Parked --> Parked: further non-chaining finalized blocks (orphans_since++) Parked --> Syncing: valid successor finalizes on frozen final tip → stall cleared note right of Parked - final_stall — persisted, survives restart. + final_stall — the one stall. Persisted, survives restart. Head-tier bad blocks do NOT enter this state: - they set head_stall (in-memory) and self-heal - via reorg/finalization. Producer (on our turn) - builds on the last valid tip either way. + the head tip freezes intrinsically (no stall) and + self-heals via reorg/finalization. Producer (on our + turn) builds on the last valid tip either way. end note ``` @@ -258,13 +267,13 @@ stateDiagram-v2 | 6 | Batch reorg: some `orphaned` + some `adopted` in one event | revert all orphaned, re-derive head, then apply all adopted in order | deterministic convergence | | 7 | Orphan chain (parent transitively off canonical) | SDK surfaces all affected as `orphaned`; revert each, replay survivors | head_state matches new canonical branch | -**Invalid / bad block** — "stall" below means the **persisted `final_stall`** -(§4a). A bad block seen only in `adopted` sets the in-memory `head_stall` (not -persisted); it becomes a persisted `final_stall` only if it finalizes. +**Invalid / bad block** — "stall" below means the one **persisted `final_stall`** +(§4a). A bad block seen only in `adopted` records **no** stall — the head tip just +freezes and self-heals; it becomes a `final_stall` only if it finalizes. | # | Scenario | Handling | Expected | | --- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------- | -| 8 | Authorized sequencer posts a block with an invalid state transition | head: `apply_block` → `Parked`, set `head_stall` (in-memory), no persist. If it finalizes: persisted `final_stall` | park-and-skip; no apply, no halt | +| 8 | Authorized sequencer posts a block with an invalid state transition | head: `apply_block` → `Parked`, freeze head tip, no stall recorded. If it finalizes: persisted `final_stall` | park-and-skip; no apply, no halt | | 9 | Broken chain link / hash mismatch / unexpected id in adopted | `Parked(BrokenChainLink / HashMismatch / UnexpectedBlockId)`; same park | frozen tip; peers park identically | | 10 | Undeserializable inscription payload | park with `Deserialize` (no header); processing advances | recover when a valid block chains on frozen tip | | 11 | Valid successor after a park (recovery) | block chaining on frozen tip → `Applied` → clear stall | head resumes automatically; no divergence | diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs new file mode 100644 index 00000000..38e49b53 --- /dev/null +++ b/lez/chain_state/src/apply.rs @@ -0,0 +1,280 @@ +//! The single validate-then-apply entry point shared by the sequencer and the +//! indexer. Pure and storage-free: callers apply on a scratch clone of state and +//! commit only on `Ok`. + +use common::{ + HashType, + block::Block, + transaction::{LeeTransaction, clock_invocation}, +}; +use lee::{GENESIS_BLOCK_ID, V03State}; + +use crate::ingest_error::BlockIngestError; + +/// The last successfully applied block: the parent the next block must chain on. +/// +/// Only what validation needs today (`block_id` + `hash`). The two-tier +/// `ChainState`'s `final_tip` will extend this with the inscription `l1_slot` +/// when the anchor layer lands; the slot is currently tracked separately. +#[derive(Debug, Clone)] +pub struct Tip { + pub block_id: u64, + pub hash: HashType, +} + +/// Outcome of feeding a parsed L2 block to a validated tip. +pub enum AcceptOutcome { + /// Chained and applied; the tip advances. + Applied, + /// A duplicate re-delivery of an already-applied block. No state change. + AlreadyApplied, + /// Did not chain or failed to apply; the tip stays frozen. + Parked(BlockIngestError), +} + +/// Validates `block` against `tip`, then applies it to `state`. +/// +/// Validation runs first and touches nothing. Application then mutates `state` +/// in place and can fail partway, so callers pass a scratch clone and commit it +/// only when this returns `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. +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`. +fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { + let (clock_tx, user_txs) = block + .body + .transactions + .split_last() + .ok_or(BlockIngestError::EmptyBlock)?; + + let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp)); + if *clock_tx != expected_clock { + return Err(BlockIngestError::InvalidClockTransaction); + } + + let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; + for (tx_index, transaction) in user_txs.iter().enumerate() { + let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { + tx_index: tx_index.try_into().expect("tx index fits in u64"), + reason: format!("{err:#}"), + }; + if is_genesis { + let LeeTransaction::Public(public_tx) = transaction else { + return Err(BlockIngestError::NonPublicGenesisTransaction); + }; + state + .transition_from_public_transaction( + public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| state_transition(err.into()))?; + } else { + transaction + .clone() + .execute_on_state(state, block.header.block_id, block.header.timestamp) + .map_err(|err| state_transition(err.into()))?; + } + } + + let LeeTransaction::Public(clock_public_tx) = clock_tx else { + return Err(BlockIngestError::InvalidClockTransaction); + }; + state + .transition_from_public_transaction( + clock_public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| BlockIngestError::StateTransition { + tx_index: user_txs.len().try_into().expect("tx index fits in u64"), + reason: format!("{:#}", anyhow::Error::from(err)), + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use common::{ + block::HashableBlockData, + test_utils::{ + create_transaction_native_token_transfer, produce_dummy_block, + produce_dummy_empty_transaction, sequencer_sign_key_for_testing, + }, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn tip_of(block: &Block) -> Tip { + Tip { + block_id: block.header.block_id, + hash: block.header.hash, + } + } + + #[test] + fn genesis_applies_on_empty_tip() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + } + + #[test] + fn non_genesis_first_block_is_unexpected_id() { + let mut state = initial_state(); + let block = produce_dummy_block(2, None, vec![]); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { expected: 1, got: 2 } + )); + } + + #[test] + fn skip_ahead_block_is_unexpected_id() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Tip is at 1; a block with id 3 skips ahead. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + let err = apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { expected: 2, got: 3 } + )); + } + + #[test] + fn broken_chain_link_detected() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Correct id (2), wrong parent hash. + let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::BrokenChainLink { .. })); + } + + #[test] + fn hash_mismatch_detected() { + let mut state = initial_state(); + let mut genesis = produce_dummy_block(1, None, vec![]); + // Tampering with the header invalidates the stored hash. + genesis.header.timestamp = 999; + let err = apply_block(None, &genesis, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::HashMismatch { .. })); + } + + #[test] + fn empty_block_rejected() { + let mut state = initial_state(); + // A block with no transactions at all (not even the mandatory clock tx). + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::EmptyBlock)); + } + + #[test] + fn missing_clock_tail_is_invalid_clock() { + let mut state = initial_state(); + // Last tx is not the expected clock invocation for the timestamp. + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 50, + transactions: vec![produce_dummy_empty_transaction()], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::InvalidClockTransaction)); + } + + #[test] + fn applies_transfers_and_advances_state() { + let mut state = initial_state(); + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Genesis (block 1): clock-only. + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut tip = tip_of(&genesis); + + // Blocks 2..=11: one native transfer of 10 each (nonces 0..=9). + for i in 0..10_u64 { + let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key); + let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]); + apply_block(Some(&tip), &block, &mut state).expect("transfer applies"); + tip = tip_of(&block); + } + + assert_eq!(state.get_account_by_id(from).balance, 9900); + assert_eq!(state.get_account_by_id(to).balance, 20100); + } +} diff --git a/lez/chain_state/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs new file mode 100644 index 00000000..bd377a0e --- /dev/null +++ b/lez/chain_state/src/ingest_error.rs @@ -0,0 +1,67 @@ +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, + }, +} + +#[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 + } + )); + } +} diff --git a/lez/chain_state/src/lib.rs b/lez/chain_state/src/lib.rs new file mode 100644 index 00000000..67efa146 --- /dev/null +++ b/lez/chain_state/src/lib.rs @@ -0,0 +1,17 @@ +//! Shared, storage-free chain-state core for the LEZ sequencer and indexer. +//! +//! Hosts the single validate-then-apply entry point ([`apply_block`]) plus the +//! shared types ([`BlockIngestError`], [`StallReason`], [`Tip`], +//! [`AcceptOutcome`]) that both the sequencer and the indexer build on. The +//! crate performs no I/O: callers own their storage and drive the +//! `scratch → persist → commit` ordering around these primitives. +//! +//! See `DESIGN.md` in this crate for the two-tier chain-state model this backs. + +pub mod apply; +pub mod ingest_error; +pub mod stall_reason; + +pub use apply::{AcceptOutcome, Tip, apply_block}; +pub use ingest_error::BlockIngestError; +pub use stall_reason::StallReason; diff --git a/lez/chain_state/src/stall_reason.rs b/lez/chain_state/src/stall_reason.rs new file mode 100644 index 00000000..c5118fcc --- /dev/null +++ b/lez/chain_state/src/stall_reason.rs @@ -0,0 +1,25 @@ +use common::HashType; +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, + pub block_hash: Option, + pub prev_block_hash: Option, + pub l1_slot: Slot, + pub error: BlockIngestError, + pub first_seen: Option, + /// 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, +}