Skip to content

routing: add RouteOrigin interface for multi-source pathfinding#10764

Open
calvinrzachman wants to merge 3 commits into
lightningnetwork:elle-base-branch-payment-servicefrom
calvinrzachman:route-origin-interface
Open

routing: add RouteOrigin interface for multi-source pathfinding#10764
calvinrzachman wants to merge 3 commits into
lightningnetwork:elle-base-branch-payment-servicefrom
calvinrzachman:route-origin-interface

Conversation

@calvinrzachman
Copy link
Copy Markdown
Collaborator

Change Description

Path finding currently terminates at a single concrete source vertex, which assumes the node doing the routing is the node dispatching HTLCs. An external router controller that pathfinds centrally and dispatches from multiple lnd "gateway" backends needs routes that can originate from any of those backends.

This PR introduces a RouteOrigin interface that generalizes where routes can originate from. RouteOrigin replaces the concrete source parameter with an interface method (IsOrigin(v) bool) so the path-finding algorithm (a modified version of Dijkstra running from target to source) can terminate at any vertex in a caller-provided set, naturally selecting whichever provides the cheapest path.

This is the source-end counterpart to what AdditionalEdge does at the destination end. Route hints inject extra edges near the target so the pathfinder can reach nodes the graph doesn't know about. RouteOrigin works similarly at the source: it's equivalent to adding a virtual super-source s* with zero-weight edges to each origin and running single-source Dijkstra.

s* ---0--- gw1 -------- A -------- target
s* ---0--- gw2 -------- B -------/
s* ---0--- gw3 -------- C ------/

RouteOrigin achieves the same result without s* or extra edges in the graph by widening the termination check. Subpaths of shortest paths are shortest paths, so stripping the virtual edge leaves the optimal path from the selected origin. And the min-heap ensures the first origin popped has the smallest distance among all origins in the set (no cheaper one can remain in the queue).

The default singleOrigin wraps a single vertex and produces identical behavior for all existing callers so should present no functional change for standard lnd.

  • Add RouteOrigin interface and singleOrigin implementation. Replace the concrete source parameter on findPath with origin RouteOrigin.
  • findPath returns the origin vertex it selected as its first return value. RequestRoute passes this to newRoute so route.SourcePubKey is set correctly regardless of origin type. SessionSource gains an optional Origin field, threaded to the payment session via functional options.
  • RouteRequest accepts an optional Origin field so FindRoute (used by QueryRoutes) can use multi-origin, same as the payment session and the SendPaymentV2 flow.
  • Restrict routeToSelf to single-origin callers. When a multi-origin set includes the target vertex, circular routing is not the intent. Rather we want a direct route from another origin.

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a flexible mechanism for multi-source pathfinding in lnd. By replacing the concrete source vertex with a RouteOrigin interface, the pathfinder can now efficiently determine the optimal route from a set of potential gateway nodes. This change is particularly beneficial for external payment controllers that manage multiple dispatch backends, allowing them to route payments through the most cost-effective gateway without requiring complex graph modifications.

Highlights

  • RouteOrigin Interface: Introduced the RouteOrigin interface to generalize route starting points, allowing the pathfinder to terminate at any vertex within a caller-provided set.
  • Multi-Source Pathfinding: Updated the pathfinding algorithm to support multi-source routing, enabling payment controllers to dispatch from multiple gateway nodes by selecting the cheapest origin.
  • API Updates: Refactored findPath, RequestRoute, and SessionSource to accept the new RouteOrigin interface, ensuring backward compatibility with existing single-source logic.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions Bot added the severity-high Requires knowledgeable engineer review label Apr 20, 2026
@github-actions
Copy link
Copy Markdown

🟠 PR Severity: HIGH

Automated classification | 4 files | 196 lines changed

