From 2c016d40457120c162f9a0997c8802341e943d10 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 5 Aug 2026 13:41:10 +0530 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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 7f4f533cd699d73217afb7cdc11d977838cf438b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 2 Aug 2026 20:50:55 +0200 Subject: [PATCH 06/11] smite: add is_standard_shutdown_script helper BOLT-02 specifies sender requirements for shutdown scripts. They must be witness v0 (P2WPKH, P2WSH) or following features must be negotiated: * `option_shutdown_anysegwit`: witness v1-v16 with a 2..=40 byte program * `option_simple_close`: `OP_RETURN` with a single minimal data push of 6..=80 bytes Receivers may accept legacy scripts (P2PKH, P2SH), but we reject them since we're judging the sender's output. This applies to the `shutdown` and `closing_complete` messages, and the `upfront_shutdown_script` TLV in the `open_channel`, `open_channel2`, `accept_channel` and `accept_channel2` messages. This commit adds a helper to catch targets that don't comply with the spec. --- smite/src/bolt.rs | 2 +- smite/src/bolt/features.rs | 4 + smite/src/bolt/shutdown.rs | 209 +++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 0e2ddcce..a92dad5d 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -64,7 +64,7 @@ pub use open_channel2::{OpenChannel2, OpenChannel2Tlvs}; pub use ping::Ping; pub use pong::Pong; pub use revoke_and_ack::RevokeAndAck; -pub use shutdown::Shutdown; +pub use shutdown::{Shutdown, is_acceptable_shutdown_script, is_standard_shutdown_script}; pub use tlv::{TlvRecord, TlvStream}; pub use tx_abort::TxAbort; pub use tx_ack_rbf::{TxAckRbf, TxAckRbfTlvs}; diff --git a/smite/src/bolt/features.rs b/smite/src/bolt/features.rs index 2e18c361..a5bde36e 100644 --- a/smite/src/bolt/features.rs +++ b/smite/src/bolt/features.rs @@ -17,6 +17,8 @@ impl Features { pub const OPTION_STATIC_REMOTEKEY: FeatureBit = 12; /// `option_anchors` (bits 22/23). pub const OPTION_ANCHORS: FeatureBit = 22; + /// `option_shutdown_anysegwit` (bits 26/27). + pub const OPTION_SHUTDOWN_ANYSEGWIT: FeatureBit = 26; /// `option_dual_fund` (bits 28/29). pub const OPTION_DUAL_FUND: FeatureBit = 28; /// `zero_fee_commitments` (bits 40/41). @@ -27,6 +29,8 @@ impl Features { pub const OPTION_SCID_ALIAS: FeatureBit = 46; /// `option_zeroconf` (bits 50/51). pub const OPTION_ZEROCONF: FeatureBit = 50; + /// `option_simple_close` (bits 60/61). + pub const OPTION_SIMPLE_CLOSE: FeatureBit = 60; /// `option_simple_taproot` (bits 80/81). pub const OPTION_SIMPLE_TAPROOT: FeatureBit = 80; /// `option_simple_taproot_staging` (bits 180/181). diff --git a/smite/src/bolt/shutdown.rs b/smite/src/bolt/shutdown.rs index 8246b380..767ddb6c 100644 --- a/smite/src/bolt/shutdown.rs +++ b/smite/src/bolt/shutdown.rs @@ -1,6 +1,11 @@ //! BOLT 2 shutdown message. +use bitcoin::opcodes::all::OP_RETURN; +use bitcoin::script::Instruction; +use bitcoin::{Script, WitnessVersion}; + use super::BoltError; +use super::features::Features; use super::types::ChannelId; use super::wire::WireFormat; @@ -51,11 +56,77 @@ impl Shutdown { } } +/// Returns `true` if `spk` is a standard `shutdown` scriptpubkey per BOLT 2. +/// +/// Legacy P2PKH/P2SH are rejected. A receiver may accept them for backward +/// compatibility, but this oracle judges the sender's output. +#[must_use] +pub fn is_standard_shutdown_script(spk: &[u8], features: &Features) -> bool { + let script = Script::from_bytes(spk); + let witness_v0 = script.is_p2wpkh() || script.is_p2wsh(); + let anysegwit = features.supports_feature(Features::OPTION_SHUTDOWN_ANYSEGWIT) + && matches!(script.witness_version(), Some(v) if v != WitnessVersion::V0); + let simple_close = features.supports_feature(Features::OPTION_SIMPLE_CLOSE) + && is_simple_close_op_return(script); + witness_v0 || anysegwit || simple_close +} + +/// Returns `true` if `spk` is a shutdown scriptpubkey a receiver may accept per +/// BOLT 2. +/// +/// This includes the standard shutdown scriptpubkey forms, as well as legacy +/// P2PKH/P2SH outputs accepted for backward compatibility. +#[must_use] +pub fn is_acceptable_shutdown_script(spk: &[u8], features: &Features) -> bool { + let script = Script::from_bytes(spk); + is_standard_shutdown_script(spk, features) || script.is_p2pkh() || script.is_p2sh() +} + +/// Returns `true` if `script` is a BOLT 2 `option_simple_close` `OP_RETURN` script: `OP_RETURN` +/// followed by a single minimal push of 6..=80 bytes. +/// +/// A non-minimal push here would be `OP_PUSHDATA1` used for a payload of fewer than 76 bytes. +fn is_simple_close_op_return(script: &Script) -> bool { + let mut instrs = script.instructions_minimal(); + if !matches!(instrs.next(), Some(Ok(Instruction::Op(op))) if op == OP_RETURN) { + return false; + } + match instrs.next() { + // matches any minimal push + Some(Ok(Instruction::PushBytes(bytes))) => { + (6..=80).contains(&bytes.len()) && instrs.next().is_none() + } + _ => false, + } +} + #[cfg(test)] mod tests { + use bitcoin::opcodes::all::{ + OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHBYTES_0, OP_PUSHBYTES_1, + OP_PUSHBYTES_20, OP_PUSHBYTES_21, OP_PUSHBYTES_32, OP_PUSHBYTES_41, OP_PUSHDATA1, + OP_PUSHNUM_1, OP_PUSHNUM_16, OP_RETURN, + }; + use super::super::CHANNEL_ID_SIZE; use super::*; + fn none() -> Features { + Features::new() + } + fn anysegwit() -> Features { + Features::from_bits(&[Features::OPTION_SHUTDOWN_ANYSEGWIT]) + } + fn simple_close() -> Features { + Features::from_bits(&[Features::OPTION_SIMPLE_CLOSE]) + } + fn all() -> Features { + Features::from_bits(&[ + Features::OPTION_SHUTDOWN_ANYSEGWIT, + Features::OPTION_SIMPLE_CLOSE, + ]) + } + #[test] fn shutdown_for_channel() { let channel_id = ChannelId::new([0x42; CHANNEL_ID_SIZE]); @@ -151,4 +222,142 @@ mod tests { assert_eq!(original, decoded); assert!(decoded.scriptpubkey.is_empty()); } + + #[test] + #[allow(clippy::similar_names)] + fn is_standard_shutdown_script_rejects_legacy_accepts_witness_v0() { + // Legacy P2PKH/P2SH are non-standard even with all features negotiated. + let mut p2pkh = vec![OP_DUP.to_u8(), OP_HASH160.to_u8(), OP_PUSHBYTES_20.to_u8()]; + p2pkh.extend_from_slice(&[0x11; 20]); + p2pkh.extend_from_slice(&[OP_EQUALVERIFY.to_u8(), OP_CHECKSIG.to_u8()]); + assert!(!is_standard_shutdown_script(&p2pkh, &all())); + + let mut p2sh = vec![OP_HASH160.to_u8(), OP_PUSHBYTES_20.to_u8()]; + p2sh.extend_from_slice(&[0x22; 20]); + p2sh.push(OP_EQUAL.to_u8()); + assert!(!is_standard_shutdown_script(&p2sh, &all())); + + // Witness v0 forms are accepted regardless of features. + let v0 = OP_PUSHBYTES_0.to_u8(); + let mut p2wpkh = vec![v0, OP_PUSHBYTES_20.to_u8()]; + p2wpkh.extend_from_slice(&[0x33; 20]); + assert!(is_standard_shutdown_script(&p2wpkh, &none())); + assert!(is_standard_shutdown_script(&p2wpkh, &all())); + + let mut p2wsh = vec![v0, OP_PUSHBYTES_32.to_u8()]; + p2wsh.extend_from_slice(&[0x44; 32]); + assert!(is_standard_shutdown_script(&p2wsh, &none())); + assert!(is_standard_shutdown_script(&p2wsh, &all())); + } + + #[test] + fn is_standard_shutdown_script_accepts_anysegwit() { + for v in OP_PUSHNUM_1.to_u8()..=OP_PUSHNUM_16.to_u8() { + let mut spk = vec![v, OP_PUSHBYTES_20.to_u8()]; + spk.extend_from_slice(&[0x00; 20]); + // Gated on option_shutdown_anysegwit; option_simple_close alone doesn't help. + assert!(!is_standard_shutdown_script(&spk, &none())); + assert!(!is_standard_shutdown_script(&spk, &simple_close())); + assert!(is_standard_shutdown_script(&spk, &anysegwit())); + assert!(is_standard_shutdown_script(&spk, &all())); + } + } + + #[test] + fn is_standard_shutdown_script_rejects_other() { + // These are non-standard even with all features negotiated. Because + // features only widen the accepted set, rejection under `all()` implies + // rejection under any subset. + let empty_spk = vec![]; + assert!(!is_standard_shutdown_script(&empty_spk, &all())); + + let random_spk = vec![0x00, 0x01, 0x02]; + assert!(!is_standard_shutdown_script(&random_spk, &all())); + + // OP_RETURN with a 1-byte push: below the simple_close minimum of 6. + let op_return_spk = vec![OP_RETURN.to_u8(), OP_PUSHBYTES_1.to_u8(), 0xff]; + assert!(!is_standard_shutdown_script(&op_return_spk, &all())); + + // Witness version 0 with a non-{20,32} program length + let v0 = OP_PUSHBYTES_0.to_u8(); + let mut witness_v0_invalid_prog_length_spk = vec![v0, OP_PUSHBYTES_21.to_u8()]; + witness_v0_invalid_prog_length_spk.extend_from_slice(&[0x00; 21]); + assert!(!is_standard_shutdown_script( + &witness_v0_invalid_prog_length_spk, + &all() + )); + + // Witness version 1 with a program length outside 2..=40 + let mut witness_v1_invalid_prog_length_spk = + vec![OP_PUSHNUM_1.to_u8(), OP_PUSHBYTES_41.to_u8()]; + witness_v1_invalid_prog_length_spk.extend_from_slice(&[0x00; 41]); + assert!(!is_standard_shutdown_script( + &witness_v1_invalid_prog_length_spk, + &all() + )); + + // Length prefix disagrees with the actual program length + let mut witness_v0_invalid_length_prefix_spk = vec![v0, OP_PUSHBYTES_20.to_u8()]; + witness_v0_invalid_length_prefix_spk.extend_from_slice(&[0x00; 19]); + assert!(!is_standard_shutdown_script( + &witness_v0_invalid_length_prefix_spk, + &all() + )); + } + + #[test] + fn is_standard_shutdown_script_accepts_simple_close_op_return() { + // OP_RETURN + a single direct push of 6..=75 bytes. + for len in [6u8, 40, 75] { + let mut spk = vec![OP_RETURN.to_u8(), len]; + spk.extend_from_slice(&vec![0xab; usize::from(len)]); + // Gated on option_simple_close; option_shutdown_anysegwit alone doesn't help. + assert!(!is_standard_shutdown_script(&spk, &none())); + assert!(!is_standard_shutdown_script(&spk, &anysegwit())); + assert!(is_standard_shutdown_script(&spk, &simple_close())); + assert!(is_standard_shutdown_script(&spk, &all())); + } + + // OP_RETURN + OP_PUSHDATA1 + a single push of 76..=80 bytes. + for len in [76u8, 80] { + let mut spk = vec![OP_RETURN.to_u8(), OP_PUSHDATA1.to_u8(), len]; + spk.extend_from_slice(&vec![0xab; usize::from(len)]); + assert!(!is_standard_shutdown_script(&spk, &none())); + assert!(is_standard_shutdown_script(&spk, &simple_close())); + } + } + + #[test] + fn is_standard_shutdown_script_rejects_invalid_simple_close_op_return() { + // Rejected even with option_simple_close negotiated (via `all()`). + + // Bare OP_RETURN with no push. + assert!(!is_standard_shutdown_script(&[OP_RETURN.to_u8()], &all())); + + // Direct push below the 6-byte minimum. + let mut too_short = vec![OP_RETURN.to_u8(), 5]; + too_short.extend_from_slice(&[0xab; 5]); + assert!(!is_standard_shutdown_script(&too_short, &all())); + + // Push length disagrees with the trailing data (claims 6, has 5). + let mut len_mismatch = vec![OP_RETURN.to_u8(), 6]; + len_mismatch.extend_from_slice(&[0xab; 5]); + assert!(!is_standard_shutdown_script(&len_mismatch, &all())); + + // Extra bytes after the single push (multiple pushes not allowed). + let mut trailing = vec![OP_RETURN.to_u8(), 6]; + trailing.extend_from_slice(&[0xab; 6]); + trailing.extend_from_slice(&[OP_PUSHBYTES_1.to_u8(), 0xff]); + assert!(!is_standard_shutdown_script(&trailing, &all())); + + // OP_PUSHDATA1 with a length above the 80-byte maximum. + let mut too_long = vec![OP_RETURN.to_u8(), OP_PUSHDATA1.to_u8(), 81]; + too_long.extend_from_slice(&[0xab; 81]); + assert!(!is_standard_shutdown_script(&too_long, &all())); + + // Non-minimal push: OP_PUSHDATA1 used for less than 76 bytes. + let mut non_minimal = vec![OP_RETURN.to_u8(), OP_PUSHDATA1.to_u8(), 10]; + non_minimal.extend_from_slice(&[0xab; 10]); + assert!(!is_standard_shutdown_script(&non_minimal, &all())); + } } From cce6459e81e2d6dcfa41efab317e4d2ecaeb3bde Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Sat, 8 Aug 2026 14:51:50 +0530 Subject: [PATCH 07/11] smite: move ChannelTypeVariant to bolt::types The `accept_channel` oracle needs to verify that `channel_type` uses the smallest possible bitmap and represents a defined channel type. Keeping `ChannelTypeVariant` in `bolt::types` gives the oracle a canonical set of channel types to validate against and avoids duplicating the definition across modules. Signed-off-by: Nishant Bansal --- smite-ir/src/operation.rs | 197 +------------------------------------ smite/src/bolt.rs | 6 +- smite/src/bolt/types.rs | 202 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 198 deletions(-) diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index fccde02a..906f7393 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -14,7 +14,8 @@ 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::{FeatureBit, Features, ShortChannelId}; +pub use smite::bolt::ChannelTypeVariant; +use smite::bolt::ShortChannelId; use super::VariableType; @@ -422,200 +423,6 @@ impl fmt::Display for ShutdownScriptVariant { } } -/// A specific BOLT 2 `channel_type` feature-bit combination. -/// -/// Each variant corresponds to a channel type accepted by at least one target -/// implementation: -/// -/// - `option_static_remotekey` (bit 12) -/// - `option_anchors` (bits 22 and 12) -/// - `zero_fee_commitments` (bit 40) -/// - `option_simple_taproot` (bit 80) -/// - `option_simple_taproot_staging` (bit 180) -/// - `option_script_enforced_lease` (bits 2022, 22, 12) -/// -/// Additionally, the following bits can be added to any channel type: -/// - `option_scid_alias` (bit 46) -/// - `option_zeroconf` (bit 50) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ChannelTypeVariant { - /// bit 12 - StaticRemoteKey, - /// bits 12, 46 - StaticRemoteKeyScidAlias, - /// bits 12, 50 - StaticRemoteKeyZeroConf, - /// bits 12, 46, 50 - StaticRemoteKeyScidAliasZeroConf, - /// bits 12, 22 - Anchors, - /// bits 12, 22, 46 - AnchorsScidAlias, - /// bits 12, 22, 50 - AnchorsZeroConf, - /// bits 12, 22, 46, 50 - AnchorsScidAliasZeroConf, - /// bit 40 - ZeroFeeCommitments, - /// bits 40, 46 - ZeroFeeCommitmentsScidAlias, - /// bits 40, 50 - ZeroFeeCommitmentsZeroConf, - /// bits 40, 46, 50 - ZeroFeeCommitmentsScidAliasZeroConf, - /// bit 80 - SimpleTaproot, - /// bits 80, 46 - SimpleTaprootScidAlias, - /// bits 80, 50 - SimpleTaprootZeroConf, - /// bits 80, 46, 50 - SimpleTaprootScidAliasZeroConf, - /// bit 180 - SimpleTaprootStaging, - /// bits 180, 46 - SimpleTaprootStagingScidAlias, - /// bits 180, 50 - SimpleTaprootStagingZeroConf, - /// bits 180, 46, 50 - SimpleTaprootStagingScidAliasZeroConf, - /// bits 12, 22, 2022 - ScriptEnforcedLease, - /// bits 12, 22, 2022, 46 - ScriptEnforcedLeaseScidAlias, - /// bits 12, 22, 2022, 50 - ScriptEnforcedLeaseZeroConf, - /// bits 12, 22, 2022, 46, 50 - ScriptEnforcedLeaseScidAliasZeroConf, -} - -impl ChannelTypeVariant { - /// All variants. Keep in sync with the enum definition. - pub const ALL: &[Self] = &[ - Self::StaticRemoteKey, - Self::StaticRemoteKeyScidAlias, - Self::StaticRemoteKeyZeroConf, - Self::StaticRemoteKeyScidAliasZeroConf, - Self::Anchors, - Self::AnchorsScidAlias, - Self::AnchorsZeroConf, - Self::AnchorsScidAliasZeroConf, - Self::ZeroFeeCommitments, - Self::ZeroFeeCommitmentsScidAlias, - Self::ZeroFeeCommitmentsZeroConf, - Self::ZeroFeeCommitmentsScidAliasZeroConf, - Self::SimpleTaproot, - Self::SimpleTaprootScidAlias, - Self::SimpleTaprootZeroConf, - Self::SimpleTaprootScidAliasZeroConf, - Self::SimpleTaprootStaging, - Self::SimpleTaprootStagingScidAlias, - Self::SimpleTaprootStagingZeroConf, - Self::SimpleTaprootStagingScidAliasZeroConf, - Self::ScriptEnforcedLease, - Self::ScriptEnforcedLeaseScidAlias, - Self::ScriptEnforcedLeaseZeroConf, - Self::ScriptEnforcedLeaseScidAliasZeroConf, - ]; - - /// The feature bits (even/required) contained in this channel type. - #[must_use] - pub fn bits(self) -> &'static [FeatureBit] { - use Features as F; - match self { - 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] - pub fn encode(self) -> Vec { - Features::from_bits(self.bits()).into_bytes() - } -} - -impl fmt::Display for ChannelTypeVariant { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{self:?}") - } -} - /// Fields that can be extracted from an `AcceptChannel` compound variable. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum AcceptChannelField { diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index a92dad5d..7008cb00 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -74,9 +74,9 @@ pub use tx_init_rbf::{TxInitRbf, TxInitRbfTlvs}; 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, + BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, ChannelTypeVariant, + 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, }; 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..5b1d6d2a 100644 --- a/smite/src/bolt/types.rs +++ b/smite/src/bolt/types.rs @@ -1,8 +1,10 @@ //! Fundamental types for BOLT message encoding. +use super::{FeatureBit, Features}; use bitcoin::OutPoint; use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; +use serde::{Deserialize, Serialize}; use std::fmt; /// Maximum Lightning message size (2-byte length prefix limit). @@ -73,6 +75,206 @@ impl fmt::Display for ChannelId { } } +/// A specific BOLT 2 `channel_type` feature-bit combination. +/// +/// Each variant corresponds to a channel type accepted by at least one target +/// implementation: +/// +/// - `option_static_remotekey` (bit 12) +/// - `option_anchors` (bits 22 and 12) +/// - `zero_fee_commitments` (bit 40) +/// - `option_simple_taproot` (bit 80) +/// - `option_simple_taproot_staging` (bit 180) +/// - `option_script_enforced_lease` (bits 2022, 22, 12) +/// +/// Additionally, the following bits can be added to any channel type: +/// - `option_scid_alias` (bit 46) +/// - `option_zeroconf` (bit 50) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ChannelTypeVariant { + /// bit 12 + StaticRemoteKey, + /// bits 12, 46 + StaticRemoteKeyScidAlias, + /// bits 12, 50 + StaticRemoteKeyZeroConf, + /// bits 12, 46, 50 + StaticRemoteKeyScidAliasZeroConf, + /// bits 12, 22 + Anchors, + /// bits 12, 22, 46 + AnchorsScidAlias, + /// bits 12, 22, 50 + AnchorsZeroConf, + /// bits 12, 22, 46, 50 + AnchorsScidAliasZeroConf, + /// bit 40 + ZeroFeeCommitments, + /// bits 40, 46 + ZeroFeeCommitmentsScidAlias, + /// bits 40, 50 + ZeroFeeCommitmentsZeroConf, + /// bits 40, 46, 50 + ZeroFeeCommitmentsScidAliasZeroConf, + /// bit 80 + SimpleTaproot, + /// bits 80, 46 + SimpleTaprootScidAlias, + /// bits 80, 50 + SimpleTaprootZeroConf, + /// bits 80, 46, 50 + SimpleTaprootScidAliasZeroConf, + /// bit 180 + SimpleTaprootStaging, + /// bits 180, 46 + SimpleTaprootStagingScidAlias, + /// bits 180, 50 + SimpleTaprootStagingZeroConf, + /// bits 180, 46, 50 + SimpleTaprootStagingScidAliasZeroConf, + /// bits 12, 22, 2022 + ScriptEnforcedLease, + /// bits 12, 22, 2022, 46 + ScriptEnforcedLeaseScidAlias, + /// bits 12, 22, 2022, 50 + ScriptEnforcedLeaseZeroConf, + /// bits 12, 22, 2022, 46, 50 + ScriptEnforcedLeaseScidAliasZeroConf, +} + +impl ChannelTypeVariant { + /// All variants. Keep in sync with the enum definition. + pub const ALL: &[Self] = &[ + Self::StaticRemoteKey, + Self::StaticRemoteKeyScidAlias, + Self::StaticRemoteKeyZeroConf, + Self::StaticRemoteKeyScidAliasZeroConf, + Self::Anchors, + Self::AnchorsScidAlias, + Self::AnchorsZeroConf, + Self::AnchorsScidAliasZeroConf, + Self::ZeroFeeCommitments, + Self::ZeroFeeCommitmentsScidAlias, + Self::ZeroFeeCommitmentsZeroConf, + Self::ZeroFeeCommitmentsScidAliasZeroConf, + Self::SimpleTaproot, + Self::SimpleTaprootScidAlias, + Self::SimpleTaprootZeroConf, + Self::SimpleTaprootScidAliasZeroConf, + Self::SimpleTaprootStaging, + Self::SimpleTaprootStagingScidAlias, + Self::SimpleTaprootStagingZeroConf, + Self::SimpleTaprootStagingScidAliasZeroConf, + Self::ScriptEnforcedLease, + Self::ScriptEnforcedLeaseScidAlias, + Self::ScriptEnforcedLeaseZeroConf, + Self::ScriptEnforcedLeaseScidAliasZeroConf, + ]; + + /// The feature bits (even/required) contained in this channel type. + #[must_use] + pub fn bits(self) -> &'static [FeatureBit] { + use Features as F; + match self { + 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, + ], + } + } + + /// Converts the channel type variant to a `Features` bitmap. + #[must_use] + pub fn to_features(self) -> Features { + Features::from_bits(self.bits()) + } + + /// Encodes the channel type as a BOLT feature bitmap (big-endian bytes). + #[must_use] + pub fn encode(self) -> Vec { + self.to_features().into_bytes() + } +} + +impl fmt::Display for ChannelTypeVariant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + /// A BOLT 7 `short_channel_id`. /// /// Per [BOLT 7]: From 003d9d61ad66f8eeffbac0fce32ee5272e3f5e1f Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Sat, 8 Aug 2026 18:16:00 +0530 Subject: [PATCH 08/11] smite: validate channel type variants in accept_channel oracle Signed-off-by: Nishant Bansal --- smite/src/oracles/accept_channel.rs | 226 ++++++++++++++++++++++++---- 1 file changed, 200 insertions(+), 26 deletions(-) diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs index c2e73bc3..aeaa8022 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, Features, OpenChannel}; +use crate::bolt::{AcceptChannel, ChannelTypeVariant, Features, OpenChannel}; use crate::channel_tx::CommitmentCost; use crate::pending_channel::PendingChannel; use crate::violation::Violation; @@ -10,7 +10,8 @@ use bitcoin::Amount; // Constants from the BOLT 2 `open_channel` and `accept_channel` requirements: // https://github.com/lightning/bolts/blob/master/02-peer-protocol.md#requirements-8 -const MAX_ACCEPTED_HTLCS_LIMIT: u16 = 483; +const MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS: u16 = 114; +const MAX_ACCEPTED_HTLCS_DEFAULT: u16 = 483; const MIN_DUST_LIMIT_SATOSHIS: u64 = 354; /// Context for `AcceptChannelOracle` @@ -114,13 +115,35 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String return Err("open_channel does not include a channel_type".to_string()); }; + // Check that the channel type is one of the known variants. + if !ChannelTypeVariant::ALL + .iter() + .any(|variant| channel_type == variant.to_features()) + { + return Err("channel_type is not a known variant".to_string()); + } + + // Check that feerate_per_kw is 0 when `zero_fee_commitments` is negotiated. + if channel_type.supports_feature(Features::ZERO_FEE_COMMITMENTS) + && open_channel.feerate_per_kw != 0 + { + return Err(format!( + "zero_fee_commitments requires feerate_per_kw to be 0, but got {}", + open_channel.feerate_per_kw, + )); + } + + // Check that option_scid_alias is only negotiated for private channels. + let announce_channel = (open_channel.channel_flags & 1) == 1; + if announce_channel && channel_type.supports_feature(Features::OPTION_SCID_ALIAS) { + return Err("option_scid_alias requires the channel to be private".to_string()); + } + // Check the HTLC limit is within the maximum. - // FIXME: Does not apply to channels whose `channel_type` includes - // `zero_fee_commitments`. These channel types have a lower upper limit on - // `max_accepted_htlcs`, so we are currently safe. - if open_channel.max_accepted_htlcs > MAX_ACCEPTED_HTLCS_LIMIT { + let htlc_limit = max_accepted_htlcs_limit(&channel_type); + if open_channel.max_accepted_htlcs > htlc_limit { return Err(format!( - "max_accepted_htlcs {} exceeds the limit of {MAX_ACCEPTED_HTLCS_LIMIT}", + "max_accepted_htlcs {} exceeds the limit of {htlc_limit}", open_channel.max_accepted_htlcs, )); } @@ -169,6 +192,15 @@ fn verify_accept_channel( return Err("accept_channel channel_type does not match open_channel".to_string()); } + // Check that option_zeroconf has a minimum depth of 0. + if channel_type.supports_feature(Features::OPTION_ZEROCONF) && accept_channel.minimum_depth != 0 + { + return Err(format!( + "option_zeroconf requires minimum_depth to be 0, but got {}", + accept_channel.minimum_depth, + )); + } + // 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!( @@ -186,12 +218,10 @@ fn verify_accept_channel( } // Check the HTLC limit is within the maximum. - // FIXME: Does not apply to channels whose `channel_type` includes - // `zero_fee_commitments`. These channel types have a lower upper limit on - // `max_accepted_htlcs`, so we are currently safe. - if accept_channel.max_accepted_htlcs > MAX_ACCEPTED_HTLCS_LIMIT { + let htlc_limit = max_accepted_htlcs_limit(&channel_type); + if accept_channel.max_accepted_htlcs > htlc_limit { return Err(format!( - "max_accepted_htlcs {} exceeds the limit of {MAX_ACCEPTED_HTLCS_LIMIT}", + "max_accepted_htlcs {} exceeds the limit of {htlc_limit}", accept_channel.max_accepted_htlcs, )); } @@ -216,22 +246,24 @@ 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: Validation is skipped for channel types we do not yet fully support, +/// such as 0FC and Taproot, to avoid misleading errors. /// -/// - `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. +/// TODO: Enable validation once we support commitment handling for these +/// channel types. fn verify_initial_commitment( open_channel: &OpenChannel, channel_type: &Features, channel_reserve_satoshis: u64, ) -> Result<(), String> { + // Skip validation for channel types we don't yet fully support. + if channel_type.supports_feature(Features::ZERO_FEE_COMMITMENTS) + || channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT) + || channel_type.supports_feature(Features::OPTION_SIMPLE_TAPROOT_STAGING) + { + return Ok(()); + } + // Check that the opener can afford the proposed feerate. let opener_balance_sat = (open_channel.funding_satoshis * 1000 - open_channel.push_msat) / 1000; let commitment_cost = CommitmentCost::new(open_channel.feerate_per_kw, channel_type); @@ -262,6 +294,15 @@ fn verify_initial_commitment( Ok(()) } +/// Returns the maximum number of inbound HTLCs allowed by the channel type. +pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 { + if channel_type.supports_feature(Features::ZERO_FEE_COMMITMENTS) { + MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS + } else { + MAX_ACCEPTED_HTLCS_DEFAULT + } +} + #[cfg(test)] mod tests { use super::*; @@ -375,6 +416,30 @@ mod tests { ); } + #[test] + fn conforming_zero_fee_commitments_channel_passes() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + oc.feerate_per_kw = 0; + oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; + + assert_pass(&ac, Some(&pending_negotiation(oc))); + } + + #[test] + fn conforming_option_zeroconf_with_valid_minimum_depth_passes() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::StaticRemoteKeyZeroConf.encode()); + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(ChannelTypeVariant::StaticRemoteKeyZeroConf.encode()); + ac.minimum_depth = 0; + + assert_pass(&ac, Some(&pending_negotiation(oc))); + } + #[test] fn accept_channel_for_unknown_temporary_channel_id() { assert_fail( @@ -421,9 +486,47 @@ mod tests { } #[test] - fn open_channel_max_accepted_htlcs_above_the_limit() { + fn open_channel_with_unknown_channel_type_variant() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(vec![0xff, 0x40, 0x10, 0x00]); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + "invalid open_channel: channel_type is not a known variant", + ); + } + + #[test] + fn open_channel_zero_fee_commitments_with_nonzero_feerate() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + oc.feerate_per_kw = 1; + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + "invalid open_channel: zero_fee_commitments requires feerate_per_kw to be 0", + ); + } + + #[test] + fn open_channel_option_scid_alias_for_public_channel() { let mut oc = open_channel(); - oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_LIMIT + 1; + oc.channel_flags = 1; + oc.tlvs.channel_type = Some(ChannelTypeVariant::StaticRemoteKeyScidAlias.encode()); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + "invalid open_channel: option_scid_alias requires the channel to be private", + ); + } + + #[test] + fn open_channel_max_accepted_htlcs_above_the_default_limit() { + let mut oc = open_channel(); + oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_DEFAULT + 1; assert_fail( &accept_channel(), @@ -432,6 +535,20 @@ mod tests { ); } + #[test] + fn open_channel_max_accepted_htlcs_above_the_zero_fee_commitments_limit() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + oc.feerate_per_kw = 0; + oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS + 1; + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + "invalid open_channel: max_accepted_htlcs 115 exceeds the limit of 114", + ); + } + #[test] fn open_channel_dust_limit_below_the_minimum() { let mut oc = open_channel(); @@ -505,6 +622,21 @@ mod tests { ); } + #[test] + fn accept_channel_option_zeroconf_with_nonzero_minimum_depth() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::StaticRemoteKeyZeroConf.encode()); + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(ChannelTypeVariant::StaticRemoteKeyZeroConf.encode()); + ac.minimum_depth = 1; + + assert_fail( + &ac, + Some(&pending_negotiation(oc)), + "invalid accept_channel: option_zeroconf requires minimum_depth to be 0", + ); + } + #[test] fn accept_channel_reserve_below_the_open_channel_dust_limit() { let oc = open_channel(); @@ -532,9 +664,9 @@ mod tests { } #[test] - fn accept_channel_max_accepted_htlcs_above_the_limit() { + fn accept_channel_max_accepted_htlcs_above_the_default_limit() { let mut ac = accept_channel(); - ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_LIMIT + 1; + ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_DEFAULT + 1; assert_fail( &ac, @@ -543,6 +675,23 @@ mod tests { ); } + #[test] + fn accept_channel_max_accepted_htlcs_above_the_zero_fee_commitments_limit() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + oc.feerate_per_kw = 0; + oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS + 1; + + assert_fail( + &ac, + Some(&pending_negotiation(oc)), + "invalid accept_channel: max_accepted_htlcs 115 exceeds the limit of 114", + ); + } + #[test] fn accept_channel_dust_limit_below_the_minimum() { let mut ac = accept_channel(); @@ -567,6 +716,31 @@ mod tests { ); } + #[test] + fn commitment_validation_skipped_for_zero_fee_commitments() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + oc.feerate_per_kw = 0; + oc.push_msat = oc.funding_satoshis * 1000; + oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); + ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; + + assert_pass(&ac, Some(&pending_negotiation(oc))); + } + + #[test] + fn commitment_validation_skipped_for_option_simple_taproot() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode()); + oc.push_msat = oc.funding_satoshis * 1000; + let mut ac = accept_channel(); + ac.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode()); + + assert_pass(&ac, Some(&pending_negotiation(oc))); + } + #[test] fn temporary_channel_id_reuse_before_funding_created() { let mut negotiation = pending_negotiation(open_channel()); From 9b35a31fbb7797095a4dd3f425ad82b3ce5a0b14 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Sat, 8 Aug 2026 18:27:24 +0530 Subject: [PATCH 09/11] smite-scenarios: rename target_features to negotiated_features We strip certain feature bits during setup to exercise only the single funded flow, so the features stored here are what both sides have agreed to continue with, the negotiated feature set, not just the target's advertised features. This prepares for oracle validation that will use negotiated features to validate field constraints. If the target didn't disconnect after our init, that confirms it also conforms to our negotiated features, not its original advertised feature set. Signed-off-by: Nishant Bansal --- smite-scenarios/src/executor.rs | 6 +++--- smite-scenarios/src/scenarios/setup.rs | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index a8e42dfa..63c56c1c 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -133,8 +133,8 @@ pub struct ProgramContext { pub chain_hash: [u8; 32], /// Current block height at snapshot time. pub block_height: u32, - /// Target's advertised feature bits from init message. - pub target_features: Vec, + /// Features negotiated between the target node and Smite. + pub negotiated_features: Features, } /// Abstraction over a Noise-encrypted connection, allowing mock implementations @@ -1557,7 +1557,7 @@ mod tests { target_pubkey: sample_pubkey(1), chain_hash: [0xcc; 32], block_height: 800_000, - target_features: vec![], + negotiated_features: Features::from(vec![0x40, 0x10, 0x00]), } } diff --git a/smite-scenarios/src/scenarios/setup.rs b/smite-scenarios/src/scenarios/setup.rs index 1daf89e9..c626279d 100644 --- a/smite-scenarios/src/scenarios/setup.rs +++ b/smite-scenarios/src/scenarios/setup.rs @@ -73,7 +73,7 @@ impl SnapshotSetup for PostInitSetup { // Echo features but strip the bits that would take us off the // single-funded `open_channel` path this setup is built for. let our_init = init_for_single_funded(&target_init); - conn.send_message(&Message::Init(our_init).encode())?; + conn.send_message(&Message::Init(our_init.clone()).encode())?; // Drain any remaining post-init noise so the snapshot starts with a // clean connection. @@ -86,7 +86,11 @@ impl SnapshotSetup for PostInitSetup { // this is the floor. Dynamic per-target queries can replace it // later. block_height: u32::try_from(INITIAL_BLOCKS).expect("fits in u32"), - target_features: target_init.features, + // Since we echo the same features the target sent, but strip both + // required and optional bits to exercise only the single funded + // flow and avoid unrelated noise, negotiated features are just the + // features we sent in our init. + negotiated_features: Features::from(our_init.features), }; Ok((conn, context)) From 246ced502d40173b81b71ad4b91b02eeb8036347 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Mon, 10 Aug 2026 23:22:15 +0530 Subject: [PATCH 10/11] smite: add is_supported for negotiated feature validation This is useful when comparing features in message fields against negotiated features. For eg., comparing channel_type in open_channel and accept_channel to ensure they match the features negotiated during setup. Signed-off-by: Nishant Bansal --- smite/src/bolt/features.rs | 117 +++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/smite/src/bolt/features.rs b/smite/src/bolt/features.rs index a5bde36e..728fa964 100644 --- a/smite/src/bolt/features.rs +++ b/smite/src/bolt/features.rs @@ -122,6 +122,18 @@ impl Features { self.clear_bit(bit); self.clear_bit(bit ^ 1); } + + /// Returns whether every bit set here is supported by `other`, where the + /// feature's required (even) or optional (odd) bit both count as support. + #[must_use] + pub fn is_supported(&self, other: &Features) -> bool { + for bit in 0..(self.0.len() * 8) { + if self.is_bit_set(bit) && !other.supports_feature(bit) { + return false; + } + } + true + } } impl PartialEq for Features { @@ -306,6 +318,111 @@ mod tests { assert!(features.supports_feature(Features::OPTION_STATIC_REMOTEKEY)); } + #[test] + fn is_supported_with_empty_and_nonempty_features() { + let empty = Features::new(); + let single_bit = Features::from_bits(&[Features::OPTION_ANCHORS]); + let multiple_bits = + Features::from_bits(&[Features::OPTION_ANCHORS, Features::OPTION_STATIC_REMOTEKEY]); + + assert!(empty.is_supported(&empty)); + assert!(empty.is_supported(&single_bit)); + assert!(empty.is_supported(&multiple_bits)); + + assert!(!single_bit.is_supported(&empty)); + assert!(single_bit.is_supported(&single_bit)); + assert!(single_bit.is_supported(&multiple_bits)); + + assert!(!multiple_bits.is_supported(&empty)); + assert!(!multiple_bits.is_supported(&single_bit)); + assert!(multiple_bits.is_supported(&multiple_bits)); + } + + #[test] + fn is_supported_with_fewer_bits() { + let superset = Features::from(vec![0xff, 0xff]); + let subset1 = Features::from(vec![0x0f, 0xff]); + let subset2 = Features::from(vec![0xff, 0x0f]); + + assert!(subset1.is_supported(&superset)); + assert!(subset2.is_supported(&superset)); + + assert!(!subset2.is_supported(&subset1)); + assert!(!subset1.is_supported(&subset2)); + + assert!(!superset.is_supported(&subset1)); + assert!(!superset.is_supported(&subset2)); + } + + #[test] + fn is_supported_with_partial_overlap() { + let anchors_and_remotekey = + Features::from_bits(&[Features::OPTION_ANCHORS, Features::OPTION_STATIC_REMOTEKEY]); + let remotekey_and_dual_fund = Features::from_bits(&[ + Features::OPTION_STATIC_REMOTEKEY, + Features::OPTION_DUAL_FUND, + ]); + + assert!(!anchors_and_remotekey.is_supported(&remotekey_and_dual_fund)); + assert!(!remotekey_and_dual_fund.is_supported(&anchors_and_remotekey)); + } + + #[test] + fn is_supported_with_different_lengths() { + let short = Features::from(vec![0x01]); + let long = Features::from(vec![0x10, 0x01]); + + assert!(short.is_supported(&long)); + assert!(!long.is_supported(&short)); + + let short = Features::from(vec![0x01]); + let long = Features::from(vec![0x01, 0x00]); + + assert!(!short.is_supported(&long)); + assert!(!long.is_supported(&short)); + + let short = Features::from(vec![0x80]); + let long = Features::from(vec![0x00, 0x80]); + + assert!(short.is_supported(&long)); + assert!(long.is_supported(&short)); + } + + #[test] + fn is_supported_accepts_optional_bit_for_required_bit() { + // A channel type carries `option_scid_alias` as required (bit 46), + // while peers advertise it as optional (bit 47) in `init`. + let channel_type = Features::from_bits(&[ + Features::OPTION_STATIC_REMOTEKEY, + Features::OPTION_SCID_ALIAS, + ]); + let mut negotiated = Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY]); + negotiated.set_bit(Features::OPTION_SCID_ALIAS ^ 1); + + assert!(!negotiated.is_bit_set(Features::OPTION_SCID_ALIAS)); + assert!(channel_type.is_supported(&negotiated)); + + // A required bit is also satisfied by the same required bit. + let mut negotiated = Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY]); + negotiated.set_bit(Features::OPTION_SCID_ALIAS); + assert!(channel_type.is_supported(&negotiated)); + + // A feature advertised in neither parity is still not negotiated. + let anchors = Features::from_bits(&[Features::OPTION_ANCHORS]); + assert!(!anchors.is_supported(&negotiated)); + } + + #[test] + fn is_supported_accepts_required_bit_for_optional_bit() { + // The pairing is symmetric: an optional bit on the left is satisfied + // by the required bit on the right. + let optional = Features::from_bits(&[Features::OPTION_SCID_ALIAS ^ 1]); + let required = Features::from_bits(&[Features::OPTION_SCID_ALIAS]); + + assert!(optional.is_supported(&required)); + assert!(required.is_supported(&optional)); + } + #[test] fn equality_with_same_and_different_bits() { let lease = Features::from_bits(&[Features::OPTION_SCRIPT_ENFORCED_LEASE]); From 33dd398da064be0cbf5961f5f3acc8ba7ef1f828 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Tue, 11 Aug 2026 01:22:41 +0530 Subject: [PATCH 11/11] smite: add negotiated feature validation to accept_channel oracle Signed-off-by: Nishant Bansal --- smite-scenarios/src/executor.rs | 7 +- smite/src/bolt/features.rs | 6 + smite/src/oracles/accept_channel.rs | 307 ++++++++++++++++++++++++++-- 3 files changed, 300 insertions(+), 20 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 63c56c1c..4c23bfab 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -481,6 +481,7 @@ impl Executor { AcceptChannelOracle.evaluate(&AcceptChannelContext { accept_channel: &ac, negotiation: self.negotiations.get(&ac.temporary_channel_id), + negotiated_features: &self.context.negotiated_features, })?; record_recv_accept_channel(&mut self.negotiations, &ac); Some(Variable::AcceptChannel(ac)) @@ -1557,7 +1558,11 @@ mod tests { target_pubkey: sample_pubkey(1), chain_hash: [0xcc; 32], block_height: 800_000, - negotiated_features: Features::from(vec![0x40, 0x10, 0x00]), + negotiated_features: Features::from_bits(&[ + Features::OPTION_STATIC_REMOTEKEY, + Features::OPTION_ANCHORS, + Features::OPTION_CHANNEL_TYPE, + ]), } } diff --git a/smite/src/bolt/features.rs b/smite/src/bolt/features.rs index 728fa964..2b70d1b9 100644 --- a/smite/src/bolt/features.rs +++ b/smite/src/bolt/features.rs @@ -9,12 +9,16 @@ pub type FeatureBit = usize; pub struct Features(Vec); impl Features { + /// `option_upfront_shutdown_script` (bits 4/5). + pub const OPTION_UPFRONT_SHUTDOWN_SCRIPT: FeatureBit = 4; /// `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_support_large_channel` (bits 18/19). + pub const OPTION_SUPPORT_LARGE_CHANNEL: FeatureBit = 18; /// `option_anchors` (bits 22/23). pub const OPTION_ANCHORS: FeatureBit = 22; /// `option_shutdown_anysegwit` (bits 26/27). @@ -25,6 +29,8 @@ impl Features { pub const ZERO_FEE_COMMITMENTS: FeatureBit = 40; /// `option_provide_storage` (bits 42/43). pub const OPTION_PROVIDE_STORAGE: FeatureBit = 42; + /// `option_channel_type` (bits 44/45). + pub const OPTION_CHANNEL_TYPE: FeatureBit = 44; /// `option_scid_alias` (bits 46/47). pub const OPTION_SCID_ALIAS: FeatureBit = 46; /// `option_zeroconf` (bits 50/51). diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs index aeaa8022..3cf15575 100644 --- a/smite/src/oracles/accept_channel.rs +++ b/smite/src/oracles/accept_channel.rs @@ -1,7 +1,10 @@ //! BOLT 2 `accept_channel` oracle, for the v1 outbound channel funding flow. use super::Oracle; -use crate::bolt::{AcceptChannel, ChannelTypeVariant, Features, OpenChannel}; +use crate::bolt::{ + AcceptChannel, ChannelTypeVariant, Features, OpenChannel, is_acceptable_shutdown_script, + is_standard_shutdown_script, +}; use crate::channel_tx::CommitmentCost; use crate::pending_channel::PendingChannel; use crate::violation::Violation; @@ -10,6 +13,7 @@ use bitcoin::Amount; // Constants from the BOLT 2 `open_channel` and `accept_channel` requirements: // https://github.com/lightning/bolts/blob/master/02-peer-protocol.md#requirements-8 +const MAX_FUNDING_SATOSHIS_NO_WUMBO: u64 = (1 << 24) - 1; const MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS: u16 = 114; const MAX_ACCEPTED_HTLCS_DEFAULT: u16 = 483; const MIN_DUST_LIMIT_SATOSHIS: u64 = 354; @@ -21,6 +25,8 @@ pub struct AcceptChannelContext<'a> { /// The negotiation the `accept_channel` answers, identified by its /// `temporary_channel_id`, or `None` if no matching `open_channel` was sent. pub negotiation: Option<&'a PendingChannel>, + /// Features negotiated between the target node and Smite. + pub negotiated_features: &'a Features, } /// Checks whether the `open_channel` answered by an `accept_channel` satisfied @@ -46,7 +52,8 @@ impl Oracle> for AcceptChannelOracle { }; // Check that the `open_channel` was valid to accept. - if let Err(reason) = verify_accepted_open_channel(open_channel) { + if let Err(reason) = verify_accepted_open_channel(open_channel, context.negotiated_features) + { return Err(Violation::InvalidAcceptChannel( context.accept_channel.temporary_channel_id, format!("accepted invalid open_channel: {reason}"), @@ -54,7 +61,11 @@ impl Oracle> for AcceptChannelOracle { } // Check that the `accept_channel` itself is valid. - if let Err(reason) = verify_accept_channel(context.accept_channel, open_channel) { + if let Err(reason) = verify_accept_channel( + context.accept_channel, + open_channel, + context.negotiated_features, + ) { return Err(Violation::InvalidAcceptChannel( context.accept_channel.temporary_channel_id, format!("invalid accept_channel: {reason}"), @@ -84,13 +95,20 @@ impl Oracle> for AcceptChannelOracle { /// be less than or equal to the channel reserve. However, implementations /// such as LDK accept zero channel reserves on the receiving side, so we do /// not enforce this check on the target's receiving side. -fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String> { +fn verify_accepted_open_channel( + open_channel: &OpenChannel, + negotiated_features: &Features, +) -> Result<(), String> { + // Check that option_dual_fund has not been negotiated. + if negotiated_features.supports_feature(Features::OPTION_DUAL_FUND) { + return Err("option_dual_fund has been negotiated".to_string()); + } + // Check that the funding amounts are valid. - // FIXME: Varies if `option_support_large_channel` is not negotiated. - let total_supply_satoshis = Amount::MAX_MONEY.to_sat(); - if open_channel.funding_satoshis > total_supply_satoshis { + let max_funding = max_funding_satoshis(negotiated_features); + if open_channel.funding_satoshis > max_funding { return Err(format!( - "funding_satoshis {} exceeds maximum funding of {total_supply_satoshis} sat", + "funding_satoshis {} exceeds maximum funding of {max_funding} sat", open_channel.funding_satoshis, )); } @@ -103,9 +121,24 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String )); } + // Check that the upfront shutdown script is present and valid when negotiated. + if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) { + if let Some(script) = &open_channel.tlvs.upfront_shutdown_script { + if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) { + return Err("upfront_shutdown_script is not valid".to_string()); + } + } else { + return Err("open_channel does not include upfront_shutdown_script".to_string()); + } + } + + // Check option_channel_type in negotiated features since it is assumed to + // be supported. + if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) { + return Err("option_channel_type is not supported".to_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 @@ -115,6 +148,11 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String return Err("open_channel does not include a channel_type".to_string()); }; + // Check that the channel type only contains negotiated features. + if !channel_type.is_supported(negotiated_features) { + return Err("channel_type contains features that were not negotiated".to_string()); + } + // Check that the channel type is one of the known variants. if !ChannelTypeVariant::ALL .iter() @@ -176,7 +214,19 @@ fn verify_accepted_open_channel(open_channel: &OpenChannel) -> Result<(), String fn verify_accept_channel( accept_channel: &AcceptChannel, open_channel: &OpenChannel, + negotiated_features: &Features, ) -> Result<(), String> { + // Check that the upfront shutdown script is present and valid when negotiated. + if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) { + if let Some(script) = &accept_channel.tlvs.upfront_shutdown_script { + if !script.is_empty() && !is_standard_shutdown_script(script, negotiated_features) { + return Err("upfront_shutdown_script is not valid".to_string()); + } + } else { + return Err("accept_channel does not include upfront_shutdown_script".to_string()); + } + } + // Check that the channel type was included. let Some(channel_type) = accept_channel .tlvs @@ -294,6 +344,15 @@ fn verify_initial_commitment( Ok(()) } +/// Returns the maximum funding amount allowed by the negotiated features. +pub fn max_funding_satoshis(negotiated_features: &Features) -> u64 { + if negotiated_features.supports_feature(Features::OPTION_SUPPORT_LARGE_CHANNEL) { + Amount::MAX_MONEY.to_sat() + } else { + MAX_FUNDING_SATOSHIS_NO_WUMBO + } +} + /// Returns the maximum number of inbound HTLCs allowed by the channel type. pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 { if channel_type.supports_feature(Features::ZERO_FEE_COMMITMENTS) { @@ -307,7 +366,9 @@ pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 { mod tests { use super::*; use crate::bolt::{AcceptChannelTlvs, ChannelId, OpenChannelTlvs}; + use bitcoin::hashes::Hash; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use bitcoin::{PubkeyHash, ScriptBuf, WPubkeyHash}; fn pubkey(seed: u8) -> PublicKey { let sk = SecretKey::from_slice(&[seed; 32]).expect("valid secret key"); @@ -377,11 +438,30 @@ mod tests { } } + /// Valid negotiated features for testing. + fn sample_negotiated_features() -> Features { + Features::from_bits(&[ + Features::OPTION_STATIC_REMOTEKEY, + Features::OPTION_ANCHORS, + Features::ZERO_FEE_COMMITMENTS, + Features::OPTION_CHANNEL_TYPE, + Features::OPTION_SCID_ALIAS, + Features::OPTION_ZEROCONF, + Features::OPTION_SIMPLE_TAPROOT, + Features::OPTION_SIMPLE_TAPROOT_STAGING, + ]) + } + #[track_caller] - fn assert_pass(accept_channel: &AcceptChannel, negotiation: Option<&PendingChannel>) { + fn assert_pass( + accept_channel: &AcceptChannel, + negotiation: Option<&PendingChannel>, + negotiated_features: &Features, + ) { if let Err(err) = AcceptChannelOracle.evaluate(&AcceptChannelContext { accept_channel, negotiation, + negotiated_features, }) { panic!("expected pass, got: {err}"); } @@ -391,11 +471,13 @@ mod tests { fn assert_fail( accept_channel: &AcceptChannel, negotiation: Option<&PendingChannel>, + negotiated_features: &Features, expected: &str, ) { match AcceptChannelOracle.evaluate(&AcceptChannelContext { accept_channel, negotiation, + negotiated_features, }) { Err(Violation::InvalidAcceptChannel(chan_id, reason)) => { assert_eq!(accept_channel.temporary_channel_id, chan_id); @@ -413,6 +495,7 @@ mod tests { assert_pass( &accept_channel(), Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), ); } @@ -426,7 +509,11 @@ mod tests { ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; - assert_pass(&ac, Some(&pending_negotiation(oc))); + assert_pass( + &ac, + Some(&pending_negotiation(oc)), + &sample_negotiated_features(), + ); } #[test] @@ -437,7 +524,26 @@ mod tests { ac.tlvs.channel_type = Some(ChannelTypeVariant::StaticRemoteKeyZeroConf.encode()); ac.minimum_depth = 0; - assert_pass(&ac, Some(&pending_negotiation(oc))); + assert_pass( + &ac, + Some(&pending_negotiation(oc)), + &sample_negotiated_features(), + ); + } + + #[test] + fn conforming_compliant_shutdown_script_passes() { + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT); + let legacy_script = ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()).into_bytes(); + let segwit_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()).into_bytes(); + + let mut oc = open_channel(); + oc.tlvs.upfront_shutdown_script = Some(legacy_script.clone()); + let mut ac = accept_channel(); + ac.tlvs.upfront_shutdown_script = Some(segwit_script); + + assert_pass(&ac, Some(&pending_negotiation(oc)), &negotiated_features); } #[test] @@ -445,19 +551,50 @@ mod tests { assert_fail( &accept_channel(), None, + &sample_negotiated_features(), "unknown temporary_channel_id: no open_channel was sent for this negotiation", ); } #[test] - fn funding_satoshis_above_bitcoins_total_supply() { + fn open_channel_option_dual_fund_negotiated() { + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_DUAL_FUND); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(open_channel())), + &negotiated_features, + "invalid open_channel: option_dual_fund has been negotiated", + ); + } + + #[test] + fn funding_satoshis_above_non_wumbo_limit_without_option_support_large_channel() { + let mut oc = open_channel(); + oc.funding_satoshis = MAX_FUNDING_SATOSHIS_NO_WUMBO + 1; + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + &sample_negotiated_features(), + "invalid open_channel: funding_satoshis 16777216 exceeds maximum funding of 16777215 sat", + ); + } + + #[test] + fn funding_satoshis_above_bitcoins_total_supply_with_option_support_large_channel() { let mut oc = open_channel(); oc.funding_satoshis = Amount::MAX_MONEY.to_sat() + 1; + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_SUPPORT_LARGE_CHANNEL); + assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), - "invalid open_channel: funding_satoshis 2100000000000001 exceeds maximum funding", + &negotiated_features, + "invalid open_channel: funding_satoshis 2100000000000001 exceeds maximum funding of 2100000000000000 sat", ); } @@ -469,10 +606,52 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: push_msat 10000000001 exceeds funding amount", ); } + #[test] + fn open_channel_invalid_upfront_shutdown_script() { + let mut oc = open_channel(); + oc.tlvs.upfront_shutdown_script = Some(vec![0xFF, 0xFF]); + + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + &negotiated_features, + "invalid open_channel: upfront_shutdown_script is not valid", + ); + } + + #[test] + fn open_channel_missing_upfront_shutdown_script() { + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(open_channel())), + &negotiated_features, + "invalid open_channel: open_channel does not include upfront_shutdown_script", + ); + } + + #[test] + fn open_channel_option_channel_type_not_supported() { + let negotiated_features = Features::new(); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(open_channel())), + &negotiated_features, + "invalid open_channel: option_channel_type is not supported", + ); + } + #[test] fn open_channel_without_a_channel_type() { let mut oc = open_channel(); @@ -481,18 +660,41 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: open_channel does not include a channel_type", ); } + #[test] + fn open_channel_channel_type_contains_non_negotiated_features() { + let mut oc = open_channel(); + oc.tlvs.channel_type = Some(vec![0x10, 0x00]); + + let negotiated_features = Features::from_bits(&[ + Features::ZERO_FEE_COMMITMENTS, + Features::OPTION_CHANNEL_TYPE, + ]); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + &negotiated_features, + "invalid open_channel: channel_type contains features that were not negotiated", + ); + } + #[test] fn open_channel_with_unknown_channel_type_variant() { let mut oc = open_channel(); - oc.tlvs.channel_type = Some(vec![0xff, 0x40, 0x10, 0x00]); + oc.tlvs.channel_type = Some(vec![0x40, 0x40, 0x10, 0x00]); + + let mut negotiated_features = Features::from(vec![0x40, 0x40, 0x10, 0x00]); + negotiated_features.set_bit(Features::OPTION_CHANNEL_TYPE); assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &negotiated_features, "invalid open_channel: channel_type is not a known variant", ); } @@ -506,6 +708,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: zero_fee_commitments requires feerate_per_kw to be 0", ); } @@ -519,6 +722,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: option_scid_alias requires the channel to be private", ); } @@ -531,6 +735,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: max_accepted_htlcs 484 exceeds the limit of 483", ); } @@ -545,6 +750,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: max_accepted_htlcs 115 exceeds the limit of 114", ); } @@ -557,6 +763,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: dust_limit_satoshis 353 is below the minimum of 354 sat", ); } @@ -569,6 +776,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: opener balance 10000 sat cannot cover the commitment fee", ); } @@ -582,6 +790,7 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: opener balance 17000 sat cannot cover anchor cost of 660 sat (after fee deduction)", ); } @@ -594,10 +803,48 @@ mod tests { assert_fail( &accept_channel(), Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid open_channel: neither side exceeds channel reserve", ); } + #[test] + fn accept_channel_invalid_upfront_shutdown_script() { + let mut oc = open_channel(); + let legacy_script = ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()).into_bytes(); + oc.tlvs.upfront_shutdown_script = Some(legacy_script.clone()); + + let mut ac = accept_channel(); + ac.tlvs.upfront_shutdown_script = Some(legacy_script); + + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT); + + assert_fail( + &ac, + Some(&pending_negotiation(oc)), + &negotiated_features, + "invalid accept_channel: upfront_shutdown_script is not valid", + ); + } + + #[test] + fn accept_channel_missing_upfront_shutdown_script() { + let mut oc = open_channel(); + let valid_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()).into_bytes(); + oc.tlvs.upfront_shutdown_script = Some(valid_script); + + let mut negotiated_features = sample_negotiated_features(); + negotiated_features.set_bit(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT); + + assert_fail( + &accept_channel(), + Some(&pending_negotiation(oc)), + &negotiated_features, + "accept_channel does not include upfront_shutdown_script", + ); + } + #[test] fn accept_channel_without_a_channel_type() { let mut ac = accept_channel(); @@ -606,6 +853,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), "invalid accept_channel: accept_channel does not include a channel_type", ); } @@ -618,6 +866,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), "invalid accept_channel: accept_channel channel_type does not match open_channel", ); } @@ -633,6 +882,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid accept_channel: option_zeroconf requires minimum_depth to be 0", ); } @@ -646,6 +896,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid accept_channel: channel_reserve_satoshis 545 is below the open_channel dust_limit_satoshis 546", ); } @@ -659,6 +910,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), "invalid accept_channel: dust_limit_satoshis 5000 exceeds channel_reserve_satoshis 4000", ); } @@ -671,6 +923,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), "invalid accept_channel: max_accepted_htlcs 484 exceeds the limit of 483", ); } @@ -688,6 +941,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(oc)), + &sample_negotiated_features(), "invalid accept_channel: max_accepted_htlcs 115 exceeds the limit of 114", ); } @@ -700,6 +954,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), "invalid accept_channel: dust_limit_satoshis 353 is below the minimum of 354 sat", ); } @@ -712,6 +967,7 @@ mod tests { assert_fail( &ac, Some(&pending_negotiation(open_channel())), + &sample_negotiated_features(), "invalid accept_channel: neither side exceeds channel reserve", ); } @@ -727,7 +983,11 @@ mod tests { ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode()); ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS; - assert_pass(&ac, Some(&pending_negotiation(oc))); + assert_pass( + &ac, + Some(&pending_negotiation(oc)), + &sample_negotiated_features(), + ); } #[test] @@ -738,7 +998,11 @@ mod tests { let mut ac = accept_channel(); ac.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode()); - assert_pass(&ac, Some(&pending_negotiation(oc))); + assert_pass( + &ac, + Some(&pending_negotiation(oc)), + &sample_negotiated_features(), + ); } #[test] @@ -749,6 +1013,7 @@ mod tests { assert_fail( &accept_channel(), Some(&negotiation), + &sample_negotiated_features(), "temporary_channel_id reuse: previous negotiation has not reached funding_created", ); } @@ -759,6 +1024,10 @@ mod tests { negotiation.accept_channel = Some(accept_channel()); negotiation.funding_built = true; - assert_pass(&accept_channel(), Some(&negotiation)); + assert_pass( + &accept_channel(), + Some(&negotiation), + &sample_negotiated_features(), + ); } }