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
10 changes: 6 additions & 4 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ jobs:
# 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.
# The state-transition and fork-choice trees are taken whole rather than suite by
# suite: each harness already names every suite it consumes 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
Expand All @@ -112,7 +113,8 @@ jobs:
'*/ssz/*/ssz/test_xmss_containers/*.json' \
'*/justifiability/*/state_transition/test_justifiability/*.json' \
'*/slot_clock/*/chain/test_slot_clock/*.json' \
'*/state_transition/*/state_transition/*/*.json'
'*/state_transition/*/state_transition/*/*.json' \
'*/fork_choice/*/fork_choice/*/*.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: 1 addition & 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: the state transition, justification candidacy, and the slot clock."
description = "Consensus decisions over the container types: the state transition, fork choice, justification candidacy, and the slot clock."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
Expand Down
83 changes: 80 additions & 3 deletions crates/verity-chain/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
//! 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.
//! four still absent are raised from paths this workspace has not reached — proposer-index
//! range checking, block-proof verification, and wire decoding. 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
Expand Down Expand Up @@ -54,6 +55,44 @@ pub enum RejectionReason {
JustifiedSlotOutOfRange,
/// A tracked justification root is the zero hash, which marks a slot with no block.
ZeroHashJustificationRoot,
/// The anchor block does not commit to the anchor state it was handed with.
AnchorStateRootMismatch,
/// The block's parent has no state in the store, so the transition has nothing to start from.
UnknownParentBlock,
/// The block's slot runs past the horizon the store's own clock admits.
BlockTooFarInFuture,
/// The block repeats one attestation data entry, which the wire format forbids.
DuplicateAttestationData,
/// The vote names a source block the store has never seen.
UnknownSourceBlock,
/// The vote names a target block the store has never seen.
UnknownTargetBlock,
/// The vote names a head block the store has never seen.
UnknownHeadBlock,
/// The vote's source sits later than its target, which history forbids.
SourceAfterTarget,
/// The vote's head sits earlier than its target, which history forbids.
HeadOlderThanTarget,
/// The vote's source checkpoint slot disagrees with the slot of the block it names.
SourceSlotMismatch,
/// The vote's target checkpoint slot disagrees with the slot of the block it names.
TargetSlotMismatch,
/// The vote's head checkpoint slot disagrees with the slot of the block it names.
HeadSlotMismatch,
/// The vote's source does not lie on the target's chain of ancestors.
SourceNotAncestorOfTarget,
/// The vote's target does not lie on the head's chain of ancestors.
TargetNotAncestorOfHead,
/// The vote's head does not descend from the finalized block, so it can carry no weight.
HeadNotDescendantOfFinalized,
/// The vote's slot has not started locally yet, beyond the clock-skew margin.
AttestationTooFarInFuture,
/// The vote claims a head from a slot the vote itself precedes.
AttestationSlotBeforeHead,
/// The vote names a validator the target block's post-state registry does not hold.
ValidatorNotInState,
/// Signature verification failed.
InvalidSignature,
}

impl RejectionReason {
Expand All @@ -75,6 +114,25 @@ impl RejectionReason {
Self::JustificationVotesLengthMismatch => "JUSTIFICATION_VOTES_LENGTH_MISMATCH",
Self::JustifiedSlotOutOfRange => "JUSTIFIED_SLOT_OUT_OF_RANGE",
Self::ZeroHashJustificationRoot => "ZERO_HASH_JUSTIFICATION_ROOT",
Self::AnchorStateRootMismatch => "ANCHOR_STATE_ROOT_MISMATCH",
Self::UnknownParentBlock => "UNKNOWN_PARENT_BLOCK",
Self::BlockTooFarInFuture => "BLOCK_TOO_FAR_IN_FUTURE",
Self::DuplicateAttestationData => "DUPLICATE_ATTESTATION_DATA",
Self::UnknownSourceBlock => "UNKNOWN_SOURCE_BLOCK",
Self::UnknownTargetBlock => "UNKNOWN_TARGET_BLOCK",
Self::UnknownHeadBlock => "UNKNOWN_HEAD_BLOCK",
Self::SourceAfterTarget => "SOURCE_AFTER_TARGET",
Self::HeadOlderThanTarget => "HEAD_OLDER_THAN_TARGET",
Self::SourceSlotMismatch => "SOURCE_SLOT_MISMATCH",
Self::TargetSlotMismatch => "TARGET_SLOT_MISMATCH",
Self::HeadSlotMismatch => "HEAD_SLOT_MISMATCH",
Self::SourceNotAncestorOfTarget => "SOURCE_NOT_ANCESTOR_OF_TARGET",
Self::TargetNotAncestorOfHead => "TARGET_NOT_ANCESTOR_OF_HEAD",
Self::HeadNotDescendantOfFinalized => "HEAD_NOT_DESCENDANT_OF_FINALIZED",
Self::AttestationTooFarInFuture => "ATTESTATION_TOO_FAR_IN_FUTURE",
Self::AttestationSlotBeforeHead => "ATTESTATION_SLOT_BEFORE_HEAD",
Self::ValidatorNotInState => "VALIDATOR_NOT_IN_STATE",
Self::InvalidSignature => "INVALID_SIGNATURE",
}
}
}
Expand Down Expand Up @@ -107,6 +165,25 @@ mod tests {
RejectionReason::JustificationVotesLengthMismatch,
RejectionReason::JustifiedSlotOutOfRange,
RejectionReason::ZeroHashJustificationRoot,
RejectionReason::AnchorStateRootMismatch,
RejectionReason::UnknownParentBlock,
RejectionReason::BlockTooFarInFuture,
RejectionReason::DuplicateAttestationData,
RejectionReason::UnknownSourceBlock,
RejectionReason::UnknownTargetBlock,
RejectionReason::UnknownHeadBlock,
RejectionReason::SourceAfterTarget,
RejectionReason::HeadOlderThanTarget,
RejectionReason::SourceSlotMismatch,
RejectionReason::TargetSlotMismatch,
RejectionReason::HeadSlotMismatch,
RejectionReason::SourceNotAncestorOfTarget,
RejectionReason::TargetNotAncestorOfHead,
RejectionReason::HeadNotDescendantOfFinalized,
RejectionReason::AttestationTooFarInFuture,
RejectionReason::AttestationSlotBeforeHead,
RejectionReason::ValidatorNotInState,
RejectionReason::InvalidSignature,
];

