Skip to content

Onion message forwarding #10089

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ replace github.com/gogo/protobuf => github.com/gogo/protobuf v1.3.2
// allows us to specify that as an option.
replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-display v1.30.0-hex-display

// TODO(gijs): remove once onion messaging PR is merged into lightning-onion.
replace github.com/lightningnetwork/lightning-onion => github.com/gijswijs/lightning-onion v0.0.0-20250710135052-c3f8db769b88

// If you change this please also update docs/INSTALL.md and GO_VERSION in
// Makefile (then run `make lint` to see where else it needs to be updated as
// well).
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gijswijs/lightning-onion v0.0.0-20250710132007-63dd4c5705db/go.mod h1:AX52YxRT/CQOb/c6IExwNoZUTHbiepDgGmrPrRS7gog=
github.com/gijswijs/lightning-onion v0.0.0-20250710133033-97ee6ea9781c h1:wSnFxRM2bsI0/ewihfw9uKh8xu7TeXHV2Xj3M6d6Wkg=
github.com/gijswijs/lightning-onion v0.0.0-20250710133033-97ee6ea9781c/go.mod h1:QriyoZ6g5m4R+0cSa8O7sstLjyKTDqLiEPBvY3wRD4s=
github.com/gijswijs/lightning-onion v0.0.0-20250710134414-bfdbdec7f9d7 h1:wgjWd6wmbmj2GEL6eqwaz2xh7t8zzI2wIW4xDN4VLX4=
github.com/gijswijs/lightning-onion v0.0.0-20250710134414-bfdbdec7f9d7/go.mod h1:QriyoZ6g5m4R+0cSa8O7sstLjyKTDqLiEPBvY3wRD4s=
github.com/gijswijs/lightning-onion v0.0.0-20250710135052-c3f8db769b88 h1:xekg0CO6TbpDFUybdAPsdeIuku7OQDw/Cp46Msefe5Q=
github.com/gijswijs/lightning-onion v0.0.0-20250710135052-c3f8db769b88/go.mod h1:QriyoZ6g5m4R+0cSa8O7sstLjyKTDqLiEPBvY3wRD4s=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
Expand Down
6 changes: 6 additions & 0 deletions htlcswitch/hop/forwarding_info.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hop

