Skip to content

Commit 32ccae3

Browse files
authored
Make minimum channel reserve configurable (#1)
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.
1 parent 5fe3688 commit 32ccae3

2 files changed

Lines changed: 123 additions & 19 deletions

File tree

lightning/src/ln/channel.rs

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,10 @@ pub const MAX_CHAN_DUST_LIMIT_SATOSHIS: u64 = MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS
704704
pub const MIN_CHAN_DUST_LIMIT_SATOSHIS: u64 = 354;
705705

706706
// Just a reasonable implementation-specific safe lower bound, higher than the dust limit.
707+
// Deprecated: This constant is kept for backward compatibility.
708+
// The minimum channel reserve is now configurable via `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`.
709+
// This constant retains its original value for API compatibility, but the actual behavior uses the config value.
710+
#[allow(dead_code)]
707711
pub const MIN_THEIR_CHAN_RESERVE_SATOSHIS: u64 = 1000;
708712

709713
/// Used to return a simple Error back to ChannelManager. Will get converted to a
@@ -1931,9 +1935,10 @@ impl<SP: Deref> ChannelContext<SP> where SP::Target: SignerProvider {
19311935
}
19321936
}
19331937

1934-
if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS {
1935-
// Protocol level safety check in place, although it should never happen because
1936-
// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`
1938+
// Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case)
1939+
if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS
1940+
&& config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 {
1941+
// Protocol level safety check in place
19371942
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)));
19381943
}
19391944
if holder_selected_channel_reserve_satoshis * 1000 >= full_channel_value_msat {
@@ -1943,7 +1948,9 @@ impl<SP: Deref> ChannelContext<SP> where SP::Target: SignerProvider {
19431948
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.",
19441949
msg_channel_reserve_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS);
19451950
}
1946-
if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis {
1951+
// Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case)
1952+
if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis
1953+
&& config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 {
19471954
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)));
19481955
}
19491956

