Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
334 changes: 334 additions & 0 deletions bolt12/subtypes.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
package bolt12

import (
"encoding/binary"
"errors"
"fmt"
"io"
"math"

"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)

// ErrTooManyChains is returned when offer_chains declares more entries than
// maxOfferChains.
var ErrTooManyChains = errors.New("offer_chains exceeds maxOfferChains")

// ErrNonMinimalFeatures is returned when a decoded feature vector is not
// canonically (minimally) encoded.
var ErrNonMinimalFeatures = errors.New("non-minimal feature vector encoding")

// ErrTooManyBlindedPayInfos is returned when decoded blinded_payinfo entries
// exceed maxBlindedPayInfos.
var ErrTooManyBlindedPayInfos = errors.New(
"invoice_blindedpay exceeds maxBlindedPayInfos",
)

// ErrTooManyFallbackAddrs is returned when decoded fallback_address entries
// exceed maxFallbackAddrs.
var ErrTooManyFallbackAddrs = errors.New(
"invoice_fallbacks exceeds maxFallbackAddrs",
)

const (
// chainHashLen is the length of a chain hash (32 bytes).
chainHashLen = 32
Expand All @@ -20,6 +39,19 @@ const (
// check to prevent excessive memory allocation and is not a protocol
// limit but a local implementation choice.
maxOfferChains = 32

// maxBlindedPayInfos caps decoded blinded_payinfo entries to prevent
// excessive allocation and validation cost.
maxBlindedPayInfos = 32

// maxFallbackAddrs caps decoded fallback_address entries to prevent
// excessive allocation and validation cost.
maxFallbackAddrs = 32

// maxFallbackAddrLen bounds the address bytes in a single fallback
// entry. The spec encodes the length as a uint16, so 65535 is the
// format's ceiling.
maxFallbackAddrLen = math.MaxUint16
)

// ChainsRecord holds one or more chain hashes for the offer_chains field.
Expand Down Expand Up @@ -85,3 +117,305 @@ func decodeChainsRecord(r io.Reader, val any, _ *[8]byte, l uint64) error {

return nil
}

// BlindedPayInfo holds the payment parameters for a blinded path, corresponding
// to the blinded_payinfo subtype.
type BlindedPayInfo struct {
FeeBaseMsat uint32
FeeProportionalMillionths uint32
CltvExpiryDelta uint16
HtlcMinimumMsat uint64
HtlcMaximumMsat uint64

// Features is the relay feature bitmap for this blinded path, typed for
// consistency with the other BOLT 12 feature fields.
//
// WARNING: RawFeatureVector re-encodes to minimal length, so setting
// non-minimal feature bytes (trailing zeros) yields different wire
// bytes than were read and invalidates the invoice signature.
Comment on lines +133 to +135

@erickcestari erickcestari Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I believe it would be worth a change in the spec to only allow minimal encoded features in all lightning types.

This would be similar to what is being done with channel_announcement and node_announcement:
lightning/bolts#1341 (review)

Features lnwire.RawFeatureVector
}

// BlindedPayInfos holds a list of BlindedPayInfo entries for the
// invoice_blindedpay field.
type BlindedPayInfos struct {
Infos []BlindedPayInfo

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is a known issue as documented. I favored using the internal data structure for tlv mechanics over keeping the byte-wise representation. Added an error path to detect this explicitly.

}

// Record returns a TLV record for BlindedPayInfos.
//
// NOTE: This implements the tlv.RecordProducer interface.
func (bp *BlindedPayInfos) Record() tlv.Record {
return tlv.MakeDynamicRecord(
0, bp,
func() uint64 {
return blindedPayInfosSize(bp)
},
encodeBlindedPayInfos, decodeBlindedPayInfos,
)
}

// blindedPayInfosSize returns the encoded byte length of all blinded_payinfo
// entries, used to size the dynamic TLV record.
func blindedPayInfosSize(bp *BlindedPayInfos) uint64 {
var size uint64
for _, info := range bp.Infos {
// fee_base(4) + fee_prop(4) + cltv(2) + htlc_min(8) +
// htlc_max(8) + flen(2) + features.
size += 4 + 4 + 2 + 8 + 8 + 2 +
uint64(info.Features.SerializeSize())
}

return size
}

// encodeBlindedPayInfos writes each blinded_payinfo entry in sequence: the
// fixed fee, cltv and htlc fields followed by a u16-length-prefixed feature
// vector. Entries are concatenated without a count prefix; the count is
// recovered on decode from the surrounding invoice_paths length.
func encodeBlindedPayInfos(
w io.Writer, val interface{}, buf *[8]byte) error {

bp, ok := val.(*BlindedPayInfos)
if !ok {
return fmt.Errorf("expected *BlindedPayInfos, got %T", val)
}

for _, info := range bp.Infos {
binary.BigEndian.PutUint32(buf[:4], info.FeeBaseMsat)
if _, err := w.Write(buf[:4]); err != nil {
return err
}

binary.BigEndian.PutUint32(
buf[:4], info.FeeProportionalMillionths,
)
if _, err := w.Write(buf[:4]); err != nil {
return err
}

binary.BigEndian.PutUint16(buf[:2], info.CltvExpiryDelta)
if _, err := w.Write(buf[:2]); err != nil {
return err
}

binary.BigEndian.PutUint64(buf[:8], info.HtlcMinimumMsat)
if _, err := w.Write(buf[:8]); err != nil {
return err
}

binary.BigEndian.PutUint64(buf[:8], info.HtlcMaximumMsat)
if _, err := w.Write(buf[:8]); err != nil {
return err
}

// flen is a u16, so guard the cast before framing the minimal
// feature bytes, mirroring encodeFallbackAddrs.
flen := info.Features.SerializeSize()
if flen > math.MaxUint16 {
return fmt.Errorf("features %d exceed limit %d",
flen, math.MaxUint16)
}

binary.BigEndian.PutUint16(buf[:2], uint16(flen))
if _, err := w.Write(buf[:2]); err != nil {
return err
}
if err := info.Features.EncodeBase256(w); err != nil {
return err
}
}

return nil
Comment thread
bitromortac marked this conversation as resolved.
}

// decodeBlindedPayInfos reads blinded_payinfo entries until the record bytes
// are exhausted. The entry count is capped at maxBlindedPayInfos to prevent
// excessive memory allocation and validation cost.
func decodeBlindedPayInfos(
r io.Reader, val interface{}, buf *[8]byte, l uint64) error {

bp, ok := val.(*BlindedPayInfos)
if !ok {
return fmt.Errorf("expected *BlindedPayInfos, got %T", val)
}

lr := &io.LimitedReader{R: r, N: int64(l)}

for lr.N > 0 {
if len(bp.Infos) >= maxBlindedPayInfos {
return ErrTooManyBlindedPayInfos
}

var info BlindedPayInfo

if _, err := io.ReadFull(lr, buf[:4]); err != nil {
return fmt.Errorf("read fee_base: %w", err)
}
info.FeeBaseMsat = binary.BigEndian.Uint32(buf[:4])

if _, err := io.ReadFull(lr, buf[:4]); err != nil {
return fmt.Errorf("read fee_prop: %w", err)
}
info.FeeProportionalMillionths = binary.BigEndian.Uint32(
buf[:4],
)

if _, err := io.ReadFull(lr, buf[:2]); err != nil {
return fmt.Errorf("read cltv_delta: %w", err)
}
info.CltvExpiryDelta = binary.BigEndian.Uint16(buf[:2])

if _, err := io.ReadFull(lr, buf[:8]); err != nil {
return fmt.Errorf("read htlc_min: %w", err)
}
info.HtlcMinimumMsat = binary.BigEndian.Uint64(buf[:8])

if _, err := io.ReadFull(lr, buf[:8]); err != nil {
return fmt.Errorf("read htlc_max: %w", err)
}
info.HtlcMaximumMsat = binary.BigEndian.Uint64(buf[:8])

// flen then features, mirroring decodeFallbackAddrs: reject a
// length that overruns the remaining bytes before allocating.
// Decode into a constructed vector so its map is initialised.
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
return fmt.Errorf("read flen: %w", err)
}
flen := binary.BigEndian.Uint16(buf[:2])
if int64(flen) > lr.N {
return fmt.Errorf("flen %d exceeds remaining %d",
flen, lr.N)
}

fv := lnwire.NewRawFeatureVector()
if err := fv.DecodeBase256(lr, int(flen)); err != nil {
return fmt.Errorf("read features: %w", err)
}
if fv.SerializeSize() != int(flen) {
return ErrNonMinimalFeatures
}
info.Features = *fv

bp.Infos = append(bp.Infos, info)
}

return nil
}

// FallbackAddress represents an on-chain fallback address.
type FallbackAddress struct {
Version byte
Address []byte
}

// FallbackAddresses holds a list of fallback addresses for the
// invoice_fallbacks field.
type FallbackAddresses struct {
Addrs []FallbackAddress
}

// Record returns a TLV record for FallbackAddresses.
//
// NOTE: This implements the tlv.RecordProducer interface.
func (fa *FallbackAddresses) Record() tlv.Record {
return tlv.MakeDynamicRecord(
0, fa,
func() uint64 {
return fallbackAddrsSize(fa)
},
encodeFallbackAddrs, decodeFallbackAddrs,
)
}

// fallbackAddrsSize returns the encoded byte length of all fallback_address
// entries, used to size the dynamic TLV record.
func fallbackAddrsSize(fa *FallbackAddresses) uint64 {
var size uint64
for _, a := range fa.Addrs {
// version(1) + len(2) + address
size += 1 + 2 + uint64(len(a.Address))
}

return size
}

// encodeFallbackAddrs writes each fallback_address entry as a version byte, a
// u16 address length and the raw address bytes, concatenated without a count
// prefix.
func encodeFallbackAddrs(
w io.Writer, val interface{}, buf *[8]byte) error {

fa, ok := val.(*FallbackAddresses)
if !ok {
return fmt.Errorf("expected *FallbackAddresses, got %T", val)
}

for i, a := range fa.Addrs {
if len(a.Address) > maxFallbackAddrLen {
return fmt.Errorf("fallback %d: address %d exceeds "+
"limit %d", i, len(a.Address),
maxFallbackAddrLen)
}

buf[0] = a.Version
if _, err := w.Write(buf[:1]); err != nil {
return err
}

binary.BigEndian.PutUint16(buf[:2], uint16(len(a.Address)))
if _, err := w.Write(buf[:2]); err != nil {
return err
}
if _, err := w.Write(a.Address); err != nil {
return err
}
}

return nil
}

// decodeFallbackAddrs reads fallback_address entries until the record bytes are
// exhausted. The entry count is capped at maxFallbackAddrs to prevent
// excessive memory allocation and validation cost.
func decodeFallbackAddrs(
r io.Reader, val interface{}, buf *[8]byte, l uint64) error {

fa, ok := val.(*FallbackAddresses)
if !ok {
return fmt.Errorf("expected *FallbackAddresses, got %T", val)
}

lr := &io.LimitedReader{R: r, N: int64(l)}

for lr.N > 0 {
if len(fa.Addrs) >= maxFallbackAddrs {
return ErrTooManyFallbackAddrs
}

var a FallbackAddress

if _, err := io.ReadFull(lr, buf[:1]); err != nil {
return fmt.Errorf("read version: %w", err)
}
a.Version = buf[0]

if _, err := io.ReadFull(lr, buf[:2]); err != nil {
return fmt.Errorf("read addrlen: %w", err)
}
addrLen := binary.BigEndian.Uint16(buf[:2])
if int64(addrLen) > lr.N {
return fmt.Errorf("addrlen %d exceeds remaining %d",
addrLen, lr.N)
}

a.Address = make([]byte, addrLen)
if _, err := io.ReadFull(lr, a.Address); err != nil {
return fmt.Errorf("read address: %w", err)
}

fa.Addrs = append(fa.Addrs, a)
}

return nil
}
Loading