Add `ChannelManager::create_channel_to_trusted_peer_0reserve`
What changed, and why it matters
This commit adds a new, clearly labeled API for opening Lightning channels to a trusted peer where the counterparty is allowed to keep zero reserve funds. The reserve normally prevents a peer from spending their entire balance, which protects against a specific cheap attack. The new method removes that protection intentionally, so it is only safe with a trusted counterparty. The commit itself documents this risk and does not appear to be a hidden vulnerability.
Treat this as a feature addition with inherent security trade-offs rather than a vulnerability. Users should be warned (via documentation and release notes) to only call `create_channel_to_trusted_peer_0reserve` with mutually trusted peers, because removing the reserve eliminates economic deterrence against revoked commitment broadcasts. No code revert is indicated by the diff evidence.
Security signals we found
New API explicitly enables zero channel reserve for the counterparty
Documentation acknowledges counterparty can force-close with revoked commitment 'for free'
Validation relaxed: dust-limit vs. reserve check skipped when reserve is zero
Minimum channel reserve safety check bypassed only under the new trusted flag
Existing default channel creation path remains unchanged
Evidence from the diff
The patch introduces ChannelManager::create_channel_to_trusted_peer_0reserve, which passes TrustedChannelFeatures::ZeroReserve into a new internal create_channel_internal path. When this flag is set, OutboundV1Channel::new sets holder_selected_channel_reserve_satoshis to 0 and skips the usual minimum-dust-limit safety check. A receiving-side validation in ChannelContext is also relaxed so that a dust limit larger than the (now zero) holder reserve no longer triggers a channel close. The documentation explicitly warns that the counterparty can force-close with a revoked commitment ‘for free’. Existing create_channel behavior is unchanged (it passes None for trusted features).
Changed components
lightning/src/ln/channelmanager.rslightning/src/ln/channel.rsOutboundV1Channel::newChannelContext channel acceptance validationInspect captured patch +77 / −19
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 0b6d173..a0b3bb1 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -4510,7 +4510,7 @@ impl<SP: SignerProvider> ChannelContext<SP> {
if channel_reserve_satoshis > funding.get_value_satoshis() {
return Err(ChannelError::close(format!("Bogus channel_reserve_satoshis ({}). Must not be greater than ({})", channel_reserve_satoshis, funding.get_value_satoshis())));
}
- if common_fields.dust_limit_satoshis > funding.holder_selected_channel_reserve_satoshis {
+ if common_fields.dust_limit_satoshis > funding.holder_selected_channel_reserve_satoshis && funding.holder_selected_channel_reserve_satoshis != 0 {
return Err(ChannelError::close(format!("Dust limit ({}) is bigger than our channel reserve ({})", common_fields.dust_limit_satoshis, funding.holder_selected_channel_reserve_satoshis)));
}
if channel_reserve_satoshis > funding.get_value_satoshis() - funding.holder_selected_channel_reserve_satoshis {
@@ -13866,23 +13866,24 @@ impl<SP: SignerProvider> OutboundV1Channel<SP> {
pub fn new<ES: EntropySource, F: FeeEstimator, L: Logger>(
fee_estimator: &LowerBoundedFeeEstimator<F>, entropy_source: &ES, signer_provider: &SP, counterparty_node_id: PublicKey, their_features: &InitFeatures,
channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig, current_chain_height: u32,
- outbound_scid_alias: u64, temporary_channel_id: Option<ChannelId>, logger: L
+ outbound_scid_alias: u64, temporary_channel_id: Option<ChannelId>, logger: L, trusted_channel_features: Option<TrustedChannelFeatures>,
) -> Result<OutboundV1Channel<SP>, APIError> {
// At this point, we do not know what `dust_limit_satoshis` the counterparty will want for themselves,
// so we set the channel reserve with no regard for their dust limit, and fail the channel if they want
// a dust limit higher than our selected reserve.
let their_dust_limit_satoshis = 0;
+ let is_0reserve = trusted_channel_features.is_some_and(|f| f.is_0reserve());
let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(
channel_value_satoshis,
their_dust_limit_satoshis,
config,
- false,
+ is_0reserve,
);
- if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS {
+ 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`
return Err(APIError::APIMisuseError { err: format!("Holder selected channel reserve below \
- implemention limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) });
+ implementation limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) });
}
let channel_keys_id = signer_provider.generate_channel_keys_id(false, user_id);
@@ -16470,6 +16471,7 @@ mod tests {
42,
None,
&logger,
+ None,
);
match res {
Err(APIError::IncompatibleShutdownScript { script }) => {
@@ -16496,7 +16498,7 @@ mod tests {
let node_a_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
let config = UserConfig::default();
- let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&bounded_fee_estimator, &&keys_provider, &&keys_provider, node_a_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap();
+ let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&bounded_fee_estimator, &&keys_provider, &&keys_provider, node_a_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger, None).unwrap();
// Now change the fee so we can check that the fee in the open_channel message is the
// same as the old fee.
@@ -16526,7 +16528,7 @@ mod tests {
let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
let mut config = UserConfig::default();
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
- let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap();
+ let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger, None).unwrap();
// Create Node B's channel by receiving Node A's open_channel message
// Make sure A's dust limit is as we expect.
@@ -16617,7 +16619,7 @@ mod tests {
let node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
let mut config = UserConfig::default();
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
- let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap();
+ let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger, None).unwrap();
chan.context.counterparty_max_htlc_value_in_flight_msat = 1_000_000_000;
let commitment_tx_fee_0_htlcs = commit_tx_fee_sat(chan.context.feerate_per_kw, 0, chan.funding.get_channel_type()) * 1000;
@@ -16672,7 +16674,7 @@ mod tests {
// Create Node A's channel pointing to Node B's pubkey
let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
let config = UserConfig::default();
- let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap();
+ let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger, None).unwrap();
// Create Node B's channel by receiving Node A's open_channel message
let open_channel_msg = node_a_chan.get_open_channel(chain_hash, &&logger).unwrap();
@@ -16738,12 +16740,12 @@ mod tests {
// Test that `OutboundV1Channel::new` creates a channel with the correct value for
// `holder_max_htlc_value_in_flight_msat`, when configured with a valid percentage value,
// which is set to the lower bound + 1 (2%) of the `channel_value`.
- let mut chan_1 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_2_percent), 10000000, 100000, 42, &config_2_percent, 0, 42, None, &logger).unwrap();
+ let mut chan_1 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_2_percent), 10000000, 100000, 42, &config_2_percent, 0, 42, None, &logger, None).unwrap();
let chan_1_value_msat = chan_1.funding.get_value_satoshis() * 1000;
assert_eq!(chan_1.context.holder_max_htlc_value_in_flight_msat, (chan_1_value_msat as f64 * 0.02) as u64);
// Test with the upper bound - 1 of valid values (99%).
- let chan_2 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_99_percent), 10000000, 100000, 42, &config_99_percent, 0, 42, None, &logger).unwrap();
+ let chan_2 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_99_percent), 10000000, 100000, 42, &config_99_percent, 0, 42, None, &logger, None).unwrap();
let chan_2_value_msat = chan_2.funding.get_value_satoshis() * 1000;
assert_eq!(chan_2.context.holder_max_htlc_value_in_flight_msat, (chan_2_value_msat as f64 * 0.99) as u64);
@@ -16763,14 +16765,14 @@ mod tests {
// Test that `OutboundV1Channel::new` uses the lower bound of the configurable percentage values (1%)
// if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a value less than 1.
- let chan_5 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_0_percent), 10000000, 100000, 42, &config_0_percent, 0, 42, None, &logger).unwrap();
+ let chan_5 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_0_percent), 10000000, 100000, 42, &config_0_percent, 0, 42, None, &logger, None).unwrap();
let chan_5_value_msat = chan_5.funding.get_value_satoshis() * 1000;
assert_eq!(chan_5.context.holder_max_htlc_value_in_flight_msat, (chan_5_value_msat as f64 * 0.01) as u64);
// Test that `OutboundV1Channel::new` uses the upper bound of the configurable percentage values
// (100%) if `max_inbound_htlc_value_in_flight_percent_of_channel` is set to a larger value
// than 100.
- let chan_6 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_101_percent), 10000000, 100000, 42, &config_101_percent, 0, 42, None, &logger).unwrap();
+ let chan_6 = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, outbound_node_id, &channelmanager::provided_init_features(&config_101_percent), 10000000, 100000, 42, &config_101_percent, 0, 42, None, &logger, None).unwrap();
let chan_6_value_msat = chan_6.funding.get_value_satoshis() * 1000;
assert_eq!(chan_6.context.holder_max_htlc_value_in_flight_msat, chan_6_value_msat);
@@ -16826,7 +16828,7 @@ mod tests {
let mut outbound_node_config = UserConfig::default();
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 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, None).unwrap();
let expected_outbound_selected_chan_reserve = cmp::max(MIN_THEIR_CHAN_RESERVE_SATOSHIS, (chan.funding.get_value_satoshis() as f64 * outbound_selected_channel_reserve_perc) as u64);
assert_eq!(chan.funding.holder_selected_channel_reserve_satoshis, expected_outbound_selected_chan_reserve);
@@ -16865,7 +16867,7 @@ mod tests {
// Create Node A's channel pointing to Node B's pubkey
let node_b_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
let config = UserConfig::default();
- let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger).unwrap();
+ let mut node_a_chan = OutboundV1Channel::<&TestKeysInterface>::new(&feeest, &&keys_provider, &&keys_provider, node_b_node_id, &channelmanager::provided_init_features(&config), 10000000, 100000, 42, &config, 0, 42, None, &logger, None).unwrap();
// Create Node B's channel by receiving Node A's open_channel message
// Make sure A's dust limit is as we expect.
@@ -16957,6 +16959,7 @@ mod tests {
42,
None,
&logger,
+ None,
)
.unwrap();
let open_channel_msg = &outbound_chan
@@ -17313,6 +17316,7 @@ mod tests {
42,
None,
&*logger,
+ None,
)
.unwrap(); // Nothing uses their network key in this test
chan.context.holder_dust_limit_satoshis = 546;
@@ -18037,6 +18041,7 @@ mod tests {
0,
None,
&*logger,
+ None,
)
.unwrap();
@@ -18612,7 +18617,8 @@ mod tests {
0,
42,
None,
- &logger
+ &logger,
+ None,
).unwrap();
let open_channel_msg = node_a_chan.get_open_channel(ChainHash::using_genesis_block(network), &&logger).unwrap();
diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs
index 9645d3c..d28d157 100644
--- a/lightning/src/ln/channel_open_tests.rs
+++ b/lightning/src/ln/channel_open_tests.rs
@@ -939,6 +939,7 @@ pub fn test_user_configurable_csv_delay() {
42,
None,
&logger,
+ None,
) {
match error {
APIError::APIMisuseError { err } => {
diff --git a/lightning/src/ln/channel_type_tests.rs b/lightning/src/ln/channel_type_tests.rs
index dc58655..77caa8a 100644
--- a/lightning/src/ln/channel_type_tests.rs
+++ b/lightning/src/ln/channel_type_tests.rs
@@ -144,6 +144,7 @@ fn test_zero_conf_channel_type_support() {
42,
None,
&logger,
+ None,
)
.unwrap();
@@ -244,6 +245,7 @@ fn do_test_supports_channel_type(config: UserConfig, expected_channel_type: Chan
42,
None,
&logger,
+ None,
)
.unwrap();
assert_eq!(
@@ -265,6 +267,7 @@ fn do_test_supports_channel_type(config: UserConfig, expected_channel_type: Chan
42,
None,
&logger,
+ None,
)
.unwrap();
@@ -330,6 +333,7 @@ fn test_rejects_if_channel_type_not_set() {
42,
None,
&logger,
+ None,
)
.unwrap();
@@ -416,6 +420,7 @@ fn test_rejects_if_channel_type_differ() {
42,
None,
&logger,
+ None,
)
.unwrap();
@@ -499,6 +504,7 @@ fn test_rejects_simple_anchors_channel_type() {
42,
None,
&logger,
+ None,
)
.unwrap();
@@ -540,6 +546,7 @@ fn test_rejects_simple_anchors_channel_type() {
42,
None,
&logger,
+ None,
)
.unwrap();
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index d8302ee..d896fbe 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -3789,8 +3789,52 @@ impl<
/// [`Event::FundingGenerationReady::user_channel_id`]: events::Event::FundingGenerationReady::user_channel_id
/// [`Event::FundingGenerationReady::temporary_channel_id`]: events::Event::FundingGenerationReady::temporary_channel_id
/// [`Event::ChannelClosed::channel_id`]: events::Event::ChannelClosed::channel_id
- #[rustfmt::skip]
- pub fn create_channel(&self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64, user_channel_id: u128, temporary_channel_id: Option<ChannelId>, override_config: Option<UserConfig>) -> Result<ChannelId, APIError> {
+ pub fn create_channel(
+ &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64,
+ user_channel_id: u128, temporary_channel_id: Option<ChannelId>,
+ override_config: Option<UserConfig>,
+ ) -> Result<ChannelId, APIError> {
+ self.create_channel_internal(
+ their_network_key,
+ channel_value_satoshis,
+ push_msat,
+ user_channel_id,
+ temporary_channel_id,
+ override_config,
+ None,
+ )
+ }
+
+ /// Creates a new outbound channel to the given remote node and with the given value.
+ ///
+ /// The only difference between this method and [`ChannelManager::create_channel`] is that this method sets
+ /// the reserve the counterparty must keep at all times in the channel to zero. This allows the counterparty to
+ /// spend their entire channel balance, and attempt to force-close the channel with a revoked commitment
+ /// transaction *for free*.
+ ///
+ /// Note that there is no guarantee that the counterparty accepts such a channel.
+ pub fn create_channel_to_trusted_peer_0reserve(
+ &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64,
+ user_channel_id: u128, temporary_channel_id: Option<ChannelId>,
+ override_config: Option<UserConfig>,
+ ) -> Result<ChannelId, APIError> {
+ self.create_channel_internal(
+ their_network_key,
+ channel_value_satoshis,
+ push_msat,
+ user_channel_id,
+ temporary_channel_id,
+ override_config,
+ Some(TrustedChannelFeatures::ZeroReserve),
+ )
+ }
+
+ fn create_channel_internal(
+ &self, their_network_key: PublicKey, channel_value_satoshis: u64, push_msat: u64,
+ user_channel_id: u128, temporary_channel_id: Option<ChannelId>,
+ override_config: Option<UserConfig>,
+ trusted_channel_features: Option<TrustedChannelFeatures>,
+ ) -> Result<ChannelId, APIError> {
if channel_value_satoshis < 1000 {
return Err(APIError::APIMisuseError { err: format!("Channel value must be at least 1000 satoshis. It was {}", channel_value_satoshis) });
}
@@ -3826,7 +3870,7 @@ impl<
};
match OutboundV1Channel::new(&self.fee_estimator, &self.entropy_source, &self.signer_provider, their_network_key,
their_features, channel_value_satoshis, push_msat, user_channel_id, config,
- self.best_block.read().unwrap().height, outbound_scid_alias, temporary_channel_id, &self.logger)
+ self.best_block.read().unwrap().height, outbound_scid_alias, temporary_channel_id, &self.logger, trusted_channel_features)
{
Ok(res) => res,
Err(e) => {
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.