From 3f8ac11475929575d88108ed4eb7b735b550a4b6 Mon Sep 17 00:00:00 2001 From: erhant Date: Wed, 8 Jul 2026 16:33:58 +0300 Subject: [PATCH] feat(chain_state): initial design doc --- lez/chain_state/DESIGN.md | 248 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 lez/chain_state/DESIGN.md diff --git a/lez/chain_state/DESIGN.md b/lez/chain_state/DESIGN.md new file mode 100644 index 00000000..3bb993e3 --- /dev/null +++ b/lez/chain_state/DESIGN.md @@ -0,0 +1,248 @@ +# `lez/chain_state` — Two-Tier Chain State + +Design doc for the shared block-apply engine and two-tier chain state that +backs decentralized sequencing. Status: **interface freeze** — the +`apply_block` signature and the `ChainState` tip/state shape below are the +contract the produce-on-turn and follow-blocks tracks build against. Changing +them after the tracks split forces rework in both. + +Branch: `erhant/lez-two-tip-chain-state` (off `erhant/indexer-recoverable-invalid-blocks`). + +--- + +## 1. Motivation + +Decentralized sequencing requires every honest node — sequencer or indexer — to +converge on the same chain and the same state by running one deterministic +_validate-then-apply_ path over blocks pulled from the channel. That path today +lives only inside the indexer (`lez/indexer/core/src/block_store.rs`), where the +recoverability work built a park-and-skip ingest: `accept_block` validates a +block against the current tip, applies it to a scratch clone of state atomically, +and on any failure records a `StallReason`, freezes the tip, and marks the bad +block _processed_ without applying it. The sequencer has no equivalent — it only +produces blocks and reads peer inscriptions for finalization; it never executes +peer blocks into its own state. + +This crate lifts that logic into a shared home and generalizes it into a +**two-tier** state machine so the sequencer can produce on the head while both +sequencer and indexer expose their exact current state. + +## 2. Crate placement & layering + +``` +lee_core ← lee (owns V03State) ← common (owns Block, BedrockStatus, clock_invocation, recompute_hash) ← lez/chain_state ← { indexer/core, sequencer/core } +``` + +- **Not** `lee_core`: that is the RISC0 guest crate; a stateful host machine has + no place in the zkVM circuit and does not know what a `Block` is. +- **Not** `lee`: the apply logic needs `Block`/`BedrockStatus`/`clock_invocation` + from `common`, and `common` depends on `lee` — putting it in `lee` inverts the + layer. +- `lez/chain_state` sits above `common`, depends on `common` + `lee`, and is + consumed by both `indexer/core` and `sequencer/core`. + +**Persistence boundary (decision: A).** `chain_state` holds the in-memory state +machine and the pure logic; it performs **no I/O**. Each consumer keeps its own +`RocksDBIO` and drives the `scratch → put_block → commit` ordering, exactly as +`accept_block` does today. This keeps the crate fully unit-testable without a DB. +A storage-trait abstraction (option B) is a possible later follow-up, not part of +this task. + +## 3. The `apply_block` entry point + +A single pure function, called identically whether the block was produced by us, +adopted from a peer, or read finalized from the channel: + +```rust +/// Validate `block` against `tip`, then apply it to `state`. Pure: no I/O. +/// Mutates `state` only on success; on failure `state` is untouched and the +/// caller parks. +fn apply_block( + tip: Option<&Tip>, + block: &Block, + state: &mut V03State, +) -> Result<(), BlockIngestError>; +``` + +Validation order (unchanged from the indexer): hash integrity +(`recompute_hash`) → block-id continuity → `prev_block_hash` linkage, with a +`None` tip expecting the genesis block. Application splits off the mandatory +trailing clock tx, executes user txs (genesis = public-only), applies the clock +last. + +Shared types moved into the crate: `AcceptOutcome`, `BlockIngestError`, +`StallReason`, and `Tip`. + +```rust +struct Tip { block_id: u64, hash: HashType, l1_slot: Slot } + +enum AcceptOutcome { Applied, AlreadyApplied, Parked(BlockIngestError) } +``` + +`Tip` carries `l1_slot` (recorded atomically with the tip) because the anchor / +chain-consistency logic keys on the inscription slot, not just `(id, hash)`. + +## 4. The two-tier `ChainState` + +```rust +struct ChainState { + final_state: V03State, // driven by finalized channel ops + final_tip: Option, + head_state: V03State, // final_state + applied head blocks + head_blocks: Vec, // ordered, above final_tip + stall: Option, +} + +struct HeadEntry { this_msg: MsgId, block: Block } +``` + +The **head** tier is a MsgId-keyed chain (adopted/orphaned reference +`this_msg`/`parent_msg`); the **final** tier is block-id-keyed. `apply_block` +validation stays LEZ-level (`block_id` + `prev_block_hash`) — the two chains run +in parallel and must agree, so we validate via `apply_block` _and_ track `MsgId` +for revert correlation. + +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 record stall + park (head tip frozen). +- `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. +- `finalize_up_to(block_id)` — move `head_blocks` up to `block_id` into + `final_state` (already validated; a move, not a re-apply). +- `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. +- `rollback_orphan(this_msg)` — drop from that entry forward, re-derive head. +- `status() -> { final_height, head_height, stall }` — for RPC/UI. + +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. + +## 5. Event → tier mapping + +`Event::BlocksProcessed { checkpoint, channel_update: { orphaned, adopted }, finalized }`: + +| Input | Source | Effect | +| --------------------- | ---------------------------------------------- | --------------------------------------------------- | +| adopted inscription | `channel_update.adopted` | validate + apply to **head** | +| orphaned inscription | `channel_update.orphaned` | revert from **head**, return txs to mempool | +| finalized inscription | `finalized[].ops` (`FinalizedOp::Inscription`) | move head→**final**, or apply directly on backfill | +| own publish | publish-return | optimistically apply to **head**, record `this_msg` | + +**Golden rules:** (1) validation is deterministic, so every honest node makes the +same accept/park decision. (2) An invalid block is _processed but discarded_ — +never applied, never halts the node. (3) Finalized is never reverted. (4) We +rebuild orphaned blocks ourselves; we do not trust the SDK's republish (it keeps +stale LEZ contents — prev-hash, tx selection, and resulting state were all +computed against the old parent). + +--- + +## 6. Scenarios + +### Processing one `BlocksProcessed` event + +```mermaid +flowchart TD + EV["Event::BlocksProcessed"] --> ORPH{"orphaned
non-empty?"} + ORPH -->|yes| REV["For each orphaned by this_msg:
drop from head_blocks,
return its txs to mempool"] + REV --> RED["Re-derive head_state:
clone final_state, replay survivors"] + ORPH -->|no| ADO + RED --> ADO{"adopted
non-empty?"} + ADO -->|"yes, in order"| DEDUP{"this_msg in
our outbox?"} + ADO -->|no| FIN + DEDUP -->|"yes (our own)"| SKIP["skip: already applied optimistically"] + DEDUP -->|no| VAL["apply_block on head tip"] + VAL --> OUT{"AcceptOutcome"} + OUT -->|Applied| APP["append this_msg+block to head,
advance head tip, clear stall"] + OUT -->|AlreadyApplied| SKIP + OUT -->|"Parked(err)"| PARK["record StallReason,
freeze head tip,
mark processed — do NOT apply"] + SKIP --> FIN + APP --> FIN + PARK --> FIN + FIN{"finalized
inscriptions?"} + FIN -->|"already in head (steady state)"| MOVE["finalize_up_to:
move head→final, trim head_blocks"] + FIN -->|"not in head (cold-start backfill)"| DIRECT["apply_block directly to final,
mirror into head"] + FIN -->|none| CP + MOVE --> CP + DIRECT --> CP + CP["persist checkpoint atomically"] +``` + +### Park / recovery status + +```mermaid +stateDiagram-v2 + [*] --> Syncing + Syncing --> CaughtUp: stream drained, no stall + CaughtUp --> Syncing: new adopted / finalized arrives + Syncing --> Parked: apply_block returns Parked(err) + CaughtUp --> Parked: invalid block adopted + Parked --> Parked: further non-chaining blocks (orphans_since++) + Parked --> Syncing: valid successor chains on frozen tip → stall cleared + note right of Parked + head tip frozen at last valid block. + Producer (on our turn) builds on this + frozen tip, same as honest peers, + so the invalid block is skipped. + end note +``` + +### Scenario table + +**Normal flow** + +| # | Scenario | Handling | Expected | +| --- | ---------------------------------------- | ------------------------------------------------------------- | ----------------------------------- | +| 1 | Adopted block chains cleanly on head tip | `apply_block` → `Applied`; append `{this_msg, block}` to head | head advances; converges with peers | +| 2 | Our own block comes back in `adopted` | dedup by `this_msg` against outbox → skip | no double-apply | +| 3 | Adopted block later finalizes | `finalize_up_to` moves head→final, trims `head_blocks` | final advances; no re-apply | +| 4 | Re-delivery of an already-applied block | id ≤ tip & stored hash matches → `AlreadyApplied` | idempotent, no state change | + +**Reorg / orphan** + +| # | Scenario | Handling | Expected | +| --- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| 5 | Our block orphaned at turn handoff (stale-parent race) | revert by `this_msg`, return txs to mempool, **rebuild** on new head tip (not SDK republish) | our txs re-queued; next block on correct parent | +| 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** + +| # | Scenario | Handling | Expected | +| --- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------- | +| 8 | Authorized sequencer posts a block with an invalid state transition | `apply_block` → `Parked(StateTransition)`; freeze head tip, record stall, mark processed | 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 | +| 12 | Further non-chaining blocks while parked | keep first `StallReason`, bump `orphans_since` | original cause preserved; still parked | + +**Producing while parked** + +| # | Scenario | Handling | Expected | +| --- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 13 | It's our turn but head is parked on a bad block | producer builds on the **frozen valid tip** (head tip = last valid), skipping the invalid block | we emit the next valid block on the same parent honest peers use — chain moves on our turn | + +**Startup / backfill** + +| # | Scenario | Handling | Expected | +| --- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------- | +| 14 | Cold start / reconnect backfill | history via `finalized`, empty `channel_update`; apply directly to final + mirror head | head == final until live deltas start | +| 15 | Local store belongs to a different chain (L1 reset) | anchor-based `chain_consistency` check at startup: wipe+reindex if `allow_chain_reset`, else error | no silent divergence | + +## 7. Invariants + +Should-never-happen conditions — assert/log, don't silently absorb: + +- An `orphaned` entry never references a block at or below the **final** tip — + finalized is irreversible. If seen, it is a bug. +- `head` tip ≥ `final` tip at all times; `head_blocks` holds exactly the blocks + between them. +- After processing any event, `head_state == final_state` replayed through + `head_blocks` (the re-derivation is the source of truth). +- A parked node's frozen tip is identical across all honest nodes for the same + invalid block (deterministic validation).