Skip to content

[v0.21.x-branch] Backport #10942: htlcswitch: forward blinded payments addressed by node_id - #11010

Merged
Roasbeef merged 10 commits into
v0.21.x-branchfrom
backport-10942-to-v0.21.x-branch
Aug 3, 2026
Merged

[v0.21.x-branch] Backport #10942: htlcswitch: forward blinded payments addressed by node_id#11010
Roasbeef merged 10 commits into
v0.21.x-branchfrom
backport-10942-to-v0.21.x-branch

Conversation

@github-actions

Copy link
Copy Markdown

Backport of #10942


Fixes #10937.

When lnd is the introduction node of a blinded path, the recipient may identify the next hop by next_node_id instead of a short_channel_id. lnd only handled the channel-ID case and failed such forwards with next SCID not set for non-final blinded hop.

The next hop now becomes an fn.Either[ShortChannelID, pubkey]: decode yields the peer's key when only next_node_id is present, and the switch resolves it through the same non-strict getLinks path that already load-balances across a peer's channels. outgoingChanID (the circuit key) stays a short channel ID, set once a channel is chosen, so private and alias channels need no special handling.

Alternative considered

Resolving the node ID to a channel at decode time is smaller but makes onion decoding depend on live link state and re-implements alias handling the switch already owns. Resolving in the switch keeps decode pure and is correct for alias channels by construction.

During non-strict forwarding, handlePacketAdd evaluates every candidate
channel to the next peer and calls CheckHtlcForward with the sender-requested
outgoing SCID (originalOutgoingChanID) for each candidate. That SCID flowed
through canSendHtlc into AuxTrafficShaper.ShouldHandleTraffic, so a
channel-keyed shaper was asked about the requested channel rather than the
candidate actually being evaluated. With parallel channels to a peer this
inspects the wrong channel.

Key the shaper on l.ShortChanID() (the channel under evaluation) instead.
originalScid is retained solely for createFailureWithUpdate / FailAliasUpdate,
so the alias-aware channel_update returned to the sender is unchanged and the
real SCID handed to the shaper never leaks onto the wire.

(cherry picked from commit b166780)
A blinded route may identify the next hop by node ID (next_node_id) rather
than by channel, in which case there is no sender-specified outgoing channel
to report to an HTLC interceptor. Add an outgoing_node_id field to
ForwardHtlcInterceptRequest to carry the next hop's public key for these
forwards, and document that outgoing_requested_chan_id then holds a reserved
sentinel value so that clients switching on a zero channel ID to detect the
exit hop do not misclassify the forward as a final receive.

This commit only adds the schema and regenerated stubs; the fields are
populated by later commits.

(cherry picked from commit 14640a5)
@github-actions github-actions Bot added this to the v0.21.2 milestone Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Author

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-10942-to-v0.21.x-branch
git worktree add --checkout .worktree/backport-10942-to-v0.21.x-branch backport-10942-to-v0.21.x-branch
cd .worktree/backport-10942-to-v0.21.x-branch
git reset --hard HEAD^
git cherry-pick -x d28a71765bf639bd3917d9b67cdc0afc49209a25 4fd4289a08c34024c44747182b7c668e604e86fd dbc5704070a11c24694598b2101796e8a88348ec a4844ef52299bbe29259d9183164fe041871d18b 32373b76c7b74aba29655aefce25c24ff0c2771a 9c4b8bfec2e35d7fc2810cc3eb9b1162053f79bf da6a40c01d4963df8a462672b423e059d9a859a8 f42b4298992a64d49db0b0ddbf774f68ead089fd
git push --force-with-lease

The forwarding next hop is currently always a short channel ID. To allow a
blinded route to identify the next hop by node ID instead, change
ForwardingInfo.NextHop to fn.Either[lnwire.ShortChannelID, [33]byte], where
the Left is the outgoing channel ID and the Right (wired up in a follow-up
commit) is the next node's public key.

This commit is a pure representational change with no behavioural effect:
every next hop is still a channel ID. The Either is encapsulated behind
ForwardingInfo methods so callers never destructure it directly: IsExit()
is the single source of truth for exit-hop detection (used by the link and
the contract court) and NextHopChannel() yields the outgoing SCID.

(cherry picked from commit d28a717)
Some implementations (e.g. Core Lightning) identify the next hop in a
blinded route by the next node's ID (next_node_id) instead of a short
channel ID. Decode such a hop into a node-ID next hop, the Right of
ForwardingInfo.NextHop, holding the next node's public key. The switch
resolves that key to one of our channels with the peer in a later commit.

BOLT 4 requires a non-final blinded hop to carry exactly one of
short_channel_id or next_node_id, so a hop that sets both is rejected.

(cherry picked from commit 4fd4289)
Fixes #10937: forward a blinded-route payment when the
recipient identifies the next hop by node ID rather than a short channel
ID. The htlcPacket carries the decoded next hop to the switch, whose
handlePacketAdd resolves the pubkey to the peer's links via getLinks() and
lets the existing non-strict forwarding logic load-balance across the
peer's channels.