#[test]
Expand Down
226 changes: 226 additions & 0 deletions crates/verity-chain/src/fork_choice/attestation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//! Admitting gossiped votes into the store's pools.
//!
//! leanSpec's `on_gossip_attestation` and `on_gossip_aggregated_attestation` each do three
//! things in a row: validate the vote, verify its signature, then record it. Verity splits
//! the signature verification out. It is the one step that needs a cryptographic library,
//! and this crate has none by design (see the crate docs); the composed entry points land
//! with `verity-crypto`, which supplies the missing middle.
//!
//! What that leaves here is every check that is a decision about the *vote* rather than
//! about the bytes signing it — which is all but two of the rejections leanSpec's gossip
//! path can produce.
//!
//! Transcribed from leanSpec `src/lean_spec/spec/forks/lstar/fork_choice.py`, read at commit
//! `0588c2d215a955a516378677a92db2a5666802f3`.

use verity_types::config::{GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT};
use verity_types::{AttestationData, Checkpoint, SignedAggregatedAttestation, ValidatorIndex};

use crate::error::RejectionReason;
use crate::fork_choice::store::{AttestationSignature, AttestationSignatureEntry, Store};
use crate::fork_choice::weights::participants;

/// Whether a vote is admissible against the store's current view.
///
/// The vote must name blocks the store knows, order them the way history allows, agree with
/// those blocks' actual slots, lie on one chain, and belong to a slot that has already
/// started locally. The head must also descend from the finalized block: fork choice only
/// ever walks down from there, so an orphaned head could never carry weight, and admitting
/// one would let a stale vote re-enter after pruning dropped it.
///
/// # Errors
///
/// One of the ten gossip-validation [`RejectionReason`]s, named on each check below.
pub fn validate_attestation(store: &Store, data: &AttestationData) -> Result<(), RejectionReason> {
validate_availability(store, data)?;
validate_topology(store, data)?;
validate_ancestry(store, data)?;
validate_timing(store, data)
}

/// Every block the vote names must already be in the local view.
fn validate_availability(store: &Store, data: &AttestationData) -> Result<(), RejectionReason> {
if !store.blocks.contains_key(&data.source.root) {
return Err(RejectionReason::UnknownSourceBlock);
}
if !store.blocks.contains_key(&data.target.root) {
return Err(RejectionReason::UnknownTargetBlock);
}
if !store.blocks.contains_key(&data.head.root) {
return Err(RejectionReason::UnknownHeadBlock);
}
Ok(())
}

/// History is linear: source at or before target, target at or before head — and each
/// checkpoint's slot must be the slot of the block it names.
fn validate_topology(store: &Store, data: &AttestationData) -> Result<(), RejectionReason> {
if data.source.slot.0 > data.target.slot.0 {
return Err(RejectionReason::SourceAfterTarget);
}
if data.head.slot.0 < data.target.slot.0 {
return Err(RejectionReason::HeadOlderThanTarget);
}

let checks = [
(data.source, RejectionReason::SourceSlotMismatch),
(data.target, RejectionReason::TargetSlotMismatch),
(data.head, RejectionReason::HeadSlotMismatch),
];
for (checkpoint, reason) in checks {
// Availability ran first, so every root here resolves.
if store.blocks.get(&checkpoint.root).map(|block| block.slot) != Some(checkpoint.slot) {
return Err(reason);
}
}
Ok(())
}

