diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index d7cc31d1..77092672 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -124,6 +124,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 98e5014c..181ef535 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -241,6 +241,19 @@ 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, 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 no reply is awaited before then. + /// + /// 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`. @@ -571,6 +584,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"), @@ -594,7 +608,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(_) @@ -742,6 +758,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] } @@ -789,6 +806,7 @@ impl Operation { | Self::SendShutdown | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::MineBlocks(_) | Self::BroadcastTransaction | Self::LookupShortChannelId => vec![], @@ -840,6 +858,7 @@ impl Operation { | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::MineBlocks(_) | Self::BroadcastTransaction => true, } @@ -896,6 +915,7 @@ impl Operation { | Self::RecvAcceptChannel | Self::RecvFundingSigned | Self::RecvChannelReady + | Self::RecvShutdown | Self::MineBlocks(_) | Self::BroadcastTransaction | Self::LookupShortChannelId => false, @@ -951,6 +971,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 76f7389e..5493e7f0 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 fb41854d..76668ad4 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -522,13 +522,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) } @@ -583,6 +584,32 @@ 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]); + 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. @@ -684,6 +711,7 @@ impl Executor { /// /// This currently includes: /// - `channel_ready` + /// - `shutdown` /// /// # Errors /// @@ -720,6 +748,20 @@ impl Executor { ); Ok(true) } + Message::Shutdown(sd) => { + log::debug!("received shutdown on {}", sd.channel_id); + // TODO: judge received `shutdown`s with an oracle here. It must reject one: + // * for an untracked channel + // * for a channel whose `funding_signed` we haven't received yet + // * for a channel we already received a `shutdown` on + // * with a `scriptpubkey` in a form the negotiated features disallow + // * with a `scriptpubkey` differing from a non-zero-length upfront shutdown + // script the target sent in `accept_channel` + // * sent while updates are pending on our commitment transaction, once HTLCs + // are supported + record_recv_shutdown(&mut self.channel_states, sd); + Ok(true) + } _ => Ok(false), } } @@ -1364,6 +1406,23 @@ 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 { + // TODO: we only expect `shutdown` when all HTLCs are resolved + channel_states.get(&channel_id).is_some_and(|state| { + state.our_shutdown_script.is_some() + && state.counterparty_shutdown_script.is_none() + && state.received_channel_ready_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. /// @@ -1507,6 +1566,41 @@ 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) { + // TODO: a second `shutdown` on the same channel violates BOLT 2 and is dropped + // here unjudged until we have a shutdown oracle + 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 5cbbec61..8afed8f8 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; @@ -1328,6 +1328,422 @@ 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_send_shutdown_keeps_first_script() { + let (mut fx, _) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + let first = ShutdownScriptVariant::P2wpkh([0xab; 20]); + let second = ShutdownScriptVariant::P2wpkh([0xcd; 20]); + + let mut b = ProgramBuilder::new(); + establish_channel(&mut b, 6); + send_shutdown(&mut b, channel_id, first.clone()); + send_shutdown(&mut b, channel_id, second.clone()); + + fx.run(&b.build()); + + // The target may ignore our second `shutdown`, so the first one is what it + // is replying to. + assert_eq!( + fx.channel_state(&channel_id).our_shutdown_script, + Some(first.encode()) + ); + // Both were still put on the wire. + let sent: Shutdown = fx.sent(fx.sent_len() - 1); + assert_eq!(sent.scriptpubkey, second.encode()); +} + +#[test] +fn execute_recv_shutdown() { + let (fx, _) = recv_channel_ready_fixture(); + let channel_id = funding_channel_id(); + let our_script = ShutdownScriptVariant::P2wpkh([0xab; 20]); + let target_script = ShutdownScriptVariant::P2wpkh([0xcd; 20]); + let mut fx = fx.queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + target_script.encode(), + ))); + + fx.run(&recv_shutdown_program(channel_id, our_script)); + + // TODO: Once we add IR support for building closing transactions, verify + // the returned scriptpubkey through the closing transaction's output. + + assert_eq!( + fx.channel_state(&channel_id).counterparty_shutdown_script, + Some(target_script.encode()) + ); + 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_keeps_first_reply() { + // Two `shutdown`s arrive while `RecvChannelReady` is waiting, so both are + // recorded before `RecvShutdown` runs, but only the first is kept. + let channel_id = funding_channel_id(); + let first = ShutdownScriptVariant::P2wpkh([0x11; 20]); + let second = ShutdownScriptVariant::P2wpkh([0x22; 20]); + let mut fx = recv_funding_signed_fixture() + .queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + first.encode(), + ))) + .queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + second.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(first.encode()) + ); + let echoed: Shutdown = fx.sent(fx.sent_len() - 1); + assert_eq!(echoed.scriptpubkey, first.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 target_script = ShutdownScriptVariant::P2wpkh([0xcd; 20]); + let mut fx = fx.queue(&Message::Shutdown(Shutdown::for_channel( + channel_id, + target_script.encode(), + ))); + + fx.run(&recv_shutdown_program( + channel_id, + ShutdownScriptVariant::Empty, + )); + + assert_eq!( + fx.channel_state(&channel_id).counterparty_shutdown_script, + Some(target_script.encode()) + ); + 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, } }