From 939e25da66ccc205948d642fbd0e4f23c385a985 Mon Sep 17 00:00:00 2001 From: Thomas HUET <81159533+thomash-acinq@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:21:59 +0100 Subject: [PATCH] Add path finding for blinded routes (#3027) When generating a blot12 invoice, we may need to find a payment path using only nodes that support route blinding. --- .../scala/fr/acinq/eclair/router/Graph.scala | 32 +++++++++++ .../eclair/router/RouteCalculation.scala | 21 ++++++- .../scala/fr/acinq/eclair/router/Router.scala | 11 ++++ .../fr/acinq/eclair/router/GraphSpec.scala | 56 ++++++++++++++++++- 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala index c5aedf5d3..832fc0d11 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala @@ -434,6 +434,38 @@ object Graph { wr: MessageWeightRatios): Option[Seq[GraphEdge]] = dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) + /** + * Find non-overlapping (no vertices shared) payment paths that support route blinding + * This is used to build blinded routes for Bolt12 invoices where `sourceNode` is the first node of the blinded path and `targetNode` is ourself. + * + * @param pathsToFind Number of paths to find. We may return fewer paths if we couldn't find more non-overlapping ones. + */ + def routeBlindingPaths(graph: DirectedGraph, + sourceNode: PublicKey, + targetNode: PublicKey, + amount: MilliSatoshi, + ignoredEdges: Set[ChannelDesc], + ignoredVertices: Set[PublicKey], + pathsToFind: Int, + wr: WeightRatios[PaymentPathWeight], + currentBlockHeight: BlockHeight, + boundaries: PaymentPathWeight => Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + val paths = new mutable.ArrayBuffer[WeightedPath[PaymentPathWeight]](pathsToFind) + val verticesToIgnore = new mutable.HashSet[PublicKey]() + verticesToIgnore.addAll(ignoredVertices) + for (_ <- 1 to pathsToFind) { + dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) match { + case Some(path) => + val weight = pathWeight(sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true) + paths += WeightedPath(path, weight) + // Additional paths must keep using the source and target nodes, but shouldn't use any of the same intermediate nodes. + verticesToIgnore.addAll(path.drop(1).map(_.desc.a)) + case None => return paths.toSeq + } + } + paths.toSeq + } + /** * Calculate the minimum amount that the start node needs to receive to be able to forward @amountWithFees to the end * node. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index c8e532ebc..db9afcdcf 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.router -import akka.actor.{ActorContext, ActorRef, Status} +import akka.actor.{ActorContext, ActorRef} import akka.event.DiagnosticLoggingAdapter import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey @@ -229,6 +229,25 @@ object RouteCalculation { } } + def handleBlindedRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: BlindedRouteRequest)(implicit log: DiagnosticLoggingAdapter): Data = { + val maxFee = r.routeParams.getMaxFee(r.amount) + + val boundaries: PaymentPathWeight => Boolean = { weight => + weight.amount - r.amount <= maxFee && + weight.length <= r.routeParams.boundaries.maxRouteLength && + weight.length <= ROUTE_MAX_LENGTH && + weight.cltv <= r.routeParams.boundaries.maxCltv + } + + val routes = Graph.routeBlindingPaths(d.graphWithBalances.graph, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries) + if (routes.isEmpty) { + r.replyTo ! PaymentRouteNotFound(RouteNotFound) + } else { + r.replyTo ! RouteResponse(routes.map(route => Route(r.amount, route.path.map(graphEdgeToHop), None))) + } + d + } + def handleMessageRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: MessageRouteRequest, routeParams: MessageRouteParams)(implicit log: DiagnosticLoggingAdapter): Data = { val boundaries: MessagePathWeight => Boolean = { weight => weight.length <= routeParams.maxRouteLength && weight.length <= ROUTE_MAX_LENGTH diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala index dcc9e5511..ce67a7c96 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala @@ -241,6 +241,9 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm case Event(r: RouteRequest, d) => stay() using RouteCalculation.handleRouteRequest(d, nodeParams.currentBlockHeight, r) + case Event(r: BlindedRouteRequest, d) => + stay() using RouteCalculation.handleBlindedRouteRequest(d, nodeParams.currentBlockHeight, r) + case Event(r: MessageRouteRequest, d) => stay() using RouteCalculation.handleMessageRouteRequest(d, nodeParams.currentBlockHeight, r, nodeParams.routerConf.messageRouteParams) @@ -614,6 +617,14 @@ object Router { pendingPayments: Seq[Route] = Nil, paymentContext: Option[PaymentContext] = None) + case class BlindedRouteRequest(replyTo: typed.ActorRef[PaymentRouteResponse], + source: PublicKey, + target: PublicKey, + amount: MilliSatoshi, + routeParams: RouteParams, + pathsToFind: Int, + ignore: Ignore = Ignore.empty) + case class FinalizeRoute(replyTo: typed.ActorRef[PaymentRouteResponse], route: PredefinedRoute, extraEdges: Seq[ExtraEdge] = Nil, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala index 48ce3f2dd..c84799c77 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala @@ -21,7 +21,7 @@ import fr.acinq.bitcoin.scalacompat.SatoshiLong import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Announcements.makeNodeAnnouncement import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} -import fr.acinq.eclair.router.Graph.{HeuristicsConstants, MessagePathWeight, MessageWeightRatios, PaymentWeightRatios, dijkstraMessagePath, yenKshortestPaths} +import fr.acinq.eclair.router.Graph.{HeuristicsConstants, MessagePathWeight, MessageWeightRatios, PaymentWeightRatios, dijkstraMessagePath, routeBlindingPaths, yenKshortestPaths} import fr.acinq.eclair.router.RouteCalculationSpec._ import fr.acinq.eclair.router.Router.ChannelDesc import fr.acinq.eclair.wire.protocol.Color @@ -479,4 +479,58 @@ class GraphSpec extends AnyFunSuite { assert(g == g.updateChannel(ChannelDesc(ShortChannelId(1), randomKey().publicKey, b), RealShortChannelId(10), 99 sat)) } + test("blinded routes for bolt12 invoices") { + /* + D does not support route blinding + + +----- B ------+ + | | + A -- C -- D -- H --+ + | | | + +--- E -- F ---+ | + | | + +--- G -------+ + */ + val graph = DirectedGraph(Seq( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(1L, b, a, 1 msat, 1), + makeEdge(2L, b, h, 2 msat, 2), + makeEdge(2L, h, b, 3 msat, 3), + makeEdge(3L, a, c, 4 msat, 4), + makeEdge(3L, c, a, 5 msat, 5), + makeEdge(4L, c, d, 6 msat, 6), + makeEdge(4L, d, c, 7 msat, 7), + makeEdge(5L, d, h, 8 msat, 8), + makeEdge(5L, h, d, 9 msat, 9), + makeEdge(6L, a, e, 10 msat, 10), + makeEdge(6L, e, a, 11 msat, 11), + makeEdge(7L, e, f, 12 msat, 12), + makeEdge(7L, f, e, 13 msat, 13), + makeEdge(8L, f, h, 14 msat, 14), + makeEdge(8L, h, f, 15 msat, 15), + makeEdge(9L, e, g, 16 msat, 16), + makeEdge(9L, g, e, 17 msat, 17), + makeEdge(10L, g, h, 18 msat, 18), + makeEdge(10L, h, g, 19 msat, 19), + )).addOrUpdateVertex(makeNodeAnnouncement(priv_a, "A", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_b, "B", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_c, "C", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_d, "D", Color(0, 0, 0), Nil, Features())) + .addOrUpdateVertex(makeNodeAnnouncement(priv_e, "E", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_f, "F", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_g, "G", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + .addOrUpdateVertex(makeNodeAnnouncement(priv_h, "H", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) + + { + val paths = routeBlindingPaths(graph, a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)), BlockHeight(793397), _ => true) + assert(paths.length == 2) + assert(paths(0).path.map(_.desc.a) == Seq(a, b)) + assert(paths(1).path.map(_.desc.a) == Seq(a, e, f)) + } + { + val paths = routeBlindingPaths(graph, c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)), BlockHeight(793397), _ => true) + assert(paths.length == 1) + assert(paths(0).path.map(_.desc.a) == Seq(c, a, b)) + } + } }