/// The three checkpoints must lie on one chain, rooted under the finalized block.
///
/// Weight accrues to every ancestor of the attested head, so a head on a sibling branch
/// would steer that weight onto a chain the vote never meant to support.
fn validate_ancestry(store: &Store, data: &AttestationData) -> Result<(), RejectionReason> {
if !store.is_ancestor(data.source, data.target) {
return Err(RejectionReason::SourceNotAncestorOfTarget);
}
if !store.is_ancestor(data.target, data.head) {
return Err(RejectionReason::TargetNotAncestorOfHead);
}
if !store.is_ancestor(store.latest_finalized, data.head) {
return Err(RejectionReason::HeadNotDescendantOfFinalized);
}
Ok(())
}

/// A vote cannot predate the head it claims, nor arrive before its own slot has started.
///
/// The clock-skew margin is one interval, not a whole slot. With five intervals per slot,
/// slot 10 begins at interval 50: interval 49 is admitted as skew, interval 45 would admit a
/// vote a full slot early and let an adversary pre-publish next-slot aggregates.
///
/// The comparison stays in slot units. Multiplying a near-`u64::MAX` wire slot up into
/// intervals would overflow before it could be rejected.
fn validate_timing(store: &Store, data: &AttestationData) -> Result<(), RejectionReason> {
if data.slot.0 < data.head.slot.0 {
return Err(RejectionReason::AttestationSlotBeforeHead);
}

let admission_horizon = store.time.0.saturating_add(GOSSIP_DISPARITY_INTERVALS);
if data.slot.0 > admission_horizon / INTERVALS_PER_SLOT {
return Err(RejectionReason::AttestationTooFarInFuture);
}
Ok(())
}

/// Whether a gossiped vote is admissible *and* names a validator the target block knew.
///
/// This is the admission decision every node makes, aggregator or not. leanSpec reaches the
/// registry check on the way to resolving the signer's public key, so a node that only
/// validates and relays applies it too.
///
/// # Errors
///
/// Any [`RejectionReason`] from [`validate_attestation`], or
/// [`RejectionReason::ValidatorNotInState`] when the signer is outside the target's registry.
pub fn validate_attestation_signer(
store: &Store,
validator_index: ValidatorIndex,
data: &AttestationData,
) -> Result<(), RejectionReason> {
validate_attestation(store, data)?;
validate_signers(store, data.target, [validator_index])
}

/// Whether the target block's post-state registry holds every named validator.
///
/// The registry is read from the target's post-state rather than the head's: that is the
/// state a verifier would resolve the signers' keys from, so an index outside it names a
/// validator the vote's own target never knew.
///
/// # Errors
///
/// [`RejectionReason::ValidatorNotInState`] for the first index outside the registry.
fn validate_signers(
store: &Store,
target: Checkpoint,
signers: impl IntoIterator<Item = ValidatorIndex>,
) -> Result<(), RejectionReason> {
// Validation ran first, so the target's post-state is present whenever this is reached.
let registry_size = store
.states
.get(&target.root)
.map_or(0, |state| state.validators.len() as u64);

for validator_index in signers {
if validator_index.0 >= registry_size {
return Err(RejectionReason::ValidatorNotInState);
}
}
Ok(())
}

/// Records one validator's signature in the aggregator's pool.
///
/// **The caller must have verified `signature` against the validator's key first.** This
/// crate cannot: see the module docs. Every other admission check leanSpec performs on the
/// gossip path runs here, in leanSpec's order, before anything is written.
///
/// A node that does not aggregate has no reason to call this. leanSpec validates and relays
/// such a vote without keeping it, which is [`validate_attestation`] on its own.
///
/// # Errors
///
/// Any [`RejectionReason`] from [`validate_attestation`], or
/// [`RejectionReason::ValidatorNotInState`] when the signer is outside the target's registry.
/// The store is left untouched on every one of them.
pub fn record_attestation_signature(
store: &mut Store,
validator_index: ValidatorIndex,
data: AttestationData,
signature: AttestationSignature,
) -> Result<(), RejectionReason> {
validate_attestation_signer(store, validator_index, &data)?;

store
.attestation_signatures
.entry(data)
.or_default()
.insert(AttestationSignatureEntry {
validator_index,
signature,
});
Ok(())
}

/// Records a gossiped aggregate proof in the pending pool.
///
/// **The caller must have verified the proof against its participants' keys first**, for the
/// same reason as above. The proof carries no weight until an acceptance tick promotes it —
/// see [`super::timeline::accept_new_attestations`].
///
/// # Errors
///
/// Any [`RejectionReason`] from [`validate_attestation`],
/// [`RejectionReason::EmptyAggregationBits`] when the proof names nobody, or
/// [`RejectionReason::ValidatorNotInState`] when a participant is outside the target's
/// registry. The store is left untouched on every one of them.
pub fn record_aggregated_payload(
store: &mut Store,
attestation: &SignedAggregatedAttestation,
) -> Result<(), RejectionReason> {
validate_attestation(store, &attestation.data)?;

let signers: Vec<ValidatorIndex> = participants(&attestation.proof.participants).collect();
if signers.is_empty() {
return Err(RejectionReason::EmptyAggregationBits);
}
validate_signers(store, attestation.data.target, signers)?;

store
.latest_new_aggregated_payloads
.entry(attestation.data)
.or_default()
.insert(attestation.proof.clone());
Ok(())
}
Loading
Loading