From 677a7a6093f36d2aa929286f765fe03874fe5660 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 22 Sep 2026 15:46:18 +0200 Subject: [PATCH 1/3] 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/3] 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), } From ca92cf29a380526c5fc2c484765f8cab1002c3cd Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 22 Oct 2026 17:26:16 -0500 Subject: [PATCH 3/3] smite: check upfront_shutdown_script on shutdown An `upfront_shutdown_script` in `open_channel` or `accept_channel` binds its sender to that exact `scriptpubkey` when closing the channel, but neither commitment was recorded, so no `shutdown` was checked against them. Record both from the negotiated messages and have `ShutdownOracle` also flag the target if: * it replied to a `shutdown` of ours that broke our own commitment, which BOLT 2 requires it to fail the connection over instead * its own `scriptpubkey` differs from what it committed to A commitment only binds once `option_upfront_shutdown_script` is negotiated, and a zero-length script commits to nothing, so neither check applies in those cases. Our side is read from the `shutdown` we recorded sending, so a target closing the channel first breaks no commitment of ours. --- smite-scenarios/src/executor.rs | 2 + smite-scenarios/src/executor/tests.rs | 36 ++++++ smite/src/channel_state.rs | 49 ++++++++ smite/src/oracles/channel_ready.rs | 2 +- smite/src/oracles/funding_signed.rs | 2 +- smite/src/oracles/shutdown.rs | 155 ++++++++++++++++++++++++-- smite/src/violation.rs | 7 +- 7 files changed, 242 insertions(+), 11 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 0a807ec1..a6861c0f 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -1054,6 +1054,8 @@ fn build_funding_created( is_funding_outpoint_valid, mined_txids.contains(&funding_outpoint.txid), sent_invalid_signature, + open_channel.tlvs.upfront_shutdown_script.clone(), + accept_channel.tlvs.upfront_shutdown_script.clone(), ) }); } diff --git a/smite-scenarios/src/executor/tests.rs b/smite-scenarios/src/executor/tests.rs index 1f32fb5d..93df2c1e 100644 --- a/smite-scenarios/src/executor/tests.rs +++ b/smite-scenarios/src/executor/tests.rs @@ -1461,6 +1461,42 @@ fn execute_recv_shutdown_unknown_channel() { assert_eq!(reason, "unknown channel_id: no channel was funded for it"); } +#[test] +fn execute_records_negotiated_upfront_shutdown_scripts() { + // `ShutdownOracle` judges a received `shutdown` against both sides' upfront + // commitments, so the funding flow has to record them from the negotiated + // `open_channel` and `accept_channel`. + let our_upfront = ShutdownScriptVariant::P2wpkh([0x11; 20]).encode(); + let their_upfront = ShutdownScriptVariant::P2wpkh([0x22; 20]).encode(); + + let mut oc = funded_open_channel(); + oc.message.tlvs.upfront_shutdown_script = Some(our_upfront.clone()); + let accept_channel = AcceptChannel { + tlvs: AcceptChannelTlvs { + upfront_shutdown_script: Some(their_upfront.clone()), + ..funded_accept_channel().tlvs + }, + ..funded_accept_channel() + }; + + let mut b = ProgramBuilder::new(); + negotiate_channel(&mut b, &oc); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + + let mut fx = Fixture::new() + .queue(&Message::AcceptChannel(accept_channel)) + .queue(&funding_signed_reply(funding_channel_id())); + fx.run(&b.build()); + + let state = fx.channel_state(&funding_channel_id()); + assert_eq!(state.our_upfront_shutdown_script, Some(our_upfront)); + assert_eq!( + state.counterparty_upfront_shutdown_script, + Some(their_upfront) + ); +} + #[test] fn execute_recv_shutdown_other_tracked_channel() { let channel_id = funding_channel_id(); diff --git a/smite/src/channel_state.rs b/smite/src/channel_state.rs index 318e66dd..58c75fa3 100644 --- a/smite/src/channel_state.rs +++ b/smite/src/channel_state.rs @@ -4,6 +4,7 @@ //! commitments, and what each side has sent so far, against which the //! target's later messages are checked. +use crate::bolt::Features; use crate::channel_tx::{ChannelCommitments, ChannelConfig, CommitmentState, HolderIdentity, Side}; use bitcoin::secp256k1::PublicKey; @@ -56,11 +57,24 @@ pub struct ChannelState { /// 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>, + /// The `upfront_shutdown_script` we committed to in our `open_channel`, if + /// any. + pub our_upfront_shutdown_script: Option>, + /// The `upfront_shutdown_script` the peer committed to in its + /// `accept_channel`, if any. + pub counterparty_upfront_shutdown_script: Option>, +} + +/// Returns `true` if a `shutdown` carrying `scriptpubkey` keeps the commitment +/// to `upfront`. A missing or empty `upfront` commits to nothing. +fn verify_upfront_shutdown_script(upfront: Option<&[u8]>, scriptpubkey: &[u8]) -> bool { + upfront.is_none_or(|upfront| upfront.is_empty() || upfront == scriptpubkey) } impl ChannelState { /// Constructs a channel state with both next per-commitment points unknown. #[must_use] + #[allow(clippy::too_many_arguments)] pub fn new( config: ChannelConfig, holder: HolderIdentity, @@ -68,6 +82,8 @@ impl ChannelState { is_funding_outpoint_valid: bool, was_funding_mined_prematurely: bool, sent_invalid_signature: bool, + our_upfront_shutdown_script: Option>, + counterparty_upfront_shutdown_script: Option>, ) -> Self { Self { config, @@ -82,9 +98,42 @@ impl ChannelState { funding_signed_received: false, our_shutdown_script: None, counterparty_shutdown_script: None, + our_upfront_shutdown_script, + counterparty_upfront_shutdown_script, } } + /// Returns `true` if the `shutdown` we sent, if any, keeps the + /// `upfront_shutdown_script` we committed to in our `open_channel`. + /// + /// A commitment only binds when `option_upfront_shutdown_script` was + /// negotiated. + #[must_use] + pub fn verify_our_upfront_shutdown_script(&self, negotiated_features: &Features) -> bool { + !negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) + || self.our_shutdown_script.as_deref().is_none_or(|sent| { + verify_upfront_shutdown_script(self.our_upfront_shutdown_script.as_deref(), sent) + }) + } + + /// Returns `true` if a peer `shutdown` carrying `scriptpubkey` keeps the + /// `upfront_shutdown_script` it committed to in its `accept_channel`. + /// + /// A commitment only binds when `option_upfront_shutdown_script` was + /// negotiated. + #[must_use] + pub fn verify_counterparty_upfront_shutdown_script( + &self, + scriptpubkey: &[u8], + negotiated_features: &Features, + ) -> bool { + !negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) + || verify_upfront_shutdown_script( + self.counterparty_upfront_shutdown_script.as_deref(), + scriptpubkey, + ) + } + /// Returns the state of the holder's commitment. #[must_use] pub fn holder_commitment_state(&self) -> &CommitmentState { diff --git a/smite/src/oracles/channel_ready.rs b/smite/src/oracles/channel_ready.rs index 3dca5657..3b7045c6 100644 --- a/smite/src/oracles/channel_ready.rs +++ b/smite/src/oracles/channel_ready.rs @@ -193,7 +193,7 @@ mod tests { htlc_basepoint_privkey: secret_key(1), }; - ChannelState::new(config, holder, commitments, true, false, false) + ChannelState::new(config, holder, commitments, true, false, false, None, None) } /// Oracle inputs for testing. Defaults to a conforming `channel_ready` diff --git a/smite/src/oracles/funding_signed.rs b/smite/src/oracles/funding_signed.rs index 9649ac9f..1b6457cc 100644 --- a/smite/src/oracles/funding_signed.rs +++ b/smite/src/oracles/funding_signed.rs @@ -137,7 +137,7 @@ mod tests { htlc_basepoint_privkey: skey1, }; - ChannelState::new(config, holder, commitments, true, false, false) + ChannelState::new(config, holder, commitments, true, false, false, None, None) } /// Valid `funding_signed` message for testing. diff --git a/smite/src/oracles/shutdown.rs b/smite/src/oracles/shutdown.rs index 84226121..61635871 100644 --- a/smite/src/oracles/shutdown.rs +++ b/smite/src/oracles/shutdown.rs @@ -13,13 +13,15 @@ pub struct ShutdownContext<'a> { /// 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. + /// standard `scriptpubkey` forms and whether `upfront_shutdown_script`s + /// bind. 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. +/// was allowed to reply on it, and that its `scriptpubkey` keeps the +/// `upfront_shutdown_script` the target committed to and is a standard form for +/// the negotiated features. pub struct ShutdownOracle; impl Oracle> for ShutdownOracle { @@ -46,6 +48,44 @@ impl Oracle> for ShutdownOracle { )); } + // Check that the target did not reply to a `shutdown` that broke our own + // upfront commitment, since BOLT 2 requires it to fail the connection + // instead of replying. + if !channel.verify_our_upfront_shutdown_script(context.negotiated_features) { + return Err(Violation::InvalidShutdown( + *channel_id, + format!( + "replied to a shutdown breaking our upfront_shutdown_script: sent {}, committed to {}", + hex::encode(channel.our_shutdown_script.as_deref().unwrap_or_default()), + hex::encode( + channel + .our_upfront_shutdown_script + .as_deref() + .unwrap_or_default() + ), + ), + )); + } + + // Check that the target kept the commitment it made in `accept_channel`. + if !channel + .verify_counterparty_upfront_shutdown_script(scriptpubkey, context.negotiated_features) + { + return Err(Violation::InvalidShutdown( + *channel_id, + format!( + "upfront_shutdown_script mismatch: sent {}, committed to {}", + hex::encode(scriptpubkey), + hex::encode( + channel + .counterparty_upfront_shutdown_script + .as_deref() + .unwrap_or_default() + ), + ), + )); + } + // 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( @@ -82,10 +122,10 @@ mod tests { PublicKey::from_secret_key(&Secp256k1::new(), &secret_key(seed)) } - /// A standard P2WPKH `scriptpubkey`. - fn p2wpkh() -> Vec { + /// A standard P2WPKH `scriptpubkey`, hashing to `hash_byte` repeated. + fn p2wpkh(hash_byte: u8) -> Vec { let mut spk = vec![0x00, 0x14]; - spk.extend_from_slice(&[0xab; 20]); + spk.extend_from_slice(&[hash_byte; 20]); spk } @@ -107,7 +147,7 @@ mod tests { /// Valid `shutdown` message for testing. fn shutdown() -> Shutdown { - Shutdown::for_channel(ChannelId::new([1u8; 32]), p2wpkh()) + Shutdown::for_channel(ChannelId::new([1u8; 32]), p2wpkh(0xab)) } /// Funded channel state for testing. @@ -141,7 +181,7 @@ mod tests { htlc_basepoint_privkey: secret_key(1), }; - ChannelState::new(config, holder, commitments, true, false, false) + ChannelState::new(config, holder, commitments, true, false, false, None, None) } /// Oracle inputs for testing. Defaults to a conforming `shutdown` for a @@ -241,6 +281,105 @@ mod tests { } } + fn upfront_feature() -> Features { + Features::from_bits(&[Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT]) + } + + /// Inputs where both sides committed to an `upfront_shutdown_script` and + /// each has so far kept it. + fn upfront_inputs() -> OracleInputs { + let mut inputs = OracleInputs { + shutdown: Shutdown::for_channel(shutdown().channel_id, p2wpkh(0x33)), + negotiated_features: upfront_feature(), + ..Default::default() + }; + let channel = inputs.channel_mut(); + channel.our_upfront_shutdown_script = Some(p2wpkh(0x55)); + channel.our_shutdown_script = Some(p2wpkh(0x55)); + channel.counterparty_upfront_shutdown_script = Some(p2wpkh(0x33)); + inputs + } + + #[test] + fn kept_upfront_scripts_pass() { + assert_pass(&upfront_inputs()); + } + + #[test] + fn rejects_counterparty_upfront_script_mismatch() { + let mut inputs = upfront_inputs(); + // Standard form, but not the one the target committed to. + inputs.shutdown = Shutdown::for_channel(shutdown().channel_id, p2wpkh(0x44)); + + assert_fail( + &inputs, + &format!( + "upfront_shutdown_script mismatch: sent {}, committed to {}", + hex::encode(p2wpkh(0x44)), + hex::encode(p2wpkh(0x33)), + ), + ); + } + + #[test] + fn rejects_reply_to_shutdown_breaking_our_upfront_script() { + let mut inputs = upfront_inputs(); + // We broke our own commitment, so the target had to fail the connection + // rather than reply, whatever its own `scriptpubkey` is. + inputs.channel_mut().our_shutdown_script = Some(p2wpkh(0x66)); + + assert_fail( + &inputs, + &format!( + "replied to a shutdown breaking our upfront_shutdown_script: sent {}, committed to {}", + hex::encode(p2wpkh(0x66)), + hex::encode(p2wpkh(0x55)), + ), + ); + } + + #[test] + fn empty_upfront_script_commits_to_nothing() { + // BOLT 2 lets either side opt out with a zero-length script. + let mut inputs = upfront_inputs(); + let channel = inputs.channel_mut(); + channel.our_upfront_shutdown_script = Some(Vec::new()); + channel.our_shutdown_script = Some(p2wpkh(0x66)); + channel.counterparty_upfront_shutdown_script = Some(Vec::new()); + + assert_pass(&inputs); + } + + #[test] + fn upfront_scripts_only_bind_when_negotiated() { + let mut inputs = upfront_inputs(); + inputs.negotiated_features = Features::new(); + inputs.channel_mut().our_shutdown_script = Some(p2wpkh(0x66)); + inputs.shutdown = Shutdown::for_channel(shutdown().channel_id, p2wpkh(0x44)); + + assert_pass(&inputs); + } + + #[test] + fn unsolicited_shutdown_keeps_our_upfront_script() { + // The target may close first, so there is no `shutdown` of ours to + // break our own commitment. + let mut inputs = upfront_inputs(); + inputs.channel_mut().our_shutdown_script = None; + + assert_pass(&inputs); + } + + #[test] + fn rejects_non_standard_script_matching_upfront_script() { + // A commitment to a non-standard form does not make it sendable. + let mut inputs = upfront_inputs(); + inputs.shutdown = Shutdown::for_channel(shutdown().channel_id, p2pkh()); + inputs.channel_mut().counterparty_upfront_shutdown_script = Some(p2pkh()); + + assert_fail(&inputs, "non-standard scriptpubkey"); + } + #[test] fn accepts_anysegwit_only_when_negotiated() { let mut spk = vec![0x51, 0x02]; diff --git a/smite/src/violation.rs b/smite/src/violation.rs index c6de5b32..2e4dee9a 100644 --- a/smite/src/violation.rs +++ b/smite/src/violation.rs @@ -66,7 +66,12 @@ pub enum Violation { /// 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 + /// target should have failed instead, + /// - it replies to a `shutdown` whose `scriptpubkey` broke the + /// `upfront_shutdown_script` we committed to, which the target should have + /// failed the connection over instead, + /// - its `scriptpubkey` differs from the `upfront_shutdown_script` the + /// target committed to in its `accept_channel`, or /// - its `scriptpubkey` is not a standard form for the negotiated features. #[error("invalid shutdown for channel_id {0}: {1}")] InvalidShutdown(ChannelId, String),