Skip to content

Commit d08fbe7

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 d08fbe7

File tree

2 files changed

+246
-16
lines changed

2 files changed

+246
-16
lines changed

lightning/src/routing/router.rs

Lines changed: 245 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,22 @@ 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+
// In order to avoid integer division precision loss, we simply shift the costs up to
2135+
// the top half of a u128 and divide by the value (which is, at max, just under a u64).
2136+
((fee_cost as u128) << 64) / value_msat as u128
2137+
}
2138+
}
2139+
2140+
/// Gets the fees plus scorer penalty in msats of the path.
21252141
fn get_cost_msat(&self) -> u64 {
21262142
self.get_total_fee_paid_msat().saturating_add(self.get_path_penalty_msat())
21272143
}
@@ -2788,8 +2804,6 @@ where L::Target: Logger {
27882804
*used_liquidity_msat
27892805
});
27902806

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;
27932807
// Do not consider candidate hops that would exceed the maximum path length.
27942808
let path_length_to_node = $next_hops_path_length
27952809
+ if $candidate.blinded_hint_idx().is_some() { 0 } else { 1 };
@@ -2801,6 +2815,8 @@ where L::Target: Logger {
28012815
let exceeds_cltv_delta_limit = hop_total_cltv_delta > max_total_cltv_expiry_delta as u32;
28022816

28032817
let value_contribution_msat = cmp::min(available_value_contribution_msat, $next_hops_value_contribution);
2818+
// Verify the liquidity offered by this channel complies to the minimal contribution.
2819+
let contributes_sufficient_value = value_contribution_msat >= minimal_value_contribution_msat;
28042820
// Includes paying fees for the use of the following channels.
28052821
let amount_to_transfer_over_msat: u64 = match value_contribution_msat.checked_add($next_hops_fee_msat) {
28062822
Some(result) => result,
@@ -2950,7 +2966,7 @@ where L::Target: Logger {
29502966
// Ignore hops if augmenting the current path to them would put us over `max_total_routing_fee_msat`
29512967
if total_fee_msat > max_total_routing_fee_msat {
29522968
if should_log_candidate {
2953-
log_trace!(logger, "Ignoring {} due to exceeding max total routing fee limit.", LoggedCandidateHop(&$candidate));
2969+
log_trace!(logger, "Ignoring {} with fee {total_fee_msat} due to exceeding max total routing fee limit {max_total_routing_fee_msat}.", LoggedCandidateHop(&$candidate));
29542970

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

3002-
if !old_entry.was_processed && should_replace {
3034+
if !old_entry.was_processed && new_cost < old_cost {
30033035
#[cfg(all(not(ldk_bench), any(test, fuzzing)))]
30043036
{
30053037
assert!(!old_entry.best_path_from_hop_selected);
@@ -3008,7 +3040,7 @@ where L::Target: Logger {
30083040

30093041
let new_graph_node = RouteGraphNode {
30103042
node_counter: src_node_counter,
3011-
score: cmp::max(total_fee_msat, path_htlc_minimum_msat).saturating_add(path_penalty_msat),
3043+
score: new_cost,
30123044
total_cltv_delta: hop_total_cltv_delta as u16,
30133045
value_contribution_msat,
30143046
path_length_to_node,
@@ -3558,10 +3590,7 @@ where L::Target: Logger {
35583590
// First, sort by the cost-per-value of the path, dropping the paths that cost the most for
35593591
// the value they contribute towards the payment amount.
35603592
// 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-
);
3593+
selected_route.sort_unstable_by(|a, b| b.get_cost_per_msat().cmp(&a.get_cost_per_msat()));
35653594

35663595
// We should make sure that at least 1 path left.
35673596
let mut paths_left = selected_route.len();
@@ -9009,6 +9038,207 @@ mod tests {
90099038

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

90149244
#[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)