Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,19 @@ jobs:
# Only the suites a crate actually consumes. tar fails when a pattern matches nothing,
# so a suite leanSpec renames or drops breaks the job here rather than quietly halving
# the vector count downstream.
#
# The state-transition tree is taken whole rather than suite by suite: its harness
# already names all fifteen suites and fails on any that matches no file, so listing
# them again here would only put the same list in two places.
- name: Extract consumed fixture suites
run: |
mkdir -p fixtures-prod
tar -xzf fixtures-prod-scheme.tar.gz -C fixtures-prod \
--wildcards '*/ssz/*/ssz/test_consensus_containers/*.json' \
'*/ssz/*/ssz/test_xmss_containers/*.json' \
'*/justifiability/*/state_transition/test_justifiability/*.json' \
'*/slot_clock/*/chain/test_slot_clock/*.json'
'*/slot_clock/*/chain/test_slot_clock/*.json' \
'*/state_transition/*/state_transition/*/*.json'

# The whole workspace, so a crate that starts consuming fixtures is picked up without
# editing this job. Every fixture test skips when VERITY_FIXTURES is unset, which is
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion crates/verity-chain/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "verity-chain"
description = "Consensus decisions over the container types: justification candidacy and the slot clock."
description = "Consensus decisions over the container types: the state transition, justification candidacy, and the slot clock."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
Expand All @@ -11,7 +11,14 @@ publish = false

# Pure decisions over `verity-types` shapes. No storage, no networking, no cryptography, and
# no wall clock: every function here takes the time it should reason about as an argument.
#
# `libssz-merkle` is here for one reason: the state transition commits to `hash_tree_root` of
# the state, the parent header, and the block body, so the decision cannot be expressed
# without it. It is the same Serialization capability `verity-types` already depends on, not
# a new supplier.
[dependencies]
libssz-merkle.workspace = true
libssz-types.workspace = true
verity-types.workspace = true

[dev-dependencies]
Expand Down
130 changes: 130 additions & 0 deletions crates/verity-chain/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Why the spec rejects an input.
//!
//! This is the `ProcessingError` of `ARCHITECTURE.md`'s capability contracts — a plain enum,
//! no structured payload, because rejection reasons are a small closed set and nothing but
//! the discriminant has to survive a future trip across the C ABI. It is named after the
//! leanSpec enum it mirrors so the two stay greppable against each other.
//!
//! Only the reasons Verity can currently produce are defined. leanSpec's enum has 36; the
//! rest belong to fork choice and gossip validation, and land with them. An unmodelled
//! reason is not silently tolerated: [`RejectionReason::as_str`] is what the fixture suites
//! compare against, so a vector expecting a reason this enum lacks fails the run.
//!
//! One variant is here ahead of the code that leanSpec raises it from.
//! [`RejectionReason::BlockSlotGapTooLarge`] guards the transition's empty-slot walk, which
//! leanSpec guards from fork choice instead — see `state_transition::process_slots`.
//!
//! Transcribed from leanSpec `src/lean_spec/spec/forks/lstar/errors.py`, read at commit
//! `0588c2d215a955a516378677a92db2a5666802f3`.

use core::fmt;

/// Language-neutral reason the spec rejects an invalid input.
///
/// The variant names, and the strings [`RejectionReason::as_str`] returns, are leanSpec's
/// verbatim. They are the wire form: fixtures carry them as `rejectionReason`, and a future
/// FFI status code maps one-to-one onto them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RejectionReason {
/// The block slot is not strictly greater than the current state slot.
BlockSlotNotInFuture,
/// The block slot runs so far beyond its parent it would force an unbounded empty-slot walk.
BlockSlotGapTooLarge,
/// The block slot disagrees with the state slot after slot processing.
BlockSlotMismatch,
/// The block slot is not newer than the latest block header.
BlockOlderThanLatestHeader,
/// The block parent root disagrees with the latest block header root.
ParentRootMismatch,
/// The block state root disagrees with the computed post-state root.
StateRootMismatch,
/// The block proposer is not the scheduled proposer for its slot.
WrongProposer,
/// The registry holds no validators, so no proposer can be scheduled for any slot.
EmptyValidatorRegistry,
/// A set aggregation bit points outside the validator registry.
ValidatorIndexOutOfRange,
/// The block carries more distinct attestation data entries than allowed.
TooManyAttestationData,
/// An aggregated attestation references no validator at all.
EmptyAggregationBits,
/// The flat vote list length is not the tracked-root count times the validator count.
JustificationVotesLengthMismatch,
/// A queried slot is active but outside the tracked justification range.
JustifiedSlotOutOfRange,
/// A tracked justification root is the zero hash, which marks a slot with no block.
ZeroHashJustificationRoot,
}

