Add inbound and outbound checks for zero reserve channels
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where a payment channel could end up with a commitment transaction that has zero spendable outputs. In Bitcoin, a transaction with no outputs is invalid and cannot be broadcast, which would make it impossible to enforce or recover funds from the channel if something goes wrong. The patch adds checks during channel opening, fee updates, and HTLC handling to ensure that zero-reserve channels always keep at least one valid output.
Review and merge the patch, then run the updated test suite. Operators using zero-reserve channels should upgrade to a release containing this fix to avoid unenforceable channel states.
Security signals we found
Prevents non-broadcastable commitment transactions (zero outputs)
Adds explicit rejection of peer commitment txs with empty output set
Preserves zero-reserve policy across channel value renegotiation
Adds HTLC boundary checks to avoid dusting out all commitment outputs
Includes new/updated unit tests across channel open, payment, fee, and reserve test files
Evidence from the diff
The change prevents construction or acceptance of commitment transactions with an empty output set. It introduces an is_0reserve flag that preserves zero-reserve settings across channel value updates (splicing/RBF), adds a has_output helper in tx_builder.rs to verify that a commitment has at least one output after accounting for fees and dust limits, and rejects commitment transactions from a peer that have zero outputs. It also adjusts outbound HTLC minimum and available capacity boundaries so that a newly added dust HTLC cannot push both parties’ balances below the dust limit simultaneously. The fix applies to both inbound and outbound zero-reserve channels.
Changed components
lightning/src/ln/channel.rslightning/src/sign/tx_builder.rslightning/src/ln/channel_open_tests.rslightning/src/ln/functional_tests.rslightning/src/ln/htlc_reserve_unit_tests.rslightning/src/ln/payment_tests.rslightning/src/ln/update_fee_tests.rsInspect captured patch +247 / −53
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index c2b7e06..c8c93ee 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -2781,11 +2781,18 @@ impl FundingScope {
.funding_pubkey = counterparty_funding_pubkey;
// 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, MIN_CHAN_DUST_LIMIT_SATOSHIS);
+ let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
+ post_channel_value,
+ MIN_CHAN_DUST_LIMIT_SATOSHIS,
+ prev_funding
+ .counterparty_selected_channel_reserve_satoshis
+ .expect("counterparty reserve is set")
+ == 0,
+ );
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
post_channel_value,
context.counterparty_dust_limit_satoshis,
+ prev_funding.holder_selected_channel_reserve_satoshis == 0,
);
Self {
@@ -5155,27 +5162,27 @@ impl<SP: SignerProvider> ChannelContext<SP> {
));
}
- if funding.is_outbound() {
- let (local_stats, _local_htlcs) = self
- .get_next_local_commitment_stats(
- funding,
- Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }),
- include_counterparty_unknown_htlcs,
- fee_spike_buffer_htlc,
- self.feerate_per_kw,
- dust_exposure_limiting_feerate,
- )
- .map_err(|()| {
- ChannelError::close(String::from("Balance exhausted on local commitment"))
- })?;
- // Check that they won't violate our local required channel reserve by adding this HTLC.
- if local_stats.commitment_stats.holder_balance_msat
+ let (local_stats, _local_htlcs) = self
+ .get_next_local_commitment_stats(
+ funding,
+ Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat }),
+ include_counterparty_unknown_htlcs,
+ fee_spike_buffer_htlc,
+ self.feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ )
+ .map_err(|()| {
+ ChannelError::close(String::from("Balance exhausted on local commitment"))
+ })?;
+
+ // Check that they won't violate our local required channel reserve by adding this HTLC.
+ if funding.is_outbound()
+ && local_stats.commitment_stats.holder_balance_msat
< funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000
- {
- return Err(ChannelError::close(
- "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned()
- ));
- }
+ {
+ return Err(ChannelError::close(
+ "Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned()
+ ));
}
Ok(())
@@ -5269,6 +5276,12 @@ impl<SP: SignerProvider> ChannelContext<SP> {
let commitment_txid = {
let trusted_tx = commitment_data.tx.trust();
let bitcoin_tx = trusted_tx.built_transaction();
+ if bitcoin_tx.transaction.output.is_empty() {
+ return Err(ChannelError::close(
+ "Commitment tx from peer has 0 outputs".to_owned(),
+ ));
+ }
+
let sighash = bitcoin_tx.get_sighash_all(&funding_script, funding.get_value_satoshis());
log_trace!(logger, "Checking commitment tx signature {} by key {} against tx {} (sighash {}) with redeemscript {} in channel {}",
@@ -6395,7 +6408,11 @@ fn get_holder_max_htlc_value_in_flight_msat(
/// the counterparty.
pub(crate) fn get_holder_selected_channel_reserve_satoshis(
channel_value_satoshis: u64, their_dust_limit_satoshis: u64, config: &UserConfig,
+ is_0reserve: bool,
) -> u64 {
+ if is_0reserve {
+ return 0;
+ }
let counterparty_chan_reserve_prop_mil =
config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64;
let calculated_reserve =
@@ -6423,7 +6440,12 @@ pub(crate) fn get_legacy_default_holder_selected_channel_reserve_satoshis(
///
/// This is used both for outbound and inbound channels and has lower bound
/// of `dust_limit_satoshis`.
-fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satoshis: u64) -> u64 {
+fn get_v2_channel_reserve_satoshis(
+ channel_value_satoshis: u64, dust_limit_satoshis: u64, is_0reserve: bool,
+) -> u64 {
+ if is_0reserve {
+ return 0;
+ }
// Fixed at 1% of channel value by spec.
let (q, _) = channel_value_satoshis.overflowing_div(100);
cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis))
@@ -12363,12 +12385,19 @@ where
our_funding_contribution.to_sat(),
their_funding_contribution.to_sat(),
);
- let counterparty_selected_channel_reserve = Amount::from_sat(
- get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS),
- );
+ let counterparty_selected_channel_reserve =
+ Amount::from_sat(get_v2_channel_reserve_satoshis(
+ post_channel_value,
+ MIN_CHAN_DUST_LIMIT_SATOSHIS,
+ self.funding
+ .counterparty_selected_channel_reserve_satoshis
+ .expect("counterparty reserve is set")
+ == 0,
+ ));
let holder_selected_channel_reserve = Amount::from_sat(get_v2_channel_reserve_satoshis(
post_channel_value,
self.context.counterparty_dust_limit_satoshis,
+ self.funding.holder_selected_channel_reserve_satoshis == 0,
));
// We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve
@@ -13846,7 +13875,8 @@ impl<SP: SignerProvider> OutboundV1Channel<SP> {
let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(
channel_value_satoshis,
their_dust_limit_satoshis,
- config
+ config,
+ false,
);
if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS {
// Protocol level safety check in place, although it should never happen because
@@ -14231,7 +14261,8 @@ impl<SP: SignerProvider> InboundV1Channel<SP> {
let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(
msg.common_fields.funding_satoshis,
msg.common_fields.dust_limit_satoshis,
- config
+ config,
+ false,
);
let counterparty_pubkeys = ChannelPublicKeys {
funding_pubkey: msg.common_fields.funding_pubkey,
@@ -14484,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);
+ funding_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false);
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)
@@ -14623,9 +14654,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);
+ channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS, false);
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
- channel_value_satoshis, msg.common_fields.dust_limit_satoshis);
+ channel_value_satoshis, msg.common_fields.dust_limit_satoshis, false);
let channel_type = channel_type_from_open_channel(&msg.common_fields, our_supported_features)?;
diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs
index e13343a..1de51bf 100644
--- a/lightning/src/ln/channel_open_tests.rs
+++ b/lightning/src/ln/channel_open_tests.rs
@@ -470,7 +470,7 @@ pub fn test_insane_channel_opens() {
// funding satoshis
let channel_value_sat = 31337; // same as funding satoshis
let channel_reserve_satoshis =
- get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg);
+ get_holder_selected_channel_reserve_satoshis(channel_value_sat, 0, &legacy_cfg, false);
let push_msat = (channel_value_sat - channel_reserve_satoshis) * 1000;
// Have node0 initiate a channel to node1 with aforementioned parameters
diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs
index a325247..12b6aab 100644
--- a/lightning/src/ln/functional_tests.rs
+++ b/lightning/src/ln/functional_tests.rs
@@ -414,7 +414,7 @@ pub fn test_inbound_outbound_capacity_is_not_zero() {
assert_eq!(channels0.len(), 1);
assert_eq!(channels1.len(), 1);
- let reserve = get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config);
+ let reserve = get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false);
assert_eq!(channels0[0].inbound_capacity_msat, 95000000 - reserve * 1000);
assert_eq!(channels1[0].outbound_capacity_msat, 95000000 - reserve * 1000);
diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs
index 3069783..862d947 100644
--- a/lightning/src/ln/htlc_reserve_unit_tests.rs
+++ b/lightning/src/ln/htlc_reserve_unit_tests.rs
@@ -51,7 +51,8 @@ fn do_test_counterparty_no_reserve(send_from_initiator: bool) {
push_amt -= feerate_per_kw as u64
* (commitment_tx_base_weight(&channel_type_features) + 4 * COMMITMENT_TX_WEIGHT_PER_HTLC)
/ 1000 * 1000;
- push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000;
+ push_amt -=
+ get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000;
let push = if send_from_initiator { 0 } else { push_amt };
let temp_channel_id =
@@ -997,7 +998,8 @@ pub fn test_chan_reserve_violation_outbound_htlc_inbound_chan() {
&channel_type_features,
);
- push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000;
+ push_amt -=
+ get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000;
let _ = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt);
@@ -1041,7 +1043,8 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() {
MIN_AFFORDABLE_HTLC_COUNT as u64,
&channel_type_features,
);
- push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000;
+ push_amt -=
+ get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000;
let chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt);
// Send four HTLCs to cover the initial push_msat buffer we're required to include
@@ -1119,7 +1122,8 @@ pub fn test_chan_reserve_dust_inbound_htlcs_outbound_chan() {
MIN_AFFORDABLE_HTLC_COUNT as u64,
&channel_type_features,
);
- push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000;
+ push_amt -=
+ get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000;
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100000, push_amt);
let (htlc_success_tx_fee_sat, _) =
diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs
index 7d198d2..be52459 100644
--- a/lightning/src/ln/payment_tests.rs
+++ b/lightning/src/ln/payment_tests.rs
@@ -4985,7 +4985,7 @@ fn test_htlc_forward_considers_anchor_outputs_value() {
create_announced_chan_between_nodes_with_value(&nodes, 1, 2, CHAN_AMT, PUSH_MSAT);
let channel_reserve_msat =
- get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config) * 1000;
+ get_holder_selected_channel_reserve_satoshis(CHAN_AMT, 0, &config, false) * 1000;
let commitment_fee_msat = chan_utils::commit_tx_fee_sat(
*nodes[1].fee_estimator.sat_per_kw.lock().unwrap(),
2,
diff --git a/lightning/src/ln/update_fee_tests.rs b/lightning/src/ln/update_fee_tests.rs
index 9c309b5..fc80059 100644
--- a/lightning/src/ln/update_fee_tests.rs
+++ b/lightning/src/ln/update_fee_tests.rs
@@ -410,7 +410,7 @@ pub fn do_test_update_fee_that_funder_cannot_afford(channel_type_features: Chann
let channel_id = chan.2;
let secp_ctx = Secp256k1::new();
let bs_channel_reserve_sats =
- get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg);
+ get_holder_selected_channel_reserve_satoshis(channel_value, 0, &cfg, false);
let (anchor_outputs_value_sats, outputs_num_no_htlcs) =
if channel_type_features.supports_anchors_zero_fee_htlc_tx() {
(ANCHOR_OUTPUT_VALUE_SATOSHI * 2, 4)
@@ -886,7 +886,8 @@ pub fn test_chan_init_feerate_unaffordability() {
// During open, we don't have a "counterparty channel reserve" to check against, so that
// requirement only comes into play on the open_channel handling side.
- push_amt -= get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config) * 1000;
+ push_amt -=
+ get_holder_selected_channel_reserve_satoshis(100_000, 0, &default_config, false) * 1000;
nodes[0].node.create_channel(node_b_id, 100_000, push_amt, 42, None, None).unwrap();
let mut open_channel_msg =
get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id);
diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs
index 4273b62..ca61b27 100644
--- a/lightning/src/sign/tx_builder.rs
+++ b/lightning/src/sign/tx_builder.rs
@@ -206,6 +206,35 @@ fn get_dust_exposure_stats(
}
}
+fn has_output(
+ is_outbound_from_holder: bool, holder_balance_before_fee_msat: u64,
+ counterparty_balance_before_fee_msat: u64, feerate_per_kw: u32, nondust_htlc_count: usize,
+ broadcaster_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures,
+) -> bool {
+ let commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, nondust_htlc_count, channel_type);
+
+ let (real_holder_balance_msat, real_counterparty_balance_msat) = if is_outbound_from_holder {
+ (
+ holder_balance_before_fee_msat.saturating_sub(commit_tx_fee_sat * 1000),
+ counterparty_balance_before_fee_msat,
+ )
+ } else {
+ (
+ holder_balance_before_fee_msat,
+ counterparty_balance_before_fee_msat.saturating_sub(commit_tx_fee_sat * 1000),
+ )
+ };
+
+ // Make sure the commitment transaction has at least one output
+ let dust_limit_msat = broadcaster_dust_limit_satoshis * 1000;
+ let has_no_output = real_holder_balance_msat < dust_limit_msat
+ && real_counterparty_balance_msat < dust_limit_msat
+ && nondust_htlc_count == 0
+ // 0FC channels always have a P2A output on the commitment transaction
+ && !channel_type.supports_anchor_zero_fee_commitments();
+ !has_no_output
+}
+
fn get_next_commitment_stats(
local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64,
value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection],
@@ -250,6 +279,15 @@ fn get_next_commitment_stats(
channel_type,
)?;
+ let (dust_exposure_msat, _extra_accepted_htlc_dust_exposure_msat) = get_dust_exposure_stats(
+ local,
+ next_commitment_htlcs,
+ feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ broadcaster_dust_limit_satoshis,
+ channel_type,
+ );
+
// Calculate fees on commitment transaction
let nondust_htlc_count = next_commitment_htlcs
.iter()
@@ -257,18 +295,27 @@ fn get_next_commitment_stats(
!htlc.is_dust(local, feerate_per_kw, broadcaster_dust_limit_satoshis, channel_type)
})
.count();
- let commit_tx_fee_sat = commit_tx_fee_sat(
+
+ // For zero-reserve channels, we check two things independently:
+ // 1) Given the current set of HTLCs and feerate, does the commitment have at least one output ?
+ if !has_output(
+ is_outbound_from_holder,
+ holder_balance_before_fee_msat,
+ counterparty_balance_before_fee_msat,
feerate_per_kw,
- nondust_htlc_count + addl_nondust_htlc_count,
+ nondust_htlc_count,
+ broadcaster_dust_limit_satoshis,
channel_type,
- );
+ ) {
+ return Err(());
+ }
- let (dust_exposure_msat, _extra_accepted_htlc_dust_exposure_msat) = get_dust_exposure_stats(
- local,
- next_commitment_htlcs,
+ // 2) Now including any additional non-dust HTLCs (usually the fee spike buffer HTLC), does the funder cover
+ // this bigger transaction fee ? The funder can dip below their dust limit to cover this case, as the
+ // commitment will have at least one output: the non-dust fee spike buffer HTLC offered by the counterparty.
+ let commit_tx_fee_sat = commit_tx_fee_sat(
feerate_per_kw,
- dust_exposure_limiting_feerate,
- broadcaster_dust_limit_satoshis,
+ nondust_htlc_count + addl_nondust_htlc_count,
channel_type,
);
@@ -316,7 +363,7 @@ fn get_available_balances(
if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 };
// Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop
- let local_feerate = feerate_per_kw
+ let spiked_feerate = feerate_per_kw
* if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() {
crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32
} else {
@@ -328,19 +375,19 @@ fn get_available_balances(
.filter(|htlc| {
!htlc.is_dust(
true,
- local_feerate,
+ spiked_feerate,
channel_constraints.holder_dust_limit_satoshis,
channel_type,
)
})
.count();
let local_max_commit_tx_fee_sat = commit_tx_fee_sat(
- local_feerate,
+ spiked_feerate,
local_nondust_htlc_count + fee_spike_buffer_htlc + 1,
channel_type,
);
let local_min_commit_tx_fee_sat = commit_tx_fee_sat(
- local_feerate,
+ spiked_feerate,
local_nondust_htlc_count + fee_spike_buffer_htlc,
channel_type,
);
@@ -512,7 +559,49 @@ fn get_available_balances(
available_capacity_msat = 0;
}
- #[allow(deprecated)] // TODO: Remove once balance_msat is removed
+ // Now adjust our min and max size HTLC to make sure both the local and the remote commitments still have
+ // at least one output at the spiked feerate.
+
+ let remote_nondust_htlc_count = pending_htlcs
+ .iter()
+ .filter(|htlc| {
+ !htlc.is_dust(
+ false,
+ spiked_feerate,
+ channel_constraints.counterparty_dust_limit_satoshis,
+ channel_type,
+ )
+ })
+ .count();
+
+ let (next_outbound_htlc_minimum_msat, available_capacity_msat) =
+ adjust_boundaries_if_max_dust_htlc_produces_no_output(
+ true,
+ is_outbound_from_holder,
+ local_balance_before_fee_msat,
+ remote_balance_before_fee_msat,
+ local_nondust_htlc_count,
+ spiked_feerate,
+ channel_constraints.holder_dust_limit_satoshis,
+ channel_type,
+ next_outbound_htlc_minimum_msat,
+ available_capacity_msat,
+ );
+
+ let (next_outbound_htlc_minimum_msat, available_capacity_msat) =
+ adjust_boundaries_if_max_dust_htlc_produces_no_output(
+ false,
+ is_outbound_from_holder,
+ local_balance_before_fee_msat,
+ remote_balance_before_fee_msat,
+ remote_nondust_htlc_count,
+ spiked_feerate,
+ channel_constraints.counterparty_dust_limit_satoshis,
+ channel_type,
+ next_outbound_htlc_minimum_msat,
+ available_capacity_msat,
+ );
+
crate::ln::channel::AvailableBalances {
inbound_capacity_msat: remote_balance_before_fee_msat
.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000),
@@ -522,6 +611,75 @@ fn get_available_balances(
}
}
+fn adjust_boundaries_if_max_dust_htlc_produces_no_output(
+ local: bool, is_outbound_from_holder: bool, holder_balance_before_fee_msat: u64,
+ counterparty_balance_before_fee_msat: u64, nondust_htlc_count: usize, spiked_feerate: u32,
+ dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures,
+ next_outbound_htlc_minimum_msat: u64, available_capacity_msat: u64,
+) -> (u64, u64) {
+ // First, determine the biggest dust HTLC we could send
+ let (htlc_success_tx_fee_sat, htlc_timeout_tx_fee_sat) =
+ second_stage_tx_fees_sat(channel_type, spiked_feerate);
+ let min_nondust_htlc_sat =
+ dust_limit_satoshis + if local { htlc_timeout_tx_fee_sat } else { htlc_success_tx_fee_sat };
+ let max_dust_htlc_msat = (min_nondust_htlc_sat.saturating_mul(1000)).saturating_sub(1);
+
+ // If this dust HTLC produces no outputs, then we have to say something! It is now possible to produce a
+ // commitment with no outputs.
+ if !has_output(
+ is_outbound_from_holder,
+ holder_balance_before_fee_msat.saturating_sub(max_dust_htlc_msat),
+ counterparty_balance_before_fee_msat,
+ spiked_feerate,
+ nondust_htlc_count,
+ dust_limit_satoshis,
+ channel_type,
+ ) {
+ // If we are allowed to send non-dust HTLCs, set the min HTLC to the smallest non-dust HTLC...
+ if available_capacity_msat >= min_nondust_htlc_sat.saturating_mul(1000) {
+ (
+ cmp::max(
+ min_nondust_htlc_sat.saturating_mul(1000),
+ next_outbound_htlc_minimum_msat,
+ ),
+ available_capacity_msat,
+ )
+ // Otherwise, set the max HTLC to the biggest that still leaves our main balance output untrimmed.
+ // Note that this will be a dust HTLC.
+ } else {
+ // Remember we've got no non-dust HTLCs on the commitment here
+ let current_spiked_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 0, channel_type);
+ let spike_buffer_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 1, channel_type);
+ // In case we are the funder, we must cover the greater of
+ // 1) The dust_limit_satoshis plus the fee of the existing commitment at the spiked feerate.
+ // 2) The fee of the commitment with an additional non-dust HTLC, aka the fee spike buffer HTLC.
+ // In this case we don't mind the holder balance output dropping below the dust limit, as
+ // this additional non-dust HTLC will create the single remaining output on the commitment.
+ let min_balance_msat = if is_outbound_from_holder {
+ cmp::max(dust_limit_satoshis + current_spiked_tx_fee_sat, spike_buffer_tx_fee_sat)
+ * 1000
+ // In case we are the fundee, we can send dust HTLCs as long as our own balance output
+ // remains above the dust limit.
+ } else {
+ dust_limit_satoshis * 1000
+ };
+ (
+ next_outbound_htlc_minimum_msat,
+ // We make no assumptions about the size of `available_capacity_msat` passed to this
+ // function, we only care that the new `available_capacity_msat` is under
+ // `holder_balance_before_fee_msat - min_balance_msat`
+ cmp::min(
+ holder_balance_before_fee_msat.saturating_sub(min_balance_msat),
+ available_capacity_msat,
+ ),
+ )
+ }
+ // Otherwise, it is impossible to produce no outputs with this upcoming HTLC add, so we stay quiet
+ } else {
+ (next_outbound_htlc_minimum_msat, available_capacity_msat)
+ }
+}
+
pub(crate) trait TxBuilder {
fn get_channel_stats(
&self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64,
Why this scored 70/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.