🟠 High (4 files)
  • routing/pathfind.go - Payment pathfinding algorithm (routing/*)
  • routing/payment_session.go - Payment session management (routing/*)
  • routing/payment_session_source.go - Payment session source factory (routing/*)
  • routing/router.go - Core router logic (routing/*)

Analysis

All changed files are in the routing/* package, which handles payment pathfinding algorithms and is classified as HIGH severity. The changes touch core pathfinding logic (pathfind.go), payment session management (payment_session.go, payment_session_source.go), and the router itself (router.go).

Test files (pathfind_test.go, payment_session_test.go) were excluded from file/line counts per policy. The 4 non-test files change 196 lines — below the 500-line bump threshold, with only 4 files (below the 20-file threshold), and touching only one package (no multi-critical-package bump applies).


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors pathfinding to support multiple origins via a new RouteOrigin interface, allowing Dijkstra's algorithm to terminate at any node in a set. This change enables multi-backend payment services to find the cheapest path from several gateway nodes. A review comment correctly identifies a potential nil pointer dereference in findPath when accessing the distance map for the selected source, suggesting a more robust check for origin presence in the map.

Comment thread routing/pathfind.go
@saubyk saubyk added this to lnd v0.22 Apr 20, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in lnd v0.22 Apr 20, 2026
@saubyk saubyk moved this from Backlog to In review in lnd v0.22 Apr 20, 2026
@saubyk saubyk moved this from In review to In progress in lnd v0.22 Apr 20, 2026
@lightninglabs-deploy
Copy link
Copy Markdown
Collaborator

@calvinrzachman, remember to re-request review from reviewers when ready

Copy link
Copy Markdown
Collaborator

@bitromortac bitromortac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good conceptually 🙏

Comment thread routing/pathfind.go Outdated
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we return here only in case we have a single source? Otherwise we'd reject pathfinding attempts that would contain self among others. (I know that's not a mode you currently operate in).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, don't think I'd this with how it's used currently but still a good catch. Gated both early returns on single-source mode per your fixup — multi-origin now skips the balance pre-check so the search can discover paths through other gateways even when self lacks balance.

Comment thread routing/pathfind.go Outdated
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, maybe needs a single source check

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated. same fix applied to both the errInsufficientBalance and errNoPathFound returns.

Comment thread routing/pathfind.go Outdated
// on first visit rather than terminating immediately. For
// multi-origin, the target may happen to be in the origin set
// but we still want a direct route from another origin.
_, isSingle := origin.(*singleOrigin)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is kind of an antipattern, I'd prefer a function (closure) instead of an interface of type type RouteOrigin func(v route.Vertex) bool, that way we can use simple closures and would probably be more performant. To do that we'd need to add a routeToSelf bool.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had considered keeping the interface with something like an AllowCircular() method as an alternative way to avoid the type assertion, but the func type is probably simpler.

With routeToSelf extracted to an explicit bool there's no remaining reason for the interface. Switched to the func type as advised. Thanks for the example commits.

@ziggie1984 ziggie1984 force-pushed the elle-base-branch-payment-service branch from 4c5132e to 673ee6a Compare May 4, 2026 13:16
@calvinrzachman calvinrzachman force-pushed the route-origin-interface branch from 6b942a7 to cd7723d Compare May 6, 2026 05:14
@calvinrzachman calvinrzachman requested a review from bitromortac May 6, 2026 05:21
@calvinrzachman calvinrzachman force-pushed the route-origin-interface branch 2 times, most recently from 942436e to f77dc93 Compare May 6, 2026 06:09
Copy link
Copy Markdown
Collaborator

@bitromortac bitromortac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the changes, I think the PR still needs some rebase work and commit messages need updates.

Comment thread routing/pathfind.go Outdated
// Implementations should be O(1). findPath calls RouteOrigin once
// per heap pop and once per edge relaxation, so any per-call cost
// directly contributes to path-finding latency.
type RouteOrigin func(v route.Vertex) bool
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: rename to IsRouteOrigin to reflect the bool type.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to IsRouteOrigin ✅

Comment thread routing/pathfind.go Outdated
cfg *PathFindingConfig, self, source, target route.Vertex,
amt lnwire.MilliSatoshi, timePref float64, finalHtlcExpiry int32) (
[]*unifiedEdge, float64, error)
cfg *PathFindingConfig, self route.Vertex, origin RouteOrigin,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

origin -> isOrigin

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated.

Comment thread routing/pathfind_test.go Outdated
),
}

ctx := newPathFindingTestContext(t, useCache, testChannels, "gw1")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe specify a realistic "self"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to use a separate node as self rather than a gateway.

Comment thread routing/pathfind_test.go
// When the target is also an origin (circular payment scenario), the
// pathfinder should still find a valid route.
targetIsOrigin := multiOrigin(map[route.Vertex]struct{}{
gw1: {},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test is not really computing a circular route as we don't have another channel originating from target? and maybe isolate by removing gw1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this isn't a circular route in the single-node sense. It's a cross-gateway rebalance (logical circular rebalance for a multi-node entityt): the proxy controls both gw1 and dest, and the pathfinder routes from gw1 rather than trying dest→dest. Updated the comment.

Comment thread routing/pathfind_test.go Outdated
},
r, cfg, sourceNode.PubKeyBytes, source, target, amt,
r, cfg, sourceNode.PubKeyBytes,
func(v route.Vertex) bool { return v == sourceAlias }, target, amt,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do linter fixes already as you introduce changes, otherwise this adds review friction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies. reduced to fewer commits and this should be fixed now 🙏

Comment thread routing/pathfind_test.go Outdated
var routeEdges []*unifiedEdge
err = graph.GraphSession(ctx, func(graph graphdb.NodeTraverser) error {
route, _, err = findPath(
sourceAlias := source
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the copy seems unnecessary?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. Was a workaround for a variable shadow that no longer exists.

Comment thread routing/pathfind.go
Comment on lines -685 to +690
// balance available.
// balance available. This check is skipped when self is not in the
// origin set (e.g. multi-origin), since local balance information is
// not available for remote origin nodes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put the doc change to the commit where behavior is changed, same below with routeToSelf

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be fixed now with commit consolidation.

Comment thread routing/pathfind_test.go
gw1: {},
target: {},
})
source, path, err = findPathWithOrigin(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be best to restructure the PR such that you do the prep work earlier, such as returning the source. Then you could introduce all the tests at once when you allow for multi-origins.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured things a bit. The first commit is a pure refactor returning the source vertex from findPath. Then IsRouteOrigin and multi-origin tests come in the second commit, followed up by the docs in the final commit.

findPath now returns the source vertex it settled on as its first return
value, alongside the path and probability. For standard callers this is
always the source they passed in. This prepares for a follow-up that
generalizes the source parameter so the pathfinder can terminate at any
vertex in a caller-provided set.
The findPath function previously accepted a concrete source vertex that
the path-finding loop terminated at. This introduces an IsRouteOrigin
func type and replaces the source parameter on findPath with it. Standard
callers pass a simple vertex-equality closure and get identical behavior.
IsRouteOrigin is the source-end counterpart to AdditionalEdge, which
extends the graph at the destination end via route hints.

After the path-finding loop, we verify the settled node is a valid
origin before unraveling the forward path. When the heap empties without
reaching an origin, we return errNoPathFound.

A routeToSelf bool parameter restricts circular self-payments to
single-origin callers. The local balance pre-check is gated on
routeToSelf so multi-origin searches continue through other gateways
when self lacks sufficient balance. SessionSource gains an optional
Origin field threaded to the payment session via functional options.
RouteRequest accepts an optional Origin field so FindRoute can use
multi-origin, same as the payment session.
@calvinrzachman calvinrzachman force-pushed the route-origin-interface branch from f77dc93 to 93d9fd5 Compare May 12, 2026 17:43
@github-actions github-actions Bot added severity-high Requires knowledgeable engineer review and removed severity-high Requires knowledgeable engineer review labels May 12, 2026
@ziggie1984
Copy link
Copy Markdown
Collaborator

One readability concern: routeToSelf seems to carry two related but different meanings now.

In the early local balance checks, it is used more like "strict local/default origin mode":

if total < amt {
    if routeToSelf {
        return errInsufficientBalance
    }
}

At that point it does not necessarily mean the target is self yet; it mostly means we are in the legacy/default single-origin path where local self liquidity is decisive.

Later it is narrowed into the actual route-to-self meaning:

routeToSelf = routeToSelf && isOrigin(target)

and then used to allow/disallow the target cycle behavior.

Would it be clearer to split these concepts or rename the incoming bool? For example, an input like singleSelfOrigin / localOriginOnly / strictLocalBalanceCheck, then derive a separate local routeToSelf := singleSelfOrigin && isOrigin(target) for the cycle logic. That would make the balance-check behavior and circular-route behavior easier to reason about independently.

Copy link
Copy Markdown
Collaborator

@ziggie1984 ziggie1984 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, only some minor questions

Comment thread routing/pathfind.go

// RouteOrigin determines where routes can originate from. The backward
// Dijkstra terminates when it reaches any origin vertex. This is the
// source-end counterpart to AdditionalEdge, which extends the graph at the
Copy link
Copy Markdown
Collaborator

@ziggie1984 ziggie1984 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this allow also chaining of the source vertex or is only one entry allowed ? Having Gateyway1<=GatewayPrevious ...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we have chaining here. IsRouteOrigin is a flat set of termination points, not a chain of edges. I think the analogy made in the PR description to route hints is helpful but not total. Route hints on the receive side add actual edges the pathfinder traverses (each hop becomes an onion layer). IsRouteOrigin just widens the termination condition of path finding - no edges added or extra hops.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each origin must be a node you operate and can dispatch HTLCs from (via SendOnion). The use case is an external controller that pathfinds centrally across multiple lnd backends (like PS): the predicate returns true for each gateway backend, and the pathfinder terminates at whichever one provides the cheapest path.

You could technically put any node in the origin set and the pathfinder would produce a valid graph path. But the route is also a payment instruction: the source node must dispatch an HTLC to the first hop. If you don't operate that node, you can't execute payment via the route.

Comment thread routing/pathfind.go
Comment thread routing/pathfind.go
"balance: %v", amt, total)

return route.Vertex{}, nil, 0, errInsufficientBalance
if routeToSelf {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the supplied optional origins could also include the source node and that's why we only abort if it is the legacy case only ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

severity-high Requires knowledgeable engineer review

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

5 participants