Skip to content

Commit ac95262

Browse files
committed
Use cost / path amt limit as the pathfinding score, not cost
While walking nodes in our Dijkstra's pathfinding, we may find a channel which is amount-limited to less than the amount we're currently trying to send. This is fine, and when we encounter such nodes we simply limit the amount we'd send in this path if we pick the channel. When we encounter such a path, we keep summing the cost across hops as we go, keeping whatever scores we assigned to channels between the amount-limited one and the recipient, but using the new limited amount for any channels we look at later as we walk towards the sender. This leads to somewhat inconsistent scores, especially as our scorer assigns a large portion of its penalties and a portion of network fees are proportional to the amount. Thus, we end up with a somewhat higher score than we "should" for this path as later hops use a high proportional cost. We accepted this as a simple way to bias against small-value paths and many MPP parts. Sadly, in practice it appears our bias is not strong enough, as several users have reported that we often attempt far too many MPP parts. In practice, if we encounter a channel with a small limit early in the Dijkstra's pass (towards the end of the path), we may prefer it over many other paths as we start assigning very low costs early on before we've accumulated much cost from larger channels. Here, we swap the `cost` Dijkstra's score for `cost / path amount`. This should bias much stronger against many MPP parts by preferring larger paths proportionally to their amount. This somewhat better aligns with our goal - if we have to pick multiple paths, we should be searching for paths the optimize fee-per-sat-sent, not strictly the fee paid. However, it might bias us against smaller paths somewhat stronger than we want - because we're still using the fees/scores calculated with the sought amount for hops processed already, but are now dividing by a smaller sent amount when walking further hops, we will bias "incorrectly" (and fairly strongly) against smaller parts. Still, because of the complaints on pathfinding performance due to too many MPP paths, it seems like a worthwhile tradeoff, as ultimately MPP splitting is always the domain of heuristics anyway.
1 parent a31da0a commit ac95262

File tree

2 files changed

+244
-16
lines changed

2 files changed

+244
-16
lines changed

lightning/src/routing/router.rs

