mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-09 15:29:34 +00:00
feat(chain_state): initial preps, one more design fix
This commit is contained in:
parent
8ab13de775
commit
80e0af241b
14
Cargo.lock
generated
14
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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" }
|
||||
|
||||
21
lez/chain_state/Cargo.toml
Normal file
21
lez/chain_state/Cargo.toml
Normal 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
|
||||
@ -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 |
|
||||
|
||||
280
lez/chain_state/src/apply.rs
Normal file
280
lez/chain_state/src/apply.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
67
lez/chain_state/src/ingest_error.rs
Normal file
67
lez/chain_state/src/ingest_error.rs
Normal 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
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
17
lez/chain_state/src/lib.rs
Normal file
17
lez/chain_state/src/lib.rs
Normal 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;
|
||||
25
lez/chain_state/src/stall_reason.rs
Normal file
25
lez/chain_state/src/stall_reason.rs
Normal 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,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user