Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 92 additions & 16 deletions lightning/src/ln/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1931,9 +1935,10 @@ impl<SP: Deref> ChannelContext<SP> 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 {
Expand All @@ -1943,7 +1948,9 @@ impl<SP: Deref> ChannelContext<SP> 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)));
}

Expand Down Expand Up @@ -2579,7 +2586,9 @@ impl<SP: Deref> ChannelContext<SP> 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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -8207,9 +8226,10 @@ impl<SP: Deref> OutboundV1Channel<SP> 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) });
}
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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();
Expand Down
34 changes: 31 additions & 3 deletions lightning/src/util/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably use MIN_THEIR_CHAN_RESERVE_SATOSHIS?

Copy link
Author

@amackillop amackillop Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change basically deprecates that constant as it's no longer a real constant. Although I should double check if that constant is actually exposed publicly and remove it if not.

negotiate_anchors_zero_fee_htlc_tx: false,
our_max_accepted_htlcs: 50,
}
Expand All @@ -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)?,
})
Expand Down
Loading