import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/lightningnetwork/lnd/lnwire"
)
Expand All @@ -16,6 +17,11 @@ type ForwardingInfo struct {
// end-to-end route.
NextHop lnwire.ShortChannelID

// NextNodeID is the public key of the next node in the route. This is
// used by onion messages that do not necessarily care about the channel
// ID.
NextNodeID *btcec.PublicKey

// AmountToForward is the amount of milli-satoshis that the receiving
// node should forward to the next hop.
AmountToForward lnwire.MilliSatoshi
Expand Down
174 changes: 122 additions & 52 deletions htlcswitch/hop/iterator.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ type sphinxHopIterator struct {
// This is required for peeling of dummy hops in a blinded path where
// the same node will iteratively need to unwrap the onion.
router *sphinx.Router

// isOnionMessage is a flag that indicates whether the iterator is for
// an onion message.
isOnionMessage bool
}

// makeSphinxHopIterator converts a processed packet returned from a sphinx
Expand All @@ -141,14 +145,15 @@ type sphinxHopIterator struct {
// for blinded routes.
func makeSphinxHopIterator(router *sphinx.Router, ogPacket *sphinx.OnionPacket,
packet *sphinx.ProcessedPacket, blindingKit BlindingKit,
rHash []byte) *sphinxHopIterator {
rHash []byte, isOnionMessage bool) *sphinxHopIterator {

return &sphinxHopIterator{
router: router,
ogPacket: ogPacket,
processedPacket: packet,
blindingKit: blindingKit,
rHash: rHash,
isOnionMessage: isOnionMessage,
}
}

Expand Down Expand Up @@ -180,7 +185,9 @@ func (r *sphinxHopIterator) HopPayload() (*Payload, RouteRole, error) {
// directly from the pre-populated ForwardingInstructions field.
case sphinx.PayloadLegacy:
fwdInst := r.processedPacket.ForwardingInstructions
return NewLegacyPayload(fwdInst), RouteRoleCleartext, nil
payload := NewLegacyPayload(fwdInst)
payload.isFinal = r.processedPacket.Action == sphinx.ExitNode
return payload, RouteRoleCleartext, nil

// Otherwise, if this is the TLV payload, then we'll make a new stream
// to decode only what we need to make routing decisions.
Expand All @@ -203,12 +210,15 @@ func extractTLVPayload(r *sphinxHopIterator) (*Payload, RouteRole, error) {
// Initial payload parsing and validation
payload, routeRole, recipientData, err := parseAndValidateSenderPayload(
r.processedPacket.Payload.Payload, isFinal,
r.blindingKit.UpdateAddBlinding.IsSome(),
r.blindingKit.UpdateAddBlinding.IsSome(), r.isOnionMessage,
)
if err != nil {
return nil, routeRole, err
}

// Indicate whether this is the final hop in the blinded path.
payload.isFinal = isFinal

// If the payload contained no recipient data, then we can exit now.
if !recipientData {
return payload, routeRole, nil
Expand All @@ -234,29 +244,33 @@ func parseAndValidateRecipientData(r *sphinxHopIterator, payload *Payload,
// This is the final node in the blinded route.
if isFinal {
return deriveBlindedRouteFinalHopForwardingInfo(
routeData, payload, routeRole,
routeData, payload, routeRole, r.isOnionMessage,
)
}

// Else, we are a forwarding node in this blinded path.
return deriveBlindedRouteForwardingInfo(
r, routeData, payload, routeRole, blindingPoint,
r.isOnionMessage,
)
}

// deriveBlindedRouteFinalHopForwardingInfo extracts the PathID from the
// routeData and constructs the ForwardingInfo accordingly.
func deriveBlindedRouteFinalHopForwardingInfo(
routeData *record.BlindedRouteData, payload *Payload,
routeRole RouteRole) (*Payload, RouteRole, error) {
routeRole RouteRole, isOnionMessage bool) (*Payload, RouteRole, error) {

var pathID *chainhash.Hash
routeData.PathID.WhenSome(func(r tlv.RecordT[tlv.TlvType6, []byte]) {
var id chainhash.Hash
copy(id[:], r.Val)
pathID = &id
})
if pathID == nil {

// If this is not an onion message, then we expect the path ID to be
// set.
if !isOnionMessage && pathID == nil {
return nil, routeRole, ErrInvalidPayload{
Type: tlv.Type(6),
Violation: InsufficientViolation,
Expand All @@ -274,22 +288,29 @@ func deriveBlindedRouteFinalHopForwardingInfo(
// recipient to derive the ForwardingInfo for the payment.
func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
routeData *record.BlindedRouteData, payload *Payload,
routeRole RouteRole, blindingPoint *btcec.PublicKey) (*Payload,
RouteRole, error) {
routeRole RouteRole, blindingPoint *btcec.PublicKey,
isOnionMessage bool) (*Payload, RouteRole, error) {

relayInfo, err := routeData.RelayInfo.UnwrapOrErr(
fmt.Errorf("relay info not set for non-final blinded hop"),
var (
cltvExpiryDelta uint32
fwdAmt lnwire.MilliSatoshi
)
if err != nil {
return nil, routeRole, err
}
if !isOnionMessage {
relayInfo, err := routeData.RelayInfo.UnwrapOrErr(
fmt.Errorf("relay info not set for non-final blinded hop"),
)
cltvExpiryDelta = uint32(relayInfo.Val.CltvExpiryDelta)
if err != nil {
return nil, routeRole, err
}

fwdAmt, err := calculateForwardingAmount(
r.blindingKit.IncomingAmount, relayInfo.Val.BaseFee,
relayInfo.Val.FeeRate,
)
if err != nil {
return nil, routeRole, err
fwdAmt, err = calculateForwardingAmount(
r.blindingKit.IncomingAmount, relayInfo.Val.BaseFee,
relayInfo.Val.FeeRate,
)
if err != nil {
return nil, routeRole, err
}
}

nextEph, err := routeData.NextBlindingOverride.UnwrapOrFuncErr(
Expand All @@ -313,23 +334,30 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
// payload.
if checkForDummyHop(routeData, r.router.OnionPublicKey()) {
return peelBlindedPathDummyHop(
r, uint32(relayInfo.Val.CltvExpiryDelta), fwdAmt,
routeRole, nextEph,
r, cltvExpiryDelta, fwdAmt,
routeRole, nextEph, isOnionMessage,
)
}

var nextNodeID tlv.RecordT[tlv.TlvType4, *btcec.PublicKey]
nextSCID, err := routeData.ShortChannelID.UnwrapOrErr(
fmt.Errorf("next SCID not set for non-final blinded hop"),
)
if err != nil && !isOnionMessage {
return nil, routeRole, err
} else if isOnionMessage {
nextNodeID, err = routeData.NextNodeID.UnwrapOrErr(
fmt.Errorf("next SCID nor NodeID set for non-final " +
"blinded onion message hop"),
)
}
if err != nil {
return nil, routeRole, err
}

payload.FwdInfo = ForwardingInfo{
NextHop: nextSCID.Val,
AmountToForward: fwdAmt,
OutgoingCTLV: r.blindingKit.IncomingCltv - uint32(
relayInfo.Val.CltvExpiryDelta,
),
NextHop: nextSCID.Val,
NextNodeID: nextNodeID.Val,
// Remap from blinding override type to blinding point type.
NextBlinding: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](
Expand All @@ -338,6 +366,11 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
),
}

if !isOnionMessage {
payload.FwdInfo.AmountToForward = fwdAmt
payload.FwdInfo.OutgoingCTLV = r.blindingKit.IncomingCltv - cltvExpiryDelta
}

return payload, routeRole, nil
}

Expand All @@ -362,8 +395,8 @@ func checkForDummyHop(routeData *record.BlindedRouteData,
// to be the final hop on the path.
func peelBlindedPathDummyHop(r *sphinxHopIterator, cltvExpiryDelta uint32,
fwdAmt lnwire.MilliSatoshi, routeRole RouteRole,
nextEph tlv.RecordT[tlv.TlvType8, *btcec.PublicKey]) (*Payload,
RouteRole, error) {
nextEph tlv.RecordT[tlv.TlvType8, *btcec.PublicKey],
isOnionMessage bool) (*Payload, RouteRole, error) {

onionPkt := r.processedPacket.NextPacket
sphinxPacket, err := r.router.ReconstructOnionPacket(
Expand All @@ -373,18 +406,22 @@ func peelBlindedPathDummyHop(r *sphinxHopIterator, cltvExpiryDelta uint32,
return nil, routeRole, err
}

iterator := makeSphinxHopIterator(
r.router, onionPkt, sphinxPacket, BlindingKit{
Processor: r.router,
UpdateAddBlinding: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( //nolint:ll
nextEph.Val,
),
blindingKit := BlindingKit{
Processor: r.router,
UpdateAddBlinding: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( //nolint:ll
nextEph.Val,
),
IncomingAmount: fwdAmt,
IncomingCltv: r.blindingKit.IncomingCltv -
cltvExpiryDelta,
}, r.rHash,
),
}

if !isOnionMessage {
blindingKit.IncomingAmount = fwdAmt
blindingKit.IncomingCltv = r.blindingKit.IncomingCltv - cltvExpiryDelta
}

iterator := makeSphinxHopIterator(
r.router, onionPkt, sphinxPacket, blindingKit, r.rHash, false,
)
Comment on lines +423 to 425

Choose a reason for hiding this comment

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

critical

The isOnionMessage flag is hardcoded to false when creating a new hop iterator for a peeled dummy hop. This is incorrect, as the peeled hop is still part of the original onion message. This will cause incorrect payload parsing for any subsequent hops within the same onion message, leading to forwarding failures.

Suggested change
iterator := makeSphinxHopIterator(
r.router, onionPkt, sphinxPacket, blindingKit, r.rHash, false,
)
iterator := makeSphinxHopIterator(
r.router, onionPkt, sphinxPacket, blindingKit, r.rHash, isOnionMessage,
)


return extractTLVPayload(iterator)
Expand Down Expand Up @@ -416,10 +453,14 @@ func decryptAndValidateBlindedRouteData(r *sphinxHopIterator,
return nil, nil, fmt.Errorf("%w: %w", ErrDecodeFailed, err)
}

err = ValidateBlindedRouteData(
routeData, r.blindingKit.IncomingAmount,
r.blindingKit.IncomingCltv,
)
if r.isOnionMessage {
err = ValidateBlindedFeatures(routeData)
} else {
err = ValidateBlindedRouteData(
routeData, r.blindingKit.IncomingAmount,
r.blindingKit.IncomingCltv,
)
}
if err != nil {
return nil, nil, err
}
Expand All @@ -435,10 +476,25 @@ func decryptAndValidateBlindedRouteData(r *sphinxHopIterator,
// value indicates that the sender payload includes encrypted data from the
// recipient that should be parsed.
func parseAndValidateSenderPayload(payloadBytes []byte, isFinalHop,
updateAddBlindingSet bool) (*Payload, RouteRole, bool, error) {
updateAddBlindingSet, isOnionMessage bool) (*Payload, RouteRole, bool,
error) {

var (
payload *Payload
parsed map[tlv.Type][]byte
err error
)
// Extract TLVs from the packet constructor (the sender).
payload, parsed, err := ParseTLVPayload(bytes.NewReader(payloadBytes))
if !isOnionMessage {
payload, parsed, err = ParseTLVPayload(
bytes.NewReader(payloadBytes),
)
} else {
payload, parsed, err = ParseTLVPayloadOnionMessage(
bytes.NewReader(payloadBytes),
)
}

if err != nil {
// If we couldn't even parse our payload then we do a
// best-effort of determining our role in a blinded route,
Expand All @@ -459,15 +515,24 @@ func parseAndValidateSenderPayload(payloadBytes []byte, isFinalHop,

// Validate the presence of the various payload fields we received from
// the sender.
err = ValidateTLVPayload(parsed, isFinalHop, updateAddBlindingSet)
err = ValidateTLVPayload(
parsed, isFinalHop, updateAddBlindingSet, isOnionMessage,
)
if err != nil {
return nil, routeRole, false, err
}

// If this is an onion message the payload is now fully validated. Since
// onion messages contain recipient data by defintion, we return true
// for that boolean.
if isOnionMessage {
return payload, routeRole, true, nil
}

// If there is no encrypted data from the receiver then return the
// payload as is since the forwarding info would have been received
// from the sender.
if payload.encryptedData == nil {
// payload as is since the forwarding info would have been received from
// the sender.
if payload.encryptedData == nil || isOnionMessage {
return payload, routeRole, false, nil
}

Expand Down Expand Up @@ -706,7 +771,7 @@ func (p *OnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte,
UpdateAddBlinding: blindingInfo.BlindingKey,
IncomingAmount: blindingInfo.IncomingAmt,
IncomingCltv: blindingInfo.IncomingExpiry,
}, rHash,
}, rHash, false,
), nil
}

Expand All @@ -719,6 +784,7 @@ type DecodeHopIteratorRequest struct {
IncomingCltv uint32
IncomingAmount lnwire.MilliSatoshi
BlindingPoint lnwire.BlindingPointRecord
IsOnionMessage bool
}

// DecodeHopIteratorResponse encapsulates the outcome of a batched sphinx onion
Expand Down Expand Up @@ -785,6 +851,10 @@ func (p *OnionProcessor) DecodeHopIterators(id []byte,
))
})

if req.IsOnionMessage {
opts = append(opts, sphinx.WithIsOnionMessage())
}

// TODO(yy): use `p.router.ProcessOnionPacket` instead.
err = tx.ProcessOnionPacket(
seqNum, onionPkt, req.RHash, req.IncomingCltv, opts...,
Expand Down Expand Up @@ -826,7 +896,7 @@ func (p *OnionProcessor) DecodeHopIterators(id []byte,
wg.Wait()

// With that batch created, we will now attempt to write the shared
// secrets to disk. This operation will returns the set of indices that
// secrets to disk. This operation will return the set of indices that
// were detected as replays, and the computed sphinx packets for all
// indices that did not fail the above loop. Only indices that are not
// in the replay set should be considered valid, as they are
Expand Down Expand Up @@ -894,7 +964,7 @@ func (p *OnionProcessor) DecodeHopIterators(id []byte,
UpdateAddBlinding: reqs[i].BlindingPoint,
IncomingAmount: reqs[i].IncomingAmount,
IncomingCltv: reqs[i].IncomingCltv,
}, reqs[i].RHash,
}, reqs[i].RHash, reqs[i].IsOnionMessage,
)
}

Expand Down
Loading