Skip to content

Commit aa9c2c2

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 5a18e5c commit aa9c2c2

File tree

1 file changed

+133
-76
lines changed

1 file changed

+133
-76
lines changed

lightning/src/routing/router.rs

Lines changed: 133 additions & 76 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,
@@ -615,87 +626,116 @@ pub fn get_route<L: Deref>(our_node_id: &PublicKey, network: &NetworkGraph, paye
615626
total_fee_msat: u64::max_value(),
616627
htlc_minimum_msat: $directional_info.htlc_minimum_msat,
617628
path_htlc_minimum_msat,
629+
was_processed: false,
630+
#[cfg(test)]
631+
value_contribution_msat,
618632
}
619633
});
620634

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

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

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

726-
if !features.requires_unknown_bits() {
778+
if !skip_node && !features.requires_unknown_bits() {
727779
for chan_id in $node.channels.iter() {
728780
let chan = network.get_channels().get(chan_id).unwrap();
729781
if !chan.features.requires_unknown_bits() {
@@ -942,6 +994,11 @@ pub fn get_route<L: Deref>(our_node_id: &PublicKey, network: &NetworkGraph, paye
942994
break 'path_construction;
943995
}
944996

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

0 commit comments

Comments
 (0)