Skip to content

Commit 9eb3d2a

Browse files
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.
1 parent 5fe3688 commit 9eb3d2a

1 file changed

Lines changed: 82 additions & 8 deletions

File tree

lightning/src/routing/router.rs

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2502,7 +2502,8 @@ where L::Target: Logger {
25022502
// Returns the contribution amount of $candidate if the channel caused an update to `targets`.
25032503
( $candidate: expr, $next_hops_fee_msat: expr,
25042504
$next_hops_value_contribution: expr, $next_hops_path_htlc_minimum_msat: expr,
2505-
$next_hops_path_penalty_msat: expr, $next_hops_cltv_delta: expr, $next_hops_path_length: expr ) => { {
2505+
$next_hops_path_penalty_msat: expr, $next_hops_cltv_delta: expr, $next_hops_path_length: expr,
2506+
$allow_first_hop_route_convergence: expr ) => { {
25062507
// We "return" whether we updated the path at the end, and how much we can route via
25072508
// this channel, via this:
25082509
let mut hop_contribution_amt_msat = None;
@@ -2559,7 +2560,12 @@ where L::Target: Logger {
25592560

25602561
let value_contribution_msat = cmp::min(available_value_contribution_msat, $next_hops_value_contribution);
25612562
// Verify the liquidity offered by this channel complies to the minimal contribution.
2562-
let contributes_sufficient_value = value_contribution_msat >= minimal_value_contribution_msat;
2563+
// For first hops, we allow skipping this if their aggregate capacity meets the
2564+
// threshold (they converge immediately, so no real fragmentation occurs).
2565+
// We still require >= 1 to avoid division by zero in cost calculation.
2566+
let is_first_hop = matches!($candidate, CandidateRouteHop::FirstHop(_));
2567+
let contributes_sufficient_value = value_contribution_msat >= minimal_value_contribution_msat
2568+
|| ($allow_first_hop_route_convergence && is_first_hop && value_contribution_msat >= 1);
25632569
// Includes paying fees for the use of the following channels.
25642570
let amount_to_transfer_over_msat: u64 = match value_contribution_msat.checked_add($next_hops_fee_msat) {
25652571
Some(result) => result,
@@ -2894,12 +2900,18 @@ where L::Target: Logger {
28942900
add_entry!(candidate, fee_to_target_msat,
28952901
$next_hops_value_contribution,
28962902
next_hops_path_htlc_minimum_msat, next_hops_path_penalty_msat,
2897-
$next_hops_cltv_delta, $next_hops_path_length);
2903+
$next_hops_cltv_delta, $next_hops_path_length, false);
28982904
}
28992905
}
29002906
}
29012907
if is_first_hop_target {
29022908
if let Some((first_channels, peer_node_counter)) = first_hop_targets.get(&$node_id) {
2909+
// Check aggregate capacity to this peer for the fragmentation limit.
2910+
let aggregate_capacity_to_peer: u64 = first_channels.iter()
2911+
.map(|details| details.next_outbound_htlc_limit_msat)
2912+
.sum();
2913+
let aggregate_meets_threshold = aggregate_capacity_to_peer >= minimal_value_contribution_msat;
2914+
29032915
for details in first_channels {
29042916
debug_assert_eq!(*peer_node_counter, $node_counter);
29052917
let candidate = CandidateRouteHop::FirstHop(FirstHopCandidate {
@@ -2909,7 +2921,8 @@ where L::Target: Logger {
29092921
add_entry!(&candidate, fee_to_target_msat,
29102922
$next_hops_value_contribution,
29112923
next_hops_path_htlc_minimum_msat, next_hops_path_penalty_msat,
2912-
$next_hops_cltv_delta, $next_hops_path_length);
2924+
$next_hops_cltv_delta, $next_hops_path_length,
2925+
aggregate_meets_threshold);
29132926
}
29142927
}
29152928
}
@@ -2937,7 +2950,8 @@ where L::Target: Logger {
29372950
$next_hops_value_contribution,
29382951
next_hops_path_htlc_minimum_msat,
29392952
next_hops_path_penalty_msat,
2940-
$next_hops_cltv_delta, $next_hops_path_length);
2953+
$next_hops_cltv_delta, $next_hops_path_length,
2954+
false);
29412955
}
29422956
}
29432957
}
@@ -3066,7 +3080,7 @@ where L::Target: Logger {
30663080
CandidateRouteHop::Blinded(BlindedPathCandidate { source_node_counter, source_node_id, hint, hint_idx })
30673081
};
30683082
if let Some(hop_used_msat) = add_entry!(&candidate,
3069-
0, path_value_msat, 0, 0_u64, 0, 0)
3083+
0, path_value_msat, 0, 0_u64, 0, 0, false)
30703084
{
30713085
blind_intros_added.insert(source_node_id, (hop_used_msat, candidate));
30723086
} else { continue }
@@ -3084,6 +3098,13 @@ where L::Target: Logger {
30843098
sort_first_hop_channels(
30853099
first_channels, &used_liquidities, recommended_value_msat, our_node_pubkey
30863100
);
3101+
3102+
// Check aggregate capacity to this peer for the fragmentation limit.
3103+
let aggregate_capacity_to_peer: u64 = first_channels.iter()
3104+
.map(|details| details.next_outbound_htlc_limit_msat)
3105+
.sum();
3106+
let aggregate_meets_threshold = aggregate_capacity_to_peer >= minimal_value_contribution_msat;
3107+
30873108
for details in first_channels {
30883109
let first_hop_candidate = CandidateRouteHop::FirstHop(FirstHopCandidate {
30893110
details, payer_node_id: &our_node_id, payer_node_counter,
@@ -3096,7 +3117,7 @@ where L::Target: Logger {
30963117
let path_min = candidate.htlc_minimum_msat().saturating_add(
30973118
compute_fees_saturating(candidate.htlc_minimum_msat(), candidate.fees()));
30983119
add_entry!(&first_hop_candidate, blinded_path_fee, path_contribution_msat, path_min,
3099-
0_u64, candidate.cltv_expiry_delta(), 0);
3120+
0_u64, candidate.cltv_expiry_delta(), 0, aggregate_meets_threshold);
31003121
}
31013122
}
31023123
}
@@ -6865,6 +6886,57 @@ mod tests {
68656886
}
68666887
}
68676888

