307 lines
18 KiB
Markdown

# `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`: 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.** `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.
## 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<Tip>,
head_state: V03State, // final_state + applied head blocks
head_blocks: Vec<HeadEntry>, // ordered, above final_tip
final_stall: Option<StallReason>, // the one stall — persisted to RocksDB. See §4a
}
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, **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.
- `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. If a finalized block
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. One stall — `final_stall`, persisted
There is a single stall, `final_stall`, on the final tier. The head tier does
**not** carry its own stall, and this is deliberate.
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.
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 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
its last valid tip. Net: parking never stops us from producing correctly on our
turn.
## 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<br/>non-empty?"}
ORPH -->|yes| REV["For each orphaned by this_msg:<br/>drop from head_blocks,<br/>return its txs to mempool"]
REV --> RED["Re-derive head_state:<br/>clone final_state, replay survivors"]
ORPH -->|no| ADO
RED --> ADO{"adopted<br/>non-empty?"}
ADO -->|"yes, in order"| DEDUP{"this_msg in<br/>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,<br/>advance head tip, clear stall"]
OUT -->|AlreadyApplied| SKIP
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
FIN{"finalized<br/>inscriptions?"}
FIN -->|"already in head (steady state)"| MOVE["finalize_up_to:<br/>move head→final, trim head_blocks"]
FIN -->|"not in head (cold-start backfill)"| DIRECT["apply_block directly to final"]
DIRECT --> DOK{"applied?"}
DOK -->|yes| MIRROR["mirror into head"]
DOK -->|"no (invalid finalized)"| FSTALL["record StallReason on FINAL,<br/>freeze final tip"]
FIN -->|none| CP
MOVE --> CP
MIRROR --> CP
FSTALL --> 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: invalid block FINALIZED (apply_finalized fails)
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 — the one stall. Persisted, survives restart.
Head-tier bad blocks do NOT enter this state:
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
```
### 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** — "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`, 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 |
| 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).