Always enforce the 1000sat min channel value in zero-reserve channels
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where very small channels could be created or spliced down to below 1000 satoshis when zero-reserve channels were used. Normally, a reserve requirement prevents channels from being too tiny, but that safeguard was skipped for zero-reserve channels. The patch adds a direct 1000-satoshi minimum channel value check for opening and splicing channels, including during splicing of normal reserve-enabled channels. This prevents the creation or modification of channels so small that they could be economically unviable or cause protocol edge cases.
Review and merge the patch, then ensure downstream users running nodes with zero-reserve / anchor-zero-fee channels upgrade. Monitor for any open channels or pending splices that may have been created below the new 1000-sat minimum and consider closing or renegotiating them.
Security signals we found
Missing lower-bound validation on channel value for zero-reserve channels
Splicing could reduce channel value below 1000 satoshis
New constant MIN_CHANNEL_VALUE_SATOSHIS introduced and enforced
Validation added in inbound channel acceptance, splice funding scope construction, and splice-out maximum calculation
Tests extended for anchors_zero_fee_commitments and adjusted for new minimum
Evidence from the diff
The change introduces a new constant MIN_CHANNEL_VALUE_SATOSHIS = 1000 and enforces it in three places: (1) when accepting an inbound channel open (ChannelContext::new_inbound), (2) when validating a splice’s post-channel value (FundingScope::build_post_splice_funding_scope), and (3) when computing the maximum splice-out amount (get_next_splice_out_maximum_sat in tx_builder). Previously, the 1000-sat minimum was indirectly guaranteed by MIN_THEIR_CHAN_RESERVE_SATOSHIS, which is no longer consulted for zero-reserve channels. The patch also updates tests to cover anchors_zero_fee_commitments channel types and adjusts expected splice-out limits to respect the new minimum.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rslightning/src/sign/tx_builder.rslightning/src/util/config.rsInspect captured patch +115 / −34
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index f7c4ca2..e07ee7f 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -1012,6 +1012,9 @@ pub const MIN_CHAN_DUST_LIMIT_SATOSHIS: u64 = 354;
// Just a reasonable implementation-specific safe lower bound, higher than the dust limit.
pub const MIN_THEIR_CHAN_RESERVE_SATOSHIS: u64 = 1000;
+// Just a reasonable implementation-specific safe lower bound.
+pub const MIN_CHANNEL_VALUE_SATOSHIS: u64 = 1000;
+
/// Used to return a simple Error back to ChannelManager. Will get converted to a
/// msgs::ErrorAction::SendErrorMessage or msgs::ErrorAction::IgnoreError as appropriate with our
/// channel_id in ChannelManager.
@@ -2786,10 +2789,15 @@ impl FundingScope {
),
)?;
- let post_channel_value = prev_funding.get_value_satoshis()
+ let post_channel_value_sat = prev_funding.get_value_satoshis()
.checked_add_signed(our_funding_contribution.to_sat())
.and_then(|v| v.checked_add_signed(their_funding_contribution.to_sat()))
.ok_or(format!("The sum of contributions {our_funding_contribution} and {their_funding_contribution} is greater than the channel's value"))?;
+ if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS {
+ return Err(format!(
+ "Spliced channel value must be at least 1000 satoshis. It would be {post_channel_value_sat}",
+ ));
+ }
let channel_parameters = &prev_funding.channel_transaction_parameters;
let mut post_channel_transaction_parameters = ChannelTransactionParameters {
@@ -2801,7 +2809,7 @@ impl FundingScope {
funding_outpoint: None, // filled later
splice_parent_funding_txid: prev_funding.get_funding_txid(),
channel_type_features: channel_parameters.channel_type_features.clone(),
- channel_value_satoshis: post_channel_value,
+ channel_value_satoshis: post_channel_value_sat,
};
post_channel_transaction_parameters
.counterparty_parameters
@@ -2812,7 +2820,7 @@ impl FundingScope {
// New reserve values are based on the new channel value and are v2-specific
let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
- post_channel_value,
+ post_channel_value_sat,
MIN_CHAN_DUST_LIMIT_SATOSHIS,
prev_funding
.counterparty_selected_channel_reserve_satoshis
@@ -2820,7 +2828,7 @@ impl FundingScope {
== 0,
);
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
- post_channel_value,
+ post_channel_value_sat,
context.counterparty_dust_limit_satoshis,
prev_funding.holder_selected_channel_reserve_satoshis == 0,
);
@@ -3748,6 +3756,11 @@ impl<SP: SignerProvider> ChannelContext<SP> {
let channel_value_satoshis =
our_funding_satoshis.saturating_add(open_channel_fields.funding_satoshis);
+ if channel_value_satoshis < MIN_CHANNEL_VALUE_SATOSHIS {
+ return Err(ChannelError::close(format!(
+ "Channel value must be at least 1000 satoshis. It was {channel_value_satoshis}",
+ )));
+ }
let channel_keys_id = signer_provider.generate_channel_keys_id(true, user_id);
let holder_signer = signer_provider.derive_channel_signer(channel_keys_id);
@@ -3896,7 +3909,7 @@ impl<SP: SignerProvider> ChannelContext<SP> {
&& holder_selected_channel_reserve_satoshis != 0
{
// Protocol level safety check in place, although it should never happen because
- // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`
+ // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` and `MIN_CHANNEL_VALUE_SATOSHIS`
return Err(ChannelError::close(format!(
"Suitable channel reserve not found. remote_channel_reserve was ({holder_selected_channel_reserve_satoshis}). dust_limit_satoshis is ({MIN_CHAN_DUST_LIMIT_SATOSHIS})."
)));
@@ -14453,7 +14466,7 @@ impl<SP: SignerProvider> OutboundV1Channel<SP> {
);
if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS && !is_0reserve {
// Protocol level safety check in place, although it should never happen because
- // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`
+ // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` and `MIN_CHANNEL_VALUE_SATOSHIS`
return Err(APIError::APIMisuseError {
err: format!(
"Holder selected channel reserve below implementation limit dust_limit_satoshis {holder_selected_channel_reserve_satoshis}"
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 43a90a1..a7a0942 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -3887,7 +3887,7 @@ impl<
override_config: Option<UserConfig>,
trusted_channel_features: Option<TrustedChannelFeatures>,
) -> Result<ChannelId, APIError> {
- if channel_value_satoshis < 1000 {
+ if channel_value_satoshis < crate::ln::channel::MIN_CHANNEL_VALUE_SATOSHIS {
return Err(APIError::APIMisuseError {
err: format!(
"Channel value must be at least 1000 satoshis. It was {channel_value_satoshis}"
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 986af79..a536135 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -18,6 +18,7 @@ use crate::ln::chan_utils;
use crate::ln::channel::{
ANCHOR_OUTPUT_VALUE_SATOSHI, CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY,
DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE,
+ MIN_CHANNEL_VALUE_SATOSHIS,
};
use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT};
use crate::ln::functional_test_utils::*;
@@ -7509,6 +7510,20 @@ fn test_0reserve_splice() {
assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies());
+ config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
+ config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true;
+ let a = do_test_0reserve_splice_holder_validation(false, false, false, config.clone());
+ let _b = do_test_0reserve_splice_holder_validation(true, false, false, config.clone());
+ let _c = do_test_0reserve_splice_holder_validation(false, true, false, config.clone());
+ let _d = do_test_0reserve_splice_holder_validation(true, true, false, config.clone());
+
+ let _e = do_test_0reserve_splice_holder_validation(false, false, true, config.clone());
+ let _f = do_test_0reserve_splice_holder_validation(true, false, true, config.clone());
+ let _g = do_test_0reserve_splice_holder_validation(false, true, true, config.clone());
+ let _h = do_test_0reserve_splice_holder_validation(true, true, true, config.clone());
+
+ assert_eq!(a, ChannelTypeFeatures::anchors_zero_fee_commitments());
+
let mut config = test_default_channel_config();
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false;
@@ -7521,6 +7536,7 @@ fn test_0reserve_splice() {
let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone());
let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone());
let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone());
+
assert_eq!(a, ChannelTypeFeatures::only_static_remote_key());
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true;
@@ -7534,12 +7550,22 @@ fn test_0reserve_splice() {
let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone());
let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone());
let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone());
+
assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies());
- // TODO: Skip 0FC channels for now as these always have an output on the commitment, the P2A
- // output. We will be able to withdraw up to the dust limit of the funding script, which
- // is checked in interactivetx. Still need to double check whether that's what we actually
- // want.
+ config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
+ config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true;
+ let a = do_test_0reserve_splice_counterparty_validation(false, false, false, config.clone());
+ let _b = do_test_0reserve_splice_counterparty_validation(true, false, false, config.clone());
+ let _c = do_test_0reserve_splice_counterparty_validation(false, true, false, config.clone());
+ let _d = do_test_0reserve_splice_counterparty_validation(true, true, false, config.clone());
+
+ let _e = do_test_0reserve_splice_counterparty_validation(false, false, true, config.clone());
+ let _f = do_test_0reserve_splice_counterparty_validation(true, false, true, config.clone());
+ let _g = do_test_0reserve_splice_counterparty_validation(false, true, true, config.clone());
+ let _h = do_test_0reserve_splice_counterparty_validation(true, true, true, config.clone());
+
+ assert_eq!(a, ChannelTypeFeatures::anchors_zero_fee_commitments());
}
#[cfg(test)]
@@ -7569,13 +7595,12 @@ fn do_test_0reserve_splice_holder_validation(
let details = &nodes[0].node.list_channels()[0];
let channel_type = details.channel_type.clone().unwrap();
- let feerate = 253;
+ let feerate =
+ if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { 0 } else { 253 };
let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() {
feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32
- } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() {
- feerate
} else {
- panic!("Unexpected channel type");
+ feerate
};
let anchors_sat =
if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() {
@@ -7603,13 +7628,18 @@ fn do_test_0reserve_splice_holder_validation(
- chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type) * 1000;
assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis);
let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0];
- assert_eq!(commit_tx.output.len(), if anchors_sat == 0 { 1 } else { 2 });
+ assert_eq!(
+ commit_tx.output.len(),
+ if channel_type == ChannelTypeFeatures::only_static_remote_key() { 1 } else { 2 }
+ );
assert_eq!(
commit_tx.output.last().unwrap().value,
Amount::from_sat(available_capacity_msat / 1000)
);
- if anchors_sat != 0 {
+ if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() {
assert_eq!(commit_tx.output[0].value, Amount::from_sat(330));
+ } else if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() {
+ assert_eq!(commit_tx.output[0].value, Amount::ZERO);
}
available_capacity_msat / 1000
@@ -7618,27 +7648,36 @@ fn do_test_0reserve_splice_holder_validation(
};
// The estimated fees to splice out a single output at 253sat/kw
- let estimated_fees = 183;
- let splice_out_max_value = if counterparty_has_output && node_0_is_initiator {
+ let estimated_fees_sat = 183;
+ let mut splice_out_max_value = if counterparty_has_output && node_0_is_initiator {
let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type);
Amount::from_sat(
- initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - estimated_fees,
+ initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat - estimated_fees_sat,
)
} else if !counterparty_has_output && node_0_is_initiator {
let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type);
Amount::from_sat(
initiator_value_to_self_sat
- commit_tx_fee_sat
- - anchors_sat - estimated_fees
+ - anchors_sat - estimated_fees_sat
- dust_limit_satoshis,
)
} else if counterparty_has_output && !node_0_is_initiator {
- Amount::from_sat(initiator_value_to_self_sat - estimated_fees)
+ Amount::from_sat(initiator_value_to_self_sat - estimated_fees_sat)
} else if !counterparty_has_output && !node_0_is_initiator {
- Amount::from_sat(initiator_value_to_self_sat - estimated_fees - dust_limit_satoshis)
+ Amount::from_sat(initiator_value_to_self_sat - estimated_fees_sat - dust_limit_satoshis)
} else {
panic!("unexpected case!");
};
+
+ if channel_value_sat
+ < splice_out_max_value.to_sat() + estimated_fees_sat + MIN_CHANNEL_VALUE_SATOSHIS
+ {
+ splice_out_max_value = Amount::from_sat(
+ channel_value_sat.saturating_sub(estimated_fees_sat + MIN_CHANNEL_VALUE_SATOSHIS),
+ );
+ }
+
let outputs = vec![TxOut {
value: splice_out_max_value + if splice_passes { Amount::ZERO } else { Amount::ONE_SAT },
script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
@@ -7650,7 +7689,7 @@ fn do_test_0reserve_splice_holder_validation(
let initiator_details = &initiator.node.list_channels()[0];
assert_eq!(
initiator_details.next_splice_out_maximum_sat,
- splice_out_max_value.to_sat() + estimated_fees
+ splice_out_max_value.to_sat() + estimated_fees_sat
);
if splice_passes {
@@ -7663,8 +7702,8 @@ fn do_test_0reserve_splice_holder_validation(
} else {
assert!(initiate_splice_out(initiator, acceptor, channel_id, outputs).is_err());
let splice_out_value =
- splice_out_max_value + Amount::from_sat(estimated_fees) + Amount::ONE_SAT;
- let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees);
+ splice_out_max_value + Amount::from_sat(estimated_fees_sat) + Amount::ONE_SAT;
+ let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees_sat);
let cannot_be_funded = format!(
"Channel {channel_id} cannot be funded: Our \
splice-out value of {splice_out_value} is greater than the maximum \
@@ -7703,13 +7742,12 @@ fn do_test_0reserve_splice_counterparty_validation(
let details = &nodes[0].node.list_channels()[0];
let channel_type = details.channel_type.clone().unwrap();
- let feerate = 253;
+ let feerate =
+ if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() { 0 } else { 253 };
let spiked_feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() {
feerate * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32
- } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() {
- feerate
} else {
- panic!("Unexpected channel type");
+ feerate
};
let anchors_sat =
if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() {
@@ -7737,13 +7775,18 @@ fn do_test_0reserve_splice_counterparty_validation(
- chan_utils::commit_tx_fee_sat(spiked_feerate, 0, &channel_type) * 1000;
assert!(node_0_to_local_output_msat / 1000 < dust_limit_satoshis);
let commit_tx = &get_local_commitment_txn!(nodes[0], channel_id)[0];
- assert_eq!(commit_tx.output.len(), if anchors_sat == 0 { 1 } else { 2 });
+ assert_eq!(
+ commit_tx.output.len(),
+ if channel_type == ChannelTypeFeatures::only_static_remote_key() { 1 } else { 2 }
+ );
assert_eq!(
commit_tx.output.last().unwrap().value,
Amount::from_sat(available_capacity_msat / 1000)
);
- if anchors_sat != 0 {
+ if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() {
assert_eq!(commit_tx.output[0].value, Amount::from_sat(330));
+ } else if channel_type == ChannelTypeFeatures::anchors_zero_fee_commitments() {
+ assert_eq!(commit_tx.output[0].value, Amount::ZERO);
}
available_capacity_msat / 1000
@@ -7751,7 +7794,7 @@ fn do_test_0reserve_splice_counterparty_validation(
channel_value_sat
};
- let splice_out_value_incl_fees = if counterparty_has_output && node_0_is_initiator {
+ let mut splice_out_value_incl_fees = if counterparty_has_output && node_0_is_initiator {
let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(spiked_feerate, 1, &channel_type);
Amount::from_sat(initiator_value_to_self_sat - commit_tx_fee_sat - anchors_sat)
} else if !counterparty_has_output && node_0_is_initiator {
@@ -7767,6 +7810,10 @@ fn do_test_0reserve_splice_counterparty_validation(
panic!("unexpected case!");
};
+ if channel_value_sat < splice_out_value_incl_fees.to_sat() + MIN_CHANNEL_VALUE_SATOSHIS {
+ splice_out_value_incl_fees =
+ Amount::from_sat(channel_value_sat.saturating_sub(MIN_CHANNEL_VALUE_SATOSHIS));
+ }
let (initiator, acceptor) =
if node_0_is_initiator { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) };
@@ -7775,6 +7822,9 @@ fn do_test_0reserve_splice_counterparty_validation(
let funding_contribution_sat =
-(splice_out_value_incl_fees.to_sat() as i64) - if splice_passes { 0 } else { 1 };
+ let post_channel_value_sat =
+ channel_value_sat.checked_add_signed(funding_contribution_sat).unwrap();
+
let outputs = vec![TxOut {
// Splice out some dummy amount to get past the initiator's validation,
// we'll modify the message in-flight.
@@ -7812,11 +7862,22 @@ fn do_test_0reserve_splice_counterparty_validation(
let cannot_splice_out = if u64::try_from(funding_contribution_sat.abs()).unwrap()
> initiator_value_to_self_sat
{
+ // They obviously can't afford their contribution, so we fail before even
+ // querying `TxBuilder`
format!(
"Got non-closing error: Their contribution candidate {funding_contribution_sat}sat \
is greater than their total balance in the channel {initiator_value_to_self_sat}sat"
)
+ } else if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS {
+ // We require all spliced channels to have a value of at least 1000 satoshis after the splice
+ format!(
+ "Got non-closing error: Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \
+ It would be {post_channel_value_sat}"
+ )
} else {
+ // Last but not least, `TxBuilder` decides whether all parties can afford
+ // HTLCs, anchors, and transaction fees while retaining at least one
+ // output on the commitments
format!(
"Got non-closing error: Channel {channel_id} cannot \
be spliced; Balance exhausted on local commitment"
diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs
index 986cb9e..ffb01c5 100644
--- a/lightning/src/sign/tx_builder.rs
+++ b/lightning/src/sign/tx_builder.rs
@@ -11,6 +11,7 @@ use crate::ln::chan_utils::{
};
use crate::ln::channel::{
get_v2_channel_reserve_satoshis, CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI,
+ MIN_CHANNEL_VALUE_SATOSHIS,
};
use crate::prelude::*;
use crate::types::features::ChannelTypeFeatures;
@@ -416,6 +417,11 @@ fn get_next_splice_out_maximum_sat(
(local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat);
}
+ if channel_value_satoshis < next_splice_out_maximum_sat + MIN_CHANNEL_VALUE_SATOSHIS {
+ next_splice_out_maximum_sat =
+ channel_value_satoshis.saturating_sub(MIN_CHANNEL_VALUE_SATOSHIS);
+ }
+
next_splice_out_maximum_sat
}
diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs
index c83eb69..78ab45d 100644
--- a/lightning/src/util/config.rs
+++ b/lightning/src/util/config.rs
@@ -326,7 +326,8 @@ pub struct ChannelHandshakeLimits {
/// only applies to inbound channels.
///
/// Default value: `1000`
- /// (Minimum of [`ChannelHandshakeConfig::their_channel_reserve_proportional_millionths`])
+ ///
+ /// Minimum value: `1000` (Any values less will be treated as `1000` instead.)
pub min_funding_satoshis: u64,
/// The remote node sets a limit on the minimum size of HTLCs we can send to them. This allows
/// you to limit the maximum minimum-size they can require.
Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.