diff --git a/smite-ir/src/generators.rs b/smite-ir/src/generators.rs index 58d1b2df..3ea2c4a6 100644 --- a/smite-ir/src/generators.rs +++ b/smite-ir/src/generators.rs @@ -8,18 +8,22 @@ mod channel_announcement; mod channel_ready; mod channel_update; +mod error; mod funding_created; mod funding_flow; mod node_announcement; mod open_channel; +mod warning; pub use channel_announcement::ChannelAnnouncementGenerator; pub use channel_ready::ChannelReadyGenerator; pub use channel_update::ChannelUpdateGenerator; +pub use error::SendErrorGenerator; pub use funding_created::FundingCreatedGenerator; pub use funding_flow::FundingFlowGenerator; pub use node_announcement::NodeAnnouncementGenerator; pub use open_channel::OpenChannelGenerator; +pub use warning::SendWarningGenerator; use rand::Rng; @@ -42,6 +46,8 @@ pub enum AnyGenerator { FundingCreated(FundingCreatedGenerator), ChannelReady(ChannelReadyGenerator), FundingFlow(FundingFlowGenerator), + SendError(SendErrorGenerator), + SendWarning(SendWarningGenerator), } impl AnyGenerator { @@ -54,6 +60,8 @@ impl AnyGenerator { Self::FundingCreated(FundingCreatedGenerator), Self::ChannelReady(ChannelReadyGenerator), Self::FundingFlow(FundingFlowGenerator), + Self::SendError(SendErrorGenerator), + Self::SendWarning(SendWarningGenerator), ]; } @@ -67,6 +75,8 @@ impl Generator for AnyGenerator { Self::FundingCreated(generator) => generator.generate(builder, rng), Self::ChannelReady(generator) => generator.generate(builder, rng), Self::FundingFlow(generator) => generator.generate(builder, rng), + Self::SendError(generator) => generator.generate(builder, rng), + Self::SendWarning(generator) => generator.generate(builder, rng), } } } diff --git a/smite-ir/src/generators/error.rs b/smite-ir/src/generators/error.rs new file mode 100644 index 00000000..8047986c --- /dev/null +++ b/smite-ir/src/generators/error.rs @@ -0,0 +1,30 @@ +//! Generator for the BOLT 1 `error` message. + +use rand::{Rng, RngExt}; +use smite::bolt::ChannelId; + +use super::Generator; +use crate::builder::ProgramBuilder; +use crate::{Operation, VariableType}; + +/// Generates an unsolicited `error` send. +/// +/// The target fails the referenced channel, so inserting this after a funding +/// flow exercises its force-close path. +#[derive(Clone, Copy)] +pub struct SendErrorGenerator; + +impl Generator for SendErrorGenerator { + fn generate(&self, builder: &mut ProgramBuilder, rng: &mut impl Rng) { + // The all-zero id fails every channel at once (BOLT 1), a path the + // mutator rarely reaches by flipping bits in a real channel_id. + let channel_id = if rng.random_ratio(1, 4) { + builder.append(Operation::LoadChannelId(ChannelId::ALL.0), &[]) + } else { + builder.pick_variable(VariableType::ChannelId, rng) + }; + let data = builder.pick_variable(VariableType::Bytes, rng); + + builder.append(Operation::SendError, &[channel_id, data]); + } +} diff --git a/smite-ir/src/generators/warning.rs b/smite-ir/src/generators/warning.rs new file mode 100644 index 00000000..4ed9c4a3 --- /dev/null +++ b/smite-ir/src/generators/warning.rs @@ -0,0 +1,30 @@ +//! Generator for the BOLT 1 `warning` message. + +use rand::{Rng, RngExt}; +use smite::bolt::ChannelId; + +use super::Generator; +use crate::builder::ProgramBuilder; +use crate::{Operation, VariableType}; + +/// Generates an unsolicited `warning` send. +/// +/// Targets only log warnings, so this mainly covers their parsing and logging +/// of untrusted `data` for both known and unknown channels. +#[derive(Clone, Copy)] +pub struct SendWarningGenerator; + +impl Generator for SendWarningGenerator { + fn generate(&self, builder: &mut ProgramBuilder, rng: &mut impl Rng) { + // The all-zero id marks a warning as not channel-specific (BOLT 1), a + // path the mutator rarely reaches by flipping bits in a real channel_id. + let channel_id = if rng.random_ratio(1, 4) { + builder.append(Operation::LoadChannelId(ChannelId::ALL.0), &[]) + } else { + builder.pick_variable(VariableType::ChannelId, rng) + }; + let data = builder.pick_variable(VariableType::Bytes, rng); + + builder.append(Operation::SendWarning, &[channel_id, data]); + } +} diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index 745aa415..c5a43d81 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -119,6 +119,8 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { | Operation::SendOpenChannel | Operation::SendFundingCreated | Operation::SendShutdown + | Operation::SendError + | Operation::SendWarning | Operation::RecvAcceptChannel | Operation::RecvFundingSigned | Operation::RecvChannelReady diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 22a17899..9d416187 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -214,6 +214,18 @@ pub enum Operation { /// 0: `channel_id` (`ChannelId`) /// 1: `scriptpubkey` (`Bytes`) SendShutdown, + /// Build and send an `error` message (BOLT 1, type 17). + /// + /// Inputs (2): + /// 0: `channel_id` (`ChannelId`, all zeros = all channels) + /// 1: `data` (`Bytes`) + SendError, + /// Build and send a `warning` message (BOLT 1, type 1). + /// + /// Inputs (2): + /// 0: `channel_id` (`ChannelId`, all zeros = not channel-specific) + /// 1: `data` (`Bytes`) + SendWarning, /// Receive and parse an `accept_channel` response. /// Produces an `AcceptChannel` compound variable. RecvAcceptChannel, @@ -553,6 +565,8 @@ impl fmt::Display for Operation { write!(f, "SendChannelReady{{include_alias={include_alias}}}") } Self::SendShutdown => write!(f, "SendShutdown"), + Self::SendError => write!(f, "SendError"), + Self::SendWarning => write!(f, "SendWarning"), Self::RecvAcceptChannel => write!(f, "RecvAcceptChannel"), Self::RecvFundingSigned => write!(f, "RecvFundingSigned"), Self::RecvChannelReady => write!(f, "RecvChannelReady()"), @@ -594,6 +608,8 @@ impl Operation { | Self::BuildAnnouncementSignatures => Some(VariableType::Message), Self::SendMessage | Self::SendChannelReady { .. } + | Self::SendError + | Self::SendWarning | Self::RecvChannelReady | Self::MineBlocks(_) | Self::BroadcastTransaction => None, @@ -712,9 +728,9 @@ impl Operation { VariableType::Point, // second_per_commitment_point VariableType::ShortChannelId, // short_channel_id (alias) ], - Self::SendShutdown => vec![ + Self::SendShutdown | Self::SendError | Self::SendWarning => vec![ VariableType::ChannelId, // channel_id - VariableType::Bytes, // scriptpubkey + VariableType::Bytes, // scriptpubkey (shutdown) or data (error, warning) ], Self::RecvAcceptChannel => vec![VariableType::SentOpenChannel], Self::RecvFundingSigned => vec![VariableType::SentFundingCreated], @@ -761,6 +777,8 @@ impl Operation { | Self::SendFundingCreated | Self::SendChannelReady { .. } | Self::SendShutdown + | Self::SendError + | Self::SendWarning | Self::RecvFundingSigned | Self::RecvChannelReady | Self::MineBlocks(_) @@ -809,6 +827,8 @@ impl Operation { | Self::SendFundingCreated | Self::SendChannelReady { .. } | Self::SendShutdown + | Self::SendError + | Self::SendWarning | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady @@ -853,7 +873,9 @@ impl Operation { | Self::SendMessage | Self::SendOpenChannel | Self::SendChannelReady { .. } - | Self::SendShutdown => true, + | Self::SendShutdown + | Self::SendError + | Self::SendWarning => true, // `CreateFundingTransaction` selects coins from the wallet, whose // contents change as transactions are created and broadcast. // `SendFundingCreated` builds its message from the recorded @@ -916,6 +938,8 @@ impl Operation { | Self::SendOpenChannel | Self::SendFundingCreated | Self::SendShutdown + | Self::SendError + | Self::SendWarning | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index 2cb77285..ecf25543 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -10,6 +10,7 @@ use super::*; use generators::{ AnyGenerator, ChannelAnnouncementGenerator, ChannelReadyGenerator, ChannelUpdateGenerator, FundingCreatedGenerator, FundingFlowGenerator, NodeAnnouncementGenerator, OpenChannelGenerator, + SendErrorGenerator, SendWarningGenerator, }; use minimizers::{CommonSubexpressionEliminator, DeadCodeEliminator, Minimizer}; use mutators::{ @@ -603,6 +604,72 @@ fn display_send_shutdown_program() { } } +#[test] +fn display_send_error_program() { + let instructions = vec![ + Instruction { + operation: Operation::LoadChannelId([0x00; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadBytes(vec![0x6f, 0x6f, 0x70, 0x73]), + inputs: vec![], + }, + Instruction { + operation: Operation::SendError, + inputs: vec![0, 1], + }, + ]; + + let program = Program { instructions }; + let text = program.to_string(); + let lines: Vec<&str> = text.lines().collect(); + + let cid_hex = "00".repeat(32); + let expected: Vec = vec![ + format!("v0 = LoadChannelId(0x{cid_hex})"), + "v1 = LoadBytes(0x6f6f7073)".into(), + "SendError(v0, v1)".into(), + ]; + assert_eq!(lines.len(), expected.len(), "line count mismatch"); + for (i, (got, want)) in lines.iter().zip(expected.iter()).enumerate() { + assert_eq!(got, want, "line {i} mismatch"); + } +} + +#[test] +fn display_send_warning_program() { + let instructions = vec![ + Instruction { + operation: Operation::LoadChannelId([0xcd; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadBytes(vec![0x6f, 0x6f, 0x70, 0x73]), + inputs: vec![], + }, + Instruction { + operation: Operation::SendWarning, + inputs: vec![0, 1], + }, + ]; + + let program = Program { instructions }; + let text = program.to_string(); + let lines: Vec<&str> = text.lines().collect(); + + let cid_hex = "cd".repeat(32); + let expected: Vec = vec![ + format!("v0 = LoadChannelId(0x{cid_hex})"), + "v1 = LoadBytes(0x6f6f7073)".into(), + "SendWarning(v0, v1)".into(), + ]; + assert_eq!(lines.len(), expected.len(), "line count mismatch"); + for (i, (got, want)) in lines.iter().zip(expected.iter()).enumerate() { + assert_eq!(got, want, "line {i} mismatch"); + } +} + #[test] fn postcard_roundtrip() { let program = Program { @@ -915,7 +982,9 @@ fn any_generator_all_is_complete() { | AnyGenerator::OpenChannel(_) | AnyGenerator::FundingCreated(_) | AnyGenerator::ChannelReady(_) - | AnyGenerator::FundingFlow(_) => 7, + | AnyGenerator::FundingFlow(_) + | AnyGenerator::SendError(_) + | AnyGenerator::SendWarning(_) => 9, } }; assert_eq!(AnyGenerator::ALL.len(), variant_count(AnyGenerator::ALL[0])); @@ -1602,6 +1671,113 @@ fn generated_channel_update_program_structure() { assert_eq!(build_count, 1, "expected exactly one BuildChannelUpdate"); } +fn generate_send_error_program(seed: u64) -> Program { + let mut rng = SmallRng::seed_from_u64(seed); + let mut builder = ProgramBuilder::new(); + SendErrorGenerator.generate(&mut builder, &mut rng); + builder.build() +} + +// If SendErrorGenerator completes without panicking, every instruction has +// correct input types (enforced by ProgramBuilder::append). +#[test] +fn generated_send_error_program_is_type_correct() { + for seed in 0..100 { + generate_send_error_program(seed); + } +} + +/// Asserts that `program` ends with exactly one send matching `is_send`, and +/// that its `channel_id` and `data` inputs are literal loads. +fn assert_send_error_like_structure(program: &Program, is_send: fn(&Operation) -> bool) { + let ops: Vec<_> = program.instructions.iter().map(|i| &i.operation).collect(); + assert!( + is_send(ops[ops.len() - 1]), + "last instruction should be the send" + ); + assert_eq!( + ops.iter().filter(|op| is_send(op)).count(), + 1, + "expected exactly one send" + ); + + let send = program.instructions.last().expect("non-empty"); + assert!( + matches!(ops[send.inputs[0]], Operation::LoadChannelId(_)), + "channel_id input should be a LoadChannelId", + ); + assert!( + matches!(ops[send.inputs[1]], Operation::LoadBytes(_)), + "data input should be a LoadBytes", + ); +} + +#[test] +fn generated_send_error_program_structure() { + let program = generate_send_error_program(0); + assert_send_error_like_structure(&program, |op| matches!(op, Operation::SendError)); +} + +/// Returns whether the last instruction's `channel_id` input is the all-zero +/// "all channels" id. +fn targets_all_channels(program: &Program) -> bool { + let send = program.instructions.last().expect("non-empty"); + matches!( + program.instructions[send.inputs[0]].operation, + Operation::LoadChannelId(id) if id == [0u8; 32], + ) +} + +// Both the all-channels id and a specific id must show up across seeds, so +// the "fail everything" path gets fresh coverage without relying on mutation. +#[test] +fn generated_send_error_program_varies_channel_id_scope() { + let programs: Vec<_> = (0..100).map(generate_send_error_program).collect(); + assert!( + programs.iter().any(targets_all_channels), + "never targets all channels" + ); + assert!( + !programs.iter().all(targets_all_channels), + "always targets all channels" + ); +} + +fn generate_send_warning_program(seed: u64) -> Program { + let mut rng = SmallRng::seed_from_u64(seed); + let mut builder = ProgramBuilder::new(); + SendWarningGenerator.generate(&mut builder, &mut rng); + builder.build() +} + +// If SendWarningGenerator completes without panicking, every instruction has +// correct input types (enforced by ProgramBuilder::append). +#[test] +fn generated_send_warning_program_is_type_correct() { + for seed in 0..100 { + generate_send_warning_program(seed); + } +} + +#[test] +fn generated_send_warning_program_structure() { + let program = generate_send_warning_program(0); + assert_send_error_like_structure(&program, |op| matches!(op, Operation::SendWarning)); +} + +#[test] +fn generated_send_warning_program_varies_channel_id_scope() { + let programs: Vec<_> = (0..100).map(generate_send_warning_program).collect(); + assert!( + programs.iter().any(targets_all_channels), + "never targets all channels" + ); + assert!( + !programs.iter().all(targets_all_channels), + "always targets all channels" + ); +} + #[test] fn generated_open_channel_program_postcard_roundtrip() { let program = generate_open_channel_program(42); @@ -1658,6 +1834,22 @@ fn generated_channel_update_program_postcard_roundtrip() { assert_eq!(program, decoded); } +#[test] +fn generated_send_error_program_postcard_roundtrip() { + let program = generate_send_error_program(42); + let bytes = postcard::to_allocvec(&program).expect("postcard serialization"); + let decoded: Program = postcard::from_bytes(&bytes).expect("postcard deserialization"); + assert_eq!(program, decoded); +} + +#[test] +fn generated_send_warning_program_postcard_roundtrip() { + let program = generate_send_warning_program(42); + let bytes = postcard::to_allocvec(&program).expect("postcard serialization"); + let decoded: Program = postcard::from_bytes(&bytes).expect("postcard deserialization"); + assert_eq!(program, decoded); +} + #[test] fn generate_fresh_produces_distinct_indices() { let mut rng = SmallRng::seed_from_u64(0); diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index bf6f6b90..dcdbfa84 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -11,7 +11,7 @@ use smite::bolt::{ AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady, ChannelReadyTlvs, ChannelUpdate, Features, FromMessage, FundingCreated, FundingSigned, Message, MessageType, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, - TemporaryChannelId, + TemporaryChannelId, Warning, }; use smite::channel_tx::{ ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side, @@ -27,7 +27,7 @@ use smite::violation::Violation; use super::targets::TargetRpc; use smite_ir::operation::AcceptChannelField; use smite_ir::{Operation, Program, Variable, VariableType}; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::time::Duration; /// The timeout used when receiving messages from the target. We will wait this @@ -234,6 +234,29 @@ pub enum ExecuteError { Violation(#[from] Violation), } +/// The negotiations of one `temporary_channel_id`. +#[derive(Default)] +struct ChannelNegotiation { + /// Current negotiation, if any. + live: Option, + /// Negotiations we failed with `error` before receiving their + /// `accept_channel`, oldest first. The target handles our `open_channel` + /// before the `error`, so the replies may still arrive. + awaiting_orphan_reply: VecDeque, +} + +impl ChannelNegotiation { + /// Fails the live negotiation, as the target does on a sent `error`. Its + /// reply is still expected if not yet received. + fn fail(&mut self) { + if let Some(pending) = self.live.take() + && pending.accept_channel.is_none() + { + self.awaiting_orphan_reply.push_back(pending); + } + } +} + /// Executes IR programs against a target over an established connection. pub struct Executor { /// Connection used to send and receive Lightning messages. @@ -252,7 +275,7 @@ pub struct Executor { /// Negotiation state captured during program execution, keyed by /// `temporary_channel_id`, so the funding flow can build commitments from /// the parameters actually sent on the wire. - negotiations: HashMap, + negotiations: HashMap, /// Transactions stored outside Bitcoin Core's mempool, typically because they /// were rejected by mempool policy, to be included in the next `MineBlocks` /// operation. Each is stored as `(txid, raw_hex)`: re-signing the same @@ -494,6 +517,31 @@ impl Executor { Some(Variable::SentShutdown) } + Operation::SendError => { + let err = build_error(&variables, &instr.inputs); + record_send_error( + &mut self.negotiations, + &mut self.channel_states, + err.channel_id, + ); + let encoded = Message::Error(err).encode(); + log::debug!("[{:?}] SendError: {} bytes", start.elapsed(), encoded.len()); + self.conn.send_message(&encoded)?; + None + } + + Operation::SendWarning => { + let warning = build_warning(&variables, &instr.inputs); + let encoded = Message::Warning(warning).encode(); + log::debug!( + "[{:?}] SendWarning: {} bytes", + start.elapsed(), + encoded.len() + ); + self.conn.send_message(&encoded)?; + None + } + Operation::RecvAcceptChannel => { consume_affine( &mut variables, @@ -503,12 +551,11 @@ impl Executor { log::debug!("[{:?}] RecvAcceptChannel: waiting", start.elapsed()); let ac: AcceptChannel = recv_bolt(&mut self.conn, RECV_IDLE_TIMEOUT)?; log::debug!("[{:?}] RecvAcceptChannel: received", start.elapsed()); - AcceptChannelOracle.evaluate(&AcceptChannelContext { - accept_channel: &ac, - negotiation: self.negotiations.get(&ac.temporary_channel_id), - negotiated_features: &self.context.negotiated_features, - })?; - record_recv_accept_channel(&mut self.negotiations, &ac); + record_recv_accept_channel( + &mut self.negotiations, + &ac, + &self.context.negotiated_features, + )?; Some(Variable::AcceptChannel(ac)) } @@ -781,7 +828,7 @@ fn build_funding_created( variables: &[Option], inputs: &[usize], channel_states: &mut HashMap, - negotiations: &mut HashMap, + negotiations: &mut HashMap, mined_txids: &HashSet, ) -> Result { let funding_tx = resolve_funding_transaction(variables, inputs[0]); @@ -798,24 +845,21 @@ fn build_funding_created( // Without both the recorded `open_channel` and the peer's `accept_channel` // we cannot build the commitment to sign, so fall back to an unsigned // `funding_created` and leave `channel_states` untouched. - let Some(pending) = negotiations.get(&temporary_channel_id) else { - return Ok(FundingCreated { - temporary_channel_id, - funding_txid: funding_outpoint.txid, - funding_output_index, - signature: Signature::from_compact(&[0u8; 64]) - .expect("zero bytes parse as a signature"), - }); + let unsigned_funding_created = || FundingCreated { + temporary_channel_id, + funding_txid: funding_outpoint.txid, + funding_output_index, + signature: Signature::from_compact(&[0u8; 64]).expect("zero bytes parse as a signature"), + }; + let live = negotiations + .get_mut(&temporary_channel_id) + .and_then(|negotiation| negotiation.live.as_mut()); + let Some(pending) = live else { + return Ok(unsigned_funding_created()); }; let open_channel = &pending.open_channel; let Some(accept_channel) = pending.accept_channel.as_ref() else { - return Ok(FundingCreated { - temporary_channel_id, - funding_txid: funding_outpoint.txid, - funding_output_index, - signature: Signature::from_compact(&[0u8; 64]) - .expect("zero bytes parse as a signature"), - }); + return Ok(unsigned_funding_created()); }; let opener_funding_privkey = @@ -901,9 +945,7 @@ fn build_funding_created( // so repeated `funding_created` messages can still be built, but a later // `open_channel` reusing this `temporary_channel_id` starts a fresh // negotiation. - if let Some(pending) = negotiations.get_mut(&temporary_channel_id) { - pending.funding_built = true; - } + pending.funding_built = true; Ok(FundingCreated { temporary_channel_id, @@ -953,6 +995,22 @@ fn build_shutdown(variables: &[Option], inputs: &[usize]) -> Shutdown Shutdown::for_channel(channel_id, scriptpubkey) } +/// Builds an `Error` message from 2 input variables (wire order). +fn build_error(variables: &[Option], inputs: &[usize]) -> smite::bolt::Error { + smite::bolt::Error { + channel_id: resolve_channel_id(variables, inputs[0]), + data: resolve_bytes(variables, inputs[1]).to_vec(), + } +} + +/// Builds a `Warning` message from 2 input variables (wire order). +fn build_warning(variables: &[Option], inputs: &[usize]) -> Warning { + Warning { + channel_id: resolve_channel_id(variables, inputs[0]), + data: resolve_bytes(variables, inputs[1]).to_vec(), + } +} + /// Builds a signed `ChannelAnnouncement` from 7 input variables. fn build_channel_announcement( variables: &[Option], @@ -1255,41 +1313,71 @@ fn is_channel_ready_expected( /// `funding_created` has been built, it is overwritten, allowing the /// `temporary_channel_id` to be reused for a new negotiation. fn record_send_open_channel( - negotiations: &mut HashMap, + negotiations: &mut HashMap, open_channel: &OpenChannel, ) { - if negotiations - .get(&open_channel.temporary_channel_id) + let negotiation = negotiations + .entry(open_channel.temporary_channel_id) + .or_default(); + if negotiation + .live + .as_ref() .is_some_and(|pending| !pending.funding_built) { return; } - negotiations.insert( - open_channel.temporary_channel_id, - PendingChannel { - open_channel: open_channel.clone(), - accept_channel: None, - funding_built: false, - }, - ); + negotiation.live = Some(PendingChannel { + open_channel: open_channel.clone(), + accept_channel: None, + funding_built: false, + }); } -/// Pairs a received `accept_channel` with the recorded `open_channel` of the -/// same `temporary_channel_id`. +/// Checks a received `accept_channel` against the negotiation it answers and +/// records it, unless that negotiation was failed by a sent `error`: the +/// target has forgotten it. /// -/// # Panics +/// The message only names a `temporary_channel_id`, so it answers the first +/// negotiation it is valid for: those awaiting an orphan reply, oldest first, +/// then the live one. The target answers in order but may never answer a +/// negotiation we failed, so skipped ones are dropped. /// -/// Panics if no matching `open_channel` exists. This should be unreachable, as -/// `AcceptChannelOracle` reports such messages as a [`Violation`]. +/// # Errors +/// +/// Returns the [`Violation`] of the oldest negotiation if it is valid for none. fn record_recv_accept_channel( - negotiations: &mut HashMap, + negotiations: &mut HashMap, accept_channel: &AcceptChannel, -) { - negotiations - .get_mut(&accept_channel.temporary_channel_id) - .expect("AcceptChannelOracle guaranteed this temporary_channel_id exists") + negotiated_features: &Features, +) -> Result<(), Violation> { + let evaluate = |negotiation: Option<&PendingChannel>| { + AcceptChannelOracle.evaluate(&AcceptChannelContext { + accept_channel, + negotiation, + negotiated_features, + }) + }; + let Some(negotiation) = negotiations.get_mut(&accept_channel.temporary_channel_id) else { + return evaluate(None); + }; + + let mut oldest_violation = None; + while let Some(orphan) = negotiation.awaiting_orphan_reply.pop_front() { + match evaluate(Some(&orphan)) { + Ok(()) => return Ok(()), + Err(violation) => _ = oldest_violation.get_or_insert(violation), + } + } + + evaluate(negotiation.live.as_ref()) + .map_err(|violation| oldest_violation.unwrap_or(violation))?; + negotiation + .live + .as_mut() + .expect("AcceptChannelOracle guaranteed this negotiation exists") .accept_channel = Some(accept_channel.clone()); + Ok(()) } /// Records that a `funding_signed` has been accepted for its channel. @@ -1308,6 +1396,35 @@ fn record_recv_funding_signed( .funding_signed_received = true; } +/// Forgets the negotiations and funded channels failed by a sent `error`, as +/// the target may, so negotiating their `temporary_channel_id` or funding +/// outpoint again is not reported as reuse. `ChannelId::ALL` fails them all. +/// +/// The target answers messages in order, so a reply not yet received is still +/// in flight. Its negotiation keeps awaiting that reply so that it is neither +/// reported as unknown nor paired with a later `open_channel` reusing the id. +/// Likewise, a channel still awaiting `funding_signed` stays tracked. +fn record_send_error( + negotiations: &mut HashMap, + channel_states: &mut HashMap, + channel_id: ChannelId, +) { + if channel_id == ChannelId::ALL { + negotiations.values_mut().for_each(ChannelNegotiation::fail); + channel_states.retain(|_, state| !state.funding_signed_received); + } else { + if let Some(negotiation) = negotiations.get_mut(&channel_id) { + negotiation.fail(); + } + if channel_states + .get(&channel_id) + .is_some_and(|state| state.funding_signed_received) + { + channel_states.remove(&channel_id); + } + } +} + /// Extracts a field from a parsed `accept_channel` message. fn extract_field(ac: &AcceptChannel, field: AcceptChannelField) -> Variable { match field { diff --git a/smite-scenarios/src/executor/tests.rs b/smite-scenarios/src/executor/tests.rs index ed2f6362..1d95fbd7 100644 --- a/smite-scenarios/src/executor/tests.rs +++ b/smite-scenarios/src/executor/tests.rs @@ -498,6 +498,157 @@ fn execute_recv_accept_channel_rejects_reuse_before_funding() { )); } +// The target forgets a negotiation we fail with `error`, so negotiating its +// `temporary_channel_id` again is not reuse. +#[test] +fn execute_send_error_allows_temporary_channel_id_reuse() { + let temporary_channel_id = TemporaryChannelId::new([0xbb; 32]); + + for error_channel_id in [temporary_channel_id, ChannelId::ALL] { + let mut b = ProgramBuilder::new(); + let negotiated = negotiate_channel(&mut b, &announced_open_channel()); + send_error(&mut b, error_channel_id); + let resent = b.append( + Operation::SendOpenChannel, + &[negotiated.open_channel.vars.built], + ); + b.append(Operation::RecvAcceptChannel, &[resent]); + + let mut fx = Fixture::new() + .queue(&Message::AcceptChannel(sample_accept_channel())) + .queue(&Message::AcceptChannel(sample_accept_channel())); + fx.run(&b.build()); + + let pending = fx.negotiation(&temporary_channel_id); + assert!(pending.accept_channel.is_some()); + } +} + +#[test] +fn execute_send_error_for_other_channel_keeps_reuse_violation() { + let mut b = ProgramBuilder::new(); + let negotiated = negotiate_channel(&mut b, &announced_open_channel()); + send_error(&mut b, ChannelId::new([0xcc; 32])); + let resent = b.append( + Operation::SendOpenChannel, + &[negotiated.open_channel.vars.built], + ); + b.append(Operation::RecvAcceptChannel, &[resent]); + + let err = Fixture::new() + .queue(&Message::AcceptChannel(sample_accept_channel())) + .queue(&Message::AcceptChannel(sample_accept_channel())) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidAcceptChannel(_, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert!(reason.contains("temporary_channel_id reuse")); +} + +// An `accept_channel` still in flight when we fail its negotiation must not be +// reported as unknown, nor paired with a later `open_channel` reusing the id. +#[test] +fn execute_recv_accept_channel_in_flight_when_error_sent() { + let temporary_channel_id = TemporaryChannelId::new([0xbb; 32]); + + // First open_channel: funding_satoshis = 100_000, failed before its reply. + // Second open_channel: same temporary_channel_id, funding_satoshis = 200_000. + let mut b = ProgramBuilder::new(); + let first = send_open_channel(&mut b, &announced_open_channel()); + send_error(&mut b, temporary_channel_id); + let mut second = first.vars; + second.funding_satoshis = b.append(Operation::LoadAmount(200_000), &[]); + second.built = b.append(Operation::BuildOpenChannel, &second.build_inputs()); + let resent = b.append(Operation::SendOpenChannel, &[second.built]); + b.append(Operation::RecvAcceptChannel, &[first.sent]); + b.append(Operation::RecvAcceptChannel, &[resent]); + + let mut fx = Fixture::new() + .queue(&Message::AcceptChannel(sample_accept_channel())) + .queue(&Message::AcceptChannel(sample_accept_channel())); + fx.run(&b.build()); + + let pending = fx.negotiation(&temporary_channel_id); + assert_eq!(pending.open_channel.funding_satoshis, 200_000); + assert!(pending.accept_channel.is_some()); +} + +// A reply to a negotiation we failed is still checked against its +// `open_channel`: the target had to reject this one per BOLT 2. +#[test] +fn execute_orphaned_accept_channel_is_still_validated() { + let mut oc = announced_open_channel(); + oc.message.push_msat = 99_900_000; + + let mut b = ProgramBuilder::new(); + let first = send_open_channel(&mut b, &oc); + send_error(&mut b, oc.message.temporary_channel_id); + b.append(Operation::RecvAcceptChannel, &[first.sent]); + + let err = Fixture::new() + .queue(&Message::AcceptChannel(sample_accept_channel())) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidAcceptChannel(_, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert!(reason.contains("invalid open_channel: opener balance")); +} + +// The target may never answer an `open_channel` we failed. The reply to a later +// one reusing the id must then not be checked against the failed negotiation. +#[test] +fn execute_recv_accept_channel_when_failed_open_channel_unanswered() { + let mut unanswered = announced_open_channel(); + unanswered.message.dust_limit_satoshis = 20_000; + let temporary_channel_id = unanswered.message.temporary_channel_id; + + // `sample_accept_channel` has a channel reserve of 10_000 sat: invalid for + // the first `open_channel`, valid for the second. + let mut b = ProgramBuilder::new(); + let first = send_open_channel(&mut b, &unanswered); + send_error(&mut b, temporary_channel_id); + let mut second = first.vars; + second.dust_limit_satoshis = b.append(Operation::LoadAmount(546), &[]); + second.built = b.append(Operation::BuildOpenChannel, &second.build_inputs()); + let resent = b.append(Operation::SendOpenChannel, &[second.built]); + b.append(Operation::RecvAcceptChannel, &[resent]); + + let mut fx = Fixture::new().queue(&Message::AcceptChannel(sample_accept_channel())); + fx.run(&b.build()); + + let pending = fx.negotiation(&temporary_channel_id); + assert_eq!(pending.open_channel.dust_limit_satoshis, 546); + assert!(pending.accept_channel.is_some()); +} + +// Only one in-flight reply is excused per failed negotiation. A further +// `accept_channel` for the forgotten id is still reported as unknown. +#[test] +fn execute_orphaned_accept_channel_is_consumed_once() { + let temporary_channel_id = TemporaryChannelId::new([0xbb; 32]); + let mut other = announced_open_channel(); + other.message.temporary_channel_id = TemporaryChannelId::new([0xcc; 32]); + + let mut b = ProgramBuilder::new(); + let first = send_open_channel(&mut b, &announced_open_channel()); + send_error(&mut b, temporary_channel_id); + b.append(Operation::RecvAcceptChannel, &[first.sent]); + negotiate_channel(&mut b, &other); + + let err = Fixture::new() + .queue(&Message::AcceptChannel(sample_accept_channel())) + .queue(&Message::AcceptChannel(sample_accept_channel())) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidAcceptChannel(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, temporary_channel_id); + assert!(reason.contains("unknown temporary_channel_id")); +} + #[test] fn execute_records_only_first_open_channel_for_duplicate_id_before_funding() { let temporary_channel_id = TemporaryChannelId::new([0xbb; 32]); @@ -1014,6 +1165,57 @@ fn execute_recv_funding_signed_duplicate() { assert!(reason.contains("duplicate funding_signed: channel already funded")); } +// The target may forget a funded channel we fail with `error` and accept a new +// one reusing its funding outpoint, so a later `funding_signed` for the same +// channel_id is not a duplicate. +#[test] +fn execute_send_error_forgets_funded_channel() { + for error_channel_id in [funding_channel_id(), ChannelId::ALL] { + let mut b = ProgramBuilder::new(); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + send_error(&mut b, error_channel_id); + + let mut fx = recv_funding_signed_fixture(); + fx.run(&b.build()); + + assert!(fx.channel_states().is_empty()); + } +} + +#[test] +fn execute_send_error_for_other_channel_keeps_funded_channel() { + let mut b = ProgramBuilder::new(); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + send_error(&mut b, ChannelId::new([0xcc; 32])); + + let mut fx = recv_funding_signed_fixture(); + fx.run(&b.build()); + + assert!(fx.channel_states().contains_key(&funding_channel_id())); +} + +// A `funding_signed` still in flight when we fail its channel must not be +// reported as unknown. +#[test] +fn execute_recv_funding_signed_in_flight_when_error_sent() { + for error_channel_id in [funding_channel_id(), ChannelId::ALL] { + let mut b = ProgramBuilder::new(); + let funding_created = send_funding_created(&mut b); + send_error(&mut b, error_channel_id); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + + let mut fx = recv_funding_signed_fixture(); + fx.run(&b.build()); + + assert!( + fx.channel_state(&funding_channel_id()) + .funding_signed_received + ); + } +} + #[test] fn execute_send_channel_ready() { let channel_id = funding_channel_id(); @@ -1119,6 +1321,46 @@ fn execute_send_shutdown_empty_scriptpubkey() { assert!(sd.scriptpubkey.is_empty()); } +#[test] +fn execute_send_error() { + let channel_id = ChannelId::new([0x7a; 32]); + // Non-printable bytes are allowed so the fuzzer can probe the target's + // handling of data that violates BOLT 1's printable-ASCII requirement. + let data = vec![0x00, b'b', b'a', b'd', 0xff]; + + let mut b = ProgramBuilder::new(); + let channel_id_var = b.append(Operation::LoadChannelId(channel_id.0), &[]); + let data_var = b.append(Operation::LoadBytes(data.clone()), &[]); + b.append(Operation::SendError, &[channel_id_var, data_var]); + + let mut fx = Fixture::new(); + fx.run(&b.build()); + + assert_eq!(fx.sent_len(), 1); + let err: smite::bolt::Error = fx.sent(0); + assert_eq!(err.channel_id, channel_id); + assert_eq!(err.data, data); +} + +#[test] +fn execute_send_warning() { + let channel_id = ChannelId::ALL; + let data = b"not channel-specific".to_vec(); + + let mut b = ProgramBuilder::new(); + let channel_id_var = b.append(Operation::LoadChannelId(channel_id.0), &[]); + let data_var = b.append(Operation::LoadBytes(data.clone()), &[]); + b.append(Operation::SendWarning, &[channel_id_var, data_var]); + + let mut fx = Fixture::new(); + fx.run(&b.build()); + + assert_eq!(fx.sent_len(), 1); + let warning: Warning = fx.sent(0); + assert_eq!(warning.channel_id, channel_id); + assert_eq!(warning.data, data); +} + #[test] fn execute_recv_channel_ready_invalid_funding_outpoint_is_noop() { // Corrupt the negotiated acceptor funding pubkey so the broadcast funding diff --git a/smite-scenarios/src/executor/tests/harness.rs b/smite-scenarios/src/executor/tests/harness.rs index 64ca233a..62e86229 100644 --- a/smite-scenarios/src/executor/tests/harness.rs +++ b/smite-scenarios/src/executor/tests/harness.rs @@ -151,11 +151,14 @@ impl Fixture { self } - /// Records `pending` as the negotiation for its `temporary_channel_id`. + /// Records `pending` as the live negotiation for its `temporary_channel_id`. pub fn with_negotiation(mut self, pending: PendingChannel) -> Self { + let temporary_channel_id = pending.open_channel.temporary_channel_id; self.executor .negotiations - .insert(pending.open_channel.temporary_channel_id, pending); + .entry(temporary_channel_id) + .or_default() + .live = Some(pending); self } @@ -184,11 +187,12 @@ impl Fixture { .expect_err("program execution failure") } - /// Returns the negotiation recorded for `id`. + /// Returns the live negotiation recorded for `id`. pub fn negotiation(&self, id: &TemporaryChannelId) -> &PendingChannel { self.executor .negotiations .get(id) + .and_then(|negotiation| negotiation.live.as_ref()) .expect("negotiation recorded") } diff --git a/smite-scenarios/src/executor/tests/programs.rs b/smite-scenarios/src/executor/tests/programs.rs index 362ab313..023ba3b2 100644 --- a/smite-scenarios/src/executor/tests/programs.rs +++ b/smite-scenarios/src/executor/tests/programs.rs @@ -343,6 +343,15 @@ pub fn send_channel_announcement(b: &mut ProgramBuilder, scid: usize) { b.append(Operation::SendMessage, &[announcement]); } +// -- error -- + +/// Emits a `SendError` for `channel_id` with empty data. +pub fn send_error(b: &mut ProgramBuilder, channel_id: ChannelId) { + let channel_id = b.append(Operation::LoadChannelId(channel_id.0), &[]); + let data = b.append(Operation::LoadBytes(vec![]), &[]); + b.append(Operation::SendError, &[channel_id, data]); +} + // -- Malformed programs -- /// Builds a program from `(operation, inputs)` pairs, skipping the