feat(chain_state): initial preps, one more design fix

This commit is contained in:
erhant 2026-07-08 17:40:20 +03:00
parent 8ab13de775
commit 80e0af241b
8 changed files with 480 additions and 45 deletions

14
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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

View File

@ -86,8 +86,7 @@ struct ChainState {
final_tip: Option<Tip>,
head_state: V03State, // final_state + applied head blocks
head_blocks: Vec<HeadEntry>, // ordered, above final_tip
final_stall: Option<StallReason>, // persisted to RocksDB — see §4a
head_stall: Option<StallReason>, // in-memory only — recomputed from the stream on restart
final_stall: Option<StallReason>, // 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,<br/>advance head tip, clear stall"]
OUT -->|AlreadyApplied| SKIP
OUT -->|"Parked(err)"| PARK["set head_stall (in-memory),<br/>freeze head tip — do NOT apply.<br/>Not persisted"]
OUT -->|"Parked(err)"| PARK["freeze head tip — do NOT apply.<br/>No stall recorded (self-heals<br/>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 |

View File

@ -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);
}
}

View File

@ -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
}
));
}
}

View File

@ -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;

View File

@ -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<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,
}