diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0d86f6f3c98..32660e8abe9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -704,6 +704,10 @@ pub const MAX_CHAN_DUST_LIMIT_SATOSHIS: u64 = MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS pub const MIN_CHAN_DUST_LIMIT_SATOSHIS: u64 = 354; // Just a reasonable implementation-specific safe lower bound, higher than the dust limit. +// Deprecated: This constant is kept for backward compatibility. +// The minimum channel reserve is now configurable via `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`. +// This constant retains its original value for API compatibility, but the actual behavior uses the config value. +#[allow(dead_code)] pub const MIN_THEIR_CHAN_RESERVE_SATOSHIS: u64 = 1000; /// Used to return a simple Error back to ChannelManager. Will get converted to a @@ -1931,9 +1935,10 @@ impl ChannelContext where SP::Target: SignerProvider { } } - if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { - // Protocol level safety check in place, although it should never happen because - // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + // Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case) + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS + && config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 { + // Protocol level safety check in place return Err(ChannelError::close(format!("Suitable channel reserve not found. remote_channel_reserve was ({}). dust_limit_satoshis is ({}).", holder_selected_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS))); } if holder_selected_channel_reserve_satoshis * 1000 >= full_channel_value_msat { @@ -1943,7 +1948,9 @@ impl ChannelContext where SP::Target: SignerProvider { log_debug!(logger, "channel_reserve_satoshis ({}) is smaller than our dust limit ({}). We can broadcast stale states without any risk, implying this channel is very insecure for our counterparty.", msg_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); } - if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis { + // Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case) + if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis + && config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 { return Err(ChannelError::close(format!("Dust limit ({}) too high for the channel reserve we require the remote to keep ({})", open_channel_fields.dust_limit_satoshis, holder_selected_channel_reserve_satoshis))); } @@ -2579,7 +2586,9 @@ impl ChannelContext where SP::Target: SignerProvider { if channel_reserve_satoshis > self.channel_value_satoshis { return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", channel_reserve_satoshis, self.channel_value_satoshis))); } - if common_fields.dust_limit_satoshis > self.holder_selected_channel_reserve_satoshis { + // Allow bypassing dust limit when holder_selected_channel_reserve_satoshis is 0 (LSP use case) + if common_fields.dust_limit_satoshis > self.holder_selected_channel_reserve_satoshis + && self.holder_selected_channel_reserve_satoshis > 0 { return Err(ChannelError::close(format!("Dust limit ({}) is bigger than our channel reserve ({})", common_fields.dust_limit_satoshis, self.holder_selected_channel_reserve_satoshis))); } if channel_reserve_satoshis > self.channel_value_satoshis - self.holder_selected_channel_reserve_satoshis { @@ -4067,10 +4076,20 @@ fn get_holder_max_htlc_value_in_flight_msat(channel_value_satoshis: u64, config: /// Guaranteed to return a value no larger than channel_value_satoshis /// /// This is used both for outbound and inbound channels and has lower bound -/// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`. -pub(crate) fn get_holder_selected_channel_reserve_satoshis(channel_value_satoshis: u64, config: &UserConfig) -> u64 { - let calculated_reserve = channel_value_satoshis.saturating_mul(config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64) / 1_000_000; - cmp::min(channel_value_satoshis, cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS)) +/// of `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`. +pub(crate) fn get_holder_selected_channel_reserve_satoshis( + channel_value_satoshis: u64, config: &UserConfig, +) -> u64 { + let counterparty_chan_reserve_prop_mil = + config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64; + let min_their_channel_reserve_satoshis = + config.channel_handshake_config.min_their_channel_reserve_satoshis; + let calculated_reserve = + channel_value_satoshis.saturating_mul(counterparty_chan_reserve_prop_mil) / 1_000_000; + cmp::min( + channel_value_satoshis, + cmp::max(calculated_reserve, min_their_channel_reserve_satoshis), + ) } /// This is for legacy reasons, present for forward-compatibility. @@ -8207,9 +8226,10 @@ impl OutboundV1Channel where SP::Target: SignerProvider { L::Target: Logger, { let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(channel_value_satoshis, config); - if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS { - // Protocol level safety check in place, although it should never happen because - // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + // Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case) + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS + && config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 { + // Protocol level safety check in place return Err(APIError::APIMisuseError { err: format!("Holder selected channel reserve below \ implemention limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) }); } @@ -10150,7 +10170,7 @@ mod tests { use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; use crate::ln::channel::InitFeatures; use crate::ln::channel::{AwaitingChannelReadyFlags, Channel, ChannelState, InboundHTLCOutput, OutboundV1Channel, InboundV1Channel, OutboundHTLCOutput, InboundHTLCState, OutboundHTLCState, HTLCCandidate, HTLCInitiator, HTLCUpdateAwaitingACK, commit_tx_fee_sat}; - use crate::ln::channel::{MAX_FUNDING_SATOSHIS_NO_WUMBO, TOTAL_BITCOIN_SUPPLY_SATOSHIS, MIN_THEIR_CHAN_RESERVE_SATOSHIS}; + use crate::ln::channel::{MAX_FUNDING_SATOSHIS_NO_WUMBO, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; use crate::types::features::{ChannelFeatures, ChannelTypeFeatures, NodeFeatures}; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, DecodeError, UnsignedChannelUpdate, MAX_VALUE_MSAT}; @@ -10576,7 +10596,7 @@ mod tests { test_self_and_counterparty_channel_reserve(10_000_000, 0.60, 0.30); // Test with calculated channel reserve less than lower bound - // i.e `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + // i.e `ChannelHandshakeConfig::min_their_channel_reserve_satoshis` test_self_and_counterparty_channel_reserve(100_000, 0.00002, 0.30); // Test with invalid channel reserves since sum of both is greater than or equal @@ -10600,7 +10620,7 @@ mod tests { outbound_node_config.channel_handshake_config.their_channel_reserve_proportional_millionths = (outbound_selected_channel_reserve_perc * 1_000_000.0) as u32; let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&outbound_node_config), channel_value_satoshis, 100_000, 42, &outbound_node_config, 0, 42, None, &logger).unwrap(); - let expected_outbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.context.channel_value_satoshis as f64 * outbound_selected_channel_reserve_perc) as u64); + let expected_outbound_selected_chan_reserve = cmp::max(outbound_node_config.channel_handshake_config.min_their_channel_reserve_satoshis, (chan.context.channel_value_satoshis as f64 * outbound_selected_channel_reserve_perc) as u64); assert_eq!(chan.context.holder_selected_channel_reserve_satoshis, expected_outbound_selected_chan_reserve); let chan_open_channel_msg = chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap(); @@ -10610,7 +10630,7 @@ mod tests { if outbound_selected_channel_reserve_perc + inbound_selected_channel_reserve_perc < 1.0 { let chan_inbound_node = InboundV1Channel::<&TestKeysInterface>::new(&&fee_est, &&keys_provider, &&keys_provider, inbound_node_id, &channelmanager::provided_channel_type_features(&inbound_node_config), &channelmanager::provided_init_features(&outbound_node_config), &chan_open_channel_msg, 7, &inbound_node_config, 0, &&logger, /*is_0conf=*/false).unwrap(); - let expected_inbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.context.channel_value_satoshis as f64 * inbound_selected_channel_reserve_perc) as u64); + let expected_inbound_selected_chan_reserve = cmp::max(inbound_node_config.channel_handshake_config.min_their_channel_reserve_satoshis, (chan.context.channel_value_satoshis as f64 * inbound_selected_channel_reserve_perc) as u64); assert_eq!(chan_inbound_node.context.holder_selected_channel_reserve_satoshis, expected_inbound_selected_chan_reserve); assert_eq!(chan_inbound_node.context.counterparty_selected_channel_reserve_satoshis.unwrap(), expected_outbound_selected_chan_reserve); @@ -10622,6 +10642,62 @@ mod tests { } #[test] + #[rustfmt::skip] + fn test_configurable_min_channel_reserve() { + let fee_est = LowerBoundedFeeEstimator::new(&TestFeeEstimator { fee_est: 15_000 }); + let logger = test_utils::TestLogger::new(); + let secp_ctx = Secp256k1::new(); + let keys_provider = test_utils::TestKeysInterface::new(&[42; 32], Network::Testnet); + let outbound_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); + + // Test with min_their_channel_reserve_satoshis set to 0 (LSP use case) + let mut config = UserConfig::default(); + config.channel_handshake_config.min_their_channel_reserve_satoshis = 0; + config.channel_handshake_config.their_channel_reserve_proportional_millionths = 0; + + let chan = OutboundV1Channel::<&TestKeysInterface>::new( + &fee_est, &&keys_provider, &&keys_provider, outbound_node_id, + &channelmanager::provided_init_features(&config), + 1_000_000, 100_000, 42, &config, 0, 42, None, &logger + ).unwrap(); + + // With 0 minimum and 0 proportional, reserve should be 0 (bypasses dust limit) + assert_eq!(chan.context.holder_selected_channel_reserve_satoshis, 0); + + // Test with custom minimum enforced when proportional is lower + config.channel_handshake_config.min_their_channel_reserve_satoshis = 10_000; + config.channel_handshake_config.their_channel_reserve_proportional_millionths = 10_000; // 1% + + let chan_small = OutboundV1Channel::<&TestKeysInterface>::new( + &fee_est, &&keys_provider, &&keys_provider, outbound_node_id, + &channelmanager::provided_init_features(&config), + 100_000, 100_000, 42, &config, 0, 42, None, &logger + ).unwrap(); + + // Proportional would be 1% of 100k = 1000, but minimum is 10000, so 10000 should be used + assert_eq!(chan_small.context.holder_selected_channel_reserve_satoshis, 10_000); + + // Test that dust limit is still enforced when min_their_channel_reserve_satoshis is non-zero but below dust limit + config.channel_handshake_config.min_their_channel_reserve_satoshis = 100; // Below dust limit of 354 + config.channel_handshake_config.their_channel_reserve_proportional_millionths = 0; + + let result = OutboundV1Channel::<&TestKeysInterface>::new( + &fee_est, &&keys_provider, &&keys_provider, outbound_node_id, + &channelmanager::provided_init_features(&config), + 1_000_000, 100_000, 42, &config, 0, 42, None, &logger + ); + + // Should fail because 100 < 354 (dust limit) and min_their_channel_reserve_satoshis > 0 + assert!(result.is_err()); + if let Err(APIError::APIMisuseError { err }) = result { + assert!(err.contains("dust_limit_satoshis")); + } else { + panic!("Expected APIMisuseError"); + } + } + + #[test] + #[rustfmt::skip] fn channel_update() { let feeest = LowerBoundedFeeEstimator::new(&TestFeeEstimator{fee_est: 15000}); let logger = test_utils::TestLogger::new(); diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index ef35df1a0b1..e9623647107 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -150,14 +150,40 @@ pub struct ChannelHandshakeConfig { /// /// Default value: `10_000` millionths (i.e., 1% of channel value) /// - /// Minimum value: If the calculated proportional value is less than `1000` sats, it will be - /// treated as `1000` sats instead, which is a safe implementation-specific lower - /// bound. + /// Minimum value: If the calculated proportional value is less than `min_their_channel_reserve_satoshis`, + /// it will be treated as `min_their_channel_reserve_satoshis` instead. /// /// Maximum value: `1_000_000` (i.e., 100% of channel value. Any values larger than one million /// will be treated as one million instead, although channel negotiations will /// fail in that case.) pub their_channel_reserve_proportional_millionths: u32, + /// The minimum absolute channel reserve value in satoshis that will be enforced regardless of + /// the proportional reserve calculation. + /// + /// This ensures that even if the proportional reserve calculation results in a very small value + /// (or zero), at least this minimum amount will be required as a channel reserve. This provides + /// a safety mechanism to ensure some minimum reserve is always maintained. + /// + /// **Special case: Setting to `0`** + /// + /// Setting this value to `0` allows the counterparty to have no channel reserve, enabling them + /// to use their entire channel balance for payments. This is useful for LSP use cases where the + /// LSP wants to allow clients to be able to fully withdraw their funds from the channel without + /// closing it. + /// + /// **Security Warning:** + /// + /// When set to `0`, the channel reserve no longer provides economic security. If the counterparty + /// broadcasts a revoked state, there is no reserve to claim as punishment. This removes the + /// economic disincentive for the counterparty to attempt cheating. Only use this setting with + /// trusted counterparties (e.g., known LSP clients) or when other trust mechanisms are in place. + /// + /// When set to `0`, the dust limit check is bypassed, allowing reserves below the protocol + /// minimum dust limit (354 sats). For any non-zero value below the dust limit, the dust limit + /// check will still be enforced. + /// + /// Default value: `1000` sats + pub min_their_channel_reserve_satoshis: u64, /// If set, we attempt to negotiate the `anchors_zero_fee_htlc_tx`option for all future /// channels. This feature requires having a reserve of onchain funds readily available to bump /// transactions in the event of a channel force close to avoid the possibility of losing funds. @@ -214,6 +240,7 @@ impl Default for ChannelHandshakeConfig { announce_for_forwarding: false, commit_upfront_shutdown_pubkey: true, their_channel_reserve_proportional_millionths: 10_000, + min_their_channel_reserve_satoshis: 1_000, negotiate_anchors_zero_fee_htlc_tx: false, our_max_accepted_htlcs: 50, } @@ -235,6 +262,7 @@ impl Readable for ChannelHandshakeConfig { announce_for_forwarding: Readable::read(reader)?, commit_upfront_shutdown_pubkey: Readable::read(reader)?, their_channel_reserve_proportional_millionths: Readable::read(reader)?, + min_their_channel_reserve_satoshis: Readable::read(reader)?, negotiate_anchors_zero_fee_htlc_tx: Readable::read(reader)?, our_max_accepted_htlcs: Readable::read(reader)?, })