htlcswitch/hop: decode next_node_id blinded hops

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 4fd4289a08)
This commit is contained in:
bitromortac 2026-07-08 13:13:44 +00:00 committed by ziggie
parent f7e745c113
commit 0bbacacab3
No known key found for this signature in database
GPG key ID: 1AFF9C4DCED6D666
6 changed files with 471 additions and 8 deletions

View file

@ -48,6 +48,15 @@ func NewChannelNextHop(
return fn.NewLeft[lnwire.ShortChannelID, [33]byte](scid)
}
// NewNodeNextHop returns a next-hop value that identifies the next hop by the
// next node's compressed public key, as used by blinded routes that set
// next_node_id instead of a short channel ID.
func NewNodeNextHop(
nodeID [33]byte) fn.Either[lnwire.ShortChannelID, [33]byte] {
return fn.NewRight[lnwire.ShortChannelID, [33]byte](nodeID)
}
// 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
@ -69,6 +78,13 @@ func (f ForwardingInfo) NextHopChannel() fn.Option[lnwire.ShortChannelID] {
return f.NextHop.LeftToSome()
}
// NextHopNode returns the next hop's compressed pubkey when it is identified by
// node ID (blinded routes via next_node_id), or None when identified by
// channel.
func (f ForwardingInfo) NextHopNode() fn.Option[[33]byte] {
return f.NextHop.RightToSome()
}
// FinalHtlcValidationResult describes the result of checking a final-hop
// HTLC against the onion payload and supported final-hop CLTV range.
type FinalHtlcValidationResult uint8

View file

@ -3,6 +3,7 @@ package hop
import (
"testing"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
@ -135,3 +136,41 @@ func TestValidateFinalHtlc(t *testing.T) {
})
}
}
// TestForwardingInfoNextHop asserts the next-hop accessors for both the short
// channel ID (Left) and node ID (Right) representations, including the
// invariant that the zero-value ForwardingInfo denotes the exit hop.
func TestForwardingInfoNextHop(t *testing.T) {
t.Parallel()
scid := lnwire.NewShortChanIDFromInt(12345)
nodeID := [33]byte{0x02}
// The zero-value ForwardingInfo must denote the exit hop, since its
// NextHop is a Left equal to hop.Exit. Callers rely on this to detect
// that we are the final recipient.
zero := ForwardingInfo{}
require.True(t, zero.IsExit(), "zero value must be the exit hop")
require.Equal(
t, fn.Some(Exit), zero.NextHopChannel(),
"zero value must expose the Exit channel",
)
// An explicit channel next hop equal to Exit is likewise the exit hop.
exit := ForwardingInfo{NextHop: NewChannelNextHop(Exit)}
require.True(t, exit.IsExit())
// A channel next hop with a real SCID is a forward, and exposes that
// SCID through NextHopChannel.
channel := ForwardingInfo{NextHop: NewChannelNextHop(scid)}
require.False(t, channel.IsExit())
require.Equal(t, fn.Some(scid), channel.NextHopChannel())
// A node-ID next hop is always a forward and never exposes an outgoing
// channel, since the switch selects one via non-strict forwarding.
node := ForwardingInfo{NextHop: NewNodeNextHop(nodeID)}
require.False(t, node.IsExit())
require.Equal(
t, fn.None[lnwire.ShortChannelID](), node.NextHopChannel(),
)
}

View file