6889+
#[test]
6890+
fn first_hop_aggregate_capacity_overrides_fragmentation_heuristic() {
6891+
// The fragmentation heuristic requires each channel to contribute at least
6892+
// `payment_amount / max_path_count`. However, for first hops to the same peer,
6893+
// this is overly restrictive since all channels converge immediately.
6894+
//
6895+
// Here we test that the aggregate capacity across all first-hop channels to a
6896+
// peer is used for the fragmentation check, not individual channel capacities.
6897+
//
6898+
// Setup:
6899+
// payment_amount = 49_737_000 msat
6900+
// min_contribution = payment_amount / 10 = 4_973_700 msat
6901+
// channel_1 = 2_180_500 msat (below threshold, would be rejected individually)
6902+
// channel_2 = 47_557_520 msat (above threshold, but insufficient alone)
6903+
// aggregate = 49_738_020 msat (sufficient for payment)
6904+
6905+
let secp_ctx = Secp256k1::new();
6906+
let (_, our_id, _, nodes) = get_nodes(&secp_ctx);
6907+
let logger = Arc::new(ln_test_utils::TestLogger::new());
6908+
let network_graph = NetworkGraph::new(Network::Testnet, Arc::clone(&logger));
6909+
let scorer = ln_test_utils::TestScorer::new();
6910+
let config = UserConfig::default();
6911+
let payment_params = PaymentParameters::from_node_id(nodes[0], 42)
6912+
.with_bolt11_features(channelmanager::provided_bolt11_invoice_features(&config))
6913+
.unwrap();
6914+
let random_seed_bytes = [42; 32];
6915+
6916+
let payment_amt = 49_737_000;
6917+
let small_channel_capacity = 2_180_500;
6918+
let large_channel_capacity = 47_557_520;
6919+
6920+
let route_params = RouteParameters::from_payment_params_and_value(
6921+
payment_params.clone(), payment_amt);
6922+
let route = get_route(&our_id, &route_params, &network_graph.read_only(), Some(&[
6923+
&get_channel_details(Some(1), nodes[0], channelmanager::provided_init_features(&config), small_channel_capacity),
6924+
&get_channel_details(Some(2), nodes[0], channelmanager::provided_init_features(&config), large_channel_capacity),
6925+
]), Arc::clone(&logger), &scorer, &Default::default(), &random_seed_bytes).unwrap();
6926+
6927+
assert_eq!(route.paths.len(), 2, "Expected 2 paths");
6928+
6929+
let total_sent: u64 = route.paths.iter()
6930+
.map(|path| path.hops.last().unwrap().fee_msat)
6931+
.sum();
6932+
assert_eq!(total_sent, payment_amt);
6933+
6934+
let scids: std::collections::HashSet<u64> = route.paths.iter()
6935+
.map(|path| path.hops[0].short_channel_id)
6936+
.collect();
6937+
assert!(scids.contains(&1) && scids.contains(&2), "Both channels should be used");
6938+
}
6939+
68686940
#[test]
68696941
fn prefers_shorter_route_with_higher_fees() {
68706942
let (secp_ctx, network_graph, _, _, logger) = build_graph();
@@ -7966,7 +8038,9 @@ mod tests {
79668038
if let Err(LightningError { err, .. }) = get_route(&nodes[0], &route_params, &netgraph,
79678039
Some(&first_hops.iter().collect::<Vec<_>>()), Arc::clone(&logger), &scorer,
79688040
&Default::default(), &random_seed_bytes) {
7969-
assert_eq!(err, "Failed to find a path to the given destination");
8041+
assert!(err == "Failed to find a path to the given destination" ||
8042+
err == "Failed to find a sufficient route to the given destination",
8043+
"Unexpected error: {}", err);
79708044
} else { panic!("Expected error") }
79718045

79728046
// Sending an exact amount accounting for the blinded path fee works.

0 commit comments

Comments
 (0)