From 677a7a6093f36d2aa929286f765fe03874fe5660 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 22 Sep 2026 15:46:18 +0200 Subject: [PATCH 1/2] smite-ir: add RecvShutdown operation RecvShutdown consumes the SentShutdown of a previous SendShutdown and waits for the target's `shutdown` on the given channel. It returns that channel's scriptpubkey, or empty bytes if no `shutdown` was received. The target can send `shutdown` at any time and on any channel, so a received one is recorded into its channel's state like `channel_ready`, and explicit receives skip past it instead of failing. SendShutdown records the script we sent, which is what makes a reply expected there, and RecvShutdown reads the reply from channel state, so one recorded before it ran is reported just the same. BOLT 2 lets the target warn about a non-standard scriptpubkey instead of replying, but every implementation except LDK disconnects afterwards and LDK acts as if the `shutdown` never came, so a warning just ends execution like any other unexpected message. A target that has not sent `channel_ready` may ignore our `shutdown`, so RecvShutdown is a no-op until it has. That gives up coverage of targets which do reply before then, in exchange for not spending a receive timeout on every target which doesn't. --- smite-ir/src/mutators/operation_param.rs | 1 + smite-ir/src/operation.rs | 21 +- smite-ir/src/tests.rs | 7 +- smite-scenarios/src/executor.rs | 100 ++++- smite-scenarios/src/executor/tests.rs | 348 +++++++++++++++++- .../src/executor/tests/programs.rs | 61 ++- smite/src/channel_state.rs | 9 + 7 files changed, 535 insertions(+), 12 deletions(-) diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index b5152f6c..97140adb 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -122,6 +122,7 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { | Operation::RecvAcceptChannel | Operation::RecvFundingSigned | Operation::RecvChannelReady + | Operation::RecvShutdown | Operation::BroadcastTransaction | Operation::LookupShortChannelId => { unreachable!("is_param_mutable returned true for {op:?}") diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 2b369251..61b1ff2d 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -229,6 +229,17 @@ pub enum Operation { /// point unknown) and its funding transaction has enough confirmations for /// the target to have sent `channel_ready`. RecvChannelReady, + /// Receive and parse a `shutdown` from the target. + /// Produces the target's `scriptpubkey` (`Bytes`), or empty `Bytes` if no + /// `shutdown` was received. + /// + /// This is a no-op unless we sent a `shutdown` on the channel and the + /// target has not replied there yet. + /// + /// Inputs (2): + /// 0: `SentShutdown` from the `SendShutdown` being answered + /// 1: `channel_id` (`ChannelId`) the reply is expected on + RecvShutdown, /// Mines the given number of blocks on the Bitcoin network. MineBlocks(u8), /// Sign wallet inputs of the transaction and broadcast it via `bitcoin-cli`. @@ -557,6 +568,7 @@ impl fmt::Display for Operation { Self::RecvAcceptChannel => write!(f, "RecvAcceptChannel"), Self::RecvFundingSigned => write!(f, "RecvFundingSigned"), Self::RecvChannelReady => write!(f, "RecvChannelReady()"), + Self::RecvShutdown => write!(f, "RecvShutdown"), Self::MineBlocks(v) => write!(f, "MineBlocks({v})"), Self::BroadcastTransaction => write!(f, "BroadcastTransaction"), Self::LookupShortChannelId => write!(f, "LookupShortChannelId"), @@ -580,7 +592,9 @@ impl Operation { Self::LoadForwardingFee(_) => Some(VariableType::ForwardingFee), Self::LoadU16(_) => Some(VariableType::U16), Self::LoadU8(_) => Some(VariableType::U8), - Self::LoadBytes(_) | Self::LoadShutdownScript(_) => Some(VariableType::Bytes), + Self::LoadBytes(_) | Self::LoadShutdownScript(_) | Self::RecvShutdown => { + Some(VariableType::Bytes) + } Self::LoadFeatures(_) | Self::LoadChannelType(_) => Some(VariableType::Features), Self::LoadPrivateKey(_) => Some(VariableType::PrivateKey), Self::LoadChannelId(_) | Self::RecvFundingSigned => Some(VariableType::ChannelId), @@ -720,6 +734,7 @@ impl Operation { ], Self::RecvAcceptChannel => vec![VariableType::SentOpenChannel], Self::RecvFundingSigned => vec![VariableType::SentFundingCreated], + Self::RecvShutdown => vec![VariableType::SentShutdown, VariableType::ChannelId], Self::BroadcastTransaction | Self::LookupShortChannelId => { vec![VariableType::FundingTransaction] } @@ -765,6 +780,7 @@ impl Operation { | Self::SendShutdown | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::MineBlocks(_) | Self::BroadcastTransaction | Self::LookupShortChannelId => vec![], @@ -814,6 +830,7 @@ impl Operation { | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::MineBlocks(_) | Self::BroadcastTransaction => true, } @@ -868,6 +885,7 @@ impl Operation { | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::MineBlocks(_) | Self::BroadcastTransaction | Self::LookupShortChannelId => false, @@ -921,6 +939,7 @@ impl Operation { | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::BroadcastTransaction | Self::LookupShortChannelId => false, } diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index 84072fcb..e66ca882 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -570,7 +570,7 @@ fn display_send_and_recv_channel_ready_program() { } #[test] -fn display_send_shutdown_program() { +fn display_send_and_recv_shutdown_program() { let instructions = vec![ Instruction { operation: Operation::LoadChannelId([0xcd; 32]), @@ -584,6 +584,10 @@ fn display_send_shutdown_program() { operation: Operation::SendShutdown, inputs: vec![0, 1], }, + Instruction { + operation: Operation::RecvShutdown, + inputs: vec![2, 0], + }, ]; let program = Program { instructions }; @@ -596,6 +600,7 @@ fn display_send_shutdown_program() { format!("v0 = LoadChannelId(0x{cid_hex})"), format!("v1 = LoadShutdownScript(P2wpkh(0x{spk_hex}))"), "v2 = SendShutdown(v0, v1)".into(), + "v3 = RecvShutdown(v2, v0)".into(), ]; assert_eq!(lines.len(), expected.len(), "line count mismatch"); for (i, (got, want)) in lines.iter().zip(expected.iter()).enumerate() { diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 59ea939f..d8b98133 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -507,13 +507,14 @@ impl Executor { Operation::SendShutdown => { let sd = build_shutdown(&variables, &instr.inputs); - let encoded = Message::Shutdown(sd).encode(); + let encoded = Message::Shutdown(sd.clone()).encode(); log::debug!( "[{:?}] SendShutdown: {} bytes", start.elapsed(), encoded.len() ); self.conn.send_message(&encoded)?; + record_send_shutdown(&mut self.channel_states, &sd); Some(Variable::SentShutdown) } @@ -568,6 +569,34 @@ impl Executor { None } + Operation::RecvShutdown => { + consume_affine( + &mut variables, + instr.inputs[0], + instr.operation.input_types()[0], + ); + let channel_id = resolve_channel_id(&variables, instr.inputs[1]); + // TODO: we only expect `shutdown` when all HTLCs are resolved, else this is a + // no-op. + if is_shutdown_expected(&self.channel_states, channel_id) { + log::debug!("[{:?}] RecvShutdown: waiting", start.elapsed()); + // `shutdown` is handled implicitly, so one for another channel is + // recorded by `handle_implicit` and we keep waiting for ours. + while is_shutdown_expected(&self.channel_states, channel_id) { + self.recv_bolt::(RECV_IDLE_TIMEOUT)?; + } + log::debug!("[{:?}] RecvShutdown: received", start.elapsed()); + } + // Read the reply from the channel's state, so one recorded before this + // instruction ran is reported just the same. + let scriptpubkey = self + .channel_states + .get(&channel_id) + .and_then(|state| state.counterparty_shutdown_script.clone()) + .unwrap_or_default(); + Some(Variable::Bytes(scriptpubkey)) + } + Operation::MineBlocks(v) => { // Clear the private mempool and mine the requested blocks, // adding those transactions to the first block. @@ -669,6 +698,7 @@ impl Executor { /// /// This currently includes: /// - `channel_ready` + /// - `shutdown` /// /// # Errors /// @@ -705,6 +735,13 @@ impl Executor { ); Ok(true) } + Message::Shutdown(sd) => { + log::debug!("received shutdown on {}", sd.channel_id); + // TODO: judge it with a `shutdown` oracle here, starting with one naming a + // channel we never funded. + record_recv_shutdown(&mut self.channel_states, sd); + Ok(true) + } _ => Ok(false), } } @@ -780,8 +817,12 @@ define_resolver!( ); /// Consumes an affine variable, leaving its slot void so it cannot be used -/// again. -fn consume_affine(variables: &mut [Option], index: usize, expected: VariableType) { +/// again, and returns it. +fn consume_affine( + variables: &mut [Option], + index: usize, + expected: VariableType, +) -> Variable { assert!( expected.is_affine(), "consume_affine called with non-affine type {expected:?}; voiding the slot would break later reads" @@ -790,7 +831,9 @@ fn consume_affine(variables: &mut [Option], index: usize, expected: Va if actual != expected { type_mismatch(index, expected, actual); } - variables[index] = None; + variables[index] + .take() + .expect("resolve checked the slot is not void") } // -- Operation handlers -- @@ -1349,6 +1392,22 @@ fn is_channel_ready_expected( }) } +/// Returns `true` if we sent a `shutdown` on the given channel, the target has +/// sent its `channel_ready` there, and it has not replied to our `shutdown` yet. +/// +/// A target that has not sent `channel_ready` may ignore our `shutdown`, so we +/// do not wait for a reply before then. +fn is_shutdown_expected( + channel_states: &HashMap, + channel_id: ChannelId, +) -> bool { + channel_states.get(&channel_id).is_some_and(|state| { + state.our_shutdown_script.is_some() + && state.counterparty_shutdown_script.is_none() + && state.next_counterparty_per_commitment_point().is_some() + }) +} + /// Records a sent `open_channel`, keyed by `temporary_channel_id`, so the /// funding flow can build commitments from the values actually put on the wire. /// @@ -1492,6 +1551,39 @@ fn record_recv_channel_ready( ); } +/// Records a sent `shutdown`'s `scriptpubkey` in its channel's state, which is +/// what makes a reply expected there. +/// +/// The first one is kept: a target may ignore any `shutdown` after the first, +/// so our first one is what it is replying to. A `shutdown` for a channel we do +/// not track is not recorded, leaving no reply expected for it. +fn record_send_shutdown( + channel_states: &mut HashMap, + shutdown: &Shutdown, +) { + if let Some(state) = channel_states.get_mut(&shutdown.channel_id) { + state + .our_shutdown_script + .get_or_insert_with(|| shutdown.scriptpubkey.clone()); + } +} + +/// Records a received `shutdown`'s `scriptpubkey` in its channel's state. +/// +/// The first one is kept: a target may ignore any `shutdown` after the first, +/// so its first reply is the one a later `RecvShutdown` must report. A +/// `shutdown` for a channel we do not track is not recorded. +fn record_recv_shutdown( + channel_states: &mut HashMap, + shutdown: &Shutdown, +) { + if let Some(state) = channel_states.get_mut(&shutdown.channel_id) { + state + .counterparty_shutdown_script + .get_or_insert_with(|| shutdown.scriptpubkey.clone()); + } +} + /// 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 4b06179d..2767dec2 100644 --- a/smite-scenarios/src/executor/tests.rs +++ b/smite-scenarios/src/executor/tests.rs @@ -8,7 +8,7 @@ use harness::*; use programs::*; use smite::bolt::{ AcceptChannelTlvs, ChannelReadyTlvs, GossipTimestampFilter, Init, Ping, QueryShortChannelIds, - QueryShortChannelIdsTlvs, ReplyShortChannelIdsEnd, + QueryShortChannelIdsTlvs, ReplyShortChannelIdsEnd, Warning, }; use smite_ir::Instruction; use smite_ir::builder::ProgramBuilder; @@ -1277,6 +1277,352 @@ fn execute_send_shutdown_empty_scriptpubkey() { assert_eq!(sd.scriptpubkey, b""); } +#[test] +fn execute_send_shutdown_records_our_script() { + let (mut fx, _) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + let script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + + let mut b = ProgramBuilder::new(); + establish_channel(&mut b, 6); + send_shutdown(&mut b, channel_id, script.clone()); + + fx.run(&b.build()); + + // Our script is what makes a reply expected on this channel. + assert_eq!( + fx.channel_state(&channel_id).our_shutdown_script, + Some(script.encode()) + ); + assert_eq!( + fx.channel_state(&channel_id).counterparty_shutdown_script, + None + ); +} + +#[test] +fn execute_recv_shutdown() { + let (fx, _) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + let script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + let mut fx = fx.queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + script.encode(), + ))); + + fx.run(&recv_shutdown_program(channel_id, script)); + + // TODO: Once we add IR support for building closing transactions, verify + // the returned scriptpubkey through the closing transaction's output. + + assert!( + fx.channel_state(&channel_id) + .counterparty_shutdown_script + .is_some() + ); + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_skips_implicit_channel_ready() { + // A `channel_ready` arriving while `RecvShutdown` waits is recorded and + // skipped, and the target's `shutdown` still completes the receive. + let (fx, target_pcp) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + let script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + // A resend carrying the same point, which the `channel_ready` oracle allows. + let mut fx = fx + .queue(&channel_ready_reply(target_pcp)) + .queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + script.encode(), + ))); + + fx.run(&recv_shutdown_program(channel_id, script.clone())); + + // The `channel_ready` was skipped instead of ending the program, so the + // `shutdown` behind it still arrived. + let state = fx.channel_state(&channel_id); + assert_eq!(state.counterparty_shutdown_script, Some(script.encode())); + assert_eq!( + *state.next_counterparty_per_commitment_point(), + Some(target_pcp) + ); + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_received_early() { + // The target's `shutdown` arrives while `RecvChannelReady` is waiting, so + // it is recorded before `RecvShutdown` runs, which must still report it. + let channel_id = funding_channel_id(); + let target_script = ShutdownScriptVariant::P2wpkh([0xcd; 20]); + let mut fx = recv_funding_signed_fixture() + .queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + target_script.encode(), + ))) + .queue(&channel_ready_reply(sample_pubkey(7))); + + let mut b = ProgramBuilder::new(); + fund_channel(&mut b); + b.append(Operation::MineBlocks(6), &[]); + let shutdown = send_shutdown( + &mut b, + channel_id, + ShutdownScriptVariant::P2wpkh([0xab; 20]), + ); + b.append(Operation::RecvChannelReady, &[]); + let spk = b.append( + Operation::RecvShutdown, + &[shutdown.sent, shutdown.channel_id], + ); + // Put the produced script back on the wire so it can be inspected. + b.append(Operation::SendShutdown, &[shutdown.channel_id, spk]); + + fx.run(&b.build()); + + assert_eq!( + fx.channel_state(&channel_id).counterparty_shutdown_script, + Some(target_script.encode()) + ); + let echoed: Shutdown = fx.sent(fx.sent_len() - 1); + assert_eq!(echoed.scriptpubkey, target_script.encode()); + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_reports_own_channel() { + let channel_id = funding_channel_id(); + let other = second_funding_channel_id(); + + // Establish both channels. + let mut fx = recv_two_accept_channel_fixture() + .queue(&channel_ready_reply(sample_pubkey(7))) + .queue(&Message::ChannelReady(ChannelReady { + channel_id: other, + second_per_commitment_point: sample_pubkey(8), + tlvs: ChannelReadyTlvs::default(), + })); + fx.run(&recv_two_channel_ready_program()); + + // We sent a `shutdown` on both channels and the other channel is answered + // first, so a lookup ignoring the channel would report its script. + let other_script = ShutdownScriptVariant::P2wpkh([0x11; 20]); + let script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + let mut fx = fx + .queue(&Message::Shutdown(Shutdown::for_channel( + other, + other_script.encode(), + ))) + .queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + script.encode(), + ))); + let mut b = ProgramBuilder::new(); + send_shutdown(&mut b, other, other_script.clone()); + let shutdown = send_shutdown(&mut b, channel_id, script.clone()); + let spk = b.append( + Operation::RecvShutdown, + &[shutdown.sent, shutdown.channel_id], + ); + // Put the produced script back on the wire so it can be inspected. + b.append(Operation::SendShutdown, &[shutdown.channel_id, spk]); + + fx.run(&b.build()); + + assert_eq!( + fx.channel_state(&other).counterparty_shutdown_script, + Some(other_script.encode()) + ); + let echoed: Shutdown = fx.sent(fx.sent_len() - 1); + assert_eq!(echoed.scriptpubkey, script.encode()); + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_other_tracked_channel() { + let channel_id = funding_channel_id(); + let other = second_funding_channel_id(); + + // Establish both channels. + let mut fx = recv_two_accept_channel_fixture() + .queue(&channel_ready_reply(sample_pubkey(7))) + .queue(&Message::ChannelReady(ChannelReady { + channel_id: other, + second_per_commitment_point: sample_pubkey(8), + tlvs: ChannelReadyTlvs::default(), + })); + fx.run(&recv_two_channel_ready_program()); + + // A `shutdown` on a channel we sent none on may answer one sent there + // earlier. It is recorded against that channel and skipped, and we keep + // waiting for ours. + let other_script = ShutdownScriptVariant::P2wpkh([0x11; 20]); + let script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + let mut fx = fx + .queue(&Message::Shutdown(Shutdown::for_channel( + other, + other_script.encode(), + ))) + .queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + script.encode(), + ))); + let mut b = ProgramBuilder::new(); + let shutdown = send_shutdown(&mut b, channel_id, script.clone()); + b.append( + Operation::RecvShutdown, + &[shutdown.sent, shutdown.channel_id], + ); + + fx.run(&b.build()); + + assert_eq!( + fx.channel_state(&channel_id).counterparty_shutdown_script, + Some(script.encode()) + ); + assert_eq!( + fx.channel_state(&other).counterparty_shutdown_script, + Some(other_script.encode()) + ); + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_after_response_is_noop() { + let (fx, _) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + let script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + // Only one reply is queued: once the target has responded, the second + // RecvShutdown must be a no-op rather than block on an empty queue. + let mut fx = fx.queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + script.encode(), + ))); + + let mut b = ProgramBuilder::new(); + establish_channel(&mut b, 6); + let first = send_shutdown(&mut b, channel_id, script); + b.append(Operation::RecvShutdown, &[first.sent, first.channel_id]); + let second = b.append( + Operation::SendShutdown, + &[first.channel_id, first.scriptpubkey], + ); + b.append(Operation::RecvShutdown, &[second, first.channel_id]); + + fx.run(&b.build()); + + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_before_channel_ready_is_noop() { + // The target has not sent `channel_ready`, so it may ignore our `shutdown`. + // No reply is queued: RecvShutdown must be a no-op rather than block on an + // empty queue. + let channel_id = funding_channel_id(); + let mut fx = recv_funding_signed_fixture(); + + let mut b = ProgramBuilder::new(); + fund_channel(&mut b); + let shutdown = send_shutdown( + &mut b, + channel_id, + ShutdownScriptVariant::P2wpkh([0xab; 20]), + ); + b.append( + Operation::RecvShutdown, + &[shutdown.sent, shutdown.channel_id], + ); + + fx.run(&b.build()); + + // We sent one, so a reply would be expected once `channel_ready` arrives. + assert!(fx.channel_state(&channel_id).our_shutdown_script.is_some()); + assert!( + fx.channel_state(&channel_id) + .counterparty_shutdown_script + .is_none() + ); + assert_eq!(fx.queued_len(), 0); +} + +#[test] +fn execute_recv_shutdown_untracked_channel_is_noop() { + let (mut fx, _) = recv_channel_ready_fixture(); + let untracked = ChannelId::new([0x99; 32]); + + // No shutdown reply is queued: a RecvShutdown for a channel we never + // established must be a no-op rather than block on an empty queue. + fx.run(&recv_shutdown_program( + untracked, + ShutdownScriptVariant::P2wpkh([0xab; 20]), + )); + + assert_eq!(fx.queued_len(), 0); +} + +fn warning_reply(channel_id: ChannelId) -> Message { + Message::Warning(Warning { + channel_id, + data: b"non-standard scriptpubkey".to_vec(), + }) +} + +#[test] +fn execute_recv_shutdown_warning_ends_execution() { + // BOLT 2 lets the target warn about our non-standard `scriptpubkey` instead + // of replying, but every implementation except LDK disconnects afterwards + // and LDK acts as if the `shutdown` never came, so a warning just ends + // execution, whichever channel it names. + for channel_id in [ + funding_channel_id(), + ChannelId::ALL, + ChannelId::new([0x99; 32]), + ] { + let (fx, _) = recv_channel_ready_fixture(); + let mut fx = fx.queue(&warning_reply(channel_id)); + + let err = fx.run_err(&recv_shutdown_program( + funding_channel_id(), + ShutdownScriptVariant::Empty, + )); + + assert!(matches!( + err, + ExecuteError::UnexpectedMessage { + expected: MessageType::SHUTDOWN, + got: MessageType::WARNING, + } + )); + } +} + +#[test] +fn execute_recv_shutdown_reply_to_non_standard_script() { + let (fx, _) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + // The target SHOULD warn about our non-standard `scriptpubkey`, but may + // reply with a `shutdown` anyway, which must still be valid. + let mut fx = fx.queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + ShutdownScriptVariant::P2wpkh([0xcd; 20]).encode(), + ))); + + fx.run(&recv_shutdown_program( + channel_id, + ShutdownScriptVariant::Empty, + )); + + assert!( + fx.channel_state(&channel_id) + .counterparty_shutdown_script + .is_some() + ); + assert_eq!(fx.queued_len(), 0); +} + #[test] fn execute_recv_channel_ready_invalid_funding_outpoint_is_noop() { // The target negotiates a funding pubkey that differs from what we use for diff --git a/smite-scenarios/src/executor/tests/programs.rs b/smite-scenarios/src/executor/tests/programs.rs index 71aae02a..14833d74 100644 --- a/smite-scenarios/src/executor/tests/programs.rs +++ b/smite-scenarios/src/executor/tests/programs.rs @@ -14,6 +14,7 @@ use super::harness::{ use crate::executor::*; use smite_ir::Instruction; use smite_ir::builder::ProgramBuilder; +use smite_ir::operation::ShutdownScriptVariant; // -- open_channel -- @@ -334,14 +335,64 @@ pub fn fund_channel_program() -> Program { b.build() } -/// A program that funds a channel, mines `confirmations` blocks, and receives -/// the target's `channel_ready`. -pub fn recv_channel_ready_program(confirmations: u8) -> Program { - let mut b = ProgramBuilder::new(); - fund_channel(&mut b); +/// Funds a channel, mines `confirmations` blocks, and receives the target's +/// `channel_ready`. +pub fn establish_channel(b: &mut ProgramBuilder, confirmations: u8) -> FundedChannel { + let funded = fund_channel(b); b.append(Operation::MineBlocks(confirmations), &[]); b.append(Operation::RecvChannelReady, &[]); + funded +} + +/// A program that runs [`establish_channel`]. +pub fn recv_channel_ready_program(confirmations: u8) -> Program { + let mut b = ProgramBuilder::new(); + establish_channel(&mut b, confirmations); + + b.build() +} + +// -- shutdown -- + +/// The variables a sent `shutdown` produces. +#[derive(Clone, Copy)] +pub struct SentShutdown { + pub channel_id: usize, + pub scriptpubkey: usize, + /// The `SendShutdown` result, an affine variable a single `RecvShutdown` + /// may consume. + pub sent: usize, +} + +/// Sends a `shutdown` for `channel_id` carrying `script`. +pub fn send_shutdown( + b: &mut ProgramBuilder, + channel_id: ChannelId, + script: ShutdownScriptVariant, +) -> SentShutdown { + let channel_id = b.append(Operation::LoadChannelId(channel_id.0), &[]); + let scriptpubkey = b.append(Operation::LoadShutdownScript(script), &[]); + let sent = b.append(Operation::SendShutdown, &[channel_id, scriptpubkey]); + + SentShutdown { + channel_id, + scriptpubkey, + sent, + } +} + +/// A program that establishes the funding flow's channel, sends a `shutdown` +/// for `channel_id` carrying `script`, and receives the target's `shutdown`. +pub fn recv_shutdown_program(channel_id: ChannelId, script: ShutdownScriptVariant) -> Program { + let mut b = ProgramBuilder::new(); + establish_channel(&mut b, 6); + let shutdown = send_shutdown(&mut b, channel_id, script); + b.append( + Operation::RecvShutdown, + &[shutdown.sent, shutdown.channel_id], + ); + b.build() } diff --git a/smite/src/channel_state.rs b/smite/src/channel_state.rs index 90253e9f..318e66dd 100644 --- a/smite/src/channel_state.rs +++ b/smite/src/channel_state.rs @@ -49,6 +49,13 @@ pub struct ChannelState { /// Whether a `funding_signed` has already been accepted for this channel. /// Any later one means the target re-signed a channel it already funded. pub funding_signed_received: bool, + /// The `scriptpubkey` of the `shutdown` we sent, once we have sent one. + /// Its presence is what makes a reply expected on this channel. + pub our_shutdown_script: Option>, + /// The `scriptpubkey` of the peer's `shutdown`, once it has sent one. A + /// target may ignore any `shutdown` after the first, so a later + /// `RecvShutdown` for this channel is a no-op that reports this script. + pub counterparty_shutdown_script: Option>, } impl ChannelState { @@ -73,6 +80,8 @@ impl ChannelState { was_funding_mined_prematurely, sent_invalid_signature, funding_signed_received: false, + our_shutdown_script: None, + counterparty_shutdown_script: None, } } From f2da4406249d37e73f492306f1fb691e3a28ecc9 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 22 Oct 2026 14:42:12 -0500 Subject: [PATCH 2/2] smite: add shutdown oracle A received `shutdown` was recorded against its channel without being judged, so a target naming a channel we never funded, replying on a channel it should have failed, or using a `scriptpubkey` it was not allowed to, went unreported. Add a `ShutdownOracle` beside the other message oracles, evaluated by `handle_implicit` before the `shutdown` is recorded, and report its findings as `InvalidShutdown`. It flags the target if: * the `shutdown` is for a channel we never funded * we sent an invalid signature, so the target should have failed the channel instead of replying * its `scriptpubkey` isn't a standard form for the negotiated features --- smite-scenarios/src/executor.rs | 9 +- smite-scenarios/src/executor/tests.rs | 21 +++ smite/src/oracles.rs | 2 + smite/src/oracles/shutdown.rs | 262 ++++++++++++++++++++++++++ smite/src/violation.rs | 10 + 5 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 smite/src/oracles/shutdown.rs diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index d8b98133..0a807ec1 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -21,7 +21,7 @@ use smite::channel_tx::{ use smite::noise::{ConnectionError, NoiseConnection}; use smite::oracles::{ AcceptChannelContext, AcceptChannelOracle, ChannelReadyContext, ChannelReadyOracle, - FundingSignedContext, FundingSignedOracle, Oracle, + FundingSignedContext, FundingSignedOracle, Oracle, ShutdownContext, ShutdownOracle, }; use smite::pending_channel::{KeyOrigin, PendingChannel}; use smite::violation::Violation; @@ -737,8 +737,11 @@ impl Executor { } Message::Shutdown(sd) => { log::debug!("received shutdown on {}", sd.channel_id); - // TODO: judge it with a `shutdown` oracle here, starting with one naming a - // channel we never funded. + ShutdownOracle.evaluate(&ShutdownContext { + shutdown: sd, + channel: self.channel_states.get(&sd.channel_id), + negotiated_features: &self.context.negotiated_features, + })?; record_recv_shutdown(&mut self.channel_states, sd); Ok(true) } diff --git a/smite-scenarios/src/executor/tests.rs b/smite-scenarios/src/executor/tests.rs index 2767dec2..1f32fb5d 100644 --- a/smite-scenarios/src/executor/tests.rs +++ b/smite-scenarios/src/executor/tests.rs @@ -1440,6 +1440,27 @@ fn execute_recv_shutdown_reports_own_channel() { assert_eq!(fx.queued_len(), 0); } +#[test] +fn execute_recv_shutdown_unknown_channel() { + let (fx, _) = recv_channel_ready_fixture(); + let unknown = ChannelId::new([0x7a; 32]); + let script = ShutdownScriptVariant::P2wpkh([0xcd; 20]); + // A `shutdown` is judged against its channel, so one for a channel we never + // funded is a violation. + let mut fx = fx.queue(&Message::Shutdown(Shutdown::for_channel( + unknown, + script.encode(), + ))); + + let err = fx.run_err(&recv_shutdown_program(funding_channel_id(), script)); + + let ExecuteError::Violation(Violation::InvalidShutdown(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, unknown); + assert_eq!(reason, "unknown channel_id: no channel was funded for it"); +} + #[test] fn execute_recv_shutdown_other_tracked_channel() { let channel_id = funding_channel_id(); diff --git a/smite/src/oracles.rs b/smite/src/oracles.rs index 8a4e395f..aa0a3e83 100644 --- a/smite/src/oracles.rs +++ b/smite/src/oracles.rs @@ -5,11 +5,13 @@ mod accept_channel; mod channel_ready; mod funding_signed; +mod shutdown; use super::violation::Violation; pub use accept_channel::{AcceptChannelContext, AcceptChannelOracle}; pub use channel_ready::{ChannelReadyContext, ChannelReadyOracle}; pub use funding_signed::{FundingSignedContext, FundingSignedOracle}; +pub use shutdown::{ShutdownContext, ShutdownOracle}; /// `Oracle` evaluates a condition against some context pub trait Oracle { diff --git a/smite/src/oracles/shutdown.rs b/smite/src/oracles/shutdown.rs new file mode 100644 index 00000000..84226121 --- /dev/null +++ b/smite/src/oracles/shutdown.rs @@ -0,0 +1,262 @@ +//! BOLT 2 `shutdown` oracle, for the cooperative close flow. + +use super::Oracle; +use crate::bolt::{Features, Shutdown, is_standard_shutdown_script}; +use crate::channel_state::ChannelState; +use crate::violation::Violation; + +/// Context for `ShutdownOracle` +pub struct ShutdownContext<'a> { + /// The `shutdown` received from the peer. + pub shutdown: &'a Shutdown, + /// The channel the `shutdown` belongs to, identified by its `channel_id`, + /// or `None` if no channel was funded for it. + pub channel: Option<&'a ChannelState>, + /// Features negotiated between the target node and Smite, which decide the + /// standard `scriptpubkey` forms. + pub negotiated_features: &'a Features, +} + +/// Checks that a received `shutdown` names a channel we funded, that the target +/// was allowed to reply on it, and that its `scriptpubkey` is a standard form +/// for the negotiated features. +pub struct ShutdownOracle; + +impl Oracle> for ShutdownOracle { + fn evaluate(&self, context: &ShutdownContext<'_>) -> Result<(), Violation> { + let Shutdown { + channel_id, + scriptpubkey, + } = context.shutdown; + + // Check that the `shutdown` names a channel we funded. + let Some(channel) = context.channel else { + return Err(Violation::InvalidShutdown( + *channel_id, + "unknown channel_id: no channel was funded for it".to_string(), + )); + }; + + // Check that the target did not reply on a channel it was required to + // fail. + if channel.sent_invalid_signature { + return Err(Violation::InvalidShutdown( + *channel_id, + "replied on a failed channel: we sent an invalid signature".to_string(), + )); + } + + // Check that the `scriptpubkey` is a form BOLT 2 permits a sender to use. + if !is_standard_shutdown_script(scriptpubkey, context.negotiated_features) { + return Err(Violation::InvalidShutdown( + *channel_id, + format!( + "non-standard scriptpubkey: {} is not permitted for the negotiated features", + hex::encode(scriptpubkey), + ), + )); + } + + // TODO: BOLT 2 forbids sending `shutdown` while HTLCs are still pending. + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bolt::ChannelId; + use crate::channel_tx::{ChannelConfig, ChannelPartyConfig, HolderIdentity, Side}; + use bitcoin::OutPoint; + use bitcoin::opcodes::all::{ + OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHBYTES_20, + }; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + fn secret_key(seed: u8) -> SecretKey { + SecretKey::from_slice(&[seed; 32]).expect("valid secret key") + } + + fn pubkey(seed: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &secret_key(seed)) + } + + /// A standard P2WPKH `scriptpubkey`. + fn p2wpkh() -> Vec { + let mut spk = vec![0x00, 0x14]; + spk.extend_from_slice(&[0xab; 20]); + spk + } + + /// A legacy P2PKH `scriptpubkey`. + fn p2pkh() -> Vec { + let mut spk = vec![OP_DUP.to_u8(), OP_HASH160.to_u8(), OP_PUSHBYTES_20.to_u8()]; + spk.extend_from_slice(&[0xab; 20]); + spk.extend_from_slice(&[OP_EQUALVERIFY.to_u8(), OP_CHECKSIG.to_u8()]); + spk + } + + /// A legacy P2SH `scriptpubkey`. + fn p2sh() -> Vec { + let mut spk = vec![OP_HASH160.to_u8(), OP_PUSHBYTES_20.to_u8()]; + spk.extend_from_slice(&[0xab; 20]); + spk.push(OP_EQUAL.to_u8()); + spk + } + + /// Valid `shutdown` message for testing. + fn shutdown() -> Shutdown { + Shutdown::for_channel(ChannelId::new([1u8; 32]), p2wpkh()) + } + + /// Funded channel state for testing. + fn channel_state() -> ChannelState { + let pkey1 = pubkey(1); + let pkey2 = pubkey(2); + let party = |key| ChannelPartyConfig { + funding_pubkey: key, + payment_basepoint: key, + revocation_basepoint: key, + delayed_payment_basepoint: key, + htlc_basepoint: key, + dust_limit_satoshis: 546, + to_self_delay: 144, + }; + + let config = ChannelConfig { + funding_outpoint: OutPoint::null(), + funding_satoshis: 10_000_000, + channel_type: Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY]), + opener: party(pkey1), + acceptor: party(pkey2), + minimum_depth: 6, + }; + let commitments = config + .new_initial_commitments(3_000_000_000, 15_000, pkey1, pkey2) + .expect("valid initial commitments"); + let holder = HolderIdentity { + side: Side::Opener, + funding_privkey: secret_key(1), + htlc_basepoint_privkey: secret_key(1), + }; + + ChannelState::new(config, holder, commitments, true, false, false) + } + + /// Oracle inputs for testing. Defaults to a conforming `shutdown` for a + /// funded channel. + struct OracleInputs { + shutdown: Shutdown, + channel: Option, + negotiated_features: Features, + } + + impl Default for OracleInputs { + fn default() -> Self { + Self { + shutdown: shutdown(), + channel: Some(channel_state()), + negotiated_features: Features::new(), + } + } + } + + impl OracleInputs { + fn evaluate(&self) -> Result<(), Violation> { + ShutdownOracle.evaluate(&ShutdownContext { + shutdown: &self.shutdown, + channel: self.channel.as_ref(), + negotiated_features: &self.negotiated_features, + }) + } + + fn channel_mut(&mut self) -> &mut ChannelState { + self.channel.as_mut().expect("funded channel") + } + } + + #[track_caller] + fn assert_pass(inputs: &OracleInputs) { + if let Err(err) = inputs.evaluate() { + panic!("expected pass, got: {err}"); + } + } + + #[track_caller] + fn assert_fail(inputs: &OracleInputs, expected: &str) { + match inputs.evaluate() { + Err(Violation::InvalidShutdown(chan_id, reason)) => { + assert_eq!(inputs.shutdown.channel_id, chan_id); + assert!( + reason.contains(expected), + "unexpected failure reason: {reason}" + ); + } + _ => panic!("expected failure: {expected}"), + } + } + + #[test] + fn conforming_shutdown_passes() { + assert_pass(&OracleInputs::default()); + } + + #[test] + fn rejects_unknown_channel() { + let inputs = OracleInputs { + channel: None, + ..Default::default() + }; + + assert_fail(&inputs, "unknown channel_id: no channel was funded for it"); + } + + #[test] + fn rejects_reply_on_failed_channel() { + let mut inputs = OracleInputs::default(); + inputs.channel_mut().sent_invalid_signature = true; + + assert_fail( + &inputs, + "replied on a failed channel: we sent an invalid signature", + ); + } + + #[test] + fn rejects_non_standard_scriptpubkey() { + // A receiver may accept legacy P2PKH/P2SH, but the target is the sender + // here, and no feature makes them a standard form for a sender to use. + for spk in [p2pkh(), p2sh()] { + let inputs = OracleInputs { + shutdown: Shutdown::for_channel(shutdown().channel_id, spk), + negotiated_features: Features::from_bits(&[ + Features::OPTION_SHUTDOWN_ANYSEGWIT, + Features::OPTION_SIMPLE_CLOSE, + ]), + ..Default::default() + }; + + assert_fail(&inputs, "non-standard scriptpubkey"); + } + } + + #[test] + fn accepts_anysegwit_only_when_negotiated() { + let mut spk = vec![0x51, 0x02]; + spk.extend_from_slice(&[0xcd; 2]); + let inputs = OracleInputs { + shutdown: Shutdown::for_channel(shutdown().channel_id, spk), + ..Default::default() + }; + + assert_fail(&inputs, "non-standard scriptpubkey"); + + let inputs = OracleInputs { + negotiated_features: Features::from_bits(&[Features::OPTION_SHUTDOWN_ANYSEGWIT]), + ..inputs + }; + + assert_pass(&inputs); + } +} diff --git a/smite/src/violation.rs b/smite/src/violation.rs index 5e45eb03..c6de5b32 100644 --- a/smite/src/violation.rs +++ b/smite/src/violation.rs @@ -60,4 +60,14 @@ pub enum Violation { /// - it reuses a per-commitment point from a previously revealed key. #[error("invalid channel_ready for channel_id {0}: {1}")] InvalidChannelReady(ChannelId, String), + + /// The target's `shutdown` broke a BOLT 2 requirement, as judged by + /// [`crate::oracles::ShutdownOracle`]. The reason names the breached + /// requirement, one of: + /// - it names a `channel_id` we have not funded, + /// - it replies on a channel we sent an invalid signature for, which the + /// target should have failed instead, or + /// - its `scriptpubkey` is not a standard form for the negotiated features. + #[error("invalid shutdown for channel_id {0}: {1}")] + InvalidShutdown(ChannelId, String), }