What changed, and why it matters
This commit adds new code to LND that handles a new kind of Lightning network message called an 'onion message payload.' It is a feature addition, not a fix. The code reads and writes message fields such as reply paths, encrypted recipient data, and special sub-messages for invoices. There is no direct evidence in the commit that this introduces a security vulnerability, but any new network parser can carry implementation risks.
Treat as a normal feature commit. Reviewers should verify that callers of OnionMessagePayload.Decode enforce overall message-size limits and that unknown final-hop TLVs are validated before use in higher layers. No immediate security patch is indicated by this commit alone.
Security signals we found
New network message parser added to lnwire package
TLV decode preserves unknown final-hop records (type >= 64), which could affect protocol behavior if validation is incomplete elsewhere
Reply path length is decoded from a single uint8 and checked against the remaining byte length via l > 67
No bounds check on total reply-path length beyond the minimum; relies on io.Reader EOF behavior
No vendor security disclosure or CVE references present in commit or supplied references
Evidence from the diff
The patch introduces lnwire.OnionMessagePayload, FinalHopTLV, and reply-path encoding/decoding logic for BOLT-style onion messages. It implements TLV serialization, recognizes known even TLV namespaces (64, 66, 68), preserves unknown final-hop TLVs >= 64, and validates that final-hop TLVs are in the reserved range. The change is purely additive (+932 lines, no deletions) and includes unit and property-based tests. No security bug is visible in the diff; the code follows existing TLV patterns and rejects zero-hop reply paths.
Changed components
lnwire/onion_msg_payload.golnwire/onion_msg_payload_test.golnwire/test_utils.goInspect captured patch +932 / −0
diff --git a/lnwire/onion_msg_payload.go b/lnwire/onion_msg_payload.go
new file mode 100644
index 0000000..64c5aba
--- /dev/null
+++ b/lnwire/onion_msg_payload.go
@@ -0,0 +1,412 @@
+package lnwire
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+
+ sphinx "github.com/lightningnetwork/lightning-onion"
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+const (
+ // finalHopPayloadStart is the inclusive beginning of the tlv type
+ // range that is reserved for payloads for the final hop.
+ finalHopPayloadStart tlv.Type = 64
+
+ // replyPathType is a record for onion messaging reply paths.
+ replyPathType tlv.Type = 2
+
+ // encryptedDataTLVType is a record containing encrypted data for
+ // message recipient.
+ encryptedDataTLVType tlv.Type = 4
+
+ // InvoiceRequestNamespaceType is a record containing the sub-namespace
+ // of tlvs that request invoices for offers.
+ InvoiceRequestNamespaceType tlv.Type = 64
+
+ // InvoiceNamespaceType is a record containing the sub-namespace of
+ // tlvs that describe an invoice.
+ InvoiceNamespaceType tlv.Type = 66
+
+ // InvoiceErrorNamespaceType is a record containing the sub-namespace of
+ // tlvs that describe an invoice error.
+ InvoiceErrorNamespaceType tlv.Type = 68
+)
+
+var (
+ // ErrNotFinalPayload is returned when a final hop payload is not
+ // within the correct range.
+ ErrNotFinalPayload = errors.New("final hop payloads type should be " +
+ ">= 64")
+
+ // ErrNoHops is returned when we handle a reply path that does not
+ // have any hops (this makes no sense).
+ ErrNoHops = errors.New("reply path requires hops")
+)
+
+// OnionMessagePayload contains the contents of an onion message payload.
+type OnionMessagePayload struct {
+ // ReplyPath contains a blinded path that can be used to respond to an
+ // onion message.
+ ReplyPath *sphinx.BlindedPath
+
+ // EncryptedData contains encrypted data for the recipient.
+ EncryptedData []byte
+
+ // FinalHopTLVs contains any TLVs with type >= 64 that are reserved for
+ // the final hop's payload.
+ FinalHopTLVs []*FinalHopTLV
+}
+
+// NewOnionMessagePayload creates a new OnionMessagePayload.
+func NewOnionMessagePayload() *OnionMessagePayload {
+ return &OnionMessagePayload{}
+}
+
+// Encode encodes an onion message's payload.
+//
+// This is part of the lnwire.Message interface.
+func (o *OnionMessagePayload) Encode() ([]byte, error) {
+ var records []tlv.Record
+
+ if o.ReplyPath != nil {
+ records = append(records, replyPathRecord(o.ReplyPath))
+ }
+
+ if len(o.EncryptedData) != 0 {
+ record := tlv.MakePrimitiveRecord(
+ encryptedDataTLVType, &o.EncryptedData,
+ )
+ records = append(records, record)
+ }
+
+ for _, finalHopTLV := range o.FinalHopTLVs {
+ if err := finalHopTLV.Validate(); err != nil {
+ return nil, err
+ }
+
+ // Create a primitive record that just writes the final hop
+ // tlv's bytes as-is. The creating function should have
+ // encoded the value correctly.
+ record := tlv.MakePrimitiveRecord(
+ finalHopTLV.TLVType, &finalHopTLV.Value,
+ )
+ records = append(records, record)
+ }
+
+ // Sort our records just in case the final hop payload records were
+ // provided in the incorrect order.
+ tlv.SortRecords(records)
+
+ stream, err := tlv.NewStream(records...)
+ if err != nil {
+ return nil, fmt.Errorf("new stream: %w", err)
+ }
+
+ b := new(bytes.Buffer)
+ if err := stream.Encode(b); err != nil {
+ return nil, fmt.Errorf("encode stream: %w", err)
+ }
+
+ return b.Bytes(), nil
+}
+
+// Decode decodes an onion message's payload.
+//
+// This is part of the lnwire.Message interface.
+func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
+ var (
+ invoicePayload = &FinalHopTLV{
+ TLVType: InvoiceNamespaceType,
+ }
+
+ invoiceErrorPayload = &FinalHopTLV{
+ TLVType: InvoiceErrorNamespaceType,
+ }
+
+ invoiceRequestPayload = &FinalHopTLV{
+ TLVType: InvoiceRequestNamespaceType,
+ }
+ )
+ // Create a non-nil entry so that we can directly decode into it.
+ o.ReplyPath = &sphinx.BlindedPath{}
+
+ records := []tlv.Record{
+ replyPathRecord(o.ReplyPath),
+ tlv.MakePrimitiveRecord(
+ encryptedDataTLVType, &o.EncryptedData,
+ ),
+ // Add a record for invoice request sub-namespace so that we
+ // won't fail on the even tlv - reasoning below.
+ tlv.MakePrimitiveRecord(
+ InvoiceRequestNamespaceType,
+ &invoiceRequestPayload.Value,
+ ),
+ // Add records to read invoice and invoice errors sub-namespaces
+ // out. Although this is technically one of our "final hop
+ // payload" tlvs, it is an even value, so we need to include it
+ // as a known tlv here, or decoding will fail. We decode
+ // directly into a final hop payload, so that we can just add it
+ // if present later.
+ tlv.MakePrimitiveRecord(
+ InvoiceNamespaceType,
+ &invoicePayload.Value,
+ ),
+ tlv.MakePrimitiveRecord(
+ InvoiceErrorNamespaceType,
+ &invoiceErrorPayload.Value,
+ ),
+ }
+
+ stream, err := tlv.NewStream(records...)
+ if err != nil {
+ return nil, fmt.Errorf("new stream: %w", err)
+ }
+
+ tlvMap, err := stream.DecodeWithParsedTypesP2P(r)
+ if err != nil {
+ return tlvMap, fmt.Errorf("decode stream: %w", err)
+ }
+
+ // If our reply path wasn't populated, replace it with a nil entry.
+ if _, ok := tlvMap[replyPathType]; !ok {
+ o.ReplyPath = nil
+ }
+
+ // Once we're decoded our message, we want to also include any tlvs
+ // that are intended for the final hop's payload which we may not have
+ // recognized. We'll just directly read these out and allow higher
+ // application layers to deal with them.
+ for tlvType, tlvBytes := range tlvMap {
+ // Skip any tlvs that are not in our range.
+ if tlvType < finalHopPayloadStart {
+ continue
+ }
+
+ // Skip any tlvs that have been recognized in our decoding (a
+ // zero entry means that we recognized the entry).
+ if len(tlvBytes) == 0 {
+ continue
+ }
+
+ // Add the payload to our message's final hop payloads.
+ payload := &FinalHopTLV{
+ TLVType: tlvType,
+ Value: tlvBytes,
+ }
+
+ o.FinalHopTLVs = append(
+ o.FinalHopTLVs, payload,
+ )
+ }
+
+ // If we read out an invoice, invoice error or invoice request tlv
+ // sub-namespace, add it to our set of final payloads. This value won't
+ // have been added in the loop above, because we recognized the TLV so
+ // len(tlvMap[invoiceType].tlvBytes) will be zero (thus, skipped above).
+ if _, ok := tlvMap[InvoiceNamespaceType]; ok {
+ o.FinalHopTLVs = append(
+ o.FinalHopTLVs, invoicePayload,
+ )
+ }
+
+ if _, ok := tlvMap[InvoiceErrorNamespaceType]; ok {
+ o.FinalHopTLVs = append(
+ o.FinalHopTLVs, invoiceErrorPayload,
+ )
+ }
+
+ if _, ok := tlvMap[InvoiceRequestNamespaceType]; ok {
+ o.FinalHopTLVs = append(
+ o.FinalHopTLVs, invoiceRequestPayload,
+ )
+ }
+
+ // Iteration through maps occurs in random order - sort final hop
+ // TLVs in ascending order to make this decoding function
+ // deterministic.
+ sort.SliceStable(o.FinalHopTLVs, func(i, j int) bool {
+ return o.FinalHopTLVs[i].TLVType <
+ o.FinalHopTLVs[j].TLVType
+ })
+
+ return tlvMap, nil
+}
+
+// FinalHopTLV contains values reserved for the final hop, which are just
+// directly read from the tlv stream.
+type FinalHopTLV struct {
+ // TLVType is the type for the payload.
+ TLVType tlv.Type
+
+ // Value is the raw byte value read for this tlv type. This field is
+ // expected to contain "sub-tlv" namespaces, and will require further
+ // decoding to be used.
+ Value []byte
+}
+
+// Validate performs validation of items added to the final hop's payload in an
+// onion. This function returns an error if a tlv is not within the range
+// reserved for final payload.
+func (f *FinalHopTLV) Validate() error {
+ if f.TLVType < finalHopPayloadStart {
+ return fmt.Errorf("%w: %v", ErrNotFinalPayload, f.TLVType)
+ }
+
+ return nil
+}
+
+// replyPathRecord produces a tlv record for a reply path.
+func replyPathRecord(r *sphinx.BlindedPath) tlv.Record {
+ return tlv.MakeDynamicRecord(
+ replyPathType, r, replyPathSize(r), encodeReplyPath,
+ decodeReplyPath,
+ )
+}
+
+// replyPathSize returns the encoded size of a reply path.
+func replyPathSize(r *sphinx.BlindedPath) func() uint64 {
+ return func() uint64 {
+ // First node pubkey 33 + blinding point pubkey 33 + 1 byte for
+ // uint8 for our hop count.
+ size := uint64(33 + 33 + 1)
+
+ // Add each hop's size to our total.
+ for _, hop := range r.BlindedHops {
+ size += blindedHopSize(hop)
+ }
+
+ return size
+ }
+}
+
+// encodeReplyPath encodes a reply path tlv.
+func encodeReplyPath(w io.Writer, val interface{}, buf *[8]byte) error {
+ if p, ok := val.(*sphinx.BlindedPath); ok {
+ err := tlv.EPubKey(w, &p.IntroductionPoint, buf)
+ if err != nil {
+ return fmt.Errorf("encode first node id: %w", err)
+ }
+
+ if err := tlv.EPubKey(w, &p.BlindingPoint, buf); err != nil {
+ return fmt.Errorf("encode blinding point: %w", err)
+ }
+
+ hopCount := uint8(len(p.BlindedHops))
+ if hopCount == 0 {
+ return ErrNoHops
+ }
+
+ if err := tlv.EUint8(w, &hopCount, buf); err != nil {
+ return fmt.Errorf("encode hop count: %w", err)
+ }
+
+ for i, hop := range p.BlindedHops {
+ if err := encodeBlindedHop(w, hop, buf); err != nil {
+ return fmt.Errorf("hop %v: %w", i, err)
+ }
+ }
+
+ return nil
+ }
+
+ return tlv.NewTypeForEncodingErr(val, "*sphinx.BlindedPath")
+}
+
+// decodeReplyPath decodes a reply path tlv.
+func decodeReplyPath(r io.Reader, val interface{}, buf *[8]byte,
+ l uint64) error {
+
+ // If we have the correct type, and the length exceeds the fixed header
+ // size (first node pubkey (33) + blinding point (33) + hop count (1) =
+ // 67 bytes) to accommodate at least one hop, decode the reply path.
+ if p, ok := val.(*sphinx.BlindedPath); ok && l > 67 {
+ err := tlv.DPubKey(r, &p.IntroductionPoint, buf, 33)
+ if err != nil {
+ return fmt.Errorf("decode first id: %w", err)
+ }
+
+ err = tlv.DPubKey(r, &p.BlindingPoint, buf, 33)
+ if err != nil {
+ return fmt.Errorf("decode blinding point: %w", err)
+ }
+
+ var hopCount uint8
+ if err := tlv.DUint8(r, &hopCount, buf, 1); err != nil {
+ return fmt.Errorf("decode hop count: %w", err)
+ }
+
+ if hopCount == 0 {
+ return ErrNoHops
+ }
+
+ for i := 0; i < int(hopCount); i++ {
+ hop := &sphinx.BlindedHopInfo{}
+ if err := decodeBlindedHop(r, hop, buf); err != nil {
+ return fmt.Errorf("decode hop: %w", err)
+ }
+
+ p.BlindedHops = append(p.BlindedHops, hop)
+ }
+
+ return nil
+ }
+
+ return tlv.NewTypeForDecodingErr(val, "*sphinx.BlindedPath", l, l)
+}
+
+// blindedHopSize returns the encoded size of a blinded hop.
+func blindedHopSize(b *sphinx.BlindedHopInfo) uint64 {
+ // 33 byte pubkey + 2 bytes uint16 length + var bytes.
+ return uint64(33 + 2 + len(b.CipherText))
+}
+
+// encodeBlindedHop encodes a blinded hop tlv.
+func encodeBlindedHop(w io.Writer, val interface{}, buf *[8]byte) error {
+ if b, ok := val.(*sphinx.BlindedHopInfo); ok {
+ if err := tlv.EPubKey(w, &b.BlindedNodePub, buf); err != nil {
+ return fmt.Errorf("encode blinded id: %w", err)
+ }
+
+ dataLen := uint16(len(b.CipherText))
+ if err := tlv.EUint16(w, &dataLen, buf); err != nil {
+ return fmt.Errorf("data len: %w", err)
+ }
+
+ if err := tlv.EVarBytes(w, &b.CipherText, buf); err != nil {
+ return fmt.Errorf("encode encrypted data: %w", err)
+ }
+
+ return nil
+ }
+
+ return tlv.NewTypeForEncodingErr(val, "*sphinx.BlindedHopInfo")
+}
+
+// decodeBlindedHop decodes a blinded hop tlv.
+func decodeBlindedHop(r io.Reader, val interface{}, buf *[8]byte) error {
+ if b, ok := val.(*sphinx.BlindedHopInfo); ok {
+ err := tlv.DPubKey(r, &b.BlindedNodePub, buf, 33)
+ if err != nil {
+ return fmt.Errorf("decode blinded id: %w", err)
+ }
+
+ var dataLen uint16
+ err = tlv.DUint16(r, &dataLen, buf, 2)
+ if err != nil {
+ return fmt.Errorf("decode data len: %w", err)
+ }
+
+ err = tlv.DVarBytes(r, &b.CipherText, buf, uint64(dataLen))
+ if err != nil {
+ return fmt.Errorf("decode data: %w", err)
+ }
+
+ return nil
+ }
+
+ return tlv.NewTypeForDecodingErr(val, "*sphinx.BlindedHopInfo", 0, 0)
+}
diff --git a/lnwire/onion_msg_payload_test.go b/lnwire/onion_msg_payload_test.go
new file mode 100644
index 0000000..3a85ec6
--- /dev/null
+++ b/lnwire/onion_msg_payload_test.go
@@ -0,0 +1,492 @@
+package lnwire
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+
+ sphinx "github.com/lightningnetwork/lightning-onion"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+ "pgregory.net/rapid"
+)
+
+// makeBlindedPath creates a BlindedPath with the given number of hops for
+// testing. Each hop has a random blinded node pub and some cipher text.
+func makeBlindedPath(t *testing.T, numHops int) *sphinx.BlindedPath {
+ t.Helper()
+
+ introKey, err := randPubKey()
+ require.NoError(t, err)
+
+ blindingKey, err := randPubKey()
+ require.NoError(t, err)
+
+ hops := make([]*sphinx.BlindedHopInfo, numHops)
+ for i := range hops {
+ nodePub, err := randPubKey()
+ require.NoError(t, err)
+
+ hops[i] = &sphinx.BlindedHopInfo{
+ BlindedNodePub: nodePub,
+ CipherText: bytes.Repeat([]byte{byte(i + 1)}, 32),
+ }
+ }
+
+ return &sphinx.BlindedPath{
+ IntroductionPoint: introKey,
+ BlindingPoint: blindingKey,
+ BlindedHops: hops,
+ }
+}
+
+// assertBlindedPathEqual compares two BlindedPaths for equality, checking each
+// field.
+func assertBlindedPathEqual(t *testing.T, expected,
+ actual *sphinx.BlindedPath) {
+
+ t.Helper()
+
+ require.True(
+ t,
+ expected.IntroductionPoint.IsEqual(actual.IntroductionPoint),
+ "IntroductionPoint mismatch",
+ )
+ require.True(
+ t, expected.BlindingPoint.IsEqual(actual.BlindingPoint),
+ "BlindingPoint mismatch",
+ )
+ require.Len(t, actual.BlindedHops, len(expected.BlindedHops))
+
+ for i, expectedHop := range expected.BlindedHops {
+ actualHop := actual.BlindedHops[i]
+
+ require.True(
+ t,
+ expectedHop.BlindedNodePub.IsEqual(
+ actualHop.BlindedNodePub,
+ ),
+ "hop %d: BlindedNodePub mismatch", i,
+ )
+ require.Equal(
+ t, expectedHop.CipherText, actualHop.CipherText,
+ "hop %d: CipherText mismatch", i,
+ )
+ }
+}
+
+// encodeAndDecode is a helper that encodes a payload and decodes it into a
+// fresh OnionMessagePayload.
+func encodeAndDecode(t *testing.T,
+ original *OnionMessagePayload) *OnionMessagePayload {
+
+ t.Helper()
+
+ encoded, err := original.Encode()
+ require.NoError(t, err)
+
+ decoded := NewOnionMessagePayload()
+ _, err = decoded.Decode(bytes.NewReader(encoded))
+ require.NoError(t, err)
+
+ return decoded
+}
+
+// TestOnionMessagePayloadRoundTrip tests encode/decode roundtrips for various
+// payload configurations.
+func TestOnionMessagePayloadRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ t.Run("only reply path", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ ReplyPath: makeBlindedPath(t, 3),
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.NotNil(t, decoded.ReplyPath)
+ assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
+ require.Empty(t, decoded.EncryptedData)
+ require.Empty(t, decoded.FinalHopTLVs)
+ })
+
+ t.Run("only encrypted data", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ EncryptedData: []byte("encrypted-recipient-data"),
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.Nil(t, decoded.ReplyPath)
+ require.Equal(
+ t, original.EncryptedData, decoded.EncryptedData,
+ )
+ require.Empty(t, decoded.FinalHopTLVs)
+ })
+
+ t.Run("reply path and encrypted data", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ ReplyPath: makeBlindedPath(t, 2),
+ EncryptedData: []byte("test-ciphertext"),
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.NotNil(t, decoded.ReplyPath)
+ assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
+ require.Equal(
+ t, original.EncryptedData, decoded.EncryptedData,
+ )
+ require.Empty(t, decoded.FinalHopTLVs)
+ })
+
+ t.Run("single hop reply path", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ ReplyPath: makeBlindedPath(t, 1),
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.NotNil(t, decoded.ReplyPath)
+ assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
+ })
+
+ t.Run("final hop TLVs", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ EncryptedData: []byte("ciphertext"),
+ FinalHopTLVs: []*FinalHopTLV{
+ {
+ TLVType: InvoiceRequestNamespaceType,
+ Value: []byte("invoice-request"),
+ },
+ },
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.Equal(
+ t, original.EncryptedData, decoded.EncryptedData,
+ )
+ require.Len(t, decoded.FinalHopTLVs, 1)
+ require.Equal(
+ t, InvoiceRequestNamespaceType,
+ decoded.FinalHopTLVs[0].TLVType,
+ )
+ require.Equal(
+ t, original.FinalHopTLVs[0].Value,
+ decoded.FinalHopTLVs[0].Value,
+ )
+ })
+
+ t.Run("multiple final hop TLVs", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ FinalHopTLVs: []*FinalHopTLV{
+ {
+ TLVType: InvoiceRequestNamespaceType,
+ Value: []byte("request"),
+ },
+ {
+ TLVType: InvoiceNamespaceType,
+ Value: []byte("invoice"),
+ },
+ {
+ TLVType: InvoiceErrorNamespaceType,
+ Value: []byte("error"),
+ },
+ },
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.Nil(t, decoded.ReplyPath)
+ require.Len(t, decoded.FinalHopTLVs, 3)
+
+ // Decoded TLVs should be sorted by type.
+ require.Equal(
+ t, InvoiceRequestNamespaceType,
+ decoded.FinalHopTLVs[0].TLVType,
+ )
+ require.Equal(
+ t, InvoiceNamespaceType,
+ decoded.FinalHopTLVs[1].TLVType,
+ )
+ require.Equal(
+ t, InvoiceErrorNamespaceType,
+ decoded.FinalHopTLVs[2].TLVType,
+ )
+ })
+
+ t.Run("all fields populated", func(t *testing.T) {
+ t.Parallel()
+
+ original := &OnionMessagePayload{
+ ReplyPath: makeBlindedPath(t, 2),
+ EncryptedData: []byte("encrypted-data"),
+ FinalHopTLVs: []*FinalHopTLV{
+ {
+ TLVType: InvoiceNamespaceType,
+ Value: []byte("invoice-data"),
+ },
+ },
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.NotNil(t, decoded.ReplyPath)
+ assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
+ require.Equal(
+ t, original.EncryptedData, decoded.EncryptedData,
+ )
+ require.Len(t, decoded.FinalHopTLVs, 1)
+ require.Equal(
+ t, original.FinalHopTLVs[0].Value,
+ decoded.FinalHopTLVs[0].Value,
+ )
+ })
+
+ t.Run("odd unknown final hop TLV", func(t *testing.T) {
+ t.Parallel()
+
+ // Odd TLV types >= 64 that we don't explicitly recognize
+ // should be preserved as FinalHopTLVs.
+ original := &OnionMessagePayload{
+ FinalHopTLVs: []*FinalHopTLV{
+ {
+ TLVType: 65,
+ Value: []byte("custom-data"),
+ },
+ },
+ }
+
+ decoded := encodeAndDecode(t, original)
+
+ require.Len(t, decoded.FinalHopTLVs, 1)
+ require.Equal(t, tlv.Type(65), decoded.FinalHopTLVs[0].TLVType)
+ require.Equal(
+ t, []byte("custom-data"),
+ decoded.FinalHopTLVs[0].Value,
+ )
+ })
+}
+
+// TestFinalHopTLVValidate tests that FinalHopTLV.Validate correctly rejects
+// types below the final hop range and accepts types within it.
+func TestFinalHopTLVValidate(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ recordType tlv.Type
+ wantErr error
+ }{
+ {
+ name: "type 0 rejected",
+ recordType: 0,
+ wantErr: ErrNotFinalPayload,
+ },
+ {
+ name: "type 2 rejected",
+ recordType: 2,
+ wantErr: ErrNotFinalPayload,
+ },
+ {
+ name: "type 63 rejected",
+ recordType: 63,
+ wantErr: ErrNotFinalPayload,
+ },
+ {
+ name: "type 64 accepted",
+ recordType: 64,
+ },
+ {
+ name: "type 65 accepted",
+ recordType: 65,
+ },
+ {
+ name: "type 255 accepted",
+ recordType: 255,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ f := &FinalHopTLV{
+ TLVType: tc.recordType,
+ Value: []byte("value"),
+ }
+
+ err := f.Validate()
+ if tc.wantErr != nil {
+ require.ErrorIs(t, err, tc.wantErr)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+// TestOnionMessagePayloadEncodeReplyPathNoHops tests that encoding a reply path
+// with zero hops returns an error.
+func TestOnionMessagePayloadEncodeReplyPathNoHops(t *testing.T) {
+ t.Parallel()
+
+ introKey, err := randPubKey()
+ require.NoError(t, err)
+
+ blindingKey, err := randPubKey()
+ require.NoError(t, err)
+
+ payload := &OnionMessagePayload{
+ ReplyPath: &sphinx.BlindedPath{
+ IntroductionPoint: introKey,
+ BlindingPoint: blindingKey,
+ BlindedHops: nil,
+ },
+ }
+
+ _, err = payload.Encode()
+ require.ErrorIs(t, err, ErrNoHops)
+}
+
+// TestOnionMessagePayloadEmpty tests that an empty payload roundtrips
+// correctly.
+func TestOnionMessagePayloadEmpty(t *testing.T) {
+ t.Parallel()
+
+ original := NewOnionMessagePayload()
+ decoded := encodeAndDecode(t, original)
+
+ require.Nil(t, decoded.ReplyPath)
+ require.Empty(t, decoded.EncryptedData)
+ require.Empty(t, decoded.FinalHopTLVs)
+}
+
+// TestOnionMessagePayloadRoundTripQuickCheck uses property-based testing to
+// verify that randomly generated OnionMessagePayload values survive
+// encode/decode roundtrips.
+func TestOnionMessagePayloadRoundTripQuickCheck(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ original := &OnionMessagePayload{}
+
+ // Optionally include a reply path.
+ if rapid.Bool().Draw(t, "hasReplyPath") {
+ original.ReplyPath = RandBlindedPath(t)
+ }
+
+ // Optionally include encrypted data.
+ if rapid.Bool().Draw(t, "hasEncryptedData") {
+ dataLen := rapid.IntRange(1, 256).Draw(
+ t, "encryptedDataLen",
+ )
+ original.EncryptedData = rapid.SliceOfN(
+ rapid.Byte(), dataLen, dataLen,
+ ).Draw(t, "encryptedData")
+ }
+
+ // Optionally include final hop TLVs. We use the three known
+ // even types (64, 66, 68) since unknown even types would cause
+ // decode to fail.
+ knownTypes := []tlv.Type{
+ InvoiceRequestNamespaceType,
+ InvoiceNamespaceType,
+ InvoiceErrorNamespaceType,
+ }
+ numFinalTLVs := rapid.IntRange(0, len(knownTypes)).Draw(
+ t, "numFinalTLVs",
+ )
+ for i := range numFinalTLVs {
+ valLen := rapid.IntRange(1, 64).Draw(
+ t, fmt.Sprintf("finalTLVLen-%d", i),
+ )
+ original.FinalHopTLVs = append(
+ original.FinalHopTLVs,
+ &FinalHopTLV{
+ TLVType: knownTypes[i],
+ Value: rapid.SliceOfN(
+ rapid.Byte(), valLen, valLen,
+ ).Draw(
+ t,
+ fmt.Sprintf("finalTLV-%d", i),
+ ),
+ },
+ )
+ }
+
+ // Encode.
+ encoded, err := original.Encode()
+ require.NoError(t, err)
+
+ // Decode.
+ decoded := NewOnionMessagePayload()
+ _, err = decoded.Decode(bytes.NewReader(encoded))
+ require.NoError(t, err)
+
+ // Verify reply path.
+ if original.ReplyPath == nil {
+ require.Nil(t, decoded.ReplyPath)
+ } else {
+ require.NotNil(t, decoded.ReplyPath)
+ require.True(
+ t,
+ original.ReplyPath.IntroductionPoint.IsEqual(
+ decoded.ReplyPath.IntroductionPoint,
+ ),
+ )
+ require.True(
+ t,
+ original.ReplyPath.BlindingPoint.IsEqual(
+ decoded.ReplyPath.BlindingPoint,
+ ),
+ )
+ require.Len(
+ t, decoded.ReplyPath.BlindedHops,
+ len(original.ReplyPath.BlindedHops),
+ )
+ for i, hop := range original.ReplyPath.BlindedHops {
+ dHop := decoded.ReplyPath.BlindedHops[i]
+ require.True(
+ t,
+ hop.BlindedNodePub.IsEqual(
+ dHop.BlindedNodePub,
+ ),
+ )
+ require.Equal(
+ t, hop.CipherText,
+ dHop.CipherText,
+ )
+ }
+ }
+
+ // Verify encrypted data.
+ require.Equal(
+ t, original.EncryptedData, decoded.EncryptedData,
+ )
+
+ // Verify final hop TLVs.
+ require.Len(
+ t, decoded.FinalHopTLVs,
+ len(original.FinalHopTLVs),
+ )
+ for i, orig := range original.FinalHopTLVs {
+ dec := decoded.FinalHopTLVs[i]
+ require.Equal(t, orig.TLVType, dec.TLVType)
+ require.Equal(t, orig.Value, dec.Value)
+ }
+ })
+}
diff --git a/lnwire/test_utils.go b/lnwire/test_utils.go
index ed86230..227640a 100644
--- a/lnwire/test_utils.go
+++ b/lnwire/test_utils.go
@@ -9,6 +9,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
+ sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
@@ -58,6 +59,33 @@ func RandPubKey(t *rapid.T) *btcec.PublicKey {
return pub
}
+// RandBlindedPath generates a random blinded path with 1-5 hops.
+func RandBlindedPath(t *rapid.T) *sphinx.BlindedPath {
+ introKey := RandPubKey(t)
+ blindingKey := RandPubKey(t)
+
+ numHops := rapid.IntRange(1, 5).Draw(t, "numBlindedHops")
+ hops := make([]*sphinx.BlindedHopInfo, numHops)
+ for i := range hops {
+ cipherLen := rapid.IntRange(1, 128).Draw(
+ t, fmt.Sprintf("cipherLen-%d", i),
+ )
+
+ hops[i] = &sphinx.BlindedHopInfo{
+ BlindedNodePub: RandPubKey(t),
+ CipherText: rapid.SliceOfN(
+ rapid.Byte(), cipherLen, cipherLen,
+ ).Draw(t, fmt.Sprintf("cipherText-%d", i)),
+ }
+ }
+
+ return &sphinx.BlindedPath{
+ IntroductionPoint: introKey,
+ BlindingPoint: blindingKey,
+ BlindedHops: hops,
+ }
+}
+
// RandChannelID generates a random channel ID.
func RandChannelID(t *rapid.T) ChannelID {
var c ChannelID
Why this scored 21/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.