multi: represent the blinded forwarding next hop as an fn.Either
What changed, and why it matters
This commit is a behind-the-scenes code cleanup in the LND Lightning node. It changes how the 'next hop' for forwarding payments is stored internally so that, in the future, blinded routes can identify the next hop by a node's public key instead of only by a channel ID. The commit explicitly says it has no behavioral effect yet: every next hop is still a channel ID. There is no direct security vulnerability introduced here, but it is a preparatory step for a larger feature (blinded routing) that will need careful review when fully implemented.
No immediate action required. Treat this as a benign refactoring. When the follow-up commit that wires up node-ID next hops for blinded routes is reviewed, pay close attention to: validation of the node ID, non-strict forwarding channel selection, replay/loop prevention, fee validation, and ensuring exit-hop detection cannot be spoofed by a malformed Either value.
Security signals we found
Refactor of core HTLC forwarding data structure (ForwardingInfo.NextHop)
Preparation for blinded route next-hop identification by node public key
Encapsulation of Either type behind IsExit() and NextHopChannel() to centralize exit-hop detection
No behavioral change claimed by author; all next hops remain channel IDs
Mock serialization explicitly rejects node-ID next hops, indicating feature is not yet active
Evidence from the diff
The patch refactors ForwardingInfo.NextHop from a concrete lnwire.ShortChannelID to an fn.Either[lnwire.ShortChannelID, [33]byte], allowing future support for next-hop-by-node-id in blinded routes. It introduces helper methods IsExit(), NextHopChannel(), and NewChannelNextHop() to encapsulate the Either and keep callers from destructuring it directly. All existing call sites are updated to use these methods. The commit is described by the author as a ‘pure representational change with no behavioural effect.’ No new parsing of node-ID next hops is wired up yet; the Right variant is reserved for a follow-up commit.
Changed components
htlcswitch/hop/forwarding_info.gohtlcswitch/hop/payload.gohtlcswitch/hop/iterator.gohtlcswitch/link.gocontractcourt/htlc_incoming_contest_resolver.gowitness_beacon.goassociated test filesInspect captured patch +83 / −26
diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go
index 3ee8cd6..c1a289a 100644
--- a/contractcourt/htlc_incoming_contest_resolver.go
+++ b/contractcourt/htlc_incoming_contest_resolver.go
@@ -84,7 +84,7 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error {
func (h *htlcIncomingContestResolver) invalidFinalHtlc(
payload *hop.Payload, height uint32) bool {
- if payload.FwdInfo.NextHop != hop.Exit {
+ if !payload.FwdInfo.IsExit() {
return false
}
@@ -312,7 +312,7 @@ func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) {
hodlChan <-chan interface{}
witnessUpdates <-chan lntypes.Preimage
)
- if payload.FwdInfo.NextHop == hop.Exit {
+ if payload.FwdInfo.IsExit() {
// Create a buffered hodl chan to prevent deadlock.
hodlQueue := queue.NewConcurrentQueue(10)
hodlQueue.Start()
@@ -701,7 +701,7 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
// Exit early if this is not the exit hop, which means we are not the
// payment receiver and don't have the preimage.
- if payload.FwdInfo.NextHop != hop.Exit {
+ if !payload.FwdInfo.IsExit() {
return false, nil
}
diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go
index f46b924..8e05872 100644
--- a/htlcswitch/hop/forwarding_info.go
+++ b/htlcswitch/hop/forwarding_info.go
@@ -2,6 +2,7 @@ package hop
import (
"github.com/btcsuite/btcd/chainhash/v2"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
)
@@ -11,10 +12,14 @@ import (
// received within the incoming HTLC, to ensure that the prior hop didn't
// tamper with the end-to-end routing information at all.
type ForwardingInfo struct {
- // NextHop is the channel ID of the next hop. The received HTLC should
- // be forwarded to this particular channel in order to continue the
- // end-to-end route.
- NextHop lnwire.ShortChannelID
+ // NextHop identifies the next hop the HTLC should be forwarded to. In
+ // the common case it is a Left holding the short channel ID of the
+ // outgoing channel. For a blinded route whose recipient identifies the
+ // next hop by node ID (next_node_id) it is a Right holding the next
+ // node's compressed public key, which the switch's non-strict
+ // forwarding logic resolves to one of our channels with that peer. The
+ // zero value is a Left equal to hop.Exit, which denotes the exit hop.
+ NextHop fn.Either[lnwire.ShortChannelID, [33]byte]
// AmountToForward is the amount of milli-satoshis that the receiving
// node should forward to the next hop.
@@ -35,6 +40,35 @@ type ForwardingInfo struct {
PathID *chainhash.Hash
}
+// NewChannelNextHop returns a next-hop value that identifies the outgoing
+// channel by its short channel ID, which is the common case.
+func NewChannelNextHop(
+ scid lnwire.ShortChannelID) fn.Either[lnwire.ShortChannelID, [33]byte] {
+
+ return fn.NewLeft[lnwire.ShortChannelID, [33]byte](scid)
+}
+
+// IsExit returns true if this forwarding info denotes the exit hop, i.e. we are
+// the final recipient of the HTLC. This is the case when the next hop is a
+// short channel ID equal to hop.Exit. A node-ID next hop (used by some blinded
+// routes) is always a forward, never the exit hop.
+func (f ForwardingInfo) IsExit() bool {
+ var isExit bool
+ f.NextHop.WhenLeft(func(scid lnwire.ShortChannelID) {
+ isExit = scid == Exit
+ })
+
+ return isExit
+}
+
+// NextHopChannel returns the short channel ID of the outgoing channel when the
+// next hop is identified by channel ID (the common case). It returns None when
+// the next hop is identified by node ID instead, in which case the outgoing
+// channel is selected by the switch's non-strict forwarding.
+func (f ForwardingInfo) NextHopChannel() fn.Option[lnwire.ShortChannelID] {
+ return f.NextHop.LeftToSome()
+}
+
// FinalHtlcValidationResult describes the result of checking a final-hop
// HTLC against the onion payload and supported final-hop CLTV range.
type FinalHtlcValidationResult uint8
diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go
index 82a5ad0..284c7c3 100644
--- a/htlcswitch/hop/forwarding_info_test.go
+++ b/htlcswitch/hop/forwarding_info_test.go
@@ -21,7 +21,7 @@ func TestValidateFinalHtlc(t *testing.T) {
fwdInfo := ForwardingInfo{
AmountToForward: amount,
OutgoingCLTV: expiry,
- NextHop: Exit,
+ NextHop: NewChannelNextHop(Exit),
}
testCases := []struct {
@@ -115,7 +115,7 @@ func TestValidateFinalHtlc(t *testing.T) {
fwdInfo: ForwardingInfo{
AmountToForward: amount,
OutgoingCLTV: expiry + maxCltvDelta + 2,
- NextHop: Exit,
+ NextHop: NewChannelNextHop(Exit),
},
validateAmount: true,
expected: FinalHtlcInvalidCltv,
diff --git a/htlcswitch/hop/fuzz_test.go b/htlcswitch/hop/fuzz_test.go
index bafede0..cbb4619 100644
--- a/htlcswitch/hop/fuzz_test.go
+++ b/htlcswitch/hop/fuzz_test.go
@@ -96,7 +96,7 @@ func hopFromPayload(p *Payload) (*route.Hop, uint64) {
BlindingPoint: p.blindingPoint,
CustomRecords: p.customRecords,
TotalAmtMsat: p.totalAmtMsat,
- }, p.FwdInfo.NextHop.ToUint64()
+ }, p.FwdInfo.NextHop.UnwrapLeftOr(Exit).ToUint64()
}
// FuzzPayloadFinal fuzzes final hop payloads, providing the additional context
diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go
index ada071e..1ef559f 100644
--- a/htlcswitch/hop/iterator.go
+++ b/htlcswitch/hop/iterator.go
@@ -324,8 +324,9 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
if err != nil {
return nil, routeRole, err
}
+
payload.FwdInfo = ForwardingInfo{
- NextHop: nextSCID.Val,
+ NextHop: NewChannelNextHop(nextSCID.Val),
AmountToForward: fwdAmt,
OutgoingCLTV: r.blindingKit.IncomingCltv - uint32(
relayInfo.Val.CltvExpiryDelta,
diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go
index e60aa16..7e6b030 100644
--- a/htlcswitch/hop/iterator_test.go
+++ b/htlcswitch/hop/iterator_test.go
@@ -33,7 +33,9 @@ func TestSphinxHopIteratorForwardingInstructions(t *testing.T) {
// extract each type, no matter the payload type.
nextAddrInt := binary.BigEndian.Uint64(hopData.NextAddress[:])
expectedFwdInfo := ForwardingInfo{
- NextHop: lnwire.NewShortChanIDFromInt(nextAddrInt),
+ NextHop: NewChannelNextHop(
+ lnwire.NewShortChanIDFromInt(nextAddrInt),
+ ),
AmountToForward: lnwire.MilliSatoshi(hopData.ForwardAmount),
OutgoingCLTV: hopData.OutgoingCltv,
}
diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go
index dccbc3a..5f60467 100644
--- a/htlcswitch/hop/payload.go
+++ b/htlcswitch/hop/payload.go
@@ -126,7 +126,9 @@ func NewLegacyPayload(f *sphinx.HopData) *Payload {
return &Payload{
FwdInfo: ForwardingInfo{
- NextHop: lnwire.NewShortChanIDFromInt(nextHop),
+ NextHop: NewChannelNextHop(
+ lnwire.NewShortChanIDFromInt(nextHop),
+ ),
AmountToForward: lnwire.MilliSatoshi(f.ForwardAmount),
OutgoingCLTV: f.OutgoingCltv,
},
@@ -201,7 +203,9 @@ func ParseTLVPayload(r io.Reader) (*Payload, map[tlv.Type][]byte, error) {
return &Payload{
FwdInfo: ForwardingInfo{
- NextHop: lnwire.NewShortChanIDFromInt(cid),
+ NextHop: NewChannelNextHop(
+ lnwire.NewShortChanIDFromInt(cid),
+ ),
AmountToForward: lnwire.MilliSatoshi(amt),
OutgoingCLTV: cltv,
},
diff --git a/htlcswitch/link.go b/htlcswitch/link.go
index 715afe7..5ce8160 100644
--- a/htlcswitch/link.go
+++ b/htlcswitch/link.go
@@ -3189,8 +3189,8 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
continue
}
- switch fwdInfo.NextHop {
- case hop.Exit:
+ switch {
+ case fwdInfo.IsExit():
err := l.processExitHop(
add, sourceRef, obfuscator, fwdInfo,
heightNow, pld,
@@ -3268,7 +3268,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
updatePacket := &htlcPacket{
incomingChanID: l.ShortChanID(),
incomingHTLCID: add.ID,
- outgoingChanID: fwdInfo.NextHop,
+ outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit),
sourceRef: &sourceRef,
incomingAmount: add.Amount,
amount: outgoingAdd.Amount,
@@ -3345,7 +3345,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
updatePacket := &htlcPacket{
incomingChanID: l.ShortChanID(),
incomingHTLCID: add.ID,
- outgoingChanID: fwdInfo.NextHop,
+ outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit),
sourceRef: &sourceRef,
incomingAmount: add.Amount,
amount: addMsg.Amount,
diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go
index 72e08f3..2e5fdfb 100644
--- a/htlcswitch/link_test.go
+++ b/htlcswitch/link_test.go
@@ -777,8 +777,9 @@ func testChannelLinkInboundFee(t *testing.T, //nolint:thelper
hops := []*hop.Payload{
{
FwdInfo: hop.ForwardingInfo{
- NextHop: n.carolChannelLink.
- ShortChanID(),
+ NextHop: hop.NewChannelNextHop(
+ n.carolChannelLink.ShortChanID(),
+ ),
AmountToForward: 1_000_000,
OutgoingCLTV: 106,
},
diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go
index a3079e6..9d201d5 100644
--- a/htlcswitch/mock.go
+++ b/htlcswitch/mock.go
@@ -368,7 +368,13 @@ func (r *mockHopIterator) EncodeNextHop(w io.Writer) error {
}
func encodeFwdInfo(w io.Writer, f *hop.ForwardingInfo) error {
- if err := binary.Write(w, binary.BigEndian, f.NextHop); err != nil {
+ if f.NextHop.IsRight() {
+ return fmt.Errorf("mock serialization does not support " +
+ "node-ID next hop")
+ }
+
+ nextHop := f.NextHopChannel().UnwrapOr(hop.Exit)
+ if err := binary.Write(w, binary.BigEndian, nextHop); err != nil {
return err
}
@@ -510,7 +516,8 @@ func (p *mockIteratorDecoder) DecodeHopIterator(r io.Reader, rHash []byte,
}
var nextHopBytes [8]byte
- binary.BigEndian.PutUint64(nextHopBytes[:], f.NextHop.ToUint64())
+ scid := f.NextHopChannel().UnwrapOr(hop.Exit)
+ binary.BigEndian.PutUint64(nextHopBytes[:], scid.ToUint64())
hops[i] = hop.NewLegacyPayload(&sphinx.HopData{
Realm: [1]byte{}, // hop.BitcoinNetwork
@@ -563,9 +570,11 @@ func (p *mockIteratorDecoder) DecodeHopIterators(id []byte,
}
func decodeFwdInfo(r io.Reader, f *hop.ForwardingInfo) error {
- if err := binary.Read(r, binary.BigEndian, &f.NextHop); err != nil {
+ var nextHop lnwire.ShortChannelID
+ if err := binary.Read(r, binary.BigEndian, &nextHop); err != nil {
return err
}
+ f.NextHop = hop.NewChannelNextHop(nextHop)
if err := binary.Read(r, binary.BigEndian, &f.AmountToForward); err != nil {
return err
diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go
index 6b6d2cc..94942fc 100644
--- a/routing/pathfind_test.go
+++ b/routing/pathfind_test.go
@@ -1190,7 +1190,9 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc
require.Equal(
t, route.Hops[i+1].ChannelID,
- payload.FwdInfo.NextHop.ToUint64(),
+ payload.FwdInfo.NextHopChannel().UnwrapOr(
+ switchhop.Exit,
+ ).ToUint64(),
)
}
@@ -1203,7 +1205,11 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc
// The final hop should have a next hop value of all zeroes in order
// to indicate it's the exit hop.
- require.Zero(t, payload.FwdInfo.NextHop.ToUint64())
+ require.Zero(
+ t, payload.FwdInfo.NextHopChannel().UnwrapOr(
+ switchhop.Exit,
+ ).ToUint64(),
+ )
var expectedTotalFee lnwire.MilliSatoshi
for i := 0; i < expectedHopCount; i++ {
diff --git a/witness_beacon.go b/witness_beacon.go
index c1ccc63..1c2e78c 100644
--- a/witness_beacon.go
+++ b/witness_beacon.go
@@ -114,7 +114,7 @@ func (p *preimageBeacon) SubscribeUpdates(
IncomingExpiry: htlc.RefundTimeout,
IncomingAmount: htlc.Amt,
IncomingCircuit: inKey,
- OutgoingChanID: payload.FwdInfo.NextHop,
+ OutgoingChanID: payload.FwdInfo.NextHopChannel().UnwrapOr(hop.Exit),
OutgoingExpiry: payload.FwdInfo.OutgoingCLTV,
OutgoingAmount: payload.FwdInfo.AmountToForward,
InOnionCustomRecords: payload.CustomRecords(),
Why this scored 18/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.