From f57ace94b2a44fd192efbd06cf388f216823f634 Mon Sep 17 00:00:00 2001 From: hvanz Date: Mon, 9 Feb 2026 21:09:42 -0800 Subject: [PATCH 1/6] feat(engine-byzantine): add crate for simulating Byzantine faults Introduces a network proxy actor that intercepts outgoing consensus messages to equivocate or drop votes/proposals, and a middleware that ignores voting locks (amnesia attack). Attacks are configurable via TOML triggers (always, random, at specific heights/rounds, or ranges). --- code/Cargo.lock | 18 ++ code/Cargo.toml | 2 + code/crates/engine-byzantine/Cargo.toml | 29 ++ code/crates/engine-byzantine/src/config.rs | 219 +++++++++++++++ code/crates/engine-byzantine/src/lib.rs | 21 ++ .../crates/engine-byzantine/src/middleware.rs | 180 ++++++++++++ code/crates/engine-byzantine/src/proxy.rs | 262 ++++++++++++++++++ 7 files changed, 731 insertions(+) create mode 100644 code/crates/engine-byzantine/Cargo.toml create mode 100644 code/crates/engine-byzantine/src/config.rs create mode 100644 code/crates/engine-byzantine/src/lib.rs create mode 100644 code/crates/engine-byzantine/src/middleware.rs create mode 100644 code/crates/engine-byzantine/src/proxy.rs diff --git a/code/Cargo.lock b/code/Cargo.lock index 68110b87b..93195d490 100644 --- a/code/Cargo.lock +++ b/code/Cargo.lock @@ -399,6 +399,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "arc-malachitebft-engine-byzantine" +version = "0.7.0-pre" +dependencies = [ + "arc-malachitebft-core-consensus", + "arc-malachitebft-core-types", + "arc-malachitebft-engine", + "arc-malachitebft-signing", + "arc-malachitebft-test", + "async-trait", + "eyre", + "ractor", + "rand 0.8.5", + "serde", + "toml", + "tracing", +] + [[package]] name = "arc-malachitebft-example-channel" version = "0.7.0-pre" diff --git a/code/Cargo.toml b/code/Cargo.toml index baac8f63f..0d790604a 100644 --- a/code/Cargo.toml +++ b/code/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/core-types", "crates/core-votekeeper", "crates/engine", + "crates/engine-byzantine", "crates/metrics", "crates/network", "crates/peer", @@ -73,6 +74,7 @@ doc_overindented_list_items = "allow" [workspace.dependencies] malachitebft-engine = { version = "0.7.0-pre", package = "arc-malachitebft-engine", path = "crates/engine" } +malachitebft-engine-byzantine = { version = "0.7.0-pre", package = "arc-malachitebft-engine-byzantine", path = "crates/engine-byzantine" } malachitebft-app = { version = "0.7.0-pre", package = "arc-malachitebft-app", path = "crates/app" } malachitebft-app-channel = { version = "0.7.0-pre", package = "arc-malachitebft-app-channel", path = "crates/app-channel" } malachitebft-codec = { version = "0.7.0-pre", package = "arc-malachitebft-codec", path = "crates/codec" } diff --git a/code/crates/engine-byzantine/Cargo.toml b/code/crates/engine-byzantine/Cargo.toml new file mode 100644 index 000000000..055ecf327 --- /dev/null +++ b/code/crates/engine-byzantine/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "arc-malachitebft-engine-byzantine" +description = "Byzantine behavior support for the Malachite BFT consensus engine" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +malachitebft-engine.workspace = true +malachitebft-core-types.workspace = true +malachitebft-core-consensus.workspace = true +malachitebft-signing.workspace = true +malachitebft-test.workspace = true + +async-trait.workspace = true +eyre.workspace = true +ractor.workspace = true +rand.workspace = true +serde = { workspace = true, features = ["derive"] } +tracing.workspace = true + +[dev-dependencies] +toml.workspace = true diff --git a/code/crates/engine-byzantine/src/config.rs b/code/crates/engine-byzantine/src/config.rs new file mode 100644 index 000000000..132707ebd --- /dev/null +++ b/code/crates/engine-byzantine/src/config.rs @@ -0,0 +1,219 @@ +//! Byzantine behavior configuration and trigger types. +//! +//! [`ByzantineConfig`] is the top-level configuration for a Byzantine node, +//! specifying which attacks to perform and when they fire. +//! +//! [`Trigger`] specifies the timing of an attack: always, randomly, at +//! specific heights/rounds, or within a height range. + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use serde::{Deserialize, Serialize}; + +use malachitebft_core_types::{Height, Round}; + +/// Top-level Byzantine behavior configuration. +/// +/// This struct is TOML-serializable and can be embedded in the node's +/// `config.toml` under a `[byzantine]` section. +/// +/// # Example +/// +/// ```toml +/// [byzantine] +/// equivocate_votes = { mode = "random", probability = 0.3 } +/// drop_proposals = { mode = "at_heights", heights = [10, 20, 30] } +/// ignore_locks = true +/// seed = 42 +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ByzantineConfig { + /// When to send conflicting votes (equivocation). + #[serde(default)] + pub equivocate_votes: Option, + + /// When to send conflicting proposals (equivocation). + #[serde(default)] + pub equivocate_proposals: Option, + + /// When to drop outgoing votes (silence / censorship). + #[serde(default)] + pub drop_votes: Option, + + /// When to drop outgoing proposals (silence / censorship). + #[serde(default)] + pub drop_proposals: Option, + + /// Whether to ignore voting locks (amnesia attack). + /// + /// When `true`, the node will vote for the proposed value even when + /// locked on a different value. + #[serde(default)] + pub ignore_locks: bool, + + /// Random seed for reproducible random attacks. + /// + /// If set, the random number generator is seeded with this value, + /// making random triggers deterministic across runs. + #[serde(default)] + pub seed: Option, +} + +impl ByzantineConfig { + /// Returns `true` if any Byzantine behavior is configured. + pub fn is_active(&self) -> bool { + self.equivocate_votes.is_some() + || self.equivocate_proposals.is_some() + || self.drop_votes.is_some() + || self.drop_proposals.is_some() + || self.ignore_locks + } +} + +/// Specifies **when** a Byzantine attack fires. +/// +/// Triggers support both controlled (deterministic) and random modes, +/// and are fully TOML-serializable via the `mode` tag. +/// +/// # TOML examples +/// +/// ```toml +/// # Always fire +/// trigger = { mode = "always" } +/// +/// # Fire randomly 20% of the time +/// trigger = { mode = "random", probability = 0.2 } +/// +/// # Fire at specific heights +/// trigger = { mode = "at_heights", heights = [10, 20, 30] } +/// +/// # Fire at specific rounds (within any height) +/// trigger = { mode = "at_rounds", rounds = [2, 3] } +/// +/// # Fire within a height range (inclusive) +/// trigger = { mode = "height_range", from = 50, to = 100 } +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "mode")] +pub enum Trigger { + /// Fire on every message. + #[serde(rename = "always")] + Always, + + /// Fire randomly with a given probability (0.0 to 1.0). + #[serde(rename = "random")] + Random { + /// Probability of firing, between 0.0 (never) and 1.0 (always). + probability: f64, + }, + + /// Fire at specific heights. + #[serde(rename = "at_heights")] + AtHeights { + /// The set of heights at which the attack fires. + heights: Vec, + }, + + /// Fire at specific rounds (within any height). + #[serde(rename = "at_rounds")] + AtRounds { + /// The set of rounds at which the attack fires. + rounds: Vec, + }, + + /// Fire within a height range `[from, to]` (inclusive). + #[serde(rename = "height_range")] + HeightRange { + /// Start of the height range (inclusive). + from: u64, + /// End of the height range (inclusive). + to: u64, + }, +} + +impl Trigger { + /// Evaluate whether this trigger fires for the given height and round. + pub fn fires(&self, height: H, round: Round, rng: &mut StdRng) -> bool { + let h = height.as_u64(); + let r = round.as_i64(); + + match self { + Trigger::Always => true, + Trigger::Random { probability } => rng.gen::() < *probability, + Trigger::AtHeights { heights } => heights.contains(&h), + Trigger::AtRounds { rounds } => rounds.contains(&r), + Trigger::HeightRange { from, to } => h >= *from && h <= *to, + } + } +} + +/// Creates a [`StdRng`] from an optional seed, or from entropy if `None`. +pub fn make_rng(seed: Option) -> StdRng { + match seed { + Some(s) => StdRng::seed_from_u64(s), + None => StdRng::from_entropy(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_toml_roundtrip() { + let config = ByzantineConfig { + equivocate_votes: Some(Trigger::Random { probability: 0.3 }), + equivocate_proposals: None, + drop_votes: Some(Trigger::AtHeights { + heights: vec![10, 20, 30], + }), + drop_proposals: Some(Trigger::HeightRange { from: 50, to: 100 }), + ignore_locks: true, + seed: Some(42), + }; + + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: ByzantineConfig = toml::from_str(&toml_str).unwrap(); + + assert!(parsed.equivocate_votes.is_some()); + assert!(parsed.drop_votes.is_some()); + assert!(parsed.drop_proposals.is_some()); + assert!(parsed.ignore_locks); + assert_eq!(parsed.seed, Some(42)); + } + + #[test] + fn test_empty_config_is_inactive() { + let config = ByzantineConfig::default(); + assert!(!config.is_active()); + } + + #[test] + fn test_trigger_always() { + let trigger = Trigger::Always; + let mut rng = make_rng(Some(0)); + assert!(trigger.fires(malachitebft_test::Height::new(1), Round::new(0), &mut rng)); + } + + #[test] + fn test_trigger_at_heights() { + let trigger = Trigger::AtHeights { + heights: vec![5, 10], + }; + let mut rng = make_rng(Some(0)); + assert!(!trigger.fires(malachitebft_test::Height::new(1), Round::new(0), &mut rng)); + assert!(trigger.fires(malachitebft_test::Height::new(5), Round::new(0), &mut rng)); + assert!(trigger.fires(malachitebft_test::Height::new(10), Round::new(0), &mut rng)); + } + + #[test] + fn test_trigger_height_range() { + let trigger = Trigger::HeightRange { from: 5, to: 10 }; + let mut rng = make_rng(Some(0)); + assert!(!trigger.fires(malachitebft_test::Height::new(4), Round::new(0), &mut rng)); + assert!(trigger.fires(malachitebft_test::Height::new(5), Round::new(0), &mut rng)); + assert!(trigger.fires(malachitebft_test::Height::new(7), Round::new(0), &mut rng)); + assert!(trigger.fires(malachitebft_test::Height::new(10), Round::new(0), &mut rng)); + assert!(!trigger.fires(malachitebft_test::Height::new(11), Round::new(0), &mut rng)); + } +} diff --git a/code/crates/engine-byzantine/src/lib.rs b/code/crates/engine-byzantine/src/lib.rs new file mode 100644 index 000000000..a2a395bd6 --- /dev/null +++ b/code/crates/engine-byzantine/src/lib.rs @@ -0,0 +1,21 @@ +//! Byzantine behavior support for the Malachite BFT consensus engine. +//! +//! This crate provides a [`ByzantineNetworkProxy`] actor that sits between the +//! consensus actor and the real network actor, intercepting outgoing messages to +//! simulate Byzantine faults such as equivocation, vote dropping, and more. +//! +//! It also provides a [`ByzantineMiddleware`] that can override vote construction +//! to simulate amnesia attacks (ignoring voting locks). +//! +//! # Configuration +//! +//! Byzantine behavior is configured via [`ByzantineConfig`], which is used to +//! configure the [`ByzantineNetworkProxy`] and [`ByzantineMiddleware`]. + +pub mod middleware; +pub mod proxy; +pub mod config; + +pub use middleware::ByzantineMiddleware; +pub use proxy::ByzantineNetworkProxy; +pub use config::{ByzantineConfig, Trigger}; diff --git a/code/crates/engine-byzantine/src/middleware.rs b/code/crates/engine-byzantine/src/middleware.rs new file mode 100644 index 000000000..d6674337e --- /dev/null +++ b/code/crates/engine-byzantine/src/middleware.rs @@ -0,0 +1,180 @@ +//! Byzantine middleware for the test app's [`Middleware`] trait. +//! +//! [`ByzantineMiddleware`] wraps an inner middleware and overrides vote +//! construction to simulate amnesia attacks (ignoring voting locks). +//! +//! When `ignore_locks` is enabled, the middleware tracks proposed values via +//! the [`get_validity`](Middleware::get_validity) callback and overrides nil +//! prevotes to vote for the most recently proposed value in the current round. + +use std::fmt; +use std::sync::{Arc, Mutex}; + +use malachitebft_core_consensus::{LocallyProposedValue, ProposedValue}; +use malachitebft_core_types::{CommitCertificate, LinearTimeouts, NilOrVal, Round, Validity}; +use malachitebft_test::middleware::Middleware; +use malachitebft_test::{ + Address, Genesis, Height, Proposal, TestContext, ValidatorSet, Value, ValueId, Vote, +}; + +/// A middleware that simulates Byzantine amnesia by ignoring voting locks. +/// +/// When `ignore_locks` is `true`, this middleware tracks the most recently +/// proposed value via [`get_validity`] and overrides nil prevotes to vote for +/// that value instead. All other middleware methods delegate to the inner +/// middleware. +/// +/// # Usage +/// +/// ```rust,ignore +/// let inner = Arc::new(DefaultMiddleware); +/// let byzantine = ByzantineMiddleware::new(true, inner); +/// let ctx = TestContext::with_middleware(Arc::new(byzantine)); +/// ``` +pub struct ByzantineMiddleware { + /// Whether to ignore voting locks (amnesia attack). + pub ignore_locks: bool, + /// The inner middleware to delegate to for non-Byzantine behavior. + pub inner: Arc, + /// Tracks the most recently proposed value ID for the current round, + /// captured via `get_validity`. + current_proposed_value: Mutex>, +} + +impl ByzantineMiddleware { + /// Create a new `ByzantineMiddleware`. + pub fn new(ignore_locks: bool, inner: Arc) -> Self { + Self { + ignore_locks, + inner, + current_proposed_value: Mutex::new(None), + } + } +} + +impl fmt::Debug for ByzantineMiddleware { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ByzantineMiddleware") + .field("ignore_locks", &self.ignore_locks) + .field("inner", &self.inner) + .finish() + } +} + +impl Middleware for ByzantineMiddleware { + fn get_validator_set( + &self, + ctx: &TestContext, + current_height: Height, + height: Height, + genesis: &Genesis, + ) -> Option { + self.inner + .get_validator_set(ctx, current_height, height, genesis) + } + + fn get_timeouts( + &self, + ctx: &TestContext, + current_height: Height, + height: Height, + ) -> Option { + self.inner.get_timeouts(ctx, current_height, height) + } + + fn new_proposal( + &self, + ctx: &TestContext, + height: Height, + round: Round, + value: Value, + pol_round: Round, + address: Address, + ) -> Proposal { + self.inner + .new_proposal(ctx, height, round, value, pol_round, address) + } + + fn new_prevote( + &self, + ctx: &TestContext, + height: Height, + round: Round, + value_id: NilOrVal, + address: Address, + ) -> Vote { + if self.ignore_locks { + if let NilOrVal::Nil = &value_id { + // The state machine decided nil (likely due to lock on a different value). + // Override with the most recently proposed value if we have one. + let stored = self.current_proposed_value.lock().unwrap().take(); + if let Some(vid) = stored { + tracing::warn!( + %height, %round, + "BYZANTINE AMNESIA: Overriding nil prevote with value (ignoring lock)" + ); + return self + .inner + .new_prevote(ctx, height, round, NilOrVal::Val(vid), address); + } + } + } + + self.inner + .new_prevote(ctx, height, round, value_id, address) + } + + fn new_precommit( + &self, + ctx: &TestContext, + height: Height, + round: Round, + value_id: NilOrVal, + address: Address, + ) -> Vote { + self.inner + .new_precommit(ctx, height, round, value_id, address) + } + + fn on_propose_value( + &self, + ctx: &TestContext, + proposed_value: &mut LocallyProposedValue, + reproposal: bool, + ) { + // Track the proposed value for potential amnesia override. + if self.ignore_locks { + let vid = proposed_value.value.id(); + *self.current_proposed_value.lock().unwrap() = Some(vid); + } + + self.inner.on_propose_value(ctx, proposed_value, reproposal) + } + + fn get_validity( + &self, + ctx: &TestContext, + height: Height, + round: Round, + value: &Value, + ) -> Validity { + // Track the proposed value for potential amnesia override. + // This is called when we receive a proposed value from another node, + // giving us a chance to capture the value ID before `new_prevote`. + if self.ignore_locks { + let vid = value.id(); + *self.current_proposed_value.lock().unwrap() = Some(vid); + } + + self.inner.get_validity(ctx, height, round, value) + } + + fn on_commit( + &self, + ctx: &TestContext, + certificate: &CommitCertificate, + proposal: &ProposedValue, + ) -> Result<(), eyre::Report> { + self.inner.on_commit(ctx, certificate, proposal) + } +} diff --git a/code/crates/engine-byzantine/src/proxy.rs b/code/crates/engine-byzantine/src/proxy.rs new file mode 100644 index 000000000..6c0c2fea3 --- /dev/null +++ b/code/crates/engine-byzantine/src/proxy.rs @@ -0,0 +1,262 @@ +//! Byzantine network proxy actor. +//! +//! [`ByzantineNetworkProxy`] is a ractor actor that sits between the consensus +//! actor and the real network actor. It intercepts outgoing +//! [`NetworkMsg::PublishConsensusMsg`] messages and can: +//! +//! - **Drop** messages (simulating silence / censorship) +//! - **Duplicate** messages with conflicting content (simulating equivocation) +//! - **Forward** messages unchanged (honest behavior) +//! +//! All other message types are forwarded transparently to the real network. + +use async_trait::async_trait; +use eyre::eyre; +use ractor::{Actor, ActorProcessingErr, ActorRef}; +use rand::rngs::StdRng; +use tracing::{debug, warn}; + +use malachitebft_core_consensus::SignedConsensusMsg; +use malachitebft_core_types::{Context, NilOrVal, Proposal, Vote, VoteType}; +use malachitebft_engine::network::{Msg as NetworkMsg, NetworkRef}; +use malachitebft_signing::SigningProvider; + +use crate::config::{make_rng, ByzantineConfig}; + +/// A ractor actor that proxies [`NetworkMsg`] between consensus and the real +/// network, applying Byzantine behavior according to a [`ByzantineConfig`]. +/// +/// Because it handles the same `Msg` message type as the `Network` actor, +/// its `ActorRef` is a `NetworkRef` and can be used as a drop-in +/// replacement when constructing the consensus actor. +pub struct ByzantineNetworkProxy { + config: ByzantineConfig, + real_network: NetworkRef, + signing_provider: Box>, + ctx: Ctx, + address: Ctx::Address, + span: tracing::Span, +} + +/// Internal mutable state for the proxy actor. +pub struct ProxyState { + rng: StdRng, +} + +impl ByzantineNetworkProxy { + /// Spawn the proxy actor and return its ref (which is a `NetworkRef`). + pub async fn spawn( + config: ByzantineConfig, + real_network: NetworkRef, + signing_provider: Box>, + ctx: Ctx, + address: Ctx::Address, + span: tracing::Span, + ) -> Result, eyre::Report> { + let seed = config.seed; + let proxy = Self { + config, + real_network, + signing_provider, + ctx, + address, + span, + }; + + let (actor_ref, _) = Actor::spawn(None, proxy, seed) + .await + .map_err(|e| eyre!("Failed to spawn ByzantineNetworkProxy: {e}"))?; + + Ok(actor_ref) + } +} + +#[async_trait] +impl Actor for ByzantineNetworkProxy { + type Msg = NetworkMsg; + type State = ProxyState; + type Arguments = Option; // seed + + async fn pre_start( + &self, + _myself: ActorRef, + seed: Self::Arguments, + ) -> Result { + Ok(ProxyState { + rng: make_rng(seed), + }) + } + + async fn handle( + &self, + _myself: ActorRef, + msg: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + let _enter = self.span.enter(); + + match msg { + NetworkMsg::PublishConsensusMsg(ref consensus_msg) => { + self.handle_consensus_msg(consensus_msg, state).await?; + } + // All other message types are forwarded transparently. + other => { + self.real_network + .cast(other) + .map_err(|e| format!("Failed to forward message to network: {e:?}"))?; + } + } + + Ok(()) + } +} + +impl ByzantineNetworkProxy { + async fn handle_consensus_msg( + &self, + msg: &SignedConsensusMsg, + state: &mut ProxyState, + ) -> Result<(), ActorProcessingErr> { + match msg { + SignedConsensusMsg::Vote(signed_vote) => { + let vote = &signed_vote.message; + let height = vote.height(); + let round = vote.round(); + + // Check drop trigger first + if let Some(ref trigger) = self.config.drop_votes { + if trigger.fires(height, round, &mut state.rng) { + warn!( + %height, %round, + vote_type = ?vote.vote_type(), + "BYZANTINE: Dropping vote" + ); + return Ok(()); + } + } + + // Check equivocation trigger + if let Some(ref trigger) = self.config.equivocate_votes { + if trigger.fires(height, round, &mut state.rng) { + warn!( + %height, %round, + vote_type = ?vote.vote_type(), + "BYZANTINE: Equivocating vote" + ); + + // Send the original vote + self.forward_consensus_msg(msg)?; + + // Construct and send a conflicting vote + if let Err(e) = self.send_conflicting_vote(vote).await { + warn!("Failed to send conflicting vote: {e}"); + } + + return Ok(()); + } + } + + // Default: forward as-is + debug!(%height, %round, "Forwarding vote"); + self.forward_consensus_msg(msg)?; + } + + SignedConsensusMsg::Proposal(signed_proposal) => { + let proposal = &signed_proposal.message; + let height = proposal.height(); + let round = proposal.round(); + + // Check drop trigger first + if let Some(ref trigger) = self.config.drop_proposals { + if trigger.fires(height, round, &mut state.rng) { + warn!( + %height, %round, + "BYZANTINE: Dropping proposal" + ); + return Ok(()); + } + } + + // Check equivocation trigger + if let Some(ref trigger) = self.config.equivocate_proposals { + if trigger.fires(height, round, &mut state.rng) { + warn!( + %height, %round, + "BYZANTINE: Equivocating proposal (sending original only; \ + conflicting proposal construction requires application-level value)" + ); + // For proposals, equivocation requires constructing a different Value, + // which is application-specific. We send the original and log a warning. + // A future extension could accept a value factory. + } + } + + // Default: forward as-is + debug!(%height, %round, "Forwarding proposal"); + self.forward_consensus_msg(msg)?; + } + } + + Ok(()) + } + + /// Forward a consensus message to the real network. + fn forward_consensus_msg( + &self, + msg: &SignedConsensusMsg, + ) -> Result<(), ActorProcessingErr> { + self.real_network + .cast(NetworkMsg::PublishConsensusMsg(msg.clone())) + .map_err(|e| { + ActorProcessingErr::from(format!( + "Failed to forward consensus message to network: {e:?}" + )) + }) + } + + /// Construct a conflicting vote (flipping value <-> nil) and send it. + async fn send_conflicting_vote(&self, original: &Ctx::Vote) -> Result<(), eyre::Report> { + let height = original.height(); + let round = original.round(); + let vote_type = original.vote_type(); + + // Flip the value: if the original votes for a value, vote nil; if nil, we can't + // easily construct a conflicting value vote without knowing a valid value ID, + // so we just skip equivocation for nil votes. + let conflicting_value = match original.value() { + NilOrVal::Val(_) => NilOrVal::Nil, + NilOrVal::Nil => { + debug!( + %height, %round, + "Cannot equivocate a nil vote (no value to flip to), skipping" + ); + return Ok(()); + } + }; + + let conflicting_vote = match vote_type { + VoteType::Prevote => { + self.ctx + .new_prevote(height, round, conflicting_value, self.address.clone()) + } + VoteType::Precommit => { + self.ctx + .new_precommit(height, round, conflicting_value, self.address.clone()) + } + }; + + let signed = self + .signing_provider + .sign_vote(conflicting_vote) + .await + .map_err(|e| eyre!("Failed to sign conflicting vote: {e}"))?; + + self.real_network + .cast(NetworkMsg::PublishConsensusMsg(SignedConsensusMsg::Vote( + signed, + ))) + .map_err(|e| eyre!("Failed to send conflicting vote to network: {e:?}"))?; + + Ok(()) + } +} From 0e66b2819ba672ea99a97d82236eea243e35b8f5 Mon Sep 17 00:00:00 2001 From: hvanz Date: Mon, 9 Feb 2026 21:10:56 -0800 Subject: [PATCH 2/6] feat(test-app): integrate Byzantine engine Integrate the engine-byzantine crate into the test app's node startup to conditionally inject the network proxy and amnesia middleware based on the new [byzantine] config section. --- code/Cargo.lock | 3 +- code/crates/app-channel/src/lib.rs | 2 +- code/crates/test/app/Cargo.toml | 3 +- code/crates/test/app/src/config.rs | 8 +++ code/crates/test/app/src/node.rs | 96 +++++++++++++++++++++++++----- code/crates/test/tests/it/main.rs | 1 + 6 files changed, 93 insertions(+), 20 deletions(-) diff --git a/code/Cargo.lock b/code/Cargo.lock index 93195d490..463a57cf3 100644 --- a/code/Cargo.lock +++ b/code/Cargo.lock @@ -726,13 +726,12 @@ name = "arc-malachitebft-test-app" version = "0.7.0-pre" dependencies = [ "arc-malachitebft-app-channel", + "arc-malachitebft-engine-byzantine", "arc-malachitebft-proto", "arc-malachitebft-test", - "arc-malachitebft-test-cli", "arc-malachitebft-test-framework", "async-trait", "bytes", - "color-eyre", "config", "derive-where", "eyre", diff --git a/code/crates/app-channel/src/lib.rs b/code/crates/app-channel/src/lib.rs index f3df21f75..f8abaa5c8 100644 --- a/code/crates/app-channel/src/lib.rs +++ b/code/crates/app-channel/src/lib.rs @@ -14,7 +14,7 @@ pub use malachitebft_app as app; mod builder; mod connector; -mod spawn; +pub mod spawn; mod msgs; pub use msgs::{ diff --git a/code/crates/test/app/Cargo.toml b/code/crates/test/app/Cargo.toml index 41bd960ef..37dda3f73 100644 --- a/code/crates/test/app/Cargo.toml +++ b/code/crates/test/app/Cargo.toml @@ -10,7 +10,6 @@ publish = false [dependencies] async-trait.workspace = true bytes.workspace = true -color-eyre.workspace = true config.workspace = true derive-where.workspace = true eyre.workspace = true @@ -27,9 +26,9 @@ tokio.workspace = true tracing.workspace = true malachitebft-app-channel.workspace = true +malachitebft-engine-byzantine.workspace = true malachitebft-proto.workspace = true malachitebft-test.workspace = true -malachitebft-test-cli.workspace = true [dev-dependencies] malachitebft-test-framework.workspace = true diff --git a/code/crates/test/app/src/config.rs b/code/crates/test/app/src/config.rs index 35f801013..dd48e3054 100644 --- a/code/crates/test/app/src/config.rs +++ b/code/crates/test/app/src/config.rs @@ -3,6 +3,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use malachitebft_app_channel::app::config::NodeConfig; +use malachitebft_engine_byzantine::ByzantineConfig; pub use malachitebft_app_channel::app::config::{ ConsensusConfig, LogFormat, LogLevel, LoggingConfig, MetricsConfig, RuntimeConfig, TestConfig, @@ -32,6 +33,13 @@ pub struct Config { /// Test configuration pub test: TestConfig, + + /// Byzantine behavior configuration (optional). + /// + /// When present and active, the node will exhibit configurable Byzantine + /// faults such as vote equivocation, proposal dropping, or amnesia attacks. + #[serde(default)] + pub byzantine: Option, } impl NodeConfig for Config { diff --git a/code/crates/test/app/src/node.rs b/code/crates/test/app/src/node.rs index 0cc2a7534..01a68c70c 100644 --- a/code/crates/test/app/src/node.rs +++ b/code/crates/test/app/src/node.rs @@ -10,12 +10,14 @@ use tracing::Instrument; use malachitebft_app_channel::app::config::*; use malachitebft_app_channel::app::events::{RxEvent, TxEvent}; +use malachitebft_app_channel::app::metrics::SharedRegistry; use malachitebft_app_channel::app::types::core::VotingPower; use malachitebft_app_channel::app::types::Keypair; use malachitebft_app_channel::{ ConsensusContext, EngineBuilder, EngineHandle, NetworkContext, NetworkIdentity, RequestContext, SyncContext, WalContext, }; +use malachitebft_engine_byzantine::{ByzantineMiddleware, ByzantineNetworkProxy}; use malachitebft_test::codec::json::JsonCodec; use malachitebft_test::codec::proto::ProtobufCodec; use malachitebft_test::node::{Node, NodeHandle}; @@ -134,12 +136,28 @@ impl Node for App { let span = tracing::error_span!("node", moniker = %config.moniker); let _guard = span.enter(); - let middleware = self - .middleware - .clone() - .unwrap_or_else(|| Arc::new(DefaultMiddleware)); + let byzantine_cfg = config.byzantine.clone(); - let ctx = TestContext::with_middleware(middleware); + // Wrap middleware with ByzantineMiddleware if amnesia is configured + let middleware: Arc = { + let inner = self + .middleware + .clone() + .unwrap_or_else(|| Arc::new(DefaultMiddleware)); + + if let Some(ref byz) = byzantine_cfg { + if byz.ignore_locks { + tracing::warn!("BYZANTINE: Amnesia attack enabled (ignoring voting locks)"); + Arc::new(ByzantineMiddleware::new(true, inner)) + } else { + inner + } + } else { + inner + } + }; + + let ctx = TestContext::with_middleware(middleware.clone()); let public_key = self.get_public_key(&self.private_key); let address = self.get_address(&public_key); @@ -149,18 +167,65 @@ impl Node for App { let identity = NetworkIdentity::new(config.moniker.clone(), keypair, Some(address.to_string())); - let (mut channels, engine_handle) = EngineBuilder::new(ctx.clone(), config.clone()) - .with_default_wal(WalContext::new(wal_path, ProtobufCodec)) - .with_default_network(NetworkContext::new(identity, JsonCodec)) - .with_default_consensus(ConsensusContext::new( + // Build the engine, conditionally injecting the Byzantine proxy + let builder = EngineBuilder::new(ctx.clone(), config.clone()) + .with_default_wal(WalContext::new(wal_path, ProtobufCodec)); + + let is_byzantine = byzantine_cfg.as_ref().is_some_and(|c| c.is_active()); + + let (mut channels, engine_handle) = if is_byzantine { + let byz_cfg = byzantine_cfg.unwrap(); // safe: is_active() was true + + tracing::warn!( + ?byz_cfg, + "BYZANTINE: Starting node with Byzantine behavior enabled" + ); + + // Spawn the real network actor manually + let registry = SharedRegistry::global().with_moniker(config.moniker.clone()); + let (real_network, tx_network) = malachitebft_app_channel::spawn::spawn_network_actor( + identity, + config.consensus(), + config.value_sync(), + ®istry, + JsonCodec, + ) + .await?; + + // Spawn the proxy in front of the real network + let proxy_ref = ByzantineNetworkProxy::spawn( + byz_cfg, + real_network, + Box::new(self.get_signing_provider(self.private_key.clone())), + ctx.clone(), address, - self.get_signing_provider(self.private_key.clone()), - )) - .with_default_sync(SyncContext::new(JsonCodec)) - .with_default_request(RequestContext::new(100)) - .build() + span.clone(), + ) .await?; + builder + .with_custom_network(proxy_ref, tx_network) + .with_default_consensus(ConsensusContext::new( + address, + self.get_signing_provider(self.private_key.clone()), + )) + .with_default_sync(SyncContext::new(JsonCodec)) + .with_default_request(RequestContext::new(100)) + .build() + .await? + } else { + builder + .with_default_network(NetworkContext::new(identity, JsonCodec)) + .with_default_consensus(ConsensusContext::new( + address, + self.get_signing_provider(self.private_key.clone()), + )) + .with_default_sync(SyncContext::new(JsonCodec)) + .with_default_request(RequestContext::new(100)) + .build() + .await? + }; + drop(_guard); let db_path = self.get_home_dir().join("db"); @@ -177,7 +242,7 @@ impl Node for App { start_height, store, self.get_signing_provider(self.private_key.clone()), - self.middleware.clone(), + Some(middleware), ); let tx_event = channels.events.clone(); @@ -301,5 +366,6 @@ fn make_config(index: usize, total: usize, settings: MakeConfigSettings) -> Conf value_sync: ValueSyncConfig::default(), logging: LoggingConfig::default(), test: TestConfig::default(), + byzantine: None, } } diff --git a/code/crates/test/tests/it/main.rs b/code/crates/test/tests/it/main.rs index df7ba715e..58b5a8f31 100644 --- a/code/crates/test/tests/it/main.rs +++ b/code/crates/test/tests/it/main.rs @@ -223,6 +223,7 @@ impl TestRunner { }, runtime: RuntimeConfig::single_threaded(), test: TestConfig::default(), + byzantine: None, } } } From 0b2e9e69674a3cfa729ee6dfea89bc75696ed4dd Mon Sep 17 00:00:00 2001 From: hvanz Date: Mon, 9 Feb 2026 21:19:15 -0800 Subject: [PATCH 3/6] feat(engine-byzantine): implement proposal equivocation Previously proposal equivocation only logged a warning and forwarded the original. Now it constructs and sends a conflicting proposal via an optional ConflictingValueFn callback. The test app provides a factory that creates a conflicting value by incrementing the original's u64. --- code/crates/engine-byzantine/src/lib.rs | 6 +- code/crates/engine-byzantine/src/proxy.rs | 97 +++++++++++++++++++++-- code/crates/test/app/src/node.rs | 11 ++- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/code/crates/engine-byzantine/src/lib.rs b/code/crates/engine-byzantine/src/lib.rs index a2a395bd6..c6da743a5 100644 --- a/code/crates/engine-byzantine/src/lib.rs +++ b/code/crates/engine-byzantine/src/lib.rs @@ -12,10 +12,10 @@ //! Byzantine behavior is configured via [`ByzantineConfig`], which is used to //! configure the [`ByzantineNetworkProxy`] and [`ByzantineMiddleware`]. +pub mod config; pub mod middleware; pub mod proxy; -pub mod config; -pub use middleware::ByzantineMiddleware; -pub use proxy::ByzantineNetworkProxy; pub use config::{ByzantineConfig, Trigger}; +pub use middleware::ByzantineMiddleware; +pub use proxy::{ByzantineNetworkProxy, ConflictingValueFn}; diff --git a/code/crates/engine-byzantine/src/proxy.rs b/code/crates/engine-byzantine/src/proxy.rs index 6c0c2fea3..3a489da40 100644 --- a/code/crates/engine-byzantine/src/proxy.rs +++ b/code/crates/engine-byzantine/src/proxy.rs @@ -17,12 +17,19 @@ use rand::rngs::StdRng; use tracing::{debug, warn}; use malachitebft_core_consensus::SignedConsensusMsg; -use malachitebft_core_types::{Context, NilOrVal, Proposal, Vote, VoteType}; +use malachitebft_core_types::{Context, NilOrVal, Proposal, Round, Vote, VoteType}; use malachitebft_engine::network::{Msg as NetworkMsg, NetworkRef}; use malachitebft_signing::SigningProvider; use crate::config::{make_rng, ByzantineConfig}; +/// A function that creates a conflicting value from an original one. +/// +/// Used for proposal equivocation: the proxy sends the original proposal +/// and then a second proposal with the value returned by this function. +pub type ConflictingValueFn = + Box::Value) -> ::Value + Send + Sync>; + /// A ractor actor that proxies [`NetworkMsg`] between consensus and the real /// network, applying Byzantine behavior according to a [`ByzantineConfig`]. /// @@ -36,6 +43,9 @@ pub struct ByzantineNetworkProxy { ctx: Ctx, address: Ctx::Address, span: tracing::Span, + /// Optional factory for creating a conflicting value for proposal equivocation. + /// If `None`, proposal equivocation sends the original proposal with a flipped `pol_round`. + conflicting_value_fn: Option>, } /// Internal mutable state for the proxy actor. @@ -45,6 +55,11 @@ pub struct ProxyState { impl ByzantineNetworkProxy { /// Spawn the proxy actor and return its ref (which is a `NetworkRef`). + /// + /// The optional `conflicting_value_fn` is used for proposal equivocation: + /// given the original proposed value, it returns a different value to use + /// in the conflicting proposal. If `None`, proposal equivocation sends a + /// proposal with a flipped `pol_round` instead. pub async fn spawn( config: ByzantineConfig, real_network: NetworkRef, @@ -52,6 +67,7 @@ impl ByzantineNetworkProxy { ctx: Ctx, address: Ctx::Address, span: tracing::Span, + conflicting_value_fn: Option>, ) -> Result, eyre::Report> { let seed = config.seed; let proxy = Self { @@ -61,6 +77,7 @@ impl ByzantineNetworkProxy { ctx, address, span, + conflicting_value_fn, }; let (actor_ref, _) = Actor::spawn(None, proxy, seed) @@ -182,12 +199,18 @@ impl ByzantineNetworkProxy { if trigger.fires(height, round, &mut state.rng) { warn!( %height, %round, - "BYZANTINE: Equivocating proposal (sending original only; \ - conflicting proposal construction requires application-level value)" + "BYZANTINE: Equivocating proposal" ); - // For proposals, equivocation requires constructing a different Value, - // which is application-specific. We send the original and log a warning. - // A future extension could accept a value factory. + + // Send the original proposal first + self.forward_consensus_msg(msg)?; + + // Construct and send a conflicting proposal + if let Err(e) = self.send_conflicting_proposal(proposal).await { + warn!("Failed to send conflicting proposal: {e}"); + } + + return Ok(()); } } @@ -214,6 +237,68 @@ impl ByzantineNetworkProxy { }) } + /// Construct a conflicting proposal and send it. + /// + /// If a [`ConflictingValueFn`] was provided, creates a proposal with a + /// different value. Otherwise, creates a proposal with a flipped `pol_round` + /// (Nil if original had a pol_round, Round(0) if original was Nil). + async fn send_conflicting_proposal( + &self, + original: &Ctx::Proposal, + ) -> Result<(), eyre::Report> { + let height = original.height(); + let round = original.round(); + let pol_round = original.pol_round(); + + let conflicting_proposal = if let Some(ref make_value) = self.conflicting_value_fn { + let conflicting_value = make_value(original.value()); + warn!( + %height, %round, + "BYZANTINE: Sending conflicting proposal with different value" + ); + self.ctx.new_proposal( + height, + round, + conflicting_value, + pol_round, + self.address.clone(), + ) + } else { + // No value factory: flip pol_round to create a structurally different proposal. + let conflicting_pol_round = if pol_round == Round::Nil { + Round::new(0) + } else { + Round::Nil + }; + warn!( + %height, %round, + "BYZANTINE: Sending conflicting proposal with flipped pol_round \ + (no conflicting value factory configured)" + ); + self.ctx.new_proposal( + height, + round, + original.value().clone(), + conflicting_pol_round, + self.address.clone(), + ) + }; + + let signed = self + .signing_provider + .sign_proposal(conflicting_proposal) + .await + .map_err(|e| eyre!("Failed to sign conflicting proposal: {e}"))?; + + self.real_network + .cast(NetworkMsg::PublishConsensusMsg( + SignedConsensusMsg::Proposal(signed), + )) + .map_err(|e| eyre!("Failed to send conflicting proposal to network: {e:?}"))?; + + Ok(()) + } + /// Construct a conflicting vote (flipping value <-> nil) and send it. async fn send_conflicting_vote(&self, original: &Ctx::Vote) -> Result<(), eyre::Report> { let height = original.height(); diff --git a/code/crates/test/app/src/node.rs b/code/crates/test/app/src/node.rs index 01a68c70c..00a09a11e 100644 --- a/code/crates/test/app/src/node.rs +++ b/code/crates/test/app/src/node.rs @@ -192,7 +192,15 @@ impl Node for App { ) .await?; - // Spawn the proxy in front of the real network + // Spawn the proxy in front of the real network. + // Provide a value factory for proposal equivocation: create a + // conflicting value by incrementing the original value's u64. + let conflicting_value_fn: Option< + malachitebft_engine_byzantine::ConflictingValueFn, + > = Some(Box::new(|original: &malachitebft_test::Value| { + malachitebft_test::Value::new(original.value.wrapping_add(1)) + })); + let proxy_ref = ByzantineNetworkProxy::spawn( byz_cfg, real_network, @@ -200,6 +208,7 @@ impl Node for App { ctx.clone(), address, span.clone(), + conflicting_value_fn, ) .await?; From 506c37e82e14e84ef1127604e6f150916aa0377a Mon Sep 17 00:00:00 2001 From: hvanz Date: Wed, 25 Feb 2026 09:56:02 +0100 Subject: [PATCH 4/6] test(engine-byzantine): add integration tests for Byzantine fault scenarios Cover vote/proposal equivocation detection, message dropping, and amnesia attacks using the Byzantine engine with the test framework. --- code/Cargo.lock | 1 + code/crates/test/Cargo.toml | 1 + code/crates/test/tests/it/byzantine_engine.rs | 279 ++++++++++++++++++ code/crates/test/tests/it/main.rs | 1 + 4 files changed, 282 insertions(+) create mode 100644 code/crates/test/tests/it/byzantine_engine.rs diff --git a/code/Cargo.lock b/code/Cargo.lock index 463a57cf3..c7392c1f3 100644 --- a/code/Cargo.lock +++ b/code/Cargo.lock @@ -690,6 +690,7 @@ dependencies = [ "arc-malachitebft-core-consensus", "arc-malachitebft-core-types", "arc-malachitebft-engine", + "arc-malachitebft-engine-byzantine", "arc-malachitebft-peer", "arc-malachitebft-proto", "arc-malachitebft-signing", diff --git a/code/crates/test/Cargo.toml b/code/crates/test/Cargo.toml index 087f33e22..4b97393ee 100644 --- a/code/crates/test/Cargo.toml +++ b/code/crates/test/Cargo.toml @@ -41,6 +41,7 @@ signature = { workspace = true } tokio = { workspace = true } [dev-dependencies] +malachitebft-engine-byzantine.workspace = true malachitebft-test-app.workspace = true malachitebft-test-framework.workspace = true diff --git a/code/crates/test/tests/it/byzantine_engine.rs b/code/crates/test/tests/it/byzantine_engine.rs new file mode 100644 index 000000000..a3debf3f6 --- /dev/null +++ b/code/crates/test/tests/it/byzantine_engine.rs @@ -0,0 +1,279 @@ +use std::time::Duration; + +use malachitebft_core_consensus::MisbehaviorEvidence; +use malachitebft_core_types::{Context, Proposal, Vote}; +use malachitebft_engine_byzantine::{ByzantineConfig, Trigger}; +use malachitebft_test_framework::HandlerResult; + +use crate::TestBuilder; + +/// Delay applied to each vote to slow consensus and allow conflicting +/// messages to propagate through the gossip network before a height is decided. +const VOTE_DELAY: Duration = Duration::from_millis(300); + +fn validate_evidence(evidence: &MisbehaviorEvidence) { + for addr in evidence.proposals.iter() { + let list = evidence.proposals.get(addr).unwrap(); + if let Some((p1, p2)) = list.first() { + assert_ne!( + p1.value(), + p2.value(), + "Proposal equivocation should have different values" + ); + } + } + + for addr in evidence.votes.iter() { + let list = evidence.votes.get(addr).unwrap(); + if let Some((v1, v2)) = list.first() { + assert_eq!( + v1.round(), + v2.round(), + "Vote equivocation should be for same round" + ); + assert_eq!( + v1.vote_type(), + v2.vote_type(), + "Vote equivocation should be for same vote type" + ); + assert_ne!( + v1.value(), + v2.value(), + "Vote equivocation should have different values" + ); + } + } +} + +/// A Byzantine node that always equivocates votes should be detected +/// by honest nodes via MisbehaviorEvidence. +#[tokio::test] +pub async fn vote_equivocation_detected() { + let mut test = TestBuilder::<()>::new(); + + // Node 1: Byzantine — equivocates votes on every message + test.add_node() + .with_voting_power(1) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + equivocate_votes: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .success(); + + // Nodes 2-3: Honest validators + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .wait_until(3) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .wait_until(3) + .success(); + + // Node 4: Honest validator that checks for vote equivocation evidence + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .on_finalized(|_cert, evidence, _state| { + if evidence.votes.is_empty() { + Ok(HandlerResult::WaitForNextEvent) + } else { + validate_evidence(&evidence); + Ok(HandlerResult::ContinueTest) + } + }) + .success(); + + test.build().run(Duration::from_secs(60)).await; +} + +/// A Byzantine node that always equivocates proposals should be detected +/// by honest nodes via MisbehaviorEvidence. +/// +/// NOTE: Currently ignored because the conflicting proposal sent via the +/// gossip network arrives at honest nodes after they have already decided +/// the height, so it gets filtered out before reaching the ProposalKeeper. +/// This is a known limitation of gossip-based equivocation detection. +#[tokio::test] +#[ignore] +pub async fn proposal_equivocation_detected() { + let mut test = TestBuilder::<()>::new(); + + // Node 1: Byzantine — equivocates proposals on every message + test.add_node() + .with_voting_power(10) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + equivocate_proposals: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .success(); + + // Nodes 2-3: Honest validators + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .wait_until(5) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .wait_until(5) + .success(); + + // Node 4: Honest validator that checks for proposal equivocation evidence + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .on_finalized(|_cert, evidence, _state| { + if evidence.proposals.is_empty() { + Ok(HandlerResult::WaitForNextEvent) + } else { + validate_evidence(&evidence); + Ok(HandlerResult::ContinueTest) + } + }) + .success(); + + test.build().run(Duration::from_secs(90)).await; +} + +/// A Byzantine node that drops all its proposals should not prevent +/// the honest majority from making progress. +#[tokio::test] +pub async fn proposal_dropping_liveness() { + const TARGET_HEIGHT: u64 = 5; + + let mut test = TestBuilder::<()>::new(); + + // Node 1: Byzantine — drops all outgoing proposals + test.add_node() + .with_voting_power(1) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + drop_proposals: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .success(); + + // Nodes 2-4: Honest validators that should still make progress + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + + test.build().run(Duration::from_secs(60)).await; +} + +/// A Byzantine node that drops all its votes should not prevent +/// the honest majority from making progress (Byzantine node has +/// minority voting power). +#[tokio::test] +pub async fn vote_dropping_liveness() { + const TARGET_HEIGHT: u64 = 5; + + let mut test = TestBuilder::<()>::new(); + + // Node 1: Byzantine — drops all outgoing votes + test.add_node() + .with_voting_power(1) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + drop_votes: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .success(); + + // Nodes 2-4: Honest validators that should still make progress + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + + test.build().run(Duration::from_secs(60)).await; +} + +/// A Byzantine node performing an amnesia attack (ignoring voting locks) +/// should not prevent the honest majority from making progress. +#[tokio::test] +pub async fn amnesia_attack_liveness() { + const TARGET_HEIGHT: u64 = 3; + + let mut test = TestBuilder::<()>::new(); + + // Node 1: Byzantine — ignores voting locks (amnesia attack) + test.add_node() + .with_voting_power(1) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + ignore_locks: true, + ..Default::default() + }); + }) + .success(); + + // Nodes 2-4: Honest validators that should still make progress + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + + test.build().run(Duration::from_secs(60)).await; +} diff --git a/code/crates/test/tests/it/main.rs b/code/crates/test/tests/it/main.rs index 58b5a8f31..dc4d9d02b 100644 --- a/code/crates/test/tests/it/main.rs +++ b/code/crates/test/tests/it/main.rs @@ -1,3 +1,4 @@ +mod byzantine_engine; mod equivocation; mod finalization; mod full_nodes; From 116b0501c4b36b0708d3c8a5427d66cddbbecd85 Mon Sep 17 00:00:00 2001 From: hvanz Date: Wed, 25 Feb 2026 10:39:58 +0100 Subject: [PATCH 5/6] fix(engine-byzantine): drop votes on liveness channel and add stall test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ByzantineNetworkProxy only intercepted votes on the consensus gossip channel but forwarded them transparently on the liveness (rebroadcast) channel. This meant drop_votes was ineffective — votes still reached peers through the liveness path. Fix the proxy to also apply drop_votes rules to PublishLivenessMsg containing votes. Add an integration test (two_byzantine_of_four_stalls_consensus) that verifies 2 Byzantine vote-droppers out of 4 equal-power nodes cause consensus to stall, confirming the f < n/3 fault threshold. Co-Authored-By: Claude Opus 4.6 --- code/crates/engine-byzantine/src/proxy.rs | 57 +++++++++++- code/crates/test/tests/it/byzantine_engine.rs | 91 ++++++++++++++++++- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/code/crates/engine-byzantine/src/proxy.rs b/code/crates/engine-byzantine/src/proxy.rs index 3a489da40..70354b617 100644 --- a/code/crates/engine-byzantine/src/proxy.rs +++ b/code/crates/engine-byzantine/src/proxy.rs @@ -2,7 +2,8 @@ //! //! [`ByzantineNetworkProxy`] is a ractor actor that sits between the consensus //! actor and the real network actor. It intercepts outgoing -//! [`NetworkMsg::PublishConsensusMsg`] messages and can: +//! [`NetworkMsg::PublishConsensusMsg`] and [`NetworkMsg::PublishLivenessMsg`] +//! messages and can: //! //! - **Drop** messages (simulating silence / censorship) //! - **Duplicate** messages with conflicting content (simulating equivocation) @@ -16,7 +17,7 @@ use ractor::{Actor, ActorProcessingErr, ActorRef}; use rand::rngs::StdRng; use tracing::{debug, warn}; -use malachitebft_core_consensus::SignedConsensusMsg; +use malachitebft_core_consensus::{LivenessMsg, SignedConsensusMsg}; use malachitebft_core_types::{Context, NilOrVal, Proposal, Round, Vote, VoteType}; use malachitebft_engine::network::{Msg as NetworkMsg, NetworkRef}; use malachitebft_signing::SigningProvider; @@ -116,6 +117,9 @@ impl Actor for ByzantineNetworkProxy { NetworkMsg::PublishConsensusMsg(ref consensus_msg) => { self.handle_consensus_msg(consensus_msg, state).await?; } + NetworkMsg::PublishLivenessMsg(ref liveness_msg) => { + self.handle_liveness_msg(liveness_msg, state)?; + } // All other message types are forwarded transparently. other => { self.real_network @@ -223,6 +227,44 @@ impl ByzantineNetworkProxy { Ok(()) } + /// Handle a liveness message, applying drop rules for votes. + /// + /// Liveness messages carry rebroadcast votes and certificates. Without + /// filtering these, a Byzantine node configured to drop votes would still + /// have its votes delivered to peers through the liveness channel. + fn handle_liveness_msg( + &self, + msg: &LivenessMsg, + state: &mut ProxyState, + ) -> Result<(), ActorProcessingErr> { + match msg { + LivenessMsg::Vote(signed_vote) => { + let vote = &signed_vote.message; + let height = vote.height(); + let round = vote.round(); + + if let Some(ref trigger) = self.config.drop_votes { + if trigger.fires(height, round, &mut state.rng) { + warn!( + %height, %round, + vote_type = ?vote.vote_type(), + "BYZANTINE: Dropping liveness vote" + ); + return Ok(()); + } + } + + self.forward_liveness_msg(msg)?; + } + // Other liveness messages (certificates) are forwarded as-is. + _ => { + self.forward_liveness_msg(msg)?; + } + } + + Ok(()) + } + /// Forward a consensus message to the real network. fn forward_consensus_msg( &self, @@ -237,6 +279,17 @@ impl ByzantineNetworkProxy { }) } + /// Forward a liveness message to the real network. + fn forward_liveness_msg(&self, msg: &LivenessMsg) -> Result<(), ActorProcessingErr> { + self.real_network + .cast(NetworkMsg::PublishLivenessMsg(msg.clone())) + .map_err(|e| { + ActorProcessingErr::from(format!( + "Failed to forward liveness message to network: {e:?}" + )) + }) + } + /// Construct a conflicting proposal and send it. /// /// If a [`ConflictingValueFn`] was provided, creates a proposal with a diff --git a/code/crates/test/tests/it/byzantine_engine.rs b/code/crates/test/tests/it/byzantine_engine.rs index a3debf3f6..fcfbdaf86 100644 --- a/code/crates/test/tests/it/byzantine_engine.rs +++ b/code/crates/test/tests/it/byzantine_engine.rs @@ -1,11 +1,13 @@ use std::time::Duration; +use arc_malachitebft_test::middleware::Middleware; use malachitebft_core_consensus::MisbehaviorEvidence; -use malachitebft_core_types::{Context, Proposal, Vote}; +use malachitebft_core_types::{Context, LinearTimeouts, Proposal, Vote}; +use malachitebft_engine::util::events::Event; use malachitebft_engine_byzantine::{ByzantineConfig, Trigger}; -use malachitebft_test_framework::HandlerResult; +use malachitebft_test_framework::{Expected, HandlerResult}; -use crate::TestBuilder; +use crate::{Height, TestBuilder, TestContext}; /// Delay applied to each vote to slow consensus and allow conflicting /// messages to propagate through the gossip network before a height is decided. @@ -277,3 +279,86 @@ pub async fn amnesia_attack_liveness() { test.build().run(Duration::from_secs(60)).await; } + +/// Short timeouts so that rounds cycle quickly in the stall test. +#[derive(Copy, Clone, Debug)] +struct ShortTimeouts; + +impl Middleware for ShortTimeouts { + fn get_timeouts( + &self, + _ctx: &TestContext, + _current_height: Height, + _height: Height, + ) -> Option { + Some(LinearTimeouts { + propose: Duration::from_millis(500), + propose_delta: Duration::from_millis(100), + prevote: Duration::from_millis(200), + prevote_delta: Duration::from_millis(100), + precommit: Duration::from_millis(200), + precommit_delta: Duration::from_millis(100), + rebroadcast: Duration::from_millis(500), + }) + } +} + +/// When 2 out of 4 nodes with equal voting power are Byzantine and drop all +/// their votes, honest nodes hold only 50% of the voting power — below the +/// 2/3 quorum threshold required by Tendermint consensus. This means honest +/// nodes can never form a commit certificate and consensus must stall. +/// +/// The test verifies that honest nodes observe multiple rebroadcast cycles +/// at height 1 without ever deciding, confirming that the BFT fault threshold +/// (f < n/3) is respected: exceeding it prevents liveness. +#[tokio::test] +pub async fn two_byzantine_of_four_stalls_consensus() { + /// Number of rebroadcast events an honest node must observe at + /// height 1 before we conclude consensus has stalled. Multiple + /// rebroadcast cycles with no decision is evidence of a liveness failure. + const REBROADCAST_THRESHOLD: usize = 3; + + let mut test = TestBuilder::::new(); + + // Nodes 1-2: Byzantine — drop all outgoing votes. + // Together they hold 50% of the voting power, so the remaining honest + // nodes cannot reach the >2/3 quorum by themselves. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .with_middleware(ShortTimeouts) + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + drop_votes: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .success(); + } + + // Nodes 3-4: Honest validators. + // Each waits for several vote rebroadcasts (proof that the node is stuck + // at the prevote step without enough voting power for quorum), then asserts + // that zero decisions have been made. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .with_middleware(ShortTimeouts) + .on_event(move |event, rebroadcasts: &mut usize| { + if let Event::RepublishVote(_) = event { + *rebroadcasts += 1; + if *rebroadcasts >= REBROADCAST_THRESHOLD { + return Ok(HandlerResult::ContinueTest); + } + } + Ok(HandlerResult::WaitForNextEvent) + }) + .expect_decisions(Expected::Exactly(0)) + .success(); + } + + test.build().run(Duration::from_secs(60)).await; +} From 52b58a45cc1ca3347f1b4bdb7e53ad279d40d311 Mon Sep 17 00:00:00 2001 From: hvanz Date: Wed, 25 Feb 2026 10:52:21 +0100 Subject: [PATCH 6/6] test(engine-byzantine): add 2-of-4 Byzantine integration tests Add four tests covering different attack types with 2 out of 4 equal-power Byzantine nodes: - two_silent_of_four_stalls_consensus: total silence (drop votes + proposals) causes consensus to stall - two_proposal_droppers_of_four_still_progresses: dropping proposals alone does not prevent liveness since honest proposer rounds still form quorum - two_vote_equivocators_of_four_detected: equivocation is detected via MisbehaviorEvidence while consensus still progresses - two_amnesia_of_four_still_progresses: ignoring locks alone does not prevent liveness since all nodes still vote Co-Authored-By: Claude Opus 4.6 --- code/crates/test/tests/it/byzantine_engine.rs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/code/crates/test/tests/it/byzantine_engine.rs b/code/crates/test/tests/it/byzantine_engine.rs index fcfbdaf86..970b1c68b 100644 --- a/code/crates/test/tests/it/byzantine_engine.rs +++ b/code/crates/test/tests/it/byzantine_engine.rs @@ -362,3 +362,173 @@ pub async fn two_byzantine_of_four_stalls_consensus() { test.build().run(Duration::from_secs(60)).await; } + +/// When 2 out of 4 nodes are completely silent (dropping both votes and +/// proposals), honest nodes hold only 50% of the voting power and cannot +/// reach the >2/3 quorum. This is functionally equivalent to the vote-dropping +/// test but exercises the combined silence behavior. +#[tokio::test] +pub async fn two_silent_of_four_stalls_consensus() { + const REBROADCAST_THRESHOLD: usize = 3; + + let mut test = TestBuilder::::new(); + + // Nodes 1-2: Completely silent Byzantine nodes. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .with_middleware(ShortTimeouts) + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + drop_votes: Some(Trigger::Always), + drop_proposals: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .success(); + } + + // Nodes 3-4: Honest validators. + // Without proposals or votes from the Byzantine half, the honest nodes + // have no chance of reaching quorum. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .with_middleware(ShortTimeouts) + .on_event(move |event, rebroadcasts: &mut usize| { + if let Event::RepublishVote(_) = event { + *rebroadcasts += 1; + if *rebroadcasts >= REBROADCAST_THRESHOLD { + return Ok(HandlerResult::ContinueTest); + } + } + Ok(HandlerResult::WaitForNextEvent) + }) + .expect_decisions(Expected::Exactly(0)) + .success(); + } + + test.build().run(Duration::from_secs(60)).await; +} + +/// When 2 out of 4 nodes drop proposals, honest proposer rounds still +/// succeed because all 4 nodes vote normally. The network loses half its +/// proposer rounds but liveness is preserved. +#[tokio::test] +pub async fn two_proposal_droppers_of_four_still_progresses() { + const TARGET_HEIGHT: u64 = 5; + + let mut test = TestBuilder::<()>::new(); + + // Nodes 1-2: Byzantine — drop all proposals but vote normally. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + drop_proposals: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .wait_until(TARGET_HEIGHT) + .success(); + } + + // Nodes 3-4: Honest validators. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + } + + test.build().run(Duration::from_secs(60)).await; +} + +/// When 2 out of 4 equal-power nodes equivocate their votes, honest nodes +/// detect the misbehavior via `MisbehaviorEvidence`. The equivocators' +/// first vote still counts toward quorum, so consensus can make progress +/// while the evidence is collected. +#[tokio::test] +pub async fn two_vote_equivocators_of_four_detected() { + let mut test = TestBuilder::<()>::new(); + + // Nodes 1-2: Byzantine — equivocate every vote. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + equivocate_votes: Some(Trigger::Always), + seed: Some(42), + ..Default::default() + }); + }) + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .success(); + } + + // Nodes 3-4: Honest validators that check for equivocation evidence. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .on_vote(|_v, _s| Ok(HandlerResult::SleepAndContinueTest(VOTE_DELAY))) + .on_finalized(|_cert, evidence, _state| { + if evidence.votes.is_empty() { + Ok(HandlerResult::WaitForNextEvent) + } else { + validate_evidence(&evidence); + Ok(HandlerResult::ContinueTest) + } + }) + .success(); + } + + test.build().run(Duration::from_secs(60)).await; +} + +/// When 2 out of 4 nodes perform an amnesia attack (ignoring voting locks), +/// consensus still progresses because all 4 nodes vote (just potentially +/// for inconsistent values). With 100% of voting power visible, quorum is +/// reachable. The amnesia attack alone cannot cause a safety violation +/// without also equivocating. +#[tokio::test] +pub async fn two_amnesia_of_four_still_progresses() { + const TARGET_HEIGHT: u64 = 5; + + let mut test = TestBuilder::<()>::new(); + + // Nodes 1-2: Byzantine — ignore voting locks. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .add_config_modifier(|config| { + config.byzantine = Some(ByzantineConfig { + ignore_locks: true, + ..Default::default() + }); + }) + .wait_until(TARGET_HEIGHT) + .success(); + } + + // Nodes 3-4: Honest validators. + for _ in 0..2 { + test.add_node() + .with_voting_power(10) + .start() + .wait_until(TARGET_HEIGHT) + .success(); + } + + test.build().run(Duration::from_secs(60)).await; +}