@@ -2579,7 +2586,9 @@ impl<SP: Deref> ChannelContext<SP> where SP::Target: SignerProvider {
25792586
if channel_reserve_satoshis > self.channel_value_satoshis {
25802587
return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", channel_reserve_satoshis, self.channel_value_satoshis)));
25812588
}
2582-
if common_fields.dust_limit_satoshis > self.holder_selected_channel_reserve_satoshis {
2589+
// Allow bypassing dust limit when holder_selected_channel_reserve_satoshis is 0 (LSP use case)
2590+
if common_fields.dust_limit_satoshis > self.holder_selected_channel_reserve_satoshis
2591+
&& self.holder_selected_channel_reserve_satoshis > 0 {
25832592
return Err(ChannelError::close(format!("Dust limit ({}) is bigger than our channel reserve ({})", common_fields.dust_limit_satoshis, self.holder_selected_channel_reserve_satoshis)));
25842593
}
25852594
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:
40674076
/// Guaranteed to return a value no larger than channel_value_satoshis
40684077
///
40694078
/// This is used both for outbound and inbound channels and has lower bound
4070-
/// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`.
4071-
pub(crate) fn get_holder_selected_channel_reserve_satoshis(channel_value_satoshis: u64, config: &UserConfig) -> u64 {
4072-
let calculated_reserve = channel_value_satoshis.saturating_mul(config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64) / 1_000_000;
4073-
cmp::min(channel_value_satoshis, cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS))
4079+
/// of `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`.
4080+
pub(crate) fn get_holder_selected_channel_reserve_satoshis(
4081+
channel_value_satoshis: u64, config: &UserConfig,
4082+
) -> u64 {
4083+
let counterparty_chan_reserve_prop_mil =
4084+
config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64;
4085+
let min_their_channel_reserve_satoshis =
4086+
config.channel_handshake_config.min_their_channel_reserve_satoshis;
4087+
let calculated_reserve =
4088+
channel_value_satoshis.saturating_mul(counterparty_chan_reserve_prop_mil) / 1_000_000;
4089+
cmp::min(
4090+
channel_value_satoshis,
4091+
cmp::max(calculated_reserve, min_their_channel_reserve_satoshis),
4092+
)
40744093
}
40754094

40764095
/// This is for legacy reasons, present for forward-compatibility.
@@ -8207,9 +8226,10 @@ impl<SP: Deref> OutboundV1Channel<SP> where SP::Target: SignerProvider {
82078226
L::Target: Logger,
82088227
{
82098228
let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(channel_value_satoshis, config);
8210-
if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS {
8211-
// Protocol level safety check in place, although it should never happen because
8212-
// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`
8229+
// Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case)
8230+
if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS
8231+
&& config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 {
8232+
// Protocol level safety check in place
82138233
return Err(APIError::APIMisuseError { err: format!("Holder selected channel reserve below \
82148234
implemention limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) });
82158235
}
@@ -10150,7 +10170,7 @@ mod tests {
1015010170
use crate::ln::channelmanager::{self, HTLCSource, PaymentId};
1015110171
use crate::ln::channel::InitFeatures;
1015210172
use crate::ln::channel::{AwaitingChannelReadyFlags, Channel, ChannelState, InboundHTLCOutput, OutboundV1Channel, InboundV1Channel, OutboundHTLCOutput, InboundHTLCState, OutboundHTLCState, HTLCCandidate, HTLCInitiator, HTLCUpdateAwaitingACK, commit_tx_fee_sat};
10153-
use crate::ln::channel::{MAX_FUNDING_SATOSHIS_NO_WUMBO, TOTAL_BITCOIN_SUPPLY_SATOSHIS, MIN_THEIR_CHAN_RESERVE_SATOSHIS};
10173+
use crate::ln::channel::{MAX_FUNDING_SATOSHIS_NO_WUMBO, TOTAL_BITCOIN_SUPPLY_SATOSHIS};
1015410174
use crate::types::features::{ChannelFeatures, ChannelTypeFeatures, NodeFeatures};
1015510175
use crate::ln::msgs;
1015610176
use crate::ln::msgs::{ChannelUpdate, DecodeError, UnsignedChannelUpdate, MAX_VALUE_MSAT};
@@ -10576,7 +10596,7 @@ mod tests {
1057610596
test_self_and_counterparty_channel_reserve(10_000_000, 0.60, 0.30);
1057710597

1057810598
// Test with calculated channel reserve less than lower bound
10579-
// i.e `MIN_THEIR_CHAN_RESERVE_SATOSHIS`
10599+
// i.e `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`
1058010600
test_self_and_counterparty_channel_reserve(100_000, 0.00002, 0.30);
1058110601

1058210602
// Test with invalid channel reserves since sum of both is greater than or equal
@@ -10600,7 +10620,7 @@ mod tests {
1060010620
outbound_node_config.channel_handshake_config.their_channel_reserve_proportional_millionths = (outbound_selected_channel_reserve_perc * 1_000_000.0) as u32;
1060110621
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();
1060210622

10603-
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);
10623+
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);
1060410624
assert_eq!(chan.context.holder_selected_channel_reserve_satoshis, expected_outbound_selected_chan_reserve);
1060510625

1060610626
let chan_open_channel_msg = chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap();
@@ -10610,7 +10630,7 @@ mod tests {
1061010630
if outbound_selected_channel_reserve_perc + inbound_selected_channel_reserve_perc < 1.0 {
1061110631
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();
1061210632

10613-
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);
10633+
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);
1061410634

1061510635
assert_eq!(chan_inbound_node.context.holder_selected_channel_reserve_satoshis, expected_inbound_selected_chan_reserve);
1061610636
assert_eq!(chan_inbound_node.context.counterparty_selected_channel_reserve_satoshis.unwrap(), expected_outbound_selected_chan_reserve);
@@ -10622,6 +10642,62 @@ mod tests {
1062210642
}
1062310643

1062410644
#[test]
10645+
#[rustfmt::skip]
10646+
fn test_configurable_min_channel_reserve() {
10647+
let fee_est = LowerBoundedFeeEstimator::new(&TestFeeEstimator { fee_est: 15_000 });
10648+
let logger = test_utils::TestLogger::new();
10649+
let secp_ctx = Secp256k1::new();
10650+
let keys_provider = test_utils::TestKeysInterface::new(&[42; 32], Network::Testnet);
10651+
let outbound_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
10652+
10653+
// Test with min_their_channel_reserve_satoshis set to 0 (LSP use case)
10654+
let mut config = UserConfig::default();
10655+
config.channel_handshake_config.min_their_channel_reserve_satoshis = 0;
10656+
config.channel_handshake_config.their_channel_reserve_proportional_millionths = 0;
10657+
10658+
let chan = OutboundV1Channel::<&TestKeysInterface>::new(
10659+
&fee_est, &&keys_provider, &&keys_provider, outbound_node_id,
10660+
&channelmanager::provided_init_features(&config),
10661+
1_000_000, 100_000, 42, &config, 0, 42, None, &logger
10662+
).unwrap();
10663+
10664+
// With 0 minimum and 0 proportional, reserve should be 0 (bypasses dust limit)
10665+
assert_eq!(chan.context.holder_selected_channel_reserve_satoshis, 0);
10666+
10667+
// Test with custom minimum enforced when proportional is lower
10668+
config.channel_handshake_config.min_their_channel_reserve_satoshis = 10_000;
10669+
config.channel_handshake_config.their_channel_reserve_proportional_millionths = 10_000; // 1%
10670+
10671+
let chan_small = OutboundV1Channel::<&TestKeysInterface>::new(
10672+
&fee_est, &&keys_provider, &&keys_provider, outbound_node_id,
10673+
&channelmanager::provided_init_features(&config),
10674+
100_000, 100_000, 42, &config, 0, 42, None, &logger
10675+
).unwrap();
10676+
10677+
// Proportional would be 1% of 100k = 1000, but minimum is 10000, so 10000 should be used
10678+
assert_eq!(chan_small.context.holder_selected_channel_reserve_satoshis, 10_000);
10679+
10680+
// Test that dust limit is still enforced when min_their_channel_reserve_satoshis is non-zero but below dust limit
10681+
config.channel_handshake_config.min_their_channel_reserve_satoshis = 100; // Below dust limit of 354
10682+
config.channel_handshake_config.their_channel_reserve_proportional_millionths = 0;
10683+
10684+
let result = OutboundV1Channel::<&TestKeysInterface>::new(
10685+
&fee_est, &&keys_provider, &&keys_provider, outbound_node_id,
10686+
&channelmanager::provided_init_features(&config),
10687+
1_000_000, 100_000, 42, &config, 0, 42, None, &logger
10688+
);
10689+
10690+
// Should fail because 100 < 354 (dust limit) and min_their_channel_reserve_satoshis > 0
10691+
assert!(result.is_err());
10692+
if let Err(APIError::APIMisuseError { err }) = result {
10693+
assert!(err.contains("dust_limit_satoshis"));
10694+
} else {
10695+
panic!("Expected APIMisuseError");
10696+
}
10697+
}
10698+
10699+
#[test]
10700+
#[rustfmt::skip]
1062510701
fn channel_update() {
1062610702
let feeest = LowerBoundedFeeEstimator::new(&TestFeeEstimator{fee_est: 15000});
1062710703
let logger = test_utils::TestLogger::new();

lightning/src/util/config.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,40 @@ pub struct ChannelHandshakeConfig {
150150
///
151151
/// Default value: `10_000` millionths (i.e., 1% of channel value)
152152
///
153-
/// Minimum value: If the calculated proportional value is less than `1000` sats, it will be
154-
/// treated as `1000` sats instead, which is a safe implementation-specific lower
155-
/// bound.
153+
/// Minimum value: If the calculated proportional value is less than `min_their_channel_reserve_satoshis`,
154+
/// it will be treated as `min_their_channel_reserve_satoshis` instead.
156155
///
157156
/// Maximum value: `1_000_000` (i.e., 100% of channel value. Any values larger than one million
158157
/// will be treated as one million instead, although channel negotiations will
159158
/// fail in that case.)
160159
pub their_channel_reserve_proportional_millionths: u32,
160+
/// The minimum absolute channel reserve value in satoshis that will be enforced regardless of
161+
/// the proportional reserve calculation.
162+
///
163+
/// This ensures that even if the proportional reserve calculation results in a very small value
164+
/// (or zero), at least this minimum amount will be required as a channel reserve. This provides
165+
/// a safety mechanism to ensure some minimum reserve is always maintained.
166+
///
167+
/// **Special case: Setting to `0`**
168+
///
169+
/// Setting this value to `0` allows the counterparty to have no channel reserve, enabling them
170+
/// to use their entire channel balance for payments. This is useful for LSP use cases where the
171+
/// LSP wants to allow clients to be able to fully withdraw their funds from the channel without
172+
/// closing it.
173+
///
174+
/// **Security Warning:**
175+
///
176+
/// When set to `0`, the channel reserve no longer provides economic security. If the counterparty
177+
/// broadcasts a revoked state, there is no reserve to claim as punishment. This removes the
178+
/// economic disincentive for the counterparty to attempt cheating. Only use this setting with
179+
/// trusted counterparties (e.g., known LSP clients) or when other trust mechanisms are in place.
180+
///
181+
/// When set to `0`, the dust limit check is bypassed, allowing reserves below the protocol
182+
/// minimum dust limit (354 sats). For any non-zero value below the dust limit, the dust limit
183+
/// check will still be enforced.
184+
///
185+
/// Default value: `1000` sats
186+
pub min_their_channel_reserve_satoshis: u64,
161187
/// If set, we attempt to negotiate the `anchors_zero_fee_htlc_tx`option for all future
162188
/// channels. This feature requires having a reserve of onchain funds readily available to bump
163189
/// transactions in the event of a channel force close to avoid the possibility of losing funds.
@@ -214,6 +240,7 @@ impl Default for ChannelHandshakeConfig {
214240
announce_for_forwarding: false,
215241
commit_upfront_shutdown_pubkey: true,
216242
their_channel_reserve_proportional_millionths: 10_000,
243+
min_their_channel_reserve_satoshis: 1_000,
217244
negotiate_anchors_zero_fee_htlc_tx: false,
218245
our_max_accepted_htlcs: 50,
219246
}
@@ -235,6 +262,7 @@ impl Readable for ChannelHandshakeConfig {
235262
announce_for_forwarding: Readable::read(reader)?,
236263
commit_upfront_shutdown_pubkey: Readable::read(reader)?,
237264
their_channel_reserve_proportional_millionths: Readable::read(reader)?,
265+
min_their_channel_reserve_satoshis: Readable::read(reader)?,
238266
negotiate_anchors_zero_fee_htlc_tx: Readable::read(reader)?,
239267
our_max_accepted_htlcs: Readable::read(reader)?,
240268
})

0 commit comments

Comments
 (0)