impl RejectionReason {
/// The leanSpec name for this reason, as it appears in a test vector.
#[must_use = "this renders the reason; it does not raise or record it"]
pub const fn as_str(self) -> &'static str {
match self {
Self::BlockSlotNotInFuture => "BLOCK_SLOT_NOT_IN_FUTURE",
Self::BlockSlotGapTooLarge => "BLOCK_SLOT_GAP_TOO_LARGE",
Self::BlockSlotMismatch => "BLOCK_SLOT_MISMATCH",
Self::BlockOlderThanLatestHeader => "BLOCK_OLDER_THAN_LATEST_HEADER",
Self::ParentRootMismatch => "PARENT_ROOT_MISMATCH",
Self::StateRootMismatch => "STATE_ROOT_MISMATCH",
Self::WrongProposer => "WRONG_PROPOSER",
Self::EmptyValidatorRegistry => "EMPTY_VALIDATOR_REGISTRY",
Self::ValidatorIndexOutOfRange => "VALIDATOR_INDEX_OUT_OF_RANGE",
Self::TooManyAttestationData => "TOO_MANY_ATTESTATION_DATA",
Self::EmptyAggregationBits => "EMPTY_AGGREGATION_BITS",
Self::JustificationVotesLengthMismatch => "JUSTIFICATION_VOTES_LENGTH_MISMATCH",
Self::JustifiedSlotOutOfRange => "JUSTIFIED_SLOT_OUT_OF_RANGE",
Self::ZeroHashJustificationRoot => "ZERO_HASH_JUSTIFICATION_ROOT",
}
}
}

impl fmt::Display for RejectionReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}

impl core::error::Error for RejectionReason {}

#[cfg(test)]
mod tests {
use super::RejectionReason;

/// Every variant, so a new one cannot be added without giving it a wire name here.
const ALL: &[RejectionReason] = &[
RejectionReason::BlockSlotNotInFuture,
RejectionReason::BlockSlotGapTooLarge,
RejectionReason::BlockSlotMismatch,
RejectionReason::BlockOlderThanLatestHeader,
RejectionReason::ParentRootMismatch,
RejectionReason::StateRootMismatch,
RejectionReason::WrongProposer,
RejectionReason::EmptyValidatorRegistry,
RejectionReason::ValidatorIndexOutOfRange,
RejectionReason::TooManyAttestationData,
RejectionReason::EmptyAggregationBits,
RejectionReason::JustificationVotesLengthMismatch,
RejectionReason::JustifiedSlotOutOfRange,
RejectionReason::ZeroHashJustificationRoot,
];

#[test]
fn should_render_a_distinct_screaming_snake_name_when_each_reason_is_displayed() {
let mut names: Vec<&str> = ALL.iter().map(|reason| reason.as_str()).collect();
names.sort_unstable();
let count = names.len();
names.dedup();
assert_eq!(names.len(), count, "two reasons share a wire name");
assert!(
ALL.iter().all(|reason| {
let name = reason.as_str();
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte == b'_')
}),
"a wire name is not SCREAMING_SNAKE_CASE"
);
}
}
130 changes: 127 additions & 3 deletions crates/verity-chain/src/justification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
//! Transcribed from leanSpec `src/lean_spec/spec/forks/lstar/slot.py`, read at commit
//! `0588c2d215a955a516378677a92db2a5666802f3`.

