commit c4c98c5485cbdb1ae68dc5170b24f1b2de8ce24b
parent ff891838bb9f4bd7ca3efb27a4b31d692565c264
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Thu, 10 Sep 2026 09:11:31 +0200
refactor(errors): type validation control flow
Diffstat:
11 files changed, 207 insertions(+), 64 deletions(-)
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -12,6 +12,7 @@ use tokio::{
use crate::app::{GossipEnvelope, SharedNode, SharedPeerBook};
+mod error;
mod fetch;
mod handshake;
mod identity;
@@ -27,6 +28,7 @@ mod sync;
#[cfg(test)]
mod test_support;
mod writer;
+use error::SyncError;
pub use fetch::{fetch_peer_height, fetch_snapshot, fetch_snapshot_with_announcement};
use fetch::{
network_adjusted_time_ms, validate_blocks_extension, validate_chain_bootstrap, verify_block_vdf,
diff --git a/src/adapters/p2p/error.rs b/src/adapters/p2p/error.rs
@@ -0,0 +1,18 @@
+use std::{error::Error, fmt};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum SyncError {
+ BlockPageHasNoCommonAncestor,
+}
+
+impl fmt::Display for SyncError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::BlockPageHasNoCommonAncestor => {
+ formatter.write_str("block page has no common ancestor with local chain")
+ }
+ }
+ }
+}
+
+impl Error for SyncError {}
diff --git a/src/adapters/p2p/fetch.rs b/src/adapters/p2p/fetch.rs
@@ -254,7 +254,7 @@ pub(super) async fn validate_blocks_extension(
.blocks
.iter()
.position(|block| block.hash == blocks[0].prev_hash)
- .context("block page has no common ancestor with local chain")?;
+ .ok_or(super::SyncError::BlockPageHasNoCommonAncestor)?;
#[cfg(feature = "e2e")]
for block in &blocks {
if !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output) {
diff --git a/src/adapters/p2p/peer_addr.rs b/src/adapters/p2p/peer_addr.rs
@@ -6,6 +6,10 @@ use std::{
use anyhow::{Context, Result};
+use crate::domain::{ValidationError, error_has_validation};
+
+use super::SyncError;
+
pub(super) fn next_reconnect_delay(current: Duration, max_delay: Duration) -> Duration {
(current * 2).min(max_delay)
}
@@ -128,61 +132,77 @@ pub(super) fn is_quiet_disconnect(error: &anyhow::Error) -> bool {
}
pub(super) fn is_possible_fork_error(error: &anyhow::Error) -> bool {
- let message = format!("{error:#}");
- message.contains("does not extend local tip")
- || message.contains("conflicts with local chain")
- || message.contains("expected block height")
- || message.contains("block page has no common ancestor with local chain")
+ error_has_validation(error, ValidationError::requests_fork_recovery)
+ || error.chain().any(|cause| {
+ matches!(
+ cause.downcast_ref::<SyncError>(),
+ Some(SyncError::BlockPageHasNoCommonAncestor)
+ )
+ })
}
-pub(super) fn inbound_error_counts_as_misbehavior(message: &str) -> bool {
- !message.contains("block timestamp is too far in the future")
- && !message.contains("block timestamp is before finalizer rank")
- && !message.contains("block page has no common ancestor with local chain")
- && !message.contains("burn bundle parent hash is invalid")
- && !message.contains(
- "burn transaction anchor is not valid for either of the next two block heights",
- )
- && !message.contains("mine transaction anchor is not on this chain")
+pub(super) fn inbound_error_counts_as_misbehavior(error: &anyhow::Error) -> bool {
+ !is_possible_fork_error(error)
+ && !error_has_validation(error, |kind| kind.is_fork_relative() || kind.is_temporal())
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
+ use crate::domain::ValidationError;
+
+ use super::super::SyncError;
+
use super::{inbound_error_counts_as_misbehavior, is_possible_fork_error};
#[test]
fn future_and_unopened_rank_slot_errors_are_temporal_not_misbehavior() {
- assert!(!inbound_error_counts_as_misbehavior(
- "block timestamp is too far in the future"
- ));
- assert!(!inbound_error_counts_as_misbehavior(
- "block timestamp is before finalizer rank 1 time slot"
- ));
- assert!(inbound_error_counts_as_misbehavior("block hash is invalid"));
+ assert!(!inbound_error_counts_as_misbehavior(&anyhow::Error::new(
+ ValidationError::BlockTimestampTooFarInFuture
+ )));
+ assert!(!inbound_error_counts_as_misbehavior(&anyhow::Error::new(
+ ValidationError::BlockBeforeFinalizerRankSlot {
+ rank: 1,
+ min_timestamp: 123,
+ }
+ )));
+ assert!(inbound_error_counts_as_misbehavior(&anyhow!(
+ "block hash is invalid"
+ )));
}
#[test]
fn missing_block_page_ancestor_triggers_fork_recovery_without_peer_penalty() {
- let message = "block batch: block page has no common ancestor with local chain";
+ let error =
+ anyhow::Error::new(SyncError::BlockPageHasNoCommonAncestor).context("block batch");
- assert!(is_possible_fork_error(&anyhow!(message)));
- assert!(!inbound_error_counts_as_misbehavior(message));
+ assert!(is_possible_fork_error(&error));
+ assert!(!inbound_error_counts_as_misbehavior(&error));
}
#[test]
fn fork_scoped_gossip_errors_do_not_penalize_peers() {
- for message in [
- "burn bundle parent hash is invalid",
- "burn transaction anchor is not valid for either of the next two block heights",
- "mine transaction anchor is not on this chain",
+ for kind in [
+ ValidationError::BurnBundleParentMismatch,
+ ValidationError::BurnAnchorOutsidePendingWindow,
+ ValidationError::MineAnchorNotOnChain,
] {
- assert!(!inbound_error_counts_as_misbehavior(message));
+ let error = anyhow::Error::new(kind);
+ assert!(!inbound_error_counts_as_misbehavior(&error));
+ assert!(!is_possible_fork_error(&error));
}
- assert!(inbound_error_counts_as_misbehavior(
+ assert!(inbound_error_counts_as_misbehavior(&anyhow!(
"burn bundle signature is invalid"
- ));
+ )));
+ }
+
+ #[test]
+ fn matching_text_without_a_typed_error_is_not_trusted() {
+ let error = anyhow!("burn bundle parent hash is invalid");
+
+ assert!(inbound_error_counts_as_misbehavior(&error));
+ assert!(!is_possible_fork_error(&error));
}
}
diff --git a/src/adapters/p2p/process.rs b/src/adapters/p2p/process.rs
@@ -1,6 +1,6 @@
use std::net::SocketAddr;
-use anyhow::{Result, anyhow};
+use anyhow::Result;
use sha2::{Digest, Sha256};
use tokio::net::tcp::OwnedWriteHalf;
@@ -360,9 +360,7 @@ async fn process_transactions(
network,
known_peer,
remote_addr,
- first_error
- .map(|error| Err(anyhow!(format!("{error:#}"))))
- .unwrap_or(Ok(())),
+ first_error.map(Err).unwrap_or(Ok(())),
)
.await;
network.forward_outbox().await;
@@ -388,9 +386,7 @@ async fn process_burn_bundles(
network,
known_peer,
remote_addr,
- first_error
- .map(|error| Err(anyhow!(format!("{error:#}"))))
- .unwrap_or(Ok(())),
+ first_error.map(Err).unwrap_or(Ok(())),
)
.await;
network.forward_outbox().await;
@@ -430,7 +426,7 @@ async fn record_inbound_result(
Err(error) => {
let message = format!("{error:#}");
let mut peers = network.inner.peers.lock().await;
- if super::inbound_error_counts_as_misbehavior(&message) {
+ if super::inbound_error_counts_as_misbehavior(&error) {
if known_peer.is_some() {
peers.record_misbehavior(&peer, message.clone());
} else {
diff --git a/src/app/receive.rs b/src/app/receive.rs
@@ -1,8 +1,8 @@
use anyhow::Result;
use crate::domain::{
- Block, BurnBundle, ChainSnapshot, Ledger, MINE_ANCHOR_LIMIT_REACHED, Transaction,
- TransactionSubmitOutcome,
+ Block, BurnBundle, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome,
+ ValidationError, error_has_validation,
};
use super::{GossipEnvelope, IMPORT_REBROADCAST_LIMIT, NodeCore};
@@ -18,7 +18,9 @@ impl NodeCore {
Ok(outcome) => outcome,
Err(error)
if matches!(&tx, Transaction::Mine { .. })
- && error.to_string() == MINE_ANCHOR_LIMIT_REACHED =>
+ && error_has_validation(&error, |kind| {
+ kind == ValidationError::MineAnchorLimitReached
+ }) =>
{
return Ok(());
}
diff --git a/src/domain.rs b/src/domain.rs
@@ -6,6 +6,7 @@ mod adversarial_tests;
mod block;
#[cfg(test)]
mod consolidation_tests;
+mod error;
mod fork;
mod genesis;
mod hex;
@@ -38,6 +39,7 @@ use block::LeaderProofPayload;
pub use block::{
Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock,
};
+pub(crate) use error::{ValidationError, error_has_validation};
use fork::{FinalityCheckpoint, LeaderScore};
pub(crate) use genesis::genesis_allocation_outpoint;
pub use hex::hex_hash;
@@ -52,7 +54,6 @@ use ledger_ops::{
recovery_vdf_seed_for_child, validate_genesis_burn_transaction, vdf_content_commitment,
vdf_seed_for_child,
};
-pub(crate) use ledger_pending::MINE_ANCHOR_LIMIT_REACHED;
pub use ledger_state::Ledger;
use ledger_state::unix_now_ms;
pub(crate) use mine_policy::{MINE_RETARGET_WINDOW_BLOCKS, retarget_mine_difficulty_bits};
diff --git a/src/domain/error.rs b/src/domain/error.rs
@@ -0,0 +1,104 @@
+use std::{error::Error, fmt};
+
+/// Validation failures whose identity drives behavior outside the domain layer.
+///
+/// Keep ordinary validation messages as `anyhow` errors until a caller needs to
+/// branch on them; variants here are a control-flow contract, not an exhaustive
+/// catalog of every validation failure.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ValidationError {
+ BlockConflictsWithLocalChain { height: u64 },
+ UnexpectedBlockHeight { expected: u64, actual: u64 },
+ BlockDoesNotExtendLocalTip,
+ BlockBeforeFinalizerRankSlot { rank: u32, min_timestamp: u64 },
+ BlockTimestampTooFarInFuture,
+ BurnBundleParentMismatch,
+ BurnAnchorOutsidePendingWindow,
+ MineAnchorNotOnChain,
+ MineAnchorLimitReached,
+}
+
+impl ValidationError {
+ pub(crate) fn requests_fork_recovery(self) -> bool {
+ matches!(
+ self,
+ Self::BlockConflictsWithLocalChain { .. }
+ | Self::UnexpectedBlockHeight { .. }
+ | Self::BlockDoesNotExtendLocalTip
+ )
+ }
+
+ pub(crate) fn is_fork_relative(self) -> bool {
+ matches!(
+ self,
+ Self::BlockConflictsWithLocalChain { .. }
+ | Self::UnexpectedBlockHeight { .. }
+ | Self::BlockDoesNotExtendLocalTip
+ | Self::BurnBundleParentMismatch
+ | Self::BurnAnchorOutsidePendingWindow
+ | Self::MineAnchorNotOnChain
+ )
+ }
+
+ pub(crate) fn is_temporal(self) -> bool {
+ matches!(
+ self,
+ Self::BlockBeforeFinalizerRankSlot { .. } | Self::BlockTimestampTooFarInFuture
+ )
+ }
+}
+
+impl fmt::Display for ValidationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::BlockConflictsWithLocalChain { height } => {
+ write!(
+ formatter,
+ "block at height {height} conflicts with local chain"
+ )
+ }
+ Self::UnexpectedBlockHeight { expected, actual } => {
+ write!(formatter, "expected block height {expected}, got {actual}")
+ }
+ Self::BlockDoesNotExtendLocalTip => {
+ formatter.write_str("block does not extend local tip")
+ }
+ Self::BlockBeforeFinalizerRankSlot {
+ rank,
+ min_timestamp,
+ } => write!(
+ formatter,
+ "block timestamp is before finalizer rank {rank} time slot {min_timestamp}"
+ ),
+ Self::BlockTimestampTooFarInFuture => {
+ formatter.write_str("block timestamp is too far in the future")
+ }
+ Self::BurnBundleParentMismatch => {
+ formatter.write_str("burn bundle parent hash is invalid")
+ }
+ Self::BurnAnchorOutsidePendingWindow => formatter.write_str(
+ "burn transaction anchor is not valid for either of the next two block heights",
+ ),
+ Self::MineAnchorNotOnChain => {
+ formatter.write_str("mine transaction anchor is not on this chain")
+ }
+ Self::MineAnchorLimitReached => {
+ formatter.write_str("mine transaction anchor limit reached")
+ }
+ }
+ }
+}
+
+impl Error for ValidationError {}
+
+pub(crate) fn error_has_validation(
+ error: &anyhow::Error,
+ predicate: impl Fn(ValidationError) -> bool,
+) -> bool {
+ error.chain().any(|cause| {
+ cause
+ .downcast_ref::<ValidationError>()
+ .copied()
+ .is_some_and(&predicate)
+ })
+}
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -226,21 +226,22 @@ impl Ledger {
if existing.hash == block.hash {
return Ok(false);
}
- bail!(
- "block at height {} conflicts with local chain",
- block.height
- );
+ return Err(super::ValidationError::BlockConflictsWithLocalChain {
+ height: block.height,
+ }
+ .into());
}
let expected_height = self.tip().height + 1;
if block.height != expected_height {
- bail!(
- "expected block height {expected_height}, got {}",
- block.height
- );
+ return Err(super::ValidationError::UnexpectedBlockHeight {
+ expected: expected_height,
+ actual: block.height,
+ }
+ .into());
}
if block.prev_hash != self.tip().hash {
- bail!("block does not extend local tip");
+ return Err(super::ValidationError::BlockDoesNotExtendLocalTip.into());
}
if block.compute_hash() != block.hash {
bail!("block hash is invalid");
@@ -259,10 +260,11 @@ impl Ledger {
if block.finalizer_mode == FinalizerMode::Ticket {
let min_timestamp = ticket_block_min_timestamp(self.tip(), block.finalizer_rank)?;
if block.timestamp_ms < min_timestamp {
- bail!(
- "block timestamp is before finalizer rank {} time slot {min_timestamp}",
- block.finalizer_rank
- );
+ return Err(super::ValidationError::BlockBeforeFinalizerRankSlot {
+ rank: block.finalizer_rank,
+ min_timestamp,
+ }
+ .into());
}
}
let median_time_past = self.median_time_past();
@@ -271,7 +273,7 @@ impl Ledger {
}
let max_future_timestamp = now_ms.saturating_add(MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS);
if block.timestamp_ms > max_future_timestamp {
- bail!("block timestamp is too far in the future");
+ return Err(super::ValidationError::BlockTimestampTooFarInFuture.into());
}
if block.transactions.len() > self.launch_profile.max_block_transactions {
bail!("block has too many transactions");
diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs
@@ -24,8 +24,6 @@ use super::{
MAX_PENDING_TRANSACTIONS, MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput,
};
-pub(crate) const MINE_ANCHOR_LIMIT_REACHED: &str = "mine transaction anchor limit reached";
-
impl Ledger {
pub(super) fn valid_pending_transactions(&self) -> Vec<Transaction> {
let mut utxos = self.utxos.clone();
@@ -353,7 +351,7 @@ impl Ledger {
.count(),
);
if known_count >= MINE_ACTIONS_PER_ANCHOR_LIMIT {
- bail!(MINE_ANCHOR_LIMIT_REACHED);
+ return Err(super::ValidationError::MineAnchorLimitReached.into());
}
}
Ok(())
@@ -456,7 +454,7 @@ impl Ledger {
.chain
.iter()
.find(|block| block.hash == *anchor)
- .context("mine transaction anchor is not on this chain")?;
+ .ok_or(super::ValidationError::MineAnchorNotOnChain)?;
let anchor_age = self.tip().height.saturating_sub(anchor_block.height);
if anchor_age > MINE_MAX_ANCHOR_AGE_BLOCKS {
bail!("mine transaction anchor is too old");
@@ -527,7 +525,7 @@ impl Ledger {
return Ok(());
}
- bail!("burn transaction anchor is not valid for either of the next two block heights")
+ Err(super::ValidationError::BurnAnchorOutsidePendingWindow.into())
}
pub(crate) fn transaction_is_eligible_for_next_block(&self, transaction: &Transaction) -> bool {
diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs
@@ -431,7 +431,7 @@ impl Ledger {
bail!("burn bundle height is invalid");
}
if bundle.prev_hash != expected_prev_hash {
- bail!("burn bundle parent hash is invalid");
+ return Err(super::ValidationError::BurnBundleParentMismatch.into());
}
if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE {
bail!("burn bundle slot is invalid");