Skip to content

Commit 5bf1bc0

Browse files
committed
[router] Avoid re-processing peers when a score component decreases
While walking the graph doing Dijkstra's, we may decrease the amount being sent along one path, and not others, based on the htlc_minimum_msat value. This may result in a lower relative fees on that path in comparison to others. In the extreme, this may result in finding a second path to a node with a lower fee than the first path found (normally impossible in a Dijkstra's implementation, as we walk next hops in fee-order). In such a case, we end up with parts of our state invalid as further hops beyond that node have been filled in using the original total fee information. Instead, we simply track which nodes have been processed and ignore and further updates to them (as it implies we've reduced the amount we're sending to find a lower absolute fee, but a higher relative fee, which isn't a better route). We check that we are in exactly this case in test builds with new in-line assertions. Note that these assertions successfully detect the bug in the previous commit. Sadly this requires an extra HashMap lookup every time we pop an element off of our heap, though we can skip a number of heap pushes during the channel walking.
1 parent e4a6e4f commit 5bf1bc0

File tree

1 file changed

+131
-74
lines changed

1 file changed

+131
-74
lines changed

lightning/src/routing/router.rs

Lines changed: 131 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,17 @@ struct PathBuildingHop {
200200
/// A mirror of the same field in RouteGraphNode. Note that this is only used during the graph
201201
/// walk and may be invalid thereafter.
202202
path_htlc_minimum_msat: u64,
203+
/// If we've already processed a node as the best node, we shouldn't process it again. Normally
204+
/// we'd just ignore it if we did as all channels would have a higher new fee, but because we
205+
/// may decrease the amounts in use as we walk the graph, the actual calculated fee may
206+
/// decrease as well. Thus, we have to explicitly track which nodes have been processed and
207+
/// avoid processing them again.
208+
was_processed: bool,
209+
#[cfg(test)]
210+
// In tests, we apply further sanity checks on cases where we skip nodes we already processed
211+
// to ensure it is specifically in cases where the fee has gone down because of a decrease in
212+
// valud_contribution_msat, which requires tracking it here.
213+
value_contribution_msat: u64,
203214
}
204215

205216
// Instantiated with a list of hops with correct data in them collected during path finding,
@@ -614,85 +625,114 @@ pub fn get_route<L: Deref>(our_node_id: &PublicKey, network: &NetworkGraph, paye
614625
total_fee_msat: u64::max_value(),
615626
htlc_minimum_msat: $directional_info.htlc_minimum_msat,
616627
path_htlc_minimum_msat,
628+
was_processed: false,
629+
#[cfg(test)]
630+
value_contribution_msat,
617631
}
618632
});
619633

