htlcswitch: forward node-ID blinded hops via non-strict forwarding
What changed, and why it matters
This commit fixes a bug in LND's payment forwarding for 'blinded routes'—a privacy feature in the Lightning Network. Previously, if the next hop was identified by node ID rather than a specific channel ID, the payment could not be forwarded correctly. The change lets LND resolve the node ID to any active channel with that peer and load-balance across them. It also adds safeguards to avoid leaking private channel identifiers in error messages, which could otherwise reveal information about hidden channels.
Reviewers should verify that outgoingHop is consistently populated from hop.ForwardingInfo.NextHop in all forwarding paths, that the circular-route filter correctly handles alias SCIDs, and that no other failure paths for node-ID hops emit channel_update data. Consider whether the new FailAdd path preserves the original obfuscator behavior for locally initiated payments.
Security signals we found
Fixes a functional forwarding failure for blinded-route payments using node-ID next hops (issue #10937).
Prevents private channel SCID leakage in failure messages for node-ID blinded hops by returning FailUnknownNextPeer instead of a channel_update.
Adds circular-route filtering for node-ID next hops before non-strict forwarding selection.
Adds unit tests validating correct forwarding, circular-route rejection, and failure-message type for node-ID hops.
Evidence from the diff
The patch extends htlcPacket with an outgoingHop field (Either[ShortChannelID, [33]byte]) that preserves the original onion-decoded next-hop instruction. In switch.go’s handlePacketAdd, when outgoingHop is a node ID (Right), the switch resolves the peer’s pubkey to all links via getLinks() and applies the existing non-strict forwarding selection, while filtering out circular routes before selection. outgoingChanID remains hop.Exit until a concrete channel is chosen and is later persisted as the CircuitKey. mailbox.go’s FailAdd now returns FailUnknownNextPeer without a channel_update for node-ID hops, preventing potential leakage of private channel SCIDs in failure messages. Tests cover node-ID non-strict routing, circular-route rejection, and failure behavior.
Changed components
htlcswitch/link.gohtlcswitch/mailbox.gohtlcswitch/packet.gohtlcswitch/switch.goBlinded-route HTLC forwardingNon-strict forwarding / load-balancing logicMailbox failure handlingInspect captured patch +334 / −41
diff --git a/htlcswitch/link.go b/htlcswitch/link.go
index 5ce8160..1439171 100644
--- a/htlcswitch/link.go
+++ b/htlcswitch/link.go
@@ -3269,6 +3269,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
incomingChanID: l.ShortChanID(),
incomingHTLCID: add.ID,
outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit),
+ outgoingHop: fwdInfo.NextHop,
sourceRef: &sourceRef,
incomingAmount: add.Amount,
amount: outgoingAdd.Amount,
@@ -3346,6 +3347,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
incomingChanID: l.ShortChanID(),
incomingHTLCID: add.ID,
outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit),
+ outgoingHop: fwdInfo.NextHop,
sourceRef: &sourceRef,
incomingAmount: add.Amount,
amount: addMsg.Amount,
diff --git a/htlcswitch/mailbox.go b/htlcswitch/mailbox.go
index b283825..2a07968 100644
--- a/htlcswitch/mailbox.go
+++ b/htlcswitch/mailbox.go
@@ -699,12 +699,18 @@ func (m *memoryMailBox) FailAdd(pkt *htlcPacket) {
reason lnwire.OpaqueReason
)
- // Create a temporary channel failure which we will send back to our
- // peer if this is a forward, or report to the user if the failed
- // payment was locally initiated.
- failure := m.cfg.failMailboxUpdate(
- pkt.originalOutgoingChanID, m.cfg.shortChanID,
- )
+ var failure lnwire.FailureMessage
+ if pkt.outgoingHop.IsRight() {
+ // A node-ID next hop has no requested outgoing channel.
+ // Returning a channel_update could leak a private channel's
+ // SCID if the failure reason is persisted before blinding
+ // error processing or replayed during channel reestablishment.
+ failure = &lnwire.FailUnknownNextPeer{}
+ } else {
+ failure = m.cfg.failMailboxUpdate(
+ pkt.originalOutgoingChanID, m.cfg.shortChanID,
+ )
+ }
// If the payment was locally initiated (which is indicated by a nil
// obfuscator), we do not need to encrypt it back to the sender.
@@ -737,6 +743,8 @@ func (m *memoryMailBox) FailAdd(pkt *htlcPacket) {
failPkt := &htlcPacket{
incomingChanID: pkt.incomingChanID,
incomingHTLCID: pkt.incomingHTLCID,
+ outgoingChanID: pkt.outgoingChanID,
+ outgoingHop: pkt.outgoingHop,
circuit: pkt.circuit,
sourceRef: pkt.sourceRef,
hasSource: true,
diff --git a/htlcswitch/mailbox_test.go b/htlcswitch/mailbox_test.go
index b732694..df746c1 100644
--- a/htlcswitch/mailbox_test.go
+++ b/htlcswitch/mailbox_test.go
@@ -11,6 +11,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/chanstate"
"github.com/lightningnetwork/lnd/clock"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnmock"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
@@ -277,6 +278,17 @@ func (c *mailboxContext) sendAdds(start, num int) []*htlcPacket {
ID: uint64(start + i),
},
}
+ if i%2 == 0 {
+ pkt.outgoingHop = fn.NewLeft[
+ lnwire.ShortChannelID, [33]byte,
+ ](pkt.outgoingChanID)
+ } else {
+ var nodeID [33]byte
+ prand.Read(nodeID[:])
+ pkt.outgoingHop = fn.NewRight[
+ lnwire.ShortChannelID, [33]byte,
+ ](nodeID)
+ }
sentPackets[i] = pkt
err := c.mailbox.AddPacket(pkt)
@@ -314,6 +326,14 @@ func (c *mailboxContext) checkFails(adds []*htlcPacket) {
select {
case fail := <-c.forwards:
if add.inKey() == fail.inKey() {
+ require.Equal(
+ c.t, add.outgoingChanID,
+ fail.outgoingChanID,
+ )
+ require.Equal(
+ c.t, add.outgoingHop,
+ fail.outgoingHop,
+ )
continue
}
c.t.Fatalf("inkey mismatch #%d, add: %v vs fail: %v",
@@ -829,3 +849,54 @@ func TestMailOrchestrator(t *testing.T) {
spew.Sdump(sentPackets), spew.Sdump(recvdPackets))
}
}
+
+// TestMailBoxFailAddNodeID asserts that FailAdd for a node-ID hop returns a
+// FailUnknownNextPeer failure without a channel update.
+func TestMailBoxFailAddNodeID(t *testing.T) {
+ ctx := newMailboxContext(t, time.Now(), time.Minute)
+
+ var nodeID [33]byte
+ nodeID[0] = 0x02
+
+ pkt := &htlcPacket{
+ incomingChanID: lnwire.NewShortChanIDFromInt(1),
+ incomingHTLCID: 1,
+ outgoingHop: fn.NewRight[lnwire.ShortChannelID, [33]byte](
+ nodeID,
+ ),
+ htlc: &lnwire.UpdateAddHTLC{
+ ID: 1,
+ },
+ }
+
+ require.NoError(t, ctx.mailbox.AddPacket(pkt))
+
+ // Pull packet from mailbox to simulate link delivery.
+ select {
+ case <-ctx.mailbox.PacketOutBox():
+ case <-time.After(50 * time.Millisecond):
+ t.Fatal("timeout waiting for packet outbox")
+ }
+
+ // Fail the packet via FailAdd.
+ ctx.mailbox.FailAdd(pkt)
+
+ select {
+ case pktResponse := <-ctx.forwards:
+ require.Equal(t, pkt.incomingChanID, pktResponse.incomingChanID)
+ require.Equal(t, pkt.incomingHTLCID, pktResponse.incomingHTLCID)
+ require.Equal(t, pkt.outgoingChanID, pktResponse.outgoingChanID)
+ require.Equal(t, pkt.outgoingHop, pktResponse.outgoingHop)
+ require.NotNil(t, pktResponse.linkFailure)
+
+ var unknownNextPeer *lnwire.FailUnknownNextPeer
+ require.ErrorAs(
+ t, pktResponse.linkFailure.WireMessage(),
+ &unknownNextPeer,
+ "expected FailUnknownNextPeer for node-ID FailAdd",
+ )
+
+ case <-time.After(50 * time.Millisecond):
+ t.Fatal("timeout waiting for packet response")
+ }
+}
diff --git a/htlcswitch/packet.go b/htlcswitch/packet.go
index ed5f825..9af7e34 100644
--- a/htlcswitch/packet.go
+++ b/htlcswitch/packet.go
@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
"github.com/lightningnetwork/lnd/lnwire"
@@ -18,9 +19,23 @@ type htlcPacket struct {
incomingChanID lnwire.ShortChannelID
// outgoingChanID is the ID of the channel that we have offered or will
- // offer an outgoing HTLC on.
+ // offer an outgoing HTLC on. It is mutable and may remain zero
+ // (hop.Exit) until non-strict forwarding resolves a node-ID next hop to
+ // a concrete channel, or may differ from the requested SCID after
+ // non-strict load-balancing. A zero outgoingChanID alone does not imply
+ // an exit hop: if outgoingHop is a Right (node ID), the HTLC is a
+ // forward whose outgoing channel has not yet been selected.
outgoingChanID lnwire.ShortChannelID
+ // outgoingHop carries the immutable next-hop instruction decoded from
+ // the onion payload, following the same encoding as
+ // hop.ForwardingInfo.NextHop. The three possible cases are:
+ // 1. Left(scid) where scid != Exit: a channel-addressed forward.
+ // 2. Right(pubkey): a node-addressed forward for a blinded route,
+ // resolved to an active link via non-strict forwarding.
+ // 3. Left(Exit): a final receive at the destination/receiver node.
+ outgoingHop fn.Either[lnwire.ShortChannelID, [33]byte]
+
// incomingHTLCID is the ID of the HTLC that we have received from the peer
// on the incoming channel.
incomingHTLCID uint64
@@ -104,11 +119,10 @@ type htlcPacket struct {
// in the incoming update_add_htlc wire message.
inWireCustomRecords lnwire.CustomRecords
- // originalOutgoingChanID is used when sending back failure messages.
- // It is only used for forwarded Adds on option_scid_alias channels.
- // This is to avoid possible confusion if a payer uses the public SCID
- // but receives a channel_update with the alias SCID. Instead, the
- // payer should receive a channel_update with the public SCID.
+ // originalOutgoingChanID is used when sending back failure messages. It
+ // retains the original sender-facing requested SCID for forwarded Adds,
+ // including option_scid_alias channels. This prevents exposing the
+ // evaluated link's concrete SCID or alias in channel_update failures.
originalOutgoingChanID lnwire.ShortChannelID
// inboundFee is the fee schedule of the incoming channel.
diff --git a/htlcswitch/switch.go b/htlcswitch/switch.go
index 2c0bbdd..7461d2e 100644
--- a/htlcswitch/switch.go
+++ b/htlcswitch/switch.go
@@ -2863,41 +2863,94 @@ func (s *Switch) handlePacketAdd(packet *htlcPacket,
return s.failAddPacket(packet, failure)
}
- // Before we attempt to find a non-strict forwarding path for this
- // htlc, check whether the htlc is being routed over the same incoming
- // and outgoing channel. If our node does not allow forwards of this
- // nature, we fail the htlc early. This check is in place to disallow
- // inefficiently routed htlcs from locking up our balance. With
- // channels where the option-scid-alias feature was negotiated, we also
- // have to be sure that the IDs aren't the same since one or both could
- // be an alias.
- linkErr := s.checkCircularForward(
- packet.incomingChanID, packet.outgoingChanID,
- s.cfg.AllowCircularRoute, htlc.PaymentHash,
- )
- if linkErr != nil {
- return s.failAddPacket(packet, linkErr)
- }
+ // Collect the links that could carry this HTLC to the next hop.
+ // Non-strict forwarding then load-balances across our channels to that
+ // peer. A short channel ID maps to a link and its peer, while a blinded
+ // node-ID next hop resolves the peer directly. A node-ID hop has no
+ // sender-specified channel, so outgoingChanID stays hop.Exit until
+ // selection.
+ var interfaceLinks []ChannelLink
+ if packet.outgoingHop.IsLeft() {
+ // Before we attempt to find a non-strict forwarding path for
+ // this htlc, check whether the htlc is being routed over the
+ // same incoming and outgoing channel. If our node does not
+ // allow forwards of this nature, we fail the htlc early. This
+ // check is in place to disallow inefficiently routed htlcs from
+ // locking up our balance. With channels where the
+ // option-scid-alias feature was negotiated, we also have to be
+ // sure that the IDs aren't the same since one or both could be
+ // an alias.
+ linkErr := s.checkCircularForward(
+ packet.incomingChanID, packet.outgoingChanID,
+ s.cfg.AllowCircularRoute, htlc.PaymentHash,
+ )
+ if linkErr != nil {
+ return s.failAddPacket(packet, linkErr)
+ }
- s.indexMtx.RLock()
- targetLink, err := s.getLinkByMapping(packet)
- if err != nil {
+ s.indexMtx.RLock()
+ targetLink, err := s.getLinkByMapping(packet)
+ if err != nil {
+ s.indexMtx.RUnlock()
+
+ log.Debugf("unable to find link with "+
+ "destination %v", packet.outgoingChanID)
+
+ // If packet was forwarded from another channel link
+ // then we should notify this link that some error
+ // occurred.
+ linkError := NewLinkError(
+ &lnwire.FailUnknownNextPeer{},
+ )
+
+ return s.failAddPacket(packet, linkError)
+ }
+
+ // NOTE: for the SCID path, we fetch all links to the target
+ // peer. If parallel channels exist to the incoming peer, the
+ // candidate set may include the incoming channel even when a
+ // different SCID was requested.
+ targetPeer := targetLink.PeerPubKey()
+ interfaceLinks, _ = s.getLinks(targetPeer)
s.indexMtx.RUnlock()
+ } else {
+ // A blinded node-ID next hop identifies the peer directly, so
+ // resolve its links and let non-strict forwarding load-balance
+ // across our channels to that peer.
+ peerKey := packet.outgoingHop.UnwrapRightOr([33]byte{})
- log.Debugf("unable to find link with "+
- "destination %v", packet.outgoingChanID)
+ s.indexMtx.RLock()
+ interfaceLinks, _ = s.getLinks(peerKey)
+ s.indexMtx.RUnlock()
- // If packet was forwarded from another channel link than we
- // should notify this link that some error occurred.
- linkError := NewLinkError(
- &lnwire.FailUnknownNextPeer{},
- )
+ // Drop links that would form a disallowed circular route, so
+ // selection can't later land on the incoming channel.
+ var nonCircularLinks []ChannelLink
+ for _, link := range interfaceLinks {
+ linkErr := s.checkCircularForward(
+ packet.incomingChanID, link.ShortChanID(),
+ s.cfg.AllowCircularRoute, htlc.PaymentHash,
+ )
+ if linkErr == nil {
+ nonCircularLinks = append(
+ nonCircularLinks, link,
+ )
+ }
+ }
+ interfaceLinks = nonCircularLinks
- return s.failAddPacket(packet, linkError)
+ // Without a usable link to the peer (none exist, or all would
+ // be circular) we cannot forward. Fail as unknown next peer
+ // rather than attributing it to a specific channel.
+ if len(interfaceLinks) == 0 {
+ log.Debugf("no usable link to peer %x for blinded "+
+ "next hop", peerKey)
+
+ return s.failAddPacket(packet, NewLinkError(
+ &lnwire.FailUnknownNextPeer{},
+ ))
+ }
}
- targetPeerKey := targetLink.PeerPubKey()
- interfaceLinks, _ := s.getLinks(targetPeerKey)
- s.indexMtx.RUnlock()
// We'll keep track of any HTLC failures during the link selection
// process. This way we can return the error for precise link that the
@@ -2944,6 +2997,18 @@ func (s *Switch) handlePacketAdd(packet *htlcPacket,
// current policy, then we'll send back an error, but ensure we send
// back the error sourced at the *target* link.
if len(destinations) == 0 {
+ // A node-ID next hop has no requested outgoing channel.
+ // Returning a per-candidate failure could leak a private
+ // channel via its channel_update (a probing vector), so fail
+ // generically. Later errors don't include private data. Defense
+ // in depth: route blinding error handling hides it too via
+ // error conversion.
+ if packet.outgoingHop.IsRight() {
+ return s.failAddPacket(packet, NewLinkError(
+ &lnwire.FailUnknownNextPeer{},
+ ))
+ }
+
// At this point, some or all of the links rejected the HTLC so
// we couldn't forward it. So we'll try to look up the error
// that came from the source.
diff --git a/htlcswitch/switch_test.go b/htlcswitch/switch_test.go
index d210669..b7ec870 100644
--- a/htlcswitch/switch_test.go
+++ b/htlcswitch/switch_test.go
@@ -1988,6 +1988,139 @@ func TestCircularForwards(t *testing.T) {
}
}
+// TestNodeIDNonStrictRouting ensures that when a blinded route identifies the
+// next hop by node ID, non-strict forwarding deterministically selects a valid
+// outgoing channel to that peer and never fails the HTLC by landing on the
+// incoming channel.
+func TestNodeIDNonStrictRouting(t *testing.T) {
+ t.Parallel()
+
+ // bob is both the source of the incoming HTLC and the next hop
+ // identified by node ID, so we have two channels with bob: the channel
+ // the HTLC arrives on and a second, valid outgoing channel.
+ bobPeer, err := newMockServer(
+ t, "bob", testStartingHeight, nil, testDefaultDelta,
+ )
+ require.NoError(t, err, "unable to create bob server")
+
+ s, err := initSwitchWithTempDB(t, testStartingHeight)
+ require.NoError(t, err, "unable to init switch")
+ require.NoError(t, s.Start(), "unable to start switch")
+ defer func() { _ = s.Stop() }()
+
+ // Disallow circular routes so that forwarding back over the incoming
+ // channel is rejected.
+ s.cfg.AllowCircularRoute = false
+
+ incomingChanID, incomingScid := genID()
+ outgoingChanID, outgoingScid := genID()
+
+ incomingLink := newMockChannelLink(
+ s, incomingChanID, incomingScid, emptyScid, bobPeer,
+ true, false, false, false,
+ )
+ outgoingLink := newMockChannelLink(
+ s, outgoingChanID, outgoingScid, emptyScid, bobPeer,
+ true, false, false, false,
+ )
+ require.NoError(t, s.AddLink(incomingLink), "unable to add incoming")
+ require.NoError(t, s.AddLink(outgoingLink), "unable to add outgoing")
+
+ // Forward many HTLCs so that random selection would almost certainly
+ // land on the incoming channel, which will be sorted out by the switch.
+ const numHTLCs = 20
+ for i := 0; i < numHTLCs; i++ {
+ var hash [sha256.Size]byte
+ hash[0] = byte(i)
+
+ packet := &htlcPacket{
+ incomingChanID: incomingLink.ShortChanID(),
+ incomingHTLCID: uint64(i),
+ outgoingHop: hop.NewNodeNextHop(bobPeer.PubKey()),
+ htlc: &lnwire.UpdateAddHTLC{
+ PaymentHash: hash,
+ Amount: 1,
+ },
+ obfuscator: NewMockObfuscator(),
+ }
+
+ require.NoError(t, s.ForwardPackets(nil, packet))
+
+ select {
+ case p := <-outgoingLink.packets:
+ require.Nil(t, p.linkFailure, "unexpected link failure")
+ require.Equal(
+ t, outgoingLink.ShortChanID(),
+ p.outgoingChanID,
+ "forwarded over wrong channel",
+ )
+
+ case <-incomingLink.packets:
+ t.Fatal("HTLC forwarded over incoming (circular) " +
+ "channel")
+
+ case <-time.After(time.Second):
+ t.Fatal("no timely reply from switch")
+ }
+ }
+}
+
+// TestNodeIDNonStrictRoutingAllLinksCircular ensures that when a blinded route
+// identifies the next hop by node ID, and the only channel we have with that
+// peer is the incoming channel (forming a circular route), the switch fails the
+// HTLC early upfront.
+func TestNodeIDNonStrictRoutingAllLinksCircular(t *testing.T) {
+ t.Parallel()
+
+ bobPeer, err := newMockServer(
+ t, "bob", testStartingHeight, nil, testDefaultDelta,
+ )
+ require.NoError(t, err, "unable to create bob server")
+
+ s, err := initSwitchWithTempDB(t, testStartingHeight)
+ require.NoError(t, err, "unable to init switch")
+ require.NoError(t, s.Start(), "unable to start switch")
+ defer func() { _ = s.Stop() }()
+
+ // Disallow circular routes.
+ s.cfg.AllowCircularRoute = false
+
+ incomingChanID, incomingScid := genID()
+ incomingLink := newMockChannelLink(
+ s, incomingChanID, incomingScid, emptyScid, bobPeer,
+ true, false, false, false,
+ )
+ require.NoError(t, s.AddLink(incomingLink), "unable to add incoming")
+
+ packet := &htlcPacket{
+ incomingChanID: incomingLink.ShortChanID(),
+ incomingHTLCID: 1,
+ outgoingHop: hop.NewNodeNextHop(bobPeer.PubKey()),
+ htlc: &lnwire.UpdateAddHTLC{
+ PaymentHash: [32]byte{1},
+ Amount: 1,
+ },
+ obfuscator: NewMockObfuscator(),
+ }
+
+ err = s.ForwardPackets(nil, packet)
+ require.NoError(t, err, "unable to forward packets")
+
+ select {
+ case p := <-incomingLink.packets:
+ require.NotNil(t, p.linkFailure, "expected early link failure")
+ wireErr := p.linkFailure.WireMessage()
+ var unknownNextPeer *lnwire.FailUnknownNextPeer
+ require.ErrorAs(
+ t, wireErr, &unknownNextPeer,
+ "expected FailUnknownNextPeer",
+ )
+
+ case <-time.After(time.Second):
+ t.Fatal("no timely reply from switch")
+ }
+}
+
// TestCheckCircularForward tests the error returned by checkCircularForward
// in cases where we allow and disallow same channel circular forwards.
func TestCheckCircularForward(t *testing.T) {
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.