From 9eb3d2ac256e82d19414862af7ce8f111ab1bad2 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Wed, 21 Jan 2026 19:04:09 -0300 Subject: [PATCH] router: Use aggregate capacity for first-hop fragmentation check The routing fragmentation mitigation heuristic requires each channel to contribute at least `payment_amount / max_path_count` to avoid excessive path splitting. This makes sense for network paths where each additional path incurs base fees and increases failure probability. However, for first hops this is overly restrictive. Multiple channels to the same peer converge immediately at the first hop - there's no actual network-level fragmentation. A 2M sat channel and a 48M sat channel to the same peer should be usable together for a 50M sat payment, but currently the small channel gets rejected (threshold would be 5M sat with default max_path_count=10), leaving only 48M available. This change checks the aggregate capacity across all first-hop channels to a peer. If the aggregate meets the contribution threshold, individual channels are allowed regardless of their size. --- lightning/src/routing/router.rs | 90 ++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 33ad0f936ba..44f8a9b7a57 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -2502,7 +2502,8 @@ where L::Target: Logger { // Returns the contribution amount of $candidate if the channel caused an update to `targets`. ( $candidate: expr, $next_hops_fee_msat: expr, $next_hops_value_contribution: expr, $next_hops_path_htlc_minimum_msat: expr, - $next_hops_path_penalty_msat: expr, $next_hops_cltv_delta: expr, $next_hops_path_length: expr ) => { { + $next_hops_path_penalty_msat: expr, $next_hops_cltv_delta: expr, $next_hops_path_length: expr, + $allow_first_hop_route_convergence: expr ) => { { // We "return" whether we updated the path at the end, and how much we can route via // this channel, via this: let mut hop_contribution_amt_msat = None; @@ -2559,7 +2560,12 @@ where L::Target: Logger { let value_contribution_msat = cmp::min(available_value_contribution_msat, $next_hops_value_contribution); // Verify the liquidity offered by this channel complies to the minimal contribution. - let contributes_sufficient_value = value_contribution_msat >= minimal_value_contribution_msat; + // For first hops, we allow skipping this if their aggregate capacity meets the + // threshold (they converge immediately, so no real fragmentation occurs). + // We still require >= 1 to avoid division by zero in cost calculation. + let is_first_hop = matches!($candidate, CandidateRouteHop::FirstHop(_)); + let contributes_sufficient_value = value_contribution_msat >= minimal_value_contribution_msat + || ($allow_first_hop_route_convergence && is_first_hop && value_contribution_msat >= 1); // Includes paying fees for the use of the following channels. let amount_to_transfer_over_msat: u64 = match value_contribution_msat.checked_add($next_hops_fee_msat) { Some(result) => result, @@ -2894,12 +2900,18 @@ where L::Target: Logger { add_entry!(candidate, fee_to_target_msat, $next_hops_value_contribution, next_hops_path_htlc_minimum_msat, next_hops_path_penalty_msat, - $next_hops_cltv_delta, $next_hops_path_length); + $next_hops_cltv_delta, $next_hops_path_length, false); } } } if is_first_hop_target { if let Some((first_channels, peer_node_counter)) = first_hop_targets.get(&$node_id) { + // Check aggregate capacity to this peer for the fragmentation limit. + let aggregate_capacity_to_peer: u64 = first_channels.iter() + .map(|details| details.next_outbound_htlc_limit_msat) + .sum(); + let aggregate_meets_threshold = aggregate_capacity_to_peer >= minimal_value_contribution_msat; + for details in first_channels { debug_assert_eq!(*peer_node_counter, $node_counter); let candidate = CandidateRouteHop::FirstHop(FirstHopCandidate { @@ -2909,7 +2921,8 @@ where L::Target: Logger { add_entry!(&candidate, fee_to_target_msat, $next_hops_value_contribution, next_hops_path_htlc_minimum_msat, next_hops_path_penalty_msat, - $next_hops_cltv_delta, $next_hops_path_length); + $next_hops_cltv_delta, $next_hops_path_length, + aggregate_meets_threshold); } } } @@ -2937,7 +2950,8 @@ where L::Target: Logger { $next_hops_value_contribution, next_hops_path_htlc_minimum_msat, next_hops_path_penalty_msat, - $next_hops_cltv_delta, $next_hops_path_length); + $next_hops_cltv_delta, $next_hops_path_length, + false); } } } @@ -3066,7 +3080,7 @@ where L::Target: Logger { CandidateRouteHop::Blinded(BlindedPathCandidate { source_node_counter, source_node_id, hint, hint_idx }) }; if let Some(hop_used_msat) = add_entry!(&candidate, - 0, path_value_msat, 0, 0_u64, 0, 0) + 0, path_value_msat, 0, 0_u64, 0, 0, false) { blind_intros_added.insert(source_node_id, (hop_used_msat, candidate)); } else { continue } @@ -3084,6 +3098,13 @@ where L::Target: Logger { sort_first_hop_channels( first_channels, &used_liquidities, recommended_value_msat, our_node_pubkey ); + + // Check aggregate capacity to this peer for the fragmentation limit. + let aggregate_capacity_to_peer: u64 = first_channels.iter() + .map(|details| details.next_outbound_htlc_limit_msat) + .sum(); + let aggregate_meets_threshold = aggregate_capacity_to_peer >= minimal_value_contribution_msat; + for details in first_channels { let first_hop_candidate = CandidateRouteHop::FirstHop(FirstHopCandidate { details, payer_node_id: &our_node_id, payer_node_counter, @@ -3096,7 +3117,7 @@ where L::Target: Logger { let path_min = candidate.htlc_minimum_msat().saturating_add( compute_fees_saturating(candidate.htlc_minimum_msat(), candidate.fees())); add_entry!(&first_hop_candidate, blinded_path_fee, path_contribution_msat, path_min, - 0_u64, candidate.cltv_expiry_delta(), 0); + 0_u64, candidate.cltv_expiry_delta(), 0, aggregate_meets_threshold); } } } @@ -6865,6 +6886,57 @@ mod tests { } } + #[test] + fn first_hop_aggregate_capacity_overrides_fragmentation_heuristic() { + // The fragmentation heuristic requires each channel to contribute at least + // `payment_amount / max_path_count`. However, for first hops to the same peer, + // this is overly restrictive since all channels converge immediately. + // + // Here we test that the aggregate capacity across all first-hop channels to a + // peer is used for the fragmentation check, not individual channel capacities. + // + // Setup: + // payment_amount = 49_737_000 msat + // min_contribution = payment_amount / 10 = 4_973_700 msat + // channel_1 = 2_180_500 msat (below threshold, would be rejected individually) + // channel_2 = 47_557_520 msat (above threshold, but insufficient alone) + // aggregate = 49_738_020 msat (sufficient for payment) + + let secp_ctx = Secp256k1::new(); + let (_, our_id, _, nodes) = get_nodes(&secp_ctx); + let logger = Arc::new(ln_test_utils::TestLogger::new()); + let network_graph = NetworkGraph::new(Network::Testnet, Arc::clone(&logger)); + let scorer = ln_test_utils::TestScorer::new(); + let config = UserConfig::default(); + let payment_params = PaymentParameters::from_node_id(nodes[0], 42) + .with_bolt11_features(channelmanager::provided_bolt11_invoice_features(&config)) + .unwrap(); + let random_seed_bytes = [42; 32]; + + let payment_amt = 49_737_000; + let small_channel_capacity = 2_180_500; + let large_channel_capacity = 47_557_520; + + let route_params = RouteParameters::from_payment_params_and_value( + payment_params.clone(), payment_amt); + let route = get_route(&our_id, &route_params, &network_graph.read_only(), Some(&[ + &get_channel_details(Some(1), nodes[0], channelmanager::provided_init_features(&config), small_channel_capacity), + &get_channel_details(Some(2), nodes[0], channelmanager::provided_init_features(&config), large_channel_capacity), + ]), Arc::clone(&logger), &scorer, &Default::default(), &random_seed_bytes).unwrap(); + + assert_eq!(route.paths.len(), 2, "Expected 2 paths"); + + let total_sent: u64 = route.paths.iter() + .map(|path| path.hops.last().unwrap().fee_msat) + .sum(); + assert_eq!(total_sent, payment_amt); + + let scids: std::collections::HashSet = route.paths.iter() + .map(|path| path.hops[0].short_channel_id) + .collect(); + assert!(scids.contains(&1) && scids.contains(&2), "Both channels should be used"); + } + #[test] fn prefers_shorter_route_with_higher_fees() { let (secp_ctx, network_graph, _, _, logger) = build_graph(); @@ -7966,7 +8038,9 @@ mod tests { if let Err(LightningError { err, .. }) = get_route(&nodes[0], &route_params, &netgraph, Some(&first_hops.iter().collect::>()), Arc::clone(&logger), &scorer, &Default::default(), &random_seed_bytes) { - assert_eq!(err, "Failed to find a path to the given destination"); + assert!(err == "Failed to find a path to the given destination" || + err == "Failed to find a sufficient route to the given destination", + "Unexpected error: {}", err); } else { panic!("Expected error") } // Sending an exact amount accounting for the blinded path fee works.