iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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:
Msrc/adapters/p2p.rs | 2++
Asrc/adapters/p2p/error.rs | 18++++++++++++++++++
Msrc/adapters/p2p/fetch.rs | 2+-
Msrc/adapters/p2p/peer_addr.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/adapters/p2p/process.rs | 12++++--------
Msrc/app/receive.rs | 8+++++---
Msrc/domain.rs | 3++-
Asrc/domain/error.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_apply.rs | 30++++++++++++++++--------------
Msrc/domain/ledger_pending.rs | 8+++-----
Msrc/domain/ledger_reveal.rs | 2+-
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");