outgoingChanID stays a ShortChannelID. It is the persisted CircuitKey and
is set to the selected channel after non-strict selection. The circular
route check filters candidate channels before selection.

(cherry picked from commit dbc5704)
Now that the switch forwards blinded hops identified by node ID, a new
problem surfaces in the HTLC event stream. A node-ID next hop has no
outgoing short channel ID until non-strict forwarding selects one, so a
forward that fails before selection still carries outgoingChanID ==
hop.Exit. getEventType keys the exit hop off that sentinel, so it
misclassifies such a failed node-ID forward as a receive, mislabeling the
event streamed via SubscribeHtlcEvents (a forwarding failure reported as
a receive failure).

Two paths reach getEventType before an SCID is selected: the fail packet
built by failAddPacket and the resolution packet built by resolve, both
of which dropped the decoded next hop. Carry outgoingHop into both, and
classify a Right (node-ID) outgoingHop as a forward before the hop.Exit
check. A node-ID next hop is always a forward, never the exit hop.

(cherry picked from commit a4844ef)
…ceptor

When the switch forwards a blinded hop identified by node ID, it has not
yet resolved a concrete outgoing channel at interception time. Expose the
next hop to the interceptor: InterceptedForward.Packet() reports the
packet's outgoing channel as-is (hop.Exit, since none is selected yet) and
carries the requested pubkey in OutgoingNodeID.

At the RPC boundary, forwardInterceptor.onIntercept maps a node-ID hop to
the reserved NodeIDForwardSCID sentinel in outgoing_requested_chan_id and
the pubkey in outgoing_requested_node_id, so a client switching on a zero
channel ID to detect the exit hop does not misread the forward as a final
receive. The sentinel is a wire-only concern, applied where the request is
built rather than in the switch's internal InterceptedPacket, which stays
truthful (OutgoingNodeID.IsSome() is the node-ID discriminator).

(cherry picked from commit 32373b7)
@ziggie1984
ziggie1984 force-pushed the backport-10942-to-v0.21.x-branch branch from 063eecb to 88235fb Compare July 30, 2026 14:35
@ziggie1984

ziggie1984 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Resolved the automated backport conflict and force-pushed the completed backport.

Reviewer note: only three original commits required manual v0.21 compatibility handling:

  • d28a71765htlcswitch/hop/forwarding_info.go: resolved the import conflict by retaining v0.21's github.com/btcsuite/btcd/chaincfg/chainhash instead of master's chainhash/v2.
  • 9c4b8bfecwitness_beacon_test.go: adapted master's chanstate.HTLC usage to v0.21's channeldb.HTLC. The newly added test's type reference was not a textual Git conflict; compilation exposed it, and the adaptation is folded into the backported witness-beacon commit where that test was introduced.
  • da6a40c01itest/lnd_route_blinding_test.go: resolved the import conflict by retaining v0.21's legacy btcutil instead of master's btcutil/v2.

The fn/v2 and lightning-onion imports are changes from the original PR, not backport-specific additions; they were preserved unchanged. The other seven original commits applied cleanly. The manual resolutions above are compatibility-only; no forwarding behavior was manually reconciled.

Validation completed with:

git diff --check origin/v0.21.x-branch...HEAD
go test . ./htlcswitch/hop ./contractcourt ./lnrpc/routerrpc

Extend the on-chain interceptor path in the witness beacon to expose a
node-ID next hop, mirroring the off-chain path. A node-ID next hop has no
outgoing channel of its own, so the beacon reports hop.Exit as the outgoing
channel (via ForwardingInfo.NextHopChannel().UnwrapOr) and the requested
next node's public key. The RPC boundary maps that to the NodeIDForwardSCID
sentinel so the forward is not misread as a final receive.

This is the requested next hop, not the channel eventually selected by
non-strict forwarding, so the beacon deliberately does not resolve it
against the circuit map.

(cherry picked from commit 9c4b8bf)
Add integration tests for an lnd introduction node forwarding a blinded
payment whose non-final hops identify the next hop by node ID (next_node_id)
rather than a short channel ID, as produced by other implementations:

  - testBlindedRouteNextNodeID: the outgoing channel is public.
  - testBlindedRouteNextNodeIDPrivateChannel: the outgoing channel is
    private, so the node ID resolves to an SCID alias.
  - testBlindedRouteNextNodeIDRestart: the introduction node is restarted
    while the HTLC is in flight, exercising forwarding-package replay and
    re-decode of the node-ID blinded hop.

(cherry picked from commit da6a40c)
(cherry picked from commit f42b429)
@ziggie1984
ziggie1984 force-pushed the backport-10942-to-v0.21.x-branch branch from 88235fb to 8436653 Compare July 30, 2026 14:47
@ziggie1984
ziggie1984 marked this pull request as ready for review July 30, 2026 14:50
@ziggie1984
ziggie1984 requested a review from bitromortac July 30, 2026 15:00
@ziggie1984

Copy link
Copy Markdown
Collaborator

depends on #11012 which fixes the static check

@Roasbeef
Roasbeef merged commit 4381c48 into v0.21.x-branch Aug 3, 2026
37 of 39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants