From 2c016d40457120c162f9a0997c8802341e943d10 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 5 Aug 2026 13:41:10 +0530 Subject: [PATCH 01/16] smite: add BOLT 9 feature bitfield primitives Signed-off-by: Nishant Bansal --- smite/src/bolt.rs | 2 + smite/src/bolt/features.rs | 324 +++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 smite/src/bolt/features.rs diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 69eda957..0e2ddcce 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -14,6 +14,7 @@ mod closing_complete; mod closing_sig; mod commitment_signed; mod error; +mod features; mod funding_created; mod funding_signed; mod gossip_timestamp_filter; @@ -52,6 +53,7 @@ pub use closing_complete::{ClosingComplete, ClosingTlvs}; pub use closing_sig::ClosingSig; pub use commitment_signed::{CommitmentSigned, CommitmentSignedTlvs}; pub use error::Error; +pub use features::{FeatureBit, Features}; pub use funding_created::FundingCreated; pub use funding_signed::FundingSigned; pub use gossip_timestamp_filter::GossipTimestampFilter; diff --git a/smite/src/bolt/features.rs b/smite/src/bolt/features.rs new file mode 100644 index 00000000..2e18c361 --- /dev/null +++ b/smite/src/bolt/features.rs @@ -0,0 +1,324 @@ +//! BOLT 9 feature bitfield primitives. + +/// BOLT 9 feature bit index. Even bits are required (reject if unknown); odd +/// bits are optional (safe to ignore if unknown): "it's ok to be odd" rule. +pub type FeatureBit = usize; + +/// BOLT 9 feature bitfield, encoded as big-endian bytes. +#[derive(Debug, Clone, Default)] +pub struct Features(Vec); + +impl Features { + /// `gossip_queries` (bits 6/7). + pub const GOSSIP_QUERIES: FeatureBit = 6; + /// `gossip_queries_ex` (bits 10/11). + pub const GOSSIP_QUERIES_EX: FeatureBit = 10; + /// `option_static_remotekey` (bits 12/13). + pub const OPTION_STATIC_REMOTEKEY: FeatureBit = 12; + /// `option_anchors` (bits 22/23). + pub const OPTION_ANCHORS: FeatureBit = 22; + /// `option_dual_fund` (bits 28/29). + pub const OPTION_DUAL_FUND: FeatureBit = 28; + /// `zero_fee_commitments` (bits 40/41). + pub const ZERO_FEE_COMMITMENTS: FeatureBit = 40; + /// `option_provide_storage` (bits 42/43). + pub const OPTION_PROVIDE_STORAGE: FeatureBit = 42; + /// `option_scid_alias` (bits 46/47). + pub const OPTION_SCID_ALIAS: FeatureBit = 46; + /// `option_zeroconf` (bits 50/51). + pub const OPTION_ZEROCONF: FeatureBit = 50; + /// `option_simple_taproot` (bits 80/81). + pub const OPTION_SIMPLE_TAPROOT: FeatureBit = 80; + /// `option_simple_taproot_staging` (bits 180/181). + pub const OPTION_SIMPLE_TAPROOT_STAGING: FeatureBit = 180; + /// `option_script_enforced_lease` (bits 2022/2023). + /// Note: Currently, this feature is LND-specific and is not defined in BOLT 9. + pub const OPTION_SCRIPT_ENFORCED_LEASE: FeatureBit = 2022; + + /// Creates an empty set of features. + #[must_use] + pub fn new() -> Self { + Self(Vec::new()) + } + + /// Creates features with the given bits set. + #[must_use] + pub fn from_bits(bits: &[FeatureBit]) -> Self { + let mut features = Self::new(); + for &bit in bits { + features.set_bit(bit); + } + features + } + + /// Consumes the features into their underlying bytes. + #[must_use] + pub fn into_bytes(self) -> Vec { + self.0 + } + + /// Sets the bit, extending the features with leading zero bytes if needed. + /// + /// # Panics + /// + /// Panics if bit exceeds the BOLT 9 feature bitfield's u16-byte length + /// limit. + pub fn set_bit(&mut self, bit: FeatureBit) { + let byte_offset = bit / 8; + assert!( + byte_offset < u16::MAX as usize, + "feature bit {bit} exceeds the feature bitfield length limit" + ); + + let mut len = self.0.len(); + if len <= byte_offset { + let new_len = byte_offset + 1; + let mut new_features = vec![0u8; new_len]; + new_features[(new_len - len)..].copy_from_slice(&self.0); + self.0 = new_features; + len = new_len; + } + + let mask = 1 << (bit % 8); + self.0[len - 1 - byte_offset] |= mask; + } + + /// Clears the bit, no-op if beyond the feature's length. + pub fn clear_bit(&mut self, bit: FeatureBit) { + let byte_offset = bit / 8; + let len = self.0.len(); + if byte_offset < len { + let mask = 1 << (bit % 8); + self.0[len - 1 - byte_offset] &= !mask; + } + } + + /// Returns whether the bit is set. + #[must_use] + pub fn is_bit_set(&self, bit: FeatureBit) -> bool { + let byte_offset = bit / 8; + let len = self.0.len(); + if len <= byte_offset { + return false; + } + + let mask = 1 << (bit % 8); + self.0[len - 1 - byte_offset] & mask != 0 + } + + /// Returns whether the feature is supported by checking its required or + /// optional bit. + #[must_use] + pub fn supports_feature(&self, bit: FeatureBit) -> bool { + self.is_bit_set(bit) || self.is_bit_set(bit ^ 1) + } + + /// Clears the feature's required (even) and optional (odd) bits. + pub fn clear_feature(&mut self, bit: FeatureBit) { + self.clear_bit(bit); + self.clear_bit(bit ^ 1); + } +} + +impl PartialEq for Features { + /// Returns true if this set and other have the same feature bits set. + fn eq(&self, other: &Self) -> bool { + for bit in 0..(self.0.len().max(other.0.len()) * 8) { + if self.is_bit_set(bit) != other.is_bit_set(bit) { + return false; + } + } + true + } +} + +impl Eq for Features {} + +impl From> for Features { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for Features { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_features_support_no_features() { + let features = Features::new(); + assert!(!features.supports_feature(Features::OPTION_ANCHORS)); + assert!(!features.supports_feature(Features::OPTION_STATIC_REMOTEKEY)); + assert!(!features.supports_feature(Features::GOSSIP_QUERIES)); + } + + #[test] + fn from_bits_sets_requested_bits() { + assert_eq!(Features::from_bits(&[]), Features::new()); + assert_eq!( + Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY]).into_bytes(), + vec![0x10, 0x00] + ); + assert_eq!( + Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY, Features::OPTION_ANCHORS]), + Features::from(vec![0x40, 0x10, 0x00]) + ); + } + + #[test] + fn supports_multiple_set_features() { + let features = Features::from_bits(&[ + Features::OPTION_ANCHORS, + Features::OPTION_STATIC_REMOTEKEY, + Features::OPTION_DUAL_FUND, + ]); + + assert!(features.supports_feature(Features::OPTION_ANCHORS)); + assert!(features.supports_feature(Features::OPTION_STATIC_REMOTEKEY)); + assert!(features.supports_feature(Features::OPTION_DUAL_FUND)); + assert!(!features.supports_feature(Features::OPTION_ZEROCONF)); + } + + #[test] + fn set_bit_within_existing_length() { + let mut fv = Features::from(vec![0x00, 0x00]); + fv.set_bit(0); + assert_eq!(fv, Features::from(vec![0x00, 0x01])); + fv.set_bit(8); + assert_eq!(fv, Features::from(vec![0x01, 0x01])); + } + + #[test] + fn set_bit_grows_and_preserves_existing_bits() { + let mut fv = Features::from(vec![0x01]); + fv.set_bit(12); + assert_eq!(fv, Features::from(vec![0x10, 0x01])); + assert!(fv.is_bit_set(0)); + assert!(fv.is_bit_set(12)); + } + + #[test] + fn set_bit_accepts_the_highest_bit_within_the_length_limit() { + // The bitfield is length-prefixed with a `u16`, so `u16::MAX` bytes + // hold bits 0 through `u16::MAX * 8 - 1`. + let highest = u16::MAX as usize * 8 - 1; + let mut fv = Features::new(); + fv.set_bit(highest); + assert!(fv.is_bit_set(highest)); + assert_eq!(fv.into_bytes().len(), u16::MAX as usize); + } + + #[test] + #[should_panic(expected = "exceeds the feature bitfield length limit")] + fn set_bit_panics_beyond_the_length_limit() { + Features::new().set_bit(u16::MAX as usize * 8); + } + + #[test] + fn clear_bit_within_bounds_and_noop_out_of_bounds() { + let mut fv = Features::from(vec![0xff, 0xff]); + fv.clear_bit(0); + assert_eq!(fv, Features::from(vec![0xff, 0xfe])); + // Out of range: no-op. + fv.clear_bit(100); + assert_eq!(fv, Features::from(vec![0xff, 0xfe])); + } + + #[test] + fn is_bit_set_uses_big_endian_bit_order() { + let fv = Features::from(vec![0x00, 0x01]); + assert!(fv.is_bit_set(0)); + assert!(!fv.is_bit_set(1)); + + let fv = Features::from(vec![0x01, 0x00]); + assert!(fv.is_bit_set(8)); + assert!(!fv.is_bit_set(0)); + } + + #[test] + fn is_bit_set_out_of_bounds_returns_false() { + assert!(!Features::new().is_bit_set(0)); + assert!(!Features::from(vec![0xff]).is_bit_set(8)); + } + + #[test] + fn supports_feature_uses_big_endian_bit_order() { + // Required (bit 22), optional (bit 23). + assert!(Features::from(vec![0x40, 0x00, 0x00]).supports_feature(Features::OPTION_ANCHORS)); + assert!(Features::from(vec![0x80, 0x00, 0x00]).supports_feature(Features::OPTION_ANCHORS)); + // No support. + assert!(!Features::from(vec![0x00, 0x00, 0x40]).supports_feature(Features::OPTION_ANCHORS)); + assert!(!Features::from(vec![0x00, 0x00, 0x80]).supports_feature(Features::OPTION_ANCHORS)); + assert!(!Features::from(vec![]).supports_feature(Features::OPTION_ANCHORS)); + assert!(!Features::from(vec![0xff, 0xff]).supports_feature(Features::OPTION_ANCHORS)); + assert!(!Features::from(vec![0x00, 0x10]).supports_feature(Features::OPTION_ANCHORS)); + } + + #[test] + fn supports_feature_matches_either_bit_set() { + // Only the required (even) bit set. + let required_only = Features::from_bits(&[22]); + assert!(required_only.supports_feature(Features::OPTION_ANCHORS)); + assert!(required_only.supports_feature(23)); + // Only the optional (odd) bit set. + let optional_only = Features::from_bits(&[23]); + assert!(optional_only.supports_feature(Features::OPTION_ANCHORS)); + assert!(optional_only.supports_feature(23)); + // Neither bit set. + assert!( + !Features::from_bits(&[Features::OPTION_ZEROCONF]) + .supports_feature(Features::OPTION_ANCHORS) + ); + } + + #[test] + fn clear_feature_clears_both_bits() { + // Passing the even bit clears the pair. + let mut fv = Features::from_bits(&[22, 23]); + fv.clear_feature(Features::OPTION_ANCHORS); + assert!(!fv.supports_feature(Features::OPTION_ANCHORS)); + // Passing the odd bit clears the pair. + let mut fv = Features::from_bits(&[22, 23]); + fv.clear_feature(23); + assert!(!fv.supports_feature(Features::OPTION_ANCHORS)); + } + + #[test] + fn clear_feature_removes_support() { + let mut features = + Features::from_bits(&[Features::OPTION_ANCHORS, Features::OPTION_STATIC_REMOTEKEY]); + + assert!(features.supports_feature(Features::OPTION_ANCHORS)); + assert!(features.supports_feature(Features::OPTION_STATIC_REMOTEKEY)); + + features.clear_feature(Features::OPTION_ANCHORS); + assert!(!features.supports_feature(Features::OPTION_ANCHORS)); + assert!(features.supports_feature(Features::OPTION_STATIC_REMOTEKEY)); + } + + #[test] + fn equality_with_same_and_different_bits() { + let lease = Features::from_bits(&[Features::OPTION_SCRIPT_ENFORCED_LEASE]); + assert_eq!(lease, Features::from_bits(&[2022])); + assert_eq!( + Features::from(vec![0x02, 0x01]), + Features::from(vec![0x02, 0x01]) + ); + + // equality ignores leading zero bytes. + assert_eq!(Features::from(vec![0x01]), Features::from(vec![0x00, 0x01])); + assert_eq!(Features::new(), Features::from(vec![0x00, 0x00])); + + assert_ne!(Features::from(vec![0x01]), Features::from(vec![0x02])); + assert_ne!(Features::from(vec![0x01]), Features::from(vec![0x01, 0x00])); + assert_ne!(Features::from(vec![0x01]), Features::from(vec![0x01, 0x01])); + assert_ne!(Features::from(vec![0x01, 0x00]), Features::from(vec![0x00])); + assert_ne!(lease, Features::from_bits(&[2023])); + } +} From 43b1bf38bc2fe229eb600e37aa0687680c021c19 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 5 Aug 2026 14:09:26 +0530 Subject: [PATCH 02/16] smite: use Features for channel_type in commitment Signed-off-by: Nishant Bansal --- smite-scenarios/src/executor.rs | 6 +-- smite/src/channel_tx/commitment.rs | 67 +++++++++--------------------- 2 files changed, 22 insertions(+), 51 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 1c01b145..a8e42dfa 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -9,8 +9,8 @@ use bitcoin::{OutPoint, ScriptBuf, Txid}; use smite::bitcoin::{BitcoinCli, TxBlockPosition, Utxo}; use smite::bolt::{ AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady, - ChannelReadyTlvs, ChannelUpdate, FundingCreated, FundingSigned, Message, NodeAnnouncement, - OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, msg_type, + ChannelReadyTlvs, ChannelUpdate, Features, FundingCreated, FundingSigned, Message, + NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, msg_type, }; use smite::channel_tx::{ ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side, @@ -932,7 +932,7 @@ fn build_funding_created( let config = ChannelConfig { funding_outpoint, funding_satoshis: open_channel.funding_satoshis, - channel_type: open_channel.tlvs.channel_type.clone().unwrap_or_default(), + channel_type: Features::from(open_channel.tlvs.channel_type.clone().unwrap_or_default()), opener, acceptor, minimum_depth: accept_channel.minimum_depth, diff --git a/smite/src/channel_tx/commitment.rs b/smite/src/channel_tx/commitment.rs index 0b451b60..bc6870c5 100644 --- a/smite/src/channel_tx/commitment.rs +++ b/smite/src/channel_tx/commitment.rs @@ -1,6 +1,7 @@ //! BOLT 3 commitment transaction construction and signing. use super::funding::build_funding_witness_script; +use crate::bolt::Features; use bitcoin::absolute::LockTime; use bitcoin::hashes::sha256::Hash as Sha256; @@ -24,9 +25,6 @@ const COMMITMENT_TX_BASE_WEIGHT_NON_ANCHOR: u64 = 724; /// Weight of an anchor commitment transaction without HTLCs. const COMMITMENT_TX_BASE_WEIGHT_ANCHOR: u64 = 1124; -/// `option_anchors` feature bits (BOLT 9, bits 22/23). -const OPTION_ANCHORS_FEATURE_BITS: &[usize] = &[22, 23]; - /// Errors that can occur when constructing or validating commitment transactions. #[derive(Debug, thiserror::Error)] pub enum CommitmentError { @@ -77,7 +75,7 @@ pub struct ChannelConfig { pub funding_satoshis: u64, /// Channel type feature bits. The commitment format (anchor / legacy) is /// derived from the bits set here. - pub channel_type: Vec, + pub channel_type: Features, /// Opener's static keys and parameters. pub opener: ChannelPartyConfig, /// Acceptor's static keys and parameters. @@ -383,7 +381,7 @@ impl ChannelConfig { /// `local_side` selects whose commitment outputs are built: the /// opener's or the acceptor's. fn build_commitment_outputs(&self, state: &CommitmentState, local_side: &Side) -> Vec { - let anchor = supports_option_anchors(&self.channel_type); + let anchor = self.channel_type.supports_feature(Features::OPTION_ANCHORS); // Fee and balances. let commitment_cost = CommitmentCost::new(state.feerate_per_kw, &self.channel_type); @@ -471,7 +469,7 @@ impl CommitmentState { impl CommitmentCost { /// Calculates the total cost of a commitment transaction. #[must_use] - pub fn new(feerate_per_kw: u32, channel_type: &[u8]) -> CommitmentCost { + pub fn new(feerate_per_kw: u32, channel_type: &Features) -> CommitmentCost { CommitmentCost { fee_sat: commit_tx_fee_sat(feerate_per_kw, channel_type), anchor_cost_sat: total_anchors_sat(channel_type), @@ -486,8 +484,8 @@ impl CommitmentCost { } /// Get the fee cost of a commitment tx in satoshis. -fn commit_tx_fee_sat(feerate_per_kw: u32, channel_type: &[u8]) -> u64 { - let commitment_weight = if supports_option_anchors(channel_type) { +fn commit_tx_fee_sat(feerate_per_kw: u32, channel_type: &Features) -> u64 { + let commitment_weight = if channel_type.supports_feature(Features::OPTION_ANCHORS) { COMMITMENT_TX_BASE_WEIGHT_ANCHOR } else { COMMITMENT_TX_BASE_WEIGHT_NON_ANCHOR @@ -497,8 +495,8 @@ fn commit_tx_fee_sat(feerate_per_kw: u32, channel_type: &[u8]) -> u64 { } /// Get the anchor cost of a commitment tx in satoshis. -fn total_anchors_sat(channel_type: &[u8]) -> u64 { - if supports_option_anchors(channel_type) { +fn total_anchors_sat(channel_type: &Features) -> u64 { + if channel_type.supports_feature(Features::OPTION_ANCHORS) { ANCHOR_OUTPUT_VALUE * 2 } else { 0 @@ -528,24 +526,6 @@ fn compute_obscuring_factor( u64::from_be_bytes(buf) } -/// Checks whether `option_anchors` (BOLT 9, bits 22/23) is set in a -/// big-endian `channel_type` feature bitfield. -/// -/// Per BOLT 9, even bit (22) = required, odd bit (23) = optional. -/// Either bit indicates anchor support. -fn supports_option_anchors(channel_type: &[u8]) -> bool { - let byte_offset = OPTION_ANCHORS_FEATURE_BITS[0] / 8; - let len = channel_type.len(); - if len <= byte_offset { - return false; - } - - let required_mask = 1 << (OPTION_ANCHORS_FEATURE_BITS[0] % 8); - let optional_mask = 1 << (OPTION_ANCHORS_FEATURE_BITS[1] % 8); - - channel_type[len - 1 - byte_offset] & (required_mask | optional_mask) != 0 -} - /// Derives a public key from a basepoint and per-commitment point per BOLT 3. fn derive_pubkey(basepoint: &PublicKey, per_commitment_point: &PublicKey) -> PublicKey { let secp = Secp256k1::new(); @@ -688,19 +668,6 @@ mod tests { assert_eq!(factor, 0x2bb0_3852_1914); } - #[test] - fn supports_option_anchors_detection() { - // Required (bit 22), optional (bit 23). - assert!(supports_option_anchors(&[0x40, 0x00, 0x00])); - assert!(supports_option_anchors(&[0x80, 0x00, 0x00])); - // No support. - assert!(!supports_option_anchors(&[0x00, 0x00, 0x40])); - assert!(!supports_option_anchors(&[0x00, 0x00, 0x80])); - assert!(!supports_option_anchors(&[])); - assert!(!supports_option_anchors(&[0xff, 0xff])); - assert!(!supports_option_anchors(&[0x00, 0x10])); - } - fn bolt3_commitment_params( feerate_per_kw: u32, to_opener_msat: u64, @@ -721,7 +688,7 @@ mod tests { vout: 0, }, funding_satoshis: 10_000_000, - channel_type, + channel_type: Features::from(channel_type), opener: ChannelPartyConfig { funding_pubkey: pubkey( "023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb", @@ -1349,7 +1316,7 @@ mod tests { vout: 0, }, funding_satoshis, - channel_type, + channel_type: Features::from(channel_type), opener: sample_party(), acceptor: sample_party(), minimum_depth: 8, @@ -1377,28 +1344,32 @@ mod tests { #[test] fn opener_balance_after_commitment_cost_total_sat_checks() { let feerate_per_kw: u32 = 15_000; - let anchor_channel_type = [0x40, 0x00, 0x00]; + let legacy = Features::new(); + let anchor = Features::from_bits(&[Features::OPTION_ANCHORS]); // Legacy fee: 15000 * 724 / 1000 = 10_860 sat // Anchor fee: 15000 * 1124 / 1000 = 16_860 sat; anchor_cost = 660 sat // Comfortably affordable let opener_balance_sat: u64 = 20_000; assert_eq!( - opener_balance_sat.checked_sub(CommitmentCost::new(feerate_per_kw, &[]).total_sat()), + opener_balance_sat + .checked_sub(CommitmentCost::new(feerate_per_kw, &legacy).total_sat()), Some(9_140), ); // Exact zero opener balance let opener_balance_sat: u64 = 10_860; assert_eq!( - opener_balance_sat.checked_sub(CommitmentCost::new(feerate_per_kw, &[]).total_sat()), + opener_balance_sat + .checked_sub(CommitmentCost::new(feerate_per_kw, &legacy).total_sat()), Some(0), ); // Balance does not cover the fee let opener_balance_sat: u64 = 10_000; assert_eq!( - opener_balance_sat.checked_sub(CommitmentCost::new(feerate_per_kw, &[]).total_sat()), + opener_balance_sat + .checked_sub(CommitmentCost::new(feerate_per_kw, &legacy).total_sat()), None ); @@ -1406,7 +1377,7 @@ mod tests { let opener_balance_sat: u64 = 17_500; assert_eq!( opener_balance_sat - .checked_sub(CommitmentCost::new(feerate_per_kw, &anchor_channel_type).total_sat()), + .checked_sub(CommitmentCost::new(feerate_per_kw, &anchor).total_sat()), None, ); } From b9e9fb06fda2b5331f22eb9bd607d85346e964a6 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 5 Aug 2026 14:58:18 +0530 Subject: [PATCH 03/16] smite-ir: use Features for channel_type bits Signed-off-by: Nishant Bansal --- smite-ir/src/operation.rs | 125 ++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 44 deletions(-) diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 20b1f64b..fccde02a 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -14,7 +14,7 @@ use std::fmt::Write; use bitcoin::{opcodes::all as opcodes, script::Builder, script::PushBytes}; use rand::{Rng, RngExt}; use serde::{Deserialize, Serialize}; -use smite::bolt::ShortChannelId; +use smite::bolt::{FeatureBit, Features, ShortChannelId}; use super::VariableType; @@ -520,56 +520,93 @@ impl ChannelTypeVariant { /// The feature bits (even/required) contained in this channel type. #[must_use] - pub fn bits(self) -> &'static [usize] { - // BOLT 9 feature bits: - // 12 = option_static_remotekey - // 22 = option_anchors - // 40 = zero_fee_commitments - // 46 = option_scid_alias - // 50 = option_zeroconf - // 80 = option_simple_taproot - // 180 = option_simple_taproot_staging - // 2022 = option_script_enforced_lease + pub fn bits(self) -> &'static [FeatureBit] { + use Features as F; match self { - Self::StaticRemoteKey => &[12], - Self::StaticRemoteKeyScidAlias => &[12, 46], - Self::StaticRemoteKeyZeroConf => &[12, 50], - Self::StaticRemoteKeyScidAliasZeroConf => &[12, 46, 50], - Self::Anchors => &[12, 22], - Self::AnchorsScidAlias => &[12, 22, 46], - Self::AnchorsZeroConf => &[12, 22, 50], - Self::AnchorsScidAliasZeroConf => &[12, 22, 46, 50], - Self::ZeroFeeCommitments => &[40], - Self::ZeroFeeCommitmentsScidAlias => &[40, 46], - Self::ZeroFeeCommitmentsZeroConf => &[40, 50], - Self::ZeroFeeCommitmentsScidAliasZeroConf => &[40, 46, 50], - Self::SimpleTaproot => &[80], - Self::SimpleTaprootScidAlias => &[80, 46], - Self::SimpleTaprootZeroConf => &[80, 50], - Self::SimpleTaprootScidAliasZeroConf => &[80, 46, 50], - Self::SimpleTaprootStaging => &[180], - Self::SimpleTaprootStagingScidAlias => &[180, 46], - Self::SimpleTaprootStagingZeroConf => &[180, 50], - Self::SimpleTaprootStagingScidAliasZeroConf => &[180, 46, 50], - Self::ScriptEnforcedLease => &[12, 22, 2022], - Self::ScriptEnforcedLeaseScidAlias => &[12, 22, 2022, 46], - Self::ScriptEnforcedLeaseZeroConf => &[12, 22, 2022, 50], - Self::ScriptEnforcedLeaseScidAliasZeroConf => &[12, 22, 2022, 46, 50], + Self::StaticRemoteKey => &[F::OPTION_STATIC_REMOTEKEY], + Self::StaticRemoteKeyScidAlias => &[F::OPTION_STATIC_REMOTEKEY, F::OPTION_SCID_ALIAS], + Self::StaticRemoteKeyZeroConf => &[F::OPTION_STATIC_REMOTEKEY, F::OPTION_ZEROCONF], + Self::StaticRemoteKeyScidAliasZeroConf => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_SCID_ALIAS, + F::OPTION_ZEROCONF, + ], + Self::Anchors => &[F::OPTION_STATIC_REMOTEKEY, F::OPTION_ANCHORS], + Self::AnchorsScidAlias => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_SCID_ALIAS, + ], + Self::AnchorsZeroConf => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_ZEROCONF, + ], + Self::AnchorsScidAliasZeroConf => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_SCID_ALIAS, + F::OPTION_ZEROCONF, + ], + Self::ZeroFeeCommitments => &[F::ZERO_FEE_COMMITMENTS], + Self::ZeroFeeCommitmentsScidAlias => &[F::ZERO_FEE_COMMITMENTS, F::OPTION_SCID_ALIAS], + Self::ZeroFeeCommitmentsZeroConf => &[F::ZERO_FEE_COMMITMENTS, F::OPTION_ZEROCONF], + Self::ZeroFeeCommitmentsScidAliasZeroConf => &[ + F::ZERO_FEE_COMMITMENTS, + F::OPTION_SCID_ALIAS, + F::OPTION_ZEROCONF, + ], + Self::SimpleTaproot => &[F::OPTION_SIMPLE_TAPROOT], + Self::SimpleTaprootScidAlias => &[F::OPTION_SIMPLE_TAPROOT, F::OPTION_SCID_ALIAS], + Self::SimpleTaprootZeroConf => &[F::OPTION_SIMPLE_TAPROOT, F::OPTION_ZEROCONF], + Self::SimpleTaprootScidAliasZeroConf => &[ + F::OPTION_SIMPLE_TAPROOT, + F::OPTION_SCID_ALIAS, + F::OPTION_ZEROCONF, + ], + Self::SimpleTaprootStaging => &[F::OPTION_SIMPLE_TAPROOT_STAGING], + Self::SimpleTaprootStagingScidAlias => { + &[F::OPTION_SIMPLE_TAPROOT_STAGING, F::OPTION_SCID_ALIAS] + } + Self::SimpleTaprootStagingZeroConf => { + &[F::OPTION_SIMPLE_TAPROOT_STAGING, F::OPTION_ZEROCONF] + } + Self::SimpleTaprootStagingScidAliasZeroConf => &[ + F::OPTION_SIMPLE_TAPROOT_STAGING, + F::OPTION_SCID_ALIAS, + F::OPTION_ZEROCONF, + ], + Self::ScriptEnforcedLease => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_SCRIPT_ENFORCED_LEASE, + ], + Self::ScriptEnforcedLeaseScidAlias => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_SCRIPT_ENFORCED_LEASE, + F::OPTION_SCID_ALIAS, + ], + Self::ScriptEnforcedLeaseZeroConf => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_SCRIPT_ENFORCED_LEASE, + F::OPTION_ZEROCONF, + ], + Self::ScriptEnforcedLeaseScidAliasZeroConf => &[ + F::OPTION_STATIC_REMOTEKEY, + F::OPTION_ANCHORS, + F::OPTION_SCRIPT_ENFORCED_LEASE, + F::OPTION_SCID_ALIAS, + F::OPTION_ZEROCONF, + ], } } /// Encodes the channel type as a BOLT feature bitmap (big-endian bytes). #[must_use] - #[allow(clippy::missing_panics_doc)] // bits() is always non-empty pub fn encode(self) -> Vec { - let bits = self.bits(); - let max_bit = *bits.iter().max().expect("non-empty bits"); - let num_bytes = max_bit / 8 + 1; - let mut out = vec![0u8; num_bytes]; - for &bit in bits { - out[num_bytes - 1 - bit / 8] |= 1 << (bit % 8); - } - out + Features::from_bits(self.bits()).into_bytes() } } From 40b41b3aaba9c7f5dd560cddaf2e1d844515a2ad Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 5 Aug 2026 15:08:36 +0530 Subject: [PATCH 04/16] smite-scenarios: use Features for feature bits in setup Signed-off-by: Nishant Bansal --- smite-scenarios/src/scenarios/setup.rs | 61 ++++++++++---------------- 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/smite-scenarios/src/scenarios/setup.rs b/smite-scenarios/src/scenarios/setup.rs index 08422d86..1daf89e9 100644 --- a/smite-scenarios/src/scenarios/setup.rs +++ b/smite-scenarios/src/scenarios/setup.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use smite::bolt::{Init, InitTlvs, Message}; +use smite::bolt::{FeatureBit, Features, Init, InitTlvs, Message}; use smite::noise::NoiseConnection; use smite::scenarios::ScenarioError; @@ -30,49 +30,34 @@ pub trait SnapshotSetup { fn setup(target: &T) -> Result<(NoiseConnection, ProgramContext), ScenarioError>; } -/// Clears a feature bit from a feature vector. -/// -/// Feature vectors are encoded as big-endian byte arrays where bit N lives in -/// byte `features[len - 1 - N/8]` at position `N % 8`. -fn clear_feature_bit(features: &mut [u8], bit: usize) { - let byte_index = features.len().checked_sub(1 + bit / 8); - if let Some(i) = byte_index { - features[i] &= !(1 << (bit % 8)); - } -} - -/// Gossip-related feature bits (BOLT 9): `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. -const GOSSIP_FEATURE_BITS: &[usize] = &[6, 7, 10, 11]; - -/// Feature bits that force a dual-funded flow when both peers support them: -/// `option_dual_fund` (28/29). Eclair in particular will not allow -/// single-funded flows if either of these feature bits is set, so we strip them -/// when fuzzing the single-funded flow. -const DUAL_FUNDING_FEATURE_BITS: &[usize] = &[28, 29]; - -/// Peer storage feature bits: `option_provide_storage` (42/43). When enabled, -/// peers may send `peer_storage` and `peer_storage_retrieval` messages at -/// arbitrary times. Disabling these bits eliminates peer storage noise. -const PEER_STORAGE_FEATURE_BITS: &[usize] = &[42, 43]; +/// 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::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 { - let mut globalfeatures = received.globalfeatures.clone(); - let mut features = received.features.clone(); - for &bit in GOSSIP_FEATURE_BITS - .iter() - .chain(DUAL_FUNDING_FEATURE_BITS) - .chain(PEER_STORAGE_FEATURE_BITS) - { - clear_feature_bit(&mut globalfeatures, bit); - clear_feature_bit(&mut features, bit); + let mut globalfeatures = Features::from(received.globalfeatures.clone()); + let mut features = Features::from(received.features.clone()); + for &bit in STRIPPED_FEATURES { + globalfeatures.clear_feature(bit); + features.clear_feature(bit); } Init { - globalfeatures, - features, + globalfeatures: globalfeatures.into_bytes(), + features: features.into_bytes(), tlvs: InitTlvs::default(), } } From 74ecd0912be787ba7e1506714f83ae59e9c4071b Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Sat, 8 Aug 2026 14:34:13 +0530 Subject: [PATCH 05/16] smite: use Features for channel_type in accept_channel oracle Signed-off-by: Nishant Bansal --- smite/src/oracles/accept_channel.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs index 3ab16320..c2e73bc3 100644 --- a/smite/src/oracles/accept_channel.rs +++ b/smite/src/oracles/accept_channel.rs @@ -1,7 +1,7 @@ //! BOLT 2 `accept_channel` oracle, for the v1 outbound channel funding flow. use super::Oracle; -use crate::bolt::{AcceptChannel, OpenChannel}; +use crate::bolt::{AcceptChannel, Features, OpenChannel}; use crate::channel_tx::CommitmentCost; use crate::pending_channel::PendingChannel; use crate::violation::Violation; @@ -105,7 +105,12 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String // Check that the channel type was included. // TODO: Check option_channel_type in negotiated features since it is // assumed to be supported. - let Some(channel_type) = open_channel.tlvs.channel_type.as_deref() else { + let Some(channel_type) = open_channel + .tlvs + .channel_type + .as_deref() + .map(Features::from) + else { return Err("open_channel does not include a channel_type".to_string()); }; @@ -131,7 +136,7 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String // Check the initial commitment satisfies the channel reserve. verify_initial_commitment( open_channel, - channel_type, + &channel_type, open_channel.channel_reserve_satoshis, ) } @@ -150,7 +155,12 @@ fn verify_accept_channel( open_channel: &OpenChannel, ) -> Result<(), String> { // Check that the channel type was included. - let Some(channel_type) = accept_channel.tlvs.channel_type.as_deref() else { + let Some(channel_type) = accept_channel + .tlvs + .channel_type + .as_deref() + .map(Features::from) + else { return Err("accept_channel does not include a channel_type".to_string()); }; @@ -197,7 +207,7 @@ fn verify_accept_channel( // Check the initial commitment satisfies the channel reserve. verify_initial_commitment( open_channel, - channel_type, + &channel_type, accept_channel.channel_reserve_satoshis, ) } @@ -219,7 +229,7 @@ fn verify_accept_channel( /// they are not unnecessarily subtracted for these channel types. fn verify_initial_commitment( open_channel: &OpenChannel, - channel_type: &[u8], + channel_type: &Features, channel_reserve_satoshis: u64, ) -> Result<(), String> { // Check that the opener can afford the proposed feerate. From d6c23acdd55f555aaa90a4371794bfc901086df6 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:01:59 -0300 Subject: [PATCH 06/16] smite: add MuSig2 nonce and partial signature wire types Simple taproot channels carry a 66-byte MuSig2 public nonce and a 98-byte partial signature paired with the nonce that produced it. Both are opaque byte containers rather than parsed points. Decoding must not reject a malformed nonce: a target that sends one is violating the spec, and smite reports that as a finding instead of failing to decode the message. --- smite/src/bolt.rs | 5 +++-- smite/src/bolt/types.rs | 37 +++++++++++++++++++++++++++++++++++++ smite/src/bolt/wire.rs | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 0e2ddcce..b6ab39af 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -75,8 +75,9 @@ pub use tx_remove_input::TxRemoveInput; pub use tx_remove_output::TxRemoveOutput; 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, - SHORT_CHANNEL_ID_SIZE, ShortChannelId, TXID_SIZE, Tu32, Tu64, + PARTIAL_SIGNATURE_SIZE, PARTIAL_SIGNATURE_WITH_NONCE_SIZE, PAYMENT_ONION_PACKET_SIZE, + PER_COMMITMENT_SECRET_SIZE, PUBLIC_KEY_SIZE, PUBLIC_NONCE_SIZE, PartialSignatureWithNonce, + PublicNonce, SHA256_HASH_SIZE, SHORT_CHANNEL_ID_SIZE, ShortChannelId, TXID_SIZE, Tu32, Tu64, }; pub use update_add_htlc::{UpdateAddHtlc, UpdateAddHtlcTlvs}; pub use update_fail_htlc::{UpdateFailHtlc, UpdateFailHtlcTlvs}; diff --git a/smite/src/bolt/types.rs b/smite/src/bolt/types.rs index d3851b86..e1e7ede4 100644 --- a/smite/src/bolt/types.rs +++ b/smite/src/bolt/types.rs @@ -35,6 +35,43 @@ pub const PAYMENT_ONION_PACKET_SIZE: usize = 1366; /// Size of a per-commitment secret in bytes. pub const PER_COMMITMENT_SECRET_SIZE: usize = 32; +/// Size of a `MuSig2` public nonce: two compressed secp256k1 points. +pub const PUBLIC_NONCE_SIZE: usize = 2 * PUBLIC_KEY_SIZE; + +/// Size of a `MuSig2` partial signature: the `s` scalar. +pub const PARTIAL_SIGNATURE_SIZE: usize = 32; + +/// Size of a `partial_signature_with_nonce`: `partial_signature || public_nonce`. +pub const PARTIAL_SIGNATURE_WITH_NONCE_SIZE: usize = PARTIAL_SIGNATURE_SIZE + PUBLIC_NONCE_SIZE; + +/// A `MuSig2` public nonce as it appears on the wire: `point_1 || point_2`. +/// +/// Deliberately an opaque byte container rather than a parsed pair of points. +/// Decoding must not reject a malformed nonce: a target that sends one is +/// violating the taproot channel spec, and smite reports that as a finding +/// instead of failing to parse the message. [`crate::musig`] interprets these +/// bytes when they are actually used for signing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PublicNonce(pub [u8; PUBLIC_NONCE_SIZE]); + +/// A `MuSig2` partial signature paired with the public nonce used to produce +/// it, carried in `funding_created`, `funding_signed` and `commitment_signed`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PartialSignatureWithNonce { + /// The `s` scalar of the signer's partial signature. + pub partial_signature: [u8; PARTIAL_SIGNATURE_SIZE], + /// The public nonce the signature was produced with. + pub public_nonce: PublicNonce, +} + +impl PublicNonce { + /// Returns the wire encoding. + #[must_use] + pub fn as_bytes(&self) -> &[u8; PUBLIC_NONCE_SIZE] { + &self.0 + } +} + /// A 32-byte channel identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] pub struct ChannelId(pub [u8; CHANNEL_ID_SIZE]); diff --git a/smite/src/bolt/wire.rs b/smite/src/bolt/wire.rs index 4181e9d0..81f103dd 100644 --- a/smite/src/bolt/wire.rs +++ b/smite/src/bolt/wire.rs @@ -2,7 +2,8 @@ use crate::bolt::BoltError; use crate::bolt::types::{ - BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, PUBLIC_KEY_SIZE, SHA256_HASH_SIZE, + BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, PARTIAL_SIGNATURE_SIZE, + PUBLIC_KEY_SIZE, PUBLIC_NONCE_SIZE, PartialSignatureWithNonce, PublicNonce, SHA256_HASH_SIZE, ShortChannelId, TXID_SIZE, Tu32, Tu64, }; use bitcoin::Txid; @@ -139,6 +140,38 @@ impl WireFormat for PublicKey { } } +impl WireFormat for PublicNonce { + /// Reads a 66-byte `MuSig2` public nonce. + /// + /// The bytes are not checked to be two valid points: a peer sending a + /// malformed nonce is a spec violation to report, not a decode failure. + fn read(data: &mut &[u8]) -> Result { + let bytes: [u8; PUBLIC_NONCE_SIZE] = WireFormat::read(data)?; + Ok(Self(bytes)) + } + + fn write(&self, out: &mut Vec) { + self.as_bytes().write(out); + } +} + +impl WireFormat for PartialSignatureWithNonce { + /// Reads a 98-byte `partial_signature || public_nonce`. + fn read(data: &mut &[u8]) -> Result { + let partial_signature: [u8; PARTIAL_SIGNATURE_SIZE] = WireFormat::read(data)?; + let public_nonce = PublicNonce::read(data)?; + Ok(Self { + partial_signature, + public_nonce, + }) + } + + fn write(&self, out: &mut Vec) { + self.partial_signature.write(out); + self.public_nonce.write(out); + } +} + impl WireFormat for ChannelId { fn read(data: &mut &[u8]) -> Result { let bytes: [u8; CHANNEL_ID_SIZE] = WireFormat::read(data)?; From c9c5176cf16ab62f21fa8b1e73144cd459a38207 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:02:31 -0300 Subject: [PATCH 07/16] smite: add BIP 327 MuSig2 helpers Key aggregation with the BIP 86 taproot tweak, nonce derivation, partial signing and partial signature verification, for the taproot funding output and commitment signatures. This is the only module that talks to the musig2 crate. That crate pulls its own secp256k1 version, distinct from the one bitcoin re-exports, so keys cross the boundary as serialized bytes and the rest of smite only ever sees bitcoin::secp256k1 types. Nonces are derived deterministically from the signer's funding pubkey and a per-use context rather than from the OS RNG. Seeding from public data would be unacceptable in a real node, but smite runs under Nyx snapshots where a crash must replay from the same input, and it holds throwaway keys and no funds. It also lets the first nonce be derived in open_channel, where only the funding pubkey is in scope. Key aggregation is checked against the funding test vector in bolt-simple-taproot.md. --- Cargo.lock | 160 ++++++++++++++++++- Cargo.toml | 1 + smite/Cargo.toml | 1 + smite/src/lib.rs | 2 + smite/src/musig.rs | 372 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 smite/src/musig.rs diff --git a/Cargo.lock b/Cargo.lock index c0b34f53..0056b0a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base58ck" version = "0.1.0" @@ -98,7 +104,7 @@ dependencies = [ "bitcoin_hashes", "hex-conservative", "hex_lit", - "secp256k1", + "secp256k1 0.29.1", ] [[package]] @@ -138,6 +144,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cc" version = "1.2.51" @@ -269,6 +284,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -364,6 +390,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -419,6 +454,20 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "musig2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183312a1f782d0e27c2e9aa4d68c151301caa39b38f48ea125c1d55ab1ff29f0" +dependencies = [ + "base16ct", + "hmac", + "secp", + "secp256k1 0.31.1", + "sha2", + "subtle", +] + [[package]] name = "nix" version = "0.30.1" @@ -472,6 +521,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -496,13 +554,42 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", ] [[package]] @@ -524,6 +611,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "secp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ef0ffc3b1b2720b00b919f6c697b1e64aca7bba54b93e0ef4b8560fac6f946" +dependencies = [ + "base16ct", + "secp256k1 0.31.1", + "subtle", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -531,7 +629,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes", - "secp256k1-sys", + "secp256k1-sys 0.10.1", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", ] [[package]] @@ -543,6 +652,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "serde" version = "1.0.228" @@ -595,6 +713,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -619,6 +748,7 @@ dependencies = [ "chacha20poly1305", "hex", "log", + "musig2", "nix", "serde", "serde_json", @@ -634,7 +764,7 @@ version = "0.0.0" dependencies = [ "bitcoin", "postcard", - "rand", + "rand 0.10.0", "serde", "smite", "thiserror", @@ -646,7 +776,7 @@ version = "0.0.0" dependencies = [ "log", "postcard", - "rand", + "rand 0.10.0", "simple_logger", "smite-ir", ] @@ -864,6 +994,26 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.8.2" diff --git a/Cargo.toml b/Cargo.toml index bf448bc9..051808bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ rand = { version = "0.10", default-features = false } simple_logger = { version = "5", default-features = false } postcard = { version = "1.1", default-features = false, features = ["alloc"] } bitcoin = "0.32" +musig2 = { version = "0.4", default-features = false, features = ["secp256k1"] } serde = { version = "1", features = ["derive"] } thiserror = "2" serde_json = "1" diff --git a/smite/Cargo.toml b/smite/Cargo.toml index 66a524f3..65575e28 100644 --- a/smite/Cargo.toml +++ b/smite/Cargo.toml @@ -14,6 +14,7 @@ workspace = true log.workspace = true simple_logger.workspace = true bitcoin.workspace = true +musig2.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/smite/src/lib.rs b/smite/src/lib.rs index cee57d9a..7a409ae1 100644 --- a/smite/src/lib.rs +++ b/smite/src/lib.rs @@ -9,6 +9,7 @@ //! - [`bitcoin`] - Utilities for interacting with `bitcoind` instances via `bitcoin-cli`. //! - [`bolt`] - BOLT message encoding and decoding. //! - [`channel_tx`] - BOLT 3 channel transaction construction (funding and commitment). +//! - [`musig`] - BIP 327 `MuSig2` helpers for simple taproot channels. //! - [`noise`] - BOLT 8 `Noise_XK` encrypted transport. //! - [`oracles`] - Protocol invariant checks. //! - [`pending_channel`] - BOLT 2 channel negotiation state. @@ -20,6 +21,7 @@ pub mod bitcoin; pub mod bolt; pub mod channel_tx; +pub mod musig; pub mod noise; #[cfg(feature = "nyx")] pub mod nyx_log; diff --git a/smite/src/musig.rs b/smite/src/musig.rs new file mode 100644 index 00000000..8fe41b13 --- /dev/null +++ b/smite/src/musig.rs @@ -0,0 +1,372 @@ +//! BIP-327 `MuSig2` helpers for simple taproot channels. +//! +//! Simple taproot channels replace the 2-of-2 P2WSH funding output with a +//! single P2TR output whose key is the `MuSig2` aggregate of both +//! `funding_pubkey`s, and replace the commitment ECDSA signature with a `MuSig2` +//! partial signature exchanged in `funding_created` / `funding_signed`. +//! +//! This module is the only place that talks to the `musig2` crate. That crate +//! pulls its own `secp256k1` version, distinct from the one `bitcoin` +//! re-exports, so every key crosses the boundary as serialized bytes and the +//! rest of smite only ever sees `bitcoin::secp256k1` types. +//! +//! The `musig2` dependency is removable once `bitcoin` 0.33 is stable: +//! `secp256k1` 0.32 ships `secp256k1::musig`, and `bitcoin` 0.33 re-exports +//! that version. + +use bitcoin::secp256k1::{PublicKey, SecretKey, XOnlyPublicKey}; +use musig2::secp256k1::PublicKey as MusigPublicKey; +use musig2::{AggNonce, BinaryEncoding, KeyAggContext, PubNonce, SecNonce, SecNonceBuilder}; + +use crate::bolt::{PARTIAL_SIGNATURE_SIZE, PublicNonce}; + +/// Errors produced while building or using a `MuSig2` funding session. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MusigError { + /// The two `funding_pubkey`s could not be aggregated. + #[error("failed to aggregate funding pubkeys: {0}")] + KeyAggregation(String), + + /// The BIP 86 taproot tweak could not be applied to the aggregate key. + #[error("failed to apply taproot tweak to aggregate funding key: {0}")] + Tweak(String), + + /// A public nonce received from the peer is not two compressed points. + #[error("public nonce is not two valid compressed secp256k1 points")] + InvalidPublicNonce, + + /// Producing our own partial signature failed. + #[error("failed to produce partial signature: {0}")] + Signing(String), +} + +/// A `MuSig2` secret nonce. +/// +/// Wraps `musig2::SecNonce` so that type stays inside this module +#[derive(Debug)] +pub struct SecretNonce(SecNonce); + +/// A `MuSig2` partial signature: the `s` scalar of one signer's contribution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PartialSignature(pub [u8; PARTIAL_SIGNATURE_SIZE]); + +/// Re-parses a secret key into the `musig2` crate's `secp256k1` version. +/// +/// Both crates validate the same 32-byte range, so a key valid in one is valid +/// in the other. +fn musig_seckey(funding_privkey: &SecretKey) -> musig2::secp256k1::SecretKey { + musig2::secp256k1::SecretKey::from_byte_array(funding_privkey.secret_bytes()) + .expect("a valid secp256k1 secret key is valid in either crate version") +} + +/// The `MuSig2` signing context for a channel's funding output. +/// +/// Holds the key aggregation context built from both `funding_pubkey`s, sorted +/// with `KeySort` and tweaked per BIP 86 as the taproot channel spec requires. +pub struct FundingKeys { + ctx: KeyAggContext, +} + +/// Derives a secret nonce deterministically from `context` and the signer's +/// `funding_pubkey`. +/// +/// # Panics +/// +/// Panics if `funding_pubkey` does not round-trip between the two `secp256k1` +/// versions, which cannot happen for a valid public key. +#[must_use] +pub fn derive_nonce(context: &[&[u8]], funding_pubkey: &PublicKey) -> SecretNonce { + let pubkey = MusigPublicKey::from_slice(&funding_pubkey.serialize()) + .expect("a valid secp256k1 pubkey is valid in either crate version"); + + let builder = context.iter().fold( + SecNonce::build_with_pubkey([0u8; 32], pubkey), + SecNonceBuilder::with_extra_input, + ); + + SecretNonce(builder.build()) +} + +/// Returns whether a nonce received from the peer is two valid compressed +/// secp256k1 points. +/// +/// The wire layer accepts any 66 bytes so that a malformed nonce reaches the +/// oracles instead of failing to decode; this is the check the spec requires +/// before the nonce is used. +#[must_use] +pub fn is_valid_public_nonce(nonce: &PublicNonce) -> bool { + PubNonce::from_bytes(nonce.as_bytes()).is_ok() +} + +/// Converts a wire nonce to the `musig2` crate's representation. +fn to_pub_nonce(nonce: &PublicNonce) -> Result { + PubNonce::from_bytes(nonce.as_bytes()).map_err(|_| MusigError::InvalidPublicNonce) +} + +impl SecretNonce { + /// Returns the matching public nonce, safe to send to the peer. + #[must_use] + pub fn public_nonce(&self) -> PublicNonce { + PublicNonce(self.0.public_nonce().to_bytes()) + } +} + +impl FundingKeys { + /// Builds the funding signing context from both `funding_pubkey`s. + /// + /// The keys are sorted with `KeySort` and aggregated with `KeyAgg`, then + /// tweaked with the BIP 86 unspendable-script-path tweak. The resulting key + /// is what the funding output pays to, so the argument order does not + /// matter. + /// + /// # Errors + /// + /// Returns [`MusigError::KeyAggregation`] or [`MusigError::Tweak`] if the + /// aggregate key cannot be formed, which cannot happen for two valid + /// distinct public keys. + pub fn new(pubkey1: &PublicKey, pubkey2: &PublicKey) -> Result { + // `KeySort` from BIP 327: lexicographic over the 33-byte compressed + // encodings. Sorting here is what lets both peers derive the same + // aggregate key without exchanging ordering information. + let mut serialized = [pubkey1.serialize(), pubkey2.serialize()]; + serialized.sort_unstable(); + + let keys: Vec = serialized + .iter() + .map(|bytes| { + MusigPublicKey::from_slice(bytes) + .map_err(|e| MusigError::KeyAggregation(e.to_string())) + }) + .collect::>()?; + + let ctx = KeyAggContext::new(keys) + .map_err(|e| MusigError::KeyAggregation(e.to_string()))? + .with_unspendable_taproot_tweak() + .map_err(|e| MusigError::Tweak(e.to_string()))?; + + Ok(Self { ctx }) + } + + /// Returns the tweaked aggregate key the funding output pays to. + /// + /// # Panics + /// + /// Panics if the aggregate key does not round-trip between the two + /// `secp256k1` versions, which cannot happen for a key `KeyAgg` produced. + #[must_use] + pub fn aggregate_pubkey(&self) -> XOnlyPublicKey { + let aggregate: MusigPublicKey = self.ctx.aggregated_pubkey(); + XOnlyPublicKey::from_slice(&aggregate.x_only_public_key().0.serialize()) + .expect("musig2 aggregate key is a valid x-only pubkey") + } + + /// Produces our partial signature over `sighash` for the counterparty's + /// commitment. + /// + /// `our_nonce` is the fresh signing nonce sent alongside the signature; + /// `their_nonce` is the peer's verification nonce from `open_channel` or + /// `accept_channel`. + /// + /// # Errors + /// + /// Returns [`MusigError::InvalidPublicNonce`] if either nonce cannot be + /// parsed, or [`MusigError::Signing`] if signing fails. + pub fn partial_sign( + &self, + sighash: &[u8; 32], + funding_privkey: &SecretKey, + our_nonce: SecretNonce, + their_nonce: &PublicNonce, + ) -> Result { + let aggregated_nonce = aggregate_nonce(&our_nonce.public_nonce(), their_nonce)?; + + let signature: musig2::PartialSignature = musig2::sign_partial( + &self.ctx, + musig_seckey(funding_privkey), + our_nonce.0, + &aggregated_nonce, + sighash, + ) + .map_err(|e| MusigError::Signing(e.to_string()))?; + + Ok(PartialSignature(signature.serialize())) + } + + /// Returns whether the peer's partial signature over `sighash` is valid. + /// + /// `their_nonce` is the signing nonce the peer sent alongside the + /// signature; `our_nonce` is the verification nonce we sent earlier. + /// + /// # Errors + /// + /// Returns [`MusigError::InvalidPublicNonce`] if either nonce cannot be + /// parsed. An otherwise well-formed but incorrect signature yields + /// `Ok(false)` rather than an error. + pub fn verify_partial( + &self, + sighash: &[u8; 32], + signature: &PartialSignature, + their_pubkey: &PublicKey, + their_nonce: &PublicNonce, + our_nonce: &PublicNonce, + ) -> Result { + let aggregated_nonce = aggregate_nonce(our_nonce, their_nonce)?; + let Ok(signature) = musig2::PartialSignature::from_slice(&signature.0) else { + return Ok(false); + }; + let pubkey = MusigPublicKey::from_slice(&their_pubkey.serialize()) + .map_err(|e| MusigError::KeyAggregation(e.to_string()))?; + + Ok(musig2::verify_partial( + &self.ctx, + signature, + &aggregated_nonce, + pubkey, + &to_pub_nonce(their_nonce)?, + sighash, + ) + .is_ok()) + } +} + +/// Combines both public nonces with `NonceAgg`. Point addition is commutative, +/// so the argument order does not matter. +fn aggregate_nonce(nonce1: &PublicNonce, nonce2: &PublicNonce) -> Result { + Ok(AggNonce::sum([ + to_pub_nonce(nonce1)?, + to_pub_nonce(nonce2)?, + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pubkey(hex_str: &str) -> PublicKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + PublicKey::from_slice(&bytes).expect("valid pubkey") + } + + fn secret(hex_str: &str) -> SecretKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + SecretKey::from_slice(&bytes).expect("valid secret key") + } + + // Test vectors from the simple taproot channels spec: + // bolt-simple-taproot.md, "Test Vectors" appendix. + + /// The spec's `funding` vector: the aggregate of the two `funding_pubkey`s, + /// sorted and BIP 86 tweaked, is the key the funding output pays to. + #[test] + fn aggregate_funding_key_matches_spec_vector() { + let local = pubkey("03b7203dec7c13896b6ff1f58b24f84458c441720a12b5a57426397e22f0a8c78b"); + let remote = pubkey("02956e6845a6f346f97c5e028c0f8ab38a76b0124fd7184deab60f682b3e657fdb"); + + let keys = FundingKeys::new(&local, &remote).expect("valid funding pubkeys"); + + assert_eq!( + hex::encode(keys.aggregate_pubkey().serialize()), + "d0ebb4909d563a7ae1213fddede4ae54132fba0ef0b97ee3f8469191fecd348e" + ); + } + + /// `KeySort` runs before `KeyAgg`, so the caller's argument order cannot + /// change the funding output. + #[test] + fn aggregate_funding_key_is_argument_order_independent() { + let local = pubkey("03b7203dec7c13896b6ff1f58b24f84458c441720a12b5a57426397e22f0a8c78b"); + let remote = pubkey("02956e6845a6f346f97c5e028c0f8ab38a76b0124fd7184deab60f682b3e657fdb"); + + let one = FundingKeys::new(&local, &remote).expect("valid funding pubkeys"); + let other = FundingKeys::new(&remote, &local).expect("valid funding pubkeys"); + + assert_eq!(one.aggregate_pubkey(), other.aggregate_pubkey()); + } + + /// A partial signature produced by one side verifies against the other + /// side's view of the same session. + #[test] + fn partial_signature_round_trip() { + let local_sk = secret("20ae2d254ab29afd3dcbf8744a5b88d06070f55a4bd5532483a093ac4db91277"); + let local = pubkey("03b7203dec7c13896b6ff1f58b24f84458c441720a12b5a57426397e22f0a8c78b"); + let remote = pubkey("02956e6845a6f346f97c5e028c0f8ab38a76b0124fd7184deab60f682b3e657fdb"); + + let keys = FundingKeys::new(&local, &remote).expect("valid funding pubkeys"); + let sighash = [0x42u8; 32]; + + // The verifier's nonce is sent first, in `open_channel`. + let verification = derive_nonce(&[b"verification"], &remote); + // The signer's nonce accompanies the signature in `funding_created`. + let signing = derive_nonce(&[b"signing"], &local); + let signing_public = signing.public_nonce(); + + let signature = keys + .partial_sign(&sighash, &local_sk, signing, &verification.public_nonce()) + .expect("signing succeeds"); + + assert!( + keys.verify_partial( + &sighash, + &signature, + &local, + &signing_public, + &verification.public_nonce(), + ) + .expect("nonces parse") + ); + } + + /// A signature over a different commitment must not verify, otherwise + /// `funding_signed` verification would accept anything. + #[test] + fn partial_signature_over_wrong_sighash_is_rejected() { + let local_sk = secret("20ae2d254ab29afd3dcbf8744a5b88d06070f55a4bd5532483a093ac4db91277"); + let local = pubkey("03b7203dec7c13896b6ff1f58b24f84458c441720a12b5a57426397e22f0a8c78b"); + let remote = pubkey("02956e6845a6f346f97c5e028c0f8ab38a76b0124fd7184deab60f682b3e657fdb"); + + let keys = FundingKeys::new(&local, &remote).expect("valid funding pubkeys"); + + let verification = derive_nonce(&[b"verification"], &remote); + let signing = derive_nonce(&[b"signing"], &local); + let signing_public = signing.public_nonce(); + + let signature = keys + .partial_sign( + &[0x42u8; 32], + &local_sk, + signing, + &verification.public_nonce(), + ) + .expect("signing succeeds"); + + assert!( + !keys + .verify_partial( + &[0x43u8; 32], + &signature, + &local, + &signing_public, + &verification.public_nonce(), + ) + .expect("nonces parse") + ); + } + + /// Peer nonces arrive as untrusted bytes; the spec requires failing the + /// channel when they are not two compressed points. + #[test] + fn malformed_public_nonce_is_not_valid() { + assert!(!is_valid_public_nonce(&PublicNonce( + [0x00; crate::bolt::PUBLIC_NONCE_SIZE] + ))); + } + + #[test] + fn generated_public_nonce_is_valid() { + let key = pubkey("03b7203dec7c13896b6ff1f58b24f84458c441720a12b5a57426397e22f0a8c78b"); + + assert!(is_valid_public_nonce( + &derive_nonce(&[b"ctx"], &key).public_nonce() + )); + } +} From e08a9d6a958d10ea7e02afba5a0b598f5060e5d9 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:03:13 -0300 Subject: [PATCH 08/16] smite: add the simple taproot channel TLVs next_local_nonce (type 4) on open_channel, accept_channel and channel_ready, and partial_signature_with_nonce (type 2) on funding_created and funding_signed. funding_created and funding_signed had no TLV stream at all, so trailing bytes were silently dropped; they now decode one. Both TLV types are even, so each message that accepts one must whitelist it as known, and channel_ready's stream had to move off the reject-all-even path. Nothing emits these yet. The executor and oracle changes here only keep their fixtures compiling against the new fields. --- smite-scenarios/src/executor.rs | 22 ++++-- smite/src/bolt.rs | 6 +- smite/src/bolt/accept_channel.rs | 65 +++++++++++++++--- smite/src/bolt/channel_ready.rs | 30 +++++++-- smite/src/bolt/funding_created.rs | 101 +++++++++++++++++++++++++++- smite/src/bolt/funding_signed.rs | 82 +++++++++++++++++++++- smite/src/bolt/open_channel.rs | 84 ++++++++++++++++++++--- smite/src/oracles/accept_channel.rs | 3 +- 8 files changed, 355 insertions(+), 38 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index a8e42dfa..49d69159 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -9,8 +9,9 @@ use bitcoin::{OutPoint, ScriptBuf, Txid}; use smite::bitcoin::{BitcoinCli, TxBlockPosition, Utxo}; use smite::bolt::{ AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady, - ChannelReadyTlvs, ChannelUpdate, Features, FundingCreated, FundingSigned, Message, - NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, msg_type, + ChannelReadyTlvs, ChannelUpdate, Features, FundingCreated, FundingCreatedTlvs, FundingSigned, + Message, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, + msg_type, }; use smite::channel_tx::{ ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side, @@ -854,6 +855,7 @@ fn build_open_channel(variables: &[Option], inputs: &[usize]) -> OpenC // not negotiated is not. upfront_shutdown_script: Some(resolve_bytes(variables, inputs[18]).to_vec()), channel_type: nonempty_or_none(resolve_features(variables, inputs[19])), + next_local_nonce: None, }, } } @@ -895,6 +897,7 @@ fn build_funding_created( funding_output_index, signature: Signature::from_compact(&[0u8; 64]) .expect("zero bytes parse as a signature"), + tlvs: FundingCreatedTlvs::default(), }); }; let open_channel = &pending.open_channel; @@ -905,6 +908,7 @@ fn build_funding_created( funding_output_index, signature: Signature::from_compact(&[0u8; 64]) .expect("zero bytes parse as a signature"), + tlvs: FundingCreatedTlvs::default(), }); }; @@ -986,6 +990,7 @@ fn build_funding_created( funding_txid: funding_outpoint.txid, funding_output_index, signature, + tlvs: FundingCreatedTlvs::default(), }) } @@ -1018,7 +1023,10 @@ fn build_channel_ready( ChannelReady { channel_id, second_per_commitment_point, - tlvs: ChannelReadyTlvs { short_channel_id }, + tlvs: ChannelReadyTlvs { + short_channel_id, + next_local_nonce: None, + }, } } @@ -1433,7 +1441,7 @@ mod tests { use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use bitcoin::{Amount, Transaction}; - use smite::bolt::{AcceptChannelTlvs, GossipTimestampFilter, Init, Ping}; + use smite::bolt::{AcceptChannelTlvs, FundingSignedTlvs, GossipTimestampFilter, Init, Ping}; use smite_ir::Instruction; use smite_ir::operation::ShutdownScriptVariant; @@ -1601,6 +1609,7 @@ mod tests { htlc_basepoint: sample_pubkey(5), first_per_commitment_point: sample_pubkey(6), tlvs: AcceptChannelTlvs { + next_local_nonce: None, upfront_shutdown_script: Some(vec![0xde, 0xad]), channel_type: Some(vec![0x40, 0x10, 0x00]), }, @@ -3376,6 +3385,7 @@ mod tests { let fs_bytes = Message::FundingSigned(FundingSigned { channel_id, signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), + tlvs: FundingSignedTlvs::default(), }) .encode(); @@ -3581,6 +3591,7 @@ mod tests { let fs_bytes = Message::FundingSigned(FundingSigned { channel_id, signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), + tlvs: FundingSignedTlvs::default(), }) .encode(); @@ -3621,6 +3632,7 @@ mod tests { channel_id, signature: Signature::from_compact(&[0u8; 64]) .expect("zero bytes parse as a signature"), + tlvs: FundingSignedTlvs::default(), }) .encode(); @@ -3689,6 +3701,7 @@ mod tests { let fs_bytes = Message::FundingSigned(FundingSigned { channel_id, signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), + tlvs: FundingSignedTlvs::default(), }) .encode(); let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); @@ -3843,6 +3856,7 @@ mod tests { let fs_bytes = Message::FundingSigned(FundingSigned { channel_id, signature: "304402203dbf3dbf337b042a72576488c1fb019086089d8d790a47f652346cff2511b6e70220395fdf700cb82b0abfcfe8e0b7c822181f2ee72409c82c3ff8e04e36593662c7".parse().unwrap(), + tlvs: FundingSignedTlvs::default(), }) .encode(); diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index b6ab39af..41eef5af 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -54,8 +54,8 @@ pub use closing_sig::ClosingSig; pub use commitment_signed::{CommitmentSigned, CommitmentSignedTlvs}; pub use error::Error; pub use features::{FeatureBit, Features}; -pub use funding_created::FundingCreated; -pub use funding_signed::FundingSigned; +pub use funding_created::{FundingCreated, FundingCreatedTlvs}; +pub use funding_signed::{FundingSigned, FundingSignedTlvs}; pub use gossip_timestamp_filter::GossipTimestampFilter; pub use init::{Init, InitTlvs}; pub use node_announcement::NodeAnnouncement; @@ -604,6 +604,7 @@ mod tests { funding_txid: Txid::from_byte_array([0xcc; TXID_SIZE]), funding_output_index: 0, signature: sig, + tlvs: FundingCreatedTlvs::default(), } } @@ -626,6 +627,7 @@ mod tests { FundingSigned { channel_id: ChannelId::new([0xbb; CHANNEL_ID_SIZE]), signature: sig, + tlvs: FundingSignedTlvs::default(), } } diff --git a/smite/src/bolt/accept_channel.rs b/smite/src/bolt/accept_channel.rs index 506aac75..cd4808d7 100644 --- a/smite/src/bolt/accept_channel.rs +++ b/smite/src/bolt/accept_channel.rs @@ -2,7 +2,7 @@ use super::BoltError; use super::tlv::TlvStream; -use super::types::ChannelId; +use super::types::{ChannelId, PublicNonce}; use super::wire::WireFormat; use bitcoin::secp256k1::PublicKey; @@ -12,6 +12,9 @@ const TLV_UPFRONT_SHUTDOWN_SCRIPT: u64 = 0; /// TLV type for channel type. const TLV_CHANNEL_TYPE: u64 = 1; +/// TLV type for the `MuSig2` verification nonce of simple taproot channels. +const TLV_NEXT_LOCAL_NONCE: u64 = 4; + /// BOLT 2 `accept_channel` message (type 33). /// /// Sent by the channel acceptor in response to `open_channel` to continue the v1 channel @@ -57,6 +60,9 @@ pub struct AcceptChannelTlvs { pub upfront_shutdown_script: Option>, /// The channel type represented as feature bits pub channel_type: Option>, + /// The `MuSig2` nonce the acceptor will use to verify incoming commitment + /// signatures. Required for simple taproot channels. + pub next_local_nonce: Option, } impl AcceptChannel { @@ -87,6 +93,11 @@ impl AcceptChannel { if let Some(channel_type) = &self.tlvs.channel_type { tlv_stream.add(TLV_CHANNEL_TYPE, channel_type.clone()); } + if let Some(next_local_nonce) = &self.tlvs.next_local_nonce { + let mut value = Vec::new(); + next_local_nonce.write(&mut value); + tlv_stream.add(TLV_NEXT_LOCAL_NONCE, value); + } out.extend(tlv_stream.encode()); out @@ -117,10 +128,14 @@ impl AcceptChannel { let first_per_commitment_point = WireFormat::read(&mut cursor)?; // Decode TLVs (remaining bytes) - // Type 0 (`upfront_shutdown_script`) is an even type defined by BOLT 2, - // so we must whitelist it as known. - let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_UPFRONT_SHUTDOWN_SCRIPT])?; - let tlvs = AcceptChannelTlvs::from_stream(&tlv_stream); + // Types 0 (`upfront_shutdown_script`) and 4 (`next_local_nonce`) are + // even types defined by BOLT 2 and the simple taproot channels + // extension, so we must whitelist them as known. + let tlv_stream = TlvStream::decode_with_known( + cursor, + &[TLV_UPFRONT_SHUTDOWN_SCRIPT, TLV_NEXT_LOCAL_NONCE], + )?; + let tlvs = AcceptChannelTlvs::from_stream(&tlv_stream)?; Ok(Self { temporary_channel_id, @@ -144,20 +159,27 @@ impl AcceptChannel { impl AcceptChannelTlvs { /// Extracts accept channel TLVs from a parsed TLV stream. - fn from_stream(stream: &TlvStream) -> Self { + /// + /// # Errors + /// + /// Returns a `BoltError` if the `next_local_nonce` TLV has an invalid + /// length. + fn from_stream(stream: &TlvStream) -> Result { let upfront_shutdown_script = stream.get(TLV_UPFRONT_SHUTDOWN_SCRIPT).map(Vec::from); let channel_type = stream.get(TLV_CHANNEL_TYPE).map(Vec::from); + let next_local_nonce = stream.get_as::(TLV_NEXT_LOCAL_NONCE)?; - Self { + Ok(Self { upfront_shutdown_script, channel_type, - } + next_local_nonce, + }) } } #[cfg(test)] mod tests { - use super::super::PUBLIC_KEY_SIZE; + use super::super::{PUBLIC_KEY_SIZE, PUBLIC_NONCE_SIZE}; use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -408,6 +430,7 @@ mod tests { let original = sample_accept_channel(Some(AcceptChannelTlvs { upfront_shutdown_script: Some(vec![0xab; 22]), channel_type: Some(vec![0x01, 0x02]), + next_local_nonce: Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])), })); let encoded = original.encode(); @@ -418,8 +441,8 @@ mod tests { #[test] fn encode_with_channel_type() { let accept = sample_accept_channel(Some(AcceptChannelTlvs { - upfront_shutdown_script: None, channel_type: Some(vec![0x01, 0x02]), + ..Default::default() })); let encoded = accept.encode(); @@ -438,6 +461,7 @@ mod tests { // P2WPKH like script upfront_shutdown_script: Some(script), channel_type: Some(vec![0x01]), + ..Default::default() })); let encoded = accept.encode(); @@ -486,5 +510,26 @@ mod tests { let tlvs = AcceptChannelTlvs::default(); assert!(tlvs.upfront_shutdown_script.is_none()); assert!(tlvs.channel_type.is_none()); + assert!(tlvs.next_local_nonce.is_none()); + } + + /// Simple taproot channels require the acceptor's `MuSig2` verification + /// nonce in TLV type 4. + #[test] + fn encode_with_next_local_nonce() { + let accept = sample_accept_channel(Some(AcceptChannelTlvs { + next_local_nonce: Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])), + ..Default::default() + })); + + let encoded = accept.encode(); + // 270 fixed + TLV: type(1) + len(1) + value(66) = 68 + assert_eq!(encoded.len(), 270 + 68); + + let decoded = AcceptChannel::decode(&encoded).unwrap(); + assert_eq!( + decoded.tlvs.next_local_nonce, + Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])) + ); } } diff --git a/smite/src/bolt/channel_ready.rs b/smite/src/bolt/channel_ready.rs index 4025a67b..13f67330 100644 --- a/smite/src/bolt/channel_ready.rs +++ b/smite/src/bolt/channel_ready.rs @@ -2,13 +2,16 @@ use super::BoltError; use super::tlv::TlvStream; -use super::types::{ChannelId, ShortChannelId}; +use super::types::{ChannelId, PublicNonce, ShortChannelId}; use super::wire::WireFormat; use bitcoin::secp256k1::PublicKey; /// TLV type for short channel ID alias. const TLV_SHORT_CHANNEL_ID: u64 = 1; +/// TLV type for the `MuSig2` verification nonce of simple taproot channels. +const TLV_NEXT_LOCAL_NONCE: u64 = 4; + /// BOLT 2 `channel_ready` message (type 36). /// /// Sent by each side once the funding transaction has reached the agreed-upon @@ -29,6 +32,9 @@ pub struct ChannelReadyTlvs { /// An alias SCID for this channel, used for forwarding before confirmation /// and for private channels instead of the real `short_channel_id`. pub short_channel_id: Option, + /// A fresh `MuSig2` verification nonce, replacing the one consumed by the + /// funding flow. Required for simple taproot channels. + pub next_local_nonce: Option, } impl ChannelReady { @@ -46,6 +52,11 @@ impl ChannelReady { scid.write(&mut value); tlv_stream.add(TLV_SHORT_CHANNEL_ID, value); } + if let Some(next_local_nonce) = &self.tlvs.next_local_nonce { + let mut value = Vec::new(); + next_local_nonce.write(&mut value); + tlv_stream.add(TLV_NEXT_LOCAL_NONCE, value); + } out.extend(tlv_stream.encode()); out @@ -64,7 +75,9 @@ impl ChannelReady { let second_per_commitment_point = WireFormat::read(&mut cursor)?; // Decode TLVs (remaining bytes) - let tlv_stream = TlvStream::decode(cursor)?; + // Type 4 (`next_local_nonce`) is an even type defined by the simple + // taproot channels extension, so we must whitelist it as known. + let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_NEXT_LOCAL_NONCE])?; let tlvs = ChannelReadyTlvs::from_stream(&tlv_stream)?; Ok(Self { @@ -80,16 +93,21 @@ impl ChannelReadyTlvs { /// /// # Errors /// - /// Returns a `BoltError` if the short channel ID TLV has invalid length. + /// Returns a `BoltError` if the short channel ID or `next_local_nonce` + /// TLV has an invalid length. fn from_stream(stream: &TlvStream) -> Result { let short_channel_id = stream.get_as::(TLV_SHORT_CHANNEL_ID)?; - Ok(Self { short_channel_id }) + let next_local_nonce = stream.get_as::(TLV_NEXT_LOCAL_NONCE)?; + Ok(Self { + short_channel_id, + next_local_nonce, + }) } } #[cfg(test)] mod tests { - use super::super::{CHANNEL_ID_SIZE, PUBLIC_KEY_SIZE}; + use super::super::{CHANNEL_ID_SIZE, PUBLIC_KEY_SIZE, PUBLIC_NONCE_SIZE}; use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -160,6 +178,7 @@ mod tests { fn roundtrip_with_tlvs() { let original = sample_channel_ready(Some(ChannelReadyTlvs { short_channel_id: Some(ShortChannelId::from_u64(1_029_637_663_919_046_661)), + next_local_nonce: Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])), })); let encoded = original.encode(); @@ -171,6 +190,7 @@ mod tests { fn encode_with_short_channel_id() { let msg = sample_channel_ready(Some(ChannelReadyTlvs { short_channel_id: Some(ShortChannelId::from_u64(1_029_637_663_919_046_661)), + ..Default::default() })); let encoded = msg.encode(); diff --git a/smite/src/bolt/funding_created.rs b/smite/src/bolt/funding_created.rs index e7a4bbb9..de27d4a8 100644 --- a/smite/src/bolt/funding_created.rs +++ b/smite/src/bolt/funding_created.rs @@ -1,11 +1,15 @@ //! BOLT 2 funding created message. use super::BoltError; -use super::types::ChannelId; +use super::tlv::TlvStream; +use super::types::{ChannelId, PartialSignatureWithNonce}; use super::wire::WireFormat; use bitcoin::Txid; use bitcoin::secp256k1::ecdsa::Signature; +/// TLV type for the `MuSig2` partial signature of simple taproot channels. +const TLV_PARTIAL_SIGNATURE_WITH_NONCE: u64 = 2; + /// BOLT 2 `funding_created` message (type 34). /// /// Sent by the channel initiator after receiving `accept_channel` to provide the @@ -19,8 +23,23 @@ pub struct FundingCreated { pub funding_txid: Txid, /// The specific output index funding this channel pub funding_output_index: u16, - /// The channel initiator's signature for the counterparty's first commitment transaction + /// The channel initiator's signature for the counterparty's first commitment transaction. + /// + /// Simple taproot channels carry the real signature in + /// [`FundingCreatedTlvs::partial_signature_with_nonce`] and require this + /// field to be 64 zero bytes. pub signature: Signature, + /// Optional TLV extensions. + pub tlvs: FundingCreatedTlvs, +} + +/// TLV extensions for the `funding_created` message. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FundingCreatedTlvs { + /// The `MuSig2` partial signature over the counterparty's first commitment + /// transaction, with the nonce it was produced with. Required for simple + /// taproot channels. + pub partial_signature_with_nonce: Option, } impl FundingCreated { @@ -32,6 +51,16 @@ impl FundingCreated { self.funding_txid.write(&mut out); self.funding_output_index.write(&mut out); self.signature.write(&mut out); + + // Encode TLVs + let mut tlv_stream = TlvStream::new(); + if let Some(partial_signature_with_nonce) = &self.tlvs.partial_signature_with_nonce { + let mut value = Vec::new(); + partial_signature_with_nonce.write(&mut value); + tlv_stream.add(TLV_PARTIAL_SIGNATURE_WITH_NONCE, value); + } + out.extend(tlv_stream.encode()); + out } @@ -49,18 +78,45 @@ impl FundingCreated { let funding_output_index = WireFormat::read(&mut cursor)?; let signature = WireFormat::read(&mut cursor)?; + // Decode TLVs (remaining bytes) + // Type 2 (`partial_signature_with_nonce`) is an even type defined by + // the simple taproot channels extension, so we must whitelist it as + // known. + let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_PARTIAL_SIGNATURE_WITH_NONCE])?; + let tlvs = FundingCreatedTlvs::from_stream(&tlv_stream)?; + Ok(Self { temporary_channel_id, funding_txid, funding_output_index, signature, + tlvs, + }) + } +} + +impl FundingCreatedTlvs { + /// Extracts funding created TLVs from a parsed TLV stream. + /// + /// # Errors + /// + /// Returns a `BoltError` if the `partial_signature_with_nonce` TLV has an + /// invalid length. + fn from_stream(stream: &TlvStream) -> Result { + let partial_signature_with_nonce = + stream.get_as::(TLV_PARTIAL_SIGNATURE_WITH_NONCE)?; + Ok(Self { + partial_signature_with_nonce, }) } } #[cfg(test)] mod tests { - use super::super::{CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, TXID_SIZE}; + use super::super::{ + CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, PARTIAL_SIGNATURE_SIZE, PUBLIC_NONCE_SIZE, + PublicNonce, TXID_SIZE, + }; use super::*; use bitcoin::hashes::Hash; use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; @@ -77,6 +133,7 @@ mod tests { funding_txid: Txid::from_byte_array([0xcc; TXID_SIZE]), funding_output_index: 0, signature: sig, + tlvs: FundingCreatedTlvs::default(), } } @@ -165,4 +222,42 @@ mod tests { Err(BoltError::InvalidSignature(bad_sig)) ); } + + /// Simple taproot channels zero the fixed `signature` field and carry the + /// real signature in TLV type 2. + #[test] + fn encode_with_partial_signature_with_nonce() { + let mut msg = sample_funding_created(); + msg.signature = Signature::from_compact(&[0u8; COMPACT_SIGNATURE_SIZE]) + .expect("zero bytes parse as a signature"); + msg.tlvs.partial_signature_with_nonce = Some(PartialSignatureWithNonce { + partial_signature: [0xab; PARTIAL_SIGNATURE_SIZE], + public_nonce: PublicNonce([0xcd; PUBLIC_NONCE_SIZE]), + }); + + let encoded = msg.encode(); + // 130 fixed + TLV: type(1) + len(1) + value(98) = 100 + assert_eq!(encoded.len(), 130 + 100); + + assert_eq!(FundingCreated::decode(&encoded), Ok(msg)); + } + + /// A `partial_signature_with_nonce` of the wrong length must be rejected + /// rather than silently truncated. + #[test] + fn decode_rejects_partial_signature_of_wrong_length() { + let msg = sample_funding_created(); + let mut encoded = msg.encode(); + + // TLV type 2, length 2 -- too short for a 98-byte value. + encoded.extend_from_slice(&[0x02, 0x02, 0xaa, 0xbb]); + + assert_eq!( + FundingCreated::decode(&encoded), + Err(BoltError::Truncated { + expected: PARTIAL_SIGNATURE_SIZE, + actual: 2 + }) + ); + } } diff --git a/smite/src/bolt/funding_signed.rs b/smite/src/bolt/funding_signed.rs index 4713a879..60c84cc4 100644 --- a/smite/src/bolt/funding_signed.rs +++ b/smite/src/bolt/funding_signed.rs @@ -1,10 +1,14 @@ //! BOLT 2 funding signed message. use super::BoltError; -use super::types::ChannelId; +use super::tlv::TlvStream; +use super::types::{ChannelId, PartialSignatureWithNonce}; use super::wire::WireFormat; use bitcoin::secp256k1::ecdsa::Signature; +/// TLV type for the `MuSig2` partial signature of simple taproot channels. +const TLV_PARTIAL_SIGNATURE_WITH_NONCE: u64 = 2; + /// BOLT 2 `funding_signed` message (type 35). /// /// Sent by the channel acceptor in response to `funding_created` to provide @@ -13,8 +17,23 @@ use bitcoin::secp256k1::ecdsa::Signature; pub struct FundingSigned { /// The channel ID derived from the funding transaction outpoint pub channel_id: ChannelId, - /// The channel acceptor's signature for the counterparty's first commitment transaction + /// The channel acceptor's signature for the counterparty's first commitment transaction. + /// + /// Simple taproot channels carry the real signature in + /// [`FundingSignedTlvs::partial_signature_with_nonce`] and require this + /// field to be 64 zero bytes. pub signature: Signature, + /// Optional TLV extensions. + pub tlvs: FundingSignedTlvs, +} + +/// TLV extensions for the `funding_signed` message. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FundingSignedTlvs { + /// The `MuSig2` partial signature over the counterparty's first commitment + /// transaction, with the nonce it was produced with. Required for simple + /// taproot channels. + pub partial_signature_with_nonce: Option, } impl FundingSigned { @@ -24,6 +43,16 @@ impl FundingSigned { let mut out = Vec::new(); self.channel_id.write(&mut out); self.signature.write(&mut out); + + // Encode TLVs + let mut tlv_stream = TlvStream::new(); + if let Some(partial_signature_with_nonce) = &self.tlvs.partial_signature_with_nonce { + let mut value = Vec::new(); + partial_signature_with_nonce.write(&mut value); + tlv_stream.add(TLV_PARTIAL_SIGNATURE_WITH_NONCE, value); + } + out.extend(tlv_stream.encode()); + out } @@ -39,16 +68,43 @@ impl FundingSigned { let channel_id = WireFormat::read(&mut cursor)?; let signature = WireFormat::read(&mut cursor)?; + // Decode TLVs (remaining bytes) + // Type 2 (`partial_signature_with_nonce`) is an even type defined by + // the simple taproot channels extension, so we must whitelist it as + // known. + let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_PARTIAL_SIGNATURE_WITH_NONCE])?; + let tlvs = FundingSignedTlvs::from_stream(&tlv_stream)?; + Ok(Self { channel_id, signature, + tlvs, + }) + } +} + +impl FundingSignedTlvs { + /// Extracts funding signed TLVs from a parsed TLV stream. + /// + /// # Errors + /// + /// Returns a `BoltError` if the `partial_signature_with_nonce` TLV has an + /// invalid length. + fn from_stream(stream: &TlvStream) -> Result { + let partial_signature_with_nonce = + stream.get_as::(TLV_PARTIAL_SIGNATURE_WITH_NONCE)?; + Ok(Self { + partial_signature_with_nonce, }) } } #[cfg(test)] mod tests { - use super::super::{CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE}; + use super::super::{ + CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, PARTIAL_SIGNATURE_SIZE, PUBLIC_NONCE_SIZE, + PublicNonce, + }; use super::*; use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; @@ -62,6 +118,7 @@ mod tests { FundingSigned { channel_id: ChannelId::new([0xbb; CHANNEL_ID_SIZE]), signature: sig, + tlvs: FundingSignedTlvs::default(), } } @@ -122,4 +179,23 @@ mod tests { Err(BoltError::InvalidSignature(bad_sig)) ); } + + /// Simple taproot channels zero the fixed `signature` field and carry the + /// real signature in TLV type 2. + #[test] + fn encode_with_partial_signature_with_nonce() { + let mut msg = sample_funding_signed(); + msg.signature = Signature::from_compact(&[0u8; COMPACT_SIGNATURE_SIZE]) + .expect("zero bytes parse as a signature"); + msg.tlvs.partial_signature_with_nonce = Some(PartialSignatureWithNonce { + partial_signature: [0xab; PARTIAL_SIGNATURE_SIZE], + public_nonce: PublicNonce([0xcd; PUBLIC_NONCE_SIZE]), + }); + + let encoded = msg.encode(); + // 96 fixed + TLV: type(1) + len(1) + value(98) = 100 + assert_eq!(encoded.len(), 96 + 100); + + assert_eq!(FundingSigned::decode(&encoded), Ok(msg)); + } } diff --git a/smite/src/bolt/open_channel.rs b/smite/src/bolt/open_channel.rs index 0a61c50b..4c0480e8 100644 --- a/smite/src/bolt/open_channel.rs +++ b/smite/src/bolt/open_channel.rs @@ -2,7 +2,7 @@ use super::BoltError; use super::tlv::TlvStream; -use super::types::{CHAIN_HASH_SIZE, ChannelId}; +use super::types::{CHAIN_HASH_SIZE, ChannelId, PublicNonce}; use super::wire::WireFormat; use bitcoin::secp256k1::PublicKey; @@ -12,6 +12,9 @@ const TLV_UPFRONT_SHUTDOWN_SCRIPT: u64 = 0; /// TLV type for channel type. const TLV_CHANNEL_TYPE: u64 = 1; +/// TLV type for the `MuSig2` verification nonce of simple taproot channels. +const TLV_NEXT_LOCAL_NONCE: u64 = 4; + /// BOLT 2 `open_channel` message (type 32). /// /// Sent by the channel initiator to begin the v1 channel establishment flow. @@ -64,6 +67,9 @@ pub struct OpenChannelTlvs { pub upfront_shutdown_script: Option>, /// The channel type represented as feature bits pub channel_type: Option>, + /// The `MuSig2` nonce the initiator will use to verify incoming commitment + /// signatures. Required for simple taproot channels. + pub next_local_nonce: Option, } impl OpenChannel { @@ -98,6 +104,11 @@ impl OpenChannel { if let Some(channel_type) = &self.tlvs.channel_type { tlv_stream.add(TLV_CHANNEL_TYPE, channel_type.clone()); } + if let Some(next_local_nonce) = &self.tlvs.next_local_nonce { + let mut value = Vec::new(); + next_local_nonce.write(&mut value); + tlv_stream.add(TLV_NEXT_LOCAL_NONCE, value); + } out.extend(tlv_stream.encode()); out @@ -132,10 +143,14 @@ impl OpenChannel { let channel_flags = WireFormat::read(&mut cursor)?; // Decode TLVs (remaining bytes) - // Type 0 (`upfront_shutdown_script`) is an even type defined by BOLT 2, - // so we must whitelist it as known. - let tlv_stream = TlvStream::decode_with_known(cursor, &[TLV_UPFRONT_SHUTDOWN_SCRIPT])?; - let tlvs = OpenChannelTlvs::from_stream(&tlv_stream); + // Types 0 (`upfront_shutdown_script`) and 4 (`next_local_nonce`) are + // even types defined by BOLT 2 and the simple taproot channels + // extension, so we must whitelist them as known. + let tlv_stream = TlvStream::decode_with_known( + cursor, + &[TLV_UPFRONT_SHUTDOWN_SCRIPT, TLV_NEXT_LOCAL_NONCE], + )?; + let tlvs = OpenChannelTlvs::from_stream(&tlv_stream)?; Ok(Self { chain_hash, @@ -163,20 +178,27 @@ impl OpenChannel { impl OpenChannelTlvs { /// Extracts open channel TLVs from a parsed TLV stream. - fn from_stream(stream: &TlvStream) -> Self { + /// + /// # Errors + /// + /// Returns a `BoltError` if the `next_local_nonce` TLV has an invalid + /// length. + fn from_stream(stream: &TlvStream) -> Result { let upfront_shutdown_script = stream.get(TLV_UPFRONT_SHUTDOWN_SCRIPT).map(Vec::from); let channel_type = stream.get(TLV_CHANNEL_TYPE).map(Vec::from); + let next_local_nonce = stream.get_as::(TLV_NEXT_LOCAL_NONCE)?; - Self { + Ok(Self { upfront_shutdown_script, channel_type, - } + next_local_nonce, + }) } } #[cfg(test)] mod tests { - use super::super::PUBLIC_KEY_SIZE; + use super::super::{PUBLIC_KEY_SIZE, PUBLIC_NONCE_SIZE}; use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -459,6 +481,7 @@ mod tests { let original = sample_open_channel(Some(OpenChannelTlvs { upfront_shutdown_script: Some(vec![0xab; 22]), channel_type: Some(vec![0x01, 0x02]), + next_local_nonce: Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])), })); let encoded = original.encode(); @@ -469,8 +492,8 @@ mod tests { #[test] fn encode_with_channel_type() { let open = sample_open_channel(Some(OpenChannelTlvs { - upfront_shutdown_script: None, channel_type: Some(vec![0x01, 0x02]), + ..Default::default() })); let encoded = open.encode(); @@ -489,6 +512,7 @@ mod tests { // P2WPKH like script upfront_shutdown_script: Some(script), channel_type: Some(vec![0x01]), + ..Default::default() })); let encoded = open.encode(); @@ -537,5 +561,45 @@ mod tests { let tlvs = OpenChannelTlvs::default(); assert!(tlvs.upfront_shutdown_script.is_none()); assert!(tlvs.channel_type.is_none()); + assert!(tlvs.next_local_nonce.is_none()); + } + + /// Simple taproot channels require the initiator's `MuSig2` verification + /// nonce in TLV type 4. + #[test] + fn encode_with_next_local_nonce() { + let open = sample_open_channel(Some(OpenChannelTlvs { + next_local_nonce: Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])), + ..Default::default() + })); + + let encoded = open.encode(); + // 319 fixed + TLV: type(1) + len(1) + value(66) = 68 + assert_eq!(encoded.len(), 319 + 68); + + let decoded = OpenChannel::decode(&encoded).unwrap(); + assert_eq!( + decoded.tlvs.next_local_nonce, + Some(PublicNonce([0xcd; PUBLIC_NONCE_SIZE])) + ); + } + + /// The nonce TLV is an even type, so it must be whitelisted as known + /// rather than rejected as an unknown even type. + #[test] + fn decode_rejects_next_local_nonce_of_wrong_length() { + let open = sample_open_channel(None); + let mut encoded = open.encode(); + + // TLV type 4, length 2 -- too short for a 66-byte nonce. + encoded.extend_from_slice(&[0x04, 0x02, 0xaa, 0xbb]); + + assert_eq!( + OpenChannel::decode(&encoded), + Err(BoltError::Truncated { + expected: PUBLIC_NONCE_SIZE, + actual: 2 + }) + ); } } diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs index c2e73bc3..c76fc983 100644 --- a/smite/src/oracles/accept_channel.rs +++ b/smite/src/oracles/accept_channel.rs @@ -296,8 +296,8 @@ mod tests { first_per_commitment_point: key, channel_flags: 1, tlvs: OpenChannelTlvs { - upfront_shutdown_script: None, channel_type: Some(vec![0x10, 0x00]), + ..Default::default() }, } } @@ -321,6 +321,7 @@ mod tests { htlc_basepoint: key, first_per_commitment_point: key, tlvs: AcceptChannelTlvs { + next_local_nonce: None, upfront_shutdown_script: None, channel_type: Some(vec![0x10, 0x00]), }, From 148e615626c8c2e960fc3f37d3f603f51d7de0b5 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:03:44 -0300 Subject: [PATCH 09/16] smite: add channel_type feature-bit predicates The commitment format follows from the bits set in the negotiated channel_type. Name that mapping in two predicates: is_simple_taproot on ChannelConfig, which the funding output is about to need as well, and has_anchor_outputs for the commitment itself. has_anchor_outputs is separate from the option_anchors bit because simple taproot channels carry anchors without setting the anchor bits. The commitment tests build their channel_type from Features rather than raw byte vectors while here. --- smite/src/channel_tx/commitment.rs | 84 +++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/smite/src/channel_tx/commitment.rs b/smite/src/channel_tx/commitment.rs index bc6870c5..b5d6635a 100644 --- a/smite/src/channel_tx/commitment.rs +++ b/smite/src/channel_tx/commitment.rs @@ -226,6 +226,14 @@ impl ChannelState { } impl ChannelConfig { + /// Returns whether this is a simple taproot channel, which funds a P2TR + /// output and commits with `MuSig2` rather than a 2-of-2 P2WSH. + #[must_use] + pub fn is_simple_taproot(&self) -> bool { + self.channel_type + .supports_feature(Features::OPTION_SIMPLE_TAPROOT) + } + /// Returns the config for the given channel side. fn party(&self, side: &Side) -> &ChannelPartyConfig { match side { @@ -483,6 +491,15 @@ impl CommitmentCost { } } +/// Returns whether the commitment carries anchor outputs. +/// +/// Simple taproot channels inherit anchor semantics without setting the anchor +/// bits, so the taproot bit implies them. +fn has_anchor_outputs(channel_type: &Features) -> bool { + channel_type.supports_feature(Features::OPTION_ANCHORS) + || channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT) +} + /// Get the fee cost of a commitment tx in satoshis. fn commit_tx_fee_sat(feerate_per_kw: u32, channel_type: &Features) -> u64 { let commitment_weight = if channel_type.supports_feature(Features::OPTION_ANCHORS) { @@ -495,8 +512,11 @@ fn commit_tx_fee_sat(feerate_per_kw: u32, channel_type: &Features) -> u64 { } /// Get the anchor cost of a commitment tx in satoshis. +/// +/// This must follow [`has_anchor_outputs`]: whenever the commitment carries +/// anchors, the opener pays for them. fn total_anchors_sat(channel_type: &Features) -> u64 { - if channel_type.supports_feature(Features::OPTION_ANCHORS) { + if has_anchor_outputs(channel_type) { ANCHOR_OUTPUT_VALUE * 2 } else { 0 @@ -673,7 +693,7 @@ mod tests { to_opener_msat: u64, to_acceptor_msat: u64, dust_limit_satoshis: u64, - channel_type: Vec, + channel_type: Features, ) -> ( ChannelConfig, CommitmentState, @@ -688,7 +708,7 @@ mod tests { vout: 0, }, funding_satoshis: 10_000_000, - channel_type: Features::from(channel_type), + channel_type, opener: ChannelPartyConfig { funding_pubkey: pubkey( "023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb", @@ -761,7 +781,7 @@ mod tests { #[test] fn simple_commitment_tx_with_no_htlcs_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(15_000, 7_000_000_000, 3_000_000_000, 546, vec![]); + bolt3_commitment_params(15_000, 7_000_000_000, 3_000_000_000, 546, Features::new()); // Opener signs own commitment. assert_eq!( @@ -797,7 +817,7 @@ mod tests { #[test] fn commitment_tx_with_two_outputs_untrimmed_minimum_feerate_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(4_915, 6_988_000_000, 3_000_000_000, 546, vec![]); + bolt3_commitment_params(4_915, 6_988_000_000, 3_000_000_000, 546, Features::new()); // Opener signs own commitment. assert_eq!( @@ -833,7 +853,13 @@ mod tests { #[test] fn commitment_tx_with_two_outputs_untrimmed_maximum_feerate_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(9_651_180, 6_988_000_000, 3_000_000_000, 546, vec![]); + bolt3_commitment_params( + 9_651_180, + 6_988_000_000, + 3_000_000_000, + 546, + Features::new(), + ); // Opener signs own commitment. assert_eq!( @@ -869,7 +895,13 @@ mod tests { #[test] fn commitment_tx_with_one_output_untrimmed_minimum_feerate_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(9_651_181, 6_988_000_000, 3_000_000_000, 546, vec![]); + bolt3_commitment_params( + 9_651_181, + 6_988_000_000, + 3_000_000_000, + 546, + Features::new(), + ); // Opener signs own commitment. assert_eq!( @@ -905,7 +937,13 @@ mod tests { #[test] fn commitment_tx_with_fee_greater_than_funder_amount_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(9_651_936, 6_988_000_000, 3_000_000_000, 546, vec![]); + bolt3_commitment_params( + 9_651_936, + 6_988_000_000, + 3_000_000_000, + 546, + Features::new(), + ); // Opener signs own commitment. assert_eq!( @@ -943,7 +981,7 @@ mod tests { #[test] fn commitment_tx_with_balance_msat_not_multiple_of_1000_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(15_000, 6_999_999_000, 3_000_000_123, 546, vec![]); + bolt3_commitment_params(15_000, 6_999_999_000, 3_000_000_123, 546, Features::new()); // Opener signs own commitment. assert_eq!( @@ -981,7 +1019,7 @@ mod tests { #[test] fn commitment_tx_with_equal_output_values_orders_by_script_pubkey_legacy() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(15_000, 5_005_430_000, 4_994_570_000, 546, vec![]); + bolt3_commitment_params(15_000, 5_005_430_000, 4_994_570_000, 546, Features::new()); // Opener signs own commitment. assert_eq!( @@ -1025,7 +1063,7 @@ mod tests { 7_000_000_000, 3_000_000_000, 546, - vec![0x40, 0x00, 0x00], + Features::from_bits(&[Features::OPTION_ANCHORS]), ); // Opener signs own commitment. @@ -1062,7 +1100,13 @@ mod tests { #[test] fn simple_commitment_tx_with_no_htlc_and_single_anchor() { let (chan_config, commitment_params, opener_holder, acceptor_holder) = - bolt3_commitment_params(15_000, 10_000_000_000, 0, 546, vec![0x40, 0x00, 0x00]); + bolt3_commitment_params( + 15_000, + 10_000_000_000, + 0, + 546, + Features::from_bits(&[Features::OPTION_ANCHORS]), + ); // Opener signs own commitment. assert_eq!( @@ -1103,7 +1147,7 @@ mod tests { 6_988_000_000, 3_000_000_000, 4_001, - vec![0x40, 0x00, 0x00], + Features::from_bits(&[Features::OPTION_ANCHORS]), ); // Opener signs own commitment. @@ -1145,7 +1189,7 @@ mod tests { 6_988_000_000, 3_000_000_000, 4_001, - vec![0x40, 0x00, 0x00], + Features::from_bits(&[Features::OPTION_ANCHORS]), ); // Opener signs own commitment. @@ -1189,7 +1233,7 @@ mod tests { 6_999_999_000, 3_000_000_123, 546, - vec![0x40, 0x00, 0x00], + Features::from_bits(&[Features::OPTION_ANCHORS]), ); // Opener signs own commitment. @@ -1233,7 +1277,7 @@ mod tests { 5_008_760_000, 4_991_240_000, 546, - vec![0x40, 0x00, 0x00], + Features::from_bits(&[Features::OPTION_ANCHORS]), ); // Opener signs own commitment. @@ -1296,7 +1340,7 @@ mod tests { ); } - fn sample_chan_config(funding_satoshis: u64, channel_type: Vec) -> ChannelConfig { + fn sample_chan_config(funding_satoshis: u64, channel_type: Features) -> ChannelConfig { let sample_key = pubkey("03b28f7c5a9d1e4f8c6a7b2d3e9f1048576a1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"); let sample_party = || ChannelPartyConfig { @@ -1316,7 +1360,7 @@ mod tests { vout: 0, }, funding_satoshis, - channel_type: Features::from(channel_type), + channel_type, opener: sample_party(), acceptor: sample_party(), minimum_depth: 8, @@ -1327,7 +1371,7 @@ mod tests { fn new_initial_from_funding_msat_overflow() { let sample_key = pubkey("03b28f7c5a9d1e4f8c6a7b2d3e9f1048576a1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"); - let chan_config = sample_chan_config(u64::MAX, vec![]); + let chan_config = sample_chan_config(u64::MAX, Features::new()); let result = chan_config.new_initial_commitment(0, 15_000, sample_key, sample_key); assert!(matches!(result, Err(CommitmentError::FundingMsatOverflow))); } @@ -1336,7 +1380,7 @@ mod tests { fn new_initial_from_funding_push_exceeds_funding() { let sample_key = pubkey("03b28f7c5a9d1e4f8c6a7b2d3e9f1048576a1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"); - let chan_config = sample_chan_config(1_000, vec![]); + let chan_config = sample_chan_config(1_000, Features::new()); let result = chan_config.new_initial_commitment(2_000_000, 15_000, sample_key, sample_key); assert!(matches!(result, Err(CommitmentError::PushExceedsFunding))); } From 44da2dc87ae243655930e25772f19a8590a45f91 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:04:37 -0300 Subject: [PATCH 10/16] smite: build the taproot funding output A simple taproot channel funds a single P2TR output keyed on the MuSig2 aggregate of both funding pubkeys, instead of a 2-of-2 P2WSH. build_funding_transaction and matches_funding_output take the channel_type and pick the format from it. The aggregate key is already BIP 86 tweaked by key aggregation, so the output assumes it is tweaked rather than tweaking again, which would produce a key neither peer can sign for. Deriving the key can fail in principle, so funding construction gains an error type rather than panicking on fuzzer-chosen keys. Nothing selects taproot yet: the IR does not carry the channel type into CreateFundingTransaction, so the executor still asks for P2WSH. The output script is checked against the funding test vector in bolt-simple-taproot.md. --- smite-scenarios/src/executor.rs | 12 +- smite-scenarios/src/scenarios/ir.rs | 4 +- smite/src/channel_tx.rs | 6 +- smite/src/channel_tx/funding.rs | 191 +++++++++++++++++++++++++--- smite/src/channel_tx/taproot.rs | 44 +++++++ 5 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 smite/src/channel_tx/taproot.rs diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 49d69159..46000b4e 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -211,9 +211,9 @@ pub enum ExecuteError { #[error("peer error on {:?}: {}", .0.channel_id, .0.message().unwrap_or(""))] PeerError(smite::bolt::Error), - /// Wallet UTXOs could not cover the funding amount and fees. + /// The funding transaction could not be built. #[error("funding: {0}")] - InsufficientFunds(#[from] smite::channel_tx::InsufficientFunds), + Funding(#[from] smite::channel_tx::FundingError), /// Failed to construct the initial commitment state. #[error("commitment: {0}")] @@ -810,6 +810,9 @@ fn create_funding_transaction( &acceptor_pubkey, funding_satoshis, feerate_per_kw, + // The IR does not carry the channel type into this operation yet, so + // the funding output is always the 2-of-2 P2WSH form. + &[], utxos, change_spk, )?; @@ -963,6 +966,7 @@ fn build_funding_created( &open_channel.funding_pubkey, &accept_channel.funding_pubkey, open_channel.funding_satoshis, + &config.channel_type, ); // Building the same message again must not clobber a channel whose state @@ -3282,7 +3286,9 @@ mod tests { std::time::Instant::now(), ) .unwrap_err(); - let ExecuteError::InsufficientFunds(funds_err) = err else { + let ExecuteError::Funding(smite::channel_tx::FundingError::InsufficientFunds(funds_err)) = + err + else { panic!("expected InsufficientFunds, got {err:?}"); }; assert_eq!(funds_err.available, Amount::from_sat(1_000)); diff --git a/smite-scenarios/src/scenarios/ir.rs b/smite-scenarios/src/scenarios/ir.rs index f4926618..b8541ca2 100644 --- a/smite-scenarios/src/scenarios/ir.rs +++ b/smite-scenarios/src/scenarios/ir.rs @@ -94,10 +94,10 @@ impl> Scenario for IrScenario { // the spec doesn't allow. return ScenarioResult::Fail(format!("decode error: {e}")); } - Err(ExecuteError::InsufficientFunds(e)) => { + Err(ExecuteError::Funding(e)) => { // The mutator generated a funding amount/feerate combination // the available UTXOs can't cover. Not a bug in the target. - log::debug!("[{:?}] insufficient funds: {e}", start.elapsed()); + log::debug!("[{:?}] funding transaction: {e}", start.elapsed()); } Err(ExecuteError::Commitment(e)) => { // The mutator generated a funding amount/push_msat combination diff --git a/smite/src/channel_tx.rs b/smite/src/channel_tx.rs index 3e646154..f87750e9 100644 --- a/smite/src/channel_tx.rs +++ b/smite/src/channel_tx.rs @@ -5,9 +5,13 @@ mod commitment; mod funding; +mod taproot; pub use commitment::{ ChannelConfig, ChannelPartyConfig, ChannelState, CommitmentCost, CommitmentError, CommitmentPartyState, CommitmentState, HolderIdentity, Side, }; -pub use funding::{FundingTransaction, InsufficientFunds, build_funding_transaction}; +pub use funding::{ + FundingError, FundingTransaction, InsufficientFunds, build_funding_scriptpubkey, + build_funding_transaction, +}; diff --git a/smite/src/channel_tx/funding.rs b/smite/src/channel_tx/funding.rs index 3fb89429..b62129a4 100644 --- a/smite/src/channel_tx/funding.rs +++ b/smite/src/channel_tx/funding.rs @@ -7,7 +7,10 @@ use bitcoin::secp256k1::PublicKey; use bitcoin::transaction::{InputWeightPrediction, Version, predict_weight}; use bitcoin::{Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; +use super::taproot; use crate::bitcoin::Utxo; +use crate::bolt::Features; +use crate::musig::{FundingKeys, MusigError}; /// Error returned when available UTXOs cannot cover the funding amount plus /// estimated miner fee. @@ -22,6 +25,19 @@ pub struct InsufficientFunds { pub available: Amount, } +/// Errors that can occur while building a funding transaction. +#[derive(Debug, thiserror::Error)] +pub enum FundingError { + /// The selected UTXOs cannot cover the funding amount and fees. + #[error(transparent)] + InsufficientFunds(#[from] InsufficientFunds), + + /// The taproot funding key could not be derived from the two + /// `funding_pubkey`s. + #[error("taproot funding output: {0}")] + Musig(#[from] MusigError), +} + /// A constructed funding transaction along with the index of the 2-of-2 /// funding output within it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,15 +48,19 @@ pub struct FundingTransaction { pub vout: u32, } -/// Builds a funding transaction with a 2-of-2 P2WSH output between the opener -/// and acceptor. +/// Builds a funding transaction paying to the channel's funding output. +/// +/// The output is a 2-of-2 P2WSH, or a single-key P2TR when `channel_type` +/// negotiates `option_simple_taproot`. /// /// Coins are selected for spending in the order the `utxos` are provided. /// /// # Errors /// -/// Returns [`InsufficientFunds`] if the provided inputs do not contain enough -/// value to cover `funding_satoshis` and the required transaction fees. +/// Returns [`FundingError::InsufficientFunds`] if the provided inputs do not +/// contain enough value to cover `funding_satoshis` and the required +/// transaction fees, or [`FundingError::Musig`] if a taproot funding key +/// cannot be derived from the two `funding_pubkey`s. /// /// # Panics /// @@ -51,9 +71,10 @@ pub fn build_funding_transaction( acceptor_funding_pubkey: &PublicKey, funding_satoshis: u64, feerate_per_kw: u32, + channel_type: &Features, utxos: Vec, change_spk: ScriptBuf, -) -> Result { +) -> Result { // Amounts exceeding Bitcoin's maximum supply can never be funded. The // error's `available` field reports Bitcoin's total supply cap. let funding_amt = Amount::from_sat(funding_satoshis); @@ -61,7 +82,8 @@ pub fn build_funding_transaction( return Err(InsufficientFunds { required: funding_amt, available: Amount::MAX_MONEY, - }); + } + .into()); } // Return early if no UTXOs are available, since the funding transaction @@ -70,11 +92,12 @@ pub fn build_funding_transaction( return Err(InsufficientFunds { required: funding_amt, available: Amount::ZERO, - }); + } + .into()); } let funding_spk = - build_funding_witness_script(opener_funding_pubkey, acceptor_funding_pubkey).to_p2wsh(); + build_funding_scriptpubkey(opener_funding_pubkey, acceptor_funding_pubkey, channel_type)?; let mut inputs = Vec::new(); let mut input_weights = Vec::new(); @@ -123,7 +146,8 @@ pub fn build_funding_transaction( return Err(InsufficientFunds { required: funding_amt + expected_fee_no_change, available: total, - }); + } + .into()); } // Add remaining funds after accounting for fees as a change output, @@ -171,21 +195,53 @@ fn predict_tx_fee( impl FundingTransaction { /// Returns whether the referenced funding output matches the negotiated /// output script and amount. + /// + /// A mismatch is not an error here: mutators can make the transaction + /// disagree with the negotiation, and the caller records that so it knows + /// the target will never see the channel confirm. #[must_use] pub fn matches_funding_output( &self, opener_funding_pubkey: &PublicKey, acceptor_funding_pubkey: &PublicKey, funding_satoshis: u64, + channel_type: &Features, ) -> bool { - let expected_spk = - build_funding_witness_script(opener_funding_pubkey, acceptor_funding_pubkey).to_p2wsh(); + let Ok(expected_spk) = build_funding_scriptpubkey( + opener_funding_pubkey, + acceptor_funding_pubkey, + channel_type, + ) else { + return false; + }; self.tx.output.get(self.vout as usize).is_some_and(|out| { out.script_pubkey == expected_spk && out.value.to_sat() == funding_satoshis }) } } +/// Builds the funding output `script_pubkey` for the negotiated channel type. +/// +/// Simple taproot channels pay to a single P2TR key aggregated from both +/// `funding_pubkey`s; every other channel type pays to a 2-of-2 P2WSH. +/// +/// # Errors +/// +/// Returns [`MusigError`] if the taproot funding key cannot be derived, which +/// cannot happen for two valid public keys. +pub fn build_funding_scriptpubkey( + opener_funding_pubkey: &PublicKey, + acceptor_funding_pubkey: &PublicKey, + channel_type: &Features, +) -> Result { + if channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT) { + let keys = FundingKeys::new(opener_funding_pubkey, acceptor_funding_pubkey)?; + Ok(taproot::funding_scriptpubkey(keys.aggregate_pubkey())) + } else { + Ok(build_funding_witness_script(opener_funding_pubkey, acceptor_funding_pubkey).to_p2wsh()) + } +} + /// Builds the funding output witness script per BOLT 3. pub fn build_funding_witness_script(pubkey1: &PublicKey, pubkey2: &PublicKey) -> ScriptBuf { let key1_bytes = pubkey1.serialize(); @@ -213,6 +269,22 @@ mod tests { use bitcoin::secp256k1::{Secp256k1, SecretKey}; use bitcoin::sighash::{EcdsaSighashType, SighashCache}; + /// Unwraps the [`FundingError::InsufficientFunds`] variant, failing the + /// test on any other error. + fn expect_insufficient_funds(err: FundingError) -> InsufficientFunds { + match err { + FundingError::InsufficientFunds(e) => e, + other @ FundingError::Musig(_) => { + panic!("expected InsufficientFunds, got {other:?}") + } + } + } + + /// A `channel_type` negotiating `option_simple_taproot`. + fn simple_taproot_channel_type() -> Features { + Features::from_bits(&[Features::OPTION_SIMPLE_TAPROOT]) + } + fn pubkey(hex_str: &str) -> PublicKey { let bytes = hex::decode(hex_str).expect("valid hex"); PublicKey::from_slice(&bytes).expect("valid pubkey") @@ -296,6 +368,7 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 10_000_000, 15_000, + &Features::new(), utxos.clone(), change_spk, ) @@ -362,6 +435,7 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 10_000_000, 15_000, + &Features::new(), utxos.clone(), change_spk, ) @@ -423,6 +497,7 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 10_000_000, 15_000, + &Features::new(), utxos.clone(), change_spk, ) @@ -512,6 +587,7 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 15_000_000, 15_000, + &Features::new(), utxos.clone(), change_spk, ) @@ -558,11 +634,12 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 10_000_000, 15_000, + &Features::new(), vec![], change_spk, ) .unwrap_err(); - assert!(matches!(err, InsufficientFunds { .. })); + let err = expect_insufficient_funds(err); assert_eq!(err.required, Amount::from_sat(10_000_000)); assert_eq!(err.available, Amount::from_sat(0)); } @@ -581,11 +658,12 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 0, 0, + &Features::new(), vec![], change_spk, ) .unwrap_err(); - assert!(matches!(err, InsufficientFunds { .. })); + let err = expect_insufficient_funds(err); assert_eq!(err.required, Amount::from_sat(0)); assert_eq!(err.available, Amount::from_sat(0)); } @@ -617,11 +695,12 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 10_000_000, 15_000, + &Features::new(), utxos, change_spk, ) .unwrap_err(); - assert!(matches!(err, InsufficientFunds { .. })); + let err = expect_insufficient_funds(err); assert_eq!(err.required, Amount::from_sat(10_012_060)); assert_eq!(err.available, Amount::from_sat(1_000)); } @@ -655,11 +734,12 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), u64::MAX, 15_000, + &Features::new(), utxos, change_spk, ) .unwrap_err(); - assert!(matches!(err, InsufficientFunds { .. })); + let err = expect_insufficient_funds(err); assert_eq!(err.required, Amount::from_sat(u64::MAX)); assert_eq!(err.available, Amount::MAX_MONEY); } @@ -694,6 +774,7 @@ mod tests { &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), 10_000_000, 15_000, + &Features::new(), utxos, change_spk, ); @@ -751,6 +832,7 @@ mod tests { &acceptor_funding_pubkey, funding_satoshis, 15_000, + &Features::new(), utxos, change_spk, ) @@ -762,6 +844,7 @@ mod tests { &opener_funding_pubkey, &acceptor_funding_pubkey, funding_satoshis, + &Features::new(), )); // A different funding pubkey yields a different script and does not match. @@ -771,6 +854,7 @@ mod tests { &opener_funding_pubkey, &other_pubkey, funding_satoshis, + &Features::new(), )); // A mismatched funding amount does not match. @@ -778,7 +862,82 @@ mod tests { assert!(!funding.matches_funding_output( &opener_funding_pubkey, &acceptor_funding_pubkey, - other_amount + other_amount, + &Features::new(), )); } + + /// The spec's funding test vector: a simple taproot channel funds a P2TR + /// output keyed on the `MuSig2` aggregate of both `funding_pubkey`s, not a + /// 2-of-2 P2WSH. + #[test] + fn taproot_channel_type_funds_a_p2tr_output() { + let local = pubkey("03b7203dec7c13896b6ff1f58b24f84458c441720a12b5a57426397e22f0a8c78b"); + let remote = pubkey("02956e6845a6f346f97c5e028c0f8ab38a76b0124fd7184deab60f682b3e657fdb"); + + // `option_simple_taproot` alone: BOLT 9 bit 80. + let taproot_type = simple_taproot_channel_type(); + let spk = build_funding_scriptpubkey(&local, &remote, &taproot_type) + .expect("valid funding pubkeys"); + + assert!(spk.is_p2tr()); + assert_eq!( + hex::encode(spk.as_bytes()), + "5120d0ebb4909d563a7ae1213fddede4ae54132fba0ef0b97ee3f8469191fecd348e" + ); + + // Without the taproot bit the output stays a 2-of-2 P2WSH. + assert!( + build_funding_scriptpubkey(&local, &remote, &Features::new()) + .expect("valid funding pubkeys") + .is_p2wsh() + ); + } + + /// The funding transaction pays to the taproot script, and + /// `matches_funding_output` only agrees when given the same channel type. + #[test] + fn taproot_funding_output_matches_only_its_own_channel_type() { + let local = pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"); + let remote = pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"); + let taproot_type = simple_taproot_channel_type(); + + let utxos = vec![Utxo { + amount: Amount::from_sat(5_000_000_000), + outpoint: OutPoint { + txid: "fd2105607605d2302994ffea703b09f66b6351816ee737a93e42a841ea20bbad" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("0014a10d9257489e685dda030662390dc177852faf13") + .expect("valid P2WPKH scriptpubkey hex"), + ), + }]; + let change_spk = ScriptBuf::from( + hex::decode("00143ca33c2e4446f4a305f23c80df8ad1afdcf652f9") + .expect("valid P2WPKH scriptpubkey hex"), + ); + + let funding = build_funding_transaction( + &local, + &remote, + 10_000_000, + 15_000, + &taproot_type, + utxos, + change_spk, + ) + .expect("inputs should cover funding amount and fees"); + + assert!( + funding.tx.output[funding.vout as usize] + .script_pubkey + .is_p2tr() + ); + assert!(funding.matches_funding_output(&local, &remote, 10_000_000, &taproot_type)); + // The same transaction does not satisfy a non-taproot negotiation. + assert!(!funding.matches_funding_output(&local, &remote, 10_000_000, &Features::new())); + } } diff --git a/smite/src/channel_tx/taproot.rs b/smite/src/channel_tx/taproot.rs new file mode 100644 index 00000000..d09d6a1e --- /dev/null +++ b/smite/src/channel_tx/taproot.rs @@ -0,0 +1,44 @@ +//! Simple taproot channel output scripts. +//! +//! Every commitment output of a simple taproot channel is a P2TR output whose +//! key commits to a tapscript tree. This module builds those `script_pubkey`s. +//! The forms here are the ones negotiated by the `option_simple_taproot` +//! channel type (BOLT 9 bit 80), which is what lnd calls its "final" taproot +//! commitment and what the spec's test vectors encode. + +use bitcoin::ScriptBuf; +use bitcoin::secp256k1::XOnlyPublicKey; + +/// Builds the funding output `script_pubkey` for a simple taproot channel. +/// +/// `aggregate_funding_key` is the `MuSig2` aggregate of both `funding_pubkey`s +/// with the BIP 86 tweak already applied, so the output commits to no script +/// path and is spent with a single aggregated Schnorr signature. +#[must_use] +pub fn funding_scriptpubkey(aggregate_funding_key: XOnlyPublicKey) -> ScriptBuf { + // `dangerous_assume_tweaked` is correct here: the `MuSig2` key aggregation + // already applied the BIP 86 taptweak, so tweaking again would produce a + // key neither peer can sign for. + ScriptBuf::new_p2tr_tweaked(bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked( + aggregate_funding_key, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn funding_scriptpubkey_matches_spec_vector() { + let aggregate = XOnlyPublicKey::from_slice( + &hex::decode("d0ebb4909d563a7ae1213fddede4ae54132fba0ef0b97ee3f8469191fecd348e") + .expect("valid hex"), + ) + .expect("valid x-only pubkey"); + + assert_eq!( + hex::encode(funding_scriptpubkey(aggregate).as_bytes()), + "5120d0ebb4909d563a7ae1213fddede4ae54132fba0ef0b97ee3f8469191fecd348e" + ); + } +} From fd88118a750bf9aeeac06321aa2cf0d9074a21fb Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:05:03 -0300 Subject: [PATCH 11/16] smite: build and sign the taproot commitment transaction Every output becomes P2TR: to_local and to_remote commit to a tapscript tree under the NUMS point so the script path is always taken and the keys inside are revealed on chain, and the anchors key on the party's main output key rather than the funding key, which MuSig2 no longer reveals. The commitment spends the funding output through the taproot key path, so the digest is the BIP 341 one over the funding prevout rather than BIP 143 over a witness script, and the signature becomes a MuSig2 partial signature. ChannelState carries the nonce we gave the counterparty to sign against, so the incoming funding_signed can be verified. Taproot commitments weigh 968 rather than the 1124 of segwit v0 anchors, which changes the fee both peers must agree on. The scripts, tapscript root and output keys are checked against the test vectors in bolt-simple-taproot.md, and a partial signature is round-tripped between both sides of the same session. --- smite-scenarios/src/executor.rs | 1 + smite/src/channel_tx/commitment.rs | 278 +++++++++++++++++++++++++++-- smite/src/channel_tx/taproot.rs | 265 ++++++++++++++++++++++++++- 3 files changed, 530 insertions(+), 14 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 46000b4e..4e91edb7 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -978,6 +978,7 @@ fn build_funding_created( state, is_funding_outpoint_valid, mined_txids.contains(&funding_outpoint.txid), + None, ) }); diff --git a/smite/src/channel_tx/commitment.rs b/smite/src/channel_tx/commitment.rs index b5d6635a..d27e852d 100644 --- a/smite/src/channel_tx/commitment.rs +++ b/smite/src/channel_tx/commitment.rs @@ -1,7 +1,9 @@ //! BOLT 3 commitment transaction construction and signing. -use super::funding::build_funding_witness_script; -use crate::bolt::Features; +use super::funding::{build_funding_scriptpubkey, build_funding_witness_script}; +use super::taproot; +use crate::bolt::{Features, PublicNonce}; +use crate::musig::{FundingKeys, MusigError, PartialSignature, SecretNonce}; use bitcoin::absolute::LockTime; use bitcoin::hashes::sha256::Hash as Sha256; @@ -10,7 +12,7 @@ use bitcoin::opcodes::all as opcodes; use bitcoin::script::Builder; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{Message, PublicKey, Scalar, Secp256k1, SecretKey}; -use bitcoin::sighash::{EcdsaSighashType, SighashCache}; +use bitcoin::sighash::{EcdsaSighashType, Prevouts, SighashCache, TapSighashType}; use bitcoin::transaction::Version; use bitcoin::{ Amount, CompressedPublicKey, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, @@ -25,6 +27,9 @@ const COMMITMENT_TX_BASE_WEIGHT_NON_ANCHOR: u64 = 724; /// Weight of an anchor commitment transaction without HTLCs. const COMMITMENT_TX_BASE_WEIGHT_ANCHOR: u64 = 1124; +/// Weight of a simple taproot commitment transaction without HTLCs. +const COMMITMENT_TX_BASE_WEIGHT_TAPROOT: u64 = 968; + /// Errors that can occur when constructing or validating commitment transactions. #[derive(Debug, thiserror::Error)] pub enum CommitmentError { @@ -148,6 +153,11 @@ pub struct ChannelState { /// after the block height at which they receive `funding_created`, so they /// may never observe it and never send `channel_ready`. pub was_funding_mined_prematurely: bool, + /// The public `MuSig2` nonce the holder gave the counterparty to sign + /// against, for simple taproot channels. Needed to verify the counterparty's + /// partial signature over the holder's commitment. `None` for every other + /// channel type. + pub holder_verification_nonce: Option, } impl Side { @@ -177,6 +187,7 @@ impl ChannelState { commitment: CommitmentState, is_funding_outpoint_valid: bool, was_funding_mined_prematurely: bool, + holder_verification_nonce: Option, ) -> Self { Self { config, @@ -186,6 +197,7 @@ impl ChannelState { acceptor_next_per_commitment_point: None, is_funding_outpoint_valid, was_funding_mined_prematurely, + holder_verification_nonce, } } @@ -292,6 +304,69 @@ impl ChannelConfig { sign(&sighash, &holder.funding_privkey) } + /// Builds the `MuSig2` partial signature for the counterparty's commitment + /// transaction, for a simple taproot channel. + /// + /// `signing_nonce` is a fresh nonce that must be sent alongside the + /// signature; `counterparty_verification_nonce` is the nonce the peer sent + /// in its `accept_channel` (or `open_channel`, when we are the acceptor). + /// + /// # Errors + /// + /// Returns [`MusigError`] if either nonce is malformed or the funding keys + /// cannot be aggregated. + pub fn partial_sign_counterparty_commitment( + &self, + state: &CommitmentState, + holder: &HolderIdentity, + signing_nonce: SecretNonce, + counterparty_verification_nonce: &PublicNonce, + ) -> Result { + let sighash = self.build_commitment_sighash(state, holder.counterparty_side()); + self.funding_keys()?.partial_sign( + &sighash, + &holder.funding_privkey, + signing_nonce, + counterparty_verification_nonce, + ) + } + + /// Verifies a `MuSig2` partial signature received from the counterparty for + /// the holder's commitment transaction. + /// + /// `counterparty_signing_nonce` is the nonce carried alongside the + /// signature; `holder_verification_nonce` is the nonce we sent earlier. + /// + /// # Errors + /// + /// Returns [`MusigError`] if either nonce is malformed or the funding keys + /// cannot be aggregated. A well-formed but incorrect signature yields + /// `Ok(false)`. + pub fn verify_counterparty_partial_signature( + &self, + state: &CommitmentState, + holder: &HolderIdentity, + signature: &PartialSignature, + counterparty_signing_nonce: &PublicNonce, + holder_verification_nonce: &PublicNonce, + ) -> Result { + let sighash = self.build_commitment_sighash(state, &holder.side); + let counterparty = self.party(holder.counterparty_side()); + + self.funding_keys()?.verify_partial( + &sighash, + signature, + &counterparty.funding_pubkey, + counterparty_signing_nonce, + holder_verification_nonce, + ) + } + + /// Builds the `MuSig2` key aggregation context for the funding output. + fn funding_keys(&self) -> Result { + FundingKeys::new(&self.opener.funding_pubkey, &self.acceptor.funding_pubkey) + } + /// Verifies a signature received from the counterparty for the holder's /// commitment transaction. Returns `true` if the signature is valid. #[must_use] @@ -365,6 +440,31 @@ impl ChannelConfig { output: outputs, }; + // Simple taproot channels spend the funding output through the taproot + // key path, so the digest is the BIP 341 one over the funding prevout + // rather than the BIP 143 one over a witness script. + if self.is_simple_taproot() { + let funding_spk = build_funding_scriptpubkey( + &self.opener.funding_pubkey, + &self.acceptor.funding_pubkey, + &self.channel_type, + ) + .expect("two valid funding pubkeys always aggregate"); + let funding_output = TxOut { + value: Amount::from_sat(self.funding_satoshis), + script_pubkey: funding_spk, + }; + + return SighashCache::new(&tx) + .taproot_key_spend_signature_hash( + 0, + &Prevouts::All(&[funding_output]), + TapSighashType::Default, + ) + .expect("input index 0 is always in bounds for a single input transaction") + .to_byte_array(); + } + // Funding output witness script. let funding_witness_script = build_funding_witness_script( &self.opener.funding_pubkey, @@ -389,7 +489,8 @@ impl ChannelConfig { /// `local_side` selects whose commitment outputs are built: the /// opener's or the acceptor's. fn build_commitment_outputs(&self, state: &CommitmentState, local_side: &Side) -> Vec { - let anchor = self.channel_type.supports_feature(Features::OPTION_ANCHORS); + let anchor = has_anchor_outputs(&self.channel_type); + let taproot = self.is_simple_taproot(); // Fee and balances. let commitment_cost = CommitmentCost::new(state.feerate_per_kw, &self.channel_type); @@ -416,11 +517,19 @@ impl ChannelConfig { let revocationpubkey = derive_revocation_pubkey(&remote.revocation_basepoint, &local_per_commitment_point); - let to_local_spk = build_to_local_scriptpubkey( - &local_delayedpubkey, - &revocationpubkey, - remote.to_self_delay, - ); + let to_local_spk = if taproot { + taproot::to_local_scriptpubkey( + &local_delayedpubkey, + &revocationpubkey, + remote.to_self_delay, + ) + } else { + build_to_local_scriptpubkey( + &local_delayedpubkey, + &revocationpubkey, + remote.to_self_delay, + ) + }; outputs.push(TxOut { value: Amount::from_sat(to_local_value), @@ -428,14 +537,26 @@ impl ChannelConfig { }); if anchor { + // Taproot anchors key on the party's main output key, which is + // revealed when the commitment is spent; `MuSig2` no longer + // reveals the funding key that segwit v0 anchors use. + let anchor_spk = if taproot { + taproot::anchor_scriptpubkey(&local_delayedpubkey) + } else { + build_anchor_scriptpubkey(&local.funding_pubkey) + }; outputs.push(TxOut { value: Amount::from_sat(ANCHOR_OUTPUT_VALUE), - script_pubkey: build_anchor_scriptpubkey(&local.funding_pubkey), + script_pubkey: anchor_spk, }); } } if to_remote_value >= local.dust_limit_satoshis { - let to_remote_spk = build_to_remote_scriptpubkey(&remote.payment_basepoint, anchor); + let to_remote_spk = if taproot { + taproot::to_remote_scriptpubkey(&remote.payment_basepoint) + } else { + build_to_remote_scriptpubkey(&remote.payment_basepoint, anchor) + }; outputs.push(TxOut { value: Amount::from_sat(to_remote_value), @@ -443,9 +564,14 @@ impl ChannelConfig { }); if anchor { + let anchor_spk = if taproot { + taproot::anchor_scriptpubkey(&remote.payment_basepoint) + } else { + build_anchor_scriptpubkey(&remote.funding_pubkey) + }; outputs.push(TxOut { value: Amount::from_sat(ANCHOR_OUTPUT_VALUE), - script_pubkey: build_anchor_scriptpubkey(&remote.funding_pubkey), + script_pubkey: anchor_spk, }); } } @@ -502,7 +628,9 @@ fn has_anchor_outputs(channel_type: &Features) -> bool { /// Get the fee cost of a commitment tx in satoshis. fn commit_tx_fee_sat(feerate_per_kw: u32, channel_type: &Features) -> u64 { - let commitment_weight = if channel_type.supports_feature(Features::OPTION_ANCHORS) { + let commitment_weight = if channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT) { + COMMITMENT_TX_BASE_WEIGHT_TAPROOT + } else if has_anchor_outputs(channel_type) { COMMITMENT_TX_BASE_WEIGHT_ANCHOR } else { COMMITMENT_TX_BASE_WEIGHT_NON_ANCHOR @@ -1425,4 +1553,128 @@ mod tests { None, ); } + + /// A `channel_type` negotiating `option_simple_taproot`. + fn simple_taproot_channel_type() -> Features { + Features::from_bits(&[Features::OPTION_SIMPLE_TAPROOT]) + } + + /// The opener's partial signature over the acceptor's commitment verifies + /// from the acceptor's side of the same `MuSig2` session. This exercises the + /// whole taproot path: taproot commitment outputs, the BIP 341 key-spend + /// sighash, and `MuSig2` signing. + #[test] + fn taproot_partial_signature_round_trips_between_both_sides() { + let (chan_config, commitment_params, opener_holder, acceptor_holder) = + bolt3_commitment_params( + 2_500, + 7_000_000_000, + 3_000_000_000, + 546, + simple_taproot_channel_type(), + ); + + // The acceptor publishes a verification nonce in `accept_channel`; the + // opener signs with a fresh nonce sent alongside the signature. + let acceptor_verification = + crate::musig::derive_nonce(&[b"acceptor"], &chan_config.acceptor.funding_pubkey); + let opener_signing = + crate::musig::derive_nonce(&[b"opener"], &chan_config.opener.funding_pubkey); + let opener_signing_public = opener_signing.public_nonce(); + + let partial = chan_config + .partial_sign_counterparty_commitment( + &commitment_params, + &opener_holder, + opener_signing, + &acceptor_verification.public_nonce(), + ) + .expect("signing succeeds"); + + assert!( + chan_config + .verify_counterparty_partial_signature( + &commitment_params, + &acceptor_holder, + &partial, + &opener_signing_public, + &acceptor_verification.public_nonce(), + ) + .expect("nonces parse") + ); + } + + /// Simple taproot channels carry anchors without setting the anchor bits. + /// The staging bits (180/181) are a different channel type using different + /// tapscript leaves, so they must not imply anchors. + #[test] + fn anchor_outputs_detection() { + let taproot = Features::from_bits(&[Features::OPTION_SIMPLE_TAPROOT]); + let anchors = + Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY, Features::OPTION_ANCHORS]); + let staging = Features::from_bits(&[Features::OPTION_SIMPLE_TAPROOT_STAGING]); + let static_remotekey = Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY]); + + assert!(has_anchor_outputs(&taproot)); + assert!(has_anchor_outputs(&anchors)); + + assert!(!has_anchor_outputs(&staging)); + assert!(!has_anchor_outputs(&static_remotekey)); + assert!(!has_anchor_outputs(&Features::new())); + } + + /// A taproot commitment must not be signable with the segwit v0 digest: + /// the two formats produce different sighashes, so a signature made under + /// one channel type must not verify under the other. + #[test] + fn taproot_and_segwit_commitments_have_different_sighashes() { + let (taproot_config, state, opener_holder, _) = bolt3_commitment_params( + 2_500, + 7_000_000_000, + 3_000_000_000, + 546, + simple_taproot_channel_type(), + ); + let (segwit_config, _, _, _) = + bolt3_commitment_params(2_500, 7_000_000_000, 3_000_000_000, 546, Features::new()); + + assert_ne!( + taproot_config.build_commitment_sighash(&state, &opener_holder.side), + segwit_config.build_commitment_sighash(&state, &opener_holder.side) + ); + } + + /// Taproot commitments carry anchors and use the 968 weight rather than the + /// segwit v0 anchor weight, so the fee both peers compute must differ. + #[test] + fn taproot_commitment_cost_uses_the_taproot_weight() { + let taproot = CommitmentCost::new(2_500, &simple_taproot_channel_type()); + assert_eq!(taproot.fee_sat, 2_500 * 968 / 1000); + assert_eq!(taproot.anchor_cost_sat, ANCHOR_OUTPUT_VALUE * 2); + } + + /// Every output of a taproot commitment is a P2TR output. + #[test] + fn taproot_commitment_outputs_are_all_p2tr() { + let (chan_config, state, _, _) = bolt3_commitment_params( + 2_500, + 7_000_000_000, + 3_000_000_000, + 546, + simple_taproot_channel_type(), + ); + + let outputs = chan_config.build_commitment_outputs(&state, &Side::Opener); + + // to_local, to_remote and both anchors. + assert_eq!(outputs.len(), 4); + assert!(outputs.iter().all(|out| out.script_pubkey.is_p2tr())); + assert_eq!( + outputs + .iter() + .filter(|out| out.value == Amount::from_sat(ANCHOR_OUTPUT_VALUE)) + .count(), + 2 + ); + } } diff --git a/smite/src/channel_tx/taproot.rs b/smite/src/channel_tx/taproot.rs index d09d6a1e..29cd5bcd 100644 --- a/smite/src/channel_tx/taproot.rs +++ b/smite/src/channel_tx/taproot.rs @@ -7,7 +7,29 @@ //! commitment and what the spec's test vectors encode. use bitcoin::ScriptBuf; -use bitcoin::secp256k1::XOnlyPublicKey; +use bitcoin::opcodes::all as opcodes; +use bitcoin::script::Builder; +use bitcoin::secp256k1::{PublicKey, Secp256k1, XOnlyPublicKey}; +use bitcoin::taproot::{TaprootBuilder, TaprootSpendInfo}; + +/// The "Nothing Up My Sleeve" point used as the internal key of the `to_local` +/// and `to_remote` outputs, so that the script path must always be taken and +/// the keys inside it are revealed on chain. +/// +/// Generated with the seed phrase "Lightning Simple Taproot". +const NUMS_POINT: [u8; 33] = [ + 0x02, 0xdc, 0xa0, 0x94, 0x75, 0x11, 0x09, 0xd0, 0xbd, 0x05, 0x5d, 0x03, 0x56, 0x58, 0x74, 0xe8, + 0x27, 0x6d, 0xd5, 0x3e, 0x92, 0x6b, 0x44, 0xe3, 0xbd, 0x1b, 0xb6, 0xbf, 0x4b, 0xc1, 0x30, 0xa2, + 0x79, +]; + +/// Returns the NUMS internal key. +fn nums_point() -> XOnlyPublicKey { + PublicKey::from_slice(&NUMS_POINT) + .expect("the NUMS constant is a valid compressed pubkey") + .x_only_public_key() + .0 +} /// Builds the funding output `script_pubkey` for a simple taproot channel. /// @@ -24,9 +46,250 @@ pub fn funding_scriptpubkey(aggregate_funding_key: XOnlyPublicKey) -> ScriptBuf )) } +/// Builds the `to_local` output `script_pubkey`. +/// +/// The tree has two leaves: the owner sweeps after `to_self_delay` blocks, or +/// the counterparty sweeps immediately with the revocation key. The revocation +/// leaf pushes the delayed key and drops it, which reveals that key on chain so +/// the anchor output stays spendable. +#[must_use] +pub fn to_local_scriptpubkey( + local_delayedpubkey: &PublicKey, + revocationpubkey: &PublicKey, + to_self_delay: u16, +) -> ScriptBuf { + let delay_script = to_local_delay_script(local_delayedpubkey, to_self_delay); + let revoke_script = to_local_revoke_script(local_delayedpubkey, revocationpubkey); + + let spend_info = TaprootBuilder::new() + .add_leaf(1, delay_script) + .and_then(|builder| builder.add_leaf(1, revoke_script)) + .expect("a two-leaf tree at depth 1 is always valid") + .finalize(&Secp256k1::verification_only(), nums_point()) + .expect("a complete two-leaf tree always finalizes"); + + p2tr(&spend_info) +} + +/// Builds the `to_remote` output `script_pubkey`. +/// +/// A single leaf letting the counterparty sweep after a 1-block delay. The +/// internal key is the NUMS point so the delay cannot be bypassed, and because +/// it is a constant the counterparty can scan the chain for this output. +#[must_use] +pub fn to_remote_scriptpubkey(remotepubkey: &PublicKey) -> ScriptBuf { + let spend_info = TaprootBuilder::new() + .add_leaf(0, to_remote_script(remotepubkey)) + .expect("a single-leaf tree is always valid") + .finalize(&Secp256k1::verification_only(), nums_point()) + .expect("a complete single-leaf tree always finalizes"); + + p2tr(&spend_info) +} + +/// Builds an anchor output `script_pubkey`. +/// +/// The owner spends it via the key path; anyone may sweep it via the script +/// path after 16 blocks. Unlike the segwit v0 anchors, the internal key is the +/// owner's main output key (`local_delayedpubkey` or `remotepubkey`) rather +/// than the funding key, which `MuSig2` no longer reveals. +#[must_use] +pub fn anchor_scriptpubkey(anchor_internal_key: &PublicKey) -> ScriptBuf { + let spend_info = TaprootBuilder::new() + .add_leaf(0, anchor_script()) + .expect("a single-leaf tree is always valid") + .finalize( + &Secp256k1::verification_only(), + anchor_internal_key.x_only_public_key().0, + ) + .expect("a complete single-leaf tree always finalizes"); + + p2tr(&spend_info) +} + +/// ` OP_CHECKSIGVERIFY OP_CHECKSEQUENCEVERIFY` +fn to_local_delay_script(local_delayedpubkey: &PublicKey, to_self_delay: u16) -> ScriptBuf { + Builder::new() + .push_slice(local_delayedpubkey.x_only_public_key().0.serialize()) + .push_opcode(opcodes::OP_CHECKSIGVERIFY) + .push_int(i64::from(to_self_delay)) + .push_opcode(opcodes::OP_CSV) + .into_script() +} + +/// ` OP_DROP OP_CHECKSIG` +fn to_local_revoke_script( + local_delayedpubkey: &PublicKey, + revocationpubkey: &PublicKey, +) -> ScriptBuf { + Builder::new() + .push_slice(local_delayedpubkey.x_only_public_key().0.serialize()) + .push_opcode(opcodes::OP_DROP) + .push_slice(revocationpubkey.x_only_public_key().0.serialize()) + .push_opcode(opcodes::OP_CHECKSIG) + .into_script() +} + +/// ` OP_CHECKSIGVERIFY OP_1 OP_CHECKSEQUENCEVERIFY` +fn to_remote_script(remotepubkey: &PublicKey) -> ScriptBuf { + Builder::new() + .push_slice(remotepubkey.x_only_public_key().0.serialize()) + .push_opcode(opcodes::OP_CHECKSIGVERIFY) + .push_opcode(opcodes::OP_PUSHNUM_1) + .push_opcode(opcodes::OP_CSV) + .into_script() +} + +/// `OP_16 OP_CHECKSEQUENCEVERIFY` +fn anchor_script() -> ScriptBuf { + Builder::new() + .push_opcode(opcodes::OP_PUSHNUM_16) + .push_opcode(opcodes::OP_CSV) + .into_script() +} + +/// Returns the P2TR `script_pubkey` for a finalized tapscript tree. +fn p2tr(spend_info: &TaprootSpendInfo) -> ScriptBuf { + ScriptBuf::new_p2tr_tweaked(spend_info.output_key()) +} + +/// Returns the tapscript merkle root of a `to_local` output, exposed so tests +/// can check it against the spec vectors. +#[cfg(test)] +fn to_local_merkle_root( + local_delayedpubkey: &PublicKey, + revocationpubkey: &PublicKey, + to_self_delay: u16, +) -> bitcoin::TapNodeHash { + TaprootBuilder::new() + .add_leaf(1, to_local_delay_script(local_delayedpubkey, to_self_delay)) + .and_then(|builder| { + builder.add_leaf( + 1, + to_local_revoke_script(local_delayedpubkey, revocationpubkey), + ) + }) + .expect("a two-leaf tree at depth 1 is always valid") + .finalize(&Secp256k1::verification_only(), nums_point()) + .expect("a complete two-leaf tree always finalizes") + .merkle_root() + .expect("a tree with leaves has a merkle root") +} + #[cfg(test)] mod tests { use super::*; + use bitcoin::hashes::Hash; + + fn pubkey(hex_str: &str) -> PublicKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + PublicKey::from_slice(&bytes).expect("valid pubkey") + } + + // Test vectors from the simple taproot channels spec: + // bolt-simple-taproot.md, "Test Vectors" appendix. + // + // `csv_delay` is 144 throughout. + + const DELAYED_PUBKEY: &str = + "0315ec0138eb42f1ab4603042123988d53c854e89d1d87aa4dbb97a57482029c05"; + const REVOCATION_PUBKEY: &str = + "03d4c77088d346bce67c13bbbf82ca112588f4b1c9595a1f8af3be9b2f95a109a0"; + const REMOTE_PAYMENT_PUBKEY: &str = + "03595f2ef2a51d2250a21077dbea4a7fc3ce550f10676996bf63719e2a71d1f4c9"; + const CSV_DELAY: u16 = 144; + + #[test] + fn nums_point_matches_spec_vector() { + assert_eq!( + hex::encode(nums_point().serialize()), + "dca094751109d0bd055d03565874e8276dd53e926b44e3bd1bb6bf4bc130a279" + ); + } + + #[test] + fn to_local_leaf_scripts_match_spec_vectors() { + let delayed = pubkey(DELAYED_PUBKEY); + let revocation = pubkey(REVOCATION_PUBKEY); + + assert_eq!( + hex::encode(to_local_delay_script(&delayed, CSV_DELAY).as_bytes()), + "2015ec0138eb42f1ab4603042123988d53c854e89d1d87aa4dbb97a57482029c05ad029000b2" + ); + assert_eq!( + hex::encode(to_local_revoke_script(&delayed, &revocation).as_bytes()), + "2015ec0138eb42f1ab4603042123988d53c854e89d1d87aa4dbb97a57482029c057520d4c77088d346bce67c13bbbf82ca112588f4b1c9595a1f8af3be9b2f95a109a0ac" + ); + } + + /// BIP 341 sorts each `TapBranch`'s children, so the root is independent of + /// the order the leaves were added in. This pins the root against the spec + /// so a change in tree assembly cannot go unnoticed. + #[test] + fn to_local_merkle_root_matches_spec_vector() { + assert_eq!( + hex::encode( + to_local_merkle_root( + &pubkey(DELAYED_PUBKEY), + &pubkey(REVOCATION_PUBKEY), + CSV_DELAY + ) + .to_byte_array() + ), + "b8b76c2e893ca785072f0d7393e35d5bd72adf8b7ff2a53538aa664378a38a36" + ); + } + + #[test] + fn to_local_scriptpubkey_matches_spec_vector() { + assert_eq!( + hex::encode( + to_local_scriptpubkey( + &pubkey(DELAYED_PUBKEY), + &pubkey(REVOCATION_PUBKEY), + CSV_DELAY + ) + .as_bytes() + ), + "51203e1fcbbd06c8a7414704612c72be9834a75d86ed85b29f0ef0c52e1950afaff3" + ); + } + + #[test] + fn to_remote_script_matches_spec_vector() { + assert_eq!( + hex::encode(to_remote_script(&pubkey(REMOTE_PAYMENT_PUBKEY)).as_bytes()), + "20595f2ef2a51d2250a21077dbea4a7fc3ce550f10676996bf63719e2a71d1f4c9ad51b2" + ); + } + + #[test] + fn to_remote_scriptpubkey_matches_spec_vector() { + assert_eq!( + hex::encode(to_remote_scriptpubkey(&pubkey(REMOTE_PAYMENT_PUBKEY)).as_bytes()), + "51203609bb705034e5629aa6ec05c5ca906ac89ac08b34c4583c259521ec30174408" + ); + } + + #[test] + fn anchor_script_matches_spec_vector() { + assert_eq!(hex::encode(anchor_script().as_bytes()), "60b2"); + } + + /// The local anchor's internal key is the delayed payment key, and the + /// remote anchor's is the remote payment key: both are revealed on chain + /// when the commitment is spent. + #[test] + fn anchor_scriptpubkeys_match_spec_vectors() { + assert_eq!( + hex::encode(anchor_scriptpubkey(&pubkey(DELAYED_PUBKEY)).as_bytes()), + "5120f67ab012701705f3203d132f909a6810ef18c5da4c11d986cb50818803b8344e" + ); + assert_eq!( + hex::encode(anchor_scriptpubkey(&pubkey(REMOTE_PAYMENT_PUBKEY)).as_bytes()), + "51201249c50576fdf914caa14f9221370b986df520bdbc73f57d5056a86ee03e5ac4" + ); + } #[test] fn funding_scriptpubkey_matches_spec_vector() { From 4393456bfa8403de0c8a5fdea095e220f04874e2 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:05:57 -0300 Subject: [PATCH 12/16] smite-ir: pass the channel_type to CreateFundingTransaction The funding output format follows from the negotiated channel type, so the operation that builds the funding transaction has to know it. It becomes a fifth input rather than being inferred, keeping the dependency visible in the program listing and leaving every mutator working unchanged. It must match the type given to BuildOpenChannel; when a mutator makes them disagree the target simply never sees the funding output it negotiated. This changes the operation's arity, and the executor asserts arity exactly, so existing corpora must be regenerated. --- smite-ir/src/generators/funding_created.rs | 2 + smite-ir/src/generators/funding_flow.rs | 6 ++- smite-ir/src/operation.rs | 11 ++++- smite-ir/src/tests.rs | 17 +++++--- smite-scenarios/src/executor.rs | 50 +++++++++++++--------- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/smite-ir/src/generators/funding_created.rs b/smite-ir/src/generators/funding_created.rs index eb05b2ae..4b6f9a32 100644 --- a/smite-ir/src/generators/funding_created.rs +++ b/smite-ir/src/generators/funding_created.rs @@ -26,6 +26,7 @@ impl Generator for FundingCreatedGenerator { let funding_satoshis = builder.pick_variable(VariableType::Amount, rng); let feerate_per_kw = builder.pick_variable(VariableType::FeeratePerKw, rng); let temporary_channel_id = builder.pick_variable(VariableType::ChannelId, rng); + let channel_type = builder.pick_variable(VariableType::Features, rng); // Create the BOLT 3 funding transaction. let funding_transaction = builder.append( @@ -35,6 +36,7 @@ impl Generator for FundingCreatedGenerator { acceptor_funding_pubkey, funding_satoshis, feerate_per_kw, + channel_type, ], ); diff --git a/smite-ir/src/generators/funding_flow.rs b/smite-ir/src/generators/funding_flow.rs index 47489122..331bbf56 100644 --- a/smite-ir/src/generators/funding_flow.rs +++ b/smite-ir/src/generators/funding_flow.rs @@ -42,7 +42,6 @@ impl Generator for FundingFlowGenerator { let feerate_per_kw = builder.pick_variable(VariableType::FeeratePerKw, rng); let to_self_delay = builder.pick_variable(VariableType::U16, rng); let max_accepted_htlcs = builder.pick_variable(VariableType::U16, rng); - let channel_flags = builder.pick_variable(VariableType::U8, rng); let shutdown_script_variant = ShutdownScriptVariant::random(rng); let upfront_shutdown_script = builder.append(Operation::LoadShutdownScript(shutdown_script_variant), &[]); @@ -51,6 +50,8 @@ impl Generator for FundingFlowGenerator { .expect("ChannelTypeVariant::ALL is non-empty"); let channel_type = builder.append(Operation::LoadChannelType(variant), &[]); + let channel_flags = builder.pick_variable(VariableType::U8, rng); + // Build and send open_channel. let open_channel_msg = builder.append( Operation::BuildOpenChannel, @@ -94,6 +95,9 @@ impl Generator for FundingFlowGenerator { acceptor_funding_pubkey, funding_satoshis, feerate_per_kw, + // Must match the type sent in open_channel, or the funding + // output will not be the one the target negotiated. + channel_type, ], ); diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index fccde02a..2ae6fb91 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -75,11 +75,18 @@ pub enum Operation { ExtractAcceptChannel(AcceptChannelField), /// Create a BOLT 3 funding transaction for the channel funding flow. /// - /// Inputs (4): + /// The `channel_type` selects the funding output format: simple taproot + /// channels pay to a single P2TR key aggregated from both funding pubkeys, + /// every other type to a 2-of-2 P2WSH. It must match the `channel_type` + /// given to `BuildOpenChannel`, or the target will never see the funding + /// output it negotiated. + /// + /// Inputs (5): /// 0: `opener_funding_pubkey` (`Point`) /// 1: `acceptor_funding_pubkey` (`Point`) /// 2: `funding_satoshis` (`Amount`) /// 3: `feerate_per_kw` (`FeeratePerKw`) + /// 4: `channel_type` (`Features`, empty = 2-of-2 P2WSH) CreateFundingTransaction, // -- Build: construct a BOLT message from inputs -- @@ -517,7 +524,6 @@ impl ChannelTypeVariant { Self::ScriptEnforcedLeaseZeroConf, Self::ScriptEnforcedLeaseScidAliasZeroConf, ]; - /// The feature bits (even/required) contained in this channel type. #[must_use] pub fn bits(self) -> &'static [FeatureBit] { @@ -828,6 +834,7 @@ impl Operation { VariableType::Point, // acceptor_funding_pubkey VariableType::Amount, // funding_satoshis VariableType::FeeratePerKw, // feerate_per_kw + VariableType::Features, // channel_type ], Self::SendMessage => vec![VariableType::Message], Self::SendOpenChannel => vec![VariableType::OpenChannelMessage], diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index bb092617..8fac34b6 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -637,8 +637,9 @@ fn postcard_roundtrip() { inputs: vec![], }, Instruction { + // v6 is LoadFeatures, standing in for the channel type. operation: Operation::CreateFundingTransaction, - inputs: vec![1, 9, 5, 10], + inputs: vec![1, 9, 5, 10, 6], }, Instruction { operation: Operation::BroadcastTransaction, @@ -692,6 +693,7 @@ fn create_and_broadcast_tx_operation() { VariableType::Point, VariableType::Amount, VariableType::FeeratePerKw, + VariableType::Features, ], ); assert_eq!(op.output_type(), Some(VariableType::FundingTransaction)); @@ -741,13 +743,17 @@ fn create_and_broadcast_tx_instructions() -> Vec { operation: Operation::LoadFeeratePerKw(15_000), inputs: vec![], }, + Instruction { + operation: Operation::LoadChannelType(ChannelTypeVariant::StaticRemoteKey), + inputs: vec![], + }, Instruction { operation: Operation::CreateFundingTransaction, - inputs: vec![1, 3, 4, 5], + inputs: vec![1, 3, 4, 5, 6], }, Instruction { operation: Operation::BroadcastTransaction, - inputs: vec![6], + inputs: vec![7], }, ] } @@ -769,8 +775,9 @@ fn displays_create_and_broadcast_tx_program() { "v3 = DerivePoint(v2)".into(), "v4 = LoadAmount(10000000)".into(), "v5 = LoadFeeratePerKw(15000)".into(), - "v6 = CreateFundingTransaction(v1, v3, v4, v5)".into(), - "BroadcastTransaction(v6)".into(), + "v6 = LoadChannelType(StaticRemoteKey)".into(), + "v7 = CreateFundingTransaction(v1, v3, v4, v5, v6)".into(), + "BroadcastTransaction(v7)".into(), ]; assert_eq!(lines, expected); } diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 4e91edb7..593a6243 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -799,6 +799,7 @@ fn create_funding_transaction( let acceptor_pubkey = resolve_pubkey(variables, inputs[1]); let funding_satoshis = resolve_amount(variables, inputs[2]); let feerate_per_kw = resolve_feerate(variables, inputs[3]); + let channel_type = Features::from(resolve_features(variables, inputs[4])); // Query wallet state from bitcoind for coin selection and change. let utxos = cli.get_utxos(); @@ -810,9 +811,7 @@ fn create_funding_transaction( &acceptor_pubkey, funding_satoshis, feerate_per_kw, - // The IR does not carry the channel type into this operation yet, so - // the funding output is always the 2-of-2 P2WSH form. - &[], + &channel_type, utxos, change_spk, )?; @@ -1448,7 +1447,7 @@ mod tests { use bitcoin::{Amount, Transaction}; use smite::bolt::{AcceptChannelTlvs, FundingSignedTlvs, GossipTimestampFilter, Init, Ping}; use smite_ir::Instruction; - use smite_ir::operation::ShutdownScriptVariant; + use smite_ir::operation::{ChannelTypeVariant, ShutdownScriptVariant}; // -- MockConnection -- @@ -1742,13 +1741,17 @@ mod tests { operation: Operation::LoadFeeratePerKw(15_000), inputs: vec![], }, + Instruction { + operation: Operation::LoadChannelType(ChannelTypeVariant::StaticRemoteKey), + inputs: vec![], + }, Instruction { operation: Operation::CreateFundingTransaction, - inputs: vec![1, 3, 4, 5], + inputs: vec![1, 3, 4, 5, 6], }, Instruction { operation: Operation::BroadcastTransaction, - inputs: vec![6], + inputs: vec![7], }, ] } @@ -3115,13 +3118,16 @@ mod tests { }); instrs.push(Instruction { // Feed the FundingTransaction produced by - // CreateFundingTransaction (instruction 6) into the lookup. The - // resulting ShortChannelId is variable 9. + // CreateFundingTransaction (instruction 7) into the lookup. The + // resulting ShortChannelId is variable 10. operation: Operation::LookupShortChannelId, - inputs: vec![6], + inputs: vec![7], }); // Build and send a channel_announcement carrying the looked-up SCID. - instrs.extend(channel_announcement_from_scid_instructions(instrs.len(), 9)); + instrs.extend(channel_announcement_from_scid_instructions( + instrs.len(), + 10, + )); let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); executor @@ -3187,17 +3193,21 @@ mod tests { operation: Operation::LoadFeeratePerKw(15_000), inputs: vec![], }, + Instruction { + operation: Operation::LoadChannelType(ChannelTypeVariant::StaticRemoteKey), + inputs: vec![], + }, Instruction { operation: Operation::CreateFundingTransaction, - inputs: vec![1, 3, 4, 5], + inputs: vec![1, 3, 4, 5, 6], }, - // The looked-up SCID is variable 7. + // The looked-up SCID is variable 8. Instruction { operation: Operation::LookupShortChannelId, - inputs: vec![6], + inputs: vec![7], }, ]; - instrs.extend(channel_announcement_from_scid_instructions(instrs.len(), 7)); + instrs.extend(channel_announcement_from_scid_instructions(instrs.len(), 8)); let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); executor @@ -3360,11 +3370,11 @@ mod tests { }, Instruction { operation: Operation::SendFundingCreated, - inputs: vec![6, 0, 8], + inputs: vec![7, 0, 9], }, Instruction { operation: Operation::RecvFundingSigned, - inputs: vec![9], + inputs: vec![10], }, ]); instrs @@ -3687,13 +3697,13 @@ mod tests { operation: Operation::SendChannelReady { include_alias: false, }, - inputs: vec![10, 1, 11], + inputs: vec![11, 1, 12], }, Instruction { operation: Operation::SendChannelReady { include_alias: true, }, - inputs: vec![10, 3, 11], + inputs: vec![11, 3, 12], }, ]); @@ -4025,11 +4035,11 @@ mod tests { }, Instruction { operation: Operation::SendFundingCreated, - inputs: vec![6, 0, 9], + inputs: vec![7, 0, 10], }, Instruction { operation: Operation::RecvFundingSigned, - inputs: vec![10], + inputs: vec![11], }, Instruction { operation: Operation::RecvChannelReady, From 23da58206e94430ebb5f12df54177e215a5a2abd Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:06:20 -0300 Subject: [PATCH 13/16] smite-scenarios: drive the taproot funding flow open_channel publishes the MuSig2 nonce the peer signs our first commitment against, funding_created zeroes the fixed signature field and carries a partial signature instead, funding_signed is verified against the nonce we published, and channel_ready publishes a fresh nonce to replace the one the funding flow consumed. Each nonce gets a distinct derivation context, since reusing one across two signing sessions would leak the funding key. channel_flags is left exactly as the program specifies, including the announce_channel bit that taproot channels must not set, so mutators can still exercise how targets reject a public taproot channel. A missing or malformed peer nonce is likewise left to the oracles rather than treated as an executor error: it means the peer did not sign what we asked it to. --- smite-scenarios/src/executor.rs | 298 ++++++++++++++++++++++++---- smite-scenarios/src/scenarios/ir.rs | 6 + 2 files changed, 264 insertions(+), 40 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 593a6243..08e7ec40 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -8,15 +8,16 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{OutPoint, ScriptBuf, Txid}; use smite::bitcoin::{BitcoinCli, TxBlockPosition, Utxo}; use smite::bolt::{ - AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady, - ChannelReadyTlvs, ChannelUpdate, Features, FundingCreated, FundingCreatedTlvs, FundingSigned, - Message, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown, - msg_type, + AcceptChannel, AnnouncementSignatures, COMPACT_SIGNATURE_SIZE, ChannelAnnouncement, ChannelId, + ChannelReady, ChannelReadyTlvs, ChannelUpdate, Features, FundingCreated, FundingCreatedTlvs, + FundingSigned, Message, NodeAnnouncement, OpenChannel, OpenChannelTlvs, + PartialSignatureWithNonce, Pong, PublicNonce, ShortChannelId, Shutdown, msg_type, }; use smite::channel_tx::{ - ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side, - build_funding_transaction, + ChannelConfig, ChannelPartyConfig, ChannelState, CommitmentState, FundingTransaction, + HolderIdentity, Side, build_funding_transaction, }; +use smite::musig::{PartialSignature, derive_nonce}; use smite::noise::{ConnectionError, NoiseConnection}; use smite::oracles::{AcceptChannelContext, AcceptChannelOracle, Oracle}; use smite::pending_channel::PendingChannel; @@ -215,6 +216,10 @@ pub enum ExecuteError { #[error("funding: {0}")] Funding(#[from] smite::channel_tx::FundingError), + /// A `MuSig2` partial signature could not be produced or checked. + #[error("musig2: {0}")] + Musig(#[from] smite::musig::MusigError), + /// Failed to construct the initial commitment state. #[error("commitment: {0}")] Commitment(#[from] smite::channel_tx::CommitmentError), @@ -829,11 +834,41 @@ fn create_funding_transaction( Ok(funding) } +/// Returns the holder's own `funding_pubkey`, which is the key its `MuSig2` +/// nonces are bound to. smite always opens channels today, but reading the side +/// keeps this correct if it ever accepts one. +fn holder_funding_pubkey(state: &ChannelState) -> PublicKey { + match state.holder.side { + Side::Opener => state.config.opener.funding_pubkey, + Side::Acceptor => state.config.acceptor.funding_pubkey, + } +} + /// Builds an `OpenChannel` from 20 input variables (wire order). +/// +/// Simple taproot channels additionally carry a `MuSig2` verification nonce, +/// which the peer signs our first commitment against. `channel_flags` is left +/// exactly as the program specifies, including the `announce_channel` bit that +/// taproot channels must not set, so mutators can still exercise how targets +/// reject a public taproot channel. fn build_open_channel(variables: &[Option], inputs: &[usize]) -> OpenChannel { + let channel_type = Features::from(resolve_features(variables, inputs[19])); + let funding_pubkey = resolve_pubkey(variables, inputs[11]); + let temporary_channel_id = resolve_channel_id(variables, inputs[1]); + + let next_local_nonce = channel_type + .supports_feature(Features::OPTION_SIMPLE_TAPROOT) + .then(|| { + derive_nonce( + &[b"verification", temporary_channel_id.as_bytes()], + &funding_pubkey, + ) + .public_nonce() + }); + OpenChannel { chain_hash: resolve_chain_hash(variables, inputs[0]), - temporary_channel_id: resolve_channel_id(variables, inputs[1]), + temporary_channel_id, funding_satoshis: resolve_amount(variables, inputs[2]), push_msat: resolve_amount(variables, inputs[3]), dust_limit_satoshis: resolve_amount(variables, inputs[4]), @@ -843,7 +878,7 @@ fn build_open_channel(variables: &[Option], inputs: &[usize]) -> OpenC feerate_per_kw: resolve_feerate(variables, inputs[8]), to_self_delay: resolve_u16(variables, inputs[9]), max_accepted_htlcs: resolve_u16(variables, inputs[10]), - funding_pubkey: resolve_pubkey(variables, inputs[11]), + funding_pubkey, revocation_basepoint: resolve_pubkey(variables, inputs[12]), payment_basepoint: resolve_pubkey(variables, inputs[13]), delayed_payment_basepoint: resolve_pubkey(variables, inputs[14]), @@ -856,8 +891,8 @@ fn build_open_channel(variables: &[Option], inputs: &[usize]) -> OpenC // Omitting it is a protocol violation in that case. Including if // not negotiated is not. upfront_shutdown_script: Some(resolve_bytes(variables, inputs[18]).to_vec()), - channel_type: nonempty_or_none(resolve_features(variables, inputs[19])), - next_local_nonce: None, + channel_type: nonempty_or_none(&channel_type.into_bytes()), + next_local_nonce, }, } } @@ -919,30 +954,12 @@ fn build_funding_created( let secp = Secp256k1::new(); let opener_funding_pubkey = PublicKey::from_secret_key(&secp, &opener_funding_privkey); - let opener = ChannelPartyConfig { - funding_pubkey: opener_funding_pubkey, - payment_basepoint: open_channel.payment_basepoint, - revocation_basepoint: open_channel.revocation_basepoint, - delayed_payment_basepoint: open_channel.delayed_payment_basepoint, - dust_limit_satoshis: open_channel.dust_limit_satoshis, - to_self_delay: open_channel.to_self_delay, - }; - let acceptor = ChannelPartyConfig { - funding_pubkey: accept_channel.funding_pubkey, - payment_basepoint: accept_channel.payment_basepoint, - revocation_basepoint: accept_channel.revocation_basepoint, - delayed_payment_basepoint: accept_channel.delayed_payment_basepoint, - dust_limit_satoshis: accept_channel.dust_limit_satoshis, - to_self_delay: accept_channel.to_self_delay, - }; - let config = ChannelConfig { + let config = negotiated_channel_config( + open_channel, + accept_channel, funding_outpoint, - funding_satoshis: open_channel.funding_satoshis, - channel_type: Features::from(open_channel.tlvs.channel_type.clone().unwrap_or_default()), - opener, - acceptor, - minimum_depth: accept_channel.minimum_depth, - }; + opener_funding_pubkey, + ); let state = config.new_initial_commitment( open_channel.push_msat, @@ -954,7 +971,15 @@ fn build_funding_created( side: Side::Opener, funding_privkey: opener_funding_privkey, }; - let signature = config.sign_counterparty_commitment(&state, &holder); + + let (signature, tlvs) = sign_initial_commitment( + &config, + &state, + &holder, + &temporary_channel_id, + &opener_funding_pubkey, + accept_channel.tlvs.next_local_nonce, + )?; let channel_id = ChannelId::v1_from_funding_outpoint(config.funding_outpoint); @@ -968,6 +993,10 @@ fn build_funding_created( &config.channel_type, ); + // The nonce we published in `open_channel` is what the acceptor signs our + // commitment against, so keep it to verify the incoming `funding_signed`. + let holder_verification_nonce = open_channel.tlvs.next_local_nonce; + // Building the same message again must not clobber a channel whose state // has already been established (and possibly advanced). channel_states.entry(channel_id).or_insert_with(|| { @@ -977,7 +1006,7 @@ fn build_funding_created( state, is_funding_outpoint_valid, mined_txids.contains(&funding_outpoint.txid), - None, + holder_verification_nonce, ) }); @@ -994,10 +1023,108 @@ fn build_funding_created( funding_txid: funding_outpoint.txid, funding_output_index, signature, - tlvs: FundingCreatedTlvs::default(), + tlvs, }) } +/// Builds the channel configuration from the values actually exchanged in +/// `open_channel` and `accept_channel`. +/// +/// The opener's funding pubkey comes from the private key the program supplied +/// to `SendFundingCreated` rather than from `open_channel`, since that is the +/// key we can actually sign with. A mutator can make the two disagree, which +/// then shows up as a funding output the target does not recognize. +fn negotiated_channel_config( + open_channel: &OpenChannel, + accept_channel: &AcceptChannel, + funding_outpoint: OutPoint, + opener_funding_pubkey: PublicKey, +) -> ChannelConfig { + ChannelConfig { + funding_outpoint, + funding_satoshis: open_channel.funding_satoshis, + channel_type: Features::from(open_channel.tlvs.channel_type.clone().unwrap_or_default()), + opener: ChannelPartyConfig { + funding_pubkey: opener_funding_pubkey, + payment_basepoint: open_channel.payment_basepoint, + revocation_basepoint: open_channel.revocation_basepoint, + delayed_payment_basepoint: open_channel.delayed_payment_basepoint, + dust_limit_satoshis: open_channel.dust_limit_satoshis, + to_self_delay: open_channel.to_self_delay, + }, + acceptor: ChannelPartyConfig { + funding_pubkey: accept_channel.funding_pubkey, + payment_basepoint: accept_channel.payment_basepoint, + revocation_basepoint: accept_channel.revocation_basepoint, + delayed_payment_basepoint: accept_channel.delayed_payment_basepoint, + dust_limit_satoshis: accept_channel.dust_limit_satoshis, + to_self_delay: accept_channel.to_self_delay, + }, + minimum_depth: accept_channel.minimum_depth, + } +} + +/// Signs the acceptor's first commitment transaction. +/// +/// Simple taproot channels zero the fixed `signature` field and carry a +/// `MuSig2` partial signature in TLV 2 instead. Signing needs the acceptor's +/// verification nonce from `accept_channel`; without it we can only send the +/// unsigned form and let the target fail the channel, which is what the spec +/// tells it to do. +/// +/// # Errors +/// +/// Returns [`ExecuteError::Musig`] if the acceptor's nonce is malformed or the +/// funding keys cannot be aggregated. +fn sign_initial_commitment( + config: &ChannelConfig, + state: &CommitmentState, + holder: &HolderIdentity, + temporary_channel_id: &ChannelId, + opener_funding_pubkey: &PublicKey, + acceptor_verification_nonce: Option, +) -> Result<(Signature, FundingCreatedTlvs), ExecuteError> { + if !config.is_simple_taproot() { + return Ok(( + config.sign_counterparty_commitment(state, holder), + FundingCreatedTlvs::default(), + )); + } + + let Some(acceptor_nonce) = acceptor_verification_nonce else { + return Ok((zero_signature(), FundingCreatedTlvs::default())); + }; + + let signing_nonce = derive_nonce( + &[b"signing", temporary_channel_id.as_bytes()], + opener_funding_pubkey, + ); + let public_nonce = signing_nonce.public_nonce(); + let partial = config.partial_sign_counterparty_commitment( + state, + holder, + signing_nonce, + &acceptor_nonce, + )?; + + Ok(( + zero_signature(), + FundingCreatedTlvs { + partial_signature_with_nonce: Some(PartialSignatureWithNonce { + partial_signature: partial.0, + public_nonce, + }), + }, + )) +} + +/// The all-zero signature the taproot channel spec requires in the fixed +/// `signature` field of `funding_created` and `funding_signed`. +fn zero_signature() -> Signature { + Signature::from_compact(&[0u8; COMPACT_SIGNATURE_SIZE]) + .expect("zero bytes parse as a signature") +} + /// Builds a `ChannelReady` from 3 input variables (wire order). fn build_channel_ready( variables: &[Option], @@ -1024,12 +1151,25 @@ fn build_channel_ready( } } + // Simple taproot channels must publish a fresh verification nonce here, + // replacing the one the funding flow consumed. + let next_local_nonce = channel_states + .get(&channel_id) + .filter(|state| state.config.is_simple_taproot()) + .map(|state| { + derive_nonce( + &[b"channel-ready", channel_id.as_bytes()], + &holder_funding_pubkey(state), + ) + .public_nonce() + }); + ChannelReady { channel_id, second_per_commitment_point, tlvs: ChannelReadyTlvs { short_channel_id, - next_local_nonce: None, + next_local_nonce, }, } } @@ -1344,13 +1484,45 @@ fn verify_funding_signed( .get(&fs.channel_id) .ok_or(Violation::UnknownChannel(fs.channel_id))?; - state - .config - .verify_counterparty_signature(&state.commitment, &state.holder, &fs.signature) + let valid = if state.config.is_simple_taproot() { + verify_taproot_funding_signed(fs, state) + } else { + state + .config + .verify_counterparty_signature(&state.commitment, &state.holder, &fs.signature) + }; + + valid .then_some(()) .ok_or(Violation::InvalidCounterpartySignature(fs.channel_id)) } +/// Returns whether the peer's `MuSig2` partial signature over our first +/// commitment is valid. +/// +/// A missing TLV, a malformed nonce, or a nonce we never published all mean the +/// peer did not sign what we asked it to, so all of them read as invalid rather +/// than as an executor error. +fn verify_taproot_funding_signed(fs: &FundingSigned, state: &ChannelState) -> bool { + let (Some(partial), Some(holder_nonce)) = ( + fs.tlvs.partial_signature_with_nonce, + state.holder_verification_nonce, + ) else { + return false; + }; + + state + .config + .verify_counterparty_partial_signature( + &state.commitment, + &state.holder, + &PartialSignature(partial.partial_signature), + &partial.public_nonce, + &holder_nonce, + ) + .unwrap_or(false) +} + /// Records a sent `open_channel`, keyed by `temporary_channel_id`, so the /// funding flow can build commitments from the values actually put on the wire. /// @@ -2334,6 +2506,52 @@ mod tests { assert_eq!(oc.tlvs.channel_type, Some(vec![0x01, 0x02])); } + /// A simple taproot `channel_type` makes `open_channel` carry a `MuSig2` + /// verification nonce, which every other channel type must omit. + #[test] + fn execute_build_open_channel_adds_taproot_nonce() { + let taproot_nonce = |channel_type: ChannelTypeVariant| { + let mut instrs = open_channel_instructions(); + instrs[19] = Instruction { + operation: Operation::LoadChannelType(channel_type), + inputs: vec![], + }; + instrs.push(Instruction { + operation: Operation::BuildOpenChannel, + inputs: (0..20).collect(), + }); + instrs.push(Instruction { + operation: Operation::SendOpenChannel, + inputs: vec![20], + }); + + let mut executor = Executor::new( + MockConnection::new(), + MockBitcoinCli::default(), + sample_context(), + ); + executor + .execute( + &Program { + instructions: instrs, + }, + std::time::Instant::now(), + ) + .unwrap(); + + decode_open_channel(&executor.conn.sent[0]) + .tlvs + .next_local_nonce + }; + + let nonce = taproot_nonce(ChannelTypeVariant::SimpleTaproot) + .expect("taproot channels must publish a verification nonce"); + assert!(smite::musig::is_valid_public_nonce(&nonce)); + + assert_eq!(taproot_nonce(ChannelTypeVariant::Anchors), None); + assert_eq!(taproot_nonce(ChannelTypeVariant::StaticRemoteKey), None); + } + #[test] fn execute_derive_point() { let mut instrs = vec![ diff --git a/smite-scenarios/src/scenarios/ir.rs b/smite-scenarios/src/scenarios/ir.rs index b8541ca2..7771ca02 100644 --- a/smite-scenarios/src/scenarios/ir.rs +++ b/smite-scenarios/src/scenarios/ir.rs @@ -99,6 +99,12 @@ impl> Scenario for IrScenario { // the available UTXOs can't cover. Not a bug in the target. log::debug!("[{:?}] funding transaction: {e}", start.elapsed()); } + Err(ExecuteError::Musig(e)) => { + // The mutator produced a taproot negotiation we cannot sign, + // e.g. a malformed peer nonce. Not a bug in the target: the + // oracles report a peer that sends one. + log::debug!("[{:?}] musig2: {e}", start.elapsed()); + } Err(ExecuteError::Commitment(e)) => { // The mutator generated a funding amount/push_msat combination // that can't form a valid initial commitment transaction. Not a From 228ba06abd5650f47366359e650f502b56a3d8ae Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:06:43 -0300 Subject: [PATCH 14/16] smite: check the taproot requirements in the accept_channel oracle A target that accepts an open_channel negotiating a taproot channel without a usable MuSig2 nonce, or one that announces the channel, has skipped a check the spec requires it to make. So has one that answers without publishing a nonce of its own. Also drops a stale note about the commitment fee: the initial commitment check now uses the 968 taproot weight, since CommitmentCost derives the weight from the channel type. --- smite/src/oracles/accept_channel.rs | 138 ++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 8 deletions(-) diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs index c76fc983..545e70af 100644 --- a/smite/src/oracles/accept_channel.rs +++ b/smite/src/oracles/accept_channel.rs @@ -1,8 +1,9 @@ //! BOLT 2 `accept_channel` oracle, for the v1 outbound channel funding flow. use super::Oracle; -use crate::bolt::{AcceptChannel, Features, OpenChannel}; +use crate::bolt::{AcceptChannel, Features, OpenChannel, PublicNonce}; use crate::channel_tx::CommitmentCost; +use crate::musig::is_valid_public_nonce; use crate::pending_channel::PendingChannel; use crate::violation::Violation; @@ -13,6 +14,9 @@ use bitcoin::Amount; const MAX_ACCEPTED_HTLCS_LIMIT: u16 = 483; const MIN_DUST_LIMIT_SATOSHIS: u64 = 354; +/// `announce_channel` bit of `open_channel.channel_flags` (BOLT 2). +const ANNOUNCE_CHANNEL_FLAG: u8 = 0b0000_0001; + /// Context for `AcceptChannelOracle` pub struct AcceptChannelContext<'a> { /// The `accept_channel` received from the peer. @@ -133,6 +137,19 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String )); } + // Simple taproot channels cannot be gossiped and need a `MuSig2` nonce for + // the acceptor to sign the first commitment against, so a target accepting + // an `open_channel` without them has skipped a required check. + if channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT) { + if open_channel.channel_flags & ANNOUNCE_CHANNEL_FLAG != 0 { + return Err( + "open_channel announces a simple taproot channel, which cannot be gossiped" + .to_string(), + ); + } + verify_next_local_nonce("open_channel", open_channel.tlvs.next_local_nonce)?; + } + // Check the initial commitment satisfies the channel reserve. verify_initial_commitment( open_channel, @@ -141,6 +158,27 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String ) } +/// Verifies that a simple taproot channel message carries a usable `MuSig2` +/// verification nonce. +fn verify_next_local_nonce( + message: &str, + next_local_nonce: Option, +) -> Result<(), String> { + let Some(nonce) = next_local_nonce else { + return Err(format!( + "{message} negotiates a simple taproot channel but omits next_local_nonce" + )); + }; + + if !is_valid_public_nonce(&nonce) { + return Err(format!( + "{message} next_local_nonce is not two valid compressed secp256k1 points" + )); + } + + Ok(()) +} + /// Verifies the `accept_channel` against the BOLT 2 requirements it must meet, /// returning an error if it breaches one, or `Ok(())` if it meets them all. /// @@ -169,6 +207,11 @@ fn verify_accept_channel( return Err("accept_channel channel_type does not match open_channel".to_string()); } + // The acceptor must publish the nonce we sign its commitment against. + if channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT) { + verify_next_local_nonce("accept_channel", accept_channel.tlvs.next_local_nonce)?; + } + // Check the acceptor's channel reserve covers the opener's dust limit. if accept_channel.channel_reserve_satoshis < open_channel.dust_limit_satoshis { return Err(format!( @@ -216,17 +259,14 @@ fn verify_accept_channel( /// channel reserve requirement, returning an error if it breaches either, or /// `Ok(())` if both are met. /// -/// NOTE: This check is safe from false positives for `zero_fee_commitments` -/// and `option_simple_taproot`, although the reported error may be misleading: +/// NOTE: This check is safe from false positives for `zero_fee_commitments`, +/// although the reported error may be misleading: /// /// - `zero_fee_commitments` requires `feerate_per_kw == 0`, which we currently /// do not enforce. A non-zero feerate may cause the error to be reported here /// even though it is invalid for this channel type. -/// - `option_simple_taproot` has a different commitment fee (968-byte weight), -/// but we calculate it using the lower 724-byte weight. This may allow some -/// invalid cases through, but cannot cause a false positive. -/// - Anchor costs are only included when `option_anchors` is negotiated, so -/// they are not unnecessarily subtracted for these channel types. +/// - Anchor costs are only included when the channel type carries anchor +/// outputs, so they are not unnecessarily subtracted for other types. fn verify_initial_commitment( open_channel: &OpenChannel, channel_type: &Features, @@ -588,4 +628,86 @@ mod tests { assert_pass(&accept_channel(), Some(&negotiation)); } + + /// A `channel_type` negotiating `option_simple_taproot`, as wire bytes. + fn simple_taproot_channel_type() -> Vec { + Features::from_bits(&[Features::OPTION_SIMPLE_TAPROOT]).into_bytes() + } + + /// A well-formed taproot negotiation: both sides carry a valid nonce and + /// the channel is unannounced. + fn taproot_negotiation() -> (OpenChannel, AcceptChannel) { + let nonce = crate::musig::derive_nonce(&[b"test"], &pubkey(1)).public_nonce(); + + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(simple_taproot_channel_type()); + oc.tlvs.next_local_nonce = Some(nonce); + oc.channel_flags = 0; + // Taproot commitments are heavier, so keep the feerate affordable. + oc.feerate_per_kw = 2_500; + + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(simple_taproot_channel_type()); + ac.tlvs.next_local_nonce = Some(nonce); + + (oc, ac) + } + + #[test] + fn conforming_taproot_negotiation_passes() { + let (oc, ac) = taproot_negotiation(); + assert_pass(&ac, Some(&pending_negotiation(oc))); + } + + #[test] + fn taproot_open_channel_without_a_nonce() { + let (mut oc, ac) = taproot_negotiation(); + oc.tlvs.next_local_nonce = None; + assert_fail( + &ac, + Some(&pending_negotiation(oc)), + "open_channel negotiates a simple taproot channel but omits next_local_nonce", + ); + } + + #[test] + fn taproot_open_channel_with_a_malformed_nonce() { + let (mut oc, ac) = taproot_negotiation(); + oc.tlvs.next_local_nonce = Some(PublicNonce([0x00; crate::bolt::PUBLIC_NONCE_SIZE])); + assert_fail( + &ac, + Some(&pending_negotiation(oc)), + "open_channel next_local_nonce is not two valid compressed secp256k1 points", + ); + } + + /// Taproot channels cannot be gossiped, so a target must reject an + /// `open_channel` that sets `announce_channel`. + #[test] + fn taproot_open_channel_that_announces_the_channel() { + let (mut oc, ac) = taproot_negotiation(); + oc.channel_flags |= ANNOUNCE_CHANNEL_FLAG; + assert_fail(&ac, Some(&pending_negotiation(oc)), "cannot be gossiped"); + } + + #[test] + fn taproot_accept_channel_without_a_nonce() { + let (oc, mut ac) = taproot_negotiation(); + ac.tlvs.next_local_nonce = None; + assert_fail( + &ac, + Some(&pending_negotiation(oc)), + "accept_channel negotiates a simple taproot channel but omits next_local_nonce", + ); + } + + /// A non-taproot channel must not be required to carry a nonce. + #[test] + fn non_taproot_negotiation_does_not_require_a_nonce() { + let oc = open_channel(); + let ac = accept_channel(); + assert!(oc.tlvs.next_local_nonce.is_none()); + assert!(ac.tlvs.next_local_nonce.is_none()); + assert_pass(&ac, Some(&pending_negotiation(oc))); + } } From 9bdd127f9c958debeffeeb883a4f8b26e30d6fd8 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:06:57 -0300 Subject: [PATCH 15/16] smite-ir: generate unannounced channels for taproot types Taproot channels cannot be gossiped, so a randomly chosen channel_flags leaves the happy path unreachable for a third of the channel types the funding flow generator picks from. Clear the announce_channel bit for those types. Mutators can still flip it afterwards, which is what exercises how targets reject a public taproot channel. --- smite-ir/src/generators/funding_flow.rs | 10 +++++++++- smite-ir/src/operation.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/smite-ir/src/generators/funding_flow.rs b/smite-ir/src/generators/funding_flow.rs index 331bbf56..99a2f613 100644 --- a/smite-ir/src/generators/funding_flow.rs +++ b/smite-ir/src/generators/funding_flow.rs @@ -50,7 +50,15 @@ impl Generator for FundingFlowGenerator { .expect("ChannelTypeVariant::ALL is non-empty"); let channel_type = builder.append(Operation::LoadChannelType(variant), &[]); - let channel_flags = builder.pick_variable(VariableType::U8, rng); + // Taproot channels cannot be announced, so a random `channel_flags` + // would leave the happy path unreachable for a third of the channel + // types. Mutators can still flip the bit afterwards to exercise how + // targets reject a public taproot channel. + let channel_flags = if variant.requires_unannounced_channel() { + builder.append(Operation::LoadU8(0), &[]) + } else { + builder.pick_variable(VariableType::U8, rng) + }; // Build and send open_channel. let open_channel_msg = builder.append( diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 2ae6fb91..fef238a6 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -524,6 +524,19 @@ impl ChannelTypeVariant { Self::ScriptEnforcedLeaseZeroConf, Self::ScriptEnforcedLeaseScidAliasZeroConf, ]; + + /// Returns whether this type requires the channel to be unannounced. + /// + /// Taproot channels cannot be gossiped, so both lnd and eclair reject an + /// `open_channel` that sets the `announce_channel` bit alongside one of + /// these types. + #[must_use] + pub fn requires_unannounced_channel(self) -> bool { + self.bits().iter().any(|&bit| { + bit == Features::OPTION_SIMPLE_TAPROOT || bit == Features::OPTION_SIMPLE_TAPROOT_STAGING + }) + } + /// The feature bits (even/required) contained in this channel type. #[must_use] pub fn bits(self) -> &'static [FeatureBit] { From 9feb173d24c604d9dbc65910cb7c61a7d9252490 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 13 Aug 2026 17:06:57 -0300 Subject: [PATCH 16/16] smite-scenarios: enable simple taproot channels on the lnd target lnd v0.21.1-beta gates option_simple_taproot behind --protocol.simple-taproot-chans. Without it lnd clears bits 80/81 from its init and answers a taproot channel_type with "requested channel type not supported", so the taproot funding flow can never be exercised. Note that lnd master inverted this to an opt-out --protocol.no-taproot-chans, so this flag will need revisiting when the pinned version moves. --- smite-scenarios/src/targets/lnd.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/smite-scenarios/src/targets/lnd.rs b/smite-scenarios/src/targets/lnd.rs index f42f2a0c..9b370935 100644 --- a/smite-scenarios/src/targets/lnd.rs +++ b/smite-scenarios/src/targets/lnd.rs @@ -112,6 +112,7 @@ impl LndTarget { let mut cmd = Command::new("lnd"); cmd.arg("--noseedbackup") .arg("--debuglevel=info") + .arg("--protocol.simple-taproot-chans") .arg("--bitcoin.active") .arg("--bitcoin.regtest") .arg("--bitcoin.node=bitcoind")