use verity_types::{Checkpoint, Slot};
use verity_types::config::HISTORICAL_ROOTS_LIMIT;
use verity_types::{Checkpoint, JustifiedSlots, Slot};

use crate::error::RejectionReason;

/// Slots within this distance of the finalized boundary are always justification candidates.
pub const IMMEDIATE_JUSTIFICATION_WINDOW: u64 = 5;
Expand Down Expand Up @@ -70,15 +73,80 @@ pub fn advance_checkpoint(current: Checkpoint, candidate: Checkpoint) -> Checkpo
}
}

/// Whether `slot` is already justified, per the bitfield anchored at `finalized`.
///
/// A slot at or behind the boundary is justified by definition and is not looked up.
///
/// # Errors
///
/// [`RejectionReason::JustifiedSlotOutOfRange`] when the slot is ahead of the boundary but
/// past the end of the tracked bitfield. leanSpec surfaces the same out-of-range access as a
/// domain rejection rather than letting an index error escape block processing.
#[must_use = "this answers whether the slot is justified; it does not justify it"]
pub fn is_slot_justified(
justified_slots: &JustifiedSlots,
finalized: Slot,
slot: Slot,
) -> Result<bool, RejectionReason> {
let Some(index) = justified_index_after(slot, finalized) else {
return Ok(true);
};
justified_slots
.get(index)
.ok_or(RejectionReason::JustifiedSlotOutOfRange)
}

/// Grows the tracked bitfield until `slot` is addressable, filling new positions with `false`.
///
/// Returns the bitfield unchanged when `slot` is at or behind the boundary, or when the
/// bitfield already reaches it.
///
/// # Errors
///
/// [`RejectionReason::JustifiedSlotOutOfRange`] when addressing `slot` would need more bits
/// than [`HISTORICAL_ROOTS_LIMIT`] allows. That bound is the bitfield's SSZ limit, so a
/// larger one is not representable in the state at all.
#[must_use = "this returns the grown bitfield; the argument is left untouched"]
pub fn extend_justified_slots_to(
justified_slots: &JustifiedSlots,
finalized: Slot,
slot: Slot,
) -> Result<JustifiedSlots, RejectionReason> {
let Some(index) = justified_index_after(slot, finalized) else {
return Ok(justified_slots.clone());
};

// Zero-based index, so covering it takes one more bit than its value.
let required = index.saturating_add(1);
if required <= justified_slots.len() {
return Ok(justified_slots.clone());
}
if required > HISTORICAL_ROOTS_LIMIT {
return Err(RejectionReason::JustifiedSlotOutOfRange);
}

let mut extended = justified_slots.clone();
while extended.len() < required {
extended
.push(false)
.map_err(|_| RejectionReason::JustifiedSlotOutOfRange)?;
}
Ok(extended)
}

fn is_perfect_square(value: u128) -> bool {
let root = value.isqrt();
root * root == value
}

