From 52603a9c4179cb849191571964eeadcb451cb33e Mon Sep 17 00:00:00 2001 From: amackillop Date: Thu, 11 Dec 2025 09:19:50 -0800 Subject: [PATCH] Make minimum channel reserve configurable Add configurable min_their_channel_reserve_satoshis field to ChannelHandshakeConfig, allowing users to set the minimum channel reserve value. Special case: When set to 0, the dust limit check is bypassed. This enables LSP use cases where clients are able to fully withdraw their funds from the channel without closing it. For non-zero values below the dust limit, validation still enforces the dust limit. Replaces hardcoded MIN_THEIR_CHAN_RESERVE_SATOSHIS constant with configurable value while maintaining backward compatibility. Default remains 1000 sats to preserve existing behavior. --- lightning/src/ln/channel.rs | 108 +++++++++++++++++++++++++++++------ lightning/src/util/config.rs | 34 ++++++++++- 2 files changed, 123 insertions(+), 19 deletions(-) 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)?, })