@ -10,6 +10,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/chaincfg/chainhash"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/tlv"
@ -231,6 +232,13 @@ func parseAndValidateRecipientData(r *sphinxHopIterator, payload *Payload,
return nil, routeRole, err
}
// BOLT 4 requires a blinded hop to set exactly one of short_channel_id
// or next_node_id. Reject a hop that sets both here.
if routeData.ShortChannelID.IsSome() && routeData.NextNodeID.IsSome() {
return nil, routeRole, fmt.Errorf("blinded hop sets both " +
"short channel ID and next node ID")
}
// This is the final node in the blinded route.
if isFinal {
return deriveBlindedRouteFinalHopForwardingInfo(
@ -318,15 +326,35 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
)
}
nextSCID, err := routeData.ShortChannelID.UnwrapOrErr(
fmt.Errorf("next SCID not set for non-final blinded hop"),
)
if err != nil {
return nil, routeRole, err
// Determine the next hop. The recipient identifies it either by a short
// channel ID (the common case) or, as some implementations do for
// blinded routes, by the next node's ID (next_node_id). Setting both is
// already rejected upstream, and the dummy hop check above has handled
// a next_node_id that points at us.
var nextHop fn.Either[lnwire.ShortChannelID, [33]byte]
switch {
case routeData.ShortChannelID.IsSome():
scid := routeData.ShortChannelID.UnwrapOr(
routeData.ShortChannelID.Zero(),
)
nextHop = NewChannelNextHop(scid.Val)
case routeData.NextNodeID.IsSome():
nodeID := routeData.NextNodeID.UnwrapOr(
routeData.NextNodeID.Zero(),
)
var pubKey [33]byte
copy(pubKey[:], nodeID.Val.SerializeCompressed())
nextHop = NewNodeNextHop(pubKey)
default:
return nil, routeRole, fmt.Errorf("next hop not set for " +
"non-final blinded hop")
}
payload.FwdInfo = ForwardingInfo{
NextHop: NewChannelNextHop(nextSCID.Val),
NextHop: nextHop,
AmountToForward: fwdAmt,
OutgoingCLTV: r.blindingKit.IncomingCltv - uint32(
relayInfo.Val.CltvExpiryDelta,

View file

@ -9,6 +9,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/davecgh/go-spew/spew"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/tlv"
@ -305,3 +306,378 @@ func TestParseAndValidateRecipientData(t *testing.T) {
})
}
}
// TestDeriveBlindedRouteNextHop asserts how a non-final blinded hop's next hop
// is derived from the recipient data: a short channel ID becomes a Left, a
// next_node_id becomes a Right, having both set is rejected with an error, and
// the absence of both is also an error.
func TestDeriveBlindedRouteNextHop(t *testing.T) {
t.Parallel()
nodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nextNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nextNodePub := nextNodeKey.PubKey()
var nextNodeRaw [33]byte
copy(nextNodeRaw[:], nextNodePub.SerializeCompressed())
scid := lnwire.NewShortChanIDFromInt(1500)
relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10](
record.PaymentRelayInfo{
CltvExpiryDelta: 10,
BaseFee: 100,
FeeRate: 0,
},
))
constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12](
record.PaymentConstraints{
MaxCltvExpiry: 1000,
HtlcMinimumMsat: lnwire.MilliSatoshi(1),
},
))
scidRecord := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType2](scid))
nodeIDRecord := tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType4](nextNodePub),
)
tests := []struct {
name string
data *record.BlindedRouteData
expectedHop fn.Either[lnwire.ShortChannelID, [33]byte]
expectedErr string
}{
{
name: "short channel id only",
data: &record.BlindedRouteData{
ShortChannelID: scidRecord,
RelayInfo: relayInfo,
Constraints: constraints,
},
expectedHop: NewChannelNextHop(scid),
},
{
name: "next node id only",
data: &record.BlindedRouteData{
NextNodeID: nodeIDRecord,
RelayInfo: relayInfo,
Constraints: constraints,
},
expectedHop: NewNodeNextHop(nextNodeRaw),
},
{
// BOLT 4 requires a non-final blinded hop to set
// exactly one of short_channel_id or next_node_id, so
// setting both must be rejected.
name: "both present is an error",
data: &record.BlindedRouteData{
ShortChannelID: scidRecord,
NextNodeID: nodeIDRecord,
RelayInfo: relayInfo,
Constraints: constraints,
},
expectedErr: "both short channel ID and next node ID",
},
{
name: "neither present",
data: &record.BlindedRouteData{
RelayInfo: relayInfo,
Constraints: constraints,
},
expectedErr: "next hop not set",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
data, err := record.EncodeBlindedRouteData(
testCase.data,
)
require.NoError(t, err)
kit := BlindingKit{
Processor: &mockProcessor{},
IncomingAmount: 10000,
IncomingCltv: 500,
UpdateAddBlinding: tlv.SomeRecordT(
//nolint:ll
tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}),
),
}
iterator := &sphinxHopIterator{
blindingKit: kit,
router: sphinx.NewRouter(
&sphinx.PrivKeyECDH{PrivKey: nodeKey},
sphinx.NewMemoryReplayLog(),
),
}
payload, _, err := parseAndValidateRecipientData(
iterator, &Payload{encryptedData: data},
false, RouteRoleCleartext,
)
if testCase.expectedErr != "" {
require.ErrorContains(
t, err, testCase.expectedErr,
)
return
}
require.NoError(t, err)
require.Equal(
t, testCase.expectedHop,
payload.FwdInfo.NextHop,
)
})
}
}
// TestBlindedHopBothNextHopFieldsRejected asserts that a blinded hop setting
// both short_channel_id and next_node_id is rejected for a final hop and for a
// dummy hop (next_node_id == our own pubkey), not just an intermediate hop. The
// mutual-exclusivity check runs before the final-hop and dummy-hop branches, so
// none of them accept a hop that violates BOLT 4. The intermediate case is
// already covered by TestDeriveBlindedRouteNextHop.
func TestBlindedHopBothNextHopFieldsRejected(t *testing.T) {
t.Parallel()
nodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nodePub := nodeKey.PubKey()
// Route data that sets both short_channel_id and next_node_id. The node
// ID is our own pubkey, which for a non-final hop would otherwise
// signal a dummy hop; the both-set check must still fire first.
bothData := &record.BlindedRouteData{
ShortChannelID: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType2](
lnwire.NewShortChanIDFromInt(1500),
),
),
NextNodeID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub),
),
RelayInfo: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10](
record.PaymentRelayInfo{
CltvExpiryDelta: 10,
BaseFee: 100,
FeeRate: 0,
},
)),
Constraints: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12](
record.PaymentConstraints{
MaxCltvExpiry: 1000,
HtlcMinimumMsat: lnwire.MilliSatoshi(1),
},
)),
}
data, err := record.EncodeBlindedRouteData(bothData)
require.NoError(t, err)
// Both the dummy/forwarding path (isFinal=false, next_node_id points at
// us) and the final path (isFinal=true) must reject the hop.
for _, isFinal := range []bool{false, true} {
name := "forwarding hop"
if isFinal {
name = "final hop"
}
t.Run(name, func(t *testing.T) {
kit := BlindingKit{
Processor: &mockProcessor{},
IncomingAmount: 10000,
IncomingCltv: 500,
UpdateAddBlinding: tlv.SomeRecordT(
//nolint:ll
tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}),
),
}
iterator := &sphinxHopIterator{
blindingKit: kit,
router: sphinx.NewRouter(
&sphinx.PrivKeyECDH{PrivKey: nodeKey},
sphinx.NewMemoryReplayLog(),
),
}
_, _, err := parseAndValidateRecipientData(
iterator, &Payload{encryptedData: data},
isFinal, RouteRoleCleartext,
)
require.ErrorContains(
t, err,
"both short channel ID and next node ID",
)
})
}
}
// TestBlindedRouteDummyHopPeeledLocally asserts that a blinded route hop where
// next_node_id is our own public key is recognized as a dummy hop and is peeled
// locally rather than falling through to the generic next_node_id forwarding
// branch.
func TestBlindedRouteDummyHopPeeledLocally(t *testing.T) {
t.Parallel()
// Construct a realistic onion packet that contains a blinded final hop.
// We'll use this to test that we can peel a dummy hop locally and
// extract the forwarding information from the decrypted final hop's
// payload.
nodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nodePub := nodeKey.PubKey()
relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10](
record.PaymentRelayInfo{
CltvExpiryDelta: 10,
BaseFee: 100,
FeeRate: 0,
},
))
constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12](
record.PaymentConstraints{
MaxCltvExpiry: 1000,
HtlcMinimumMsat: lnwire.MilliSatoshi(1),
},
))
// Set next_node_id to our own public key. This signals a dummy hop.
nodeIDRecord := tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub),
)
// We'll generate a valid, cryptographically blinded final hop's payload
// using sphinx.BuildBlindedPath. This contains the PathID.
secret := make([]byte, 32)
secret[0] = 2
finalHopData := &record.BlindedRouteData{
PathID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType6](secret),
),
}
finalHopDataBytes, err := record.EncodeBlindedRouteData(finalHopData)
require.NoError(t, err)
hopInfo := &sphinx.HopInfo{
NodePub: nodePub,
PlainText: finalHopDataBytes,
}
blindingSessionKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
blindedPathInfo, err := sphinx.BuildBlindedPath(
blindingSessionKey, []*sphinx.HopInfo{hopInfo},
)
require.NoError(t, err)
// Since we are peeling a dummy hop locally, we want the next blinding
// override to be the blinding point generated for our blinded final
// hop.
dummyHopData := &record.BlindedRouteData{
NextNodeID: nodeIDRecord,
RelayInfo: relayInfo,
Constraints: constraints,
NextBlindingOverride: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType8](
blindedPathInfo.Path.BlindingPoint,
),
),
}
data, err := record.EncodeBlindedRouteData(dummyHopData)
require.NoError(t, err)
// Encode a valid TLV payload for the next hop (which we will peel).
var hop2Buffer bytes.Buffer
amt := uint64(10000)
cltv := uint32(500)
encryptedDataRecord := record.NewEncryptedDataRecord(
&blindedPathInfo.Path.BlindedHops[0].CipherText,
)
tlvRecords := []tlv.Record{
record.NewAmtToFwdRecord(&amt),
record.NewLockTimeRecord(&cltv),
encryptedDataRecord,
}
tlvStream, err := tlv.NewStream(tlvRecords...)
require.NoError(t, err)
err = tlvStream.Encode(&hop2Buffer)
require.NoError(t, err)
hopPayload, err := sphinx.NewTLVHopPayload(hop2Buffer.Bytes())
require.NoError(t, err)
// Create a valid 1-hop onion path using our blinded public key.
var paymentPath sphinx.PaymentPath
paymentPath[0] = sphinx.OnionHop{
NodePub: *blindedPathInfo.Path.BlindedHops[0].BlindedNodePub,
HopPayload: hopPayload,
}
sessionKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
rHash := [32]byte{1}
// Generate a cryptographically valid onion packet for this path.
onionPacket, err := sphinx.NewOnionPacket(
&paymentPath, sessionKey, rHash[:],
sphinx.DeterministicPacketFiller,
)
require.NoError(t, err)
// Simulate an incoming HTLC with a blinding point and a valid onion
// packet. The blinding point is used to decrypt the dummy hop's
// payload, which contains the blinding point for the next hop (the
// blinded final hop).
kit := BlindingKit{
Processor: &mockProcessor{},
IncomingAmount: 12000,
IncomingCltv: 510,
UpdateAddBlinding: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](
nodePub,
),
),
}
iterator := &sphinxHopIterator{
blindingKit: kit,
rHash: rHash[:],
router: sphinx.NewRouter(
&sphinx.PrivKeyECDH{PrivKey: nodeKey},
sphinx.NewMemoryReplayLog(),
),
// Set our valid onion packet to be peeled.
processedPacket: &sphinx.ProcessedPacket{
NextPacket: onionPacket,
},
}
// When we parse and validate the recipient data, it should enter the
// dummy-hop peeling path. Since our onion packet is valid and matches
// our private key, it should be successfully peeled and parsed.
pld, _, err := parseAndValidateRecipientData(
iterator, &Payload{encryptedData: data},
false, RouteRoleCleartext,
)
// Assert that we successfully peeled the dummy hop and extracted the
// decrypted final payload.
require.NoError(t, err)
require.NotNil(t, pld)
fwdInfo := pld.ForwardingInfo()
require.Equal(t, lnwire.MilliSatoshi(0), fwdInfo.AmountToForward)
require.Equal(t, uint32(0), fwdInfo.OutgoingCLTV)
require.NotNil(t, fwdInfo.PathID)
require.Equal(t, secret, fwdInfo.PathID[:])
}

View file

@ -31,7 +31,9 @@ type BlindedRouteData struct {
// NextNodeID is the node ID of the next node on the path. In the
// context of blinded path payments, this is used to indicate the
// presence of dummy hops that need to be peeled from the onion.
// presence of dummy hops that need to be peeled from the onion, or to
// identify a real next-node forwarding target when the public key is
// not ours.
NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey]
// PathID is a secret set of bytes that the blinded path creator will

View file

@ -32,7 +32,9 @@ type BlindedRouteData struct {
// NextNodeID is the node ID of the next node on the path. In the
// context of blinded path payments, this is used to indicate the
// presence of dummy hops that need to be peeled from the onion.
// presence of dummy hops that need to be peeled from the onion, or to
// identify a real next-node forwarding target when the public key is
// not ours.
NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey]
// PathID is a secret set of bytes that the blinded path creator will