Merge PR 'Ban non-dust HTLCs if dust limit would be breached' (#4890)
What changed, and why it matters
This change tightens how Lightning payment limits are calculated when transaction fees are high. Previously, the code could allow a non-dust HTLC (a payment large enough to normally appear on-chain) to be offered even when the combined dust-exposure risk pushed the channel past its safety limit. The patch lowers the allowed outbound amount by one millisatoshi in that edge case so the HTLC is treated as dust and rejected, preventing a situation where a miner-fee spike could make an HTLC uneconomical to claim on-chain. The practical effect is a small, defensive correction to fee-based channel safety logic.
Review the full PR #4890 and any related follow-ups to confirm the change covers all channel types and edge cases. Run the updated functional tests and consider adding adversarial test cases where a peer attempts to force an HTLC just above the corrected limit during a feerate spike. Monitor for subsequent commits that extend the same logic to other HTLC directions or commitment transaction variants.
Security signals we found
Dust-exposure limiting logic changed in HTLC availability calculation
Sub-1-msat adjustment to force non-dust HTLCs below dust threshold when fee risk is high
Functional tests rewritten to assert on dust-exposure-derived outbound limit
No explicit CVE, advisory, or security-impact statement in commit or supplied references
Evidence from the diff
In lightning/src/sign/tx_builder.rs::adjust_min_max_htlc_for_dust_exposure, when extra_htlc_remote_dust_exposure exceeds max_dust_htlc_exposure_msat, the code now caps available_capacity_msat to buffer_dust_limit_success_sat * 1000 - 1 instead of buffer_dust_limit_success_sat * 1000. The subtraction of one millisatoshi ensures any proposed HTLC is strictly below the dust threshold, so it is classified as dust and banned. The functional tests are updated to assert on remaining_dust_exposure_msat and next_outbound_htlc_limit_msat rather than on the old dust-limit formula. This is a partial, targeted fix; the commit message frames it as a merge of a feature branch whose broader scope is not fully visible in the diff.
Changed components
lightning/src/sign/tx_builder.rslightning/src/ln/functional_tests.rsInspect captured patch +25 / −20
### lightning/src/ln/functional_tests.rs
@@ -31,7 +31,7 @@ use crate::ln::chan_utils::{
};
use crate::ln::channel::{
get_holder_selected_channel_reserve_satoshis, Channel, DISCONNECT_PEER_AWAITING_RESPONSE_TICKS,
- MIN_CHAN_DUST_LIMIT_SATOSHIS, UNFUNDED_CHANNEL_AGE_LIMIT_TICKS,
+ UNFUNDED_CHANNEL_AGE_LIMIT_TICKS,
};
use crate::ln::channel_state::OutboundHTLCSource;
use crate::ln::channelmanager::{
@@ -9147,6 +9147,7 @@ pub fn test_nondust_htlc_excess_fees_are_dust() {
const DEFAULT_FEERATE: u32 = 253;
const HIGH_FEERATE: u32 = 275;
const EXCESS_FEERATE: u32 = HIGH_FEERATE - DEFAULT_FEERATE;
+ const DUST_EXPOSURE_MULTIPLIER: u64 = 10_000;
let chanmon_cfgs = create_chanmon_cfgs(3);
{
// Set the feerate of the channel funder above the `dust_exposure_limiting_feerate` of
@@ -9160,7 +9161,8 @@ pub fn test_nondust_htlc_excess_fees_are_dust() {
let mut config = test_legacy_channel_config();
// Set the dust limit to the default value
- config.channel_config.max_dust_htlc_exposure = MaxDustHTLCExposure::FeeRateMultiplier(10_000);
+ config.channel_config.max_dust_htlc_exposure =
+ MaxDustHTLCExposure::FeeRateMultiplier(DUST_EXPOSURE_MULTIPLIER);
// Make sure the HTLC limits don't get in the way
let chan_ty = ChannelTypeFeatures::only_static_remote_key();
config.channel_handshake_limits.min_max_accepted_htlcs = chan_utils::max_htlcs(&chan_ty);
@@ -9287,8 +9289,15 @@ pub fn test_nondust_htlc_excess_fees_are_dust() {
let id = PaymentId(payment_hash_0_1.0);
let res = nodes[0].node.send_payment_with_route(route_0_1, payment_hash_0_1, onion, id);
unwrap_send_err!(nodes[0], res, true, APIError::ChannelUnavailable { .. }, {});
+ let channel_details = &nodes[0].node.list_channels()[0];
+ // A fee-rate-multiplier limit is the limiting feerate multiplied by the configured value.
+ let max_dust_exposure_msat = DEFAULT_FEERATE as u64 * DUST_EXPOSURE_MULTIPLIER;
+ let remaining_dust_exposure_msat = max_dust_exposure_msat
+ - channel_details.current_dust_exposure_msat.expect("dust exposure should be available");
+ assert_eq!(remaining_dust_exposure_msat, 16_000);
+ assert_eq!(channel_details.next_outbound_htlc_limit_msat, remaining_dust_exposure_msat);
nodes[0].logger.assert_log("lightning::ln::outbound_payment",
- format!("Failed to send along path due to error: Channel unavailable: Cannot send more than our next-HTLC maximum - {} msat", 2325000), 1);
+ format!("Failed to send along path due to error: Channel unavailable: Cannot send more than our next-HTLC maximum - {} msat", remaining_dust_exposure_msat), 1);
assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
assert_eq!(nodes[0].node.list_channels().len(), 1);
@@ -9395,13 +9404,6 @@ fn do_test_nondust_htlc_fees_dust_exposure_delta(features: ChannelTypeFeatures)
let chan_id = create_chan_between_nodes_with_value(&nodes[0], &nodes[1], 100_000, 50_000_000).3;
- let node_1_dust_buffer_feerate = {
- let per_peer_state = nodes[1].node.per_peer_state.read().unwrap();
- let chan_lock = per_peer_state.get(&node_a_id).unwrap().lock().unwrap();
- let chan = chan_lock.channel_by_id.get(&chan_id).unwrap();
- chan.context().get_dust_buffer_feerate(None) as u64
- };
-
// Skip the router complaint when node 1 will attempt to pay node 0
let (route_1_0, payment_hash_1_0, _, payment_secret_1_0) =
get_route_and_payment_hash!(nodes[1], nodes[0], NON_DUST_HTLC_MSAT);
@@ -9509,15 +9511,16 @@ fn do_test_nondust_htlc_fees_dust_exposure_delta(features: ChannelTypeFeatures)
let res = nodes[1].node.send_payment_with_route(route_1_0, payment_hash_1_0, onion, id);
unwrap_send_err!(nodes[1], res, true, APIError::ChannelUnavailable { .. }, {});
- let (htlc_success_tx_fee_sat, _) =
- second_stage_tx_fees_sat(&features, node_1_dust_buffer_feerate as u32);
- let dust_limit = if features == ChannelTypeFeatures::only_static_remote_key() {
- MIN_CHAN_DUST_LIMIT_SATOSHIS * 1000 + htlc_success_tx_fee_sat * 1000
- } else {
- MIN_CHAN_DUST_LIMIT_SATOSHIS * 1000
- };
+ let current_dust_exposure_msat = BASE_DUST_EXPOSURE_MSAT
+ + EXCESS_FEERATE * commitment_tx_base_weight(&features) / 1000 * 1000;
+ let max_dust_exposure_msat = expected_dust_exposure_msat - 1;
+ let remaining_dust_exposure_msat = max_dust_exposure_msat - current_dust_exposure_msat;
+ assert_eq!(
+ nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat,
+ remaining_dust_exposure_msat
+ );
nodes[1].logger.assert_log("lightning::ln::outbound_payment",
- format!("Failed to send along path due to error: Channel unavailable: Cannot send more than our next-HTLC maximum - {} msat", dust_limit), 1);
+ format!("Failed to send along path due to error: Channel unavailable: Cannot send more than our next-HTLC maximum - {} msat", remaining_dust_exposure_msat), 1);
assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
assert_eq!(nodes[0].node.list_channels().len(), 1);
### lightning/src/sign/tx_builder.rs
@@ -609,8 +609,10 @@ fn adjust_min_max_htlc_for_dust_exposure(
if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat {
// If adding an extra HTLC would put us over the dust limit in total fees, we cannot
// send any non-dust HTLCs.
- available_capacity_msat =
- cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000);
+ available_capacity_msat = cmp::min(
+ available_capacity_msat,
+ buffer_dust_limit_success_sat.saturating_mul(1000).saturating_sub(1),
+ );
}
}
Why this scored 57/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.