620-
let mut hop_use_fee_msat = 0;
621-
let mut total_fee_msat = $next_hops_fee_msat;
622-
623-
// Ignore hop_use_fee_msat for channel-from-us as we assume all channels-from-us
624-
// will have the same effective-fee
625-
if $src_node_id != *our_node_id {
626-
match compute_fees(amount_to_transfer_over_msat, $directional_info.fees) {
627-
// max_value means we'll always fail
628-
// the old_entry.total_fee_msat > total_fee_msat check
629-
None => total_fee_msat = u64::max_value(),
630-
Some(fee_msat) => {
631-
hop_use_fee_msat = fee_msat;
632-
total_fee_msat += hop_use_fee_msat;
633-
// When calculating the lowest inbound fees to a node, we
634-
// calculate fees here not based on the actual value we think
635-
// will flow over this channel, but on the minimum value that
636-
// we'll accept flowing over it. Otherwise we may later find a
637-
// different path to the source node that is more expensive,
638-
// but which we consider to be cheaper because we are capacity
639-
// constrained and the relative fee becomes lower.
640-
match compute_fees(minimal_value_contribution_msat, old_entry.src_lowest_inbound_fees)
641-
.map(|a| a.checked_add(total_fee_msat)) {
642-
Some(Some(v)) => {
643-
total_fee_msat = v;
644-
},
645-
_ => {
646-
total_fee_msat = u64::max_value();
647-
}
648-
};
649-
}
650-
}
634+
#[allow(unused_mut)] // We only use the mut in cfg(test)
635+
let mut should_process = !old_entry.was_processed;
636+
#[cfg(any(test, feature = "fuzztarget"))]
637+
{
638+
// In test/fuzzing builds, we do extra checks to make sure the skipping
639+
// of already-seen nodes only happens in cases we expect (see below).
640+
if !should_process { should_process = true; }
651641
}
652642

653-
let new_graph_node = RouteGraphNode {
654-
pubkey: $src_node_id,
655-
lowest_fee_to_peer_through_node: total_fee_msat,
656-
lowest_fee_to_node: $next_hops_fee_msat as u64 + hop_use_fee_msat,
657-
value_contribution_msat: value_contribution_msat,
658-
path_htlc_minimum_msat,
659-
};
643+
if should_process {
644+
let mut hop_use_fee_msat = 0;
645+
let mut total_fee_msat = $next_hops_fee_msat;
646+
647+
// Ignore hop_use_fee_msat for channel-from-us as we assume all channels-from-us
648+
// will have the same effective-fee
649+
if $src_node_id != *our_node_id {
650+
match compute_fees(amount_to_transfer_over_msat, $directional_info.fees) {
651+
// max_value means we'll always fail
652+
// the old_entry.total_fee_msat > total_fee_msat check
653+
None => total_fee_msat = u64::max_value(),
654+
Some(fee_msat) => {
655+
hop_use_fee_msat = fee_msat;
656+
total_fee_msat += hop_use_fee_msat;
657+
// When calculating the lowest inbound fees to a node, we
658+
// calculate fees here not based on the actual value we think
659+
// will flow over this channel, but on the minimum value that
660+
// we'll accept flowing over it. Otherwise we may later find a
661+
// different path to the source node that is more expensive,
662+
// but which we consider to be cheaper because we are capacity
663+
// constrained and the relative fee becomes lower.
664+
match compute_fees(minimal_value_contribution_msat, old_entry.src_lowest_inbound_fees)
665+
.map(|a| a.checked_add(total_fee_msat)) {
666+
Some(Some(v)) => {
667+
total_fee_msat = v;
668+
},
669+
_ => {
670+
total_fee_msat = u64::max_value();
671+
}
672+
};
673+
}
674+
}
675+
}
660676

661-
// Update the way of reaching $src_node_id with the given $chan_id (from $dest_node_id),
662-
// if this way is cheaper than the already known
663-
// (considering the cost to "reach" this channel from the route destination,
664-
// the cost of using this channel,
665-
// and the cost of routing to the source node of this channel).
666-
// Also, consider that htlc_minimum_msat_difference, because we might end up
667-
// paying it. Consider the following exploit:
668-
// we use 2 paths to transfer 1.5 BTC. One of them is 0-fee normal 1 BTC path,
669-
// and for the other one we picked a 1sat-fee path with htlc_minimum_msat of
670-
// 1 BTC. Now, since the latter is more expensive, we gonna try to cut it
671-
// by 0.5 BTC, but then match htlc_minimum_msat by paying a fee of 0.5 BTC
672-
// to this channel.
673-
// Ideally the scoring could be smarter (e.g. 0.5*htlc_minimum_msat here),
674-
// but it may require additional tracking - we don't want to double-count
675-
// the fees included in $incl_fee_next_hops_htlc_minimum_msat, but also
676-
// can't use something that may decrease on future hops.
677-
let old_cost = cmp::max(old_entry.total_fee_msat, old_entry.path_htlc_minimum_msat);
678-
let new_cost = cmp::max(total_fee_msat, path_htlc_minimum_msat);
679-
680-
if new_cost < old_cost {
681-
targets.push(new_graph_node);
682-
old_entry.next_hops_fee_msat = $next_hops_fee_msat;
683-
old_entry.hop_use_fee_msat = hop_use_fee_msat;
684-
old_entry.total_fee_msat = total_fee_msat;
685-
old_entry.route_hop = RouteHop {
686-
pubkey: $dest_node_id.clone(),
687-
node_features: NodeFeatures::empty(),
688-
short_channel_id: $chan_id.clone(),
689-
channel_features: $chan_features.clone(),
690-
fee_msat: 0, // This value will be later filled with hop_use_fee_msat of the following channel
691-
cltv_expiry_delta: $directional_info.cltv_expiry_delta as u32,
677+
let new_graph_node = RouteGraphNode {
678+
pubkey: $src_node_id,
679+
lowest_fee_to_peer_through_node: total_fee_msat,
680+
lowest_fee_to_node: $next_hops_fee_msat as u64 + hop_use_fee_msat,
681+
value_contribution_msat: value_contribution_msat,
682+
path_htlc_minimum_msat,
692683
};
693-
old_entry.channel_fees = $directional_info.fees;
694-
old_entry.htlc_minimum_msat = $directional_info.htlc_minimum_msat;
695-
old_entry.path_htlc_minimum_msat = path_htlc_minimum_msat;
684+
685+
// Update the way of reaching $src_node_id with the given $chan_id (from $dest_node_id),
686+
// if this way is cheaper than the already known
687+
// (considering the cost to "reach" this channel from the route destination,
688+
// the cost of using this channel,
689+
// and the cost of routing to the source node of this channel).
690+
// Also, consider that htlc_minimum_msat_difference, because we might end up
691+
// paying it. Consider the following exploit:
692+
// we use 2 paths to transfer 1.5 BTC. One of them is 0-fee normal 1 BTC path,
693+
// and for the other one we picked a 1sat-fee path with htlc_minimum_msat of
694+
// 1 BTC. Now, since the latter is more expensive, we gonna try to cut it
695+
// by 0.5 BTC, but then match htlc_minimum_msat by paying a fee of 0.5 BTC
696+
// to this channel.
697+
// Ideally the scoring could be smarter (e.g. 0.5*htlc_minimum_msat here),
698+
// but it may require additional tracking - we don't want to double-count
699+
// the fees included in $incl_fee_next_hops_htlc_minimum_msat, but also
700+
// can't use something that may decrease on future hops.
701+
let old_cost = cmp::max(old_entry.total_fee_msat, old_entry.path_htlc_minimum_msat);
702+
let new_cost = cmp::max(total_fee_msat, path_htlc_minimum_msat);
703+
704+
if !old_entry.was_processed && new_cost < old_cost {
705+
targets.push(new_graph_node);
706+
old_entry.next_hops_fee_msat = $next_hops_fee_msat;
707+
old_entry.hop_use_fee_msat = hop_use_fee_msat;
708+
old_entry.total_fee_msat = total_fee_msat;
709+
old_entry.route_hop = RouteHop {
710+
pubkey: $dest_node_id.clone(),
711+
node_features: NodeFeatures::empty(),
712+
short_channel_id: $chan_id.clone(),
713+
channel_features: $chan_features.clone(),
714+
fee_msat: 0, // This value will be later filled with hop_use_fee_msat of the following channel
715+
cltv_expiry_delta: $directional_info.cltv_expiry_delta as u32,
716+
};
717+
old_entry.channel_fees = $directional_info.fees;
718+
old_entry.htlc_minimum_msat = $directional_info.htlc_minimum_msat;
719+
old_entry.path_htlc_minimum_msat = path_htlc_minimum_msat;
720+
#[cfg(test)]
721+
{
722+
old_entry.value_contribution_msat = value_contribution_msat;
723+
}
724+
} else if old_entry.was_processed && new_cost < old_cost {
725+
#[cfg(test)]
726+
{
727+
// If we're skipping processing a node which was previously
728+
// processed even though we found another path to it with a
729+
// cheaper fee, check that it was because the second path we
730+
// found (which we are processing now) has a lower value
731+
// contribution due to an HTLC minimum limit.
732+
debug_assert!(path_htlc_minimum_msat < old_entry.path_htlc_minimum_msat);
733+
debug_assert!(value_contribution_msat < old_entry.value_contribution_msat);
734+
}
735+
}
696736
}
697737
}
698738
}
@@ -707,7 +747,19 @@ pub fn get_route<L: Deref>(our_node_id: &PublicKey, network: &NetworkGraph, paye
707747
// This data can later be helpful to optimize routing (pay lower fees).
708748
macro_rules! add_entries_to_cheapest_to_target_node {
709749
( $node: expr, $node_id: expr, $fee_to_target_msat: expr, $next_hops_value_contribution: expr, $incl_fee_next_hops_htlc_minimum_msat: expr ) => {
710-
if first_hops.is_some() {
750+
let skip_node = if let Some(elem) = dist.get_mut($node_id) {
751+
let v = elem.was_processed;
752+
elem.was_processed = true;
753+
v
754+
} else {
755+
// Entries are added to dist in add_entry!() when there is a channel from a node.
756+
// Because there are no channels from payee, it will not have a dist entry at this point.
757+
// If we're processing any other node, it is always be the result of a channel from it.
758+
assert_eq!($node_id, payee);
759+
false
760+
};
761+
762+
if !skip_node && first_hops.is_some() {
711763
if let Some(&(ref first_hop, ref features, ref outbound_capacity_msat)) = first_hop_targets.get(&$node_id) {
712764
add_entry!(first_hop, *our_node_id, $node_id, dummy_directional_info, Some(outbound_capacity_msat / 1000), features.to_context(), $fee_to_target_msat, $next_hops_value_contribution, $incl_fee_next_hops_htlc_minimum_msat);
713765
}
@@ -720,7 +772,7 @@ pub fn get_route<L: Deref>(our_node_id: &PublicKey, network: &NetworkGraph, paye
720772
features = NodeFeatures::empty();
721773
}
722774

723-
if !features.requires_unknown_bits() {
775+
if !skip_node && !features.requires_unknown_bits() {
724776
for chan_id in $node.channels.iter() {
725777
let chan = network.get_channels().get(chan_id).unwrap();
726778
if !chan.features.requires_unknown_bits() {
@@ -940,6 +992,11 @@ pub fn get_route<L: Deref>(our_node_id: &PublicKey, network: &NetworkGraph, paye
940992
break 'path_construction;
941993
}
942994

995+
// If we found a path back to the payee, we shouldn't try to process it again. This is
996+
// the equivalent of the `elem.was_processed` check in
997+
// add_entries_to_cheapest_to_target_node!() (see comment there for more info).
998+
if pubkey == *payee { continue 'path_construction; }
999+
9431000
// Otherwise, since the current target node is not us,
9441001
// keep "unrolling" the payment graph from payee to payer by
9451002
// finding a way to reach the current target from the payer side.

0 commit comments

Comments
 (0)