diff --git a/smite-ir-mutator/src/lib.rs b/smite-ir-mutator/src/lib.rs index 30acdd3b..3e479b87 100644 --- a/smite-ir-mutator/src/lib.rs +++ b/smite-ir-mutator/src/lib.rs @@ -17,6 +17,13 @@ //! bypasses our custom mutators. This was an AFL++ bug fixed upstream in //! commit eddb2701b022351fb34b696ccf923bb856e9d953. //! +//! Optionally: +//! - `SMITE_IR_GENERATORS=v1|v2|all` -- which generators to draw from. BOLT 2 +//! makes the two channel establishment flows mutually exclusive on one +//! connection, so a campaign against an `ir` scenario wants `v1` and one +//! against `ir_v2` wants `v2`; the other flow's programs would only ever be +//! rejected. Defaults to `all`, which draws from both. +//! //! # Logging //! //! Logging is opt-in: [`afl_custom_init`] installs a logger only when @@ -60,6 +67,24 @@ struct MutatorState { /// Sequence of actions taken in the last [`afl_custom_fuzz`] call, used by /// [`afl_custom_describe`] to name queue entries. last_sequence: Vec<&'static str>, + /// Generators this campaign draws from, selected by `SMITE_IR_GENERATORS`. + generators: &'static [AnyGenerator], +} + +/// Reads `SMITE_IR_GENERATORS` and returns the generator set it names, +/// defaulting to all of them. +fn generators_from_env() -> &'static [AnyGenerator] { + match std::env::var("SMITE_IR_GENERATORS").as_deref() { + Ok("v1") => AnyGenerator::V1, + Ok("v2") => AnyGenerator::V2, + Ok("all") | Err(_) => AnyGenerator::ALL, + Ok(other) => { + eprintln!( + "[smite-ir-mutator] WARNING: unknown SMITE_IR_GENERATORS={other:?}, using all", + ); + AnyGenerator::ALL + } + } } impl MutatorState { @@ -69,6 +94,7 @@ impl MutatorState { out_buf: Vec::new(), description: Vec::new(), last_sequence: vec!["init"], + generators: generators_from_env(), } } @@ -76,10 +102,10 @@ impl MutatorState { /// the registered generators. fn generate_fresh(&mut self) -> Program { let mut builder = ProgramBuilder::new(); - AnyGenerator::ALL + self.generators .iter() .choose(&mut self.rng) - .expect("AnyGenerator::ALL is non-empty") + .expect("the generator set is non-empty") .generate(&mut builder, &mut self.rng); self.last_sequence.clear(); self.last_sequence.push("fresh"); @@ -114,10 +140,11 @@ impl MutatorState { "instr-reorder" } 4 => { - let generator = *AnyGenerator::ALL + let generator = *self + .generators .iter() .choose(&mut self.rng) - .expect("AnyGenerator::ALL is non-empty"); + .expect("the generator set is non-empty"); let mutator = GeneratorInsertionMutator::new(generator); mutator.mutate(program, &mut self.rng); "gen-insert" diff --git a/smite-ir/src/builder.rs b/smite-ir/src/builder.rs index fd4843b1..791db8b0 100644 --- a/smite-ir/src/builder.rs +++ b/smite-ir/src/builder.rs @@ -222,12 +222,27 @@ impl ProgramBuilder { VariableType::AcceptChannel => { panic!("cannot generate fresh AcceptChannel: requires protocol interaction") } + VariableType::OpenChannel2Message => { + panic!("cannot generate fresh OpenChannel2Message: requires composed inputs") + } + VariableType::AcceptChannel2 => { + panic!("cannot generate fresh AcceptChannel2: requires protocol interaction") + } VariableType::FundingTransaction => { panic!("cannot generate fresh FundingTransaction: requires composed inputs") } VariableType::SentOpenChannel => { panic!("cannot generate fresh SentOpenChannel: affine type") } + VariableType::SentOpenChannel2 => { + panic!("cannot generate fresh SentOpenChannel2: affine type") + } + VariableType::SentInteractiveTx => { + panic!("cannot generate fresh SentInteractiveTx: affine type") + } + VariableType::SentCommitmentSigned => { + panic!("cannot generate fresh SentCommitmentSigned: affine type") + } VariableType::SentFundingCreated => { panic!("cannot generate fresh SentFundingCreated: affine type") } diff --git a/smite-ir/src/generators.rs b/smite-ir/src/generators.rs index 58d1b2df..81b3fca9 100644 --- a/smite-ir/src/generators.rs +++ b/smite-ir/src/generators.rs @@ -8,6 +8,7 @@ mod channel_announcement; mod channel_ready; mod channel_update; +mod dual_funding_flow; mod funding_created; mod funding_flow; mod node_announcement; @@ -16,6 +17,7 @@ mod open_channel; pub use channel_announcement::ChannelAnnouncementGenerator; pub use channel_ready::ChannelReadyGenerator; pub use channel_update::ChannelUpdateGenerator; +pub use dual_funding_flow::DualFundingFlowGenerator; pub use funding_created::FundingCreatedGenerator; pub use funding_flow::FundingFlowGenerator; pub use node_announcement::NodeAnnouncementGenerator; @@ -42,9 +44,37 @@ pub enum AnyGenerator { FundingCreated(FundingCreatedGenerator), ChannelReady(ChannelReadyGenerator), FundingFlow(FundingFlowGenerator), + DualFundingFlow(DualFundingFlowGenerator), } impl AnyGenerator { + /// Generators for the v1 (single-funded) channel establishment flow, plus + /// the gossip generators, which are flow-independent. + /// + /// BOLT 2 makes the two establishment flows mutually exclusive on one + /// connection, so a campaign negotiating `option_dual_fund` can only ever + /// have the v1 generators rejected, and vice versa. Splitting them lets a + /// campaign spend its executions on programs its target can act on. + pub const V1: &[Self] = &[ + Self::ChannelAnnouncement(ChannelAnnouncementGenerator), + Self::ChannelUpdate(ChannelUpdateGenerator), + Self::NodeAnnouncement(NodeAnnouncementGenerator), + Self::OpenChannel(OpenChannelGenerator), + Self::FundingCreated(FundingCreatedGenerator), + Self::ChannelReady(ChannelReadyGenerator), + Self::FundingFlow(FundingFlowGenerator), + ]; + + /// Generators for the v2 (dual-funded) channel establishment flow, plus the + /// gossip generators. See [`Self::V1`]. + pub const V2: &[Self] = &[ + Self::ChannelAnnouncement(ChannelAnnouncementGenerator), + Self::ChannelUpdate(ChannelUpdateGenerator), + Self::NodeAnnouncement(NodeAnnouncementGenerator), + Self::ChannelReady(ChannelReadyGenerator), + Self::DualFundingFlow(DualFundingFlowGenerator), + ]; + /// All variants. Keep in sync with the enum definition. pub const ALL: &[Self] = &[ Self::ChannelAnnouncement(ChannelAnnouncementGenerator), @@ -54,6 +84,7 @@ impl AnyGenerator { Self::FundingCreated(FundingCreatedGenerator), Self::ChannelReady(ChannelReadyGenerator), Self::FundingFlow(FundingFlowGenerator), + Self::DualFundingFlow(DualFundingFlowGenerator), ]; } @@ -67,6 +98,7 @@ impl Generator for AnyGenerator { Self::FundingCreated(generator) => generator.generate(builder, rng), Self::ChannelReady(generator) => generator.generate(builder, rng), Self::FundingFlow(generator) => generator.generate(builder, rng), + Self::DualFundingFlow(generator) => generator.generate(builder, rng), } } } diff --git a/smite-ir/src/generators/dual_funding_flow.rs b/smite-ir/src/generators/dual_funding_flow.rs new file mode 100644 index 00000000..7b0fbff7 --- /dev/null +++ b/smite-ir/src/generators/dual_funding_flow.rs @@ -0,0 +1,223 @@ +//! Generator for the complete channel establishment v2 (dual-funded) flow. + +use rand::seq::IndexedRandom; +use rand::{Rng, RngExt}; + +use super::Generator; +use crate::builder::ProgramBuilder; +use crate::operation::{ + AcceptChannel2Field, ChannelTypeVariant, ShutdownScriptVariant, TxOutputRole, +}; +use crate::{Operation, VariableType}; + +/// `serial_id` of the funding output we contribute. BOLT 2 requires the +/// initiator to use even ids; picking these from a high range keeps them clear +/// of the ones assigned to inputs. +const FUNDING_OUTPUT_SERIAL_ID: u64 = 2000; + +/// `serial_id` of our change output. +const CHANGE_OUTPUT_SERIAL_ID: u64 = 2002; + +/// `nSequence` for the inputs we contribute. BOLT 2 caps it at `0xfffffffd` so +/// every input signals replaceability, and recommends one shared value across +/// implementations to avoid fingerprinting. +const SEQUENCE: u32 = 0xffff_fffd; + +/// Channel types most likely to be accepted, so the flow reaches its later +/// steps often enough to cover them. `LoadChannelType` is mutable, so the +/// mutator still reaches the rest. +const LIKELY_CHANNEL_TYPES: &[ChannelTypeVariant] = &[ + ChannelTypeVariant::Anchors, + ChannelTypeVariant::StaticRemoteKey, +]; + +/// Generates the complete channel establishment v2 flow. +/// +/// Emits instructions to: +/// 1. Build and send `open_channel2`, then receive `accept_channel2` +/// 2. Contribute inputs, the funding output and a change output through +/// interactive transaction construction, concluding with `tx_complete` +/// 3. Exchange `commitment_signed`, then `tx_signatures` +/// 4. Broadcast and confirm the funding transaction +/// 5. Complete the `channel_ready` exchange +#[derive(Clone, Copy)] +pub struct DualFundingFlowGenerator; + +impl Generator for DualFundingFlowGenerator { + // One linear protocol script, from open_channel2 through channel_ready. + // Splitting it would scatter a sequence that reads best in wire order. + #[allow(clippy::too_many_lines)] + fn generate(&self, builder: &mut ProgramBuilder, rng: &mut impl Rng) { + // Keys are generated fresh to ensure they're distinct. + let funding_privkey = builder.generate_fresh(VariableType::PrivateKey, rng); + let funding_pubkey = builder.append(Operation::DerivePoint, &[funding_privkey]); + let revocation_basepoint = builder.generate_fresh(VariableType::Point, rng); + let payment_basepoint = builder.generate_fresh(VariableType::Point, rng); + let delayed_payment_basepoint = builder.generate_fresh(VariableType::Point, rng); + let htlc_basepoint = builder.generate_fresh(VariableType::Point, rng); + let first_per_commitment_point = builder.generate_fresh(VariableType::Point, rng); + let second_per_commitment_point = builder.generate_fresh(VariableType::Point, rng); + + // BOLT 2 derives the v2 temporary_channel_id from our revocation + // basepoint with a zeroed one standing in for the peer. + let temporary_channel_id = builder.append( + Operation::DeriveTemporaryChannelIdV2, + &[revocation_basepoint], + ); + + let chain_hash = builder.pick_variable(VariableType::ChainHash, rng); + let funding_satoshis = builder.append( + Operation::LoadAmount(rng.random_range(100_000..=1_000_000)), + &[], + ); + let funding_feerate_perkw = builder.append( + Operation::LoadFeeratePerKw(rng.random_range(253..=2_000)), + &[], + ); + let commitment_feerate_perkw = builder.append( + Operation::LoadFeeratePerKw(rng.random_range(253..=5_000)), + &[], + ); + let dust_limit_satoshis = builder.append(Operation::LoadAmount(546), &[]); + let max_htlc_value_in_flight_msat = builder.append(Operation::LoadAmount(100_000_000), &[]); + let htlc_minimum_msat = builder.append(Operation::LoadAmount(1), &[]); + let to_self_delay = builder.append(Operation::LoadU16(144), &[]); + let max_accepted_htlcs = builder.append(Operation::LoadU16(483), &[]); + let locktime = builder.append(Operation::LoadBlockHeight(0), &[]); + let channel_flags = builder.append(Operation::LoadU8(u8::from(rng.random::())), &[]); + let upfront_shutdown_script = builder.append( + Operation::LoadShutdownScript(ShutdownScriptVariant::Empty), + &[], + ); + let channel_type_variant = if rng.random_range(0..4) == 0 { + *ChannelTypeVariant::ALL + .choose(rng) + .expect("ChannelTypeVariant::ALL is non-empty") + } else { + *LIKELY_CHANNEL_TYPES + .choose(rng) + .expect("LIKELY_CHANNEL_TYPES is non-empty") + }; + let channel_type = builder.append(Operation::LoadChannelType(channel_type_variant), &[]); + + // Build and send open_channel2. + let open_channel2_msg = builder.append( + Operation::BuildOpenChannel2 { + require_confirmed_inputs: rng.random_range(0..8) == 0, + }, + &[ + chain_hash, + temporary_channel_id, + funding_feerate_perkw, + commitment_feerate_perkw, + funding_satoshis, + dust_limit_satoshis, + max_htlc_value_in_flight_msat, + htlc_minimum_msat, + to_self_delay, + max_accepted_htlcs, + locktime, + funding_pubkey, + revocation_basepoint, + payment_basepoint, + delayed_payment_basepoint, + htlc_basepoint, + first_per_commitment_point, + second_per_commitment_point, + channel_flags, + upfront_shutdown_script, + channel_type, + ], + ); + let sent_open_channel2 = builder.append(Operation::SendOpenChannel2, &[open_channel2_msg]); + + // Receive accept_channel2, which reveals the peer's revocation + // basepoint and so the channel_id every later message carries. + let accept_channel2 = builder.append(Operation::RecvAcceptChannel2, &[sent_open_channel2]); + let peer_revocation_basepoint = builder.append( + Operation::ExtractAcceptChannel2(AcceptChannel2Field::RevocationBasepoint), + &[accept_channel2], + ); + let channel_id = builder.append( + Operation::DeriveChannelIdV2, + &[revocation_basepoint, peer_revocation_basepoint], + ); + + // Interactive transaction construction. The protocol is turn-based, so + // every contribution we send is followed by the peer's reply. + for i in 0..rng.random_range(1u8..=3) { + let sent = builder.append( + Operation::SendTxAddInput { + // Even ids, as BOLT 2 requires of the initiator. + serial_id: 2 * (u64::from(i) + 1), + utxo_index: i, + sequence: SEQUENCE, + }, + &[channel_id], + ); + builder.append(Operation::RecvInteractiveTx, &[sent]); + } + + // The opener must contribute the funding output, and pays its fees. + for (serial_id, role) in [ + (FUNDING_OUTPUT_SERIAL_ID, TxOutputRole::Funding), + (CHANGE_OUTPUT_SERIAL_ID, TxOutputRole::Change), + ] { + let sent = builder.append( + Operation::SendTxAddOutput { serial_id, role }, + // The value and script are derived from the negotiation for + // both roles here; they matter only once a mutator switches + // the role to `Explicit`. + &[channel_id, funding_satoshis, upfront_shutdown_script], + ); + builder.append(Operation::RecvInteractiveTx, &[sent]); + } + + // The exchange ends once both sides have sent `tx_complete` back to + // back. If the peer already sent one, ours ends it and nothing more + // arrives. If the peer contributed instead, it still has to answer + // ours with its own `tx_complete`. The executor tells the two cases + // apart at runtime, so this receive reads only when a reply is owed. + let sent_tx_complete = builder.append(Operation::SendTxComplete, &[channel_id]); + builder.append(Operation::RecvInteractiveTx, &[sent_tx_complete]); + + // Exchange commitment signatures over the negotiated transaction. + let funding_transaction = + builder.append(Operation::BuildFundingTransactionV2, &[channel_id]); + let sent_commitment_signed = builder.append( + Operation::SendCommitmentSigned, + &[funding_transaction, funding_privkey, channel_id], + ); + let funded_channel_id = + builder.append(Operation::RecvCommitmentSigned, &[sent_commitment_signed]); + + // We contribute every input, so BOLT 2 has the peer send its + // tx_signatures first. + builder.append(Operation::RecvTxSignatures, &[channel_id]); + builder.append( + Operation::SendTxSignatures, + &[channel_id, funding_transaction], + ); + builder.append(Operation::RecvTxSignatures, &[channel_id]); + + builder.append(Operation::BroadcastTransaction, &[funding_transaction]); + builder.append(Operation::MineBlocks(rng.random_range(1..=16)), &[]); + + // Reuse the second_per_commitment_point already committed to in + // open_channel2: implementations may cross-check the two, and feeding + // an unrelated point would fail channel_ready for a reason that has + // nothing to do with the flow under test. + let short_channel_id = builder.generate_fresh(VariableType::ShortChannelId, rng); + builder.append( + Operation::SendChannelReady { + include_alias: rng.random(), + }, + &[ + funded_channel_id, + second_per_commitment_point, + short_channel_id, + ], + ); + builder.append(Operation::RecvChannelReady, &[]); + } +} diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index 4f01d95f..c62ee904 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -5,7 +5,10 @@ use rand::{Rng, RngExt}; use smite::bolt::{MAX_MESSAGE_SIZE, ShortChannelId}; use super::Mutator; -use crate::operation::{AcceptChannelField, ChannelTypeVariant, ShutdownScriptVariant}; +use crate::operation::{ + AcceptChannel2Field, AcceptChannelField, ChannelTypeVariant, ShutdownScriptVariant, + TxOutputRole, +}; use crate::{Operation, Program}; /// Mutates the embedded parameter of a randomly chosen `is_param_mutable` @@ -31,6 +34,7 @@ impl Mutator for OperationParamMutator { } /// Returns `true` if the operation was changed. +#[allow(clippy::too_many_lines)] fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { match op { Operation::LoadAmount(v) => { @@ -72,26 +76,17 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { mutate_channel_type(variant, rng); true } + // Limit the number of mined blocks to keep execution times low. + // Reference execution timings: + // MineBlocks(10): 16ms, MineBlocks(100): 157ms, + // MineBlocks(200): 359ms, MineBlocks(255): 468ms Operation::MineBlocks(v) => { - // Limit the number of mined blocks to keep execution times low. - // Reference execution timings: - // MineBlocks(10): 16ms - // MineBlocks(100): 157ms - // MineBlocks(200): 359ms - // MineBlocks(255): 468ms *v = rng.random_range(1..=16); true } - Operation::ExtractAcceptChannel(field) => mutate_extract_field(field, rng), + Operation::ExtractAcceptChannel(field) => mutate_accept_channel_field(field, rng), Operation::BuildNodeAnnouncement { rgb_color, alias } => { - // Randomly mutate rgb_color or alias bytes in place; never change - // their lengths (array types prevent it). - if rng.random() { - mutate_fixed_bytes(rgb_color, rng); - } else { - mutate_fixed_bytes(alias, rng); - } - true + mutate_node_announcement(rgb_color, alias, rng) } Operation::SendChannelReady { include_alias } => { // Toggle the SCID alias TLV. Flipping always changes the value; @@ -99,6 +94,27 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { *include_alias = !*include_alias; true } + Operation::ExtractAcceptChannel2(field) => mutate_accept_channel2_field(field, rng), + Operation::SendTxAddInput { + serial_id, + utxo_index, + sequence, + } => mutate_tx_add_input(serial_id, utxo_index, sequence, rng), + Operation::SendTxAddOutput { serial_id, role } => { + mutate_tx_add_output(serial_id, role, rng) + } + Operation::SendTxRemoveInput { serial_id } + | Operation::SendTxRemoveOutput { serial_id } => { + *serial_id = tweak_serial_id(*serial_id, rng); + true + } + Operation::BuildOpenChannel2 { + require_confirmed_inputs, + } => { + // Toggle the value-less `require_confirmed_inputs` TLV. + *require_confirmed_inputs = !*require_confirmed_inputs; + true + } // Non-mutable variants. Reaching here means `is_param_mutable` and this // match have drifted out of sync. @@ -118,7 +134,18 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { | Operation::RecvFundingSigned | Operation::RecvChannelReady | Operation::BroadcastTransaction - | Operation::LookupShortChannelId => { + | Operation::LookupShortChannelId + | Operation::DeriveTemporaryChannelIdV2 + | Operation::DeriveChannelIdV2 + | Operation::SendOpenChannel2 + | Operation::RecvAcceptChannel2 + | Operation::SendTxComplete + | Operation::RecvInteractiveTx + | Operation::BuildFundingTransactionV2 + | Operation::SendCommitmentSigned + | Operation::RecvCommitmentSigned + | Operation::RecvTxSignatures + | Operation::SendTxSignatures => { unreachable!("is_param_mutable returned true for {op:?}") } } @@ -162,6 +189,96 @@ fn tweak_u8(v: u8, rng: &mut impl Rng) -> u8 { } } +/// Mutates a `node_announcement`'s colour or alias bytes in place. Their +/// lengths never change, since both are fixed-size arrays. +fn mutate_node_announcement( + rgb_color: &mut [u8; 3], + alias: &mut [u8; 32], + rng: &mut impl Rng, +) -> bool { + if rng.random() { + mutate_fixed_bytes(rgb_color, rng); + } else { + mutate_fixed_bytes(alias, rng); + } + true +} + +// -- Interactive transaction mutations -- + +/// Mutates one of a `tx_add_input`'s three parameters. +fn mutate_tx_add_input( + serial_id: &mut u64, + utxo_index: &mut u8, + sequence: &mut u32, + rng: &mut impl Rng, +) -> bool { + match rng.random_range(0..3) { + 0 => *serial_id = tweak_serial_id(*serial_id, rng), + 1 => *utxo_index = tweak_u8(*utxo_index, rng), + _ => *sequence = tweak_sequence(*sequence, rng), + } + true +} + +/// Mutates a `tx_add_output`'s serial id or its role. +fn mutate_tx_add_output(serial_id: &mut u64, role: &mut TxOutputRole, rng: &mut impl Rng) -> bool { + if rng.random() { + *serial_id = tweak_serial_id(*serial_id, rng); + true + } else { + mutate_tx_output_role(role, rng) + } +} + +/// Mutates a BOLT 2 interactive transaction `serial_id`. +/// +/// Parity carries protocol meaning here -- the initiator must use even ids and +/// the non-initiator odd ones -- so this deliberately splits between keeping +/// the parity, flipping it to reach the "wrong parity" rejection path, and +/// small values likely to collide with an id already in the negotiation. +fn tweak_serial_id(v: u64, rng: &mut impl Rng) -> u64 { + match rng.random_range(0..8) { + // Keep the parity: still a legal id, a different one. + 0..=3 => v.wrapping_add(2 * rng.random_range(1..=128)), + // Flip the parity, which the receiver must reject. + 4..=5 => v ^ 1, + // Small values, which are the ones likely to already be in use. + 6 => rng.random_range(0..16), + _ => interesting_u64(rng), + } +} + +/// Mutates a `tx_add_input` `sequence`. +/// +/// BOLT 2 caps it at `0xfffffffd` so every input signals replaceability, and +/// requires the receiver to fail on `0xfffffffe` or `0xffffffff`. Those three +/// values are the whole boundary, so target them directly rather than relying +/// on a uniform u32 tweak to stumble onto them. +fn tweak_sequence(v: u32, rng: &mut impl Rng) -> u32 { + match rng.random_range(0..4) { + 0 => 0xffff_fffd, + 1 => 0xffff_fffe, + 2 => 0xffff_ffff, + _ => tweak_u32(v, rng), + } +} + +/// Swaps a `tx_add_output` to a different role, so a mutation can turn the +/// funding output into an arbitrary one and vice versa. +fn mutate_tx_output_role(role: &mut TxOutputRole, rng: &mut impl Rng) -> bool { + let Some(new_role) = TxOutputRole::ALL + .iter() + .copied() + .filter(|r| r != role) + .choose(rng) + else { + return false; + }; + *role = new_role; + true +} + // -- Short channel id mutations -- /// Mutates a packed BOLT 7 `short_channel_id`. @@ -375,7 +492,7 @@ fn mutate_shutdown_script_bytes(variant: &mut ShutdownScriptVariant, rng: &mut i /// Returns `true` if the field was swapped, `false` if no same-type alternative /// field exists. -fn mutate_extract_field(field: &mut AcceptChannelField, rng: &mut impl Rng) -> bool { +fn mutate_accept_channel_field(field: &mut AcceptChannelField, rng: &mut impl Rng) -> bool { // Only swap to fields with the same output type to preserve program validity. let target_type = field.output_type(); let Some(new_field) = AcceptChannelField::ALL @@ -390,6 +507,22 @@ fn mutate_extract_field(field: &mut AcceptChannelField, rng: &mut impl Rng) -> b true } +/// Swaps an `accept_channel2` extraction to a different field of the same +/// output type, so the program stays type-correct. +fn mutate_accept_channel2_field(field: &mut AcceptChannel2Field, rng: &mut impl Rng) -> bool { + let target_type = field.output_type(); + let Some(new_field) = AcceptChannel2Field::ALL + .iter() + .copied() + .filter(|f| f.output_type() == target_type && f != field) + .choose(rng) + else { + return false; + }; + *field = new_field; + true +} + // -- Interesting boundary values -- // // Each width includes all boundaries from narrower widths plus width-specific diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 60644f27..59b9c42f 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -260,6 +260,273 @@ pub enum Operation { /// /// Input: `FundingTransaction`. LookupShortChannelId, + + /// Derive the v2 `temporary_channel_id` from our revocation basepoint, + /// using a zeroed basepoint for the not-yet-known peer (BOLT 2). + /// + /// Input: `Point` -- our `revocation_basepoint`. + DeriveTemporaryChannelIdV2, + /// Derive the v2 `channel_id` from both peers' revocation basepoints + /// (BOLT 2). + /// + /// Inputs (2): + /// 0: `revocation_basepoint` (`Point`) -- ours + /// 1: `revocation_basepoint` (`Point`) -- the peer's, from + /// `accept_channel2` + DeriveChannelIdV2, + /// Extract a field from a parsed `accept_channel2` response. + /// Input: `AcceptChannel2`. + ExtractAcceptChannel2(AcceptChannel2Field), + /// Build an `open_channel2` message (BOLT 2, type 64). + /// + /// `require_confirmed_inputs` is a value-less TLV, so its presence is an + /// op-level param rather than an input. + /// + /// Inputs (21, matching wire order): + /// 0: `chain_hash` (`ChainHash`) + /// 1: `temporary_channel_id` (`ChannelId`) + /// 2: `funding_feerate_perkw` (`FeeratePerKw`) + /// 3: `commitment_feerate_perkw` (`FeeratePerKw`) + /// 4: `funding_satoshis` (`Amount`) + /// 5: `dust_limit_satoshis` (`Amount`) + /// 6: `max_htlc_value_in_flight_msat` (`Amount`) + /// 7: `htlc_minimum_msat` (`Amount`) + /// 8: `to_self_delay` (`U16`) + /// 9: `max_accepted_htlcs` (`U16`) + /// 10: `locktime` (`BlockHeight`) + /// 11: `funding_pubkey` (`Point`) + /// 12: `revocation_basepoint` (`Point`) + /// 13: `payment_basepoint` (`Point`) + /// 14: `delayed_payment_basepoint` (`Point`) + /// 15: `htlc_basepoint` (`Point`) + /// 16: `first_per_commitment_point` (`Point`) + /// 17: `second_per_commitment_point` (`Point`) + /// 18: `channel_flags` (`U8`) + /// 19: `upfront_shutdown_script` (`Bytes`, empty = opt out) + /// 20: `channel_type` (`Features`, empty = omit TLV) + BuildOpenChannel2 { + /// Whether to require the peer to contribute only confirmed inputs. + require_confirmed_inputs: bool, + }, + /// Send an `open_channel2` message over the connection. + /// Produces a `SentOpenChannel2` variable. + /// Input: `OpenChannel2Message`. + SendOpenChannel2, + /// Receive and parse an `accept_channel2` response. + /// Produces an `AcceptChannel2` compound variable. + /// Input: `SentOpenChannel2`. + RecvAcceptChannel2, + /// Build and send a `tx_add_input` message (BOLT 2, type 66). + /// + /// The input is a wallet UTXO chosen by `utxo_index` modulo the spendable + /// set, so the index stays meaningful whatever the wallet holds, and + /// selecting the same index twice proposes the same outpoint twice, which + /// the peer must reject. + /// + /// Input: `channel_id` (`ChannelId`). + SendTxAddInput { + /// BOLT 2 requires the initiator to use even `serial_id`s. The parity + /// is part of the mutable value so programs can break that rule. + serial_id: u64, + /// Selects a wallet UTXO, modulo the number of spendable outputs. + utxo_index: u8, + /// `nSequence`, which BOLT 2 requires to be at most `0xfffffffd`. + sequence: u32, + }, + /// Build and send a `tx_add_output` message (BOLT 2, type 67). + /// + /// `role` decides where the value and script come from; see + /// [`TxOutputRole`]. It is an op-level param, so the input count does not + /// depend on it and a mutator can switch roles without invalidating the + /// program. + /// + /// Inputs (3): + /// 0: `channel_id` (`ChannelId`) + /// 1: `sats` (`Amount`, used by [`TxOutputRole::Explicit`]) + /// 2: `script` (`Bytes`, used by [`TxOutputRole::Explicit`]) + SendTxAddOutput { + /// See [`Self::SendTxAddInput::serial_id`]. + serial_id: u64, + /// Where the output's value and script come from. + role: TxOutputRole, + }, + /// Build and send a `tx_remove_input` message (BOLT 2, type 68). + /// Input: `channel_id` (`ChannelId`). + SendTxRemoveInput { + /// The `serial_id` to remove. + serial_id: u64, + }, + /// Build and send a `tx_remove_output` message (BOLT 2, type 69). + /// Input: `channel_id` (`ChannelId`). + SendTxRemoveOutput { + /// The `serial_id` to remove. + serial_id: u64, + }, + /// Build and send a `tx_complete` message (BOLT 2, type 70), signalling + /// that we have nothing further to contribute. + /// Input: `channel_id` (`ChannelId`). + SendTxComplete, + /// Receive one interactive transaction construction message and apply it to + /// the negotiation it names. + /// + /// Interactive transaction construction is turn-based, so each message we + /// send earns exactly one reply; the affine input enforces that pairing in + /// generated programs while leaving mutators free to break it. + /// + /// Input: `SentInteractiveTx`. + RecvInteractiveTx, + /// Reconstruct the shared funding transaction from the negotiation, with + /// inputs and outputs sorted by ascending `serial_id` per BOLT 2. + /// + /// Produces an empty transaction when the negotiation is unknown, which + /// keeps the result well-typed for a mutated program without inventing a + /// channel the target never opened. + /// + /// Input: `channel_id` (`ChannelId`). + BuildFundingTransactionV2, + /// Build and send the v2 `commitment_signed` (BOLT 2, type 132) for the + /// initial commitment, and start tracking the channel. + /// + /// BOLT 2 requires the first `commitment_signed` of a v2 open to carry no + /// HTLCs. + /// + /// Inputs (3): + /// 0: `funding_transaction` (`FundingTransaction`) + /// 1: `opener_funding_privkey` (`PrivateKey`) + /// 2: `channel_id` (`ChannelId`) + SendCommitmentSigned, + /// Receive the peer's `commitment_signed` and verify its signature against + /// the holder's initial commitment. + /// Produces the `ChannelId` carried in the message. + /// Input: `SentCommitmentSigned`. + RecvCommitmentSigned, + /// Receive the peer's `tx_signatures`, recording the witnesses it carries + /// so the funding transaction can be assembled from both peers' signatures. + /// + /// This is a no-op unless the peer owes us one, because it signs first, + /// or because it has received ours, so a program that owes the first + /// signature does not block waiting for a message the peer is waiting on us + /// to send. + /// + /// Input: `channel_id` (`ChannelId`). + RecvTxSignatures, + /// Sign the shared funding transaction and send `tx_signatures` carrying + /// one witness per input we contributed, ordered by its `serial_id`. + /// + /// Inputs (2): + /// 0: `channel_id` (`ChannelId`) + /// 1: `funding_transaction` (`FundingTransaction`) + SendTxSignatures, +} + +/// Where a `tx_add_output`'s value and script come from. +/// +/// Keeping this an op-level param rather than separate operations fixes +/// [`Operation::SendTxAddOutput`]'s input count, so `OperationParamMutator` can +/// switch a funding output to an arbitrary one, which the peer must reject, +/// without changing the program's shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TxOutputRole { + /// The channel funding output: a 2-of-2 P2WSH between both + /// `funding_pubkey`s, worth the sum of both peers' `funding_satoshis`. + /// The value and script inputs are ignored. + Funding, + /// Our change: whatever our inputs cover beyond our funding contribution + /// and our share of the fee, paid to a fresh wallet address. The value and + /// script inputs are ignored. + Change, + /// An output taken verbatim from the value and script inputs. + Explicit, +} + +impl TxOutputRole { + /// All variants. Keep in sync with the enum definition. + pub const ALL: &[Self] = &[Self::Funding, Self::Change, Self::Explicit]; +} + +impl fmt::Display for TxOutputRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +/// Fields that can be extracted from an `AcceptChannel2` compound variable. +/// +/// Mirrors [`AcceptChannelField`] with the v2 differences: `funding_satoshis` +/// is the acceptor's contribution to the funding transaction, +/// `channel_reserve_satoshis` is gone (v2 fixes the reserve at 1% of the total), +/// and `second_per_commitment_point` is sent up front. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AcceptChannel2Field { + TemporaryChannelId, + FundingSatoshis, + DustLimitSatoshis, + MaxHtlcValueInFlightMsat, + HtlcMinimumMsat, + MinimumDepth, + ToSelfDelay, + MaxAcceptedHtlcs, + FundingPubkey, + RevocationBasepoint, + PaymentBasepoint, + DelayedPaymentBasepoint, + HtlcBasepoint, + FirstPerCommitmentPoint, + SecondPerCommitmentPoint, + UpfrontShutdownScript, + ChannelType, +} + +impl fmt::Display for AcceptChannel2Field { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl AcceptChannel2Field { + /// All variants. Keep in sync with the enum definition. + pub const ALL: &[Self] = &[ + Self::TemporaryChannelId, + Self::FundingSatoshis, + Self::DustLimitSatoshis, + Self::MaxHtlcValueInFlightMsat, + Self::HtlcMinimumMsat, + Self::MinimumDepth, + Self::ToSelfDelay, + Self::MaxAcceptedHtlcs, + Self::FundingPubkey, + Self::RevocationBasepoint, + Self::PaymentBasepoint, + Self::DelayedPaymentBasepoint, + Self::HtlcBasepoint, + Self::FirstPerCommitmentPoint, + Self::SecondPerCommitmentPoint, + Self::UpfrontShutdownScript, + Self::ChannelType, + ]; + + /// Returns the variable type produced by extracting this field. + #[must_use] + pub fn output_type(self) -> VariableType { + match self { + Self::TemporaryChannelId => VariableType::ChannelId, + Self::FundingSatoshis + | Self::DustLimitSatoshis + | Self::MaxHtlcValueInFlightMsat + | Self::HtlcMinimumMsat => VariableType::Amount, + Self::MinimumDepth => VariableType::BlockHeight, + Self::ToSelfDelay | Self::MaxAcceptedHtlcs => VariableType::U16, + Self::FundingPubkey + | Self::RevocationBasepoint + | Self::PaymentBasepoint + | Self::DelayedPaymentBasepoint + | Self::HtlcBasepoint + | Self::FirstPerCommitmentPoint + | Self::SecondPerCommitmentPoint => VariableType::Point, + Self::UpfrontShutdownScript => VariableType::Bytes, + Self::ChannelType => VariableType::Features, + } + } } /// A BOLT 2 compliant `upfront_shutdown_script` template. @@ -753,6 +1020,42 @@ impl fmt::Display for Operation { Self::MineBlocks(v) => write!(f, "MineBlocks({v})"), Self::BroadcastTransaction => write!(f, "BroadcastTransaction"), Self::LookupShortChannelId => write!(f, "LookupShortChannelId"), + Self::DeriveTemporaryChannelIdV2 => write!(f, "DeriveTemporaryChannelIdV2"), + Self::DeriveChannelIdV2 => write!(f, "DeriveChannelIdV2"), + Self::ExtractAcceptChannel2(field) => write!(f, "ExtractAcceptChannel2{field}"), + Self::BuildOpenChannel2 { + require_confirmed_inputs, + } => write!( + f, + "BuildOpenChannel2{{require_confirmed_inputs={require_confirmed_inputs}}}" + ), + Self::SendOpenChannel2 => write!(f, "SendOpenChannel2"), + Self::RecvAcceptChannel2 => write!(f, "RecvAcceptChannel2"), + Self::SendTxAddInput { + serial_id, + utxo_index, + sequence, + } => write!( + f, + "SendTxAddInput{{serial_id={serial_id}, utxo_index={utxo_index}, \ + sequence={sequence}}}" + ), + Self::SendTxAddOutput { serial_id, role } => { + write!(f, "SendTxAddOutput{{serial_id={serial_id}, role={role}}}") + } + Self::SendTxRemoveInput { serial_id } => { + write!(f, "SendTxRemoveInput{{serial_id={serial_id}}}") + } + Self::SendTxRemoveOutput { serial_id } => { + write!(f, "SendTxRemoveOutput{{serial_id={serial_id}}}") + } + Self::SendTxComplete => write!(f, "SendTxComplete"), + Self::RecvInteractiveTx => write!(f, "RecvInteractiveTx"), + Self::BuildFundingTransactionV2 => write!(f, "BuildFundingTransactionV2"), + Self::SendCommitmentSigned => write!(f, "SendCommitmentSigned"), + Self::RecvCommitmentSigned => write!(f, "RecvCommitmentSigned"), + Self::RecvTxSignatures => write!(f, "RecvTxSignatures"), + Self::SendTxSignatures => write!(f, "SendTxSignatures"), } } } @@ -776,11 +1079,17 @@ impl Operation { Self::LoadBytes(_) | Self::LoadShutdownScript(_) => Some(VariableType::Bytes), Self::LoadFeatures(_) | Self::LoadChannelType(_) => Some(VariableType::Features), Self::LoadPrivateKey(_) => Some(VariableType::PrivateKey), - Self::LoadChannelId(_) | Self::RecvFundingSigned => Some(VariableType::ChannelId), + Self::LoadChannelId(_) + | Self::RecvFundingSigned + | Self::RecvCommitmentSigned + | Self::DeriveTemporaryChannelIdV2 + | Self::DeriveChannelIdV2 => Some(VariableType::ChannelId), Self::LoadTargetPubkeyFromContext | Self::DerivePoint => Some(VariableType::Point), Self::LoadChainHashFromContext => Some(VariableType::ChainHash), Self::ExtractAcceptChannel(field) => Some(field.output_type()), - Self::CreateFundingTransaction => Some(VariableType::FundingTransaction), + Self::CreateFundingTransaction | Self::BuildFundingTransactionV2 => { + Some(VariableType::FundingTransaction) + } Self::BuildOpenChannel => Some(VariableType::OpenChannelMessage), Self::BuildChannelAnnouncement | Self::BuildNodeAnnouncement { .. } @@ -790,8 +1099,21 @@ impl Operation { | Self::SendChannelReady { .. } | Self::RecvChannelReady | Self::MineBlocks(_) - | Self::BroadcastTransaction => None, + | Self::BroadcastTransaction + | Self::RecvInteractiveTx + | Self::RecvTxSignatures + | Self::SendTxSignatures => None, Self::SendOpenChannel => Some(VariableType::SentOpenChannel), + Self::ExtractAcceptChannel2(field) => Some(field.output_type()), + Self::BuildOpenChannel2 { .. } => Some(VariableType::OpenChannel2Message), + Self::SendOpenChannel2 => Some(VariableType::SentOpenChannel2), + Self::RecvAcceptChannel2 => Some(VariableType::AcceptChannel2), + Self::SendTxAddInput { .. } + | Self::SendTxAddOutput { .. } + | Self::SendTxRemoveInput { .. } + | Self::SendTxRemoveOutput { .. } + | Self::SendTxComplete => Some(VariableType::SentInteractiveTx), + Self::SendCommitmentSigned => Some(VariableType::SentCommitmentSigned), Self::SendFundingCreated => Some(VariableType::SentFundingCreated), Self::SendShutdown => Some(VariableType::SentShutdown), Self::RecvAcceptChannel => Some(VariableType::AcceptChannel), @@ -801,6 +1123,9 @@ impl Operation { /// Returns the expected variable types for each input position. #[must_use] #[allow(clippy::too_many_lines)] + // Arms that happen to share a type list are kept apart: the per-position + // comments name different protocol fields, which merging would lose. + #[allow(clippy::match_same_arms)] pub fn input_types(&self) -> Vec { match self { Self::LoadAmount(_) @@ -915,6 +1240,61 @@ impl Operation { Self::BroadcastTransaction | Self::LookupShortChannelId => { vec![VariableType::FundingTransaction] } + Self::DeriveTemporaryChannelIdV2 => vec![VariableType::Point], // revocation_basepoint + Self::DeriveChannelIdV2 => vec![ + VariableType::Point, // our revocation_basepoint + VariableType::Point, // the peer's revocation_basepoint + ], + Self::ExtractAcceptChannel2(_) => vec![VariableType::AcceptChannel2], + Self::SendTxAddInput { .. } + | Self::SendTxRemoveInput { .. } + | Self::SendTxRemoveOutput { .. } + | Self::SendTxComplete => vec![VariableType::ChannelId], + Self::SendTxAddOutput { .. } => vec![ + VariableType::ChannelId, // channel_id + VariableType::Amount, // sats + VariableType::Bytes, // script + ], + Self::RecvInteractiveTx => vec![VariableType::SentInteractiveTx], + Self::BuildFundingTransactionV2 | Self::RecvTxSignatures => { + vec![VariableType::ChannelId] + } + Self::SendCommitmentSigned => vec![ + VariableType::FundingTransaction, // funding_transaction + VariableType::PrivateKey, // opener_funding_privkey + VariableType::ChannelId, // channel_id + ], + Self::RecvCommitmentSigned => vec![VariableType::SentCommitmentSigned], + Self::SendTxSignatures => vec![ + VariableType::ChannelId, // channel_id + VariableType::FundingTransaction, // funding_transaction + ], + Self::SendOpenChannel2 => vec![VariableType::OpenChannel2Message], + Self::RecvAcceptChannel2 => vec![VariableType::SentOpenChannel2], + + Self::BuildOpenChannel2 { .. } => vec![ + VariableType::ChainHash, // chain_hash + VariableType::ChannelId, // temporary_channel_id + VariableType::FeeratePerKw, // funding_feerate_perkw + VariableType::FeeratePerKw, // commitment_feerate_perkw + VariableType::Amount, // funding_satoshis + VariableType::Amount, // dust_limit_satoshis + VariableType::Amount, // max_htlc_value_in_flight_msat + VariableType::Amount, // htlc_minimum_msat + VariableType::U16, // to_self_delay + VariableType::U16, // max_accepted_htlcs + VariableType::BlockHeight, // locktime + VariableType::Point, // funding_pubkey + VariableType::Point, // revocation_basepoint + VariableType::Point, // payment_basepoint + VariableType::Point, // delayed_payment_basepoint + VariableType::Point, // htlc_basepoint + VariableType::Point, // first_per_commitment_point + VariableType::Point, // second_per_commitment_point + VariableType::U8, // channel_flags + VariableType::Bytes, // upfront_shutdown_script + VariableType::Features, // channel_type + ], } } @@ -959,12 +1339,33 @@ impl Operation { | Self::RecvChannelReady | Self::MineBlocks(_) | Self::BroadcastTransaction - | Self::LookupShortChannelId => vec![], + | Self::LookupShortChannelId + | Self::DeriveTemporaryChannelIdV2 + | Self::DeriveChannelIdV2 + | Self::ExtractAcceptChannel2(_) + | Self::BuildOpenChannel2 { .. } + | Self::SendOpenChannel2 + | Self::SendTxAddInput { .. } + | Self::SendTxAddOutput { .. } + | Self::SendTxRemoveInput { .. } + | Self::SendTxRemoveOutput { .. } + | Self::SendTxComplete + | Self::RecvInteractiveTx + | Self::BuildFundingTransactionV2 + | Self::SendCommitmentSigned + | Self::RecvCommitmentSigned + | Self::RecvTxSignatures + | Self::SendTxSignatures => vec![], Self::RecvAcceptChannel => AcceptChannelField::ALL .iter() .map(|&f| (Self::ExtractAcceptChannel(f), f.output_type())) .collect(), + + Self::RecvAcceptChannel2 => AcceptChannel2Field::ALL + .iter() + .map(|&f| (Self::ExtractAcceptChannel2(f), f.output_type())) + .collect(), } } @@ -996,7 +1397,11 @@ impl Operation { | Self::BuildNodeAnnouncement { .. } | Self::BuildChannelUpdate | Self::BuildAnnouncementSignatures - | Self::LookupShortChannelId => false, + | Self::LookupShortChannelId + | Self::DeriveTemporaryChannelIdV2 + | Self::DeriveChannelIdV2 + | Self::ExtractAcceptChannel2(_) + | Self::BuildOpenChannel2 { .. } => false, Self::CreateFundingTransaction | Self::SendMessage | Self::SendOpenChannel @@ -1007,7 +1412,20 @@ impl Operation { | Self::RecvFundingSigned | Self::RecvChannelReady | Self::MineBlocks(_) - | Self::BroadcastTransaction => true, + | Self::BroadcastTransaction + | Self::SendOpenChannel2 + | Self::RecvAcceptChannel2 + | Self::SendTxAddInput { .. } + | Self::SendTxAddOutput { .. } + | Self::SendTxRemoveInput { .. } + | Self::SendTxRemoveOutput { .. } + | Self::SendTxComplete + | Self::RecvInteractiveTx + | Self::BuildFundingTransactionV2 + | Self::SendCommitmentSigned + | Self::RecvCommitmentSigned + | Self::RecvTxSignatures + | Self::SendTxSignatures => true, } } @@ -1044,20 +1462,39 @@ impl Operation { | Self::BuildNodeAnnouncement { .. } | Self::BuildChannelUpdate | Self::BuildAnnouncementSignatures + | Self::DeriveTemporaryChannelIdV2 + | Self::DeriveChannelIdV2 + | Self::ExtractAcceptChannel2(_) + | Self::BuildOpenChannel2 { .. } | Self::SendMessage | Self::SendOpenChannel + | Self::SendOpenChannel2 | Self::SendChannelReady { .. } - | Self::SendShutdown => true, + | Self::SendShutdown + | Self::SendTxRemoveInput { .. } + | Self::SendTxRemoveOutput { .. } + | Self::SendTxComplete => true, // `CreateFundingTransaction` selects coins from the wallet, whose // contents change as transactions are created and broadcast. // `SendFundingCreated` builds its message from the recorded // negotiation and channel state. The `Recv` operations read // whatever the target sends us. `MineBlocks` also mines whatever // the private mempool holds, `BroadcastTransaction` dedups against - // it, and `LookupShortChannelId` reads chain state. + // it, and `LookupShortChannelId` reads chain state. The + // `tx_add_*` operations pick a wallet UTXO and a fresh change + // address, so they read the wallet too. Self::CreateFundingTransaction | Self::SendFundingCreated + | Self::SendTxAddInput { .. } + | Self::SendTxAddOutput { .. } + | Self::BuildFundingTransactionV2 + | Self::SendCommitmentSigned + | Self::SendTxSignatures | Self::RecvAcceptChannel + | Self::RecvAcceptChannel2 + | Self::RecvInteractiveTx + | Self::RecvCommitmentSigned + | Self::RecvTxSignatures | Self::RecvFundingSigned | Self::RecvChannelReady | Self::MineBlocks(_) @@ -1096,7 +1533,13 @@ impl Operation { | Self::ExtractAcceptChannel(_) | Self::BuildNodeAnnouncement { .. } | Self::SendChannelReady { .. } - | Self::MineBlocks(_) => true, + | Self::MineBlocks(_) + | Self::ExtractAcceptChannel2(_) + | Self::BuildOpenChannel2 { .. } + | Self::SendTxAddInput { .. } + | Self::SendTxAddOutput { .. } + | Self::SendTxRemoveInput { .. } + | Self::SendTxRemoveOutput { .. } => true, Self::LoadTargetPubkeyFromContext | Self::LoadChainHashFromContext @@ -1114,7 +1557,18 @@ impl Operation { | Self::RecvFundingSigned | Self::RecvChannelReady | Self::BroadcastTransaction - | Self::LookupShortChannelId => false, + | Self::LookupShortChannelId + | Self::DeriveTemporaryChannelIdV2 + | Self::DeriveChannelIdV2 + | Self::SendOpenChannel2 + | Self::RecvAcceptChannel2 + | Self::SendTxComplete + | Self::RecvInteractiveTx + | Self::BuildFundingTransactionV2 + | Self::SendCommitmentSigned + | Self::RecvCommitmentSigned + | Self::RecvTxSignatures + | Self::SendTxSignatures => false, } } } diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index 09b2ecb7..4cc15a03 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -8,14 +8,18 @@ use smite::bolt::{MAX_MESSAGE_SIZE, ShortChannelId}; use super::*; use generators::{ AnyGenerator, ChannelAnnouncementGenerator, ChannelReadyGenerator, ChannelUpdateGenerator, - FundingCreatedGenerator, FundingFlowGenerator, NodeAnnouncementGenerator, OpenChannelGenerator, + DualFundingFlowGenerator, FundingCreatedGenerator, FundingFlowGenerator, + NodeAnnouncementGenerator, OpenChannelGenerator, }; use minimizers::{CommonSubexpressionEliminator, DeadCodeEliminator, Minimizer}; use mutators::{ GeneratorInsertionMutator, InputSwapMutator, InstructionDeleteMutator, InstructionReorderMutator, OperationParamMutator, }; -use operation::{AcceptChannelField, ChannelTypeVariant, ShutdownScriptVariant}; +use operation::{ + AcceptChannel2Field, AcceptChannelField, ChannelTypeVariant, ShutdownScriptVariant, + TxOutputRole, +}; /// Helper to build a private key with a single distinguishing byte. fn key(byte: u8) -> [u8; 32] { @@ -900,6 +904,58 @@ fn accept_channel_field_all_is_complete() { ); } +// Ensure AcceptChannel2Field and AcceptChannel2Field::ALL stay in sync. The +// exhaustive match in this test will fail to compile if a variant is added +// without updating it, and the assertion will fail if the match is updated +// without updating AcceptChannel2Field::ALL. +#[test] +fn accept_channel2_field_all_is_complete() { + let variant_count = |f: AcceptChannel2Field| -> usize { + match f { + AcceptChannel2Field::TemporaryChannelId + | AcceptChannel2Field::FundingSatoshis + | AcceptChannel2Field::DustLimitSatoshis + | AcceptChannel2Field::MaxHtlcValueInFlightMsat + | AcceptChannel2Field::HtlcMinimumMsat + | AcceptChannel2Field::MinimumDepth + | AcceptChannel2Field::ToSelfDelay + | AcceptChannel2Field::MaxAcceptedHtlcs + | AcceptChannel2Field::FundingPubkey + | AcceptChannel2Field::RevocationBasepoint + | AcceptChannel2Field::PaymentBasepoint + | AcceptChannel2Field::DelayedPaymentBasepoint + | AcceptChannel2Field::HtlcBasepoint + | AcceptChannel2Field::FirstPerCommitmentPoint + | AcceptChannel2Field::SecondPerCommitmentPoint + | AcceptChannel2Field::UpfrontShutdownScript + | AcceptChannel2Field::ChannelType => 17, + } + }; + assert_eq!( + AcceptChannel2Field::ALL.len(), + variant_count(AcceptChannel2Field::ALL[0]), + ); +} + +// Every `RecvAcceptChannel2` field extractor must be registered so the builder +// can reach it, and each must declare the type it actually produces. +#[test] +fn recv_accept_channel2_exposes_every_field() { + let extractable = Operation::RecvAcceptChannel2.extractable_fields(); + + assert_eq!(extractable.len(), AcceptChannel2Field::ALL.len()); + for (operation, output_type) in extractable { + let Operation::ExtractAcceptChannel2(field) = operation else { + panic!("expected an ExtractAcceptChannel2 operation, got {operation:?}"); + }; + assert_eq!(field.output_type(), output_type); + assert_eq!( + Operation::ExtractAcceptChannel2(field).input_types(), + vec![VariableType::AcceptChannel2], + ); + } +} + // Ensure AnyGenerator and AnyGenerator::ALL stay in sync. The exhaustive // match in this test will fail to compile if a variant is added without // updating it, and the assertion will fail if the match is updated @@ -914,12 +970,45 @@ fn any_generator_all_is_complete() { | AnyGenerator::OpenChannel(_) | AnyGenerator::FundingCreated(_) | AnyGenerator::ChannelReady(_) - | AnyGenerator::FundingFlow(_) => 7, + | AnyGenerator::FundingFlow(_) + | AnyGenerator::DualFundingFlow(_) => 8, } }; assert_eq!(AnyGenerator::ALL.len(), variant_count(AnyGenerator::ALL[0])); } +// The per-flow generator sets must draw only from `ALL`, and must not mix the +// two channel establishment flows: BOLT 2 makes them mutually exclusive on one +// connection, so a campaign that saw both would waste half its executions on +// programs the target rejects outright. +#[test] +fn per_flow_generator_sets_are_disjoint_subsets() { + for (name, set) in [("V1", AnyGenerator::V1), ("V2", AnyGenerator::V2)] { + for (i, generator) in set.iter().enumerate() { + assert!( + AnyGenerator::ALL + .iter() + .any(|g| std::mem::discriminant(g) == std::mem::discriminant(generator)), + "{name}[{i}] is not registered in AnyGenerator::ALL", + ); + } + } + + let v1_has_v2_flow = AnyGenerator::V1 + .iter() + .any(|g| matches!(g, AnyGenerator::DualFundingFlow(_))); + let v2_has_v1_flow = AnyGenerator::V2.iter().any(|g| { + matches!( + g, + AnyGenerator::FundingFlow(_) + | AnyGenerator::OpenChannel(_) + | AnyGenerator::FundingCreated(_) + ) + }); + assert!(!v1_has_v2_flow, "V1 draws a dual-funded generator"); + assert!(!v2_has_v1_flow, "V2 draws a single-funded generator"); +} + // -- ShutdownScriptVariant tests -- // Ensure ShutdownScriptVariant and ShutdownScriptVariant::VARIANT_COUNT stay in @@ -1293,6 +1382,155 @@ fn generated_channel_ready_program_structure() { ); } +fn generate_dual_funding_flow_program(seed: u64) -> Program { + let mut rng = SmallRng::seed_from_u64(seed); + let mut builder = ProgramBuilder::new(); + DualFundingFlowGenerator.generate(&mut builder, &mut rng); + builder.build() +} + +// If DualFundingFlowGenerator completes without panicking, every instruction +// has correct input types and every affine variable is consumed exactly once +// (both enforced by ProgramBuilder::append). +#[test] +fn generated_dual_funding_flow_program_is_type_correct() { + for seed in 0..100 { + generate_dual_funding_flow_program(seed); + } +} + +#[test] +fn generated_dual_funding_flow_program_follows_the_bolt2_order() { + let program = generate_dual_funding_flow_program(0); + let ops: Vec<_> = program.instructions.iter().map(|i| &i.operation).collect(); + + let position = |pred: fn(&Operation) -> bool| { + ops.iter() + .position(|op| pred(op)) + .unwrap_or_else(|| panic!("operation missing from the generated program")) + }; + + // BOLT 2: open_channel2, accept_channel2, interactive tx, both + // commitment_signed, both tx_signatures, then channel_ready. + let send_open = position(|op| matches!(op, Operation::SendOpenChannel2)); + let recv_accept = position(|op| matches!(op, Operation::RecvAcceptChannel2)); + let first_add_input = position(|op| matches!(op, Operation::SendTxAddInput { .. })); + let funding_output = position(|op| { + matches!( + op, + Operation::SendTxAddOutput { + role: TxOutputRole::Funding, + .. + } + ) + }); + let tx_complete = position(|op| matches!(op, Operation::SendTxComplete)); + let build_funding = position(|op| matches!(op, Operation::BuildFundingTransactionV2)); + let send_commitment = position(|op| matches!(op, Operation::SendCommitmentSigned)); + let recv_commitment = position(|op| matches!(op, Operation::RecvCommitmentSigned)); + let recv_signatures = position(|op| matches!(op, Operation::RecvTxSignatures)); + let send_signatures = position(|op| matches!(op, Operation::SendTxSignatures)); + let broadcast = position(|op| matches!(op, Operation::BroadcastTransaction)); + let send_ready = position(|op| matches!(op, Operation::SendChannelReady { .. })); + + let order = [ + send_open, + recv_accept, + first_add_input, + funding_output, + tx_complete, + build_funding, + send_commitment, + recv_commitment, + recv_signatures, + send_signatures, + broadcast, + send_ready, + ]; + assert!( + order.windows(2).all(|w| w[0] < w[1]), + "instructions are out of protocol order: {order:?}", + ); + + assert!( + matches!(ops[ops.len() - 1], Operation::RecvChannelReady), + "last instruction should be RecvChannelReady", + ); +} + +#[test] +fn generated_dual_funding_flow_pairs_every_send_with_a_receive() { + for seed in 0..20 { + let program = generate_dual_funding_flow_program(seed); + let ops: Vec<_> = program.instructions.iter().map(|i| &i.operation).collect(); + + // Interactive transaction construction is turn-based, so each message + // we send earns exactly one reply. + let sends = ops + .iter() + .filter(|op| { + matches!( + op, + Operation::SendTxAddInput { .. } + | Operation::SendTxAddOutput { .. } + | Operation::SendTxComplete + ) + }) + .count(); + let receives = ops + .iter() + .filter(|op| matches!(op, Operation::RecvInteractiveTx)) + .count(); + assert_eq!( + sends, receives, + "seed {seed}: unpaired interactive tx messages" + ); + } +} + +#[test] +fn generated_dual_funding_flow_uses_even_serial_ids() { + for seed in 0..20 { + let program = generate_dual_funding_flow_program(seed); + for instr in &program.instructions { + // BOLT 2 requires the initiator to use even serial ids. + let (Operation::SendTxAddInput { serial_id, .. } + | Operation::SendTxAddOutput { serial_id, .. }) = instr.operation + else { + continue; + }; + assert_eq!( + serial_id % 2, + 0, + "seed {seed}: odd serial_id {serial_id} from the initiator", + ); + } + } +} + +#[test] +fn generated_dual_funding_flow_reuses_the_second_per_commitment_point() { + let program = generate_dual_funding_flow_program(0); + + let open_channel2 = program + .instructions + .iter() + .find(|i| matches!(i.operation, Operation::BuildOpenChannel2 { .. })) + .expect("open_channel2 built"); + let channel_ready = program + .instructions + .iter() + .find(|i| matches!(i.operation, Operation::SendChannelReady { .. })) + .expect("channel_ready sent"); + + // Implementations may cross-check the point committed to in open_channel2 + // against the one channel_ready carries, so both must be the same variable. + assert_eq!( + open_channel2.inputs[17], channel_ready.inputs[1], + "second_per_commitment_point differs between open_channel2 and channel_ready", + ); +} + fn generate_funding_flow_program(seed: u64) -> Program { let mut rng = SmallRng::seed_from_u64(seed); let mut builder = ProgramBuilder::new(); diff --git a/smite-ir/src/variable.rs b/smite-ir/src/variable.rs index 7afe6b34..b0c1f9e5 100644 --- a/smite-ir/src/variable.rs +++ b/smite-ir/src/variable.rs @@ -4,7 +4,9 @@ //! The serialized program stores data only in [`Operation`] literals. use bitcoin::secp256k1::PublicKey; -use smite::bolt::{AcceptChannel, ChannelId, OpenChannel, ShortChannelId}; +use smite::bolt::{ + AcceptChannel, AcceptChannel2, ChannelId, OpenChannel, OpenChannel2, ShortChannelId, +}; use smite::channel_tx::FundingTransaction; const CHAIN_HASH_SIZE: usize = 32; @@ -50,12 +52,27 @@ pub enum Variable { OpenChannelMessage(OpenChannel), /// Parsed `accept_channel` response. AcceptChannel(AcceptChannel), + /// BOLT `open_channel2` message, ready to send. + OpenChannel2Message(OpenChannel2), + /// Parsed `accept_channel2` response. + AcceptChannel2(AcceptChannel2), /// Constructed funding transaction with funding output index. FundingTransaction(FundingTransaction), // Affine (single-use) variables /// `open_channel` has been sent, so `accept_channel` may now be received. SentOpenChannel, + /// `open_channel2` has been sent, so `accept_channel2` may now be received. + SentOpenChannel2, + /// An interactive transaction construction message has been sent, so the + /// peer's reply may now be received. The protocol is turn-based, so each + /// send earns exactly one receive. + /// + /// Carries the `channel_id` it was sent on, so the receive can tell whether + /// that negotiation is still expecting a reply. + SentInteractiveTx(ChannelId), + /// `commitment_signed` has been sent, so the peer's may now be received. + SentCommitmentSigned, /// `funding_created` has been sent, so `funding_signed` may now be received. SentFundingCreated, /// `shutdown` has been sent, so the counterparty's `shutdown` may now be @@ -85,8 +102,13 @@ impl Variable { Self::Message(_) => VariableType::Message, Self::OpenChannelMessage(_) => VariableType::OpenChannelMessage, Self::AcceptChannel(_) => VariableType::AcceptChannel, + Self::OpenChannel2Message(_) => VariableType::OpenChannel2Message, + Self::AcceptChannel2(_) => VariableType::AcceptChannel2, Self::FundingTransaction(_) => VariableType::FundingTransaction, Self::SentOpenChannel => VariableType::SentOpenChannel, + Self::SentOpenChannel2 => VariableType::SentOpenChannel2, + Self::SentInteractiveTx(_) => VariableType::SentInteractiveTx, + Self::SentCommitmentSigned => VariableType::SentCommitmentSigned, Self::SentFundingCreated => VariableType::SentFundingCreated, Self::SentShutdown => VariableType::SentShutdown, } @@ -114,8 +136,13 @@ pub enum VariableType { Message, OpenChannelMessage, AcceptChannel, + OpenChannel2Message, + AcceptChannel2, FundingTransaction, SentOpenChannel, + SentOpenChannel2, + SentInteractiveTx, + SentCommitmentSigned, SentFundingCreated, SentShutdown, } @@ -124,7 +151,12 @@ impl VariableType { #[must_use] pub fn is_affine(&self) -> bool { match self { - Self::SentOpenChannel | Self::SentFundingCreated | Self::SentShutdown => true, + Self::SentOpenChannel + | Self::SentOpenChannel2 + | Self::SentInteractiveTx + | Self::SentCommitmentSigned + | Self::SentFundingCreated + | Self::SentShutdown => true, Self::Bytes | Self::ChainHash @@ -142,6 +174,8 @@ impl VariableType { | Self::Message | Self::OpenChannelMessage | Self::AcceptChannel + | Self::OpenChannel2Message + | Self::AcceptChannel2 | Self::ShortChannelId | Self::FundingTransaction => false, } diff --git a/smite-scenarios/src/bin/cln_ir_v2.rs b/smite-scenarios/src/bin/cln_ir_v2.rs new file mode 100644 index 00000000..cb433421 --- /dev/null +++ b/smite-scenarios/src/bin/cln_ir_v2.rs @@ -0,0 +1,9 @@ +//! CLN channel establishment v2 IR fuzzing scenario binary. + +use smite::scenarios::smite_run; +use smite_scenarios::scenarios::{IrScenario, PostInitDualFundSetup}; +use smite_scenarios::targets::ClnTarget; + +fn main() -> std::process::ExitCode { + smite_run::>() +} diff --git a/smite-scenarios/src/bin/eclair_ir_v2.rs b/smite-scenarios/src/bin/eclair_ir_v2.rs new file mode 100644 index 00000000..7e9cfacb --- /dev/null +++ b/smite-scenarios/src/bin/eclair_ir_v2.rs @@ -0,0 +1,9 @@ +//! Eclair channel establishment v2 IR fuzzing scenario binary. + +use smite::scenarios::smite_run; +use smite_scenarios::scenarios::{IrScenario, PostInitDualFundSetup}; +use smite_scenarios::targets::EclairTarget; + +fn main() -> std::process::ExitCode { + smite_run::>() +} diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 7873ff77..ecbff40e 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -5,25 +5,27 @@ use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use bitcoin::{OutPoint, ScriptBuf, Txid}; +use bitcoin::{OutPoint, ScriptBuf, TxOut, Txid, Witness}; use smite::bitcoin::{BitcoinCli, TxBlockPosition, Utxo}; use smite::bolt::{ - AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady, - ChannelReadyTlvs, ChannelUpdate, Features, FundingCreated, FundingSigned, Message, MessageType, - NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, - TemporaryChannelId, + AcceptChannel, AcceptChannel2, AnnouncementSignatures, ChannelAnnouncement, ChannelId, + ChannelReady, ChannelReadyTlvs, ChannelUpdate, CommitmentSigned, CommitmentSignedTlvs, + Features, FundingCreated, FundingSigned, Message, MessageType, NodeAnnouncement, OpenChannel, + OpenChannel2, OpenChannel2Tlvs, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, + TemporaryChannelId, TxAddInput, TxAddInputTlvs, TxAddOutput, TxComplete, TxRemoveInput, + TxRemoveOutput, TxSignatures, TxSignaturesTlvs, }; use smite::channel_tx::{ - ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side, - build_funding_transaction, + ChannelConfig, ChannelPartyConfig, ChannelState, Contributor, FundingTransaction, + HolderIdentity, SharedInput, SharedOutput, Side, Step, build_funding_transaction, signs_first, }; use smite::noise::{ConnectionError, NoiseConnection}; use smite::oracles::{AcceptChannelContext, AcceptChannelOracle, Oracle}; -use smite::pending_channel::PendingChannel; +use smite::pending_channel::{PendingChannel, V2Negotiations}; use smite::violation::Violation; use super::targets::TargetRpc; -use smite_ir::operation::AcceptChannelField; +use smite_ir::operation::{AcceptChannel2Field, AcceptChannelField, TxOutputRole}; use smite_ir::{Operation, Program, Variable}; use std::collections::{HashMap, HashSet}; use std::time::Duration; @@ -74,6 +76,11 @@ pub trait BitcoinRpc { #[must_use] fn get_new_address_script_pubkey(&mut self) -> ScriptBuf; + /// Returns the consensus-serialized transaction with the given txid, or + /// `None` if it is unknown to the node. Used for `tx_add_input`'s `prevtx`. + #[must_use] + fn get_raw_transaction(&mut self, txid: Txid) -> Option>; + /// Signs and broadcasts a transaction. Returns hex-encoded raw transaction /// if it is consensus-valid but rejected by mempool policy, so it can be /// added to the `private_mempool`; returns `None` if it was broadcast or is @@ -81,6 +88,12 @@ pub trait BitcoinRpc { #[must_use] fn sign_and_broadcast_tx(&mut self, tx: &bitcoin::Transaction) -> Option; + /// Signs the wallet-owned inputs of a transaction without broadcasting it, + /// leaving inputs the wallet cannot sign untouched. Used to lift our own + /// witnesses for `tx_signatures`. + #[must_use] + fn sign_tx(&mut self, tx: &bitcoin::Transaction) -> Option; + /// Locks the given outpoints so subsequent [`get_utxos`](Self::get_utxos) /// calls exclude them, preventing independently built transactions from /// reusing the same coins. @@ -109,10 +122,18 @@ impl BitcoinRpc for BitcoinCli { BitcoinCli::get_new_address_script_pubkey(self) } + fn get_raw_transaction(&mut self, txid: Txid) -> Option> { + BitcoinCli::get_raw_transaction(self, txid) + } + fn sign_and_broadcast_tx(&mut self, tx: &bitcoin::Transaction) -> Option { BitcoinCli::sign_and_broadcast_tx(self, tx) } + fn sign_tx(&mut self, tx: &bitcoin::Transaction) -> Option { + BitcoinCli::sign_tx(self, tx) + } + fn lock_utxos(&mut self, outpoints: &[OutPoint]) { BitcoinCli::lock_utxos(self, outpoints); } @@ -132,6 +153,10 @@ impl BitcoinRpc for BitcoinCli { pub struct ProgramContext { /// Target node's identity public key. pub target_pubkey: PublicKey, + /// Our own identity public key, derived from the fixed Noise static key. + /// BOLT 2 breaks a `tx_signatures` ordering tie on the lexicographically + /// lower `node_id`, so both are needed to decide who signs first. + pub local_pubkey: PublicKey, /// Chain hash (genesis block hash). pub chain_hash: [u8; 32], /// Current block height at snapshot time. @@ -249,6 +274,9 @@ pub struct Executor { /// `temporary_channel_id`, so the funding flow can build commitments from /// the parameters actually sent on the wire. negotiations: HashMap, + /// Channel establishment v2 negotiation state, addressable by either the + /// `temporary_channel_id` or the derived `channel_id` a message carries. + negotiations_v2: V2Negotiations, /// Transactions stored outside Bitcoin Core's mempool, typically because they /// were rejected by mempool policy, to be included in the next `MineBlocks` /// operation. Each is stored as `(txid, raw_hex)`: re-signing the same @@ -275,6 +303,7 @@ impl Executor { context, channel_states: HashMap::new(), negotiations: HashMap::new(), + negotiations_v2: V2Negotiations::default(), private_mempool: Vec::new(), unmined_txids: HashSet::new(), mined_txids: HashSet::new(), @@ -541,11 +570,15 @@ impl Executor { start.elapsed(), txid ); + // A channel establishment v2 funding transaction carries the + // peer's inputs, which our wallet cannot sign. Its + // `tx_signatures` is the only thing that can witness them. + let tx = apply_peer_witnesses(&self.negotiations_v2, &ft.tx); // Queue transactions rejected by the mempool in the private // mempool so they can be mined later. Dedup on txid so the // same transaction broadcast again before then is queued // once, regardless of any change to its signed hex. - if let Some(hex) = self.bitcoin_cli.sign_and_broadcast_tx(&ft.tx) + if let Some(hex) = self.bitcoin_cli.sign_and_broadcast_tx(&tx) && !self.private_mempool.iter().any(|(t, _)| *t == txid) { self.private_mempool.push((txid, hex)); @@ -583,6 +616,257 @@ impl Executor { ); Some(Variable::ShortChannelId(scid)) } + + // -- Channel establishment v2 -- + Operation::DeriveTemporaryChannelIdV2 => { + let revocation_basepoint = resolve_pubkey(&variables, instr.inputs[0]); + Some(Variable::ChannelId( + ChannelId::v2_temporary_from_revocation_basepoint(&revocation_basepoint), + )) + } + + Operation::DeriveChannelIdV2 => { + let ours = resolve_pubkey(&variables, instr.inputs[0]); + let theirs = resolve_pubkey(&variables, instr.inputs[1]); + Some(Variable::ChannelId( + ChannelId::v2_from_revocation_basepoints(&ours, &theirs), + )) + } + + Operation::ExtractAcceptChannel2(field) => { + let ac = resolve_accept_channel2(&variables, instr.inputs[0]); + Some(extract_field_v2(ac, *field)) + } + + Operation::BuildOpenChannel2 { + require_confirmed_inputs, + } => { + let oc = + build_open_channel2(&variables, &instr.inputs, *require_confirmed_inputs); + Some(Variable::OpenChannel2Message(oc)) + } + + Operation::SendOpenChannel2 => { + let oc = resolve_open_channel2_message(&variables, instr.inputs[0]); + self.negotiations_v2.record_open(oc); + let encoded = Message::OpenChannel2(oc.clone()).encode(); + log::debug!( + "[{:?}] SendOpenChannel2: {} bytes", + start.elapsed(), + encoded.len(), + ); + self.conn.send_message(&encoded)?; + Some(Variable::SentOpenChannel2) + } + + Operation::RecvAcceptChannel2 => { + consume_sent_open_channel2(&mut variables, instr.inputs[0]); + log::debug!("[{:?}] RecvAcceptChannel2: waiting", start.elapsed()); + let ac = recv_accept_channel2(&mut self.conn)?; + log::debug!("[{:?}] RecvAcceptChannel2: received", start.elapsed()); + self.negotiations_v2.record_accept(&ac); + Some(Variable::AcceptChannel2(ac)) + } + + Operation::SendTxAddInput { + serial_id, + utxo_index, + sequence, + } => { + let msg = build_tx_add_input( + &variables, + &instr.inputs, + *serial_id, + *utxo_index, + *sequence, + &mut self.bitcoin_cli, + &mut self.negotiations_v2, + ); + let channel_id = msg.channel_id; + let encoded = Message::TxAddInput(msg).encode(); + log::debug!( + "[{:?}] SendTxAddInput: serial_id={serial_id}, {} bytes", + start.elapsed(), + encoded.len(), + ); + self.conn.send_message(&encoded)?; + Some(Variable::SentInteractiveTx(channel_id)) + } + + Operation::SendTxAddOutput { serial_id, role } => { + let msg = build_tx_add_output( + &variables, + &instr.inputs, + *serial_id, + *role, + &mut self.bitcoin_cli, + &mut self.negotiations_v2, + ); + let channel_id = msg.channel_id; + let encoded = Message::TxAddOutput(msg).encode(); + log::debug!( + "[{:?}] SendTxAddOutput: serial_id={serial_id}, role={role}, {} bytes", + start.elapsed(), + encoded.len(), + ); + self.conn.send_message(&encoded)?; + Some(Variable::SentInteractiveTx(channel_id)) + } + + Operation::SendTxRemoveInput { serial_id } => { + let channel_id = resolve_channel_id(&variables, instr.inputs[0]); + record_sent_step( + &mut self.negotiations_v2, + channel_id, + Step::RemoveInput(*serial_id), + ); + let encoded = Message::TxRemoveInput(TxRemoveInput { + channel_id, + serial_id: *serial_id, + }) + .encode(); + log::debug!( + "[{:?}] SendTxRemoveInput: serial_id={serial_id}", + start.elapsed(), + ); + self.conn.send_message(&encoded)?; + Some(Variable::SentInteractiveTx(channel_id)) + } + + Operation::SendTxRemoveOutput { serial_id } => { + let channel_id = resolve_channel_id(&variables, instr.inputs[0]); + record_sent_step( + &mut self.negotiations_v2, + channel_id, + Step::RemoveOutput(*serial_id), + ); + let encoded = Message::TxRemoveOutput(TxRemoveOutput { + channel_id, + serial_id: *serial_id, + }) + .encode(); + log::debug!( + "[{:?}] SendTxRemoveOutput: serial_id={serial_id}", + start.elapsed(), + ); + self.conn.send_message(&encoded)?; + Some(Variable::SentInteractiveTx(channel_id)) + } + + Operation::SendTxComplete => { + let channel_id = resolve_channel_id(&variables, instr.inputs[0]); + record_sent_step(&mut self.negotiations_v2, channel_id, Step::Complete); + let encoded = Message::TxComplete(TxComplete { channel_id }).encode(); + log::debug!("[{:?}] SendTxComplete", start.elapsed()); + self.conn.send_message(&encoded)?; + Some(Variable::SentInteractiveTx(channel_id)) + } + + Operation::RecvInteractiveTx => { + let channel_id = consume_sent_interactive_tx(&mut variables, instr.inputs[0]); + if is_interactive_tx_expected(&self.negotiations_v2, channel_id) { + log::debug!("[{:?}] RecvInteractiveTx: waiting", start.elapsed()); + let msg = self.recv_interactive_tx()?; + log::debug!("[{:?}] RecvInteractiveTx: got {msg}", start.elapsed()); + } else { + log::debug!( + "[{:?}] RecvInteractiveTx: negotiation concluded, nothing to receive", + start.elapsed(), + ); + } + None + } + + Operation::BuildFundingTransactionV2 => { + let channel_id = resolve_channel_id(&variables, instr.inputs[0]); + self.settle_negotiation(channel_id)?; + let ft = build_funding_transaction_v2(&self.negotiations_v2, channel_id); + log::debug!( + "[{:?}] BuildFundingTransactionV2: txid={} vout={}", + start.elapsed(), + ft.tx.compute_txid(), + ft.vout, + ); + Some(Variable::FundingTransaction(ft)) + } + + Operation::SendCommitmentSigned => { + self.settle_negotiation(resolve_channel_id(&variables, instr.inputs[2]))?; + let cs = build_commitment_signed( + &variables, + &instr.inputs, + &mut self.channel_states, + &mut self.negotiations_v2, + &self.mined_txids, + )?; + let encoded = Message::CommitmentSigned(cs).encode(); + log::debug!( + "[{:?}] SendCommitmentSigned: {} bytes", + start.elapsed(), + encoded.len(), + ); + self.conn.send_message(&encoded)?; + Some(Variable::SentCommitmentSigned) + } + + Operation::RecvCommitmentSigned => { + consume_sent_commitment_signed(&mut variables, instr.inputs[0]); + log::debug!("[{:?}] RecvCommitmentSigned: waiting", start.elapsed()); + let cs = recv_commitment_signed(&mut self.conn)?; + log::debug!("[{:?}] RecvCommitmentSigned: received", start.elapsed()); + verify_commitment_signed(&cs, &self.channel_states, &mut self.negotiations_v2)?; + Some(Variable::ChannelId(cs.channel_id)) + } + + Operation::RecvTxSignatures => { + let channel_id = resolve_channel_id(&variables, instr.inputs[0]); + if is_tx_signatures_expected(&self.negotiations_v2, channel_id, &self.context) { + log::debug!("[{:?}] RecvTxSignatures: waiting", start.elapsed()); + let ts = recv_tx_signatures(&mut self.conn)?; + log::debug!( + "[{:?}] RecvTxSignatures: received {} witness(es)", + start.elapsed(), + ts.witnesses.len(), + ); + let contributed = self.negotiations_v2.get(ts.channel_id).map(|pending| { + pending + .tx_exchange + .shared_tx() + .input_positions(Contributor::Remote) + .len() + }); + let witnesses = validate_peer_witnesses(&ts, contributed)?; + if let Some(pending) = self.negotiations_v2.get_mut(ts.channel_id) { + pending.commitment_exchange.tx_signatures.received = true; + pending.peer_witnesses = witnesses; + } + } + None + } + + Operation::SendTxSignatures => { + let channel_id = resolve_channel_id(&variables, instr.inputs[0]); + self.settle_negotiation(channel_id)?; + let ts = build_tx_signatures( + &variables, + &instr.inputs, + &mut self.bitcoin_cli, + &self.negotiations_v2, + ); + let encoded = Message::TxSignatures(ts).encode(); + log::debug!( + "[{:?}] SendTxSignatures: {} bytes", + start.elapsed(), + encoded.len(), + ); + self.conn.send_message(&encoded)?; + // BOLT 2 has the peer reply with its own once it has ours, + // so this is what makes a later receive expect one. + if let Some(pending) = self.negotiations_v2.get_mut(channel_id) { + pending.commitment_exchange.tx_signatures.sent = true; + } + None + } }; variables.push(result); @@ -590,6 +874,40 @@ impl Executor { Ok(()) } + + /// Reads the peer's next interactive transaction message and applies it + /// to the negotiation it names. + fn recv_interactive_tx(&mut self) -> Result { + let msg = recv_non_ping(&mut self.conn, RECV_IDLE_TIMEOUT)?; + apply_interactive_tx(&mut self.negotiations_v2, &msg)?; + Ok(msg) + } + + /// Reads every reply the peer still owes on `channel_id`, so what is + /// built from the negotiation next is what the peer negotiated. + /// + /// A program may send several messages before reading any reply, and + /// whether our `tx_complete` concluded the exchange is only known from + /// the reply to the message before it. Building the funding transaction + /// or signing over it while replies are owed would use a transaction the + /// peer may never have agreed to, and its perfectly good signatures + /// would then read as invalid. The replies are already on their way, so + /// reading them costs nothing, and a later `RecvInteractiveTx` finds + /// nothing owed and reads nothing. + /// + /// The count of owed replies is exact, so this never reads into whatever + /// the peer moved on to after the exchange. + fn settle_negotiation(&mut self, channel_id: ChannelId) -> Result<(), ExecuteError> { + while self + .negotiations_v2 + .get(channel_id) + .is_some_and(|pending| pending.tx_exchange.expects_reply()) + { + let msg = self.recv_interactive_tx()?; + log::debug!("settling negotiation {channel_id}: got {msg}"); + } + Ok(()) + } } // -- Variable resolution -- @@ -647,6 +965,16 @@ fn resolve_timestamp(variables: &[Option], index: usize) -> u32 { } } +fn resolve_block_height(variables: &[Option], index: usize) -> u32 { + match resolve(variables, index) { + Variable::BlockHeight(v) => *v, + other => panic!( + "variable {index}: expected BlockHeight, got {:?}", + other.var_type(), + ), + } +} + fn resolve_u16(variables: &[Option], index: usize) -> u16 { match resolve(variables, index) { Variable::U16(v) => *v, @@ -761,6 +1089,26 @@ fn resolve_accept_channel(variables: &[Option], index: usize) -> &Acce } } +fn resolve_open_channel2_message(variables: &[Option], index: usize) -> &OpenChannel2 { + match resolve(variables, index) { + Variable::OpenChannel2Message(v) => v, + other => panic!( + "variable {index}: expected OpenChannel2Message, got {:?}", + other.var_type(), + ), + } +} + +fn resolve_accept_channel2(variables: &[Option], index: usize) -> &AcceptChannel2 { + match resolve(variables, index) { + Variable::AcceptChannel2(v) => v, + other => panic!( + "variable {index}: expected AcceptChannel2, got {:?}", + other.var_type(), + ), + } +} + fn resolve_funding_transaction( variables: &[Option], index: usize, @@ -787,6 +1135,49 @@ fn consume_sent_open_channel(variables: &mut [Option], index: usize) { } } +fn consume_sent_open_channel2(variables: &mut [Option], index: usize) { + match resolve(variables, index) { + Variable::SentOpenChannel2 => { + // Consume the affine `SentOpenChannel2`. + variables[index] = None; + } + other => panic!( + "variable {index}: expected SentOpenChannel2, got {:?}", + other.var_type(), + ), + } +} + +/// Consumes the affine `SentInteractiveTx`, returning the `channel_id` the +/// message it stands for was sent on. +fn consume_sent_interactive_tx(variables: &mut [Option], index: usize) -> ChannelId { + match resolve(variables, index) { + Variable::SentInteractiveTx(channel_id) => { + let channel_id = *channel_id; + // Consume the affine `SentInteractiveTx`. + variables[index] = None; + channel_id + } + other => panic!( + "variable {index}: expected SentInteractiveTx, got {:?}", + other.var_type(), + ), + } +} + +fn consume_sent_commitment_signed(variables: &mut [Option], index: usize) { + match resolve(variables, index) { + Variable::SentCommitmentSigned => { + // Consume the affine `SentCommitmentSigned`. + variables[index] = None; + } + other => panic!( + "variable {index}: expected SentCommitmentSigned, got {:?}", + other.var_type(), + ), + } +} + fn consume_sent_funding_created(variables: &mut [Option], index: usize) { match resolve(variables, index) { Variable::SentFundingCreated => { @@ -874,6 +1265,722 @@ fn build_open_channel(variables: &[Option], inputs: &[usize]) -> OpenC } } +/// Builds an `OpenChannel2` from 21 input variables (wire order). +fn build_open_channel2( + variables: &[Option], + inputs: &[usize], + require_confirmed_inputs: bool, +) -> OpenChannel2 { + OpenChannel2 { + chain_hash: resolve_chain_hash(variables, inputs[0]), + temporary_channel_id: resolve_channel_id(variables, inputs[1]), + funding_feerate_perkw: resolve_feerate(variables, inputs[2]), + commitment_feerate_perkw: resolve_feerate(variables, inputs[3]), + funding_satoshis: resolve_amount(variables, inputs[4]), + dust_limit_satoshis: resolve_amount(variables, inputs[5]), + max_htlc_value_in_flight_msat: resolve_amount(variables, inputs[6]), + htlc_minimum_msat: resolve_amount(variables, inputs[7]), + to_self_delay: resolve_u16(variables, inputs[8]), + max_accepted_htlcs: resolve_u16(variables, inputs[9]), + locktime: resolve_block_height(variables, inputs[10]), + funding_pubkey: resolve_pubkey(variables, inputs[11]), + revocation_basepoint: resolve_pubkey(variables, inputs[12]), + payment_basepoint: resolve_pubkey(variables, inputs[13]), + delayed_payment_basepoint: resolve_pubkey(variables, inputs[14]), + htlc_basepoint: resolve_pubkey(variables, inputs[15]), + first_per_commitment_point: resolve_pubkey(variables, inputs[16]), + second_per_commitment_point: resolve_pubkey(variables, inputs[17]), + channel_flags: resolve_u8(variables, inputs[18]), + tlvs: OpenChannel2Tlvs { + // Always send the TLV: a zero-length value is the BOLT 2 opt-out + // signal when option_upfront_shutdown_script is negotiated, so + // omitting it would be a protocol violation in that case. + upfront_shutdown_script: Some(resolve_bytes(variables, inputs[19]).to_vec()), + // BOLT 2 requires `open_channel2` to set `channel_type`, but an + // empty `Features` still omits the TLV so the receiver's "MUST fail + // if channel_type is not set" path stays reachable. + channel_type: nonempty_or_none(resolve_features(variables, inputs[20])), + require_confirmed_inputs, + }, + } +} + +/// Records a step we sent on `channel_id` in its negotiation. +/// +/// A negotiation we do not track records nothing: the message still goes out +/// for the peer to judge, as does anything sent after the exchange concluded. +fn record_sent_step(negotiations: &mut V2Negotiations, channel_id: ChannelId, step: Step) { + if let Some(pending) = negotiations.get_mut(channel_id) { + pending.tx_exchange.send(step); + } +} + +/// Builds a `tx_add_input` proposing one of our wallet UTXOs, and records it in +/// the negotiation so the shared transaction can be rebuilt later. +/// +/// `utxo_index` selects modulo the spendable set, so any index is meaningful +/// and reusing one proposes the same outpoint twice, which the peer must +/// reject. An empty wallet or a previous transaction the node does not know +/// yields an empty `prevtx`, which is likewise the peer's to reject. +fn build_tx_add_input( + variables: &[Option], + inputs: &[usize], + serial_id: u64, + utxo_index: u8, + sequence: u32, + cli: &mut impl BitcoinRpc, + negotiations: &mut V2Negotiations, +) -> TxAddInput { + let channel_id = resolve_channel_id(variables, inputs[0]); + + let utxos = cli.get_utxos(); + let selected = (!utxos.is_empty()).then(|| { + let index = usize::from(utxo_index) % utxos.len(); + utxos[index].clone() + }); + + let (prevtx, prevtx_vout) = match &selected { + Some(utxo) => ( + cli.get_raw_transaction(utxo.outpoint.txid) + .unwrap_or_default(), + utxo.outpoint.vout, + ), + None => (Vec::new(), 0), + }; + + if let Some(utxo) = &selected { + // Locking keeps a later selection from proposing the same coin, which + // the peer would reject as a duplicate input. + cli.lock_utxos(&[utxo.outpoint]); + } + + let mut input = SharedInput::from_prevtx(&prevtx, prevtx_vout, sequence, Contributor::Local); + if let Some(utxo) = &selected { + // Prefer what the wallet told us: a missing `prevtx` still leaves + // us knowing exactly what we are spending. + input.outpoint = utxo.outpoint; + input.prevout = Some(TxOut { + value: utxo.amount, + script_pubkey: utxo.script_pubkey.clone(), + }); + } + record_sent_step( + negotiations, + channel_id, + Step::AddInput { serial_id, input }, + ); + + TxAddInput { + channel_id, + serial_id, + prevtx, + prevtx_vout, + sequence, + tlvs: TxAddInputTlvs::default(), + } +} + +/// Builds a `tx_add_output` and records it in the negotiation. +/// +/// The funding and change roles derive their value and script from the +/// negotiation; without one to derive from they fall back to the value and +/// script inputs, so the message still goes out and the peer still gets to +/// judge it. +fn build_tx_add_output( + variables: &[Option], + inputs: &[usize], + serial_id: u64, + role: TxOutputRole, + cli: &mut impl BitcoinRpc, + negotiations: &mut V2Negotiations, +) -> TxAddOutput { + let channel_id = resolve_channel_id(variables, inputs[0]); + let explicit_sats = resolve_amount(variables, inputs[1]); + let explicit_script = ScriptBuf::from(resolve_bytes(variables, inputs[2]).to_vec()); + + let derived = match role { + TxOutputRole::Explicit => None, + TxOutputRole::Funding => negotiations.get(channel_id).and_then(|pending| { + Some((pending.total_funding_satoshis(), pending.funding_script()?)) + }), + TxOutputRole::Change => { + let change_script = cli.get_new_address_script_pubkey(); + negotiations.get(channel_id).map(|pending| { + let feerate = pending.open_channel2.funding_feerate_perkw; + let fee = pending + .tx_exchange + .shared_tx() + .local_fee_sat(feerate, &[change_script.len()]); + // Whatever our inputs cover beyond our funding contribution and + // our share of the fee. Saturating: an under-funded selection + // yields a zero-value output the peer rejects, rather than a + // panic. + let value = pending + .tx_exchange + .shared_tx() + .contributed_input_value(Contributor::Local) + .saturating_sub(pending.open_channel2.funding_satoshis) + .saturating_sub(fee); + (value, change_script) + }) + } + }; + + let (sats, script) = derived.unwrap_or((explicit_sats, explicit_script)); + let script = script.into_bytes(); + + record_sent_step( + negotiations, + channel_id, + Step::AddOutput { + serial_id, + output: SharedOutput { + value: sats, + script_pubkey: ScriptBuf::from(script.clone()), + contributor: Contributor::Local, + }, + }, + ); + + TxAddOutput { + channel_id, + serial_id, + sats, + script, + } +} + +/// Applies one received interactive transaction message to the negotiation it +/// names. +/// +/// A message for an unknown negotiation is logged and dropped rather than +/// reported: only the peer can tell whether it is consistent with its own +/// view, and it will fail the negotiation if not. +fn apply_interactive_tx( + negotiations: &mut V2Negotiations, + msg: &Message, +) -> Result<(), ExecuteError> { + let (channel_id, step) = match msg { + Message::TxAddInput(m) => ( + m.channel_id, + Step::AddInput { + serial_id: m.serial_id, + input: SharedInput::from_prevtx( + &m.prevtx, + m.prevtx_vout, + m.sequence, + Contributor::Remote, + ), + }, + ), + Message::TxAddOutput(m) => ( + m.channel_id, + Step::AddOutput { + serial_id: m.serial_id, + output: SharedOutput { + value: m.sats, + script_pubkey: ScriptBuf::from(m.script.clone()), + contributor: Contributor::Remote, + }, + }, + ), + Message::TxRemoveInput(m) => (m.channel_id, Step::RemoveInput(m.serial_id)), + Message::TxRemoveOutput(m) => (m.channel_id, Step::RemoveOutput(m.serial_id)), + Message::TxComplete(m) => (m.channel_id, Step::Complete), + Message::TxAbort(m) => { + log::debug!( + "peer aborted the negotiation: {}", + m.message().unwrap_or(""), + ); + if let Some(pending) = negotiations.get_mut(m.channel_id) { + pending.tx_exchange.abort(); + } + return Ok(()); + } + other => { + return Err(ExecuteError::UnexpectedMessage { + expected: MessageType::TX_COMPLETE, + got: other.msg_type(), + }); + } + }; + + match negotiations.get_mut(channel_id) { + Some(pending) => pending.tx_exchange.receive(step), + None => { + log::debug!("interactive tx message for unknown channel_id {channel_id}, ignoring"); + } + } + + Ok(()) +} + +/// Reconstructs the shared funding transaction from a negotiation. +/// +/// An unknown `channel_id` yields an empty transaction rather than an error: +/// a mutated program may point this at a channel that was never opened, and +/// every consumer already has to cope with a funding output that does not +/// match. +fn build_funding_transaction_v2( + negotiations: &V2Negotiations, + channel_id: ChannelId, +) -> FundingTransaction { + let Some(pending) = negotiations.get(channel_id) else { + log::debug!("no v2 negotiation for channel_id {channel_id}, building an empty transaction"); + return FundingTransaction { + tx: bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + }, + vout: 0, + }; + }; + + match pending.funding_script() { + Some(script) => pending + .tx_exchange + .shared_tx() + .build_funding(&script, pending.total_funding_satoshis()), + // Without `accept_channel2` the funding script is unknown, so there is + // nothing to locate; `vout` 0 keeps the result well-typed. + None => FundingTransaction { + tx: pending.tx_exchange.shared_tx().build(), + vout: 0, + }, + } +} + +/// Builds the v2 `commitment_signed` for the initial commitment and starts +/// tracking the channel. +/// +/// Without both `open_channel2` and the peer's `accept_channel2` there is no +/// commitment to sign, so this falls back to an all-zero signature and leaves +/// `channel_states` untouched, mirroring the v1 `funding_created` path. +/// +/// The negotiation is found by either of its ids, but the peer only answers on +/// the derived `channel_id`. A message sent on the `temporary_channel_id` +/// still goes out signed, so the peer gets to judge it, but it neither counts +/// as our side of the commitment exchange nor tracks a channel: the peer's own +/// `commitment_signed` arrives on the derived id regardless, and reading it as +/// the answer to ours would blame the target for the program's confusion. +fn build_commitment_signed( + variables: &[Option], + inputs: &[usize], + channel_states: &mut HashMap, + negotiations: &mut V2Negotiations, + mined_txids: &HashSet, +) -> Result { + let funding_tx = resolve_funding_transaction(variables, inputs[0]).clone(); + let opener_funding_privkey_bytes = resolve_private_key(variables, inputs[1]); + let channel_id = resolve_channel_id(variables, inputs[2]); + + let unsigned = |channel_id| CommitmentSigned { + channel_id, + signature: Signature::from_compact(&[0u8; 64]).expect("zero bytes parse as a signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }; + + let Some(pending) = negotiations.get_mut(channel_id) else { + return Ok(unsigned(channel_id)); + }; + let Some(accept_channel2) = pending.accept_channel2.clone() else { + return Ok(unsigned(channel_id)); + }; + let open_channel2 = pending.open_channel2.clone(); + let total_funding_satoshis = pending.total_funding_satoshis(); + let on_derived_channel_id = pending.channel_id == Some(channel_id); + let already_sent = pending.commitment_exchange.commitment_signed.sent; + if on_derived_channel_id { + pending.commitment_exchange.commitment_signed.sent = true; + } + let negotiated_txid = pending.tx_exchange.shared_tx().build().compute_txid(); + + let opener_funding_privkey = + SecretKey::from_slice(&opener_funding_privkey_bytes).expect("valid private key"); + + let funding_outpoint = OutPoint { + txid: funding_tx.tx.compute_txid(), + vout: funding_tx.vout, + }; + let config = ChannelConfig { + funding_outpoint, + funding_satoshis: total_funding_satoshis, + channel_type: Features::from(open_channel2.tlvs.channel_type.clone().unwrap_or_default()), + opener: ChannelPartyConfig { + funding_pubkey: open_channel2.funding_pubkey, + payment_basepoint: open_channel2.payment_basepoint, + revocation_basepoint: open_channel2.revocation_basepoint, + delayed_payment_basepoint: open_channel2.delayed_payment_basepoint, + dust_limit_satoshis: open_channel2.dust_limit_satoshis, + to_self_delay: open_channel2.to_self_delay, + }, + acceptor: ChannelPartyConfig { + funding_pubkey: accept_channel2.funding_pubkey, + payment_basepoint: accept_channel2.payment_basepoint, + revocation_basepoint: accept_channel2.revocation_basepoint, + delayed_payment_basepoint: accept_channel2.delayed_payment_basepoint, + dust_limit_satoshis: accept_channel2.dust_limit_satoshis, + to_self_delay: accept_channel2.to_self_delay, + }, + minimum_depth: accept_channel2.minimum_depth, + }; + + // v2 has no `push_msat`: each side's balance is simply what it contributed + // to the funding output. Pushing the acceptor's contribution reproduces + // exactly that split, since the total is the sum of the two. + let push_msat = accept_channel2.funding_satoshis.saturating_mul(1000); + let state = config.new_initial_commitment( + push_msat, + open_channel2.commitment_feerate_perkw, + open_channel2.first_per_commitment_point, + accept_channel2.first_per_commitment_point, + )?; + let holder = HolderIdentity { + side: Side::Opener, + funding_privkey: opener_funding_privkey, + }; + let signature = config.sign_counterparty_commitment(&state, &holder); + + // The peer signs over the transaction it negotiated, so a funding output + // with the right script and value is not enough: the transaction holding + // it must be the negotiated one too. A funding transaction built before + // the negotiation concluded carries the same output under another txid. + let is_funding_outpoint_valid = funding_outpoint.txid == negotiated_txid + && funding_tx.matches_funding_output( + &open_channel2.funding_pubkey, + &accept_channel2.funding_pubkey, + total_funding_satoshis, + ); + + // Only track on the first `commitment_signed` for this negotiation, so a + // resend cannot clobber state that has already advanced. + if on_derived_channel_id && !already_sent { + channel_states.entry(channel_id).or_insert_with(|| { + ChannelState::new( + config, + holder, + state, + is_funding_outpoint_valid, + mined_txids.contains(&funding_outpoint.txid), + ) + }); + } + + Ok(CommitmentSigned { + channel_id, + signature, + // BOLT 2: the first `commitment_signed` of a v2 open carries no HTLCs. + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) +} + +/// Receives and decodes a `commitment_signed` message. +fn recv_commitment_signed(conn: &mut impl Connection) -> Result { + match recv_non_ping(conn, RECV_IDLE_TIMEOUT)? { + Message::CommitmentSigned(cs) => Ok(cs), + other => Err(ExecuteError::UnexpectedMessage { + expected: MessageType::COMMITMENT_SIGNED, + got: other.msg_type(), + }), + } +} + +/// Receives and decodes a `tx_signatures` message. +fn recv_tx_signatures(conn: &mut impl Connection) -> Result { + match recv_non_ping(conn, RECV_IDLE_TIMEOUT)? { + Message::TxSignatures(ts) => Ok(ts), + other => Err(ExecuteError::UnexpectedMessage { + expected: MessageType::TX_SIGNATURES, + got: other.msg_type(), + }), + } +} + +/// Verifies the counterparty's `commitment_signed` against the holder's +/// initial commitment. +/// +/// # Errors +/// +/// Returns [`Violation::UnknownChannel`] if the message names a channel we +/// established no state for, [`Violation::InvalidCounterpartySignature`] if the +/// signature does not verify, or [`Violation::UnexpectedHtlcSignatures`] if it +/// carries HTLC signatures, which BOLT 2 forbids for a v2 open. +/// +/// A `commitment_signed` we have no state for is only reported when the +/// negotiation it names is one we sent our own `commitment_signed` on. Anything +/// else is our own doing rather than the target's: a mutated program may have +/// dropped the `accept_channel2` that would have established the state, or +/// pointed `SendCommitmentSigned` at a different `channel_id` than the one the +/// peer answers on, and blaming the target for either would be a false +/// positive. +/// +/// The signature itself is checked only when our commitment was built over the +/// negotiated funding outpoint. `SendCommitmentSigned` takes the funding +/// transaction as an operand, so a mutated program can point it at one from an +/// unrelated negotiation, or at one built from this negotiation before every +/// output was added; the peer then signs the outpoint it actually negotiated, +/// we verify against a different one, and every signature would fail to verify +/// no matter what the target did. +fn verify_commitment_signed( + cs: &CommitmentSigned, + channel_states: &HashMap, + negotiations: &mut V2Negotiations, +) -> Result<(), ExecuteError> { + if !cs.htlc_signatures.is_empty() { + return Err(Violation::UnexpectedHtlcSignatures(cs.channel_id).into()); + } + + let Some(state) = channel_states.get(&cs.channel_id) else { + if negotiations + .get(cs.channel_id) + .is_some_and(|pending| pending.commitment_exchange.commitment_signed.sent) + { + return Err(Violation::UnknownChannel(cs.channel_id).into()); + } + log::debug!( + "commitment_signed for {} with no v2 commitment exchange in flight, ignoring", + cs.channel_id, + ); + return Ok(()); + }; + + if !state.is_funding_outpoint_valid { + log::debug!( + "commitment_signed for {} was built over a funding output the negotiation never \ + produced, not checking the signature", + cs.channel_id, + ); + } else if !state.config.verify_counterparty_signature( + &state.commitment, + &state.holder, + &cs.signature, + ) { + return Err(Violation::InvalidCounterpartySignature(cs.channel_id).into()); + } + + if let Some(pending) = negotiations.get_mut(cs.channel_id) { + pending.commitment_exchange.commitment_signed.received = true; + } + + Ok(()) +} + +/// Returns whether the peer owes us a reply in the interactive transaction +/// exchange. +/// +/// The exchange is turn-based, so the peer answers every message we send until +/// the one that concludes it on two consecutive `tx_complete`s. Reading when +/// nothing is owed would consume whatever the peer moved on to, usually its +/// `commitment_signed`, and leave every later operation a message behind. +/// +/// This counts what is owed rather than asking whether the exchange concluded. +/// A mutator that drops one receive leaves the program permanently short of a +/// reply, and the count lets the next receive settle the backlog instead of +/// stranding it. +/// +/// A negotiation we do not track still reads. A mutated program may have sent +/// on a channel we never opened, and the peer's rejection of it is worth +/// surfacing. +fn is_interactive_tx_expected(negotiations: &V2Negotiations, channel_id: ChannelId) -> bool { + negotiations + .get(channel_id) + .is_none_or(|pending| pending.tx_exchange.expects_reply()) +} + +/// Returns whether the peer owes us a `tx_signatures` for this negotiation. +/// +/// Both `commitment_signed`s must have been exchanged, which is what entitles +/// either peer to send at all. After that BOLT 2 gives two ways for the peer to +/// owe one: it contributed the least, so it signs first, or it received ours +/// and "MUST reply with their `tx_signatures` if not already transmitted". +/// Waiting outside those two cases would block on a message the peer is itself +/// waiting on us to send. +fn is_tx_signatures_expected( + negotiations: &V2Negotiations, + channel_id: ChannelId, + context: &ProgramContext, +) -> bool { + let Some(pending) = negotiations.get(channel_id) else { + return false; + }; + + let peer_signs_first = signs_first( + pending + .tx_exchange + .shared_tx() + .contributed_input_value(Contributor::Remote), + pending + .tx_exchange + .shared_tx() + .contributed_input_value(Contributor::Local), + &context.target_pubkey, + &context.local_pubkey, + ); + + pending.commitment_exchange.commitment_signed.sent + && pending.commitment_exchange.commitment_signed.received + && !pending.commitment_exchange.tx_signatures.received + && !pending.tx_exchange.aborted() + && (peer_signs_first || pending.commitment_exchange.tx_signatures.sent) +} + +/// Signs the shared funding transaction and builds `tx_signatures` carrying one +/// witness per input we contributed, ordered by its `serial_id`. +/// +/// The wallet signs only what it owns, so "the wallet could sign it" is exactly +/// "we contributed it". A transaction the wallet cannot sign at all yields an +/// empty witness list, which the peer rejects rather than the harness failing. +fn build_tx_signatures( + variables: &[Option], + inputs: &[usize], + cli: &mut impl BitcoinRpc, + negotiations: &V2Negotiations, +) -> TxSignatures { + let channel_id = resolve_channel_id(variables, inputs[0]); + let funding_tx = resolve_funding_transaction(variables, inputs[1]); + let txid = funding_tx.tx.compute_txid(); + + let signed = cli.sign_tx(&funding_tx.tx); + + let local_positions = negotiations + .get(channel_id) + .map(|pending| { + pending + .tx_exchange + .shared_tx() + .input_positions(Contributor::Local) + }) + .unwrap_or_default(); + + let witnesses = signed + .as_ref() + .map(|tx| { + local_positions + .iter() + .filter_map(|&position| tx.input.get(position)) + .map(|txin| bitcoin::consensus::encode::serialize(&txin.witness)) + .collect::>() + }) + .unwrap_or_default(); + + TxSignatures { + channel_id, + txid, + witnesses, + tlvs: TxSignaturesTlvs::default(), + } +} + +/// Validates and decodes the witnesses of a received `tx_signatures`. +/// +/// `contributed` is how many inputs we recorded the peer adding, or `None` when +/// the message names a negotiation we track no state for and there is nothing +/// to count against. +/// +/// # Errors +/// +/// Returns [`Violation::InvalidTxSignatures`] for each condition BOLT 2 has the +/// receiver fail the negotiation over: +/// - an empty `witness`, named outright as a MUST-fail; +/// - a `witness_data` that is not the bitcoin wire encoding the spec's +/// rationale prescribes, so no conformant target emits it; +/// - a `num_witnesses` that does not equal the number of inputs the sender +/// added, which the sending node's own requirements forbid. +/// +/// The remaining two MUST-fail conditions, non-standard witnesses and a +/// signature flag other than `SIGHASH_ALL`, need the witness scripts and +/// signatures parsed, and are not checked yet. +fn validate_peer_witnesses( + ts: &TxSignatures, + contributed: Option, +) -> Result, Violation> { + if let Some(contributed) = contributed + && ts.witnesses.len() != contributed + { + return Err(Violation::InvalidTxSignatures( + ts.channel_id, + format!( + "{} witness(es) for the {contributed} input(s) the peer added", + ts.witnesses.len(), + ), + )); + } + + ts.witnesses + .iter() + .enumerate() + .map(|(index, encoded)| { + let witness = + bitcoin::consensus::encode::deserialize::(encoded).map_err(|e| { + Violation::InvalidTxSignatures( + ts.channel_id, + format!("witness {index} does not decode: {e}"), + ) + })?; + if witness.is_empty() { + return Err(Violation::InvalidTxSignatures( + ts.channel_id, + format!("witness {index} is empty"), + )); + } + Ok(witness) + }) + .collect() +} + +/// Attaches the witnesses from the peer's `tx_signatures` to a channel +/// establishment v2 funding transaction. +/// +/// The negotiation is found by txid, since `BroadcastTransaction` carries only +/// the transaction. Witnesses do not change a txid, so the match is exact, and +/// a v1 funding transaction matches nothing and comes back unchanged. +/// +/// Applying the peer's witnesses is what makes the shared transaction +/// broadcastable at all: our wallet owns only the inputs we contributed, so +/// without them `signrawtransactionwithwallet` can never complete it. Per BOLT +/// 2 the witnesses arrive ordered by the `serial_id` of the input they +/// correspond to, which is the order [`SharedTransaction::input_positions`] +/// returns. +/// +/// [`validate_peer_witnesses`] already rejected anything BOLT 2 fails the +/// negotiation over when the message arrived, so every witness held here is +/// well-formed and there is one per input the peer added. +fn apply_peer_witnesses( + negotiations: &V2Negotiations, + tx: &bitcoin::Transaction, +) -> bitcoin::Transaction { + let txid = tx.compute_txid(); + let mut tx = tx.clone(); + let Some(pending) = negotiations.iter().find(|pending| { + !pending.peer_witnesses.is_empty() + && pending.tx_exchange.shared_tx().build().compute_txid() == txid + }) else { + return tx; + }; + + let positions = pending + .tx_exchange + .shared_tx() + .input_positions(Contributor::Remote); + let mut applied = 0usize; + for (&position, witness) in positions.iter().zip(&pending.peer_witnesses) { + let Some(txin) = tx.input.get_mut(position) else { + continue; + }; + txin.witness = witness.clone(); + applied += 1; + } + log::debug!( + "applied {applied} of {} peer witness(es) to {txid}", + pending.peer_witnesses.len(), + ); + tx +} + /// Builds a `funding_created` message from 3 input variables. /// /// Channel parameters are read from the negotiated `open_channel` and @@ -1291,6 +2398,17 @@ fn recv_accept_channel(conn: &mut impl Connection) -> Result Result { + match recv_non_ping(conn, RECV_IDLE_TIMEOUT)? { + Message::AcceptChannel2(ac) => Ok(ac), + other => Err(ExecuteError::UnexpectedMessage { + expected: MessageType::ACCEPT_CHANNEL2, + got: other.msg_type(), + }), + } +} + /// Receives and decodes a `funding_signed` message. fn recv_funding_signed(conn: &mut impl Connection) -> Result { match recv_non_ping(conn, RECV_IDLE_TIMEOUT)? { @@ -1424,6 +2542,41 @@ fn record_recv_accept_channel( .accept_channel = Some(accept_channel.clone()); } +/// Extracts a field from a parsed `accept_channel2` message. +fn extract_field_v2(ac: &AcceptChannel2, field: AcceptChannel2Field) -> Variable { + match field { + AcceptChannel2Field::TemporaryChannelId => Variable::ChannelId(ac.temporary_channel_id), + AcceptChannel2Field::FundingSatoshis => Variable::Amount(ac.funding_satoshis), + AcceptChannel2Field::DustLimitSatoshis => Variable::Amount(ac.dust_limit_satoshis), + AcceptChannel2Field::MaxHtlcValueInFlightMsat => { + Variable::Amount(ac.max_htlc_value_in_flight_msat) + } + AcceptChannel2Field::HtlcMinimumMsat => Variable::Amount(ac.htlc_minimum_msat), + AcceptChannel2Field::MinimumDepth => Variable::BlockHeight(ac.minimum_depth), + AcceptChannel2Field::ToSelfDelay => Variable::U16(ac.to_self_delay), + AcceptChannel2Field::MaxAcceptedHtlcs => Variable::U16(ac.max_accepted_htlcs), + AcceptChannel2Field::FundingPubkey => Variable::Point(ac.funding_pubkey), + AcceptChannel2Field::RevocationBasepoint => Variable::Point(ac.revocation_basepoint), + AcceptChannel2Field::PaymentBasepoint => Variable::Point(ac.payment_basepoint), + AcceptChannel2Field::DelayedPaymentBasepoint => { + Variable::Point(ac.delayed_payment_basepoint) + } + AcceptChannel2Field::HtlcBasepoint => Variable::Point(ac.htlc_basepoint), + AcceptChannel2Field::FirstPerCommitmentPoint => { + Variable::Point(ac.first_per_commitment_point) + } + AcceptChannel2Field::SecondPerCommitmentPoint => { + Variable::Point(ac.second_per_commitment_point) + } + AcceptChannel2Field::UpfrontShutdownScript => { + Variable::Bytes(ac.tlvs.upfront_shutdown_script.clone().unwrap_or_default()) + } + AcceptChannel2Field::ChannelType => { + Variable::Features(ac.tlvs.channel_type.clone().unwrap_or_default()) + } + } +} + /// 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 b2d1a023..cc7124db 100644 --- a/smite-scenarios/src/executor/tests.rs +++ b/smite-scenarios/src/executor/tests.rs @@ -8,9 +8,11 @@ use bitcoin::Amount; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use harness::*; use programs::*; -use smite::bolt::{AcceptChannelTlvs, GossipTimestampFilter, Init, Ping}; +use smite::bolt::{AcceptChannelTlvs, GossipTimestampFilter, Init, Ping, TxAbort}; +use smite::channel_tx::build_funding_witness_script; +use smite::pending_channel::PendingChannelV2; use smite_ir::Instruction; -use smite_ir::operation::ShutdownScriptVariant; +use smite_ir::operation::{ChannelTypeVariant, ShutdownScriptVariant}; /// Decodes a sent message expected to be a `channel_announcement`. fn decode_sent_channel_announcement(bytes: &[u8]) -> ChannelAnnouncement { @@ -26,6 +28,13 @@ fn decode_open_channel(bytes: &[u8]) -> OpenChannel { other => panic!("expected open_channel(32), got {other}"), } } + +fn decode_open_channel2(bytes: &[u8]) -> OpenChannel2 { + match Message::decode(bytes).expect("valid open_channel2") { + Message::OpenChannel2(oc) => oc, + other => panic!("expected open_channel2, got {other}"), + } +} // -- execute() tests -- #[test] @@ -2497,3 +2506,2756 @@ fn extract_tlvs_absent() { Variable::Features(vec![]) ); } + +// -- Channel establishment v2 -- + +#[test] +fn execute_build_and_send_open_channel2() { + let mut instructions = open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::BuildOpenChannel2 { + require_confirmed_inputs: true, + }, + inputs: OPEN_CHANNEL2_INPUTS.to_vec(), + }); + instructions.push(Instruction { + operation: Operation::SendOpenChannel2, + inputs: vec![28], + }); + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + assert_eq!(executor.conn.sent.len(), 1); + let sent = decode_open_channel2(&executor.conn.sent[0]); + assert_eq!(sent.temporary_channel_id, sample_v2_temporary_channel_id()); + assert_eq!(sent.funding_feerate_perkw, 253); + assert_eq!(sent.commitment_feerate_perkw, 2500); + assert_eq!(sent.funding_satoshis, 200_000); + assert_eq!(sent.locktime, 120); + assert_eq!(sent.revocation_basepoint, sample_v2_revocation_basepoint()); + let secp = Secp256k1::new(); + assert_eq!( + sent.second_per_commitment_point, + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x77; 32]).unwrap()) + ); + assert!(sent.tlvs.require_confirmed_inputs); + assert_eq!( + sent.tlvs.channel_type, + Some(ChannelTypeVariant::Anchors.encode()), + ); + // A zero-length upfront_shutdown_script is the BOLT 2 opt-out signal, + // so the TLV is sent rather than omitted. + assert_eq!(sent.tlvs.upfront_shutdown_script, Some(vec![])); + + // The negotiation is recorded so later steps can build from what we + // actually put on the wire. + let pending = executor + .negotiations_v2 + .get(sample_v2_temporary_channel_id()) + .expect("negotiation recorded"); + assert_eq!(pending.open_channel2, sent); + assert!(pending.accept_channel2.is_none()); + assert!(pending.channel_id.is_none()); +} + +#[test] +fn execute_build_open_channel2_omits_an_empty_channel_type() { + let mut instructions = open_channel2_instructions(); + // Replace the channel type with an empty feature vector. + instructions[26] = Instruction { + operation: Operation::LoadFeatures(vec![]), + inputs: vec![], + }; + instructions.push(Instruction { + operation: Operation::BuildOpenChannel2 { + require_confirmed_inputs: false, + }, + inputs: OPEN_CHANNEL2_INPUTS.to_vec(), + }); + instructions.push(Instruction { + operation: Operation::SendOpenChannel2, + inputs: vec![28], + }); + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + // BOLT 2 requires open_channel2 to set channel_type, so omitting it + // must stay reachable for fuzzing the receiver's rejection path. + assert_eq!( + decode_open_channel2(&executor.conn.sent[0]) + .tlvs + .channel_type, + None + ); +} + +#[test] +fn execute_recv_accept_channel2_records_the_v2_channel_id() { + let (instructions, _) = send_open_channel2_instructions(); + let accept = sample_accept_channel2(sample_v2_temporary_channel_id()); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel2(accept.clone()).encode()); + let mut executor = Executor::new( + conn, + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let expected_channel_id = ChannelId::v2_from_revocation_basepoints( + &sample_v2_revocation_basepoint(), + &accept.revocation_basepoint, + ); + let pending = executor + .negotiations_v2 + .get(sample_v2_temporary_channel_id()) + .expect("negotiation recorded"); + assert_eq!(pending.accept_channel2.as_ref(), Some(&accept)); + assert_eq!(pending.channel_id, Some(expected_channel_id)); + // Later messages carry the v2 channel_id, and must reach the same + // negotiation as its temporary_channel_id does. + assert_eq!( + executor + .negotiations_v2 + .get(expected_channel_id) + .and_then(|p| p.channel_id), + Some(expected_channel_id), + ); +} + +#[test] +fn execute_recv_accept_channel2_unknown_temporary_channel_id_is_ignored() { + let (instructions, _) = send_open_channel2_instructions(); + // An accept_channel2 answering a temporary_channel_id we never opened, + // as a mutated program that dropped its open_channel2 would see. + let accept = sample_accept_channel2(ChannelId::new([0x77; 32])); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel2(accept).encode()); + let mut executor = Executor::new( + conn, + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes without reporting a violation"); + + // The unknown negotiation is not invented, and the one we did open is + // left untouched: no accept_channel2 paired, so no channel_id derived + // and nothing for a later message to reach it by. + let pending = executor + .negotiations_v2 + .get(sample_v2_temporary_channel_id()) + .expect("our own negotiation is still recorded"); + assert!(pending.accept_channel2.is_none()); + assert!(pending.channel_id.is_none()); +} + +#[test] +fn record_open_forgets_the_replaced_negotiations_channel_id() { + let temporary_channel_id = sample_v2_temporary_channel_id(); + let mut negotiations = V2Negotiations::default(); + + negotiations.record_open(&sample_open_channel2()); + negotiations.record_accept(&sample_accept_channel2(temporary_channel_id)); + assert!(negotiations.get(v2_channel_id()).is_some()); + + // Reusing the temporary_channel_id starts a fresh negotiation, which + // has derived no channel_id yet. A message still naming the replaced + // negotiation's must not land on it. + negotiations.record_open(&sample_open_channel2()); + + assert!(negotiations.get(v2_channel_id()).is_none()); + assert!(negotiations.get(temporary_channel_id).is_some()); +} + +#[test] +fn execute_recv_accept_channel2_unexpected_message() { + let (instructions, _) = send_open_channel2_instructions(); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel(sample_accept_channel()).encode()); + let mut executor = Executor::new( + conn, + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + let err = executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect_err("v1 accept_channel does not answer an open_channel2"); + + assert!( + matches!( + err, + ExecuteError::UnexpectedMessage { + expected: MessageType::ACCEPT_CHANNEL2, + .. + } + ), + "unexpected error: {err}", + ); +} + +#[test] +fn execute_extract_all_accept_channel2_fields() { + let (mut instructions, accept_idx) = send_open_channel2_instructions(); + for &field in AcceptChannel2Field::ALL { + instructions.push(Instruction { + operation: Operation::ExtractAcceptChannel2(field), + inputs: vec![accept_idx], + }); + } + let accept = sample_accept_channel2(sample_v2_temporary_channel_id()); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel2(accept.clone()).encode()); + let mut executor = Executor::new( + conn, + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + // Every field extracts, and each one produces the type it declares. + for &field in AcceptChannel2Field::ALL { + let extracted = extract_field_v2(&accept, field); + assert_eq!( + extracted.var_type(), + field.output_type(), + "{field} produced the wrong variable type", + ); + } + assert_eq!( + extract_field_v2(&accept, AcceptChannel2Field::FundingSatoshis), + Variable::Amount(0), + ); + assert_eq!( + extract_field_v2(&accept, AcceptChannel2Field::SecondPerCommitmentPoint), + Variable::Point(sample_pubkey(17)), + ); + assert_eq!( + extract_field_v2(&accept, AcceptChannel2Field::MinimumDepth), + Variable::BlockHeight(6), + ); +} + +#[test] +fn execute_derive_channel_id_v2_feeds_the_channel_id_on_the_wire() { + // Runtime variables do not outlive execution, so observe + // DeriveChannelIdV2 through the only field that carries a ChannelId + // here: open_channel2's temporary_channel_id. + let mut instructions = open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::LoadTargetPubkeyFromContext, + inputs: vec![], + }); // v28 the peer's revocation basepoint + instructions.push(Instruction { + operation: Operation::DeriveChannelIdV2, + inputs: vec![13, 28], + }); // v29 + let mut inputs = OPEN_CHANNEL2_INPUTS.to_vec(); + inputs[1] = 29; + instructions.push(Instruction { + operation: Operation::BuildOpenChannel2 { + require_confirmed_inputs: false, + }, + inputs, + }); // v30 + instructions.push(Instruction { + operation: Operation::SendOpenChannel2, + inputs: vec![30], + }); + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let sent = decode_open_channel2(&executor.conn.sent[0]); + assert_eq!( + sent.temporary_channel_id, + ChannelId::v2_from_revocation_basepoints( + &sample_v2_revocation_basepoint(), + &sample_context().target_pubkey, + ), + ); + // Both basepoints are mixed in, so this is not the temporary id. + assert_ne!(sent.temporary_channel_id, sample_v2_temporary_channel_id()); +} + +#[test] +fn execute_send_open_channel2_wrong_type_panics() { + let instructions = vec![ + Instruction { + operation: Operation::LoadAmount(1), + inputs: vec![], + }, + Instruction { + operation: Operation::SendOpenChannel2, + inputs: vec![0], + }, + ]; + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + executor.execute(&Program { instructions }, std::time::Instant::now()) + })); + + assert!(result.is_err(), "expected a panic on the type mismatch"); +} + +#[test] +fn execute_recv_accept_channel2_affine_overuse_panics() { + let (mut instructions, _) = send_open_channel2_instructions(); + // Receive twice against a single SendOpenChannel2. + instructions.push(Instruction { + operation: Operation::RecvAcceptChannel2, + inputs: vec![29], + }); + let accept = sample_accept_channel2(sample_v2_temporary_channel_id()); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel2(accept.clone()).encode()); + conn.queue_recv(Message::AcceptChannel2(accept).encode()); + let mut executor = Executor::new( + conn, + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + executor.execute(&Program { instructions }, std::time::Instant::now()) + })); + + assert!( + result.is_err(), + "expected a panic consuming SentOpenChannel2 twice" + ); +} + +// -- Interactive transaction construction -- + +/// The `open_channel2` / `accept_channel2` exchange followed by +/// `instructions`, all against a wallet with one spendable output. +/// +/// The `channel_id` for the interactive transaction messages is at index +/// 31, derived from both revocation basepoints. +fn run_v2_negotiation( + extra: Vec, +) -> Executor { + let mut instructions = v2_channel_id_instructions(); + instructions.extend(extra); + + let accept = sample_accept_channel2(sample_v2_temporary_channel_id()); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel2(accept).encode()); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + executor +} + +fn decode_sent(bytes: &[u8], f: impl Fn(Message) -> Option) -> T { + let msg = Message::decode(bytes).expect("valid message"); + let name = msg.to_string(); + f(msg).unwrap_or_else(|| panic!("unexpected message {name}")) +} + +fn sole_negotiation( + executor: &Executor, +) -> &PendingChannelV2 { + executor + .negotiations_v2 + .get(sample_v2_temporary_channel_id()) + .expect("negotiation recorded") +} + +#[test] +fn execute_send_tx_add_input_proposes_a_wallet_utxo() { + let executor = run_v2_negotiation(vec![tx_add_input(2, 0)]); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxAddInput(m) => Some(m), + _ => None, + }); + let prevtx = sample_prevtx(); + assert_eq!(sent.serial_id, 2); + assert_eq!(sent.sequence, 0xffff_fffd); + assert_eq!(sent.prevtx_vout, 0); + assert_eq!(sent.prevtx, bitcoin::consensus::encode::serialize(&prevtx)); + + // The input is recorded with the value we know from the wallet, so the + // change output can be computed from it. + let pending = sole_negotiation(&executor); + let (serial_id, input) = pending + .tx_exchange + .shared_tx() + .inputs() + .next() + .expect("input recorded"); + assert_eq!(serial_id, 2); + assert_eq!(input.contributor, Contributor::Local); + assert_eq!(input.outpoint.txid, prevtx.compute_txid()); + assert_eq!(input.value(), 100_000_000); +} + +#[test] +fn execute_send_tx_add_input_locks_the_selected_utxo() { + let executor = run_v2_negotiation(vec![tx_add_input(2, 0)]); + + // Locking is what stops a later selection proposing the same coin, + // which the peer would reject as a duplicate input. + assert_eq!( + executor.bitcoin_cli.locked_outpoints, + vec![OutPoint { + txid: sample_prevtx().compute_txid(), + vout: 0, + }], + ); +} + +#[test] +fn execute_send_tx_add_input_with_an_empty_wallet_sends_an_empty_prevtx() { + let (mut instructions, _) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::SendTxAddInput { + serial_id: 2, + utxo_index: 0, + sequence: 0xffff_fffd, + }, + inputs: vec![27], + }); + let accept = sample_accept_channel2(sample_v2_temporary_channel_id()); + let mut conn = MockConnection::new(); + conn.queue_recv(Message::AcceptChannel2(accept).encode()); + let mut executor = Executor::new( + conn, + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("an empty wallet is not a harness error"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxAddInput(m) => Some(m), + _ => None, + }); + // Nothing to spend, so nothing to prove non-malleable. The message + // still goes out for the peer to reject. + assert!(sent.prevtx.is_empty()); +} + +#[test] +fn execute_send_tx_add_output_derives_the_funding_output() { + let executor = run_v2_negotiation(vec![tx_add_output(4, TxOutputRole::Funding)]); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxAddOutput(m) => Some(m), + _ => None, + }); + // The acceptor contributes nothing, so the funding output is worth + // exactly our open_channel2.funding_satoshis. + assert_eq!(sent.sats, 200_000); + let secp = Secp256k1::new(); + let funding_pubkey = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x11; 32]).unwrap()); + let expected_script = build_funding_witness_script( + &funding_pubkey, + &sample_accept_channel2(sample_v2_temporary_channel_id()).funding_pubkey, + ) + .to_p2wsh(); + assert_eq!(ScriptBuf::from(sent.script), expected_script); +} + +#[test] +fn execute_send_tx_add_output_change_covers_the_funding_and_the_fee() { + let executor = run_v2_negotiation(vec![ + tx_add_input(2, 0), + tx_add_output(4, TxOutputRole::Funding), + tx_add_output(6, TxOutputRole::Change), + ]); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxAddOutput(m) => Some(m), + _ => None, + }); + // One 1 BTC input, 200_000 sat to the funding output, and our share of + // the fee at 253 sat/kw: weight 42 + 164 + 172 + 124 + 108 = 610, + // giving ceil(610 * 253 / 1000) = 155 sat. + assert_eq!(sent.sats, 100_000_000 - 200_000 - 155); + assert_eq!(ScriptBuf::from(sent.script), sample_change_spk()); +} + +#[test] +fn execute_send_tx_add_output_explicit_uses_its_inputs() { + let executor = run_v2_negotiation(vec![Instruction { + operation: Operation::SendTxAddOutput { + serial_id: 4, + role: TxOutputRole::Explicit, + }, + // v3 is funding_satoshis (200_000), v25 the empty script. + inputs: vec![V2_CHANNEL_ID_VAR, 3, 25], + }]); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxAddOutput(m) => Some(m), + _ => None, + }); + assert_eq!(sent.sats, 200_000); + assert!(sent.script.is_empty()); +} + +#[test] +fn execute_send_tx_remove_input_keeps_the_peers_input() { + let channel_id = ChannelId::v2_from_revocation_basepoints( + &sample_v2_revocation_basepoint(), + &sample_accept_channel2(sample_v2_temporary_channel_id()).revocation_basepoint, + ); + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_add_input(2, 0)); // v33 + instructions.push(recv_interactive_tx(33)); // the peer contributes an input of its own + instructions.push(Instruction { + // BOLT 2 forbids removing an input the peer added. A peer that + // receives one keeps its input, so we must keep it too or our + // reconstruction of the shared transaction diverges from theirs. + operation: Operation::SendTxRemoveInput { serial_id: 3 }, + inputs: vec![32], + }); // v35 + instructions.push(Instruction { + operation: Operation::SendTxRemoveInput { serial_id: 2 }, + inputs: vec![32], + }); // v36 + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxAddInput(TxAddInput { + channel_id, + serial_id: 3, + prevtx: bitcoin::consensus::encode::serialize(&sample_prevtx()), + prevtx_vout: 0, + sequence: 0xffff_fffd, + tlvs: TxAddInputTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + // Ours is gone, the peer's survives. + let pending = sole_negotiation(&executor); + let remaining: Vec = pending + .tx_exchange + .shared_tx() + .inputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(remaining, vec![3]); + + // Both removals still went on the wire; only our own changed local + // state, so the peer gets to reject the illegal one. + let removals = executor + .conn + .sent + .iter() + .filter(|bytes| { + Message::decode(bytes).expect("valid").msg_type() == MessageType::TX_REMOVE_INPUT + }) + .count(); + assert_eq!(removals, 2); +} + +#[test] +fn execute_send_tx_remove_output_keeps_the_peers_output() { + let channel_id = ChannelId::v2_from_revocation_basepoints( + &sample_v2_revocation_basepoint(), + &sample_accept_channel2(sample_v2_temporary_channel_id()).revocation_basepoint, + ); + let (mut instructions, _) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::ExtractAcceptChannel2(AcceptChannel2Field::RevocationBasepoint), + inputs: vec![30], + }); + instructions.push(Instruction { + operation: Operation::DeriveChannelIdV2, + inputs: vec![13, 31], + }); + instructions.push(tx_add_output(4, TxOutputRole::Funding)); // v33 + instructions.push(recv_interactive_tx(33)); + instructions.push(Instruction { + operation: Operation::SendTxRemoveOutput { serial_id: 5 }, + inputs: vec![32], + }); + instructions.push(Instruction { + operation: Operation::SendTxRemoveOutput { serial_id: 4 }, + inputs: vec![32], + }); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxAddOutput(TxAddOutput { + channel_id, + serial_id: 5, + sats: 50_000, + script: sample_change_spk().into_bytes(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let pending = sole_negotiation(&executor); + let remaining: Vec = pending + .tx_exchange + .shared_tx() + .outputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(remaining, vec![5]); +} + +#[test] +fn execute_recv_interactive_tx_records_peer_contributions() { + let prevtx = sample_prevtx(); + let channel_id = ChannelId::v2_from_revocation_basepoints( + &sample_v2_revocation_basepoint(), + &sample_accept_channel2(sample_v2_temporary_channel_id()).revocation_basepoint, + ); + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_complete()); // v33 + instructions.push(recv_interactive_tx(33)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxAddInput(TxAddInput { + channel_id, + // The non-initiator uses odd serial ids. + serial_id: 3, + prevtx: bitcoin::consensus::encode::serialize(&prevtx), + prevtx_vout: 0, + sequence: 0xffff_fffd, + tlvs: TxAddInputTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let pending = sole_negotiation(&executor); + let (serial_id, input) = pending + .tx_exchange + .shared_tx() + .inputs() + .next() + .expect("input recorded"); + assert_eq!(serial_id, 3); + assert_eq!(input.contributor, Contributor::Remote); + assert_eq!(input.value(), 100_000_000); + // The peer answered with a contribution, not a tx_complete, so our + // tx_complete did not conclude the exchange. + assert!(!pending.tx_exchange.concluded()); +} + +#[test] +fn execute_recv_interactive_tx_remove_input_keeps_our_input() { + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_add_input(2, 0)); // v33 + instructions.push(recv_interactive_tx(33)); // the peer adds an input + instructions.push(tx_complete()); // v35 + instructions.push(recv_interactive_tx(35)); // the peer removes ours, which BOLT 2 forbids + instructions.push(tx_complete()); // v37 + instructions.push(recv_interactive_tx(37)); // the peer removes its own + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxAddInput(TxAddInput { + channel_id, + serial_id: 3, + prevtx: bitcoin::consensus::encode::serialize(&sample_prevtx()), + prevtx_vout: 0, + sequence: 0xffff_fffd, + tlvs: TxAddInputTlvs::default(), + }) + .encode(), + ); + conn.queue_recv( + Message::TxRemoveInput(TxRemoveInput { + channel_id, + serial_id: 2, + }) + .encode(), + ); + conn.queue_recv( + Message::TxRemoveInput(TxRemoveInput { + channel_id, + serial_id: 3, + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + // The peer's illegal removal left ours in place; its own is gone. + let pending = sole_negotiation(&executor); + let remaining: Vec = pending + .tx_exchange + .shared_tx() + .inputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(remaining, vec![2]); +} + +#[test] +fn execute_recv_interactive_tx_remove_output_keeps_our_output() { + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_add_output(4, TxOutputRole::Funding)); // v33 + instructions.push(recv_interactive_tx(33)); // the peer adds an output + instructions.push(tx_complete()); // v35 + instructions.push(recv_interactive_tx(35)); // the peer removes ours, which BOLT 2 forbids + instructions.push(tx_complete()); // v37 + instructions.push(recv_interactive_tx(37)); // the peer removes its own + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxAddOutput(TxAddOutput { + channel_id, + serial_id: 5, + sats: 50_000, + script: sample_change_spk().into_bytes(), + }) + .encode(), + ); + conn.queue_recv( + Message::TxRemoveOutput(TxRemoveOutput { + channel_id, + serial_id: 4, + }) + .encode(), + ); + conn.queue_recv( + Message::TxRemoveOutput(TxRemoveOutput { + channel_id, + serial_id: 5, + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let pending = sole_negotiation(&executor); + let remaining: Vec = pending + .tx_exchange + .shared_tx() + .outputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(remaining, vec![4]); +} + +#[test] +fn execute_recv_interactive_tx_for_an_unknown_channel_is_ignored() { + let (mut instructions, _) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::SendTxComplete, + inputs: vec![27], + }); // v31 + instructions.push(recv_interactive_tx(31)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxComplete(TxComplete { + channel_id: ChannelId::new([0x99; 32]), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("an unknown channel_id is not a harness error"); + + // Only the peer can tell whether that message is consistent with its + // own view, so nothing is invented on our side: the reply to our + // tx_complete is still owed. + let pending = sole_negotiation(&executor); + assert!(!pending.tx_exchange.concluded()); + assert_eq!(pending.tx_exchange.outstanding_replies(), 1); +} + +#[test] +fn execute_recv_interactive_tx_unexpected_message() { + let (mut instructions, _) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::SendTxComplete, + inputs: vec![27], + }); + instructions.push(recv_interactive_tx(31)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv(Message::AcceptChannel(sample_accept_channel()).encode()); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + let err = executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect_err("accept_channel does not belong in an interactive tx exchange"); + + assert!( + matches!(err, ExecuteError::UnexpectedMessage { .. }), + "unexpected error: {err}", + ); +} + +#[test] +fn execute_recv_interactive_tx_affine_overuse_panics() { + let (mut instructions, _) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::SendTxComplete, + inputs: vec![27], + }); + instructions.push(recv_interactive_tx(31)); + instructions.push(recv_interactive_tx(31)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + // Enough for the first receive to succeed, so the second one fails on + // the consumed token rather than on an empty queue. + conn.queue_recv( + Message::TxComplete(TxComplete { + channel_id: sample_v2_temporary_channel_id(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + executor.execute(&Program { instructions }, std::time::Instant::now()) + })); + + assert!( + result.is_err(), + "the turn-based protocol earns one receive per send", + ); +} + +// -- Commitment and signature exchange -- + +/// A `commitment_signed` the acceptor would send for our initial +/// commitment, signed with the acceptor's funding key. +fn counterparty_commitment_signed( + executor: &Executor, + channel_id: ChannelId, + acceptor_funding_privkey: &SecretKey, +) -> CommitmentSigned { + let state = executor + .channel_states + .get(&channel_id) + .expect("channel tracked"); + let holder = HolderIdentity { + side: Side::Acceptor, + funding_privkey: *acceptor_funding_privkey, + }; + CommitmentSigned { + channel_id, + signature: state + .config + .sign_counterparty_commitment(&state.commitment, &holder), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + } +} + +#[test] +fn execute_build_funding_transaction_v2_locates_the_funding_output() { + let mut executor = Executor::new( + v2_flow_connection(), + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let pending = sole_negotiation(&executor); + let secp = Secp256k1::new(); + let funding_pubkey = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x11; 32]).unwrap()); + let funding = pending.tx_exchange.shared_tx().build_funding( + &build_funding_witness_script( + &funding_pubkey, + &sample_accept_channel2(sample_v2_temporary_channel_id()).funding_pubkey, + ) + .to_p2wsh(), + 200_000, + ); + // Serial 4 (funding) sorts before serial 6 (change). + assert_eq!(funding.vout, 0); + assert_eq!(funding.tx.input.len(), 1); + assert_eq!(funding.tx.output.len(), 2); + assert_eq!(funding.tx.output[0].value.to_sat(), 200_000); + assert_eq!(funding.tx.lock_time.to_consensus_u32(), 120); +} + +#[test] +fn execute_build_funding_transaction_v2_unknown_channel_is_empty() { + let instructions = vec![ + Instruction { + operation: Operation::LoadChannelId([0x99; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::BuildFundingTransactionV2, + inputs: vec![0], + }, + // The empty sentinel must flow into its consumers without panicking. + Instruction { + operation: Operation::BroadcastTransaction, + inputs: vec![1], + }, + ]; + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("an unknown channel_id is not a harness error"); +} + +#[test] +fn execute_send_commitment_signed_tracks_the_channel() { + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::CommitmentSigned(m) => Some(m), + _ => None, + }); + assert_eq!(sent.channel_id, v2_channel_id()); + // BOLT 2: the first commitment of a v2 open carries no HTLCs. + assert!(sent.htlc_signatures.is_empty()); + + let state = executor + .channel_states + .get(&v2_channel_id()) + .expect("channel tracked under the v2 channel_id"); + assert_eq!(state.config.funding_satoshis, 200_000); + assert_eq!(state.config.minimum_depth, 6); + // The acceptor contributes nothing, so the whole balance is ours. + assert_eq!(state.commitment.opener.balance_msat, 200_000_000); + assert_eq!(state.commitment.acceptor.balance_msat, 0); + assert!(state.is_funding_outpoint_valid); + // The signature we sent is over the acceptor's commitment, so it must + // verify the way the acceptor would verify it. The holder's private + // key plays no part in verification, only its side does. + assert!( + state.config.verify_counterparty_signature( + &state.commitment, + &HolderIdentity { + side: Side::Acceptor, + funding_privkey: SecretKey::from_slice(&[0x99; 32]).expect("valid secret key"), + }, + &sent.signature, + ), + "the commitment signature we sent does not verify", + ); +} + +#[test] +fn execute_send_commitment_signed_splits_the_balance_by_contribution() { + let mut accept = sample_accept_channel2(sample_v2_temporary_channel_id()); + // The acceptor contributes half the channel. + accept.funding_satoshis = 200_000; + let mut conn = MockConnection::new(); + queue_v2_flow_replies(&mut conn, accept); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let state = executor + .channel_states + .get(&v2_channel_id()) + .expect("channel tracked"); + // v2 has no push_msat: each side's balance is what it contributed. + assert_eq!(state.config.funding_satoshis, 400_000); + assert_eq!(state.commitment.opener.balance_msat, 200_000_000); + assert_eq!(state.commitment.acceptor.balance_msat, 200_000_000); +} + +#[test] +fn execute_send_commitment_signed_without_accept_channel2_is_unsigned() { + // No accept_channel2 queued, so RecvAcceptChannel2 fails and the + // negotiation never learns the peer's keys. Drive commitment_signed + // straight off the temporary channel id instead. + let mut instructions = open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::BuildOpenChannel2 { + require_confirmed_inputs: false, + }, + inputs: OPEN_CHANNEL2_INPUTS.to_vec(), + }); // v28 + instructions.push(Instruction { + operation: Operation::SendOpenChannel2, + inputs: vec![28], + }); // v29 + instructions.push(Instruction { + operation: Operation::BuildFundingTransactionV2, + inputs: vec![27], + }); // v30 + instructions.push(Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![30, 10, 27], + }); + let mut executor = Executor::new( + MockConnection::new(), + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("a missing accept_channel2 is not a harness error"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::CommitmentSigned(m) => Some(m), + _ => None, + }); + // Nothing to sign without the peer's keys, so an all-zero signature + // goes out and no channel is tracked. + assert_eq!(sent.signature.serialize_compact(), [0u8; 64]); + assert!(executor.channel_states.is_empty()); +} + +#[test] +fn execute_send_commitment_signed_commits_to_the_advertised_funding_pubkey() { + // A mutated program can hand `SendCommitmentSigned` a key unrelated to + // the `funding_pubkey` the open advertised. The peer signs the + // commitment we announced, so the commitment we track has to follow the + // advertised key; deriving it from the signing key instead would leave + // us verifying a different transaction and reporting the peer's correct + // signature as invalid. + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + // v12 is the revocation private key, not the funding one + // behind the advertised v11 `funding_pubkey`. + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 12, 32], + }]), + }, + std::time::Instant::now(), + ) + .expect("a mismatched funding key is not a harness error"); + + let state = executor + .channel_states + .get(&v2_channel_id()) + .expect("channel tracked"); + assert_eq!( + state.config.opener.funding_pubkey, + PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0x11; 32]).expect("valid secret key"), + ), + ); + // The commitment and the on-chain funding output therefore agree on the + // 2-of-2 script. + assert!(state.is_funding_outpoint_valid); +} + +#[test] +fn execute_recv_commitment_signed_accepts_a_valid_signature() { + let acceptor_key = sample_acceptor_funding_privkey(); + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + // First run establishes the channel state we need to sign against. + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let reply = counterparty_commitment_signed(&executor, v2_channel_id(), &acceptor_key); + queue_v2_flow_replies( + &mut executor.conn, + sample_accept_channel2(sample_v2_temporary_channel_id()), + ); + executor + .conn + .queue_recv(Message::CommitmentSigned(reply).encode()); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }, // v37 + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![37], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect("a valid counterparty signature verifies"); + + assert!( + sole_negotiation(&executor) + .commitment_exchange + .commitment_signed + .received + ); +} + +#[test] +fn execute_recv_commitment_signed_rejects_an_invalid_signature() { + let mut conn = v2_flow_connection(); + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id: v2_channel_id(), + // A well-formed signature over the wrong digest, which is what + // a target signing the wrong commitment would produce. + signature: Secp256k1::new().sign_ecdsa( + &bitcoin::secp256k1::Message::from_digest([0x7c; 32]), + &sample_acceptor_funding_privkey(), + ), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + let err = executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }, + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![37], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect_err("an invalid counterparty signature is a target bug"); + + assert!( + matches!( + err, + ExecuteError::Violation(Violation::InvalidCounterpartySignature(_)), + ), + "unexpected error: {err}", + ); +} + +#[test] +fn execute_recv_commitment_signed_ignores_a_signature_over_another_funding_output() { + let mut conn = v2_flow_connection(); + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id: v2_channel_id(), + signature: Secp256k1::new().sign_ecdsa( + &bitcoin::secp256k1::Message::from_digest([0x7c; 32]), + &sample_acceptor_funding_privkey(), + ), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + // A second output, so the unrelated funding transaction has something + // to spend after the interactive exchange has locked the first. + let mut wallet = sample_v2_wallet(); + let first = wallet.utxos[0].clone(); + wallet.utxos.push(Utxo { + amount: Amount::from_sat(50_000_000), + outpoint: OutPoint { + txid: first.outpoint.txid, + vout: 1, + }, + script_pubkey: first.script_pubkey, + }); + let mut executor = Executor::new(conn, wallet, MockTargetRpc::default(), sample_context()); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + // A funding transaction from no negotiation at all, + // standing in for the one a mutated program borrows + // from a different channel. + Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![11, 13, 3, 1], + }, // v37 + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![37, 10, 32], + }, // v38 + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![38], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect("a signature we could never verify is not a target bug"); + + // The signature went unchecked because our commitment spends an + // outpoint the peer never agreed to, not because it verified. + assert!( + !executor.channel_states[&v2_channel_id()].is_funding_outpoint_valid, + "the test needs a funding output the negotiation never produced", + ); +} + +#[test] +fn execute_recv_commitment_signed_ignores_a_signature_over_a_stale_funding_transaction() { + let mut conn = v2_flow_connection(); + // The reply to the output added after the funding transaction was built. + conn.queue_recv( + Message::TxComplete(TxComplete { + channel_id: v2_channel_id(), + }) + .encode(), + ); + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id: v2_channel_id(), + signature: Secp256k1::new().sign_ecdsa( + &bitcoin::secp256k1::Message::from_digest([0x7c; 32]), + &sample_acceptor_funding_privkey(), + ), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + // Another output after the funding transaction was + // built, standing in for a mutated program that keeps + // contributing past the transaction it signs over: the + // funding output is intact, but the txid the peer signs + // over is a different one. + tx_add_output(8, TxOutputRole::Change), // v37 + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }, // v38 + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![38], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect("a signature we could never verify is not a target bug"); + + assert!( + !executor.channel_states[&v2_channel_id()].is_funding_outpoint_valid, + "the test needs a funding transaction the negotiation moved past", + ); +} + +#[test] +fn execute_recv_commitment_signed_after_sending_on_the_temporary_id_is_ignored() { + let mut conn = v2_flow_connection(); + // The peer sends its commitment_signed on the derived id as soon as the + // exchange concludes, whatever we sent it. + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id: v2_channel_id(), + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + // v27 is the temporary_channel_id: an id the negotiation + // resolves, but not the one the peer answers on. + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 27], + }, // v37 + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![37], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect("a commitment_signed we never sent on this id is not a target bug"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::CommitmentSigned(m) => Some(m), + _ => None, + }); + assert_eq!(sent.channel_id, sample_v2_temporary_channel_id()); + assert!( + executor.channel_states.is_empty(), + "no channel is tracked for a commitment_signed the peer cannot attribute", + ); + assert!( + !sole_negotiation(&executor) + .commitment_exchange + .commitment_signed + .sent + ); +} + +#[test] +fn execute_recv_commitment_signed_rejects_htlc_signatures() { + let acceptor_key = sample_acceptor_funding_privkey(); + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let mut reply = counterparty_commitment_signed(&executor, v2_channel_id(), &acceptor_key); + // BOLT 2 forbids HTLCs in the first commitment of a v2 open. + reply.htlc_signatures = vec![reply.signature]; + queue_v2_flow_replies( + &mut executor.conn, + sample_accept_channel2(sample_v2_temporary_channel_id()), + ); + executor + .conn + .queue_recv(Message::CommitmentSigned(reply).encode()); + + let err = executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }, + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![37], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect_err("htlc signatures in a v2 open are a target bug"); + + assert!( + matches!( + err, + ExecuteError::Violation(Violation::UnexpectedHtlcSignatures(_)), + ), + "unexpected error: {err}", + ); +} + +#[test] +fn execute_recv_commitment_signed_without_any_v2_exchange_is_ignored() { + // A commitment_signed arriving when no v2 negotiation ever reached + // commitment_signed is a harness artifact, not a target bug. + let instructions = vec![ + Instruction { + operation: Operation::LoadChannelId([0x55; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadAmount(1), + inputs: vec![], + }, + ]; + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + MockTargetRpc::default(), + sample_context(), + ); + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let cs = CommitmentSigned { + channel_id: ChannelId::new([0x55; 32]), + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }; + let result = + verify_commitment_signed(&cs, &executor.channel_states, &mut executor.negotiations_v2); + + assert!(result.is_ok(), "expected no violation, got {result:?}"); +} + +#[test] +fn recv_commitment_signed_on_another_channel_is_not_a_violation() { + // `InputSwapMutator` can point SendCommitmentSigned at the + // temporary_channel_id instead of the derived one, keying our state by + // an id the peer never answers on. The peer then replies on the real + // channel_id, which we have no state for -- our own doing, not the + // target's, so it must not be reported. + let mut negotiations = negotiation_awaiting_tx_signatures(100_000_000, 0); + let cs = CommitmentSigned { + channel_id: ChannelId::new([0x55; 32]), + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }; + + let result = verify_commitment_signed(&cs, &HashMap::new(), &mut negotiations); + + assert!(result.is_ok(), "expected no violation, got {result:?}"); +} + +#[test] +fn recv_commitment_signed_on_our_own_channel_without_state_is_a_violation() { + // The other side of the coin: on the channel we did send our + // commitment_signed on, missing state is the target answering for a + // channel it should not have, and stays reportable. + let mut negotiations = negotiation_awaiting_tx_signatures(100_000_000, 0); + let cs = CommitmentSigned { + channel_id: v2_channel_id(), + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }; + + let result = verify_commitment_signed(&cs, &HashMap::new(), &mut negotiations); + + assert!(matches!( + result, + Err(ExecuteError::Violation(Violation::UnknownChannel(id))) if id == v2_channel_id() + )); +} + +#[test] +fn execute_send_tx_signatures_carries_our_witnesses() { + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_signing_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendTxSignatures, + inputs: vec![32, 36], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxSignatures(m) => Some(m), + _ => None, + }); + assert_eq!(sent.channel_id, v2_channel_id()); + // One witness for the single input we contributed. The txid is the + // unsigned one, since witnesses do not affect it. + assert_eq!(sent.witnesses.len(), 1); + assert!(!sent.witnesses[0].is_empty()); + assert_eq!( + sent.txid, + sole_negotiation(&executor) + .tx_exchange + .shared_tx() + .build() + .compute_txid() + ); +} + +#[test] +fn execute_send_tx_signatures_skips_inputs_the_wallet_cannot_sign() { + let mut wallet = sample_v2_wallet(); + // The wallet holds the coin but cannot sign it, as it could not sign a + // peer-contributed input. + wallet.signable_outpoints.clear(); + let conn = v2_flow_connection(); + let mut executor = Executor::new(conn, wallet, MockTargetRpc::default(), sample_context()); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendTxSignatures, + inputs: vec![32, 36], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxSignatures(m) => Some(m), + _ => None, + }); + // An empty witness is the peer's to reject, not a harness failure. + assert_eq!(sent.witnesses.len(), 1); + assert_eq!(sent.witnesses[0], vec![0x00]); +} + +#[test] +fn execute_send_tx_signatures_with_signing_failure_sends_no_witnesses() { + let mut wallet = sample_v2_signing_wallet(); + wallet.signing_fails = true; + let conn = v2_flow_connection(); + let mut executor = Executor::new(conn, wallet, MockTargetRpc::default(), sample_context()); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendTxSignatures, + inputs: vec![32, 36], + }]), + }, + std::time::Instant::now(), + ) + .expect("a signing failure is not a harness error"); + + let sent = decode_sent(executor.conn.sent.last().unwrap(), |m| match m { + Message::TxSignatures(m) => Some(m), + _ => None, + }); + assert!(sent.witnesses.is_empty()); +} + +#[test] +fn execute_recv_tx_signatures_is_a_noop_before_the_commitment_exchange() { + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::RecvTxSignatures, + inputs: vec![32], + }]), + }, + std::time::Instant::now(), + ) + .expect("no commitment_signed has been exchanged, so nothing is owed"); + + // Nothing was read, so no message was consumed from an empty queue. + assert!(executor.conn.recv_queue.is_empty()); + assert!( + !sole_negotiation(&executor) + .commitment_exchange + .tx_signatures + .received + ); +} + +/// A negotiation that has exchanged both `commitment_signed`s, with the +/// given input values contributed by each side. +fn negotiation_awaiting_tx_signatures(local_value: u64, remote_value: u64) -> V2Negotiations { + let mut negotiations = V2Negotiations::default(); + negotiations.record_open(&sample_open_channel2()); + negotiations.record_accept(&sample_accept_channel2(sample_v2_temporary_channel_id())); + + { + let pending = negotiations + .get_mut(v2_channel_id()) + .expect("record_accept paired the negotiation"); + pending.commitment_exchange.commitment_signed.sent = true; + pending.commitment_exchange.commitment_signed.received = true; + + let prevtx = sample_prevtx(); + let add_input = |serial_id: u64, value: u64, contributor| Step::AddInput { + serial_id, + input: SharedInput { + outpoint: OutPoint { + txid: prevtx.compute_txid(), + vout: u32::try_from(serial_id).expect("small"), + }, + sequence: 0xffff_fffd, + contributor, + prevout: Some(TxOut { + value: Amount::from_sat(value), + script_pubkey: sample_change_spk(), + }), + }, + }; + // One turn each way, so nothing is left owed. + pending.tx_exchange.send(if local_value > 0 { + add_input(2, local_value, Contributor::Local) + } else { + Step::Complete + }); + pending.tx_exchange.receive(if remote_value > 0 { + add_input(3, remote_value, Contributor::Remote) + } else { + Step::Complete + }); + } + + negotiations +} + +#[test] +fn tx_signatures_expected_only_when_the_peer_contributed_less() { + let context = sample_context(); + + // We contributed everything, so BOLT 2 has the peer sign first and we + // are owed a tx_signatures. + let negotiations = negotiation_awaiting_tx_signatures(100_000_000, 0); + assert!(is_tx_signatures_expected( + &negotiations, + v2_channel_id(), + &context, + )); + + // The peer contributed more, so we must sign first: waiting here would + // deadlock against a peer waiting on us. + let negotiations = negotiation_awaiting_tx_signatures(1, 100_000_000); + assert!(!is_tx_signatures_expected( + &negotiations, + v2_channel_id(), + &context, + )); +} + +#[test] +fn tx_signatures_expected_breaks_an_equal_contribution_by_node_id() { + let negotiations = negotiation_awaiting_tx_signatures(50_000, 50_000); + + // Equal contributions, so the lower node_id signs first. sample_context + // uses target_pubkey = sample_pubkey(1) and local_pubkey = + // sample_pubkey(2). + let expected = signs_first(50_000, 50_000, &sample_pubkey(1), &sample_pubkey(2)); + assert_eq!( + is_tx_signatures_expected(&negotiations, v2_channel_id(), &sample_context()), + expected, + ); + + // Swapping the two node ids swaps who signs first. + let swapped = ProgramContext { + target_pubkey: sample_pubkey(2), + local_pubkey: sample_pubkey(1), + ..sample_context() + }; + assert_eq!( + is_tx_signatures_expected(&negotiations, v2_channel_id(), &swapped), + !expected, + ); +} + +#[test] +fn tx_signatures_not_expected_once_received() { + let mut negotiations = negotiation_awaiting_tx_signatures(100_000_000, 0); + negotiations + .get_mut(sample_v2_temporary_channel_id()) + .expect("negotiation") + .commitment_exchange + .tx_signatures + .received = true; + + assert!(!is_tx_signatures_expected( + &negotiations, + v2_channel_id(), + &sample_context(), + )); +} + +#[test] +fn tx_signatures_not_expected_after_an_abort() { + let mut negotiations = negotiation_awaiting_tx_signatures(100_000_000, 0); + negotiations + .get_mut(sample_v2_temporary_channel_id()) + .expect("negotiation") + .tx_exchange + .abort(); + + assert!(!is_tx_signatures_expected( + &negotiations, + v2_channel_id(), + &sample_context(), + )); +} + +#[test] +fn tx_signatures_expected_once_the_peer_has_received_ours() { + // The peer contributed more, so we sign first and nothing is owed yet. + let mut negotiations = negotiation_awaiting_tx_signatures(1, 100_000_000); + assert!(!is_tx_signatures_expected( + &negotiations, + v2_channel_id(), + &sample_context(), + )); + + // Once ours is out, BOLT 2 has the peer "reply with their + // tx_signatures if not already transmitted", so it is owed after all. + // Without this the reply would sit unread for a later step to trip on. + negotiations + .get_mut(sample_v2_temporary_channel_id()) + .expect("negotiation") + .commitment_exchange + .tx_signatures + .sent = true; + + assert!(is_tx_signatures_expected( + &negotiations, + v2_channel_id(), + &sample_context(), + )); +} + +// -- Applying the peer's witnesses -- + +/// A negotiation contributing one input each way, with `witnesses` standing +/// in for the peer's `tx_signatures`. +fn negotiation_with_peer_witnesses(witnesses: Vec) -> V2Negotiations { + let mut negotiations = negotiation_awaiting_tx_signatures(50_000, 60_000); + negotiations + .get_mut(sample_v2_temporary_channel_id()) + .expect("negotiation") + .peer_witnesses = witnesses; + negotiations +} + +#[test] +fn apply_peer_witnesses_fills_only_the_peers_inputs() { + let negotiations = negotiation_with_peer_witnesses(vec![sample_peer_witness()]); + let unsigned = negotiations + .get(sample_v2_temporary_channel_id()) + .expect("negotiation") + .tx_exchange + .shared_tx() + .build(); + + let tx = apply_peer_witnesses(&negotiations, &unsigned); + + // serial_id 2 is ours and sorts first; serial_id 3 is the peer's. Our + // own input is the wallet's to sign, not the peer's to witness. + assert!(tx.input[0].witness.is_empty()); + assert_eq!(tx.input[1].witness.len(), 2); + // Witnesses do not change a txid, so what we broadcast still matches + // the transaction both peers committed to. + assert_eq!(tx.compute_txid(), unsigned.compute_txid()); +} + +#[test] +fn apply_peer_witnesses_leaves_an_unrelated_transaction_alone() { + let negotiations = negotiation_with_peer_witnesses(vec![sample_peer_witness()]); + + // A v1 funding transaction belongs to no v2 negotiation. + let unrelated = sample_prevtx(); + assert_eq!(apply_peer_witnesses(&negotiations, &unrelated), unrelated); +} + +/// A `tx_signatures` carrying `witnesses` as raw `witness_data`. +fn tx_signatures_with(witnesses: Vec>) -> TxSignatures { + TxSignatures { + channel_id: v2_channel_id(), + txid: sample_prevtx().compute_txid(), + witnesses, + tlvs: TxSignaturesTlvs::default(), + } +} + +#[test] +fn validate_peer_witnesses_accepts_one_witness_per_contributed_input() { + let witnesses = validate_peer_witnesses( + &tx_signatures_with(vec![sample_peer_witness_data()]), + Some(1), + ) + .expect("a well-formed witness per input is what BOLT 2 asks for"); + + assert_eq!(witnesses, vec![sample_peer_witness()]); +} + +#[test] +fn validate_peer_witnesses_rejects_a_witness_that_does_not_decode() { + // BOLT 2's rationale fixes `witness_data` as bitcoin's wire encoding, + // so bytes that do not decode are a target bug. + let err = validate_peer_witnesses(&tx_signatures_with(vec![vec![0xff; 3]]), Some(1)) + .expect_err("a malformed witness is a target bug"); + + assert!( + matches!(err, Violation::InvalidTxSignatures(id, _) if id == v2_channel_id()), + "unexpected violation: {err}", + ); +} + +#[test] +fn validate_peer_witnesses_rejects_an_empty_witness() { + // A zero-element witness decodes cleanly, so only the emptiness check + // catches it. BOLT 2 names it as a MUST-fail outright. + let empty = bitcoin::consensus::encode::serialize(&Witness::new()); + let err = validate_peer_witnesses(&tx_signatures_with(vec![empty]), Some(1)) + .expect_err("an empty witness is a MUST-fail condition"); + + assert!( + matches!(err, Violation::InvalidTxSignatures(_, ref why) if why.contains("empty")), + "unexpected violation: {err}", + ); +} + +#[test] +fn validate_peer_witnesses_rejects_a_count_that_is_not_the_inputs_added() { + let ts = tx_signatures_with(vec![sample_peer_witness_data()]); + let err = validate_peer_witnesses(&ts, Some(2)) + .expect_err("BOLT 2 requires num_witnesses to equal the inputs the sender added"); + + assert!( + matches!(err, Violation::InvalidTxSignatures(_, ref why) if why.contains("2 input")), + "unexpected violation: {err}", + ); +} + +#[test] +fn validate_peer_witnesses_cannot_count_against_an_untracked_negotiation() { + // With no state for the channel there is nothing to count against, so + // only the per-witness checks apply. + validate_peer_witnesses(&tx_signatures_with(vec![sample_peer_witness_data()]), None) + .expect("a well-formed witness is fine when the count is unknowable"); +} + +#[test] +fn execute_recv_tx_signatures_reads_when_the_peer_signs_first() { + let acceptor_key = sample_acceptor_funding_privkey(); + let conn = v2_flow_connection(); + let mut executor = Executor::new( + conn, + sample_v2_signing_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + let reply = counterparty_commitment_signed(&executor, v2_channel_id(), &acceptor_key); + queue_v2_flow_replies( + &mut executor.conn, + sample_accept_channel2(sample_v2_temporary_channel_id()), + ); + executor + .conn + .queue_recv(Message::CommitmentSigned(reply).encode()); + executor.conn.queue_recv( + Message::TxSignatures(TxSignatures { + channel_id: v2_channel_id(), + txid: Txid::from_str( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .expect("valid txid"), + witnesses: Vec::new(), + tlvs: TxSignaturesTlvs::default(), + }) + .encode(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![36, 10, 32], + }, // v37 + Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![37], + }, // v38 + Instruction { + operation: Operation::RecvTxSignatures, + inputs: vec![32], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + // We contributed every input, so BOLT 2 has the peer sign first and + // the receive is expected rather than skipped. + assert!( + sole_negotiation(&executor) + .commitment_exchange + .tx_signatures + .received + ); +} + +#[test] +fn execute_recv_interactive_tx_stops_once_the_exchange_concludes() { + // The exchange from a real Eclair run: the peer, contributing nothing, + // answers each of our messages with tx_complete. Our own tx_complete + // then makes two consecutive ones, concluding the exchange, and the + // peer moves straight on to commitment_signed. + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + // Each send is followed by the peer's reply, as the turn-based + // protocol and the generator both require. + let sends = [ + Operation::SendTxAddInput { + serial_id: 2, + utxo_index: 0, + sequence: 0xffff_fffd, + }, + Operation::SendTxAddOutput { + serial_id: 2000, + role: TxOutputRole::Funding, + }, + Operation::SendTxAddOutput { + serial_id: 2002, + role: TxOutputRole::Change, + }, + Operation::SendTxComplete, + ]; + for send in sends { + let needs_values = matches!(send, Operation::SendTxAddOutput { .. }); + instructions.push(Instruction { + operation: send, + inputs: if needs_values { + vec![32, 3, 25] + } else { + vec![32] + }, + }); + let sent = instructions.len() - 1; + instructions.push(Instruction { + operation: Operation::RecvInteractiveTx, + inputs: vec![sent], + }); + } + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + // One tx_complete per message we send before our own tx_complete. + for _ in 0..3 { + conn.queue_recv(Message::TxComplete(TxComplete { channel_id }).encode()); + } + // What the peer sends next, which the concluded exchange must not eat. + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id, + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + assert_eq!( + sole_negotiation(&executor) + .tx_exchange + .outstanding_replies(), + 0, + ); + // The commitment_signed is still queued for whoever asks for it next. + // Consuming it here would leave every later operation one message + // behind, and the program would fail on a message it never expected. + assert_eq!(executor.conn.recv_queue.len(), 1); + assert_eq!( + Message::decode(&executor.conn.recv_queue[0]) + .expect("valid") + .msg_type(), + MessageType::COMMITMENT_SIGNED, + ); +} + +#[test] +fn execute_recv_interactive_tx_settles_a_backlog_left_by_a_dropped_receive() { + // A mutated program: the first tx_add_input has no paired receive, so + // every later receive is answering an earlier message. The peer still + // replies to all five contributions and stays silent after the + // tx_complete that concludes the exchange, leaving one reply owed. + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + + let sends = [ + Operation::SendTxAddInput { + serial_id: 2, + utxo_index: 0, + sequence: 0xffff_fffd, + }, + Operation::SendTxAddInput { + serial_id: 4, + utxo_index: 1, + sequence: 0xffff_fffd, + }, + Operation::SendTxAddOutput { + serial_id: 2000, + role: TxOutputRole::Funding, + }, + Operation::SendTxAddOutput { + serial_id: 2002, + role: TxOutputRole::Change, + }, + Operation::SendTxComplete, + ]; + for (i, send) in sends.into_iter().enumerate() { + let needs_values = matches!(send, Operation::SendTxAddOutput { .. }); + instructions.push(Instruction { + operation: send, + inputs: if needs_values { + vec![32, 3, 25] + } else { + vec![32] + }, + }); + let sent = instructions.len() - 1; + // The receive after the first send is the one a mutator dropped. + if i > 0 { + instructions.push(Instruction { + operation: Operation::RecvInteractiveTx, + inputs: vec![sent], + }); + } + } + // The receive after our tx_complete, added by the loop above, is the + // one that must settle the backlog rather than skip: the exchange has + // concluded, but a reply to an earlier message is still owed. + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + // One reply per contribution; none for the concluding tx_complete. + for _ in 0..4 { + conn.queue_recv(Message::TxComplete(TxComplete { channel_id }).encode()); + } + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id, + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + // Every owed reply was read, so the commitment_signed is still there + // for the operation that actually wants it. + assert_eq!( + sole_negotiation(&executor) + .tx_exchange + .outstanding_replies(), + 0, + ); + assert_eq!(executor.conn.recv_queue.len(), 1); + assert_eq!( + Message::decode(&executor.conn.recv_queue[0]) + .expect("valid") + .msg_type(), + MessageType::COMMITMENT_SIGNED, + ); +} + +#[test] +fn execute_recv_interactive_tx_drops_contributions_sent_after_the_conclusion() { + // A mutated program from a real CLN run: three inputs go out, with the + // last two replies left unread, then the funding output, a tx_complete + // and a change output. From the peer's side its tx_complete answering + // the funding output and our tx_complete are consecutive, so the + // exchange concludes without the change output. Our transaction must + // agree, or the peer's perfectly good commitment signature reads as + // invalid. + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + + instructions.push(tx_add_input(2, 0)); // v33 + instructions.push(recv_interactive_tx(33)); + instructions.push(tx_add_input(4, 1)); // v35 + instructions.push(recv_interactive_tx(35)); + instructions.push(tx_add_input(6, 2)); // v37 + instructions.push(tx_add_output(2000, TxOutputRole::Funding)); // v38 + instructions.push(tx_complete()); // v39 + instructions.push(tx_add_output(2002, TxOutputRole::Change)); // v40 + instructions.push(recv_interactive_tx(40)); + instructions.push(recv_interactive_tx(39)); + // The exchange has concluded, so this one has nothing to read. + instructions.push(recv_interactive_tx(37)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + // One tx_complete per message before our own tx_complete; the peer then + // moves straight on to commitment_signed. + for _ in 0..4 { + conn.queue_recv(Message::TxComplete(TxComplete { channel_id }).encode()); + } + conn.queue_recv( + Message::CommitmentSigned(CommitmentSigned { + channel_id, + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let pending = sole_negotiation(&executor); + assert!(pending.tx_exchange.concluded()); + assert_eq!(pending.tx_exchange.outstanding_replies(), 0); + let inputs: Vec = pending + .tx_exchange + .shared_tx() + .inputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(inputs, vec![2, 4, 6]); + let outputs: Vec = pending + .tx_exchange + .shared_tx() + .outputs() + .map(|(id, _)| id) + .collect(); + assert_eq!( + outputs, + vec![2000], + "the late change output is not the peer's" + ); + assert_eq!(executor.conn.recv_queue.len(), 1); + assert_eq!( + Message::decode(&executor.conn.recv_queue[0]) + .expect("valid") + .msg_type(), + MessageType::COMMITMENT_SIGNED, + ); +} + +#[test] +fn execute_send_after_a_known_conclusion_is_not_recorded() { + // The peer's tx_complete has been read, so ours concludes the exchange + // on the spot and a later contribution is neither recorded nor waited on. + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_add_output(2000, TxOutputRole::Funding)); // v33 + instructions.push(recv_interactive_tx(33)); + instructions.push(tx_complete()); // v35 + instructions.push(tx_add_output(2002, TxOutputRole::Change)); // v36 + instructions.push(recv_interactive_tx(36)); + instructions.push(recv_interactive_tx(35)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv(Message::TxComplete(TxComplete { channel_id }).encode()); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + let pending = sole_negotiation(&executor); + assert!(pending.tx_exchange.concluded()); + assert_eq!(pending.tx_exchange.outstanding_replies(), 0); + let outputs: Vec = pending + .tx_exchange + .shared_tx() + .outputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(outputs, vec![2000]); + assert!(executor.conn.recv_queue.is_empty()); +} + +#[test] +fn execute_recv_interactive_tx_still_reads_mid_exchange() { + // Three contributions go out and only one reply is read. The peer's + // tx_complete answered our first send, not our latest, so the exchange + // is not concluded and the receive must not be skipped. + let channel_id = v2_channel_id(); + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_add_input(2, 0)); // v33 + instructions.push(tx_add_input(4, 1)); // v34 + instructions.push(tx_add_input(6, 2)); // v35 + instructions.push(recv_interactive_tx(35)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv(Message::TxComplete(TxComplete { channel_id }).encode()); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("program executes"); + + assert!( + executor.conn.recv_queue.is_empty(), + "the reply was not read" + ); + let pending = sole_negotiation(&executor); + // The peer still owes two replies and the next receive must not skip + // either. + assert!(!pending.tx_exchange.concluded()); + assert_eq!(pending.tx_exchange.outstanding_replies(), 2); +} + +/// The program from a real CLN run: four contributions go out with three +/// replies unread, then a `tx_complete` and a change output. The peer's +/// `tx_complete` answering the last input and ours are consecutive, so the +/// exchange concluded without the change output, but the program builds the +/// funding transaction and signs over it before reading the reply that says +/// so. Both are answered on `channel_id` 32; `extra` follows the send. +fn settle_before_build_instructions(extra: Vec) -> Vec { + let mut instructions = v2_channel_id_instructions(); + + instructions.push(tx_add_input(2, 0)); // v33 + instructions.push(tx_add_output(2000, TxOutputRole::Funding)); // v34 + instructions.push(tx_add_input(4, 1)); // v35 + instructions.push(tx_add_input(6, 2)); // v36 + instructions.push(recv_interactive_tx(33)); // v37 + instructions.push(tx_complete()); // v38 + instructions.push(tx_add_output(2002, TxOutputRole::Change)); // v39 + instructions.push(recv_interactive_tx(39)); // v40 + instructions.push(recv_interactive_tx(38)); // v41 + // The reply to input 6 is still unread here. + instructions.push(Instruction { + operation: Operation::BuildFundingTransactionV2, + inputs: vec![32], + }); // v42 + instructions.push(Instruction { + operation: Operation::SendCommitmentSigned, + inputs: vec![42, 10, 32], + }); // v43 + instructions.push(recv_interactive_tx(36)); // v44 + instructions.extend(extra); + instructions +} + +/// Queues the peer's side of [`settle_before_build_instructions`]: one +/// `tx_complete` per contribution before our own `tx_complete`, then whatever +/// the peer moved on to. +fn queue_settle_before_build_replies(conn: &mut MockConnection, then: &Message) { + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + for _ in 0..4 { + conn.queue_recv( + Message::TxComplete(TxComplete { + channel_id: v2_channel_id(), + }) + .encode(), + ); + } + conn.queue_recv(then.encode()); +} + +#[test] +fn execute_build_funding_transaction_v2_reads_owed_replies_first() { + let mut conn = MockConnection::new(); + queue_settle_before_build_replies( + &mut conn, + &Message::CommitmentSigned(CommitmentSigned { + channel_id: v2_channel_id(), + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: settle_before_build_instructions(vec![]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + // Building read the reply to input 6, which concluded the exchange and + // dropped the change output, so the transaction we signed over is the + // one the peer negotiated. + let pending = sole_negotiation(&executor); + assert!(pending.tx_exchange.concluded()); + assert_eq!(pending.tx_exchange.outstanding_replies(), 0); + let outputs: Vec = pending + .tx_exchange + .shared_tx() + .outputs() + .map(|(id, _)| id) + .collect(); + assert_eq!(outputs, vec![2000]); + let state = &executor.channel_states[&v2_channel_id()]; + assert!(state.is_funding_outpoint_valid); + assert_eq!( + state.config.funding_outpoint.txid, + pending.tx_exchange.shared_tx().build().compute_txid(), + ); + // Only what was owed was read: the peer's commitment_signed is still + // there for RecvCommitmentSigned, and the receive after the send found + // nothing owed. + assert_eq!(executor.conn.recv_queue.len(), 1); +} + +#[test] +fn execute_recv_commitment_signed_verifies_against_the_settled_funding_transaction() { + // The false positive the settling exists for: the peer signs over the + // transaction it negotiated, and so must we. + let acceptor_key = sample_acceptor_funding_privkey(); + let mut conn = MockConnection::new(); + queue_settle_before_build_replies( + &mut conn, + &Message::CommitmentSigned(CommitmentSigned { + channel_id: v2_channel_id(), + signature: Signature::from_compact(&[0u8; 64]).expect("zero signature"), + htlc_signatures: Vec::new(), + tlvs: CommitmentSignedTlvs::default(), + }), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + // First run establishes the channel state we need to sign against. + executor + .execute( + &Program { + instructions: settle_before_build_instructions(vec![]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + executor.conn.recv_queue.clear(); + + // The peer signs over the outpoint it negotiated, whatever we tracked, + // so the reply is built over that one and only then verified against + // our state. + let negotiated = { + let pending = sole_negotiation(&executor); + let accept = pending + .accept_channel2 + .as_ref() + .expect("accept_channel2 received"); + let script = build_funding_witness_script( + &pending.open_channel2.funding_pubkey, + &accept.funding_pubkey, + ) + .to_p2wsh(); + let funding = pending + .tx_exchange + .shared_tx() + .build_funding(&script, pending.total_funding_satoshis()); + OutPoint { + txid: funding.tx.compute_txid(), + vout: funding.vout, + } + }; + let tracked = { + let state = executor + .channel_states + .get_mut(&v2_channel_id()) + .expect("channel tracked"); + std::mem::replace(&mut state.config.funding_outpoint, negotiated) + }; + let reply = counterparty_commitment_signed(&executor, v2_channel_id(), &acceptor_key); + executor + .channel_states + .get_mut(&v2_channel_id()) + .expect("channel tracked") + .config + .funding_outpoint = tracked; + queue_settle_before_build_replies(&mut executor.conn, &Message::CommitmentSigned(reply)); + executor + .execute( + &Program { + instructions: settle_before_build_instructions(vec![Instruction { + operation: Operation::RecvCommitmentSigned, + inputs: vec![43], + }]), + }, + std::time::Instant::now(), + ) + .expect("the peer's signature over the negotiated transaction verifies"); + + assert!( + sole_negotiation(&executor) + .commitment_exchange + .commitment_signed + .received + ); +} + +#[test] +fn execute_send_tx_signatures_reads_owed_replies_first() { + let mut conn = v2_flow_connection(); + // The reply to the output added after the funding transaction was built. + conn.queue_recv( + Message::TxComplete(TxComplete { + channel_id: v2_channel_id(), + }) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_signing_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute( + &Program { + instructions: v2_flow_instructions(vec![ + tx_add_output(8, TxOutputRole::Change), // v37 + Instruction { + operation: Operation::SendTxSignatures, + inputs: vec![32, 36], + }, + ]), + }, + std::time::Instant::now(), + ) + .expect("program executes"); + + assert!( + executor.conn.recv_queue.is_empty(), + "the reply was not read" + ); + assert_eq!( + sole_negotiation(&executor) + .tx_exchange + .outstanding_replies(), + 0 + ); +} + +#[test] +fn execute_recv_interactive_tx_records_a_peer_abort() { + let (mut instructions, _) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::SendTxComplete, + inputs: vec![27], + }); // v31 + instructions.push(recv_interactive_tx(31)); + + let mut conn = MockConnection::new(); + conn.queue_recv( + Message::AcceptChannel2(sample_accept_channel2(sample_v2_temporary_channel_id())).encode(), + ); + conn.queue_recv( + Message::TxAbort(TxAbort::new( + sample_v2_temporary_channel_id(), + "funding output not to spec", + )) + .encode(), + ); + let mut executor = Executor::new( + conn, + sample_v2_wallet(), + MockTargetRpc::default(), + sample_context(), + ); + + executor + .execute(&Program { instructions }, std::time::Instant::now()) + .expect("an abort is normal protocol behaviour, not a harness error"); + + let pending = sole_negotiation(&executor); + assert!(pending.tx_exchange.aborted()); + // An abort is not a tx_complete, so the negotiation has not concluded. + assert!(!pending.tx_exchange.concluded()); +} diff --git a/smite-scenarios/src/executor/tests/harness.rs b/smite-scenarios/src/executor/tests/harness.rs index 5c6bdccb..5d74cd0e 100644 --- a/smite-scenarios/src/executor/tests/harness.rs +++ b/smite-scenarios/src/executor/tests/harness.rs @@ -2,7 +2,8 @@ use crate::executor::*; use bitcoin::{Amount, Transaction}; -use smite::bolt::AcceptChannelTlvs; +use smite::bolt::{AcceptChannel2Tlvs, AcceptChannelTlvs}; +use smite_ir::operation::ChannelTypeVariant; use std::collections::VecDeque; use std::str::FromStr; @@ -58,6 +59,15 @@ pub struct MockBitcoinCli { pub utxos: Vec, pub change_spk: ScriptBuf, pub confirmations: u32, + /// Serialized transactions the node knows about, keyed by txid, as + /// `getrawtransaction` would return them. + pub raw_transactions: HashMap>, + pub locked_outpoints: Vec, + /// Outpoints the wallet can sign. `sign_tx` attaches a witness only to + /// these, the way bitcoind signs only what it owns. + pub signable_outpoints: Vec, + /// When set, `sign_tx` fails outright. + pub signing_fails: bool, } impl BitcoinRpc for MockBitcoinCli { @@ -75,6 +85,28 @@ impl BitcoinRpc for MockBitcoinCli { self.change_spk.clone() } + fn get_raw_transaction(&mut self, txid: Txid) -> Option> { + self.raw_transactions.get(&txid).cloned() + } + + fn sign_tx(&mut self, tx: &bitcoin::Transaction) -> Option { + if self.signing_fails { + return None; + } + let mut signed = tx.clone(); + for txin in &mut signed.input { + if self.signable_outpoints.contains(&txin.previous_output) { + // A distinguishable two-element witness, so tests can tell + // which input a witness came from. + txin.witness = bitcoin::Witness::from_slice(&[ + vec![0xaa; 72], + txin.previous_output.txid.to_string().into_bytes(), + ]); + } + } + Some(signed) + } + fn sign_and_broadcast_tx(&mut self, tx: &bitcoin::Transaction) -> Option { self.broadcast_calls.push(tx.clone()); @@ -93,6 +125,7 @@ impl BitcoinRpc for MockBitcoinCli { } fn lock_utxos(&mut self, outpoints: &[OutPoint]) { + self.locked_outpoints.extend_from_slice(outpoints); self.utxos.retain(|u| !outpoints.contains(&u.outpoint)); } @@ -137,6 +170,7 @@ pub fn sample_pubkey(byte: u8) -> PublicKey { pub fn sample_context() -> ProgramContext { ProgramContext { target_pubkey: sample_pubkey(1), + local_pubkey: sample_pubkey(2), chain_hash: [0xcc; 32], block_height: 800_000, target_features: vec![], @@ -243,3 +277,148 @@ pub fn sample_funding_negotiation() -> PendingChannel { funding_built: false, } } + +// -- Channel establishment v2 -- + +pub fn sample_accept_channel2(temporary_channel_id: TemporaryChannelId) -> AcceptChannel2 { + AcceptChannel2 { + temporary_channel_id, + // The acceptor contributes nothing, the common case for CLN and + // Eclair when they are not configured to provide liquidity. + funding_satoshis: 0, + dust_limit_satoshis: 546, + max_htlc_value_in_flight_msat: 100_000_000, + htlc_minimum_msat: 1_000, + minimum_depth: 6, + to_self_delay: 144, + max_accepted_htlcs: 483, + funding_pubkey: sample_pubkey(11), + revocation_basepoint: sample_pubkey(12), + payment_basepoint: sample_pubkey(13), + delayed_payment_basepoint: sample_pubkey(14), + htlc_basepoint: sample_pubkey(15), + first_per_commitment_point: sample_pubkey(16), + second_per_commitment_point: sample_pubkey(17), + tlvs: AcceptChannel2Tlvs { + upfront_shutdown_script: Some(vec![0xde, 0xad]), + channel_type: Some(vec![0x00, 0x40, 0x10, 0x00]), + require_confirmed_inputs: false, + }, + } +} + +/// The `open_channel2` that `open_channel2_instructions` puts on the +/// wire. +pub fn sample_open_channel2() -> OpenChannel2 { + let secp = Secp256k1::new(); + let pk = |b: &[u8; 32]| PublicKey::from_secret_key(&secp, &SecretKey::from_slice(b).unwrap()); + OpenChannel2 { + chain_hash: [0xcc; 32], + temporary_channel_id: sample_v2_temporary_channel_id(), + funding_feerate_perkw: 253, + commitment_feerate_perkw: 2500, + funding_satoshis: 200_000, + dust_limit_satoshis: 546, + max_htlc_value_in_flight_msat: 100_000_000, + htlc_minimum_msat: 1_000, + to_self_delay: 144, + max_accepted_htlcs: 483, + locktime: 120, + funding_pubkey: pk(&[0x11; 32]), + revocation_basepoint: sample_v2_revocation_basepoint(), + payment_basepoint: pk(&[0x33; 32]), + delayed_payment_basepoint: pk(&[0x44; 32]), + htlc_basepoint: pk(&[0x55; 32]), + first_per_commitment_point: pk(&[0x66; 32]), + second_per_commitment_point: pk(&[0x77; 32]), + channel_flags: 0, + tlvs: OpenChannel2Tlvs { + upfront_shutdown_script: Some(vec![]), + channel_type: Some(ChannelTypeVariant::Anchors.encode()), + require_confirmed_inputs: false, + }, + } +} + +/// Our `revocation_basepoint`, and hence the `temporary_channel_id` that +/// `open_channel2_instructions` derives from it. +pub fn sample_v2_revocation_basepoint() -> PublicKey { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x22; 32]).expect("valid secret key"); + PublicKey::from_secret_key(&secp, &sk) +} + +pub fn sample_v2_temporary_channel_id() -> TemporaryChannelId { + ChannelId::v2_temporary_from_revocation_basepoint(&sample_v2_revocation_basepoint()) +} + +// -- Interactive transaction construction -- + +/// A minimal previous transaction paying one 1 BTC P2WPKH output, used as +/// the `prevtx` a `tx_add_input` carries. +pub fn sample_prevtx() -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(100_000_000), + script_pubkey: sample_change_spk(), + }], + } +} + +/// A wallet holding a single spendable output of `sample_prevtx`, with that +/// transaction available to `getrawtransaction`. +pub fn sample_v2_wallet() -> MockBitcoinCli { + let prevtx = sample_prevtx(); + let txid = prevtx.compute_txid(); + let mut cli = MockBitcoinCli { + change_spk: sample_change_spk(), + ..MockBitcoinCli::default() + }; + cli.utxos.push(Utxo { + amount: prevtx.output[0].value, + outpoint: OutPoint { txid, vout: 0 }, + script_pubkey: prevtx.output[0].script_pubkey.clone(), + }); + cli.raw_transactions + .insert(txid, bitcoin::consensus::encode::serialize(&prevtx)); + cli +} + +// -- Commitment and signature exchange -- + +/// A wallet whose single output is also signable, so `tx_signatures` has a +/// witness to carry. +pub fn sample_v2_signing_wallet() -> MockBitcoinCli { + let mut cli = sample_v2_wallet(); + cli.signable_outpoints = cli.utxos.iter().map(|u| u.outpoint).collect(); + cli +} + +pub fn v2_channel_id() -> ChannelId { + ChannelId::v2_from_revocation_basepoints( + &sample_v2_revocation_basepoint(), + &sample_accept_channel2(sample_v2_temporary_channel_id()).revocation_basepoint, + ) +} + +/// A plausible P2WPKH witness from the peer: signature and pubkey. +pub fn sample_peer_witness() -> Witness { + Witness::from_slice(&[vec![0xbb; 71], vec![0xcc; 33]]) +} + +/// [`sample_peer_witness`] encoded the way `tx_signatures` carries +/// `witness_data`. +pub fn sample_peer_witness_data() -> Vec { + bitcoin::consensus::encode::serialize(&sample_peer_witness()) +} + +/// The private key behind [`sample_accept_channel2`]'s `funding_pubkey`, +/// which is `sample_pubkey(11)`. +pub fn sample_acceptor_funding_privkey() -> SecretKey { + let mut sk_bytes = [0u8; 32]; + sk_bytes[31] = 11; + SecretKey::from_slice(&sk_bytes).expect("valid secret key") +} diff --git a/smite-scenarios/src/executor/tests/programs.rs b/smite-scenarios/src/executor/tests/programs.rs index b5207217..7b676a2e 100644 --- a/smite-scenarios/src/executor/tests/programs.rs +++ b/smite-scenarios/src/executor/tests/programs.rs @@ -2,8 +2,10 @@ //! //! Each helper returns the instructions for one flow. +use super::harness::*; use crate::executor::*; use smite_ir::Instruction; +use smite_ir::operation::ChannelTypeVariant; use std::str::FromStr; /// Builds the 20 `open_channel` input instructions in wire order. @@ -242,3 +244,274 @@ pub fn recv_channel_ready_instructions(confirmations: u8) -> Vec { ]); instrs } + +// -- Channel establishment v2 -- + +/// Builds the `open_channel2` inputs, deriving the `temporary_channel_id` +/// from our revocation basepoint (the `[0x22; 32]` key) as BOLT 2 requires. +/// [`OPEN_CHANNEL2_INPUTS`] maps each wire field to its variable index. +#[allow(clippy::too_many_lines)] +pub fn open_channel2_instructions() -> Vec { + vec![ + Instruction { + operation: Operation::LoadChainHashFromContext, + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(253), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(2500), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadAmount(200_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadAmount(546), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadAmount(100_000_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadAmount(1_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadU16(144), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadU16(483), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadBlockHeight(120), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x11; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![10], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x22; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![12], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x33; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![14], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x44; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![16], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x55; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![18], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x66; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![20], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x77; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![22], + }, + Instruction { + operation: Operation::LoadU8(0), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadBytes(vec![]), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadChannelType(ChannelTypeVariant::Anchors), + inputs: vec![], + }, + Instruction { + operation: Operation::DeriveTemporaryChannelIdV2, + inputs: vec![13], + }, + ] +} + +/// Indices into [`open_channel2_instructions`], in `BuildOpenChannel2` +/// wire order. +pub const OPEN_CHANNEL2_INPUTS: [usize; 21] = [ + 0, // chain_hash + 27, // temporary_channel_id + 1, // funding_feerate_perkw + 2, // commitment_feerate_perkw + 3, // funding_satoshis + 4, // dust_limit_satoshis + 5, // max_htlc_value_in_flight_msat + 6, // htlc_minimum_msat + 7, // to_self_delay + 8, // max_accepted_htlcs + 9, // locktime + 11, // funding_pubkey + 13, // revocation_basepoint + 15, // payment_basepoint + 17, // delayed_payment_basepoint + 19, // htlc_basepoint + 21, // first_per_commitment_point + 23, // second_per_commitment_point + 24, // channel_flags + 25, // upfront_shutdown_script + 26, // channel_type +]; + +/// Emits the full `open_channel2` / `accept_channel2` exchange. The +/// `AcceptChannel2` compound lands at the returned instruction index. +pub fn send_open_channel2_instructions() -> (Vec, usize) { + let mut instructions = open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::BuildOpenChannel2 { + require_confirmed_inputs: false, + }, + inputs: OPEN_CHANNEL2_INPUTS.to_vec(), + }); // v28 + instructions.push(Instruction { + operation: Operation::SendOpenChannel2, + inputs: vec![28], + }); // v29 + instructions.push(Instruction { + operation: Operation::RecvAcceptChannel2, + inputs: vec![29], + }); // v30 + (instructions, 30) +} + +// -- Commitment and signature exchange -- + +/// Queues the peer's side of [`v2_flow_instructions`] on `conn`: its +/// `accept_channel2`, then a `tx_complete` answering each of the three +/// contributions, which `BuildFundingTransactionV2` reads to settle the +/// negotiation before building. +pub fn queue_v2_flow_replies(conn: &mut MockConnection, accept: AcceptChannel2) { + conn.queue_recv(Message::AcceptChannel2(accept).encode()); + for _ in 0..3 { + conn.queue_recv( + Message::TxComplete(TxComplete { + channel_id: v2_channel_id(), + }) + .encode(), + ); + } +} + +/// A connection with the peer's side of [`v2_flow_instructions`] queued. +pub fn v2_flow_connection() -> MockConnection { + let mut conn = MockConnection::new(); + queue_v2_flow_replies( + &mut conn, + sample_accept_channel2(sample_v2_temporary_channel_id()), + ); + conn +} + +/// Index of the v2 `channel_id` produced by [`v2_channel_id_instructions`]. +pub const V2_CHANNEL_ID_VAR: usize = 32; + +/// Emits the `open_channel2` / `accept_channel2` exchange and derives the v2 +/// `channel_id` from it, at [`V2_CHANNEL_ID_VAR`]. +pub fn v2_channel_id_instructions() -> Vec { + let (mut instructions, accept) = send_open_channel2_instructions(); + instructions.push(Instruction { + operation: Operation::ExtractAcceptChannel2(AcceptChannel2Field::RevocationBasepoint), + inputs: vec![accept], + }); // v31 + instructions.push(Instruction { + operation: Operation::DeriveChannelIdV2, + inputs: vec![13, 31], + }); // v32 + instructions +} + +// -- Interactive transaction steps on the v2 channel -- + +pub fn tx_add_input(serial_id: u64, utxo_index: u8) -> Instruction { + Instruction { + operation: Operation::SendTxAddInput { + serial_id, + utxo_index, + sequence: 0xffff_fffd, + }, + inputs: vec![V2_CHANNEL_ID_VAR], + } +} + +/// The value and script inputs only matter for [`TxOutputRole::Explicit`]. +pub fn tx_add_output(serial_id: u64, role: TxOutputRole) -> Instruction { + Instruction { + operation: Operation::SendTxAddOutput { serial_id, role }, + inputs: vec![V2_CHANNEL_ID_VAR, 3, 25], + } +} + +pub fn tx_complete() -> Instruction { + Instruction { + operation: Operation::SendTxComplete, + inputs: vec![V2_CHANNEL_ID_VAR], + } +} + +/// Reads the reply to the send at instruction index `sent`. +pub fn recv_interactive_tx(sent: usize) -> Instruction { + Instruction { + operation: Operation::RecvInteractiveTx, + inputs: vec![sent], + } +} + +/// Drives the v2 flow through our three contributions and the funding +/// transaction built from them, then appends `extra`. The peer's replies +/// come from [`v2_flow_connection`]. +/// +/// Variable indices of interest: 32 is the v2 `channel_id`, 36 the funding +/// transaction, 10 our funding private key. +pub fn v2_flow_instructions(extra: Vec) -> Vec { + let mut instructions = v2_channel_id_instructions(); + instructions.push(tx_add_input(2, 0)); // v33 + instructions.push(tx_add_output(4, TxOutputRole::Funding)); // v34 + instructions.push(tx_add_output(6, TxOutputRole::Change)); // v35 + instructions.push(Instruction { + operation: Operation::BuildFundingTransactionV2, + inputs: vec![V2_CHANNEL_ID_VAR], + }); // v36 funding transaction + instructions.extend(extra); + instructions +} diff --git a/smite-scenarios/src/scenarios.rs b/smite-scenarios/src/scenarios.rs index 48a43b3f..24eaf287 100644 --- a/smite-scenarios/src/scenarios.rs +++ b/smite-scenarios/src/scenarios.rs @@ -10,12 +10,12 @@ pub use encrypted_bytes::EncryptedBytesScenario; pub use init::InitScenario; pub use ir::IrScenario; pub use noise::NoiseScenario; -pub use setup::{PostInitSetup, REGTEST_CHAIN_HASH, SnapshotSetup}; +pub use setup::{PostInitDualFundSetup, PostInitSetup, REGTEST_CHAIN_HASH, SnapshotSetup}; use smite::scenarios::ScenarioError; use std::time::Duration; -use bitcoin::secp256k1::SecretKey; +use bitcoin::secp256k1::{self, SecretKey}; use smite::bolt::{Error, Init, Message, Ping}; use smite::noise::NoiseConnection; @@ -59,6 +59,19 @@ const EPHEMERAL_KEY: [u8; 32] = [ 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, ]; +/// Returns our own identity public key, derived from the fixed Noise static +/// key used for every handshake. +/// +/// # Panics +/// +/// Panics if [`STATIC_KEY`] is not a valid secp256k1 secret key, which is a +/// compile-time constant and so cannot vary at run time. +#[must_use] +pub fn local_node_id() -> secp256k1::PublicKey { + let secret = SecretKey::from_slice(&STATIC_KEY).expect("valid static key"); + secp256k1::PublicKey::from_secret_key(&secp256k1::Secp256k1::new(), &secret) +} + /// Perform a Noise handshake with a target and receive its `Init` message. /// /// Returns the encrypted connection and the target's `Init`. The caller is diff --git a/smite-scenarios/src/scenarios/setup.rs b/smite-scenarios/src/scenarios/setup.rs index 1daf89e9..57bc2fa2 100644 --- a/smite-scenarios/src/scenarios/setup.rs +++ b/smite-scenarios/src/scenarios/setup.rs @@ -30,28 +30,23 @@ pub trait SnapshotSetup { fn setup(target: &T) -> Result<(NoiseConnection, ProgramContext), ScenarioError>; } -/// Features stripped from our echoed `init` so the target stays on the single -/// funded flow and doesn't emit unrelated noise: -/// - `gossip_queries` (6/7), `gossip_queries_ex` (10/11): Stripped so the -/// target doesn't send `gossip_timestamp_filter` or other gossip noise during -/// execution. -/// - `option_dual_fund` (28/29): Eclair in particular will not allow -/// single-funded flows if either of these feature bits is set. -/// - `option_provide_storage` (42/43): When enabled, peers may send -/// `peer_storage` and `peer_storage_retrieval` messages at arbitrary times. -const STRIPPED_FEATURES: &[FeatureBit] = &[ +/// Features stripped from every echoed `init` so the target doesn't emit noise +/// unrelated to channel establishment: +/// - `gossip_queries`, `gossip_queries_ex`: Stripped so the target doesn't send +/// `gossip_timestamp_filter` or other gossip noise during execution. +/// - `option_provide_storage`: When enabled, peers may send `peer_storage` and +/// `peer_storage_retrieval` messages at arbitrary times. +const NOISY_FEATURES: &[FeatureBit] = &[ Features::GOSSIP_QUERIES, Features::GOSSIP_QUERIES_EX, - Features::OPTION_DUAL_FUND, Features::OPTION_PROVIDE_STORAGE, ]; -/// Creates an `init` that echoes the received features with bits stripped that -/// would steer the target away from the single-funded `open_channel` flow. -fn init_for_single_funded(received: &Init) -> Init { +/// Creates an `init` that echoes the received features with `stripped` cleared. +fn init_echoing_without(received: &Init, stripped: &[FeatureBit]) -> Init { let mut globalfeatures = Features::from(received.globalfeatures.clone()); let mut features = Features::from(received.features.clone()); - for &bit in STRIPPED_FEATURES { + for &bit in stripped { globalfeatures.clear_feature(bit); features.clear_feature(bit); } @@ -62,32 +57,91 @@ fn init_for_single_funded(received: &Init) -> Init { } } +/// Creates an `init` that echoes the received features with bits stripped that +/// would steer the target away from the single-funded `open_channel` flow. +/// +/// Eclair in particular will not allow single-funded flows if `option_dual_fund` +/// is set, so it is stripped on top of the always-noisy features. +fn init_for_single_funded(received: &Init) -> Init { + let stripped: Vec = NOISY_FEATURES + .iter() + .copied() + .chain([Features::OPTION_DUAL_FUND]) + .collect(); + init_echoing_without(received, &stripped) +} + +/// Creates an `init` that keeps `option_dual_fund` so the target takes the +/// channel establishment v2 path, while still stripping the gossip and peer +/// storage noise. +fn init_for_dual_funded(received: &Init) -> Init { + init_echoing_without(received, NOISY_FEATURES) +} + +/// Performs the handshake, echoes an `init` built by `make_init`, and captures +/// the [`ProgramContext`] an IR program reads at execution time. +fn setup_with_init( + target: &T, + make_init: fn(&Init) -> Init, +) -> Result<(NoiseConnection, ProgramContext), ScenarioError> { + let (mut conn, target_init) = handshake_with_target(target, TIMEOUT)?; + + conn.send_message(&Message::Init(make_init(&target_init)).encode())?; + + // Drain any remaining post-init noise so the snapshot starts with a + // clean connection. + ping_pong(&mut conn)?; + + let context = ProgramContext { + target_pubkey: *target.pubkey(), + local_pubkey: super::local_node_id(), + chain_hash: REGTEST_CHAIN_HASH, + // All targets gate startup on `INITIAL_BLOCKS` being mined, so + // this is the floor. Dynamic per-target queries can replace it + // later. + block_height: u32::try_from(INITIAL_BLOCKS).expect("fits in u32"), + target_features: target_init.features, + }; + + Ok((conn, context)) +} + /// Setup that snapshots just after the Noise handshake and init exchange are -/// complete. +/// complete, with `option_dual_fund` stripped so the target takes the +/// single-funded `open_channel` path. pub struct PostInitSetup; impl SnapshotSetup for PostInitSetup { fn setup(target: &T) -> Result<(NoiseConnection, ProgramContext), ScenarioError> { - let (mut conn, target_init) = handshake_with_target(target, TIMEOUT)?; - - // Echo features but strip the bits that would take us off the - // single-funded `open_channel` path this setup is built for. - let our_init = init_for_single_funded(&target_init); - conn.send_message(&Message::Init(our_init).encode())?; - - // Drain any remaining post-init noise so the snapshot starts with a - // clean connection. - ping_pong(&mut conn)?; - - let context = ProgramContext { - target_pubkey: *target.pubkey(), - chain_hash: REGTEST_CHAIN_HASH, - // All targets gate startup on `INITIAL_BLOCKS` being mined, so - // this is the floor. Dynamic per-target queries can replace it - // later. - block_height: u32::try_from(INITIAL_BLOCKS).expect("fits in u32"), - target_features: target_init.features, - }; + setup_with_init(target, init_for_single_funded) + } +} + +/// Setup that snapshots just after the Noise handshake and init exchange are +/// complete, with `option_dual_fund` negotiated so the target takes the +/// channel establishment v2 path. +/// +/// BOLT 2 makes the two flows mutually exclusive on one connection: once +/// `option_dual_fund` is negotiated the opener MUST NOT send `open_channel`, +/// and the receiver of one MUST fail the channel. So a v2 scenario needs its +/// own snapshot rather than sharing [`PostInitSetup`]'s. +pub struct PostInitDualFundSetup; + +impl SnapshotSetup for PostInitDualFundSetup { + fn setup(target: &T) -> Result<(NoiseConnection, ProgramContext), ScenarioError> { + let (conn, context) = setup_with_init(target, init_for_dual_funded)?; + + // Without this feature the target stays on the v1 flow and every + // `open_channel2` is rejected, which is otherwise hard to tell apart + // from a bug in the v2 flow itself. + if !Features::from(context.target_features.as_slice()) + .supports_feature(Features::OPTION_DUAL_FUND) + { + log::warn!( + "target does not advertise option_dual_fund; channel establishment v2 will not be \ + reachable", + ); + } Ok((conn, context)) } diff --git a/smite-scenarios/src/targets/bitcoind.rs b/smite-scenarios/src/targets/bitcoind.rs index 0cff647c..d7ad7494 100644 --- a/smite-scenarios/src/targets/bitcoind.rs +++ b/smite-scenarios/src/targets/bitcoind.rs @@ -10,8 +10,26 @@ use smite::process::ManagedProcess; use super::TargetError; -/// Number of blocks to generate at startup for coinbase maturity. -pub const INITIAL_BLOCKS: u64 = 101; +/// Blocks a coinbase output must be buried under before it can be spent. +const COINBASE_MATURITY: u64 = 100; + +/// Mature coinbase outputs the wallet holds once startup is done. +/// +/// Every block mined at startup pays its coinbase to the wallet, so this is +/// also the number of separate UTXOs a program has to spend. It needs to cover +/// more than one: channel establishment v2 has a program contribute several +/// inputs to one funding transaction, and each `tx_add_input` locks the coin it +/// selects so the next one cannot propose the same outpoint. A program may also +/// open more than one channel. Sixteen leaves room for both, and mining the +/// extra blocks costs nothing measurable, since it happens once before the +/// snapshot is taken. +const SPENDABLE_UTXOS: u64 = 16; + +/// Number of blocks to generate at startup. +/// +/// Only outputs buried under [`COINBASE_MATURITY`] blocks are spendable, so the +/// wallet ends up with [`SPENDABLE_UTXOS`] of them. +pub const INITIAL_BLOCKS: u64 = COINBASE_MATURITY + SPENDABLE_UTXOS; /// Bitcoind configuration. pub struct BitcoindConfig { diff --git a/smite-scenarios/src/targets/cln.rs b/smite-scenarios/src/targets/cln.rs index f8684f34..2af7edc6 100644 --- a/smite-scenarios/src/targets/cln.rs +++ b/smite-scenarios/src/targets/cln.rs @@ -211,6 +211,10 @@ impl ClnTarget { .arg("--bitcoin-rpcuser=rpcuser") .arg("--bitcoin-rpcpassword=rpcpass") .arg(format!("--addr=0.0.0.0:{}", config.cln_p2p_port)) + // Advertise option_dual_fund so the channel establishment v2 + // scenarios can negotiate it. The v1 scenarios strip the bit from + // our own `init`, so enabling it here does not affect them. + .arg("--experimental-dual-fund") .arg("--log-level=info") .arg(format!("--log-file={}/cln.log", cln_dir.display())) .stdout(Stdio::null()) diff --git a/smite-scenarios/src/targets/eclair.rs b/smite-scenarios/src/targets/eclair.rs index 40ee3f38..71e6043e 100644 --- a/smite-scenarios/src/targets/eclair.rs +++ b/smite-scenarios/src/targets/eclair.rs @@ -102,7 +102,8 @@ impl EclairTarget { eclair.bitcoind.rpcpassword=rpcpass\n\ eclair.bitcoind.rpcport={bitcoind_rpc_port}\n\ eclair.bitcoind.zmqblock=\"tcp://127.0.0.1:{zmq_block_port}\"\n\ - eclair.bitcoind.zmqtx=\"tcp://127.0.0.1:{zmq_tx_port}\"\n", + eclair.bitcoind.zmqtx=\"tcp://127.0.0.1:{zmq_tx_port}\"\n\ + eclair.features.option_dual_fund=optional\n", eclair_p2p_port = config.eclair_p2p_port, eclair_api_port = config.eclair_api_port, api_password = API_PASSWORD, diff --git a/smite/src/bitcoin.rs b/smite/src/bitcoin.rs index 250409aa..9a6463a6 100644 --- a/smite/src/bitcoin.rs +++ b/smite/src/bitcoin.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::process::Command; use std::str::FromStr; -use bitcoin::consensus::encode::serialize_hex; +use bitcoin::consensus::encode::{deserialize, serialize_hex}; use bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, Transaction, Txid}; use serde::{Deserialize, Serialize}; @@ -49,6 +49,15 @@ pub struct TxBlockPosition { pub tx_index: u32, } +/// Parsed response from `signrawtransactionwithwallet `. +#[derive(Deserialize)] +struct SignRawTransactionResponse { + /// Consensus-serialized transaction with every signable input signed. + hex: String, + /// Whether every input now has a complete signature set. + complete: bool, +} + /// Parsed response from `getrawtransaction 1`. #[derive(Deserialize)] struct RawTransactionInfo { @@ -58,6 +67,8 @@ struct RawTransactionInfo { confirmations: u32, /// Omitted while the transaction is unconfirmed (in the mempool). blockhash: Option, + /// Consensus-serialized transaction, always present. + hex: String, } /// Connection info for invoking `bitcoin-cli` against the regtest `bitcoind` @@ -128,10 +139,6 @@ impl BitcoinCli { /// Mines a single block containing the current mempool together with the /// transactions stored in `private_mempool`. /// - /// Since `generateblock` only includes the transactions it is given, the - /// current mempool (fetched via `getrawmempool`) is included as well so - /// already-broadcast transactions are not omitted from the block. - /// /// # Panics /// /// - If `bitcoin-cli getrawmempool`, `getnewaddress`, or `generateblock` @@ -142,8 +149,28 @@ impl BitcoinCli { /// - If the combined transaction list contains a duplicate rawtx/txid or is /// not topologically ordered. fn mine_block_including(&self, private_mempool: &[String]) { + self.generate_block_with_mempool(private_mempool, true) + .unwrap_or_else(|stderr| panic!("bitcoin-cli generateblock failed: {stderr}")); + } + + /// Runs `generateblock` over the current mempool together with `extra`, + /// either submitting the block or, with `submit` false, only checking that + /// it would be valid. Returns the command's stderr if it exits non-zero. + /// + /// Since `generateblock` only includes the transactions it is given, the + /// current mempool (fetched via `getrawmempool`) is included as well, so + /// already-broadcast transactions are neither omitted from the block nor + /// missing as parents of `extra`. + /// + /// # Panics + /// + /// - If `bitcoin-cli getrawmempool`, `getnewaddress`, or `generateblock` + /// fails to execute. + /// - If `getrawmempool` does not return valid JSON. + /// - If `getnewaddress` does not return a valid regtest address. + fn generate_block_with_mempool(&self, extra: &[String], submit: bool) -> Result<(), String> { let mut txs = self.get_raw_mempool(); - txs.extend_from_slice(private_mempool); + txs.extend_from_slice(extra); let txs_json = serde_json::to_string(&txs).expect("tx list serializes to valid JSON"); let address = self.get_new_address(); @@ -152,13 +179,14 @@ impl BitcoinCli { .arg("generateblock") .arg(address.to_string()) .arg(&txs_json) + .arg(submit.to_string()) .output() .expect("bitcoin-cli generateblock should not fail"); - assert!( - gen_out.status.success(), - "bitcoin-cli generateblock failed: {}", - String::from_utf8_lossy(&gen_out.stderr) - ); + if gen_out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&gen_out.stderr).into_owned()) + } } /// Returns the txids currently in the node's mempool. @@ -271,6 +299,62 @@ impl BitcoinCli { .expect("getnewaddress should return a valid address") } + /// Signs the wallet-owned inputs of `tx`, returning the partially or fully + /// signed transaction, or `None` if the node does not know how to sign any + /// of them. + /// + /// Unlike [`Self::sign_and_broadcast_tx`], this does not require signing to + /// be complete. A channel establishment v2 funding transaction also carries + /// the peer's inputs, which our wallet cannot sign; the partially signed + /// result still carries our own witnesses, which is what `tx_signatures` + /// needs. Signing does not alter the txid, since every input we can sign is + /// a segwit input. + /// + /// # Panics + /// + /// - If `bitcoin-cli signrawtransactionwithwallet` fails to execute. + /// - If the command succeeds but its output is not valid JSON, or its `hex` + /// field does not decode as a transaction. + #[must_use] + pub fn sign_tx(&self, tx: &Transaction) -> Option { + let signed = self + .sign_raw_transaction_with_wallet(tx) + .inspect_err(|stderr| { + log::debug!("bitcoin-cli signrawtransactionwithwallet failed: {stderr}"); + }) + .ok()?; + Some( + deserialize(&hex::decode(&signed.hex).expect("signing should return valid hex")) + .expect("signing should return a valid transaction"), + ) + } + + /// Runs `signrawtransactionwithwallet`, returning the raw response, or the + /// command's stderr if it exits non-zero. + /// + /// Whether a non-zero exit is fatal is the caller's to decide, so the + /// stderr is handed back rather than logged here: [`Self::sign_tx`] treats + /// it as a transaction the wallet cannot sign, while + /// [`Self::sign_and_broadcast_tx`] panics and needs it in the message. + fn sign_raw_transaction_with_wallet( + &self, + tx: &Transaction, + ) -> Result { + let signed_out = self + .run() + .arg("signrawtransactionwithwallet") + .arg(serialize_hex(tx)) + .output() + .expect("bitcoin-cli signrawtransactionwithwallet should not fail"); + + if !signed_out.status.success() { + return Err(String::from_utf8_lossy(&signed_out.stderr).into_owned()); + } + + Ok(serde_json::from_slice(&signed_out.stdout) + .expect("signrawtransactionwithwallet should return valid JSON")) + } + /// Signs and broadcasts a transaction, unless it is already confirmed. /// /// If the signed transaction is accepted by the mempool, it is broadcast @@ -278,57 +362,55 @@ impl BitcoinCli { /// the minimum relay feerate or creates a dust output), it is returned /// instead so the caller can mine it later, bypassing mempool policy. /// - /// Returns `None` if the transaction was already confirmed or was broadcast - /// successfully, or hex-encoded raw transaction if it was rejected by the - /// mempool. + /// Returns `None` if the transaction has no inputs, was already confirmed, + /// could not be fully signed, could not go in a block, or was broadcast + /// successfully; or the hex-encoded raw transaction if it was rejected by + /// mempool policy alone. /// /// # Panics /// /// - If `bitcoin-cli signrawtransactionwithwallet` fails to execute or /// exits non-zero. /// - If the sign output is not valid JSON. - /// - If signing returns `complete=false`. /// - If `bitcoin-cli sendrawtransaction` fails to execute. - /// - If the broadcast is rejected for any reason other than a below-dust - /// output or a below-minimum relay feerate. + /// - If the broadcast is rejected for a reason other than a known mempool + /// policy rule, a consensus rule, or the transaction already being known + /// to the node. + /// - If a policy-rejected transaction fails the block validity check for + /// a reason other than a consensus rule. /// - If a successful broadcast does not return a valid UTF-8 txid. /// - If the broadcasted txid does not match the given transaction's txid. #[must_use] pub fn sign_and_broadcast_tx(&self, tx: &Transaction) -> Option { - #[derive(Deserialize)] - struct SignRawTransactionResponse { - hex: String, - complete: bool, + let txid = tx.compute_txid(); + // A channel establishment v2 funding transaction holds whatever the + // fuzzer negotiated, which may be no inputs at all. Consensus forbids + // that, and bitcoind cannot even decode the segwit serialization of + // it, so there is nothing to sign or broadcast. + if tx.input.is_empty() { + log::debug!("{txid} has no inputs, not broadcasting"); + return None; } // A confirmed transaction may be broadcast again by the fuzzer. Its // inputs are spent, so the wallet can no longer fully sign it, skip // signing and broadcasting it again. - let txid = tx.compute_txid(); if self.get_transaction_confirmations(txid) > 0 { return None; } - let tx_hex = serialize_hex(tx); - - let signed_out = self - .run() - .arg("signrawtransactionwithwallet") - .arg(&tx_hex) - .output() - .expect("bitcoin-cli signrawtransactionwithwallet should not fail"); - assert!( - signed_out.status.success(), - "bitcoin-cli signrawtransactionwithwallet failed: {}", - String::from_utf8_lossy(&signed_out.stderr) - ); + let signed_tx = self + .sign_raw_transaction_with_wallet(tx) + .unwrap_or_else(|stderr| { + panic!("bitcoin-cli signrawtransactionwithwallet failed: {stderr}") + }); - let signed_tx: SignRawTransactionResponse = serde_json::from_slice(&signed_out.stdout) - .expect("signrawtransactionwithwallet should return valid JSON"); - assert!( - signed_tx.complete, - "signrawtransactionwithwallet returned complete=false" - ); + if !signed_tx.complete { + log::debug!( + "signrawtransactionwithwallet could not fully sign {txid}, not broadcasting" + ); + return None; + } let broadcast_out = self .run() @@ -336,16 +418,73 @@ impl BitcoinCli { .arg(&signed_tx.hex) // Disable the high-feerate cap and accept any fee rate for broadcast. .arg("0") + // Lift the burn cap (`maxburnamount`, in BTC) as well: an output + // the fuzzer made provably unspendable, such as an `OP_RETURN` + // script in `tx_add_output`, is still a mineable transaction. + .arg("21000000") .output() .expect("bitcoin-cli sendrawtransaction should not fail"); if !broadcast_out.status.success() { let stderr = String::from_utf8_lossy(&broadcast_out.stderr); - // If the feerate is below the default minimum relay feerate, or any - // output is below its dust threshold, return the transactions so - // they can be mined directly, bypassing mempool policy. - if stderr.contains("tx with dust output") || stderr.contains("min relay fee not met") { - return Some(signed_tx.hex); + // If the feerate is below the default minimum relay feerate, any + // output is below its dust threshold, or an output script is not + // one of the standard templates (`tx_add_output` sends whatever + // script the fuzzer picked), return the transaction so it can be + // mined directly, bypassing mempool policy. + // + // A policy rejection says nothing about the checks bitcoind never + // got to. Standardness runs before finality and input values, so + // a non-standard script or a second dust output hides a lock time + // still in the future or outputs worth more than the inputs, both + // of which a funding transaction the fuzzer negotiated has as + // readily. Only a transaction a block would accept is worth mining + // later, so ask bitcoind to assemble one without submitting it. + if stderr.contains("dust") + || stderr.contains("min relay fee not met") + || stderr.contains("scriptpubkey") + { + return match self + .generate_block_with_mempool(std::slice::from_ref(&signed_tx.hex), false) + { + Ok(()) => Some(signed_tx.hex), + Err(stderr) if stderr.contains("bad-txns-") => { + log::debug!( + "{txid} fails mempool policy and cannot go in a block either, \ + not broadcasting: {}", + stderr.trim() + ); + None + } + Err(stderr) => panic!("bitcoin-cli generateblock failed: {stderr}"), + }; + } + // The transaction may already be known to the node: the fuzzer can + // broadcast the same transaction twice before mining, and in + // channel establishment v2 the peer broadcasts the funding + // transaction as well. Either way it is already where we want it, + // nothing to mine privately. + if stderr.contains("txn-already-in-mempool") + || stderr.contains("txn-already-known") + || stderr.contains("Transaction already in block chain") + { + return None; + } + // A channel establishment v2 funding transaction takes its + // `nLockTime` from `open_channel2.locktime` and each input's + // `nSequence` from `tx_add_input`, both of which the fuzzer picks + // freely, so it is routinely locked until a later block, either + // absolutely or relative to the inputs it spends. + if stderr.contains("non-final") || stderr.contains("non-BIP68-final") { + log::debug!("{txid} is not final yet, not broadcasting"); + return None; + } + if stderr.contains("bad-txns-") { + log::debug!( + "{txid} is consensus invalid, not broadcasting: {}", + stderr.trim() + ); + return None; } panic!("bitcoin-cli sendrawtransaction failed: {stderr}"); } @@ -453,6 +592,24 @@ impl BitcoinCli { .map_or(0, |info| info.confirmations) } + /// Returns the consensus-serialized transaction with the given txid, or + /// `None` if it is unknown to the node. + /// + /// Channel establishment v2 needs these bytes for `tx_add_input`'s + /// `prevtx` field, which lets the peer verify that the input being spent + /// is non-malleable. + /// + /// # Panics + /// + /// - If the `bitcoin-cli getrawtransaction` command fails to execute. + /// - If the command succeeds but its output is not valid JSON or its `hex` + /// field is not valid hex. + #[must_use] + pub fn get_raw_transaction(&self, txid: Txid) -> Option> { + let info = self.get_raw_transaction_info(txid)?; + Some(hex::decode(&info.hex).expect("getrawtransaction should return valid hex")) + } + /// Returns the position of the confirmed transaction with the given txid, /// or `None` if it is unconfirmed (in the mempool) or unknown to the node /// (e.g. not broadcast yet). diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 5572124f..c3e15514 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -30,10 +30,12 @@ mod tlv; mod tx_abort; mod tx_ack_rbf; mod tx_add_input; +mod tx_add_output; mod tx_complete; mod tx_init_rbf; mod tx_remove_input; mod tx_remove_output; +mod tx_signatures; mod types; mod update_add_htlc; mod update_fail_htlc; @@ -69,10 +71,12 @@ pub use tlv::{TlvRecord, TlvStream}; pub use tx_abort::TxAbort; pub use tx_ack_rbf::{TxAckRbf, TxAckRbfTlvs}; pub use tx_add_input::{TxAddInput, TxAddInputTlvs}; +pub use tx_add_output::TxAddOutput; pub use tx_complete::TxComplete; pub use tx_init_rbf::{TxInitRbf, TxInitRbfTlvs}; pub use tx_remove_input::TxRemoveInput; pub use tx_remove_output::TxRemoveOutput; +pub use tx_signatures::{TxSignatures, TxSignaturesTlvs}; pub use types::{ BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, MAX_MESSAGE_SIZE, PAYMENT_ONION_PACKET_SIZE, PER_COMMITMENT_SECRET_SIZE, PUBLIC_KEY_SIZE, SHA256_HASH_SIZE, @@ -175,12 +179,16 @@ impl MessageType { pub const ACCEPT_CHANNEL2: MessageType = MessageType(65); /// `tx_add_input` message (BOLT 2). pub const TX_ADD_INPUT: MessageType = MessageType(66); + /// `tx_add_output` message (BOLT 2). + pub const TX_ADD_OUTPUT: MessageType = MessageType(67); /// `tx_remove_input` message (BOLT 2). pub const TX_REMOVE_INPUT: MessageType = MessageType(68); /// `tx_remove_output` message (BOLT 2). pub const TX_REMOVE_OUTPUT: MessageType = MessageType(69); /// `tx_complete` message (BOLT 2). pub const TX_COMPLETE: MessageType = MessageType(70); + /// `tx_signatures` message (BOLT 2). + pub const TX_SIGNATURES: MessageType = MessageType(71); /// `tx_init_rbf` message (BOLT 2). pub const TX_INIT_RBF: MessageType = MessageType(72); /// `tx_ack_rbf` message (BOLT 2). @@ -243,9 +251,11 @@ impl MessageType { Self::OPEN_CHANNEL2 => "open_channel2", Self::ACCEPT_CHANNEL2 => "accept_channel2", Self::TX_ADD_INPUT => "tx_add_input", + Self::TX_ADD_OUTPUT => "tx_add_output", Self::TX_REMOVE_INPUT => "tx_remove_input", Self::TX_REMOVE_OUTPUT => "tx_remove_output", Self::TX_COMPLETE => "tx_complete", + Self::TX_SIGNATURES => "tx_signatures", Self::TX_INIT_RBF => "tx_init_rbf", Self::TX_ACK_RBF => "tx_ack_rbf", Self::TX_ABORT => "tx_abort", @@ -307,12 +317,16 @@ pub enum Message { AcceptChannel2(AcceptChannel2), /// `tx_add_input` message (type 66). TxAddInput(TxAddInput), + /// `tx_add_output` message (type 67). + TxAddOutput(TxAddOutput), /// `tx_remove_input` message (type 68). TxRemoveInput(TxRemoveInput), /// `tx_remove_output` message (type 69). TxRemoveOutput(TxRemoveOutput), /// `tx_complete` message (type 70). TxComplete(TxComplete), + /// `tx_signatures` message (type 71). + TxSignatures(TxSignatures), /// `tx_init_rbf` message (type 72). TxInitRbf(TxInitRbf), /// `tx_ack_rbf` message (type 73). @@ -380,9 +394,11 @@ impl Message { Self::OpenChannel2(_) => MessageType::OPEN_CHANNEL2, Self::AcceptChannel2(_) => MessageType::ACCEPT_CHANNEL2, Self::TxAddInput(_) => MessageType::TX_ADD_INPUT, + Self::TxAddOutput(_) => MessageType::TX_ADD_OUTPUT, Self::TxRemoveInput(_) => MessageType::TX_REMOVE_INPUT, Self::TxRemoveOutput(_) => MessageType::TX_REMOVE_OUTPUT, Self::TxComplete(_) => MessageType::TX_COMPLETE, + Self::TxSignatures(_) => MessageType::TX_SIGNATURES, Self::TxInitRbf(_) => MessageType::TX_INIT_RBF, Self::TxAckRbf(_) => MessageType::TX_ACK_RBF, Self::TxAbort(_) => MessageType::TX_ABORT, @@ -423,9 +439,11 @@ impl Message { Self::OpenChannel2(m) => out.extend(m.encode()), Self::AcceptChannel2(m) => out.extend(m.encode()), Self::TxAddInput(m) => out.extend(m.encode()), + Self::TxAddOutput(m) => out.extend(m.encode()), Self::TxRemoveInput(m) => out.extend(m.encode()), Self::TxRemoveOutput(m) => out.extend(m.encode()), Self::TxComplete(m) => out.extend(m.encode()), + Self::TxSignatures(m) => out.extend(m.encode()), Self::TxInitRbf(m) => out.extend(m.encode()), Self::TxAckRbf(m) => out.extend(m.encode()), Self::TxAbort(m) => out.extend(m.encode()), @@ -479,11 +497,13 @@ impl Message { Ok(Self::AcceptChannel2(AcceptChannel2::decode(cursor)?)) } MessageType::TX_ADD_INPUT => Ok(Self::TxAddInput(TxAddInput::decode(cursor)?)), + MessageType::TX_ADD_OUTPUT => Ok(Self::TxAddOutput(TxAddOutput::decode(cursor)?)), MessageType::TX_REMOVE_INPUT => Ok(Self::TxRemoveInput(TxRemoveInput::decode(cursor)?)), MessageType::TX_REMOVE_OUTPUT => { Ok(Self::TxRemoveOutput(TxRemoveOutput::decode(cursor)?)) } MessageType::TX_COMPLETE => Ok(Self::TxComplete(TxComplete::decode(cursor)?)), + MessageType::TX_SIGNATURES => Ok(Self::TxSignatures(TxSignatures::decode(cursor)?)), MessageType::TX_INIT_RBF => Ok(Self::TxInitRbf(TxInitRbf::decode(cursor)?)), MessageType::TX_ACK_RBF => Ok(Self::TxAckRbf(TxAckRbf::decode(cursor)?)), MessageType::TX_ABORT => Ok(Self::TxAbort(TxAbort::decode(cursor)?)), @@ -900,6 +920,25 @@ mod tests { assert_eq!(decoded, Message::TxAddInput(tx_add_input)); } + /// Valid `TxAddOutput` message for testing. + fn sample_tx_add_output() -> TxAddOutput { + TxAddOutput { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + serial_id: 30, + sats: 49_999_845, + script: vec![0x00, 0x14, 0x1c, 0xa1], + } + } + + #[test] + fn message_tx_add_output_roundtrip() { + let tx_add_output = sample_tx_add_output(); + let msg = Message::TxAddOutput(tx_add_output.clone()); + let encoded = msg.encode(); + let decoded = Message::decode(&encoded).unwrap(); + assert_eq!(decoded, Message::TxAddOutput(tx_add_output)); + } + #[test] fn message_tx_remove_input_roundtrip() { let tx_remove_input = TxRemoveInput { @@ -935,6 +974,25 @@ mod tests { assert_eq!(decoded, Message::TxComplete(tx_complete)); } + /// Valid `TxSignatures` message for testing. + fn sample_tx_signatures() -> TxSignatures { + TxSignatures { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + txid: Txid::from_byte_array([0xcd; TXID_SIZE]), + witnesses: vec![vec![0xde, 0xad, 0xbe, 0xef], vec![0x01, 0x02]], + tlvs: TxSignaturesTlvs::default(), + } + } + + #[test] + fn message_tx_signatures_roundtrip() { + let tx_signatures = sample_tx_signatures(); + let msg = Message::TxSignatures(tx_signatures.clone()); + let encoded = msg.encode(); + let decoded = Message::decode(&encoded).unwrap(); + assert_eq!(decoded, Message::TxSignatures(tx_signatures)); + } + #[test] fn message_tx_init_rbf_roundtrip() { let tx_init_rbf = TxInitRbf { @@ -1288,6 +1346,11 @@ mod tests { "tx_add_input", MessageType::TX_ADD_INPUT, ), + ( + Message::TxAddOutput(sample_tx_add_output()), + "tx_add_output", + MessageType::TX_ADD_OUTPUT, + ), ( Message::TxRemoveInput(TxRemoveInput { channel_id: ChannelId::new([0; CHANNEL_ID_SIZE]), @@ -1311,6 +1374,11 @@ mod tests { "tx_complete", MessageType::TX_COMPLETE, ), + ( + Message::TxSignatures(sample_tx_signatures()), + "tx_signatures", + MessageType::TX_SIGNATURES, + ), ( Message::TxInitRbf(TxInitRbf { channel_id: ChannelId::new([0; CHANNEL_ID_SIZE]), diff --git a/smite/src/bolt/tx_add_output.rs b/smite/src/bolt/tx_add_output.rs new file mode 100644 index 00000000..3b96950b --- /dev/null +++ b/smite/src/bolt/tx_add_output.rs @@ -0,0 +1,203 @@ +//! BOLT 2 `tx_add_output` message. + +use super::BoltError; +use super::types::ChannelId; +use super::wire::WireFormat; + +/// BOLT 2 `tx_add_output` message (type 67). +/// +/// Sent during interactive transaction construction to propose adding an +/// output to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxAddOutput { + /// The channel ID. + pub channel_id: ChannelId, + /// Serial ID for this output. Must be even if sent by the initiator, + /// odd if sent by the non-initiator (BOLT 2 parity rule). + pub serial_id: u64, + /// The value of this output in satoshis. + pub sats: u64, + /// The `scriptPubKey` for the output. + pub script: Vec, +} + +impl TxAddOutput { + /// Encodes to wire format (without message type prefix). + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.channel_id.write(&mut out); + self.serial_id.write(&mut out); + self.sats.write(&mut out); + self.script.write(&mut out); + out + } + + /// Decodes from wire format (without message type prefix). + /// + /// # Errors + /// + /// Returns `Truncated` if the payload is too short for any field. + pub fn decode(payload: &[u8]) -> Result { + let mut cursor = payload; + let channel_id = ChannelId::read(&mut cursor)?; + let serial_id = u64::read(&mut cursor)?; + let sats = u64::read(&mut cursor)?; + let script = Vec::::read(&mut cursor)?; + + Ok(Self { + channel_id, + serial_id, + sats, + script, + }) + } +} + +#[cfg(test)] +mod tests { + use super::super::CHANNEL_ID_SIZE; + use super::*; + + /// P2WPKH `scriptPubKey` from the BOLT 3 dual-funding test vectors. + const P2WPKH_SCRIPT: [u8; 22] = [ + 0x00, 0x14, 0x1c, 0xa1, 0xcc, 0xa8, 0x85, 0x5b, 0xad, 0x6b, 0xc1, 0xea, 0x54, 0x36, 0xed, + 0xd8, 0xcf, 0xf1, 0x0b, 0x7e, 0x44, 0x8b, + ]; + + fn sample_msg() -> TxAddOutput { + TxAddOutput { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + serial_id: 30, + sats: 49_999_845, + script: P2WPKH_SCRIPT.to_vec(), + } + } + + #[test] + fn encode_field_sizes() { + let encoded = sample_msg().encode(); + // channel_id(32) + serial_id(8) + sats(8) + scriptlen(2) + script(22) + assert_eq!(encoded.len(), CHANNEL_ID_SIZE + 8 + 8 + 2 + 22); + assert_eq!( + &encoded[CHANNEL_ID_SIZE + 16..CHANNEL_ID_SIZE + 18], + &[0x00, 0x16] + ); + } + + #[test] + fn roundtrip() { + let original = sample_msg(); + let encoded = original.encode(); + let decoded = TxAddOutput::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn roundtrip_p2wsh_script() { + // 34-byte P2WSH funding output from the BOLT 3 dual-funding vectors. + let mut script = vec![0x00, 0x20]; + script.extend_from_slice(&[0x29; 32]); + let original = TxAddOutput { + serial_id: 44, + sats: 400_000_000, + script, + ..sample_msg() + }; + let encoded = original.encode(); + let decoded = TxAddOutput::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + /// A zero-length script and an out-of-range `sats` are negotiation + /// failures, not decode failures: the codec must round-trip both so that + /// they stay reachable as fuzzing inputs. + #[test] + fn roundtrip_empty_script_and_max_sats() { + let original = TxAddOutput { + sats: u64::MAX, + script: Vec::new(), + ..sample_msg() + }; + let encoded = original.encode(); + let decoded = TxAddOutput::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn decode_ignores_trailing_bytes() { + let original = sample_msg(); + let mut encoded = original.encode(); + encoded.extend_from_slice(&[0xff; 4]); + assert_eq!(TxAddOutput::decode(&encoded).unwrap(), original); + } + + #[test] + fn decode_truncated_channel_id() { + assert_eq!( + TxAddOutput::decode(&[0x00; 20]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 20 + }) + ); + } + + #[test] + fn decode_truncated_serial_id() { + assert_eq!( + TxAddOutput::decode(&[0x00; CHANNEL_ID_SIZE + 4]), + Err(BoltError::Truncated { + expected: 8, + actual: 4 + }) + ); + } + + #[test] + fn decode_truncated_sats() { + assert_eq!( + TxAddOutput::decode(&[0x00; CHANNEL_ID_SIZE + 8 + 3]), + Err(BoltError::Truncated { + expected: 8, + actual: 3 + }) + ); + } + + #[test] + fn decode_truncated_scriptlen() { + assert_eq!( + TxAddOutput::decode(&[0x00; CHANNEL_ID_SIZE + 8 + 8 + 1]), + Err(BoltError::Truncated { + expected: 2, + actual: 1 + }) + ); + } + + #[test] + fn decode_truncated_script() { + let mut payload = vec![0x00u8; CHANNEL_ID_SIZE + 8 + 8]; + payload.extend_from_slice(&[0x00, 0x16]); // declare 22 bytes + payload.extend_from_slice(&[0x00; 5]); // only 5 provided + assert_eq!( + TxAddOutput::decode(&payload), + Err(BoltError::Truncated { + expected: 22, + actual: 5 + }) + ); + } + + #[test] + fn decode_empty() { + assert_eq!( + TxAddOutput::decode(&[]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 0 + }) + ); + } +} diff --git a/smite/src/bolt/tx_signatures.rs b/smite/src/bolt/tx_signatures.rs new file mode 100644 index 00000000..287401d3 --- /dev/null +++ b/smite/src/bolt/tx_signatures.rs @@ -0,0 +1,390 @@ +//! BOLT 2 `tx_signatures` message. + +use bitcoin::Txid; +use bitcoin::secp256k1::ecdsa::Signature; + +use super::BoltError; +use super::tlv::TlvStream; +use super::types::ChannelId; +use super::wire::WireFormat; + +/// TLV type for the shared input signature. +const TLV_SHARED_INPUT_SIGNATURE: u64 = 0; + +/// BOLT 2 `tx_signatures` message (type 71). +/// +/// Sent once interactive transaction construction has completed, carrying the +/// sender's witnesses for the inputs it contributed to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxSignatures { + /// The channel this message pertains to. + pub channel_id: ChannelId, + /// Transaction ID of the shared transaction being signed. + pub txid: Txid, + /// One entry per input added by the sender, ordered by that input's + /// `serial_id`. + /// + /// Each entry is bitcoin-wire-encoded witness data: a `CompactSize` + /// element count, then each element as a `CompactSize` length followed by + /// that many bytes. + pub witnesses: Vec>, + /// Optional TLV extensions. + pub tlvs: TxSignaturesTlvs, +} + +/// TLV extensions for the `tx_signatures` message. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TxSignaturesTlvs { + /// Signature for the shared input, when one is being spent (splicing). + pub shared_input_signature: Option, +} + +impl TxSignaturesTlvs { + /// Extracts TLVs from a parsed TLV stream. + /// + /// # Errors + /// + /// Returns a `BoltError` if `shared_input_signature` has invalid length or + /// is not a canonical compact ECDSA signature. + fn from_stream(stream: &TlvStream) -> Result { + let shared_input_signature = stream.get_as::(TLV_SHARED_INPUT_SIGNATURE)?; + Ok(Self { + shared_input_signature, + }) + } +} + +impl TxSignatures { + /// Encodes to wire format (without message type prefix). + /// + /// # Panics + /// + /// Panics if the number of witnesses exceeds `u16::MAX`. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.channel_id.write(&mut out); + self.txid.write(&mut out); + u16::try_from(self.witnesses.len()) + .expect("number of witnesses must not exceed u16::MAX") + .write(&mut out); + for witness in &self.witnesses { + witness.write(&mut out); + } + + let mut tlv_stream = TlvStream::new(); + if let Some(signature) = &self.tlvs.shared_input_signature { + tlv_stream.add( + TLV_SHARED_INPUT_SIGNATURE, + signature.serialize_compact().to_vec(), + ); + } + out.extend(tlv_stream.encode()); + + out + } + + /// Decodes from wire format (without message type prefix). + /// + /// # Errors + /// + /// Returns `Truncated` if the payload is too short for any field, + /// `InvalidSignature` if `shared_input_signature` is not a valid compact + /// ECDSA signature, or a TLV error if the TLV stream is malformed. + pub fn decode(payload: &[u8]) -> Result { + let mut cursor = payload; + + let channel_id = WireFormat::read(&mut cursor)?; + let txid = WireFormat::read(&mut cursor)?; + let num_witnesses = u16::read(&mut cursor)?; + let mut witnesses = Vec::with_capacity(num_witnesses.into()); + for _ in 0..num_witnesses { + witnesses.push(Vec::::read(&mut cursor)?); + } + + let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_SHARED_INPUT_SIGNATURE])?; + let tlvs = TxSignaturesTlvs::from_stream(&tlv_stream)?; + + Ok(Self { + channel_id, + txid, + witnesses, + tlvs, + }) + } +} + +#[cfg(test)] +mod tests { + use super::super::{CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, TXID_SIZE}; + use super::*; + use bitcoin::secp256k1::hashes::Hash; + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + + /// Offset of `num_witnesses` within the encoded payload. + const NUM_WITNESSES_OFFSET: usize = CHANNEL_ID_SIZE + TXID_SIZE; + + fn sample_msg() -> TxSignatures { + TxSignatures { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + txid: Txid::from_byte_array([0xcd; TXID_SIZE]), + witnesses: vec![vec![0xde, 0xad, 0xbe, 0xef], vec![0x01, 0x02]], + tlvs: TxSignaturesTlvs::default(), + } + } + + fn sample_signature() -> Signature { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x11; 32]).unwrap(); + let msg = Message::from_digest([0xaa; 32]); + secp.sign_ecdsa(&msg, &sk) + } + + /// Dual-funding test vectors from BOLT 3, "Appendix G: Dual Funded + /// Transaction Test Vectors". `channel_id` is unspecified there, so the + /// sample value is used. `txid` is given in display order; the wire + /// encoding is its reverse, which is what `Txid` writes. + #[test] + fn encode_bolt3_dual_funding_vectors() { + const TXID_DISPLAY: &str = + "5ca4e657c1aa9d069ea4a5d712045d233a7d7c52738cb02993637289e6386057"; + let opener_witness = "022068656c6c6f2074686572652c2074686973206973206120626974636f6e21212127\ + 82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87"; + let accepter_witness = "0247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e90220\ + 69e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b786\ + 4c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"; + + let mut txid_bytes: [u8; TXID_SIZE] = + hex::decode(TXID_DISPLAY).unwrap().try_into().unwrap(); + txid_bytes.reverse(); + let txid = Txid::from_byte_array(txid_bytes); + assert_eq!(txid.to_string(), TXID_DISPLAY); + + // (witness hex, declared `len`, expected payload hex) + let cases = [ + ( + opener_witness, + 74, + "abababababababababababababababababababababababababababababababab\ + 576038e68972639329b08c73527c7d3a235d0412d7a5a49e069daac157e6a45c0001004a", + ), + ( + accepter_witness, + 107, + "abababababababababababababababababababababababababababababababab\ + 576038e68972639329b08c73527c7d3a235d0412d7a5a49e069daac157e6a45c0001006b", + ), + ]; + + for (witness_hex, len, prefix_hex) in cases { + let witness = hex::decode(witness_hex).unwrap(); + assert_eq!(witness.len(), len, "witness `len` field"); + + let msg = TxSignatures { + channel_id: ChannelId::new([0xab; CHANNEL_ID_SIZE]), + txid, + witnesses: vec![witness], + tlvs: TxSignaturesTlvs::default(), + }; + + let encoded = msg.encode(); + assert_eq!(hex::encode(&encoded), prefix_hex.to_owned() + witness_hex); + assert_eq!(TxSignatures::decode(&encoded).unwrap(), msg); + } + } + + #[test] + fn roundtrip() { + let original = sample_msg(); + let encoded = original.encode(); + // channel_id(32) + txid(32) + num_witnesses(2) + (2+4) + (2+2) = 76 + assert_eq!(encoded.len(), 76); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn roundtrip_with_shared_input_signature() { + let mut original = sample_msg(); + original.tlvs.shared_input_signature = Some(sample_signature()); + let encoded = original.encode(); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + /// A witness count of zero is a negotiation failure, not a parse failure. + #[test] + fn roundtrip_zero_witnesses() { + let original = TxSignatures { + witnesses: vec![], + ..sample_msg() + }; + let encoded = original.encode(); + assert_eq!(encoded.len(), NUM_WITNESSES_OFFSET + 2); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + /// An empty witness is a negotiation failure, not a parse failure. + #[test] + fn roundtrip_empty_witness() { + let original = TxSignatures { + witnesses: vec![vec![]], + ..sample_msg() + }; + let encoded = original.encode(); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(decoded.witnesses, vec![Vec::::new()]); + assert_eq!(original, decoded); + } + + #[test] + #[should_panic(expected = "number of witnesses must not exceed u16::MAX")] + fn encode_panics_on_oversized_witnesses() { + let msg = TxSignatures { + witnesses: vec![vec![0x00]; usize::from(u16::MAX) + 1], + ..sample_msg() + }; + let _ = msg.encode(); + } + + #[test] + fn decode_empty() { + assert_eq!( + TxSignatures::decode(&[]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 0, + }) + ); + } + + #[test] + fn decode_truncated_channel_id() { + assert_eq!( + TxSignatures::decode(&[0x00; 5]), + Err(BoltError::Truncated { + expected: CHANNEL_ID_SIZE, + actual: 5, + }) + ); + } + + #[test] + fn decode_truncated_txid() { + assert_eq!( + TxSignatures::decode(&[0x00; CHANNEL_ID_SIZE + 20]), + Err(BoltError::Truncated { + expected: TXID_SIZE, + actual: 20, + }) + ); + } + + #[test] + fn decode_truncated_num_witnesses() { + assert_eq!( + TxSignatures::decode(&[0x00; NUM_WITNESSES_OFFSET + 1]), + Err(BoltError::Truncated { + expected: 2, + actual: 1, + }) + ); + } + + #[test] + fn decode_truncated_witness_len() { + // num_witnesses = 1, then a single byte of the 2-byte witness length. + let mut payload = vec![0x00u8; NUM_WITNESSES_OFFSET]; + payload.extend_from_slice(&[0x00, 0x01]); + payload.push(0x00); + assert_eq!( + TxSignatures::decode(&payload), + Err(BoltError::Truncated { + expected: 2, + actual: 1, + }) + ); + } + + #[test] + fn decode_truncated_witness_data() { + // num_witnesses = 1, witness declares 10 bytes but only 3 are present. + let mut payload = vec![0x00u8; NUM_WITNESSES_OFFSET]; + payload.extend_from_slice(&[0x00, 0x01]); + payload.extend_from_slice(&[0x00, 0x0a]); + payload.extend_from_slice(&[0x00; 3]); + assert_eq!( + TxSignatures::decode(&payload), + Err(BoltError::Truncated { + expected: 10, + actual: 3, + }) + ); + } + + #[test] + fn decode_missing_witness() { + // num_witnesses claims 2, but only the first witness is present. + let encoded = sample_msg().encode(); + let cutoff = NUM_WITNESSES_OFFSET + 2 + 2 + 4; + assert_eq!( + TxSignatures::decode(&encoded[..cutoff]), + Err(BoltError::Truncated { + expected: 2, + actual: 0, + }) + ); + } + + #[test] + fn decode_unknown_odd_tlv_ignored() { + let original = sample_msg(); + let mut encoded = original.encode(); + // Append unknown odd TLV: type 3, length 2, value [0xaa, 0xbb] + encoded.extend_from_slice(&[0x03, 0x02, 0xaa, 0xbb]); + let decoded = TxSignatures::decode(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn decode_unknown_even_tlv_rejected() { + let mut encoded = sample_msg().encode(); + // Append unknown even TLV: type 2, length 1, value [0xff] + encoded.extend_from_slice(&[0x02, 0x01, 0xff]); + assert_eq!( + TxSignatures::decode(&encoded), + Err(BoltError::TlvUnknownEvenType(2)) + ); + } + + #[test] + fn decode_wrong_length_shared_input_signature() { + let mut encoded = sample_msg().encode(); + // Append TLV type 0 with only 32 bytes instead of 64. + encoded.push(0x00); // type 0 + encoded.push(0x20); // length 32 + encoded.extend_from_slice(&[0xaa; 32]); + assert_eq!( + TxSignatures::decode(&encoded), + Err(BoltError::Truncated { + expected: COMPACT_SIGNATURE_SIZE, + actual: 32, + }) + ); + } + + #[test] + fn decode_invalid_shared_input_signature() { + let mut encoded = sample_msg().encode(); + // r and s are both above the curve order. + let bad_sig = [0xff; COMPACT_SIGNATURE_SIZE]; + encoded.push(0x00); // type 0 + encoded.push(0x40); // length 64 + encoded.extend_from_slice(&bad_sig); + assert_eq!( + TxSignatures::decode(&encoded), + Err(BoltError::InvalidSignature(bad_sig)) + ); + } +} diff --git a/smite/src/bolt/types.rs b/smite/src/bolt/types.rs index c13925fc..c7fdda69 100644 --- a/smite/src/bolt/types.rs +++ b/smite/src/bolt/types.rs @@ -1,8 +1,9 @@ //! Fundamental types for BOLT message encoding. use bitcoin::OutPoint; -use bitcoin::hashes::Hash; +use bitcoin::hashes::{Hash, sha256}; use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::PublicKey; use std::fmt; /// Maximum Lightning message size (2-byte length prefix limit). @@ -71,6 +72,47 @@ impl ChannelId { res[31] ^= (outpoint.vout & 0xff) as u8; Self(res) } + + /// Creates a _v2_ channel ID from both peers' revocation basepoints. + /// + /// Per BOLT 2, this is + /// `SHA256(lesser-revocation-basepoint || greater-revocation-basepoint)`, + /// ordered by the compressed serialization of the two points. + #[must_use] + pub fn v2_from_revocation_basepoints(basepoint1: &PublicKey, basepoint2: &PublicKey) -> Self { + Self::v2_from_serialized_basepoints(basepoint1.serialize(), basepoint2.serialize()) + } + + /// Creates a _v2_ `temporary_channel_id` from the opener's revocation + /// basepoint. + /// + /// When `open_channel2` is sent the peer's revocation basepoint is not yet + /// known, so BOLT 2 requires a zeroed basepoint to stand in for the + /// non-initiator. An all-zero point sorts lexicographically below every + /// valid compressed public key, so it is always the first half of the + /// digest. + #[must_use] + pub fn v2_temporary_from_revocation_basepoint( + opener_basepoint: &PublicKey, + ) -> TemporaryChannelId { + Self::v2_from_serialized_basepoints([0u8; PUBLIC_KEY_SIZE], opener_basepoint.serialize()) + } + + /// Hashes two already-serialized basepoints in BOLT 2's canonical order. + fn v2_from_serialized_basepoints( + basepoint1: [u8; PUBLIC_KEY_SIZE], + basepoint2: [u8; PUBLIC_KEY_SIZE], + ) -> Self { + let (lesser, greater) = if basepoint1 <= basepoint2 { + (basepoint1, basepoint2) + } else { + (basepoint2, basepoint1) + }; + let mut preimage = [0u8; PUBLIC_KEY_SIZE * 2]; + preimage[..PUBLIC_KEY_SIZE].copy_from_slice(&lesser); + preimage[PUBLIC_KEY_SIZE..].copy_from_slice(&greater); + Self(sha256::Hash::hash(&preimage).to_byte_array()) + } } impl fmt::Display for ChannelId { @@ -218,6 +260,17 @@ mod tests { use super::*; use bitcoin::{OutPoint, Txid}; + /// The two `funding_pubkey`s of the 2-of-2 output in the BOLT 3 + /// "Appendix G: Dual Funded Transaction Test Vectors". Used here only as a + /// convenient pair of known-valid compressed points. + const BASEPOINT_1: &str = "0292edb5f7bbf9e900f7e024be1c1339c6d149c11930e613af3a983d2565f4e41e"; + const BASEPOINT_2: &str = "02e16172a41e928cbd78f761bd1c657c4afc7495a1244f7f30166b654fbf7661e3"; + + fn pubkey(hex_str: &str) -> PublicKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + PublicKey::from_slice(&bytes).expect("valid pubkey") + } + #[test] fn bigsize_new() { let bs = BigSize::new(42); @@ -308,6 +361,62 @@ mod tests { } } + #[test] + fn channel_id_v2_from_revocation_basepoints_matches_vector() { + let channel_id = + ChannelId::v2_from_revocation_basepoints(&pubkey(BASEPOINT_1), &pubkey(BASEPOINT_2)); + + assert_eq!( + channel_id.to_string(), + "59bc22f722836ce5095a37504f8ab87b1b2dbdc8aad638b77da3f5f3e8330edd", + ); + } + + #[test] + fn channel_id_v2_from_revocation_basepoints_is_order_independent() { + let basepoint_1 = pubkey(BASEPOINT_1); + let basepoint_2 = pubkey(BASEPOINT_2); + + assert_eq!( + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_2), + ChannelId::v2_from_revocation_basepoints(&basepoint_2, &basepoint_1), + ); + } + + #[test] + fn channel_id_v2_temporary_matches_vector() { + assert_eq!( + ChannelId::v2_temporary_from_revocation_basepoint(&pubkey(BASEPOINT_1)).to_string(), + "90fc2d0fcef3376e4c26de47e9c86a7362adecf87c8c1a01cdaa3263abd74c5a", + ); + assert_eq!( + ChannelId::v2_temporary_from_revocation_basepoint(&pubkey(BASEPOINT_2)).to_string(), + "270d4e8fa33e0c15f46c4978186d02c51a5307229ba5689c9f600c68360e25e3", + ); + } + + #[test] + fn channel_id_v2_temporary_differs_from_final() { + let basepoint_1 = pubkey(BASEPOINT_1); + let basepoint_2 = pubkey(BASEPOINT_2); + + assert_ne!( + ChannelId::v2_temporary_from_revocation_basepoint(&basepoint_1), + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_2), + ); + } + + #[test] + fn channel_id_v2_distinguishes_basepoints() { + let basepoint_1 = pubkey(BASEPOINT_1); + let basepoint_2 = pubkey(BASEPOINT_2); + + assert_ne!( + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_1), + ChannelId::v2_from_revocation_basepoints(&basepoint_1, &basepoint_2), + ); + } + #[test] fn short_channel_id_ord_matches_packed_u64() { let a = ShortChannelId::new(100, 0, 0); diff --git a/smite/src/channel_tx.rs b/smite/src/channel_tx.rs index 3e646154..4c8ebfb8 100644 --- a/smite/src/channel_tx.rs +++ b/smite/src/channel_tx.rs @@ -1,13 +1,23 @@ //! BOLT 3 channel transaction construction. //! //! This module builds Lightning channel on-chain transactions: the funding -//! transaction and the commitment transaction. +//! transaction, the commitment transaction, and the shared transaction +//! negotiated by BOLT 2 interactive transaction construction. mod commitment; mod funding; +mod interactive_tx; +mod tx_exchange; pub use commitment::{ ChannelConfig, ChannelPartyConfig, ChannelState, CommitmentCost, CommitmentError, CommitmentPartyState, CommitmentState, HolderIdentity, Side, }; -pub use funding::{FundingTransaction, InsufficientFunds, build_funding_transaction}; +pub use funding::{ + FundingTransaction, InsufficientFunds, build_funding_transaction, build_funding_witness_script, +}; +pub use interactive_tx::{ + Contributor, MAX_INPUTS, MAX_OUTPUTS, MAX_SEQUENCE, SharedInput, SharedOutput, + SharedTransaction, signs_first, +}; +pub use tx_exchange::{Step, TxExchange}; diff --git a/smite/src/channel_tx/funding.rs b/smite/src/channel_tx/funding.rs index 3fb89429..2b5bd9ad 100644 --- a/smite/src/channel_tx/funding.rs +++ b/smite/src/channel_tx/funding.rs @@ -187,6 +187,7 @@ impl FundingTransaction { } /// Builds the funding output witness script per BOLT 3. +#[must_use] pub fn build_funding_witness_script(pubkey1: &PublicKey, pubkey2: &PublicKey) -> ScriptBuf { let key1_bytes = pubkey1.serialize(); let key2_bytes = pubkey2.serialize(); diff --git a/smite/src/channel_tx/interactive_tx.rs b/smite/src/channel_tx/interactive_tx.rs new file mode 100644 index 00000000..93306fa4 --- /dev/null +++ b/smite/src/channel_tx/interactive_tx.rs @@ -0,0 +1,874 @@ +//! BOLT 2 interactive transaction construction. +//! +//! Two peers collaboratively build one transaction by exchanging `tx_add_input` +//! / `tx_add_output` / `tx_remove_input` / `tx_remove_output` messages, each +//! carrying a `serial_id`. [`SharedTransaction`] accumulates those +//! contributions and assembles the transaction both peers must agree on. + +use std::collections::BTreeMap; + +use bitcoin::absolute::LockTime; +use bitcoin::consensus::encode::deserialize; +use bitcoin::hashes::Hash; +use bitcoin::transaction::Version; +use bitcoin::{Amount, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid}; +use bitcoin::{Witness, secp256k1::PublicKey}; + +use super::funding::FundingTransaction; + +/// Maximum inputs in the constructed transaction (BOLT 2). +pub const MAX_INPUTS: usize = 252; + +/// Weight of the transaction fields the initiator alone pays for (BOLT 2): +/// `(input_count + output_count + version + locktime) * 4 + segwit marker and +/// flag`. +const COMMON_FIELDS_WEIGHT: u64 = (1 + 1 + 4 + 4) * 4 + 2; + +/// Weight of one input's non-witness fields: `txid + vout + scriptSig length + +/// sequence`, all outside the witness and so multiplied by four. +const INPUT_WEIGHT: u64 = (32 + 4 + 1 + 4) * 4; + +/// Weight of one output's fixed fields: `value + script length`. +const OUTPUT_BASE_WEIGHT: u64 = (8 + 1) * 4; + +/// Witness weight charged per input we contribute. +/// +/// BOLT 3 Appendix G charges `max(num_inputs * 107, actual witness weight)`, +/// where 107 is the minimum witness weight. Our wallet inputs are P2WPKH, whose +/// witness is `1` element count `+ 1 + sig + 1 + 33` pubkey; Bitcoin Core +/// grinds for a low-R signature, so `sig` is 71 bytes and the actual weight is +/// the same 107 the floor already charges. +/// +/// Charging 108 is a deliberate one-unit overpay, sized for the 72-byte +/// signature Core does not normally produce. It keeps the estimate on the +/// paying side of the requirement: the peer fails the negotiation when our +/// feerate falls short, never when it exceeds. +const WITNESS_WEIGHT_PER_INPUT: u64 = 108; + +/// Maximum outputs in the constructed transaction (BOLT 2). +pub const MAX_OUTPUTS: usize = 252; + +/// Largest `sequence` a `tx_add_input` may carry (BOLT 2): every input must +/// signal replaceability. +pub const MAX_SEQUENCE: u32 = 0xffff_fffd; + +/// Which peer contributed an input or output to the shared transaction. +/// +/// Only [`Contributor::Local`] contributions are ours to sign and to remove. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Contributor { + /// We contributed it. + Local, + /// The peer contributed it. + Remote, +} + +/// An input contributed to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedInput { + /// The outpoint being spent. + pub outpoint: OutPoint, + /// `nSequence` for this input. + pub sequence: u32, + /// Which peer contributed it. + pub contributor: Contributor, + /// The output being spent, when known. Always known for our own inputs; + /// known for the peer's only when its `prevtx` parsed and `prevtx_vout` was + /// within range. + pub prevout: Option, +} + +impl SharedInput { + /// Builds an input from a `tx_add_input`'s serialized previous transaction. + /// + /// A `prevtx` that does not parse, or a `prevtx_vout` past the end of it, + /// yields an all-zero txid and an unknown `prevout` rather than an error: + /// the peer is free to send nonsense, and it is the peer that must then + /// fail the negotiation. + #[must_use] + pub fn from_prevtx( + prevtx: &[u8], + prevtx_vout: u32, + sequence: u32, + contributor: Contributor, + ) -> Self { + let prev: Option = deserialize(prevtx).ok(); + let prevout = prev + .as_ref() + .and_then(|tx| tx.output.get(prevtx_vout as usize)) + .cloned(); + let txid = prev.as_ref().map_or_else( + || Txid::from_byte_array([0u8; 32]), + Transaction::compute_txid, + ); + + Self { + outpoint: OutPoint { + txid, + vout: prevtx_vout, + }, + sequence, + contributor, + prevout, + } + } + + /// Value of the output being spent, or `0` when it is unknown. + #[must_use] + pub fn value(&self) -> u64 { + self.prevout.as_ref().map_or(0, |o| o.value.to_sat()) + } +} + +/// An output contributed to the shared transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedOutput { + /// Output value in satoshis. + pub value: u64, + /// Output `scriptPubKey`. + pub script_pubkey: ScriptBuf, + /// Which peer contributed it. + pub contributor: Contributor, +} + +/// The transaction being built by an interactive construction session. +/// +/// Contributions are keyed by `serial_id`, so iteration is already in the +/// ascending order BOLT 2 requires for the assembled transaction. A repeated +/// `serial_id` replaces the previous entry, mirroring what a peer that failed +/// to enforce uniqueness would end up with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedTransaction { + /// `nLockTime` of the transaction, from `open_channel2`. + pub locktime: u32, + inputs: BTreeMap, + outputs: BTreeMap, +} + +impl SharedTransaction { + /// Creates an empty session for a transaction with the given `nLockTime`. + #[must_use] + pub fn new(locktime: u32) -> Self { + Self { + locktime, + inputs: BTreeMap::new(), + outputs: BTreeMap::new(), + } + } + + /// Adds or replaces the input with `serial_id`. + /// + /// Returns `false` when the transaction already holds [`MAX_INPUTS`] + /// distinct serial ids and the input was dropped. + pub fn add_input(&mut self, serial_id: u64, input: SharedInput) -> bool { + if !self.inputs.contains_key(&serial_id) && self.inputs.len() >= MAX_INPUTS { + return false; + } + self.inputs.insert(serial_id, input); + true + } + + /// Adds or replaces the output with `serial_id`. + /// + /// Returns `false` when the transaction already holds [`MAX_OUTPUTS`] + /// distinct serial ids and the output was dropped. + pub fn add_output(&mut self, serial_id: u64, output: SharedOutput) -> bool { + if !self.outputs.contains_key(&serial_id) && self.outputs.len() >= MAX_OUTPUTS { + return false; + } + self.outputs.insert(serial_id, output); + true + } + + /// Removes the input with `serial_id` on behalf of `contributor`, + /// returning it when `contributor` had added it. + /// + /// BOLT 2 forbids removing what the other peer added, and has the + /// receiver fail the negotiation if it happens. Whoever sent such a + /// removal, the other side keeps the entry, so we keep it too and return + /// `None`. + pub fn remove_input( + &mut self, + serial_id: u64, + contributor: Contributor, + ) -> Option { + if self.inputs.get(&serial_id)?.contributor != contributor { + return None; + } + self.inputs.remove(&serial_id) + } + + /// Output sibling of [`Self::remove_input`]. + pub fn remove_output( + &mut self, + serial_id: u64, + contributor: Contributor, + ) -> Option { + if self.outputs.get(&serial_id)?.contributor != contributor { + return None; + } + self.outputs.remove(&serial_id) + } + + /// Puts our own contributions back to how they stood in `snapshot`, + /// leaving the peer's untouched. + /// + /// Used when the exchange turns out to have concluded before contributions + /// we had already sent: the peer never took them, so neither may we. The + /// peer's contributions that arrived since the snapshot did precede the + /// conclusion, so they stay. + pub(super) fn restore_local(&mut self, snapshot: &Self) { + self.inputs + .retain(|_, input| input.contributor == Contributor::Remote); + self.inputs.extend( + snapshot + .inputs + .iter() + .filter(|(_, input)| input.contributor == Contributor::Local) + .map(|(id, input)| (*id, input.clone())), + ); + self.outputs + .retain(|_, output| output.contributor == Contributor::Remote); + self.outputs.extend( + snapshot + .outputs + .iter() + .filter(|(_, output)| output.contributor == Contributor::Local) + .map(|(id, output)| (*id, output.clone())), + ); + } + + /// Inputs in ascending `serial_id` order. + pub fn inputs(&self) -> impl Iterator { + self.inputs.iter().map(|(id, input)| (*id, input)) + } + + /// Outputs in ascending `serial_id` order. + pub fn outputs(&self) -> impl Iterator { + self.outputs.iter().map(|(id, output)| (*id, output)) + } + + /// Positions in the assembled transaction of the inputs `contributor` + /// contributed. + /// + /// [`Self::build`] emits inputs in ascending `serial_id` order, so these + /// are also the positions BOLT 2's "order the `witnesses` by the + /// `serial_id` of the input they correspond to" maps a `tx_signatures`'s + /// witnesses onto, in either direction. + #[must_use] + pub fn input_positions(&self, contributor: Contributor) -> Vec { + self.inputs + .values() + .enumerate() + .filter(|(_, input)| input.contributor == contributor) + .map(|(position, _)| position) + .collect() + } + + /// Total value of the inputs contributed by `contributor`, saturating. + /// + /// Inputs whose `prevout` is unknown count as zero. + #[must_use] + pub fn contributed_input_value(&self, contributor: Contributor) -> u64 { + self.inputs + .values() + .filter(|i| i.contributor == contributor) + .fold(0u64, |acc, i| acc.saturating_add(i.value())) + } + + /// Fee we are responsible for at `feerate_per_kw`, in satoshis, **as the + /// initiator**. + /// + /// BOLT 2 splits fee responsibility: the initiator pays for the common + /// transaction fields, and each peer pays for the inputs and outputs it + /// contributed. This unconditionally charges both halves, which is correct + /// only while we are the initiator -- true for every caller today, since we + /// reach interactive construction by sending `open_channel2`. It stops + /// being true if `tx_init_rbf` is ever implemented, since an accepter that + /// initiates an RBF attempt becomes the initiator and takes the common + /// fields with it; splitting the two halves is the change to make then. + /// + /// `pending_output_script_lens` covers outputs we are about to add but have + /// not added yet, which is what makes a change output's value computable + /// before it exists. + /// + /// Rounds up. BOLT 3 Appendix G's worked example has weight 609 at 253 + /// sat/kw and states a fee of 155, not the 154 that truncating would give; + /// underpaying by a single satoshi makes the peer fail the negotiation. + #[must_use] + pub fn local_fee_sat(&self, feerate_per_kw: u32, pending_output_script_lens: &[usize]) -> u64 { + let local_inputs = self + .inputs + .values() + .filter(|i| i.contributor == Contributor::Local) + .count() as u64; + + let output_weight = self + .outputs + .values() + .filter(|o| o.contributor == Contributor::Local) + .map(|o| o.script_pubkey.len() as u64) + .chain(pending_output_script_lens.iter().map(|len| *len as u64)) + .map(|script_len| OUTPUT_BASE_WEIGHT + script_len * 4) + .sum::(); + + let weight = COMMON_FIELDS_WEIGHT + + local_inputs * INPUT_WEIGHT + + output_weight + + local_inputs * WITNESS_WEIGHT_PER_INPUT; + + weight + .saturating_mul(u64::from(feerate_per_kw)) + .div_ceil(1000) + } + + /// Assembles the transaction both peers must agree on. + /// + /// Per BOLT 2 the inputs and outputs are sorted by ascending `serial_id`; + /// `nVersion` is 2 and `nLockTime` comes from `open_channel2`. + #[must_use] + pub fn build(&self) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(self.locktime), + input: self + .inputs + .values() + .map(|i| TxIn { + previous_output: i.outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence(i.sequence), + witness: Witness::new(), + }) + .collect(), + output: self + .outputs + .values() + .map(|o| TxOut { + value: Amount::from_sat(o.value), + script_pubkey: o.script_pubkey.clone(), + }) + .collect(), + } + } + + /// Index of the channel funding output, identified by its script and value. + #[must_use] + pub fn funding_vout(&self, funding_script: &Script, funding_satoshis: u64) -> Option { + let index = self + .outputs + .values() + .position(|o| o.script_pubkey == *funding_script && o.value == funding_satoshis)?; + u32::try_from(index).ok() + } + + /// Assembles the transaction and locates its funding output. + #[must_use] + pub fn build_funding( + &self, + funding_script: &Script, + funding_satoshis: u64, + ) -> FundingTransaction { + FundingTransaction { + tx: self.build(), + vout: self + .funding_vout(funding_script, funding_satoshis) + .unwrap_or(0), + } + } +} + +/// Returns whether we must send `tx_signatures` first. +/// +/// Per BOLT 2 the peer contributing the lowest total input value signs first, +/// with the lexicographically lower `node_id` breaking a tie. The strict +/// ordering is what stops both peers waiting on each other. +#[must_use] +pub fn signs_first( + local_input_value: u64, + remote_input_value: u64, + local_node_id: &PublicKey, + remote_node_id: &PublicKey, +) -> bool { + match local_input_value.cmp(&remote_input_value) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Greater => false, + std::cmp::Ordering::Equal => local_node_id.serialize() < remote_node_id.serialize(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The transaction whose outputs both peers spend in the BOLT 3 + /// "Appendix G: Dual Funded Transaction Test Vectors". + const APPENDIX_G_PREVTX: &str = "02000000000101f86fd1d0db3ac5a72df968622f31e6b5e6566a09e2920\ +6d7c7a55df90e181de800000000171600141fb9623ffd0d422eacc450fd1e967efc477b83ccffffffff0580b2e60e0000\ +0000220020fd89acf65485df89797d9ba7ba7a33624ac4452f00db08107f34257d33e5b94680b2e60e0000000017a9146\ +a235d064786b49e7043e4a042d4cc429f7eb6948780b2e60e00000000160014fbb4db9d85fba5e301f4399e3038928e44\ +e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee0000000017a914f856a\ +70093da3a5b5c4302ade033d4c2171705d387024730440220696f6cee2929f1feb3fd6adf024ca0f9aa2f4920ed6d35fb\ +9ec5b78c8408475302201641afae11242160101c6f9932aeb4fcd1f13a9c6df5d1386def000ea259a35001210381d7d5b\ +1bc0d7600565d827242576d9cb793bfe0754334af82289ee8b65d137600000000"; + + /// The `Unsigned Funding Transaction` of BOLT 3 Appendix G. + const APPENDIX_G_UNSIGNED_TX: &str = "0200000002b932b0669cd0394d0d5bcc27e01ab8c511f1662a679992\ +5b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca0\ +3430000000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000\ +000016001444cb0c39f93ecc372b5851725bd29d865d333b100084d71700000000220020297b92c238163e820b8248608\ +4634b4846b86a3c658d87b9384192e6bea98ec578000000"; + + /// The 2-of-2 funding `scriptPubKey` of Appendix G. + const APPENDIX_G_FUNDING_SPK: &str = + "0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5"; + /// Appendix G's opener change `scriptPubKey`. + const APPENDIX_G_OPENER_CHANGE_SPK: &str = "00141ca1cca8855bad6bc1ea5436edd8cff10b7e448b"; + /// Appendix G's accepter change `scriptPubKey`. + const APPENDIX_G_ACCEPTER_CHANGE_SPK: &str = "001444cb0c39f93ecc372b5851725bd29d865d333b10"; + + /// Appendix G's `nLockTime`. + const APPENDIX_G_LOCKTIME: u32 = 120; + /// Appendix G's funding output value: 2 x 2,000,000,000 sat. + const APPENDIX_G_FUNDING_SATS: u64 = 400_000_000; + + fn script(hex_str: &str) -> ScriptBuf { + ScriptBuf::from(hex::decode(hex_str).expect("valid hex")) + } + + fn pubkey(hex_str: &str) -> PublicKey { + PublicKey::from_slice(&hex::decode(hex_str).expect("valid hex")).expect("valid pubkey") + } + + /// Rebuilds Appendix G's funding transaction from the `tx_add_input` and + /// `tx_add_output` messages the appendix says each peer sends. Note that + /// the contributions are added out of serial order on purpose. + fn appendix_g() -> SharedTransaction { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(APPENDIX_G_LOCKTIME); + + // Opener's input, serial_id 20, spending the parent's output 0. + assert!(shared.add_input( + 20, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + )); + // Accepter's input, serial_id 11, spending the parent's output 2. + assert!(shared.add_input( + 11, + SharedInput::from_prevtx(&prevtx, 2, MAX_SEQUENCE, Contributor::Remote), + )); + + // Opener's change, serial_id 30. + assert!(shared.add_output( + 30, + SharedOutput { + value: 49_999_845, + script_pubkey: script(APPENDIX_G_OPENER_CHANGE_SPK), + contributor: Contributor::Local, + }, + )); + // Opener's funding output, serial_id 44. + assert!(shared.add_output( + 44, + SharedOutput { + value: APPENDIX_G_FUNDING_SATS, + script_pubkey: script(APPENDIX_G_FUNDING_SPK), + contributor: Contributor::Local, + }, + )); + // Accepter's change, serial_id 33. + assert!(shared.add_output( + 33, + SharedOutput { + value: 49_999_900, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }, + )); + + shared + } + + #[test] + fn build_matches_bolt3_appendix_g() { + let tx = appendix_g().build(); + + assert_eq!( + bitcoin::consensus::encode::serialize_hex(&tx), + APPENDIX_G_UNSIGNED_TX, + ); + } + + #[test] + fn build_sorts_by_serial_id_not_insertion_order() { + let tx = appendix_g().build(); + + // Inputs: serial 11 (parent vout 2) before serial 20 (parent vout 0), + // even though serial 20 was added first. + assert_eq!( + tx.input + .iter() + .map(|i| i.previous_output.vout) + .collect::>(), + vec![2, 0], + ); + // Outputs: serials 30, 33, 44, even though 44 was added before 33. + assert_eq!( + tx.output + .iter() + .map(|o| o.value.to_sat()) + .collect::>(), + vec![49_999_845, 49_999_900, APPENDIX_G_FUNDING_SATS], + ); + } + + #[test] + fn build_uses_version_two_and_negotiated_locktime() { + let tx = appendix_g().build(); + + assert_eq!(tx.version, Version::TWO); + assert_eq!(tx.lock_time, LockTime::from_consensus(APPENDIX_G_LOCKTIME)); + assert!( + tx.input + .iter() + .all(|i| i.sequence == Sequence(MAX_SEQUENCE)) + ); + } + + #[test] + fn funding_vout_locates_the_two_of_two_output() { + let shared = appendix_g(); + + assert_eq!( + shared.funding_vout(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS), + Some(2), + ); + assert_eq!( + shared + .build_funding(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS) + .vout, + 2, + ); + } + + #[test] + fn funding_vout_rejects_a_wrong_value_or_script() { + let shared = appendix_g(); + + assert_eq!( + shared.funding_vout(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS - 1), + None, + ); + assert_eq!( + shared.funding_vout( + &script(APPENDIX_G_OPENER_CHANGE_SPK), + APPENDIX_G_FUNDING_SATS + ), + None, + ); + } + + #[test] + fn build_funding_falls_back_to_vout_zero_without_a_funding_output() { + let mut shared = appendix_g(); + shared.remove_output(44, Contributor::Local); + + let funding = + shared.build_funding(&script(APPENDIX_G_FUNDING_SPK), APPENDIX_G_FUNDING_SATS); + + assert_eq!(funding.vout, 0); + } + + #[test] + fn contributed_input_value_splits_by_contributor() { + let shared = appendix_g(); + + // Each peer spends one 2.5 BTC output of the parent transaction. + assert_eq!( + shared.contributed_input_value(Contributor::Local), + 250_000_000 + ); + assert_eq!( + shared.contributed_input_value(Contributor::Remote), + 250_000_000 + ); + } + + #[test] + fn input_positions_follow_serial_order_not_insertion_order() { + let shared = appendix_g(); + + // Serial 11 is the accepter's and was added second, but sorts first. + assert_eq!(shared.input_positions(Contributor::Remote), vec![0]); + assert_eq!(shared.input_positions(Contributor::Local), vec![1]); + } + + #[test] + fn input_positions_is_empty_without_contributions() { + assert!( + SharedTransaction::new(0) + .input_positions(Contributor::Local) + .is_empty() + ); + } + + #[test] + fn from_prevtx_with_unparsable_prevtx_is_not_an_error() { + let input = SharedInput::from_prevtx(&[0xde, 0xad], 0, MAX_SEQUENCE, Contributor::Remote); + + assert_eq!(input.outpoint.txid, Txid::from_byte_array([0u8; 32])); + assert_eq!(input.prevout, None); + assert_eq!(input.value(), 0); + } + + #[test] + fn from_prevtx_with_out_of_range_vout_has_no_prevout() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + + let input = SharedInput::from_prevtx(&prevtx, 99, MAX_SEQUENCE, Contributor::Remote); + + // The txid is still known, so the outpoint is well-formed and the peer + // is the one that must fail the negotiation. + assert_ne!(input.outpoint.txid, Txid::from_byte_array([0u8; 32])); + assert_eq!(input.outpoint.vout, 99); + assert_eq!(input.prevout, None); + assert_eq!(input.value(), 0); + } + + #[test] + fn add_replaces_a_duplicate_serial_id() { + let mut shared = appendix_g(); + let outputs_before = shared.outputs().count(); + + assert!(shared.add_output( + 30, + SharedOutput { + value: 1, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }, + )); + + assert_eq!(shared.outputs().count(), outputs_before); + assert_eq!(shared.build().output[0].value.to_sat(), 1); + } + + #[test] + fn add_input_stops_at_the_maximum() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + for serial_id in 0..MAX_INPUTS as u64 { + assert!(shared.add_input( + serial_id, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Remote), + )); + } + + // A new serial id is dropped, but replacing an existing one still works. + assert!(!shared.add_input( + MAX_INPUTS as u64, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Remote), + )); + assert!(shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 1, MAX_SEQUENCE, Contributor::Remote), + )); + assert_eq!(shared.inputs().count(), MAX_INPUTS); + } + + #[test] + fn restore_local_rolls_back_only_our_contributions() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let output = |contributor| SharedOutput { + value: 1000, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor, + }; + let mut shared = SharedTransaction::new(0); + shared.add_input( + 2, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + shared.add_output(2000, output(Contributor::Local)); + let snapshot = shared.clone(); + + // The peer's reply lands after the snapshot; ours is sent after it. + shared.add_input( + 1, + SharedInput::from_prevtx(&prevtx, 1, MAX_SEQUENCE, Contributor::Remote), + ); + shared.remove_output(2000, Contributor::Local); + shared.add_output(2002, output(Contributor::Local)); + + shared.restore_local(&snapshot); + + let inputs: Vec = shared.inputs().map(|(id, _)| id).collect(); + assert_eq!(inputs, vec![1, 2]); + let outputs: Vec = shared.outputs().map(|(id, _)| id).collect(); + assert_eq!(outputs, vec![2000]); + } + + #[test] + fn add_output_stops_at_the_maximum() { + let mut shared = SharedTransaction::new(0); + let output = SharedOutput { + value: 1000, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }; + for serial_id in 0..MAX_OUTPUTS as u64 { + assert!(shared.add_output(serial_id, output.clone())); + } + + assert!(!shared.add_output(MAX_OUTPUTS as u64, output.clone())); + assert!(shared.add_output(0, output)); + assert_eq!(shared.outputs().count(), MAX_OUTPUTS); + } + + #[test] + fn remove_only_takes_the_contributors_own_entries() { + let mut shared = appendix_g(); + + assert!(shared.remove_input(20, Contributor::Remote).is_none()); + assert!(shared.remove_input(20, Contributor::Local).is_some()); + assert!(shared.remove_input(20, Contributor::Local).is_none()); + assert!(shared.remove_output(44, Contributor::Remote).is_none()); + assert!(shared.remove_output(44, Contributor::Local).is_some()); + assert!(shared.remove_output(9999, Contributor::Local).is_none()); + } + + // -- Fee responsibility -- + + #[test] + fn local_fee_matches_bolt3_appendix_g_opener() { + // Appendix G's opener contributes one input, the funding output and a + // change output, at 253 sat/kw, and owes 155 sat. + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(APPENDIX_G_LOCKTIME); + shared.add_input( + 20, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + shared.add_output( + 44, + SharedOutput { + value: APPENDIX_G_FUNDING_SATS, + script_pubkey: script(APPENDIX_G_FUNDING_SPK), + contributor: Contributor::Local, + }, + ); + + // The change output is not added yet; its script length is what makes + // its own value computable. + let change_script = script(APPENDIX_G_OPENER_CHANGE_SPK); + assert_eq!(shared.local_fee_sat(253, &[change_script.len()]), 155); + } + + #[test] + fn local_fee_rounds_up() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + + // Weight 42 + 164 + 108 = 314. At 1 sat/kw that is 0.314 sat, which + // must round up to 1 rather than down to 0. + assert_eq!(shared.local_fee_sat(1, &[]), 1); + // And 314 * 1000 / 1000 divides exactly. + assert_eq!(shared.local_fee_sat(1000, &[]), 314); + } + + #[test] + fn local_fee_ignores_the_peers_contributions() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + let ours = shared.local_fee_sat(253, &[]); + + // Each peer pays for what it contributed, so adding theirs must not + // change what we owe. + shared.add_input( + 1, + SharedInput::from_prevtx(&prevtx, 1, MAX_SEQUENCE, Contributor::Remote), + ); + shared.add_output( + 3, + SharedOutput { + value: 10_000, + script_pubkey: script(APPENDIX_G_ACCEPTER_CHANGE_SPK), + contributor: Contributor::Remote, + }, + ); + + assert_eq!(shared.local_fee_sat(253, &[]), ours); + } + + #[test] + fn local_fee_covers_the_common_fields_with_no_contributions() { + // The initiator pays for version, locktime and the two counts even + // when it contributes nothing else: weight 42 at 1000 sat/kw. + assert_eq!(SharedTransaction::new(0).local_fee_sat(1000, &[]), 42); + } + + #[test] + fn local_fee_grows_with_each_input_and_output() { + let prevtx = hex::decode(APPENDIX_G_PREVTX).expect("valid hex"); + let mut shared = SharedTransaction::new(0); + let base = shared.local_fee_sat(1000, &[]); + + shared.add_input( + 0, + SharedInput::from_prevtx(&prevtx, 0, MAX_SEQUENCE, Contributor::Local), + ); + let with_input = shared.local_fee_sat(1000, &[]); + assert_eq!(with_input - base, INPUT_WEIGHT + WITNESS_WEIGHT_PER_INPUT); + + let change_script = script(APPENDIX_G_OPENER_CHANGE_SPK); + let with_output = shared.local_fee_sat(1000, &[change_script.len()]); + assert_eq!( + with_output - with_input, + OUTPUT_BASE_WEIGHT + change_script.len() as u64 * 4, + ); + } + + // -- tx_signatures ordering -- + + /// Two valid compressed points, ordered so that `LOW` sorts first. + const NODE_ID_LOW: &str = "0292edb5f7bbf9e900f7e024be1c1339c6d149c11930e613af3a983d2565f4e41e"; + const NODE_ID_HIGH: &str = "02e16172a41e928cbd78f761bd1c657c4afc7495a1244f7f30166b654fbf7661e3"; + + #[test] + fn signs_first_follows_the_lowest_contribution() { + let low = pubkey(NODE_ID_LOW); + let high = pubkey(NODE_ID_HIGH); + + // We contributed less, so we sign first regardless of node id. + assert!(signs_first(1, 2, &high, &low)); + // We contributed more, so the peer signs first. + assert!(!signs_first(2, 1, &low, &high)); + } + + #[test] + fn signs_first_breaks_an_equal_contribution_by_node_id() { + let low = pubkey(NODE_ID_LOW); + let high = pubkey(NODE_ID_HIGH); + + assert!(signs_first(5, 5, &low, &high)); + assert!(!signs_first(5, 5, &high, &low)); + } + + #[test] + fn signs_first_when_the_peer_contributes_nothing_is_the_peer() { + let low = pubkey(NODE_ID_LOW); + let high = pubkey(NODE_ID_HIGH); + + // The opener-funds-everything case: the peer's total is 0, so the peer + // signs first and we must receive tx_signatures before sending ours. + assert!(!signs_first(250_000_000, 0, &low, &high)); + } +} diff --git a/smite/src/channel_tx/tx_exchange.rs b/smite/src/channel_tx/tx_exchange.rs new file mode 100644 index 00000000..3abf08df --- /dev/null +++ b/smite/src/channel_tx/tx_exchange.rs @@ -0,0 +1,488 @@ +//! The turn-based interactive transaction exchange of BOLT 2. +//! +//! Two peers build one transaction by taking turns: every message we send +//! earns a reply, until the pair of consecutive `tx_complete`s, one from each +//! side in either order, concludes the exchange. [`TxExchange`] owns the +//! [`SharedTransaction`] being built and drives it from the messages that go +//! out and come in, so callers only translate wire messages. +//! +//! A program may send several messages before reading any reply, so whether a +//! `tx_complete` of ours concluded the exchange is often only known once the +//! reply to the message before it arrives. Sends are queued until answered, +//! and the conclusion is settled from the replies as they come in. + +use std::collections::VecDeque; + +use super::interactive_tx::{Contributor, SharedInput, SharedOutput, SharedTransaction}; + +/// One turn of the exchange, in either direction. +/// +/// The `contributor` an added input or output carries is overwritten by +/// whichever side takes the turn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Step { + AddInput { + serial_id: u64, + input: SharedInput, + }, + AddOutput { + serial_id: u64, + output: SharedOutput, + }, + RemoveInput(u64), + RemoveOutput(u64), + /// `tx_complete`. + Complete, +} + +/// The shared transaction together with how far the exchange building it has +/// progressed. +#[derive(Debug, Clone)] +pub struct TxExchange { + shared_tx: SharedTransaction, + /// Whether two consecutive `tx_complete`s have concluded the exchange. + /// Nothing sent afterwards is part of the transaction. + concluded: bool, + /// Whether the peer aborted the negotiation. May follow a conclusion: the + /// peer checks the assembled transaction only then. + aborted: bool, + /// Whether the peer's `tx_complete` is the latest message in either + /// direction, so that a `tx_complete` of ours would conclude the exchange + /// on the spot. Only ever set while nothing is unanswered. + peer_sent_tx_complete: bool, + /// Messages we have sent that the peer still owes a reply to, oldest + /// first. `Some` is our `tx_complete`, with the shared transaction as it + /// stood when it went out: should the exchange turn out to have concluded + /// on it, nothing we sent afterwards reached the peer's transaction, so + /// ours goes back to this. `None` is a contribution. + /// + /// Queuing rather than tracking only the latest send keeps a program + /// whose sends and receives have been knocked out of step by a mutator + /// from reading a message behind for the rest of its run. + unanswered: VecDeque>, +} + +impl TxExchange { + /// Starts an exchange for a transaction with the given `nLockTime`. + #[must_use] + pub fn new(locktime: u32) -> Self { + Self { + shared_tx: SharedTransaction::new(locktime), + concluded: false, + aborted: false, + peer_sent_tx_complete: false, + unanswered: VecDeque::new(), + } + } + + /// The transaction as both peers should see it so far. + #[must_use] + pub fn shared_tx(&self) -> &SharedTransaction { + &self.shared_tx + } + + /// Whether two consecutive `tx_complete`s have concluded the exchange. + #[must_use] + pub fn concluded(&self) -> bool { + self.concluded + } + + /// Whether the peer aborted the negotiation. + #[must_use] + pub fn aborted(&self) -> bool { + self.aborted + } + + /// How many messages the peer still owes a reply to. + #[must_use] + pub fn outstanding_replies(&self) -> usize { + self.unanswered.len() + } + + /// Whether the peer owes us a message. + /// + /// Reading when nothing is owed would consume whatever the peer moved on + /// to, usually its `commitment_signed`, and leave every later operation a + /// message behind. An aborted negotiation owes nothing. + #[must_use] + pub fn expects_reply(&self) -> bool { + !self.aborted && !self.unanswered.is_empty() + } + + /// Records a step we sent, applying it to the shared transaction and + /// noting the reply it earns. + /// + /// Once the exchange has concluded the peer takes no further + /// contributions and owes no further replies, so a later step is neither + /// recorded nor waited on. The message still goes out for the peer to + /// judge. + pub fn send(&mut self, step: Step) { + if self.concluded { + return; + } + if step == Step::Complete { + debug_assert!(!self.peer_sent_tx_complete || self.unanswered.is_empty()); + if self.peer_sent_tx_complete { + self.concluded = true; + } else { + self.unanswered.push_back(Some(self.shared_tx.clone())); + } + } else { + self.apply(step, Contributor::Local); + self.unanswered.push_back(None); + } + self.peer_sent_tx_complete = false; + } + + /// Records a step the peer sent as the reply to our oldest unanswered + /// message, then applies it. + /// + /// A `tx_complete` consecutive with one of ours, in either order, + /// concludes the exchange. Everything we sent after that `tx_complete` + /// never reached the peer's transaction, so ours is rolled back to how it + /// stood when the `tx_complete` went out; the peer's contributions since + /// preceded the conclusion and stay. + pub fn receive(&mut self, step: Step) { + let answered = self.unanswered.pop_front(); + if step != Step::Complete { + self.peer_sent_tx_complete = false; + self.apply(step, Contributor::Remote); + return; + } + + // Consecutive with ours if it answers our tx_complete, or if our + // tx_complete is the next thing we sent after what it answers. + let concluding = answered.flatten().or_else(|| { + self.unanswered + .pop_front_if(|sent| sent.is_some()) + .flatten() + }); + let Some(snapshot) = concluding else { + self.peer_sent_tx_complete = self.unanswered.is_empty(); + return; + }; + + self.concluded = true; + self.peer_sent_tx_complete = false; + if !self.unanswered.is_empty() { + log::debug!( + "exchange concluded on an earlier tx_complete, \ + dropped {} contribution(s) sent after it", + self.unanswered.len(), + ); + self.unanswered.clear(); + self.shared_tx.restore_local(&snapshot); + } + } + + /// Records the peer's `tx_abort`. It answers our oldest unanswered + /// message like any other reply, and ends the negotiation. + pub fn abort(&mut self) { + self.unanswered.pop_front(); + self.peer_sent_tx_complete = false; + self.aborted = true; + } + + /// Applies a contribution on behalf of `contributor`. + /// + /// `SharedTransaction` caps inputs and outputs at BOLT 2's 252 and drops + /// anything past that. The message still goes out, so from there on our + /// view and the peer's diverge: the negotiation cannot conclude either + /// way, since the peer fails on the same cap, but the divergence also + /// misaligns the input positions `tx_signatures` witnesses are ordered + /// by, which is worth naming when reading a log. + fn apply(&mut self, step: Step, contributor: Contributor) { + match step { + Step::AddInput { + serial_id, + mut input, + } => { + input.contributor = contributor; + if !self.shared_tx.add_input(serial_id, input) { + log::debug!( + "shared transaction is full, dropped input with serial_id {serial_id}" + ); + } + } + Step::AddOutput { + serial_id, + mut output, + } => { + output.contributor = contributor; + if !self.shared_tx.add_output(serial_id, output) { + log::debug!( + "shared transaction is full, dropped output with serial_id {serial_id}" + ); + } + } + Step::RemoveInput(serial_id) => { + if self + .shared_tx + .remove_input(serial_id, contributor) + .is_none() + { + log::debug!( + "{contributor:?} removed input with serial_id {serial_id} it did not add, kept" + ); + } + } + Step::RemoveOutput(serial_id) => { + if self + .shared_tx + .remove_output(serial_id, contributor) + .is_none() + { + log::debug!( + "{contributor:?} removed output with serial_id {serial_id} it did not add, kept" + ); + } + } + Step::Complete => unreachable!("tx_complete contributes nothing"), + } + } +} + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + use bitcoin::{OutPoint, ScriptBuf, Txid}; + + use super::*; + use crate::channel_tx::interactive_tx::{MAX_INPUTS, MAX_SEQUENCE}; + + /// An input whose contributor the exchange is expected to overwrite. + fn input(vout: u32) -> SharedInput { + SharedInput { + outpoint: OutPoint { + txid: Txid::from_byte_array([1u8; 32]), + vout, + }, + sequence: MAX_SEQUENCE, + contributor: Contributor::Remote, + prevout: None, + } + } + + fn output() -> SharedOutput { + SharedOutput { + value: 1000, + script_pubkey: ScriptBuf::new(), + contributor: Contributor::Remote, + } + } + + fn add_input(serial_id: u64) -> Step { + Step::AddInput { + serial_id, + input: input(u32::try_from(serial_id).expect("small serial id")), + } + } + + fn add_output(serial_id: u64) -> Step { + Step::AddOutput { + serial_id, + output: output(), + } + } + + fn input_ids(exchange: &TxExchange) -> Vec<(u64, Contributor)> { + exchange + .shared_tx() + .inputs() + .map(|(id, input)| (id, input.contributor)) + .collect() + } + + fn output_ids(exchange: &TxExchange) -> Vec { + exchange.shared_tx().outputs().map(|(id, _)| id).collect() + } + + #[test] + fn in_step_exchange_owes_one_reply_per_send() { + let mut exchange = TxExchange::new(0); + assert!(!exchange.expects_reply()); + + exchange.send(add_input(2)); + assert!(exchange.expects_reply()); + exchange.receive(add_input(1)); + assert!(!exchange.expects_reply()); + + assert_eq!( + input_ids(&exchange), + vec![(1, Contributor::Remote), (2, Contributor::Local)], + ); + assert!(!exchange.concluded()); + } + + #[test] + fn our_tx_complete_after_the_peers_concludes_on_the_spot() { + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.receive(Step::Complete); + assert!(!exchange.concluded()); + + exchange.send(Step::Complete); + assert!(exchange.concluded()); + assert!(!exchange.expects_reply()); + } + + #[test] + fn the_peers_tx_complete_after_ours_concludes() { + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.receive(add_input(1)); + exchange.send(Step::Complete); + assert!(exchange.expects_reply()); + + exchange.receive(Step::Complete); + assert!(exchange.concluded()); + assert!(!exchange.expects_reply()); + } + + #[test] + fn a_contribution_between_tx_completes_keeps_the_exchange_open() { + let mut exchange = TxExchange::new(0); + exchange.send(Step::Complete); + exchange.receive(add_input(1)); + exchange.send(Step::Complete); + assert!(!exchange.concluded()); + assert!(exchange.expects_reply()); + } + + #[test] + fn late_conclusion_on_the_peers_tx_complete_rolls_back_later_sends() { + // Three inputs go out with two replies unread, then a tx_complete and + // a change output. The peer's tx_complete answering the third input + // and our tx_complete are consecutive, so the exchange concludes + // without the change output. + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.receive(Step::Complete); + exchange.send(add_input(4)); + exchange.send(add_input(6)); + exchange.send(Step::Complete); + exchange.send(add_output(2000)); + assert_eq!(exchange.outstanding_replies(), 4); + + exchange.receive(add_input(1)); + assert!(!exchange.concluded()); + exchange.receive(Step::Complete); + assert!(exchange.concluded()); + assert!(!exchange.expects_reply()); + + assert_eq!( + input_ids(&exchange), + vec![ + (1, Contributor::Remote), + (2, Contributor::Local), + (4, Contributor::Local), + (6, Contributor::Local), + ], + ); + assert!(output_ids(&exchange).is_empty()); + } + + #[test] + fn late_conclusion_on_our_tx_complete_rolls_back_later_sends() { + // Our tx_complete answers the peer's contribution, its tx_complete + // answers ours; the output we sent in between never reached it. + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.send(Step::Complete); + exchange.send(add_output(2000)); + exchange.send(Step::RemoveInput(2)); + assert_eq!(exchange.outstanding_replies(), 4); + + exchange.receive(add_output(1)); + exchange.receive(Step::Complete); + assert!(exchange.concluded()); + assert!(!exchange.expects_reply()); + + assert_eq!(input_ids(&exchange), vec![(2, Contributor::Local)]); + assert_eq!(output_ids(&exchange), vec![1]); + } + + #[test] + fn sends_after_the_conclusion_are_not_recorded() { + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.receive(Step::Complete); + exchange.send(Step::Complete); + + exchange.send(add_output(2000)); + assert!(!exchange.expects_reply()); + assert!(output_ids(&exchange).is_empty()); + } + + #[test] + fn a_reply_settles_a_backlog_left_by_a_dropped_receive() { + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.send(add_input(4)); + exchange.send(Step::Complete); + assert_eq!(exchange.outstanding_replies(), 3); + + exchange.receive(Step::Complete); + // The tx_complete answered our first input, not our tx_complete. + assert!(!exchange.concluded()); + assert_eq!(exchange.outstanding_replies(), 2); + exchange.receive(add_input(1)); + exchange.receive(Step::Complete); + assert!(exchange.concluded()); + } + + #[test] + fn abort_stops_expecting_replies_without_concluding() { + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.send(add_input(4)); + exchange.abort(); + + assert!(exchange.aborted()); + assert!(!exchange.concluded()); + assert!(!exchange.expects_reply()); + assert_eq!(exchange.outstanding_replies(), 1); + } + + #[test] + fn abort_after_the_conclusion_keeps_it() { + let mut exchange = TxExchange::new(0); + exchange.send(Step::Complete); + exchange.receive(Step::Complete); + exchange.abort(); + + assert!(exchange.aborted()); + assert!(exchange.concluded()); + } + + #[test] + fn removals_only_touch_the_senders_own_entries() { + let mut exchange = TxExchange::new(0); + exchange.send(add_input(2)); + exchange.receive(add_input(1)); + + exchange.send(Step::RemoveInput(1)); + exchange.receive(Step::RemoveInput(2)); + assert_eq!( + input_ids(&exchange), + vec![(1, Contributor::Remote), (2, Contributor::Local)], + ); + + exchange.send(Step::RemoveInput(2)); + exchange.receive(Step::RemoveInput(1)); + assert!(input_ids(&exchange).is_empty()); + } + + #[test] + fn contributions_past_the_cap_are_dropped_in_both_directions() { + let mut exchange = TxExchange::new(0); + for serial_id in 0..u64::try_from(MAX_INPUTS).expect("fits") { + exchange.send(add_input(serial_id)); + } + exchange.send(add_input(1000)); + exchange.receive(add_input(1001)); + + assert_eq!(exchange.shared_tx().inputs().count(), MAX_INPUTS); + assert!(input_ids(&exchange).iter().all(|(id, _)| *id < 1000)); + } +} diff --git a/smite/src/pending_channel.rs b/smite/src/pending_channel.rs index bacb467e..cae2a463 100644 --- a/smite/src/pending_channel.rs +++ b/smite/src/pending_channel.rs @@ -3,7 +3,14 @@ //! Remembers the `open_channel`/`accept_channel` parameters of each channel //! being established, so later steps can build commitments from them. -use crate::bolt::{AcceptChannel, OpenChannel}; +use std::collections::HashMap; + +use bitcoin::{ScriptBuf, Witness}; + +use crate::bolt::{ + AcceptChannel, AcceptChannel2, ChannelId, OpenChannel, OpenChannel2, TemporaryChannelId, +}; +use crate::channel_tx::{TxExchange, build_funding_witness_script}; /// Negotiation parameters for a channel being established. /// @@ -15,3 +22,182 @@ pub struct PendingChannel { pub accept_channel: Option, pub funding_built: bool, } + +/// Negotiation parameters for a channel being established with the v2 +/// (dual-funded) protocol. +/// +/// Keyed by `temporary_channel_id` while the negotiation is in flight. Unlike +/// v1, the real `channel_id` does not depend on the funding transaction: it is +/// derived from both peers' revocation basepoints and so becomes known as soon +/// as `accept_channel2` arrives. +pub struct PendingChannelV2 { + pub open_channel2: OpenChannel2, + pub accept_channel2: Option, + /// The v2 `channel_id`, known once `accept_channel2` reveals the peer's + /// revocation basepoint. + pub channel_id: Option, + /// The interactive transaction exchange and the transaction it builds. + pub tx_exchange: TxExchange, + /// Progress through the commitment and signature exchange that follows it. + pub commitment_exchange: CommitmentExchange, + /// Witnesses from the peer's `tx_signatures` + pub peer_witnesses: Vec, +} + +/// Progress through a two-way exchange of one message type. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Exchange { + /// Whether we have sent ours. + pub sent: bool, + /// Whether the peer's has arrived. + pub received: bool, +} + +/// How far the commitment and signature exchange has progressed. +/// +/// BOLT 2 gates each half on the other: `tx_signatures` may only be sent once +/// both peers' `commitment_signed`s have been exchanged, and the peer owes us +/// its `tx_signatures` once it has received ours. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CommitmentExchange { + /// Progress through the `commitment_signed` exchange for this funding + /// transaction. `received` means arrived *and* verified. + pub commitment_signed: Exchange, + /// Progress through the `tx_signatures` exchange. + pub tx_signatures: Exchange, +} + +impl PendingChannelV2 { + /// Starts a negotiation from the `open_channel2` we sent, taking the + /// shared transaction's `nLockTime` from it. + #[must_use] + pub fn new(open_channel2: OpenChannel2) -> Self { + let tx_exchange = TxExchange::new(open_channel2.locktime); + Self { + open_channel2, + accept_channel2: None, + channel_id: None, + tx_exchange, + commitment_exchange: CommitmentExchange::default(), + peer_witnesses: Vec::new(), + } + } + + /// The funding output's `scriptPubKey`, once `accept_channel2` has + /// revealed the peer's funding pubkey. + #[must_use] + pub fn funding_script(&self) -> Option { + let accept = self.accept_channel2.as_ref()?; + Some( + build_funding_witness_script( + &self.open_channel2.funding_pubkey, + &accept.funding_pubkey, + ) + .to_p2wsh(), + ) + } + + /// Total funding output value: the sum of both peers' contributions, per + /// BOLT 2. Saturates rather than overflowing on a mutated amount. + #[must_use] + pub fn total_funding_satoshis(&self) -> u64 { + self.open_channel2.funding_satoshis.saturating_add( + self.accept_channel2 + .as_ref() + .map_or(0, |ac| ac.funding_satoshis), + ) + } +} + +/// Every channel establishment v2 negotiation in flight, addressable by either +/// of the two ids a message can carry. +/// +/// BOLT 2 changes the id mid-negotiation: `open_channel2` and `accept_channel2` +/// carry a `temporary_channel_id`, everything after carries the `channel_id` +/// derived from both peers' revocation basepoints. Negotiations are keyed by +/// the temporary id, which is stable for the whole negotiation, and a second +/// map redirects the derived id onto it. Owning both together is what keeps +/// that redirection from outliving the negotiation it was built for. +#[derive(Default)] +pub struct V2Negotiations { + by_temporary_id: HashMap, + temporary_ids: HashMap, +} + +impl V2Negotiations { + /// The `temporary_channel_id` keying the negotiation `channel_id` names, + /// whichever of the two ids it is. + fn key(&self, channel_id: ChannelId) -> Option { + if self.by_temporary_id.contains_key(&channel_id) { + Some(channel_id) + } else { + self.temporary_ids.get(&channel_id).copied() + } + } + + /// The negotiation `channel_id` names, by either id. + /// + /// Returns `None` when neither matches, which is what a mutated program + /// that dropped its `open_channel2`, or pointed a message at an unrelated + /// channel, looks like. + #[must_use] + pub fn get(&self, channel_id: ChannelId) -> Option<&PendingChannelV2> { + self.by_temporary_id.get(&self.key(channel_id)?) + } + + /// Mutable sibling of [`Self::get`]. + pub fn get_mut(&mut self, channel_id: ChannelId) -> Option<&mut PendingChannelV2> { + let key = self.key(channel_id)?; + self.by_temporary_id.get_mut(&key) + } + + /// Every negotiation in flight, in no particular order. + pub fn iter(&self) -> impl Iterator { + self.by_temporary_id.values() + } + + /// Records a sent `open_channel2`, starting a negotiation keyed by its + /// `temporary_channel_id`. + /// + /// A repeated `temporary_channel_id` starts a fresh negotiation, discarding + /// the previous one: unlike v1 there is no `funding_created` marking the + /// point of no return, and the id only has to stay unique until + /// `accept_channel2` arrives. Any `channel_id` the discarded negotiation + /// had derived is forgotten with it, so a message still naming the old one + /// does not land on the new negotiation. + pub fn record_open(&mut self, open_channel2: &OpenChannel2) { + let temporary_channel_id = open_channel2.temporary_channel_id; + self.temporary_ids + .retain(|_, keyed_by| *keyed_by != temporary_channel_id); + self.by_temporary_id.insert( + temporary_channel_id, + PendingChannelV2::new(open_channel2.clone()), + ); + } + + /// Pairs a received `accept_channel2` with the recorded `open_channel2` of + /// the same `temporary_channel_id`, and derives the v2 `channel_id` that + /// every subsequent message carries. + /// + /// An `accept_channel2` for an unknown `temporary_channel_id` is ignored + /// rather than fatal: a mutated program may have dropped the + /// `open_channel2` that would have recorded it, and the message still + /// decodes fine. + pub fn record_accept(&mut self, accept_channel2: &AcceptChannel2) { + let temporary_channel_id = accept_channel2.temporary_channel_id; + let Some(pending) = self.by_temporary_id.get_mut(&temporary_channel_id) else { + log::debug!( + "accept_channel2 for unknown temporary_channel_id {temporary_channel_id}, ignoring", + ); + return; + }; + + let channel_id = ChannelId::v2_from_revocation_basepoints( + &pending.open_channel2.revocation_basepoint, + &accept_channel2.revocation_basepoint, + ); + pending.accept_channel2 = Some(accept_channel2.clone()); + pending.channel_id = Some(channel_id); + self.temporary_ids.insert(channel_id, temporary_channel_id); + } +} diff --git a/smite/src/violation.rs b/smite/src/violation.rs index 515c9b52..edb64f11 100644 --- a/smite/src/violation.rs +++ b/smite/src/violation.rs @@ -45,4 +45,16 @@ pub enum Violation { /// holder's initial commitment transaction. #[error("invalid counterparty signature for channel_id {0}")] InvalidCounterpartySignature(ChannelId), + + /// The target's `commitment_signed` for a channel establishment v2 open + /// carried HTLC signatures. BOLT 2 requires the first commitment of a v2 + /// open to have no HTLCs, so there is nothing for them to sign. + #[error("unexpected htlc signatures in commitment_signed for channel_id {0}")] + UnexpectedHtlcSignatures(ChannelId), + + /// The target's `tx_signatures` carried a witness BOLT 2 requires the + /// receiver to fail the negotiation over: one that is empty, or one whose + /// `witness_data` is not the bitcoin wire encoding the spec prescribes. + #[error("invalid tx_signatures for channel_id {0}: {1}")] + InvalidTxSignatures(ChannelId, String), } diff --git a/smitebot/src/commands/start.rs b/smitebot/src/commands/start.rs index d6e21731..b5ecff13 100644 --- a/smitebot/src/commands/start.rs +++ b/smitebot/src/commands/start.rs @@ -558,6 +558,17 @@ fn ir_mutator_envs(config: &CampaignConfig) -> Vec<(&'static str, String)> { if !config.scenario.starts_with("ir") { return Vec::new(); } + // BOLT 2 makes the two channel establishment flows mutually exclusive on + // one connection, so draw only from the generators this scenario's target + // can act on. The other flow's programs would be rejected outright. + let generators = match config.scenario.as_str() { + "ir_v2" => "v2", + // `ir` and `ir_bytes` both snapshot with `option_dual_fund` stripped. + // A scenario added later lands here too: v1 is the safe default, since + // every target supports it, but a v2 scenario must be named above or + // it will fuzz a flow its snapshot cannot reach. + _ => "v1", + }; vec![ ( "AFL_CUSTOM_MUTATOR_LIBRARY", @@ -565,6 +576,7 @@ fn ir_mutator_envs(config: &CampaignConfig) -> Vec<(&'static str, String)> { ), ("AFL_CUSTOM_MUTATOR_ONLY", "1".to_string()), ("AFL_FRAMESHIFT_DISABLE", "1".to_string()), + ("SMITE_IR_GENERATORS", generators.to_string()), ] } @@ -1000,38 +1012,56 @@ sharedir = "{}" assert!(result.join("seed0").exists()); } - #[test] - fn ir_mutator_envs_sets_vars_for_ir_scenario() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("campaign.toml"); + /// A campaign config for the given IR scenario. + fn ir_config(dir: &Path, scenario: &str) -> CampaignConfig { + let config_path = dir.join("campaign.toml"); fs::write( &config_path, format!( r#" target = "lnd" -scenario = "ir_bytes" -aflpp_path = "{}" -smite_dir = "{}" +scenario = "{scenario}" +aflpp_path = "{aflpp}" +smite_dir = "{smite}" runners = 1 -output_dir = "{}" -sharedir = "{}" +output_dir = "{out}" +sharedir = "{sharedir}" "#, - dir.path().display(), - dir.path().display(), - dir.path().join("out").display(), - dir.path().join("nyx").display(), + aflpp = dir.display(), + smite = dir.display(), + out = dir.join("out").display(), + sharedir = dir.join("nyx").display(), ), ) .unwrap(); - let config = CampaignConfig::load(&config_path).unwrap(); + CampaignConfig::load(&config_path).unwrap() + } + + #[test] + fn ir_mutator_envs_sets_vars_for_ir_scenario() { + let dir = tempfile::tempdir().unwrap(); + let config = ir_config(dir.path(), "ir_bytes"); let envs = ir_mutator_envs(&config); - assert_eq!(envs.len(), 3); + assert_eq!(envs.len(), 4); assert_eq!(envs[0].0, "AFL_CUSTOM_MUTATOR_LIBRARY"); assert!(envs[0].1.ends_with("libsmite_ir_mutator.so")); assert_eq!(envs[1], ("AFL_CUSTOM_MUTATOR_ONLY", "1".to_string())); assert_eq!(envs[2], ("AFL_FRAMESHIFT_DISABLE", "1".to_string())); + // The `ir` scenario negotiates no dual funding, so it draws only the + // single-funded generators. + assert_eq!(envs[3], ("SMITE_IR_GENERATORS", "v1".to_string())); + } + + #[test] + fn ir_mutator_envs_selects_dual_funded_generators_for_ir_v2() { + let dir = tempfile::tempdir().unwrap(); + let config = ir_config(dir.path(), "ir_v2"); + + let envs = ir_mutator_envs(&config); + + assert_eq!(envs[3], ("SMITE_IR_GENERATORS", "v2".to_string())); } #[test]