diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index a80ff22d..eb8e6c91 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -108,6 +108,7 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { | Operation::SendOpenChannel | Operation::SendFundingCreated | Operation::RecvAcceptChannel + | Operation::RecvFundingSigned | Operation::BroadcastTransaction => { unreachable!("is_param_mutable returned true for {op:?}") } diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 8e32e0b2..df5694c1 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -195,6 +195,10 @@ pub enum Operation { /// Receive and parse an `accept_channel` response. /// Produces an `AcceptChannel` compound variable. RecvAcceptChannel, + /// Receive and parse a `funding_signed` response. + /// Produces the `ChannelId` carried in the message. + /// TODO: Add `ExtractFundingSigned` when implementing force-close scenarios. + RecvFundingSigned, /// Mines the given number of blocks on the Bitcoin network. MineBlocks(u8), /// Sign wallet inputs of the transaction and broadcast it via `bitcoin-cli`. @@ -606,7 +610,7 @@ fn format_hex(bytes: &[u8]) -> String { } /// Print an Operation. Operations that take no variable inputs include parens -/// (e.g., `LoadAmount(100000)`, `RecvAcceptChannel()`). Operations that do take +/// (e.g., `LoadAmount(100000)`, `LoadChainHashFromContext()`). Operations that do take /// inputs omit parens so `Program::Display` can append them `(v0, v1, ...)`. impl fmt::Display for Operation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -629,9 +633,7 @@ impl fmt::Display for Operation { Self::LoadChannelType(v) => write!(f, "LoadChannelType({v})"), Self::LoadTargetPubkeyFromContext => write!(f, "LoadTargetPubkeyFromContext()"), Self::LoadChainHashFromContext => write!(f, "LoadChainHashFromContext()"), - Self::RecvAcceptChannel => write!(f, "RecvAcceptChannel()"), Self::MineBlocks(v) => write!(f, "MineBlocks({v})"), - Self::BroadcastTransaction => write!(f, "BroadcastTransaction"), // Operations with inputs: parens added by Program::Display. Self::DerivePoint => write!(f, "DerivePoint"), Self::ExtractAcceptChannel(field) => write!(f, "Extract{field}"), @@ -649,6 +651,9 @@ impl fmt::Display for Operation { Self::SendMessage => write!(f, "SendMessage"), Self::SendOpenChannel => write!(f, "SendOpenChannel"), Self::SendFundingCreated => write!(f, "SendFundingCreated"), + Self::RecvAcceptChannel => write!(f, "RecvAcceptChannel"), + Self::RecvFundingSigned => write!(f, "RecvFundingSigned"), + Self::BroadcastTransaction => write!(f, "BroadcastTransaction"), } } } @@ -670,7 +675,7 @@ impl Operation { Self::LoadBytes(_) | Self::LoadShutdownScript(_) => Some(VariableType::Bytes), Self::LoadFeatures(_) | Self::LoadChannelType(_) => Some(VariableType::Features), Self::LoadPrivateKey(_) => Some(VariableType::PrivateKey), - Self::LoadChannelId(_) => Some(VariableType::ChannelId), + Self::LoadChannelId(_) | Self::RecvFundingSigned => Some(VariableType::ChannelId), Self::LoadTargetPubkeyFromContext | Self::DerivePoint => Some(VariableType::Point), Self::LoadChainHashFromContext => Some(VariableType::ChainHash), Self::ExtractAcceptChannel(field) => Some(field.output_type()), @@ -722,6 +727,7 @@ impl Operation { Self::SendOpenChannel => vec![VariableType::OpenChannelMessage], Self::SendFundingCreated => vec![VariableType::FundingCreatedMessage], Self::RecvAcceptChannel => vec![VariableType::SentOpenChannel], + Self::RecvFundingSigned => vec![VariableType::SentFundingCreated], Self::BroadcastTransaction => vec![VariableType::FundingTransaction], Self::BuildOpenChannel => vec![ @@ -838,6 +844,7 @@ impl Operation { | Self::SendMessage | Self::SendOpenChannel | Self::SendFundingCreated + | Self::RecvFundingSigned | Self::MineBlocks(_) | Self::BroadcastTransaction => vec![], @@ -857,6 +864,7 @@ impl Operation { | Self::SendOpenChannel | Self::SendFundingCreated | Self::RecvAcceptChannel + | Self::RecvFundingSigned | Self::MineBlocks(_) | Self::CreateFundingTransaction | Self::BroadcastTransaction => true, @@ -921,6 +929,7 @@ impl Operation { | Self::SendOpenChannel | Self::SendFundingCreated | Self::RecvAcceptChannel + | Self::RecvFundingSigned | Self::BroadcastTransaction => false, } } diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index 0ff5f233..31dfd981 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -149,13 +149,13 @@ fn display_open_channel_program() { ], }, Instruction { - operation: Operation::SendMessage, + operation: Operation::SendOpenChannel, inputs: vec![26], }, // Receive accept_channel and extract fields. Instruction { operation: Operation::RecvAcceptChannel, - inputs: vec![], + inputs: vec![27], }, Instruction { operation: Operation::ExtractAcceptChannel(AcceptChannelField::FundingPubkey), @@ -203,8 +203,8 @@ fn display_open_channel_program() { "v24 = LoadShutdownScript(Empty)".into(), "v25 = LoadFeatures()".into(), "v26 = BuildOpenChannel(v13, v12, v14, v15, v16, v17, v18, v19, v20, v21, v22, v1, v3, v5, v7, v9, v11, v23, v24, v25)".into(), - "SendMessage(v26)".into(), - "v28 = RecvAcceptChannel()".into(), + "v27 = SendOpenChannel(v26)".into(), + "v28 = RecvAcceptChannel(v27)".into(), "v29 = ExtractFundingPubkey(v28)".into(), "v30 = ExtractFirstPerCommitmentPoint(v28)".into(), ]; @@ -597,7 +597,8 @@ fn displays_create_and_broadcast_tx_program() { } #[test] -fn displays_build_and_send_funding_created_program() { +#[allow(clippy::too_many_lines)] +fn displays_send_funding_created_recv_funding_signed_program() { let instructions = vec![ // Funding transaction. Instruction { @@ -673,6 +674,11 @@ fn displays_build_and_send_funding_created_program() { operation: Operation::SendFundingCreated, inputs: vec![15], }, + // receive funding_signed. + Instruction { + operation: Operation::RecvFundingSigned, + inputs: vec![16], + }, ]; let program = Program { instructions }; @@ -700,6 +706,7 @@ fn displays_build_and_send_funding_created_program() { "v14 = LoadFeeratePerKw(253)".into(), "v15 = BuildFundingCreated(v4, v2, v5, v0, v7, v7, v7, v8, v9, v11, v11, v11, v11, v8, v9, v12, v13, v14, v7, v11)".into(), "v16 = SendFundingCreated(v15)".into(), + "v17 = RecvFundingSigned(v16)".into(), ]; assert_eq!(lines.len(), expected.len(), "line count mismatch"); diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 4d75409e..1fb1721d 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -8,8 +8,8 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{OutPoint, ScriptBuf}; use smite::bitcoin::{BitcoinCli, Utxo}; use smite::bolt::{ - AcceptChannel, ChannelAnnouncement, ChannelId, ChannelUpdate, FundingCreated, Message, - NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, msg_type, + AcceptChannel, ChannelAnnouncement, ChannelId, ChannelUpdate, FundingCreated, FundingSigned, + Message, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, msg_type, }; use smite::channel_tx::{ ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side, @@ -120,6 +120,19 @@ pub enum ExecuteError { /// Failed to construct the initial commitment state. #[error("commitment: {0}")] Commitment(#[from] smite::channel_tx::CommitmentError), + + /// Received a `funding_signed` for a channel with no tracked state. + #[error("unknown channel: no tracked state for channel_id {0:?}")] + UnknownChannel(ChannelId), + + /// The opener cannot afford the feerate for the commitment transaction. + #[error("opener cannot afford commitment fee for channel_id {0:?}")] + OpenerCannotAffordFee(ChannelId), + + /// The counterparty's signature in `funding_signed` failed to verify + /// against the holder's first commitment transaction. + #[error("invalid counterparty signature for channel_id {0:?}")] + InvalidCounterpartySignature(ChannelId), } /// Executes IR programs against a target over an established connection. @@ -158,10 +171,15 @@ impl Executor { /// /// # Errors /// - /// Returns an error on a connection/send/receive failure, a decode failure of - /// a received message, an unexpected message type from the target, when - /// wallet funds are insufficient to perform a channel operation, or when the - /// initial commitment transaction cannot be constructed. + /// Returns an error when: + /// - a connection/send/receive operation fails + /// - a received message fails to decode + /// - the target sends an unexpected message type + /// - wallet funds are insufficient to perform a channel operation + /// - the initial commitment transaction cannot be constructed + /// - no channel state exists for a received `funding_signed` + /// - the opener cannot afford the commitment feerate + /// - the counterparty's signature fails verification /// /// # Panics /// @@ -318,6 +336,15 @@ impl Executor { Some(Variable::AcceptChannel(ac)) } + Operation::RecvFundingSigned => { + consume_sent_funding_created(&mut variables, instr.inputs[0]); + log::debug!("[{:?}] RecvFundingSigned: waiting", start.elapsed()); + let fs = recv_funding_signed(&mut self.conn)?; + log::debug!("[{:?}] RecvFundingSigned: received", start.elapsed()); + verify_funding_signed(&fs, &self.channel_states)?; + Some(Variable::ChannelId(fs.channel_id)) + } + Operation::MineBlocks(v) => { self.bitcoin_cli.mine_blocks(*v); log::debug!("[{:?}] MineBlocks: mined {} block(s)", start.elapsed(), v); @@ -548,6 +575,19 @@ fn consume_sent_open_channel(variables: &mut [Option], index: usize) { } } +fn consume_sent_funding_created(variables: &mut [Option], index: usize) { + match resolve(variables, index) { + Variable::SentFundingCreated => { + // Consume the affine `SentFundingCreated`. + variables[index] = None; + } + other => panic!( + "variable {index}: expected SentFundingCreated, got {:?}", + other.var_type(), + ), + } +} + // -- Operation handlers -- /// Create a funding transaction by querying the bitcoind for UTXOs and a @@ -834,6 +874,47 @@ fn recv_accept_channel(conn: &mut impl Connection) -> Result Result { + match recv_non_ping(conn)? { + Message::FundingSigned(fs) => Ok(fs), + other => Err(ExecuteError::UnexpectedMessage { + expected: msg_type::FUNDING_SIGNED, + got: other.msg_type(), + }), + } +} + +/// Verifies the counterparty's signature from a `funding_signed` message using +/// the channel state associated with the message's `channel_id`. +/// +/// # Errors +/// +/// Returns [`ExecuteError::UnknownChannel`] if no channel state exists for the +/// given `channel_id`, [`ExecuteError::OpenerCannotAffordFee`] if the opener +/// cannot afford the commitment feerate, or [`ExecuteError::InvalidCounterpartySignature`] +/// if the signature is invalid for the holder's initial commitment transaction. +fn verify_funding_signed( + fs: &FundingSigned, + channel_states: &HashMap, +) -> Result<(), ExecuteError> { + let state = channel_states + .get(&fs.channel_id) + .ok_or(ExecuteError::UnknownChannel(fs.channel_id))?; + + // The opener cannot afford the fee, so the acceptor must not send + // `funding_signed`. Receiving one is a protocol violation. + if !state.config.can_opener_afford_feerate(&state.commitment) { + return Err(ExecuteError::OpenerCannotAffordFee(fs.channel_id)); + } + + state + .config + .verify_counterparty_signature(&state.commitment, &state.holder, &fs.signature) + .then_some(()) + .ok_or(ExecuteError::InvalidCounterpartySignature(fs.channel_id)) +} + /// Extracts a field from a parsed `accept_channel` message. fn extract_field(ac: &AcceptChannel, field: AcceptChannelField) -> Variable { match field { @@ -1971,7 +2052,7 @@ mod tests { assert_eq!(funds_err.required, Amount::from_sat(10_007_290)); } - fn build_and_send_funding_created_instructions() -> Vec { + fn send_funding_created_and_recv_funding_signed_instructions() -> Vec { let mut instrs = create_and_broadcast_tx_instructions(); instrs.extend(vec![ Instruction { @@ -2012,22 +2093,45 @@ mod tests { operation: Operation::SendFundingCreated, inputs: vec![15], }, + Instruction { + operation: Operation::RecvFundingSigned, + inputs: vec![16], + }, ]); instrs } #[test] - fn execute_build_and_send_funding_created() { + fn execute_send_funding_created_and_recv_funding_signed() { let mock_cli = MockBitcoinCli { utxos: vec![sample_utxo()], change_spk: sample_change_spk(), ..Default::default() }; + + // The acceptor replies with funding_signed carrying its signature over + // the opener's commitment. + let channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { + txid: "09b0549b35f14ee862f63bd75811c6c27963c4dea6766ec6836952ec78df1e7e" + .parse() + .unwrap(), + vout: 0, + }); + + // The expected signature here was computed using LDK as the source of + // truth. + let fs_bytes = Message::FundingSigned(FundingSigned { + channel_id, + signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), + }) + .encode(); + let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + executor.conn.queue_recv(fs_bytes); executor .execute( &Program { - instructions: build_and_send_funding_created_instructions(), + instructions: send_funding_created_and_recv_funding_signed_instructions(), }, std::time::Instant::now(), ) @@ -2047,10 +2151,6 @@ mod tests { assert_eq!(fc.funding_output_index, 0); // Verify the signature sent by the opener on the acceptor side. - let channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { - txid: fc.funding_txid, - vout: u32::from(fc.funding_output_index), - }); let state = executor.channel_states.get(&channel_id).unwrap(); let holder = HolderIdentity { side: Side::Acceptor, @@ -2071,7 +2171,7 @@ mod tests { fn execute_build_funding_created_push_exceeds_funding() { // push_msat (v12) larger than the funding amount surfaces the commitment // construction error. - let mut instrs = build_and_send_funding_created_instructions(); + let mut instrs = send_funding_created_and_recv_funding_signed_instructions(); instrs[12] = Instruction { operation: Operation::LoadAmount(20_000_000_000), inputs: vec![], @@ -2099,7 +2199,7 @@ mod tests { fn execute_build_funding_created_funding_msat_overflow() { // Re-point funding_satoshis (input index 1) to the u64::MAX amount (v14) // so converting it to millisatoshis overflows. - let mut instrs = build_and_send_funding_created_instructions(); + let mut instrs = send_funding_created_and_recv_funding_signed_instructions(); instrs[15].inputs[1] = 14; let mock_cli = MockBitcoinCli { utxos: vec![sample_utxo()], @@ -2120,6 +2220,119 @@ mod tests { )); } + #[test] + fn execute_recv_funding_signed_unknown_channel() { + let mock_cli = MockBitcoinCli { + utxos: vec![sample_utxo()], + change_spk: sample_change_spk(), + ..Default::default() + }; + + let channel_id = ChannelId::new([0xbb; 32]); + + // The expected signature here was computed using LDK as the source of + // truth. + let fs_bytes = Message::FundingSigned(FundingSigned { + channel_id, + signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), + }) + .encode(); + + let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + executor.conn.queue_recv(fs_bytes); + let err = executor + .execute( + &Program { + instructions: send_funding_created_and_recv_funding_signed_instructions(), + }, + std::time::Instant::now(), + ) + .unwrap_err(); + assert!(matches!(err, ExecuteError::UnknownChannel(id) if id == channel_id)); + } + + #[test] + fn execute_recv_funding_signed_opener_cannot_afford_fee() { + let mock_cli = MockBitcoinCli { + utxos: vec![sample_utxo()], + change_spk: sample_change_spk(), + ..Default::default() + }; + + let channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { + txid: "09b0549b35f14ee862f63bd75811c6c27963c4dea6766ec6836952ec78df1e7e" + .parse() + .unwrap(), + vout: 0, + }); + + // The expected signature here was computed using LDK as the source of + // truth. + let fs_bytes = Message::FundingSigned(FundingSigned { + channel_id, + signature: "304502210096c5e8ad834af46b42a4301828852205655d16dc8d55333831de49642d70c60a02205466283b9557447dd4c5374b90eda80f023017164dd04deb7c45cfc472e03023".parse().unwrap(), + }) + .encode(); + + let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + executor.conn.queue_recv(fs_bytes); + + let mut instrs = send_funding_created_and_recv_funding_signed_instructions(); + // Increase the pushed amount so the opener cannot afford the required + // fee when the commitment is built and funding_signed is received. + instrs[12].operation = Operation::LoadAmount(10_000_000_000); + + let err = executor + .execute( + &Program { + instructions: instrs, + }, + std::time::Instant::now(), + ) + .unwrap_err(); + assert!(matches!( + err, + ExecuteError::OpenerCannotAffordFee(id) if id == channel_id + )); + } + + #[test] + fn execute_recv_funding_signed_invalid_signature() { + let mock_cli = MockBitcoinCli { + utxos: vec![sample_utxo()], + change_spk: sample_change_spk(), + ..Default::default() + }; + + let channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { + txid: "09b0549b35f14ee862f63bd75811c6c27963c4dea6766ec6836952ec78df1e7e" + .parse() + .unwrap(), + vout: 0, + }); + let fs_bytes = Message::FundingSigned(FundingSigned { + channel_id, + signature: Signature::from_compact(&[0u8; 64]) + .expect("zero bytes parse as a signature"), + }) + .encode(); + + let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + executor.conn.queue_recv(fs_bytes); + let err = executor + .execute( + &Program { + instructions: send_funding_created_and_recv_funding_signed_instructions(), + }, + std::time::Instant::now(), + ) + .unwrap_err(); + assert!(matches!( + err, + ExecuteError::InvalidCounterpartySignature(id) if id == channel_id + )); + } + // -- extract_field tests -- // TODO: Once we can actually construct and send accept_channel messages, it diff --git a/smite-scenarios/src/scenarios/ir.rs b/smite-scenarios/src/scenarios/ir.rs index 1f8f48e4..304354ce 100644 --- a/smite-scenarios/src/scenarios/ir.rs +++ b/smite-scenarios/src/scenarios/ir.rs @@ -91,6 +91,27 @@ impl> Scenario for IrScenario { // bug in the target. log::debug!("[{:?}] invalid commitment: {e}", start.elapsed()); } + Err(ExecuteError::UnknownChannel(id)) => { + // The target sent a funding_signed for a channel it was never + // asked to open. This is a protocol violation by the target. + return ScenarioResult::Fail(format!("unknown channel: {id:?}")); + } + Err(ExecuteError::OpenerCannotAffordFee(id)) => { + // The opener cannot afford the commitment feerate. This is a + // protocol violation by the target (it should not have sent + // funding_signed if the opener cannot afford the fee). + return ScenarioResult::Fail(format!( + "opener cannot afford fee for channel {id:?}" + )); + } + Err(ExecuteError::InvalidCounterpartySignature(id)) => { + // The target's funding_signed signature did not verify against + // the holder's commitment transaction. This is a protocol + // violation by the target. + return ScenarioResult::Fail(format!( + "invalid counterparty signature for channel {id:?}" + )); + } } // Ping-pong sync to ensure the target has at least done the initial