Lines changed: 243 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ impl_writeable_tlv_based!(RouteHintHop, {
13961396
#[repr(align(32))] // Force the size to 32 bytes
13971397
struct RouteGraphNode {
13981398
node_counter: u32,
1399-
score: u64,
1399+
score: u128,
14001400
// The maximum value a yet-to-be-constructed payment path might flow through this node.
14011401
// This value is upper-bounded by us by:
14021402
// - how much is needed for a path being constructed
@@ -2122,6 +2122,20 @@ impl<'a> PaymentPath<'a> {
21222122
return result;
21232123
}
21242124

2125+
/// Gets the cost (fees plus scorer penalty in msats) of the path divided by the value we
2126+
/// can/will send over the path. This is also the heap score during our Dijkstra's walk.
2127+
fn get_cost_per_msat(&self) -> u128 {
2128+
let fee_cost = self.get_cost_msat();
2129+
let value_msat = self.get_value_msat();
2130+
debug_assert!(value_msat > 0, "Paths should always send more than 0 msat");
2131+
if fee_cost == u64::MAX || value_msat == 0 {
2132+
u64::MAX.into()
2133+
} else {
2134+
((fee_cost as u128) << 64) / value_msat as u128
2135+
}
2136+
}
2137+
2138+
/// Gets the fees plus scorer penalty in msats of the path.
21252139
fn get_cost_msat(&self) -> u64 {
21262140
self.get_total_fee_paid_msat().saturating_add(self.get_path_penalty_msat())
21272141
}
@@ -2788,8 +2802,6 @@ where L::Target: Logger {
27882802
*used_liquidity_msat
27892803
});
27902804

2791-
// Verify the liquidity offered by this channel complies to the minimal contribution.
2792-
let contributes_sufficient_value = available_value_contribution_msat >= minimal_value_contribution_msat;
27932805
// Do not consider candidate hops that would exceed the maximum path length.
27942806
let path_length_to_node = $next_hops_path_length
27952807
+ if $candidate.blinded_hint_idx().is_some() { 0 } else { 1 };
@@ -2801,6 +2813,8 @@ where L::Target: Logger {
28012813
let exceeds_cltv_delta_limit = hop_total_cltv_delta > max_total_cltv_expiry_delta as u32;
28022814

28032815
let value_contribution_msat = cmp::min(available_value_contribution_msat, $next_hops_value_contribution);
2816+
// Verify the liquidity offered by this channel complies to the minimal contribution.
2817+
let contributes_sufficient_value = value_contribution_msat >= minimal_value_contribution_msat;
28042818
// Includes paying fees for the use of the following channels.
28052819
let amount_to_transfer_over_msat: u64 = match value_contribution_msat.checked_add($next_hops_fee_msat) {
28062820
Some(result) => result,
@@ -2950,7 +2964,7 @@ where L::Target: Logger {
29502964
// Ignore hops if augmenting the current path to them would put us over `max_total_routing_fee_msat`
29512965
if total_fee_msat > max_total_routing_fee_msat {
29522966
if should_log_candidate {
2953-
log_trace!(logger, "Ignoring {} due to exceeding max total routing fee limit.", LoggedCandidateHop(&$candidate));
2967+
log_trace!(logger, "Ignoring {} with fee {total_fee_msat} due to exceeding max total routing fee limit {max_total_routing_fee_msat}.", LoggedCandidateHop(&$candidate));
29542968

29552969
if let Some(_) = first_hop_details {
29562970
log_trace!(logger,
@@ -2991,15 +3005,31 @@ where L::Target: Logger {
29913005
// but it may require additional tracking - we don't want to double-count
29923006
// the fees included in $next_hops_path_htlc_minimum_msat, but also
29933007
// can't use something that may decrease on future hops.
2994-
let old_cost = cmp::max(old_entry.total_fee_msat, old_entry.path_htlc_minimum_msat)
3008+
let old_fee_cost = cmp::max(old_entry.total_fee_msat, old_entry.path_htlc_minimum_msat)
29953009
.saturating_add(old_entry.path_penalty_msat);
2996-
let new_cost = cmp::max(total_fee_msat, path_htlc_minimum_msat)
3010+
let new_fee_cost = cmp::max(total_fee_msat, path_htlc_minimum_msat)
29973011
.saturating_add(path_penalty_msat);
2998-
let should_replace =
2999-
new_cost < old_cost
3000-
|| (new_cost == old_cost && old_entry.value_contribution_msat < value_contribution_msat);
3012+
// The actual score we use for our heap is the cost divided by how
3013+
// much we are thinking of sending over this channel. This avoids
3014+
// prioritizing channels that have a very low fee because we aren't
3015+
// sending very much over them.
3016+
// In order to avoid integer division precision loss, we simply
3017+
// shift the costs up to the top half of a u128 and divide by the
3018+
// value (which is, at max, just under a u64).
3019+
let old_cost = if old_fee_cost != u64::MAX && old_entry.value_contribution_msat != 0 {
3020+
((old_fee_cost as u128) << 64) / old_entry.value_contribution_msat as u128
3021+
} else {
3022+
u128::MAX
3023+
};
3024+
let new_cost = if new_fee_cost != u64::MAX {
3025+
// value_contribution_msat is always >= 1, checked above via
3026+
// `contributes_sufficient_value`.
3027+
((new_fee_cost as u128) << 64) / value_contribution_msat as u128
3028+
} else {
3029+
u128::MAX
3030+
};
30013031

3002-
if !old_entry.was_processed && should_replace {
3032+
if !old_entry.was_processed && new_cost < old_cost {
30033033
#[cfg(all(not(ldk_bench), any(test, fuzzing)))]
30043034
{
30053035
assert!(!old_entry.best_path_from_hop_selected);
@@ -3008,7 +3038,7 @@ where L::Target: Logger {
30083038

30093039
let new_graph_node = RouteGraphNode {
30103040
node_counter: src_node_counter,
3011-
score: cmp::max(total_fee_msat, path_htlc_minimum_msat).saturating_add(path_penalty_msat),
3041+
score: new_cost,
30123042
total_cltv_delta: hop_total_cltv_delta as u16,
30133043
value_contribution_msat,
30143044
path_length_to_node,
@@ -3558,10 +3588,7 @@ where L::Target: Logger {
35583588
// First, sort by the cost-per-value of the path, dropping the paths that cost the most for
35593589
// the value they contribute towards the payment amount.
35603590
// We sort in descending order as we will remove from the front in `retain`, next.
3561-
selected_route.sort_unstable_by(|a, b|
3562-
(((b.get_cost_msat() as u128) << 64) / (b.get_value_msat() as u128))
3563-
.cmp(&(((a.get_cost_msat() as u128) << 64) / (a.get_value_msat() as u128)))
3564-
);
3591+
selected_route.sort_unstable_by(|a, b| b.get_cost_per_msat().cmp(&a.get_cost_per_msat()));
35653592

35663593
// We should make sure that at least 1 path left.
35673594
let mut paths_left = selected_route.len();
@@ -9009,6 +9036,207 @@ mod tests {
90099036

90109037
assert_eq!(route.paths[0].hops[0].short_channel_id, 44);
90119038
}
9039+
9040+
#[test]
9041+
fn prefers_paths_by_cost_amt_ratio() {
9042+
// Previously, we preferred paths during MPP selection based on their absolute cost, rather
9043+
// than the cost-per-amount-transferred. This could result in selecting many MPP paths with
9044+
// relatively low value contribution, rather than one large path which is ultimately
9045+
// cheaper. While this is a tradeoff (and not universally better), in practice the old
9046+
// behavior was problematic, so we shifted to a proportional cost.
9047+
//
9048+
// Here we check that the proportional cost is being used in a somewhat absurd setup where
9049+
// we have one good path and several cheaper, but smaller paths.
9050+
let (secp_ctx, network_graph, gossip_sync, _, logger) = build_graph();
9051+
let (our_privkey, our_id, privkeys, nodes) = get_nodes(&secp_ctx);
9052+
let scorer = ln_test_utils::TestScorer::new();
9053+
let random_seed_bytes = [42; 32];
9054+
9055+
// Enable channel 1
9056+
let update_1 = UnsignedChannelUpdate {
9057+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9058+
short_channel_id: 1,
9059+
timestamp: 2,
9060+
message_flags: 1, // Only must_be_one
9061+
channel_flags: 0,
9062+
cltv_expiry_delta: (1 << 4) | 1,
9063+
htlc_minimum_msat: 0,
9064+
htlc_maximum_msat: 10_000_000,
9065+
fee_base_msat: 0,
9066+
fee_proportional_millionths: 0,
9067+
excess_data: Vec::new(),
9068+
};
9069+
update_channel(&gossip_sync, &secp_ctx, &our_privkey, update_1);
9070+
9071+
// Set the fee on channel 3 to 1 sat, max HTLC to 1M msat
9072+
let update_3 = UnsignedChannelUpdate {
9073+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9074+
short_channel_id: 3,
9075+
timestamp: 2,
9076+
message_flags: 1, // Only must_be_one
9077+
channel_flags: 0,
9078+
cltv_expiry_delta: (3 << 4) | 1,
9079+
htlc_minimum_msat: 0,
9080+
htlc_maximum_msat: 1_000_000,
9081+
fee_base_msat: 1_000,
9082+
fee_proportional_millionths: 0,
9083+
excess_data: Vec::new(),
9084+
};
9085+
update_channel(&gossip_sync, &secp_ctx, &privkeys[0], update_3);
9086+
9087+
// Set the fee on channel 13 to 1 sat, max HTLC to 1M msat
9088+
let update_13 = UnsignedChannelUpdate {
9089+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9090+
short_channel_id: 13,
9091+
timestamp: 2,
9092+
message_flags: 1, // Only must_be_one
9093+
channel_flags: 0,
9094+
cltv_expiry_delta: (13 << 4) | 1,
9095+
htlc_minimum_msat: 0,
9096+
htlc_maximum_msat: 1_000_000,
9097+
fee_base_msat: 1_000,
9098+
fee_proportional_millionths: 0,
9099+
excess_data: Vec::new(),
9100+
};
9101+
update_channel(&gossip_sync, &secp_ctx, &privkeys[7], update_13);
9102+
9103+
// Set the fee on channel 4 to 1 sat, max HTLC to 1M msat
9104+
let update_4 = UnsignedChannelUpdate {
9105+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9106+
short_channel_id: 4,
9107+
timestamp: 2,
9108+
message_flags: 1, // Only must_be_one
9109+
channel_flags: 0,
9110+
cltv_expiry_delta: (4 << 4) | 1,
9111+
htlc_minimum_msat: 0,
9112+
htlc_maximum_msat: 1_000_000,
9113+
fee_base_msat: 1_000,
9114+
fee_proportional_millionths: 0,
9115+
excess_data: Vec::new(),
9116+
};
9117+
update_channel(&gossip_sync, &secp_ctx, &privkeys[1], update_4);
9118+
9119+
// The router will attempt to gather 3x the requested amount, and if it finds the new path
9120+
// through channel 16, added below, it'll always prefer that, even prior to the changes
9121+
// which introduced this test.
9122+
// Instead, we add 6 additional channels so that the pathfinder always just gathers useless
9123+
// paths first.
9124+
for i in 0..6 {
9125+
// Finally, create a single channel with fee of 2 sat from node 1 to node 2 which allows
9126+
// for a larger payment.
9127+
let chan_features = ChannelFeatures::from_le_bytes(vec![]);
9128+
add_channel(&gossip_sync, &secp_ctx, &privkeys[7], &privkeys[2], chan_features, i + 42);
9129+
9130+
// Set the fee on channel 16 to 2 sats, max HTLC to 3M msat
9131+
let update_a = UnsignedChannelUpdate {
9132+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9133+
short_channel_id: i + 42,
9134+
timestamp: 2,
9135+
message_flags: 1, // Only must_be_one
9136+
channel_flags: 0,
9137+
cltv_expiry_delta: (42 << 4) | 1,
9138+
htlc_minimum_msat: 0,
9139+
htlc_maximum_msat: 1_000_000,
9140+
fee_base_msat: 1_000,
9141+
fee_proportional_millionths: 0,
9142+
excess_data: Vec::new(),
9143+
};
9144+
update_channel(&gossip_sync, &secp_ctx, &privkeys[7], update_a);
9145+
9146+
// Enable channel 16 by providing an update in both directions
9147+
let update_b = UnsignedChannelUpdate {
9148+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9149+
short_channel_id: i + 42,
9150+
timestamp: 2,
9151+
message_flags: 1, // Only must_be_one
9152+
channel_flags: 1,
9153+
cltv_expiry_delta: (42 << 4) | 1,
9154+
htlc_minimum_msat: 0,
9155+
htlc_maximum_msat: 10_000_000,
9156+
fee_base_msat: u32::MAX,
9157+
fee_proportional_millionths: 0,
9158+
excess_data: Vec::new(),
9159+
};
9160+
update_channel(&gossip_sync, &secp_ctx, &privkeys[2], update_b);
9161+
}
9162+
9163+
// Ensure that we can build a route for 3M msat across the three paths to node 2.
9164+
let config = UserConfig::default();
9165+
let mut payment_params = PaymentParameters::from_node_id(nodes[2], 42)
9166+
.with_bolt11_features(channelmanager::provided_bolt11_invoice_features(&config))
9167+
.unwrap();
9168+
payment_params.max_channel_saturation_power_of_half = 0;
9169+
let route_params =
9170+
RouteParameters::from_payment_params_and_value(payment_params, 3_000_000);
9171+
let route = get_route(
9172+
&our_id,
9173+
&route_params,
9174+
&network_graph.read_only(),
9175+
None,
9176+
Arc::clone(&logger),
9177+
&scorer,
9178+
&Default::default(),
9179+
&random_seed_bytes,
9180+
)
9181+
.unwrap();
9182+
assert_eq!(route.paths.len(), 3);
9183+
for path in route.paths {
9184+
assert_eq!(path.hops.len(), 2);
9185+
}
9186+
9187+
// Finally, create a single channel with fee of 2 sat from node 1 to node 2 which allows
9188+
// for a larger payment.
9189+
let features_16 = ChannelFeatures::from_le_bytes(id_to_feature_flags(16));
9190+
add_channel(&gossip_sync, &secp_ctx, &privkeys[1], &privkeys[2], features_16, 16);
9191+
9192+
// Set the fee on channel 16 to 2 sats, max HTLC to 3M msat
9193+
let update_16_a = UnsignedChannelUpdate {
9194+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9195+
short_channel_id: 16,
9196+
timestamp: 2,
9197+
message_flags: 1, // Only must_be_one
9198+
channel_flags: 0,
9199+
cltv_expiry_delta: (16 << 4) | 1,
9200+
htlc_minimum_msat: 0,
9201+
htlc_maximum_msat: 3_000_000,
9202+
fee_base_msat: 2_000,
9203+
fee_proportional_millionths: 0,
9204+
excess_data: Vec::new(),
9205+
};
9206+
update_channel(&gossip_sync, &secp_ctx, &privkeys[1], update_16_a);
9207+
9208+
// Enable channel 16 by providing an update in both directions
9209+
let update_16_b = UnsignedChannelUpdate {
9210+
chain_hash: ChainHash::using_genesis_block(Network::Testnet),
9211+
short_channel_id: 16,
9212+
timestamp: 2,
9213+
message_flags: 1, // Only must_be_one
9214+
channel_flags: 1,
9215+
cltv_expiry_delta: (16 << 4) | 1,
9216+
htlc_minimum_msat: 0,
9217+
htlc_maximum_msat: 10_000_000,
9218+
fee_base_msat: u32::MAX,
9219+
fee_proportional_millionths: 0,
9220+
excess_data: Vec::new(),
9221+
};
9222+
update_channel(&gossip_sync, &secp_ctx, &privkeys[2], update_16_b);
9223+
9224+
// Ensure that we now build a route for 3M msat across just the new path
9225+
let route = get_route(
9226+
&our_id,
9227+
&route_params,
9228+
&network_graph.read_only(),
9229+
None,
9230+
Arc::clone(&logger),
9231+
&scorer,
9232+
&Default::default(),
9233+
&random_seed_bytes,
9234+
)
9235+
.unwrap();
9236+
assert_eq!(route.paths.len(), 1);
9237+
assert_eq!(route.paths[0].hops.len(), 2);
9238+
assert_eq!(route.paths[0].hops[1].short_channel_id, 16);
9239+
}
90129240
}
90139241

90149242
#[cfg(any(test, ldk_bench))]

lightning/src/routing/test_utils.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ pub(crate) fn update_channel(
112112

113113
match gossip_sync.handle_channel_update(Some(node_pubkey), &valid_channel_update) {
114114
Ok(res) => assert!(res),
115-
Err(_) => panic!()
115+
Err(e) => panic!("{e:?}")
116116
};
117117
}
118118

0 commit comments

Comments
 (0)