Add 0-reserve to the internal API of V2 channels
What changed, and why it matters
This commit adds an internal-only option for V2 Lightning channels to disable the usual channel reserve requirement. It is marked by the authors as not matching the official protocol specification, using a temporary experimental message field. Because it is internal and not exposed to end users, the immediate security risk is limited, but it introduces assumptions about how peers will behave when reserves are skipped.
Treat as a feature-in-progress rather than an active vulnerability. Reviewers should verify that the public API does not expose zero-reserve channels until the spec-compliant mechanism is finalized, and ensure HTLC acceptance logic enforces the correct reserve when the counterparty is the funder. Monitor for follow-up commits that remove the experimental odd-TLV or add spec-compliant negotiation.
Security signals we found
Non-spec-compliant protocol field (odd TLV 103) added to dual-funding channel messages
Channel reserve, a standard economic safety mechanism, can be set to zero internally
Authors assume counterparties will tolerate HTLCs that push balance below reserve
Change is internal-only; no public user-facing API appears in the diff
Extensive test coverage added for message encoding with the new flag
Evidence from the diff
The change adds a disable_channel_reserve odd TLV (type 103/0x67) to OpenChannelV2 and AcceptChannelV2, plus plumbing for a trusted_channel_features flag that can set the holder’s reserve to zero. The commit message explicitly notes this does not match the BOLT spec and uses an odd TLV so unknown peers ignore it. It also assumes counterparties will not fail HTLCs that push their balance below the sender-selected reserve. The patch is additive and gated by internal feature flags; no public API exposure is visible in the diff.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/msgs.rsV2 channel establishment (open_channel2 / accept_channel2)Inspect captured patch +96 / −28
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 67ada5a..c05cd26 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -14505,7 +14505,7 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
counterparty_node_id: PublicKey, their_features: &InitFeatures, funding_satoshis: u64,
funding_inputs: Vec<FundingTxInput>, user_id: u128, config: &UserConfig,
current_chain_height: u32, outbound_scid_alias: u64, funding_confirmation_target: ConfirmationTarget,
- logger: L,
+ logger: L, trusted_channel_features: Option<TrustedChannelFeatures>,
) -> Result<Self, APIError> {
let channel_keys_id = signer_provider.generate_channel_keys_id(false, user_id);
let holder_signer = signer_provider.derive_channel_signer(channel_keys_id);
@@ -14515,7 +14515,7 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
});
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
- funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false);
+ funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, trusted_channel_features.is_some_and(|f| f.is_0reserve()));
let funding_feerate_sat_per_1000_weight = fee_estimator.bounded_sat_per_1000_weight(funding_confirmation_target);
let funding_tx_locktime = LockTime::from_height(current_chain_height)
@@ -14633,6 +14633,7 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
second_per_commitment_point,
locktime: self.funding_negotiation_context.funding_tx_locktime.to_consensus_u32(),
require_confirmed_inputs: None,
+ disable_channel_reserve: (self.funding.holder_selected_channel_reserve_satoshis == 0).then_some(()),
}
}
@@ -14645,7 +14646,7 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
fee_estimator: &LowerBoundedFeeEstimator<F>, entropy_source: &ES, signer_provider: &SP,
holder_node_id: PublicKey, counterparty_node_id: PublicKey, our_supported_features: &ChannelTypeFeatures,
their_features: &InitFeatures, msg: &msgs::OpenChannelV2,
- user_id: u128, config: &UserConfig, current_chain_height: u32, logger: &L,
+ user_id: u128, config: &UserConfig, current_chain_height: u32, logger: &L, trusted_channel_features: Option<TrustedChannelFeatures>,
) -> Result<Self, ChannelError> {
// TODO(dual_funding): Take these as input once supported
let (our_funding_contribution, our_funding_contribution_sats) = (SignedAmount::ZERO, 0u64);
@@ -14654,9 +14655,9 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
let channel_value_satoshis =
our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis);
let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
- channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false);
+ channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, msg.disable_channel_reserve.is_some());
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
- channel_value_satoshis, msg.common_fields.dust_limit_satoshis, false);
+ channel_value_satoshis, msg.common_fields.dust_limit_satoshis, trusted_channel_features.is_some_and(|f| f.is_0reserve()));
let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?;
@@ -14678,7 +14679,7 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
config,
current_chain_height,
logger,
- None,
+ trusted_channel_features,
our_funding_contribution_sats,
counterparty_pubkeys,
channel_type,
@@ -14797,6 +14798,8 @@ impl<SP: SignerProvider> PendingV2Channel<SP> {
as u64,
second_per_commitment_point,
require_confirmed_inputs: None,
+ disable_channel_reserve: (self.funding.holder_selected_channel_reserve_satoshis == 0)
+ .then_some(()),
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index d896fbe..1b3206a 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -11274,6 +11274,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
&config,
best_block_height,
&self.logger,
+ trusted_channel_features,
)
.map_err(|e| {
let channel_id = open_channel_msg.common_fields.temporary_channel_id;
diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs
index 2908903..d49c573 100644
--- a/lightning/src/ln/msgs.rs
+++ b/lightning/src/ln/msgs.rs
@@ -303,6 +303,8 @@ pub struct OpenChannelV2 {
pub second_per_commitment_point: PublicKey,
/// Optionally, a requirement that only confirmed inputs can be added
pub require_confirmed_inputs: Option<()>,
+ /// Optionally, disables the channel reserve of the receiver
+ pub disable_channel_reserve: Option<()>,
}
/// Contains fields that are both common to [`accept_channel`] and [`accept_channel2`] messages.
@@ -379,6 +381,8 @@ pub struct AcceptChannelV2 {
pub second_per_commitment_point: PublicKey,
/// Optionally, a requirement that only confirmed inputs can be added
pub require_confirmed_inputs: Option<()>,
+ /// Optionally, disables the channel reserve of the receiver
+ pub disable_channel_reserve: Option<()>,
}
/// A [`funding_created`] message to be sent to or received from a peer.
@@ -2960,6 +2964,7 @@ impl Writeable for AcceptChannelV2 {
(0, self.common_fields.shutdown_scriptpubkey.as_ref().map(|s| WithoutLength(s)), option), // Don't encode length twice.
(1, self.common_fields.channel_type, option),
(2, self.require_confirmed_inputs, option),
+ (103, self.disable_channel_reserve, option),
});
Ok(())
}
@@ -2986,10 +2991,12 @@ impl LengthReadable for AcceptChannelV2 {
let mut shutdown_scriptpubkey: Option<ScriptBuf> = None;
let mut channel_type: Option<ChannelTypeFeatures> = None;
let mut require_confirmed_inputs: Option<()> = None;
+ let mut disable_channel_reserve: Option<()> = None;
decode_tlv_stream!(r, {
(0, shutdown_scriptpubkey, (option, encoding: (ScriptBuf, WithoutLength))),
(1, channel_type, option),
(2, require_confirmed_inputs, option),
+ (103, disable_channel_reserve, option),
});
Ok(AcceptChannelV2 {
@@ -3013,6 +3020,7 @@ impl LengthReadable for AcceptChannelV2 {
funding_satoshis,
second_per_commitment_point,
require_confirmed_inputs,
+ disable_channel_reserve,
})
}
}
@@ -3390,6 +3398,7 @@ impl Writeable for OpenChannelV2 {
(0, self.common_fields.shutdown_scriptpubkey.as_ref().map(|s| WithoutLength(s)), option), // Don't encode length twice.
(1, self.common_fields.channel_type, option),
(2, self.require_confirmed_inputs, option),
+ (103, self.disable_channel_reserve, option),
});
Ok(())
}
@@ -3420,10 +3429,12 @@ impl LengthReadable for OpenChannelV2 {
let mut shutdown_scriptpubkey: Option<ScriptBuf> = None;
let mut channel_type: Option<ChannelTypeFeatures> = None;
let mut require_confirmed_inputs: Option<()> = None;
+ let mut disable_channel_reserve: Option<()> = None;
decode_tlv_stream!(r, {
(0, shutdown_scriptpubkey, (option, encoding: (ScriptBuf, WithoutLength))),
(1, channel_type, option),
(2, require_confirmed_inputs, option),
+ (103, disable_channel_reserve, option),
});
Ok(OpenChannelV2 {
common_fields: CommonOpenChannelFields {
@@ -3450,6 +3461,7 @@ impl LengthReadable for OpenChannelV2 {
locktime,
second_per_commitment_point,
require_confirmed_inputs,
+ disable_channel_reserve,
})
}
}
@@ -5187,6 +5199,7 @@ mod tests {
fn do_encoding_open_channelv2(
random_bit: bool, shutdown: bool, incl_chan_type: bool, require_confirmed_inputs: bool,
+ disable_channel_reserve: bool,
) {
let secp_ctx = Secp256k1::new();
let (_, pubkey_1) = get_keys_from!(
@@ -5255,7 +5268,8 @@ mod tests {
funding_feerate_sat_per_1000_weight: 821716,
locktime: 305419896,
second_per_commitment_point: pubkey_7,
- require_confirmed_inputs: if require_confirmed_inputs { Some(()) } else { None },
+ require_confirmed_inputs: require_confirmed_inputs.then_some(()),
+ disable_channel_reserve: disable_channel_reserve.then_some(()),
};
let encoded_value = open_channelv2.encode();
let mut target_value = Vec::new();
@@ -5340,27 +5354,46 @@ mod tests {
if require_confirmed_inputs {
target_value.append(&mut <Vec<u8>>::from_hex("0200").unwrap());
}
+ if disable_channel_reserve {
+ target_value.append(&mut <Vec<u8>>::from_hex("6700").unwrap());
+ }
assert_eq!(encoded_value, target_value);
}
#[test]
fn encoding_open_channelv2() {
- do_encoding_open_channelv2(false, false, false, false);
- do_encoding_open_channelv2(false, false, false, true);
- do_encoding_open_channelv2(false, false, true, false);
- do_encoding_open_channelv2(false, false, true, true);
- do_encoding_open_channelv2(false, true, false, false);
- do_encoding_open_channelv2(false, true, false, true);
- do_encoding_open_channelv2(false, true, true, false);
- do_encoding_open_channelv2(false, true, true, true);
- do_encoding_open_channelv2(true, false, false, false);
- do_encoding_open_channelv2(true, false, false, true);
- do_encoding_open_channelv2(true, false, true, false);
- do_encoding_open_channelv2(true, false, true, true);
- do_encoding_open_channelv2(true, true, false, false);
- do_encoding_open_channelv2(true, true, false, true);
- do_encoding_open_channelv2(true, true, true, false);
- do_encoding_open_channelv2(true, true, true, true);
+ do_encoding_open_channelv2(false, false, false, false, false);
+ do_encoding_open_channelv2(false, false, false, false, true);
+ do_encoding_open_channelv2(false, false, false, true, false);
+ do_encoding_open_channelv2(false, false, false, true, true);
+ do_encoding_open_channelv2(false, false, true, false, false);
+ do_encoding_open_channelv2(false, false, true, false, true);
+ do_encoding_open_channelv2(false, false, true, true, false);
+ do_encoding_open_channelv2(false, false, true, true, true);
+ do_encoding_open_channelv2(false, true, false, false, false);
+ do_encoding_open_channelv2(false, true, false, false, true);
+ do_encoding_open_channelv2(false, true, false, true, false);
+ do_encoding_open_channelv2(false, true, false, true, true);
+ do_encoding_open_channelv2(false, true, true, false, false);
+ do_encoding_open_channelv2(false, true, true, false, true);
+ do_encoding_open_channelv2(false, true, true, true, false);
+ do_encoding_open_channelv2(false, true, true, true, true);
+ do_encoding_open_channelv2(true, false, false, false, false);
+ do_encoding_open_channelv2(true, false, false, false, true);
+ do_encoding_open_channelv2(true, false, false, true, false);
+ do_encoding_open_channelv2(true, false, false, true, true);
+ do_encoding_open_channelv2(true, false, true, false, false);
+ do_encoding_open_channelv2(true, false, true, false, true);
+ do_encoding_open_channelv2(true, false, true, true, false);
+ do_encoding_open_channelv2(true, false, true, true, true);
+ do_encoding_open_channelv2(true, true, false, false, false);
+ do_encoding_open_channelv2(true, true, false, false, true);
+ do_encoding_open_channelv2(true, true, false, true, false);
+ do_encoding_open_channelv2(true, true, false, true, true);
+ do_encoding_open_channelv2(true, true, true, false, false);
+ do_encoding_open_channelv2(true, true, true, false, true);
+ do_encoding_open_channelv2(true, true, true, true, false);
+ do_encoding_open_channelv2(true, true, true, true, true);
}
fn do_encoding_accept_channel(shutdown: bool) {
@@ -5436,7 +5469,10 @@ mod tests {
do_encoding_accept_channel(true);
}
- fn do_encoding_accept_channelv2(shutdown: bool) {
+ fn do_encoding_accept_channelv2(
+ shutdown: bool, incl_chan_type: bool, require_confirmed_inputs: bool,
+ disable_channel_reserve: bool,
+ ) {
let secp_ctx = Secp256k1::new();
let (_, pubkey_1) = get_keys_from!(
"0101010101010101010101010101010101010101010101010101010101010101",
@@ -5492,11 +5528,16 @@ mod tests {
} else {
None
},
- channel_type: None,
+ channel_type: if incl_chan_type {
+ Some(ChannelTypeFeatures::empty())
+ } else {
+ None
+ },
},
funding_satoshis: 1311768467284833366,
second_per_commitment_point: pubkey_7,
- require_confirmed_inputs: None,
+ require_confirmed_inputs: require_confirmed_inputs.then_some(()),
+ disable_channel_reserve: disable_channel_reserve.then_some(()),
};
let encoded_value = accept_channelv2.encode();
let mut target_value =
@@ -5557,13 +5598,36 @@ mod tests {
.unwrap(),
);
}
+ if incl_chan_type {
+ target_value.append(&mut <Vec<u8>>::from_hex("0100").unwrap());
+ }
+ if require_confirmed_inputs {
+ target_value.append(&mut <Vec<u8>>::from_hex("0200").unwrap());
+ }
+ if disable_channel_reserve {
+ target_value.append(&mut <Vec<u8>>::from_hex("6700").unwrap());
+ }
assert_eq!(encoded_value, target_value);
}
#[test]
fn encoding_accept_channelv2() {
- do_encoding_accept_channelv2(false);
- do_encoding_accept_channelv2(true);
+ do_encoding_accept_channelv2(false, false, false, false);
+ do_encoding_accept_channelv2(false, false, false, true);
+ do_encoding_accept_channelv2(false, false, true, false);
+ do_encoding_accept_channelv2(false, false, true, true);
+ do_encoding_accept_channelv2(false, true, false, false);
+ do_encoding_accept_channelv2(false, true, false, true);
+ do_encoding_accept_channelv2(false, true, true, false);
+ do_encoding_accept_channelv2(false, true, true, true);
+ do_encoding_accept_channelv2(true, false, false, false);
+ do_encoding_accept_channelv2(true, false, false, true);
+ do_encoding_accept_channelv2(true, false, true, false);
+ do_encoding_accept_channelv2(true, false, true, true);
+ do_encoding_accept_channelv2(true, true, false, false);
+ do_encoding_accept_channelv2(true, true, false, true);
+ do_encoding_accept_channelv2(true, true, true, false);
+ do_encoding_accept_channelv2(true, true, true, true);
}
#[test]
Why this scored 31/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.