#[cfg(test)]
mod tests {
use super::{advance_checkpoint, is_justifiable_after, justified_index_after};
use verity_types::{Checkpoint, Slot};
use super::{
advance_checkpoint, extend_justified_slots_to, is_justifiable_after, is_slot_justified,
justified_index_after,
};
use crate::error::RejectionReason;
use verity_types::{Checkpoint, JustifiedSlots, Slot};

#[test]
fn should_report_no_index_when_slot_is_at_or_behind_the_finalized_boundary() {
Expand Down Expand Up @@ -157,4 +225,60 @@ mod tests {
};
assert_eq!(advance_checkpoint(current, candidate), candidate);
}

fn bitfield(bits: &[bool]) -> JustifiedSlots {
JustifiedSlots::try_from(bits.to_vec()).expect("fits well under the tracked limit")
}

#[test]
fn should_report_justified_when_the_slot_is_at_or_behind_the_finalized_boundary() {
let empty = bitfield(&[]);
assert_eq!(is_slot_justified(&empty, Slot(10), Slot(10)), Ok(true));
assert_eq!(is_slot_justified(&empty, Slot(10), Slot(3)), Ok(true));
}

#[test]
fn should_read_the_tracked_bit_when_the_slot_is_ahead_of_the_boundary() {
let tracked = bitfield(&[false, true, false]);
assert_eq!(is_slot_justified(&tracked, Slot(0), Slot(1)), Ok(false));
assert_eq!(is_slot_justified(&tracked, Slot(0), Slot(2)), Ok(true));
}

#[test]
fn should_reject_when_the_queried_slot_is_past_the_tracked_range() {
assert_eq!(
is_slot_justified(&bitfield(&[false]), Slot(0), Slot(9)),
Err(RejectionReason::JustifiedSlotOutOfRange)
);
}

#[test]
fn should_grow_with_unset_flags_when_the_bitfield_falls_short_of_the_slot() {
let grown = extend_justified_slots_to(&bitfield(&[true]), Slot(0), Slot(4)).unwrap();
assert_eq!(grown.len(), 4);
assert_eq!(grown.get(0), Some(true));
assert_eq!(grown.count_ones(), 1);
}

#[test]
fn should_leave_the_bitfield_alone_when_it_already_reaches_the_slot() {
let tracked = bitfield(&[true, true, true]);
let unchanged = extend_justified_slots_to(&tracked, Slot(0), Slot(2)).unwrap();
assert_eq!(unchanged.len(), 3, "reaching the slot must not shrink it");
}

#[test]
fn should_leave_the_bitfield_alone_when_the_slot_is_behind_the_boundary() {
let tracked = bitfield(&[true]);
let unchanged = extend_justified_slots_to(&tracked, Slot(7), Slot(7)).unwrap();
assert_eq!(unchanged.len(), 1);
}

#[test]
fn should_reject_when_covering_the_slot_would_overrun_the_tracked_limit() {
assert_eq!(
extend_justified_slots_to(&bitfield(&[]), Slot(0), Slot(u64::MAX)),
Err(RejectionReason::JustifiedSlotOutOfRange)
);
}
}
18 changes: 16 additions & 2 deletions crates/verity-chain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,26 @@
//! every other crate depends on.
//!
//! Nothing here reads a clock, a socket, or a database. `slot_clock` takes the instant it
//! should reason about as an argument for exactly that reason.
//! should reason about as an argument, and `state_transition` takes the block, for exactly
//! that reason. Signature verification happens before the transition is called, so this
//! crate carries no cryptographic dependency either.

pub mod error;
pub mod justification;
pub mod merkle;
pub mod proposer;
pub mod slot_clock;
pub mod state_transition;

pub use error::RejectionReason;
pub use justification::{
IMMEDIATE_JUSTIFICATION_WINDOW, advance_checkpoint, is_justifiable_after, justified_index_after,
IMMEDIATE_JUSTIFICATION_WINDOW, advance_checkpoint, extend_justified_slots_to,
is_justifiable_after, is_slot_justified, justified_index_after,
};
pub use merkle::hash_tree_root;
pub use proposer::proposer_for_slot;
pub use slot_clock::{SlotClock, intervals_at_slot_start};
pub use state_transition::{
generate_genesis, process_attestations, process_block, process_block_header, process_slots,
state_transition,
};
14 changes: 14 additions & 0 deletions crates/verity-chain/src/merkle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! The one place the hash tree root hasher is chosen.
//!
//! `hash_tree_root` is a capability contract in `ARCHITECTURE.md`, currently satisfied by the
//! external SSZ library. Routing every call in this crate through one function is what keeps
//! that swap a one-file change: nothing else names a hasher.

use libssz_merkle::{HashTreeRoot, Sha2Hasher};
use verity_types::Bytes32;

/// The SSZ hash tree root of a consensus value.
#[must_use = "this computes the root; it does not store or commit to it"]
pub fn hash_tree_root<T: HashTreeRoot>(value: &T) -> Bytes32 {
value.hash_tree_root(&Sha2Hasher)
}
Loading
Loading