From 4810bd627be434518c839ecb5b3acfc1a5d431d1 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Mon, 29 Jun 2026 18:24:22 +0200 Subject: [PATCH 1/5] bolt12: add TUint32 and invoice subtypes Add the truncated uint32 (tu32) TLV type used by invoice_relative_expiry and the dynamic invoice subtypes BlindedPayInfo and FallbackAddress, along with their encode/decode helpers and round-trip tests. These primitives are the building blocks for the BOLT 12 Invoice message struct that follows. Isolating them keeps that codec commit focused on the message shape rather than its component records. --- bolt12/subtypes.go | 334 ++++++++++++++++++++++++++++++++++++++++ bolt12/subtypes_test.go | 246 +++++++++++++++++++++++++++++ bolt12/tlv_types.go | 17 ++ 3 files changed, 597 insertions(+) diff --git a/bolt12/subtypes.go b/bolt12/subtypes.go index dcd5e41de15..abee9c11fb4 100644 --- a/bolt12/subtypes.go +++ b/bolt12/subtypes.go @@ -1,10 +1,13 @@ package bolt12 import ( + "encoding/binary" "errors" "fmt" "io" + "math" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" ) @@ -12,6 +15,22 @@ import ( // 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 @@ -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. @@ -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. + Features lnwire.RawFeatureVector +} + +// BlindedPayInfos holds a list of BlindedPayInfo entries for the +// invoice_blindedpay field. +type BlindedPayInfos struct { + Infos []BlindedPayInfo +} + +// 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 +} + +// 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 +} diff --git a/bolt12/subtypes_test.go b/bolt12/subtypes_test.go index ceecd14a084..6d85c822dd7 100644 --- a/bolt12/subtypes_test.go +++ b/bolt12/subtypes_test.go @@ -3,8 +3,10 @@ package bolt12 import ( "bytes" "encoding/hex" + "math" "testing" + "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -152,3 +154,247 @@ func TestChainsRecordRoundTrip(t *testing.T) { }) } } + +// TestFallbackAddressesRoundTrip encodes a list of fallback addresses +// covering BIP-141 v0, BIP-350 v1, a forward-compatible v2 entry, and +// a v17 entry that the spec mandates a *reader* ignore but the codec +// layer must still round-trip faithfully (the ignore policy lives at +// the invoice-consumer layer, not at the codec). The fallback list is +// on-chain payment data: a wrong version byte or mis-framed length +// translates into funds going to an unintended script, so encode/ +// decode must be a faithful bijection across the entire version +// range. +func TestFallbackAddressesRoundTrip(t *testing.T) { + t.Parallel() + + addrs := &FallbackAddresses{ + Addrs: []FallbackAddress{ + { + Version: 0, + Address: bytes.Repeat([]byte{0xab}, 20), + }, + { + Version: 1, + Address: bytes.Repeat([]byte{0xcd}, 32), + }, + { + Version: 2, + Address: bytes.Repeat([]byte{0xef}, 64), + }, + { + Version: 17, + Address: bytes.Repeat([]byte{0x99}, 20), + }, + }, + } + + var buf bytes.Buffer + require.NoError(t, encodeFallbackAddrs(&buf, addrs, new([8]byte))) + encoded := buf.Bytes() + + expectedSize := fallbackAddrsSize(addrs) + require.Equal(t, expectedSize, uint64(len(encoded))) + + var decoded FallbackAddresses + err := decodeFallbackAddrs( + bytes.NewReader(encoded), &decoded, new([8]byte), + uint64(len(encoded)), + ) + require.NoError(t, err) + require.Equal(t, addrs.Addrs, decoded.Addrs) +} + +// TestBlindedPayInfosRoundTrip encodes a list of blinded_payinfo entries and +// asserts decode reproduces them exactly. +func TestBlindedPayInfosRoundTrip(t *testing.T) { + t.Parallel() + + noFeats := *lnwire.NewRawFeatureVector() + someFeats := *lnwire.NewRawFeatureVector(8, 15) + + infos := &BlindedPayInfos{ + Infos: []BlindedPayInfo{ + { + FeeBaseMsat: 1000, + FeeProportionalMillionths: 250, + CltvExpiryDelta: 144, + HtlcMinimumMsat: 1, + HtlcMaximumMsat: 1_000_000, + Features: noFeats, + }, + { + FeeBaseMsat: 0, + FeeProportionalMillionths: 0, + CltvExpiryDelta: 40, + HtlcMinimumMsat: 0, + HtlcMaximumMsat: math.MaxUint64, + Features: someFeats, + }, + }, + } + + var buf bytes.Buffer + require.NoError(t, encodeBlindedPayInfos(&buf, infos, new([8]byte))) + encoded := buf.Bytes() + + require.Equal(t, blindedPayInfosSize(infos), uint64(len(encoded))) + + var decoded BlindedPayInfos + err := decodeBlindedPayInfos( + bytes.NewReader(encoded), &decoded, + new([8]byte), uint64(len(encoded)), + ) + require.NoError(t, err) + require.Equal(t, infos.Infos, decoded.Infos) +} + +// TestDecodeBlindedPayInfosRejectsTruncated covers truncation before the fixed +// fields and before the declared features payload. Each must fail rather than +// yield a partial BlindedPayInfos with corrupt entries. +func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { // + t.Parallel() + + tests := []struct { + name string + data []byte + declLen uint64 + errSubstr string + }{ + { + name: "missing fee_base", + data: nil, + declLen: 4, + errSubstr: "read fee_base", + }, + { + name: "features length exceeds remaining", + // fee_base(4) fee_prop(4) cltv(2) htlc_min(8) + // htlc_max(8) then flen=0xffff with no payload. + data: append( + make([]byte, 26), []byte{0xff, 0xff}..., + ), + declLen: 28, + errSubstr: "exceeds remaining", + }, + { + name: "exceeds cap", + data: make([]byte, (maxBlindedPayInfos+1)*28), + declLen: (maxBlindedPayInfos + 1) * 28, + errSubstr: "exceeds maxBlindedPayInfos", + }, + { + name: "non-minimal features", + // fee_base(4) + fee_prop(4) + cltv(2) + htlc_min(8) + + // htlc_max(8) followed by flen = 1, and 1 non-minimal + // feature byte (trailing zero). + data: append( + make([]byte, 26), []byte{0x00, 0x01, 0x00}..., + ), + declLen: 29, + errSubstr: "non-minimal", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var bp BlindedPayInfos + err := decodeBlindedPayInfos( + bytes.NewReader(tc.data), &bp, new([8]byte), + tc.declLen, + ) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } +} + +// TestEncodeFallbackAddrsRejectsOversize asserts the maxFallbackAddrLen cap is +// enforced before any bytes hit the writer. +func TestEncodeFallbackAddrsRejectsOversize(t *testing.T) { + t.Parallel() + + addrs := &FallbackAddresses{ + Addrs: []FallbackAddress{{ + Version: 0, + Address: make([]byte, maxFallbackAddrLen+1), + }}, + } + + var buf bytes.Buffer + err := encodeFallbackAddrs(&buf, addrs, new([8]byte)) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds limit") + require.Zero(t, buf.Len(), + "no bytes should be written when validation fails") +} + +// TestDecodeFallbackAddrsRejectsTruncated covers the three truncation points in +// decodeFallbackAddrs: stream ends before the version byte, before the 16-bit +// length, and before the address payload of the declared size. Each must fail +// with an error rather than yielding a partial FallbackAddresses with corrupt +// entries. +func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { + t.Parallel() + + // Each case declares a TLV-record length that overshoots the bytes + // actually present, simulating a malformed wire payload that promises + // more data than it delivers. + tests := []struct { + name string + data []byte + declLen uint64 + errSubstr string + }{ + { + name: "missing version byte", + data: nil, + declLen: 1, + errSubstr: "read version", + }, + { + name: "missing length bytes", + data: []byte{0x00}, + declLen: 3, + errSubstr: "read addrlen", + }, + { + name: "truncated address payload", + data: []byte{ + 0x00, 0x00, 0x05, 0xab, 0xab, + }, + declLen: 8, + errSubstr: "read address", + }, + { + // addrlen > remaining trips the guard before + // allocation; without it a hostile addrlen would force + // a huge make([]byte, addrLen). + name: "addrlen exceeds remaining", + data: []byte{0x00, 0xff, 0xff, 0xab}, + declLen: 4, + errSubstr: "exceeds remaining", + }, + { + name: "exceeds cap", + data: make([]byte, (maxFallbackAddrs+1)*3), + declLen: (maxFallbackAddrs + 1) * 3, + errSubstr: "exceeds maxFallbackAddrs", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var fa FallbackAddresses + err := decodeFallbackAddrs( + bytes.NewReader(tc.data), &fa, new([8]byte), + tc.declLen, + ) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } +} diff --git a/bolt12/tlv_types.go b/bolt12/tlv_types.go index b956477c112..b0918ac2a3a 100644 --- a/bolt12/tlv_types.go +++ b/bolt12/tlv_types.go @@ -20,3 +20,20 @@ func (t *TUint64) Record() tlv.Record { tlv.ETUint64, tlv.DTUint64, ) } + +// TUint32 is a uint32 that serializes using truncated encoding (tu32) as +// required by BOLT 12. Leading zero bytes are omitted. +type TUint32 uint32 + +// Record returns a TLV record using truncated uint32 encoding. +// +// NOTE: This implements the tlv.RecordProducer interface. +func (t *TUint32) Record() tlv.Record { + return tlv.MakeDynamicRecord( + 0, (*uint32)(t), + func() uint64 { + return tlv.SizeTUint32(uint32(*t)) + }, + tlv.ETUint32, tlv.DTUint32, + ) +} From a99becbc4c08c09ddf10f823b67633d829be2291 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 30 Jun 2026 11:17:10 +0200 Subject: [PATCH 2/5] bolt12: inject feature-bit catalogues into Offer and InvoiceRequest validators Inject known feature-bit catalogues into the read-side validators to enable correct must-understand capability checks, and remove write-side feature enforcement entirely. Whether a feature bit is "unknown" is a runtime property of the reading node, not of the wire format or pure codec. --- bolt12/validate.go | 26 ++++++++++---------- bolt12/validate_test.go | 53 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index 7b2e421c341..19009309bd3 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -359,8 +359,10 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { // - if it supports bolt12 invoice request features: // - MUST set invreq_features.features to the bitmap of features. // We only reject unknown even bits here; advertising a feature is the - // caller's decision. - if err := checkFeatures(ir.InvreqFeatures); err != nil { + // caller's decision. Since the writer lacks a catalogue in scope, we + // pass nil for the catalogue, treating all even feature bits as + // unknown. + if err := checkFeatures(ir.InvreqFeatures, nil); err != nil { return err } @@ -461,7 +463,8 @@ func getInvreqChain(ir *InvoiceRequest) [32]byte { // Invoice message (see the TODO at the end of this function). Until then, a // caller wiring this into a handler MUST verify the signature itself. func ValidateInvoiceRequestRead(ir *InvoiceRequest, - activeChain [32]byte) error { + activeChain [32]byte, + knownFeatures map[lnwire.FeatureBit]string) error { // A present-but-nil pubkey passes IsSome but would panic the codec on // encode, so reject both pubkey fields. @@ -506,7 +509,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest, // - if invreq_features contains unknown *even* bits that are non-zero: // - MUST reject the invoice request. - if err := checkFeatures(ir.InvreqFeatures); err != nil { + if err := checkFeatures(ir.InvreqFeatures, knownFeatures); err != nil { return err } @@ -798,7 +801,9 @@ func isKnownOfferTLVType(typ tlv.Type) bool { // defaults to Bitcoin mainnet, and the reader must reject offers that do not // list a chain it operates on. Pass the genesis hash of the chain the receiver // is willing to settle on. -func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error { +func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, + knownFeatures map[lnwire.FeatureBit]string) error { + // A present-but-nil offer_issuer_id passes IsSome but would panic the // codec on encode, so reject it here. if err := checkPubKeyNotNil( @@ -806,7 +811,6 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error { ); err != nil { return err } - // Check TLV types are in allowed range and that unknown even types are // rejected (even = must-understand). for _, t := range sortedTypes(o.decodedTLVs) { @@ -820,7 +824,7 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error { } // Check for unknown even feature bits. - if err := checkFeatures(o.OfferFeatures); err != nil { + if err := checkFeatures(o.OfferFeatures, knownFeatures); err != nil { return err } @@ -1029,15 +1033,13 @@ func checkISO4217[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob]) error { // checkFeatures rejects any unknown even (must-understand) feature bit. func checkFeatures[T tlv.TlvType]( - opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector]) error { + opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector], + known map[lnwire.FeatureBit]string) error { return fn.MapOptionZ( opt.ValOpt(), func(fv lnwire.RawFeatureVector) error { - // nil catalogue: BOLT 12 defines no feature bits yet, - // so every set even bit is "unknown". Swap in a - // Bolt12Features map once the spec assigns bits. - wrapped := lnwire.NewFeatureVector(&fv, nil) + wrapped := lnwire.NewFeatureVector(&fv, known) unknown := wrapped.UnknownRequiredFeatures() if len(unknown) == 0 { return nil diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index c31a060e4e3..e6240d658d9 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -242,6 +242,7 @@ func TestValidateOfferRead(t *testing.T) { name string mutate func(*Offer) activeChain [32]byte + known map[lnwire.FeatureBit]string wantErr error }{ { @@ -632,6 +633,21 @@ func TestValidateOfferRead(t *testing.T) { activeChain: bitcoinMainnetGenesisHash, wantErr: ErrUnknownEvenType, }, + { + name: "known even feature bit accepted", + mutate: func(o *Offer) { + o.OfferFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12]( + *lnwire.NewRawFeatureVector(0), + ), + ) + }, + activeChain: bitcoinMainnetGenesisHash, + known: map[lnwire.FeatureBit]string{ + 0: "test_feature", + }, + wantErr: nil, + }, } for _, tc := range tests { @@ -643,7 +659,9 @@ func TestValidateOfferRead(t *testing.T) { o := validBobOffer(t) tc.mutate(o) - err := ValidateOfferRead(o, now, tc.activeChain) + err := ValidateOfferRead( + o, now, tc.activeChain, tc.known, + ) if tc.wantErr == nil { require.NoError(t, err) @@ -1198,6 +1216,7 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { tests := []struct { name string mutate func(*InvoiceRequest) + known map[lnwire.FeatureBit]string wantErr error }{ { @@ -1413,6 +1432,20 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { }, wantErr: ErrUnknownEvenFeature, }, + { + name: "known even feature bit accepted", + mutate: func(ir *InvoiceRequest) { + ir.InvreqFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84]( + *lnwire.NewRawFeatureVector(0), + ), + ) + }, + known: map[lnwire.FeatureBit]string{ + 0: "test_feature", + }, + wantErr: nil, + }, } for _, tc := range tests { @@ -1422,8 +1455,16 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { ir := validInvoiceRequest(t) tc.mutate(ir) + if tc.name == "known even feature bit accepted" { + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + [64]byte{0x01}, + ), + ) + } + err := ValidateInvoiceRequestRead( - ir, bitcoinMainnetGenesisHash, + ir, bitcoinMainnetGenesisHash, tc.known, ) require.ErrorIs(t, err, tc.wantErr) }) @@ -1464,7 +1505,7 @@ func TestValidateInvoiceRequestReadAmountBelowExpected(t *testing.T) { ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82](TUint64(1999)), ) - err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash) + err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash, nil) require.ErrorIs(t, err, ErrAmountBelowExpected) } @@ -1512,7 +1553,7 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) { // The reader MUST reject the overflowing request. readErr := ValidateInvoiceRequestRead( - newRequest(), bitcoinMainnetGenesisHash, + newRequest(), bitcoinMainnetGenesisHash, nil, ) require.ErrorIs(t, readErr, ErrAmountBelowExpected) @@ -1538,7 +1579,7 @@ func TestValidateInvoiceRequestReadChain(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) - err := ValidateInvoiceRequestRead(ir, altChain) + err := ValidateInvoiceRequestRead(ir, altChain, nil) require.ErrorIs(t, err, ErrUnsupportedChain) }) @@ -1552,7 +1593,7 @@ func TestValidateInvoiceRequestReadChain(t *testing.T) { // The chain check runs before signature verification, so a // mismatched chain is rejected regardless of the signature. err := ValidateInvoiceRequestRead( - ir, bitcoinMainnetGenesisHash, + ir, bitcoinMainnetGenesisHash, nil, ) require.ErrorIs(t, err, ErrUnsupportedChain) }) From 9aecb187a02711128a128f94368eff856abdc7a8 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Mon, 29 Jun 2026 18:24:23 +0200 Subject: [PATCH 3/5] bolt12: add Invoice struct and TLV codec Add the BOLT 12 Invoice message: a struct mirroring the invoice_request fields (types 0-91) plus the invoice-specific fields (types 160-176) and the signature (type 240), together with its pure-TLV Encode/DecodeInvoice codec and the UsableFallbackAddresses accessor that applies the spec's MUST-ignore filter. Additionally, add the NewInvoiceFromRequest constructor to build an Invoice from a corresponding request. This copies all non-signature fields from the request (including unknown signed-range TLVs via the decodedTLVs sidecar) and mirrors invreq_amount into invoice_amount. --- bolt12/invoice.go | 422 +++++++++++++++++++++++++++++++++++++++++ bolt12/invoice_test.go | 341 +++++++++++++++++++++++++++++++++ 2 files changed, 763 insertions(+) create mode 100644 bolt12/invoice.go create mode 100644 bolt12/invoice_test.go diff --git a/bolt12/invoice.go b/bolt12/invoice.go new file mode 100644 index 00000000000..0e465cda16d --- /dev/null +++ b/bolt12/invoice.go @@ -0,0 +1,422 @@ +package bolt12 + +import ( + "bytes" + "fmt" + "maps" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +// Invoice represents a BOLT 12 invoice message. It mirrors all non-signature +// invoice_request fields (types 0-91) and adds invoice-specific fields (types +// 160-176) plus a Schnorr signature (type 240). +// +// An invoice in response to a request should be constructed from that request +// (e.g., using NewInvoiceFromRequest) to mirror its fields. The caller then +// populates the invoice-specific fields and signs it. +type Invoice struct { + // Fields in the 0-91 range are mirrored verbatim from the + // invoice_request (which carries the offer's fields); the byte-for-byte + // match is enforced by ValidateInvoiceAgainstRequest. + + // InvreqMetadata is the payer metadata. + InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob] + + // OfferChains are the chains the offer is valid for. + OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord] + + // OfferMetadata is the offer metadata. + OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob] + + // OfferCurrency is the offer currency. + OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob] + + // OfferAmount is the offer amount. + OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64] + + // OfferDescription is the offer description. + OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob] + + // OfferFeatures are the offer features. + OfferFeatures tlv.OptionalRecordT[ + tlv.TlvType12, lnwire.RawFeatureVector, + ] + + // OfferAbsoluteExpiry is the offer's absolute expiry. + OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64] + + // OfferPaths are the offer's blinded paths. + OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths] + + // OfferIssuer is the offer issuer name. + OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob] + + // OfferQuantityMax is the offer's maximum quantity. + OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64] + + // OfferIssuerID is the offer issuer's public key. + OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey] + + // InvreqChain is the requested chain. + InvreqChain tlv.OptionalRecordT[tlv.TlvType80, [32]byte] + + // InvreqAmount is the amount the payer offered. + InvreqAmount tlv.OptionalRecordT[tlv.TlvType82, TUint64] + + // InvreqFeatures are the payer's features. + InvreqFeatures tlv.OptionalRecordT[ + tlv.TlvType84, lnwire.RawFeatureVector, + ] + + // InvreqQuantity is the requested quantity. + InvreqQuantity tlv.OptionalRecordT[tlv.TlvType86, TUint64] + + // InvreqPayerID is the payer's signing public key. + InvreqPayerID tlv.OptionalRecordT[tlv.TlvType88, *btcec.PublicKey] + + // InvreqPayerNote is an optional payer note. + InvreqPayerNote tlv.OptionalRecordT[tlv.TlvType89, tlv.Blob] + + // InvreqPaths are the payer's blinded paths to send the invoice to. + InvreqPaths tlv.OptionalRecordT[tlv.TlvType90, lnwire.BlindedPaths] + + // InvreqBip353Name is the payer's BIP 353 name. + InvreqBip353Name tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob] + + // Fields from type 160 on are invoice-specific. + + // InvoicePaths are the blinded paths to the recipient node. + InvoicePaths tlv.OptionalRecordT[tlv.TlvType160, lnwire.BlindedPaths] + + // InvoiceBlindedPay carries one blinded_payinfo per invoice_paths + // entry, in order. + InvoiceBlindedPay tlv.OptionalRecordT[tlv.TlvType162, BlindedPayInfos] + + // InvoiceCreatedAt is the creation time in seconds since the Unix + // epoch. + InvoiceCreatedAt tlv.OptionalRecordT[tlv.TlvType164, TUint64] + + // InvoiceRelativeExp is the expiry in seconds after creation. When + // absent the spec default of 7200 seconds applies. + InvoiceRelativeExp tlv.OptionalRecordT[tlv.TlvType166, TUint32] + + // InvoicePaymentHash is the SHA256 hash of the payment preimage. + InvoicePaymentHash tlv.OptionalRecordT[tlv.TlvType168, [32]byte] + + // InvoiceAmount is the minimum amount the payee will accept, in the + // minimal payable unit of invreq_chain. + InvoiceAmount tlv.OptionalRecordT[tlv.TlvType170, TUint64] + + // InvoiceFallbacks are optional on-chain fallback addresses. + InvoiceFallbacks tlv.OptionalRecordT[ + tlv.TlvType172, FallbackAddresses, + ] + + // InvoiceFeatures are the features of the invoice. + InvoiceFeatures tlv.OptionalRecordT[ + tlv.TlvType174, lnwire.RawFeatureVector, + ] + + // InvoiceNodeID is the public key of the recipient node, used to verify + // the signature. + InvoiceNodeID tlv.OptionalRecordT[tlv.TlvType176, *btcec.PublicKey] + + // Signature is a BIP-340 Schnorr signature covering all fields. + Signature tlv.OptionalRecordT[tlv.TlvType240, [64]byte] + + // decodedTLVs is the canonical TypeMap produced by the typed-stream + // pass that decoded this invoice. See Offer.decodedTLVs for the design + // rationale. + decodedTLVs tlv.TypeMap +} + +// AllRecords returns the canonical sorted record list for this invoice, merging +// the typed records with any extra signed-range fields that the decoder +// preserved. +// +// NOTE: this is part of the tlv.PureTLVMessage interface. +func (inv *Invoice) AllRecords() []tlv.Record { + return allRecordsFromTypeMap( + inv.allRecordProducers(), inv.decodedTLVs, + ) +} + +var _ lnwire.PureTLVMessage = (*Invoice)(nil) + +// UsableFallbackAddresses returns the invoice_fallbacks entries a payer may use +// after applying the BOLT 12 reader's MUST-ignore rules for the bitcoin chain. +func (inv *Invoice) UsableFallbackAddresses() []FallbackAddress { + // Unwrap the optional up front so the filtering loop stays flat; a nil + // Addrs slice ranges as empty. + fallbacks := inv.InvoiceFallbacks.ValOpt().UnwrapOr(FallbackAddresses{}) + + var addrs []FallbackAddress + for _, a := range fallbacks.Addrs { + // MUST ignore any fallback_address for which version is greater + // than 16. + if a.Version > 16 { + continue + } + + // MUST ignore any fallback_address for which address is less + // than 2 or greater than 40 bytes. + if len(a.Address) < 2 || len(a.Address) > 40 { + continue + } + + // MUST ignore any fallback_address for which address does not + // meet known requirements for the given version. NOT enforced + // here: the per-version witness-program check needs on-chain + // address rules above this codec, so a caller dispatching + // on-chain MUST apply it. + addrs = append(addrs, a) + } + + return addrs +} + +// UsablePath pairs a blinded path with its payment parameters, as returned by +// UsablePaths after the BOLT 12 reader's feature filter has been applied. +type UsablePath struct { + // Path is the blinded path to the recipient. + Path lnwire.BlindedPath + + // PayInfo is the blinded_payinfo for Path. + PayInfo BlindedPayInfo +} + +// UsablePaths returns the invoice_paths entries a payer may use, each paired +// with its blinded_payinfo, after applying the BOLT 12 reader rule that a path +// MUST NOT be used when its payinfo.features has unknown required (even) bits +// set. knownBlindedFeatures names the feature bits the reader understands. +// +// The result is empty when invoice_paths or invoice_blindedpay is absent, or +// when the two lists differ in length; ValidateInvoiceRead rejects those cases +// separately, so a caller that validates first can treat an empty result as +// "no usable paths". +func (inv *Invoice) UsablePaths( + knownBlindedFeatures map[lnwire.FeatureBit]string) []UsablePath { + + paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{}) + bp := inv.InvoiceBlindedPay.ValOpt().UnwrapOr(BlindedPayInfos{}) + + // Entries pair by index; a length mismatch is rejected upstream by + // ValidateInvoiceRead, so guard here to stay in bounds. + if len(paths.Paths) != len(bp.Infos) { + return nil + } + + var usable []UsablePath + for i := range bp.Infos { + // MUST NOT use the path if payinfo.features has any unknown + // even bits set. + fv := bp.Infos[i].Features + wrapped := lnwire.NewFeatureVector(&fv, knownBlindedFeatures) + if len(wrapped.UnknownRequiredFeatures()) > 0 { + continue + } + + usable = append(usable, UsablePath{ + Path: paths.Paths[i], + PayInfo: bp.Infos[i], + }) + } + + return usable +} + +// allRecordProducers returns record producers for all set fields. +func (inv *Invoice) allRecordProducers() []tlv.RecordProducer { + var p []tlv.RecordProducer + + // Invreq mirrored fields. + lnwire.AddOpt(&p, inv.InvreqMetadata) + lnwire.AddOpt(&p, inv.OfferChains) + lnwire.AddOpt(&p, inv.OfferMetadata) + lnwire.AddOpt(&p, inv.OfferCurrency) + lnwire.AddOpt(&p, inv.OfferAmount) + lnwire.AddOpt(&p, inv.OfferDescription) + lnwire.AddOpt(&p, inv.OfferFeatures) + lnwire.AddOpt(&p, inv.OfferAbsoluteExpiry) + lnwire.AddOpt(&p, inv.OfferPaths) + lnwire.AddOpt(&p, inv.OfferIssuer) + lnwire.AddOpt(&p, inv.OfferQuantityMax) + lnwire.AddOpt(&p, inv.OfferIssuerID) + lnwire.AddOpt(&p, inv.InvreqChain) + lnwire.AddOpt(&p, inv.InvreqAmount) + lnwire.AddOpt(&p, inv.InvreqFeatures) + lnwire.AddOpt(&p, inv.InvreqQuantity) + lnwire.AddOpt(&p, inv.InvreqPayerID) + lnwire.AddOpt(&p, inv.InvreqPayerNote) + lnwire.AddOpt(&p, inv.InvreqPaths) + lnwire.AddOpt(&p, inv.InvreqBip353Name) + + // Invoice-specific fields. + lnwire.AddOpt(&p, inv.InvoicePaths) + lnwire.AddOpt(&p, inv.InvoiceBlindedPay) + lnwire.AddOpt(&p, inv.InvoiceCreatedAt) + lnwire.AddOpt(&p, inv.InvoiceRelativeExp) + lnwire.AddOpt(&p, inv.InvoicePaymentHash) + lnwire.AddOpt(&p, inv.InvoiceAmount) + lnwire.AddOpt(&p, inv.InvoiceFallbacks) + lnwire.AddOpt(&p, inv.InvoiceFeatures) + lnwire.AddOpt(&p, inv.InvoiceNodeID) + lnwire.AddOpt(&p, inv.Signature) + + return p +} + +// Encode validates the invoice per writer requirements and serialises it via +// the PureTLVMessage shape. +func (inv *Invoice) Encode() ([]byte, error) { + var buf bytes.Buffer + if err := lnwire.EncodePureTLVMessage(inv, &buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// DecodeInvoice deserializes an invoice from a TLV byte stream. Decoding is +// permissive: callers that need spec compliance must run ValidateInvoiceRead. +func DecodeInvoice(data []byte) (*Invoice, error) { + var inv Invoice + + invreqMetadata := tlv.ZeroRecordT[tlv.TlvType0, tlv.Blob]() + chains := tlv.ZeroRecordT[tlv.TlvType2, ChainsRecord]() + offerMeta := tlv.ZeroRecordT[tlv.TlvType4, tlv.Blob]() + currency := tlv.ZeroRecordT[tlv.TlvType6, tlv.Blob]() + offerAmt := tlv.ZeroRecordT[tlv.TlvType8, TUint64]() + desc := tlv.ZeroRecordT[tlv.TlvType10, tlv.Blob]() + offerFeat := tlv.ZeroRecordT[tlv.TlvType12, lnwire.RawFeatureVector]() + expiry := tlv.ZeroRecordT[tlv.TlvType14, TUint64]() + offerPaths := tlv.ZeroRecordT[tlv.TlvType16, lnwire.BlindedPaths]() + issuer := tlv.ZeroRecordT[tlv.TlvType18, tlv.Blob]() + qtyMax := tlv.ZeroRecordT[tlv.TlvType20, TUint64]() + issuerID := tlv.ZeroRecordT[tlv.TlvType22, *btcec.PublicKey]() + invreqChain := tlv.ZeroRecordT[tlv.TlvType80, [32]byte]() + invreqAmt := tlv.ZeroRecordT[tlv.TlvType82, TUint64]() + invreqFeat := tlv.ZeroRecordT[tlv.TlvType84, lnwire.RawFeatureVector]() + invreqQty := tlv.ZeroRecordT[tlv.TlvType86, TUint64]() + payerID := tlv.ZeroRecordT[tlv.TlvType88, *btcec.PublicKey]() + payerNote := tlv.ZeroRecordT[tlv.TlvType89, tlv.Blob]() + invreqPaths := tlv.ZeroRecordT[tlv.TlvType90, lnwire.BlindedPaths]() + bip353 := tlv.ZeroRecordT[tlv.TlvType91, tlv.Blob]() + invPaths := tlv.ZeroRecordT[tlv.TlvType160, lnwire.BlindedPaths]() + blindedPay := tlv.ZeroRecordT[tlv.TlvType162, BlindedPayInfos]() + createdAt := tlv.ZeroRecordT[tlv.TlvType164, TUint64]() + relExp := tlv.ZeroRecordT[tlv.TlvType166, TUint32]() + payHash := tlv.ZeroRecordT[tlv.TlvType168, [32]byte]() + invAmt := tlv.ZeroRecordT[tlv.TlvType170, TUint64]() + fallbacks := tlv.ZeroRecordT[tlv.TlvType172, FallbackAddresses]() + invFeat := tlv.ZeroRecordT[tlv.TlvType174, lnwire.RawFeatureVector]() + nodeID := tlv.ZeroRecordT[tlv.TlvType176, *btcec.PublicKey]() + sig := tlv.ZeroRecordT[tlv.TlvType240, [64]byte]() + + tm, err := decodeStream( + data, + invreqMetadata.Record(), chains.Record(), offerMeta.Record(), + currency.Record(), offerAmt.Record(), desc.Record(), + offerFeat.Record(), expiry.Record(), offerPaths.Record(), + issuer.Record(), qtyMax.Record(), issuerID.Record(), + invreqChain.Record(), invreqAmt.Record(), invreqFeat.Record(), + invreqQty.Record(), payerID.Record(), payerNote.Record(), + invreqPaths.Record(), bip353.Record(), invPaths.Record(), + blindedPay.Record(), createdAt.Record(), relExp.Record(), + payHash.Record(), invAmt.Record(), fallbacks.Record(), + invFeat.Record(), nodeID.Record(), sig.Record(), + ) + if err != nil { + return nil, fmt.Errorf("decode invoice: %w", err) + } + + lnwire.SetOptFromMap(tm, &inv.InvreqMetadata, invreqMetadata) + lnwire.SetOptFromMap(tm, &inv.OfferChains, chains) + lnwire.SetOptFromMap(tm, &inv.OfferMetadata, offerMeta) + lnwire.SetOptFromMap(tm, &inv.OfferCurrency, currency) + lnwire.SetOptFromMap(tm, &inv.OfferAmount, offerAmt) + lnwire.SetOptFromMap(tm, &inv.OfferDescription, desc) + lnwire.SetOptFromMap(tm, &inv.OfferFeatures, offerFeat) + lnwire.SetOptFromMap(tm, &inv.OfferAbsoluteExpiry, expiry) + lnwire.SetOptFromMap(tm, &inv.OfferPaths, offerPaths) + lnwire.SetOptFromMap(tm, &inv.OfferIssuer, issuer) + lnwire.SetOptFromMap(tm, &inv.OfferQuantityMax, qtyMax) + lnwire.SetOptFromMap(tm, &inv.OfferIssuerID, issuerID) + lnwire.SetOptFromMap(tm, &inv.InvreqChain, invreqChain) + lnwire.SetOptFromMap(tm, &inv.InvreqAmount, invreqAmt) + lnwire.SetOptFromMap(tm, &inv.InvreqFeatures, invreqFeat) + lnwire.SetOptFromMap(tm, &inv.InvreqQuantity, invreqQty) + lnwire.SetOptFromMap(tm, &inv.InvreqPayerID, payerID) + lnwire.SetOptFromMap(tm, &inv.InvreqPayerNote, payerNote) + lnwire.SetOptFromMap(tm, &inv.InvreqPaths, invreqPaths) + lnwire.SetOptFromMap(tm, &inv.InvreqBip353Name, bip353) + lnwire.SetOptFromMap(tm, &inv.InvoicePaths, invPaths) + lnwire.SetOptFromMap(tm, &inv.InvoiceBlindedPay, blindedPay) + lnwire.SetOptFromMap(tm, &inv.InvoiceCreatedAt, createdAt) + lnwire.SetOptFromMap(tm, &inv.InvoiceRelativeExp, relExp) + lnwire.SetOptFromMap(tm, &inv.InvoicePaymentHash, payHash) + lnwire.SetOptFromMap(tm, &inv.InvoiceAmount, invAmt) + lnwire.SetOptFromMap(tm, &inv.InvoiceFallbacks, fallbacks) + lnwire.SetOptFromMap(tm, &inv.InvoiceFeatures, invFeat) + lnwire.SetOptFromMap(tm, &inv.InvoiceNodeID, nodeID) + lnwire.SetOptFromMap(tm, &inv.Signature, sig) + + inv.decodedTLVs = tm + + return &inv, nil +} + +// NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all +// non-signature fields from the provided InvoiceRequest. When invreq_amount is +// present it is mirrored into invoice_amount per the writer requirement. The +// caller is responsible for populating the remaining invoice-specific fields +// (invoice_created_at, invoice_payment_hash, invoice_node_id, invoice_paths, +// invoice_blindedpay, ...) and signing the invoice. +func NewInvoiceFromRequest(req *InvoiceRequest) *Invoice { + inv := &Invoice{ + InvreqMetadata: req.InvreqMetadata, + OfferChains: req.OfferChains, + OfferMetadata: req.OfferMetadata, + OfferCurrency: req.OfferCurrency, + OfferAmount: req.OfferAmount, + OfferDescription: req.OfferDescription, + OfferFeatures: req.OfferFeatures, + OfferAbsoluteExpiry: req.OfferAbsoluteExpiry, + OfferPaths: req.OfferPaths, + OfferIssuer: req.OfferIssuer, + OfferQuantityMax: req.OfferQuantityMax, + OfferIssuerID: req.OfferIssuerID, + InvreqChain: req.InvreqChain, + InvreqAmount: req.InvreqAmount, + InvreqFeatures: req.InvreqFeatures, + InvreqQuantity: req.InvreqQuantity, + InvreqPayerID: req.InvreqPayerID, + InvreqPayerNote: req.InvreqPayerNote, + InvreqPaths: req.InvreqPaths, + InvreqBip353Name: req.InvreqBip353Name, + + // Carry the request's unknown signed-range TLVs. Known invreq + // types appear in the map with nil values and are skipped when + // the sidecar is merged, so this re-emits only the unknowns and + // never duplicates the typed fields copied above. Any + // signature-range entries (240-1000) cloned here are inert: + // allRecordsFromTypeMap drops them via bolt12InUnsignedRange, + // so the request's signature never leaks into the invoice. + decodedTLVs: maps.Clone(req.decodedTLVs), + } + + // Writer rule: if invreq_amount is present, invoice_amount MUST be set + // to it. When absent, the caller sets the expected amount. + req.InvreqAmount.WhenSome( + func(r tlv.RecordT[tlv.TlvType82, TUint64]) { + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64](r.Val), + ) + }, + ) + + return inv +} diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go new file mode 100644 index 00000000000..b24be7a0a66 --- /dev/null +++ b/bolt12/invoice_test.go @@ -0,0 +1,341 @@ +package bolt12 + +import ( + "bytes" + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// validInvoice returns an Invoice populated with the minimum set of fields +// required to satisfy ValidateInvoiceWrite. +func validInvoice(t *testing.T) *Invoice { + t.Helper() + + _, pub := bobKey() + + var payHash [32]byte + for i := range payHash { + payHash[i] = byte(i) + } + + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() + + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + return &Invoice{ + InvoiceCreatedAt: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType164, TUint64]( + TUint64(1234567890), + ), + ), + InvoiceAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64]( + TUint64(100_000), + ), + ), + InvoicePaymentHash: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType168, [32]byte]( + payHash, + ), + ), + InvoiceNodeID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](pub), + ), + InvoicePaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths]( + lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{{ + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{{ + BlindedNodeID: hopPub, + }}, + }}, + }, + ), + ), + InvoiceBlindedPay: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos]( + BlindedPayInfos{Infos: []BlindedPayInfo{{}}}, + ), + ), + } +} + +// TestUsableFallbackAddresses pins the BOLT 12 ignore semantics for +// invoice_fallbacks. +func TestUsableFallbackAddresses(t *testing.T) { + t.Parallel() + + addrs := []FallbackAddress{ + // Valid: version 0, 2 bytes. + {Version: 0, Address: []byte{0x01, 0x02}}, + // Invalid: version 17, 2 bytes. Version is not supported. + {Version: 17, Address: []byte{0x01, 0x02}}, + // Invalid: version 0, 1 byte. Address is too short. + {Version: 0, Address: []byte{0x01}}, + // Invalid: version 0, 41 bytes. Address is too long. + {Version: 0, Address: make([]byte, 41)}, + // Valid: version 16, 40 bytes. + {Version: 16, Address: make([]byte, 40)}, + } + inv := &Invoice{ + InvoiceFallbacks: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType172, FallbackAddresses]( + FallbackAddresses{Addrs: addrs}, + ), + ), + } + + got := inv.UsableFallbackAddresses() + require.Len(t, got, 2) + require.Equal(t, byte(0), got[0].Version) + require.Equal(t, byte(16), got[1].Version) + require.Len(t, got[1].Address, 40) +} + +// TestUsablePaths pins the BOLT 12 reader filter that excludes any blinded path +// whose payinfo.features carries an unknown required (even) bit, and confirms +// each surviving entry is paired with its own payinfo by index. +func TestUsablePaths(t *testing.T) { + t.Parallel() + + _, blinding := bobKey() + _, hopPub := aliceKey() + _, intro := aliceKey() + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + // hop builds a minimal single-hop blinded path; two of these populate + // invoice_paths so the by-index pairing with payinfos can be observed. + hop := lnwire.BlindedPath{ + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{{BlindedNodeID: hopPub}}, + } + pathsRecord := func(n int) tlv.OptionalRecordT[ + tlv.TlvType160, lnwire.BlindedPaths, + ] { + + paths := make([]lnwire.BlindedPath, n) + for i := range paths { + paths[i] = hop + } + + return tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths]( + lnwire.BlindedPaths{Paths: paths}, + ), + ) + } + payRecord := func(infos ...BlindedPayInfo) tlv.OptionalRecordT[ + tlv.TlvType162, BlindedPayInfos, + ] { + + return tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos]( + BlindedPayInfos{Infos: infos}, + ), + ) + } + + // The first payinfo carries an unknown required feature bit + // (MPPRequired); the second is featureless. + required := *lnwire.NewRawFeatureVector(lnwire.MPPRequired) + inv := &Invoice{ + InvoicePaths: pathsRecord(2), + InvoiceBlindedPay: payRecord( + BlindedPayInfo{FeeBaseMsat: 1, Features: required}, + BlindedPayInfo{FeeBaseMsat: 2}, + ), + } + + // Empty catalogue: the MPPRequired bit is unknown, so path 0 is + // filtered out and only path 1 (fee_base 2) survives. + got := inv.UsablePaths(nil) + require.Len(t, got, 1) + require.Equal(t, uint32(2), got[0].PayInfo.FeeBaseMsat) + + // Once the bit is known, both paths become usable and stay paired with + // their own payinfo in order. + known := map[lnwire.FeatureBit]string{lnwire.MPPRequired: "mpp"} + got = inv.UsablePaths(known) + require.Len(t, got, 2) + require.Equal(t, uint32(1), got[0].PayInfo.FeeBaseMsat) + require.Equal(t, uint32(2), got[1].PayInfo.FeeBaseMsat) + + // A length mismatch between paths and payinfos yields no usable paths + // (rejected upstream by ValidateInvoiceRead). + inv.InvoiceBlindedPay = payRecord(BlindedPayInfo{}) + require.Empty(t, inv.UsablePaths(known)) +} + +// TestInvoiceRoundTripPreservesAllTypes encodes a fully populated invoice then +// decodes it back, asserting every field is preserved byte-for-byte. The codec +// promises bijection on the message level, and any drift (dropped record, +// re-ordered output) breaks downstream signature verification because the +// Merkle root depends on the exact raw TLV stream. +func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}), + ) + + encoded, err := inv.Encode() + require.NoError(t, err) + require.NotEmpty(t, encoded) + + decoded, err := DecodeInvoice(encoded) + require.NoError(t, err) + + // Re-encode the decoded copy and confirm canonicality. + // decode(encode(decode(encode(x)))) must equal decode(encode(x)). + encoded2, err := decoded.Encode() + require.NoError(t, err) + require.Equal(t, encoded, encoded2) +} + +// TestDecodeInvoiceRejectsTruncated locks in that DecodeInvoice surfaces an +// error when fed a truncated TLV stream rather than returning a partial +// Invoice. A silent partial-decode would let validation see fields that weren't +// actually on the wire. +func TestDecodeInvoiceRejectsTruncated(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + encoded, err := inv.Encode() + require.NoError(t, err) + + // Chop off the last byte. The truncation lands in the middle of the + // final blinded_pay record's variable-length payload. + truncated := encoded[:len(encoded)-1] + + _, err = DecodeInvoice(truncated) + require.Error(t, err) +} + +// TestNewInvoiceFromRequest verifies the constructor mirrors all non-signature +// invoice_request fields into the invoice, applies the invreq_amount -> +// invoice_amount writer rule, and does not copy the request's signature. +func TestNewInvoiceFromRequest(t *testing.T) { + t.Parallel() + + _, bobPub := bobKey() + _, alicePub := aliceKey() + + req := &InvoiceRequest{ + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("description"), + ), + ), + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](alicePub), + ), + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("payer-metadata"), + ), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](2500), + ), + Signature: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}), + ), + } + + inv := NewInvoiceFromRequest(req) + require.NotNil(t, inv) + + // Non-signature request fields are mirrored exactly. + require.Equal(t, req.OfferDescription, inv.OfferDescription) + require.Equal(t, req.OfferIssuerID, inv.OfferIssuerID) + require.Equal(t, req.InvreqMetadata, inv.InvreqMetadata) + require.Equal(t, req.InvreqPayerID, inv.InvreqPayerID) + require.Equal(t, req.InvreqAmount, inv.InvreqAmount) + + // invreq_amount is mirrored into invoice_amount per the writer rule. + require.Equal(t, TUint64(2500), inv.InvoiceAmount.UnwrapOrFailV(t)) + + // The request's signature is not copied. The invoice signs its own. + require.True(t, inv.Signature.IsNone()) +} + +// TestNewInvoiceFromRequestMirrorsUnknownFields verifies the writer requirement +// "MUST copy all non-signature fields from the invoice request (including +// unknown fields)": an unknown odd TLV in the request's signed range must +// survive into the constructed invoice's canonical record set so it is signed. +func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) { + t.Parallel() + + _, bobPub := bobKey() + + // Build a minimal valid spontaneous request and encode it. + req := &InvoiceRequest{ + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("desc")), + ), + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("meta")), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](1000), + ), + } + encoded, err := req.Encode() + require.NoError(t, err) + + // Fill in an unknown odd TLV (type 93, within the invreq signed range + // and above the request's existing types) so the spliced stream stays + // canonically sorted and the unknown lands in the decoded request's + // decodedTLVs sidecar. + const unknownType = 93 + unknownVal := []byte("xyz") + var extra bytes.Buffer + require.NoError(t, tlv.WriteVarInt(&extra, unknownType, &[8]byte{})) + require.NoError(t, tlv.WriteVarInt( + &extra, uint64(len(unknownVal)), &[8]byte{}, + )) + extra.Write(unknownVal) + + spliced := append(append([]byte{}, encoded...), extra.Bytes()...) + + decodedReq, err := DecodeInvoiceRequest(spliced) + require.NoError(t, err) + + inv := NewInvoiceFromRequest(decodedReq) + + // The unknown field must appear in the invoice's canonical record set + // with its value preserved, not just its type. + var ( + found bool + gotVal bytes.Buffer + ) + for _, r := range inv.AllRecords() { + if r.Type() != unknownType { + continue + } + found = true + require.NoError(t, r.Encode(&gotVal)) + } + require.True(t, found, "unknown request TLV not mirrored into invoice") + require.Equal( + t, unknownVal, gotVal.Bytes(), + "unknown request TLV value not preserved", + ) +} From f4cd4684d669c6a3e105d3e2d8a6a1bc4331f412 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Mon, 29 Jun 2026 18:24:23 +0200 Subject: [PATCH 4/5] bolt12: validate Invoice messages Implement the structural validators for the BOLT 12 invoice, adding ValidateInvoiceWrite, ValidateInvoiceRead, ValidateInvoiceExpiry, and ValidateInvoiceAgainstRequest. The validators implement the spec writer and reader requirements in the order the spec lists them. The reader confirms the signature TLV is present but defers actual Schnorr verification until the merkle and signing primitives land, mirroring the ValidateInvoiceRequestRead precedent. --- bolt12/invoice.go | 4 + bolt12/invoice_test.go | 18 + bolt12/validate.go | 647 +++++++++++++++++++++++++- bolt12/validate_test.go | 979 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1641 insertions(+), 7 deletions(-) diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 0e465cda16d..3869a038ebb 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -272,6 +272,10 @@ func (inv *Invoice) allRecordProducers() []tlv.RecordProducer { // Encode validates the invoice per writer requirements and serialises it via // the PureTLVMessage shape. func (inv *Invoice) Encode() ([]byte, error) { + if err := ValidateInvoiceWrite(inv); err != nil { + return nil, fmt.Errorf("validate invoice: %w", err) + } + var buf bytes.Buffer if err := lnwire.EncodePureTLVMessage(inv, &buf); err != nil { return nil, err diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index b24be7a0a66..f15e1a75324 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -196,6 +196,10 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { decoded, err := DecodeInvoice(encoded) require.NoError(t, err) + err = ValidateInvoiceRead(decoded, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}) + require.NoError(t, err) + // Re-encode the decoded copy and confirm canonicality. // decode(encode(decode(encode(x)))) must equal decode(encode(x)). encoded2, err := decoded.Encode() @@ -339,3 +343,17 @@ func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) { "unknown request TLV value not preserved", ) } + +// TestInvoiceEncodeValidationGate verifies that Encode runs +// ValidateInvoiceWrite and rejects invalid invoices. +func TestInvoiceEncodeValidationGate(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.InvoiceCreatedAt = tlv.OptionalRecordT[ + tlv.TlvType164, TUint64, + ]{} + + _, err := inv.Encode() + require.ErrorIs(t, err, ErrMissingCreatedAt) +} diff --git a/bolt12/validate.go b/bolt12/validate.go index 19009309bd3..86c7c7d3198 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -1,6 +1,7 @@ package bolt12 import ( + "bytes" "errors" "fmt" "math/bits" @@ -144,6 +145,61 @@ var ( ErrOfferFieldsOnSpontaneous = errors.New( "offer fields present on non-offer response", ) + + // ErrMissingCreatedAt is returned when invoice_created_at is absent. + ErrMissingCreatedAt = errors.New("missing invoice_created_at") + + // ErrMissingPaymentHash is returned when invoice_payment_hash is + // absent. + ErrMissingPaymentHash = errors.New("missing invoice_payment_hash") + + // ErrMissingNodeID is returned when invoice_node_id is absent. + ErrMissingNodeID = errors.New("missing invoice_node_id") + + // ErrMissingBlindedPay is returned when invoice_blindedpay is absent. + ErrMissingBlindedPay = errors.New("missing invoice_blindedpay") + + // ErrBlindedPayMismatch is returned when invoice_blindedpay does not + // correspond 1:1 with invoice_paths. + ErrBlindedPayMismatch = errors.New( + "invoice_blindedpay count does not match invoice_paths", + ) + + // ErrMissingPaths is returned when invoice_paths is absent. + ErrMissingPaths = errors.New("missing invoice_paths") + + // ErrNoUsablePaths is returned by ValidateInvoiceRead when every + // blinded path in invoice_paths carries unknown required features in + // payinfo. + ErrNoUsablePaths = errors.New( + "no blinded paths with known required features", + ) + + // ErrInvoiceExpired is returned by ValidateInvoiceExpiry when the + // caller's clock is past invoice_created_at + invoice_relative_expiry + // (default 7200 seconds when relative expiry is absent). + ErrInvoiceExpired = errors.New("invoice has expired") + + // ErrInvoiceMismatch is returned when an invoice field does not match + // the invoice request. + ErrInvoiceMismatch = errors.New( + "invoice field mismatch with request", + ) + + // ErrInvoiceNodeIDMismatch is returned when offer_issuer_id is present + // but invoice_node_id does not equal it. The spec requires the invoice + // to be signed by the offer's issuer in this case. + ErrInvoiceNodeIDMismatch = errors.New( + "invoice_node_id does not match offer_issuer_id", + ) + + // ErrZeroInvoiceAmount is returned when invoice_amount is present but + // set to zero. The spec permits a zero "minimum amount", but a + // zero-amount HTLC cannot settle past the channel-layer dust limit, so + // the codec rejects it with a typed sentinel a spec-strict caller can + // distinguish from a missing-field violation. + ErrZeroInvoiceAmount = errors.New("invoice_amount must be greater " + + "than zero") ) const ( @@ -171,6 +227,17 @@ const ( invreqPathsType tlv.Type = 90 invreqBip353NameType tlv.Type = 91 signatureTLVType tlv.Type = 240 + + // Invoice TLV types. + invoicePathsType tlv.Type = 160 + invoiceBlindedPayType tlv.Type = 162 + invoiceCreatedAtType tlv.Type = 164 + invoiceRelativeExpiryType tlv.Type = 166 + invoicePaymentHashType tlv.Type = 168 + invoiceAmountType tlv.Type = 170 + invoiceFallbacksType tlv.Type = 172 + invoiceFeaturesType tlv.Type = 174 + invoiceNodeIDType tlv.Type = 176 ) // isKnownInvreqTLVType determines if a TLV type is defined in the @@ -358,13 +425,10 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { // - if it supports bolt12 invoice request features: // - MUST set invreq_features.features to the bitmap of features. - // We only reject unknown even bits here; advertising a feature is the - // caller's decision. Since the writer lacks a catalogue in scope, we - // pass nil for the catalogue, treating all even feature bits as - // unknown. - if err := checkFeatures(ir.InvreqFeatures, nil); err != nil { - return err - } + // We rely on the writer to set feature bits correctly as those are + // mostly static and the reader will also verify the features. This is + // done to not having to pass in the known feature vector for writer + // validation, similar to other write validation in this file. // check UTF-8 constraints and BIP 353 err := checkUTF8(ir.InvreqPayerNote, "invreq_payer_note") @@ -1116,3 +1180,572 @@ func checkPubKeyNotNil[T tlv.TlvType]( return nil }) } + +// checkInvoiceNodeID enforces the spec rule that, when offer_issuer_id is +// present, invoice_node_id MUST equal it. Both fields live on the invoice, so +// this is verifiable without the originating offer. The offer_paths branch +// (invoice_node_id equals the final blinded_node_id on the arrival path) needs +// caller context and is not checked here. A present-but-nil offer_issuer_id or +// invoice_node_id is rejected separately as ErrNilPublicKey, so a nil here is +// treated as absent. +func checkInvoiceNodeID(inv *Invoice) error { + // A present-but-nil offer_issuer_id is rejected separately as + // ErrNilPublicKey, so a nil here means absent and there is nothing to + // check. + issuerID := inv.OfferIssuerID.ValOpt().UnwrapOr(nil) + if issuerID == nil { + return nil + } + + // invoice_node_id is likewise guarded against present-but-nil by + // checkPubKeyNotNil, so a nil here means absent; its required presence + // is enforced separately as ErrMissingNodeID. + nodeID := inv.InvoiceNodeID.ValOpt().UnwrapOr(nil) + if nodeID == nil || !nodeID.IsEqual(issuerID) { + return ErrInvoiceNodeIDMismatch + } + + return nil +} + +// ValidateInvoiceWrite validates an invoice per the BOLT 12 invoice writer +// requirements. The checks follow the spec's writer section in order. +// Requirements that depend on context this codec layer does not have +// (signing, the payment preimage, the offer or path the request arrived on) +// are noted inline as deferred to the caller or to a paired validator. +func ValidateInvoiceWrite(inv *Invoice) error { + // - MUST set invoice_created_at to the number of seconds since Midnight + // 1 January 1970, UTC when the invoice was created. + if !inv.InvoiceCreatedAt.IsSome() { + return ErrMissingCreatedAt + } + + // - MUST set invoice_amount to the minimum amount it will accept, in + // units of the minimal lightning-payable unit (e.g. milli-satoshis + // for bitcoin) for invreq_chain. + if !inv.InvoiceAmount.IsSome() { + return ErrMissingAmount + } + + // Policy extension: reject zero invoice_amount. The spec permits it + // ("minimum amount it will accept"), but a zero-amount HTLC cannot + // settle past the channel-layer dust limit. The typed + // ErrZeroInvoiceAmount lets a spec-strict caller distinguish this from + // a missing-field violation. Symmetric with ValidateInvoiceRead. + if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 { + return ErrZeroInvoiceAmount + } + + // - if the invoice is in response to an invoice_request: + // - MUST copy all non-signature fields from the invoice request + // (including unknown fields). + // - if invreq_amount is present: MUST set invoice_amount to + // invreq_amount. + // - otherwise: MUST set invoice_amount to the expected amount. + // NOT CHECKED HERE: the copy is performed by NewInvoiceFromRequest and + // this validator runs on the assembled struct. The invoice_amount == + // invreq_amount equality and the byte-for-byte field mirror are + // enforced when the invoice is paired with its request in + // ValidateInvoiceAgainstRequest. The offer_currency "expected amount" + // needs a live exchange rate the codec cannot compute. + + // - MUST set invoice_payment_hash to the SHA256 hash of the + // payment_preimage that will be given in return for payment. + // NOT CHECKED HERE beyond presence: relating the hash to the preimage + // needs the preimage, which lives with the caller's logic. + if !inv.InvoicePaymentHash.IsSome() { + return ErrMissingPaymentHash + } + + // - if offer_issuer_id is present: MUST set invoice_node_id to + // offer_issuer_id. + // - otherwise, if offer_paths is present: MUST set invoice_node_id to + // the final blinded_node_id on the path the request arrived on. + // The offer_issuer_id case is enforced by checkInvoiceNodeID since both + // fields live on the invoice. The offer_paths case needs the blinded + // arrival path, which is caller context, so only presence is checked + // for it. + // + // A present-but-nil pubkey passes IsSome but would panic the codec on + // encode, so reject it before the presence check. + if err := checkPubKeyNotNil( + inv.InvoiceNodeID, "invoice_node_id", + ); err != nil { + return err + } + if !inv.InvoiceNodeID.IsSome() { + return ErrMissingNodeID + } + if err := checkInvoiceNodeID(inv); err != nil { + return err + } + + // - MUST specify exactly one signature TLV element: signature. + // - MUST set sig to the signature using invoice_node_id as described + // in Signature Calculation. + // NOT CHECKED HERE: signing happens after this validator runs. The + // string-codec layer rejects an unsigned invoice, mirroring + // ValidateInvoiceRequestWrite. + + // - if the expiry for accepting payment is not 7200 seconds after + // invoice_created_at: MUST set invoice_relative_expiry. + // seconds_from_creation to the number of seconds after + // invoice_created_at that payment should not be attempted. + // NOT CHECKED HERE: the writer chooses the expiry, so there is no rule + // to enforce on the encoded value. The time comparison needs a clock + // (see ValidateInvoiceExpiry). + + // - if it accepts onchain payments: + // - MAY specify invoice_fallbacks. + // - SHOULD specify invoice_fallbacks in order of most-preferred to + // least-preferred if it has a preference. + // - for the bitcoin chain, it MUST set each fallback_address with + // version as a valid witness version and address as a valid witness + // program. + // NOT CHECKED HERE: the codec stays permissive so callers can inspect + // raw fallbacks. The spec's ignore semantics are applied on the read + // side by UsableFallbackAddresses. + + // - MUST include invoice_paths containing one or more paths to the + // node. + // - MUST specify invoice_paths in order of most-preferred to + // least-preferred if it has a preference. + if !inv.InvoicePaths.IsSome() { + return ErrMissingPaths + } + + // Writer mirror of the reader rule rejecting a blinded_path with zero + // hops. + if err := checkBlindedPaths(inv.InvoicePaths); err != nil { + return err + } + + // - MUST include invoice_blindedpay with exactly one blinded_payinfo + // for each blinded_path in paths, in order. + // - MUST set features in each blinded_payinfo to match + // encrypted_data_tlv.allowed_features (or empty, if no + // allowed_features). + // NOT CHECKED HERE: matching each payinfo.features to its path's + // encrypted_data_tlv allowed_features needs the decrypted path, which + // is caller context. Only the 1:1 count is enforced below. + bp, err := inv.InvoiceBlindedPay.ValOpt().UnwrapOrErr( + ErrMissingBlindedPay, + ) + if err != nil { + return err + } + + // invoice_paths presence is enforced above, so the default is never the + // value used; UnwrapOr just avoids a second WhenSome. + paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{}) + if len(paths.Paths) != len(bp.Infos) { + return ErrBlindedPayMismatch + } + + // A present-but-nil pubkey passes IsSome but would panic the codec on + // encode, so reject the mirrored pubkey fields. Symmetric with + // ValidateInvoiceRequestWrite. + if err := fn.MapOptionZ(inv.InvreqPayerID.ValOpt(), + func(pk *btcec.PublicKey) error { + if pk == nil { + return fmt.Errorf("%w: invreq_payer_id", + ErrNilPublicKey) + } + + return nil + }); err != nil { + return err + } + if err := fn.MapOptionZ(inv.OfferIssuerID.ValOpt(), + func(pk *btcec.PublicKey) error { + if pk == nil { + return fmt.Errorf("%w: offer_issuer_id", + ErrNilPublicKey) + } + + return nil + }); err != nil { + return err + } + + return nil +} + +// defaultInvoiceRelativeExpiry is the spec-defined fallback when an invoice +// omits invoice_relative_expiry: two hours from creation. +const defaultInvoiceRelativeExpiry uint32 = 7200 + +// ValidateInvoiceExpiry rejects an invoice whose effective expiry is strictly +// before now. The effective expiry is invoice_created_at + +// invoice_relative_expiry, falling back to a 7200-second default per spec when +// relative expiry is absent. Per the BOLT 12 reader the invoice is rejected +// only when the current time is greater than the expiry, so the boundary second +// itself is still valid; this matches the strict comparison ValidateOfferRead +// uses for offer_absolute_expiry. Callers must invoke this separately after +// decoding. ValidateInvoiceRead covers the structural reader requirements, but +// the time check needs a clock the codec library doesn't supply. +func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error { + createdAt, err := inv.InvoiceCreatedAt.ValOpt().UnwrapOrErr( + ErrMissingCreatedAt, + ) + if err != nil { + return err + } + + relExpiry := inv.InvoiceRelativeExp.ValOpt().UnwrapOr( + TUint32(defaultInvoiceRelativeExpiry), + ) + + // invoice_created_at + the relative expiry can overflow uint64 for an + // absurd timestamp. The true sum then exceeds any real clock, so the + // invoice is not expired: detect the carry rather than wrapping to a + // small value that would spuriously read as expired. + expiry, carry := bits.Add64(uint64(createdAt), uint64(relExpiry), 0) + if carry == 0 && uint64(now.Unix()) > expiry { + return ErrInvoiceExpired + } + + return nil +} + +// mirroredRecordBytes encodes the records in the invreq mirror range to their +// canonical per-record bytes, keyed by TLV type. This is the view the +// byte-for-byte invreq->invoice comparison operates on. +func mirroredRecordBytes(records []tlv.Record) (map[tlv.Type][]byte, error) { + out := make(map[tlv.Type][]byte) + for i := range records { + r := records[i] + if !invreqAllowedRange(r.Type()) { + continue + } + buf, err := lnwire.EncodeRecords([]tlv.Record{r}) + if err != nil { + return nil, fmt.Errorf( + "encode record (type %d): %w", r.Type(), err, + ) + } + out[r.Type()] = buf + } + + return out, nil +} + +// ValidateInvoiceAgainstRequest performs a byte-for-byte comparison of the +// fields in ranges 0-159 and 1000000000-2999999999 between an invoice and its +// original request, as required by the BOLT 12 invoice reader specification. +// Callers must invoke this after pairing the invoice with its originating +// request. The codec library cannot reach across that pairing on its own. +// +// The comparison runs against the canonical per-record encoding from each +// side's AllRecords output. Two structs that decode to the same typed fields +// and the same ExtraSignedFields entries produce byte-identical encodings for +// any matching type. That is the byte-mirror invariant the spec demands. +// +// The amount cross-check enforces the spec's authorized-range rule: when +// invreq_amount is present, invoice_amount MUST equal it; otherwise the payer +// relied on the offer's fixed amount, so invoice_amount MUST be at least +// offer_amount * invreq_quantity for the native (bitcoin) case. The +// offer_currency case needs a caller-supplied exchange rate and is delegated to +// the caller. +func ValidateInvoiceAgainstRequest(inv *Invoice, req *InvoiceRequest) error { + reqFields, err := mirroredRecordBytes(req.AllRecords()) + if err != nil { + return fmt.Errorf("encode request fields: %w", err) + } + + invFields, err := mirroredRecordBytes(inv.AllRecords()) + if err != nil { + return fmt.Errorf("encode invoice fields: %w", err) + } + + for typ, invBytes := range invFields { + reqBytes, ok := reqFields[typ] + if !ok { + return fmt.Errorf("%w: invoice contains unexpected "+ + "field %d", ErrInvoiceMismatch, typ) + } + if !bytes.Equal(invBytes, reqBytes) { + return fmt.Errorf("%w: field %d data mismatch", + ErrInvoiceMismatch, typ) + } + delete(reqFields, typ) + } + + if len(reqFields) > 0 { + return fmt.Errorf("%w: invoice is missing %d fields from "+ + "request", ErrInvoiceMismatch, len(reqFields)) + } + + // Spec MUST: if invreq_amount (type 82) is present, invoice_amount + // (type 170) must equal it. The byte-mirror loop cannot relate fields + // with differing type numbers, so this cross-type equality is checked + // explicitly. + if req.InvreqAmount.IsSome() { + invreqAmt := req.InvreqAmount.ValOpt().UnwrapOr(0) + invAmt := inv.InvoiceAmount.ValOpt().UnwrapOr(0) + if invAmt != invreqAmt { + return fmt.Errorf("%w: invoice_amount %d != "+ + "invreq_amount %d", ErrInvoiceMismatch, invAmt, + invreqAmt) + } + + return nil + } + + // Spec SHOULD: with invreq_amount absent the payer relied on the + // offer's fixed amount, so confirm invoice_amount is within the + // authorized range. For the native (non-offer_currency) case that range + // is bounded below by offer_amount * invreq_quantity, computable here + // from the mirrored offer fields. The offer_currency case is delegated + // to the caller (see checkInvoiceAmountMeetsOffer). + return checkInvoiceAmountMeetsOffer(inv) +} + +// checkInvoiceAmountMeetsOffer confirms invoice_amount is at least the offer's +// authorized amount for the native (bitcoin) case, where the expected amount is +// offer_amount * invreq_quantity. It is a no-op when offer_amount is absent +// (there is nothing to bound against) or when offer_currency is present (the +// conversion into the invreq_chain currency needs a caller-supplied exchange +// rate, so the bound is delegated). This mirrors the request-side +// checkInvreqAmountMeetsOffer and is only meaningful when invreq_amount is +// absent, since a present invreq_amount pins invoice_amount by exact equality. +func checkInvoiceAmountMeetsOffer(inv *Invoice) error { + if !inv.OfferAmount.IsSome() { + return nil + } + + // NOT CHECKED HERE: the offer_currency (non-bitcoin) case. Caller MUST + // convert offer_amount to the invreq_chain currency and compare. + if inv.OfferCurrency.IsSome() { + return nil + } + + offerAmt := uint64(inv.OfferAmount.ValOpt().UnwrapOr(0)) + qty := uint64(inv.InvreqQuantity.ValOpt().UnwrapOr(1)) + invAmt := uint64(inv.InvoiceAmount.ValOpt().UnwrapOr(0)) + + // Guard against overflow of offer_amount * quantity. + hi, expectedAmt := bits.Mul64(offerAmt, qty) + if hi != 0 { + return fmt.Errorf("%w: offer_amount %d * quantity %d "+ + "overflows uint64", ErrAmountBelowExpected, offerAmt, + qty) + } + if invAmt < expectedAmt { + return fmt.Errorf("%w: invoice_amount %d below expected %d", + ErrAmountBelowExpected, invAmt, expectedAmt) + } + + return nil +} + +// isKnownInvoiceTLVType returns true for TLV types that are defined in the +// invoice spec. +func isKnownInvoiceTLVType(typ tlv.Type) bool { + if isKnownInvreqTLVType(typ) { + return true + } + + switch typ { + case invoicePathsType, invoiceBlindedPayType, invoiceCreatedAtType, + invoiceRelativeExpiryType, invoicePaymentHashType, + invoiceAmountType, invoiceFallbacksType, invoiceFeaturesType, + invoiceNodeIDType: + + return true + + default: + return false + } +} + +// InvoiceFeatureCatalogues names the two feature-bit catalogues the invoice +// reader validates against. They are grouped in a struct rather than passed as +// two positional map[lnwire.FeatureBit]string arguments because the identical +// types would otherwise let a caller transpose them silently: validating +// invoice_features against the blinded-path catalogue and vice versa compiles +// cleanly but misvalidates. Named fields make the swap impossible. +type InvoiceFeatureCatalogues struct { + // Invoice names the feature bits the reader understands for the + // top-level invoice_features field. + Invoice map[lnwire.FeatureBit]string + + // Blinded names the feature bits the reader understands for each + // blinded_payinfo.features field carried in invoice_blindedpay. + Blinded map[lnwire.FeatureBit]string +} + +// ValidateInvoiceRead validates an invoice against the BOLT 12 reader +// requirements, running the stateless structural checks against activeChain +// (the chain the reader supports). +// +// Note: This only performs stateless structural checks. Cryptographic Schnorr +// signature verification and identity-path binding are deferred to the caller +// (see the TODO at the end of this function). Additionally, while it verifies +// that at least one usable path is present, downstream callers must re-apply +// the same features.Blinded filter at path selection time (via +// Invoice.UsablePaths) to avoid selecting paths with unknown required features. +func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, + features InvoiceFeatureCatalogues) error { + // - MUST reject the invoice if invoice_amount is not present. + if !inv.InvoiceAmount.IsSome() { + return ErrMissingAmount + } + + // Policy extension. See ValidateInvoiceWrite. + if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 { + return ErrZeroInvoiceAmount + } + + // - MUST reject the invoice if invoice_created_at is not present. + if !inv.InvoiceCreatedAt.IsSome() { + return ErrMissingCreatedAt + } + + // - MUST reject the invoice if invoice_payment_hash is not present. + if !inv.InvoicePaymentHash.IsSome() { + return ErrMissingPaymentHash + } + + // - MUST reject the invoice if invoice_node_id is not present. A + // present-but-nil pubkey passes IsSome but would panic the codec, so + // reject it before the presence check. + if err := checkPubKeyNotNil( + inv.InvoiceNodeID, "invoice_node_id", + ); err != nil { + return err + } + if !inv.InvoiceNodeID.IsSome() { + return ErrMissingNodeID + } + + // - if invreq_chain is not present: + // - MUST reject the invoice if bitcoin is not a supported chain. + // - otherwise: + // - MUST reject the invoice if invreq_chain.chain is not a supported + // chain. + // invreq_chain defaults to bitcoin mainnet when absent. activeChain is + // the chain the reader supports. + chain := inv.InvreqChain.ValOpt().UnwrapOr(bitcoinMainnetGenesisHash) + if chain != activeChain { + return ErrUnsupportedChain + } + + // - if invoice_features contains unknown odd bits that are non-zero: + // - MUST ignore the bit. + // - if invoice_features contains unknown even bits that are non-zero: + // - MUST reject the invoice. + // checkFeatures enforces those invoice_features bit rules below. + // + // Separately, BOLT 1 makes unknown even TLV types must-understand, so + // reject those here over the decoded type set. Unlike the + // invoice_request reader, the invoice reader defines no out-of-range + // type rejection, so unknown odd types are simply ignored ("it's ok to + // be odd"). The signature range (240-1000) is exempt for the same + // reason, matching the invoice_request reader and the Merkle path. + for _, t := range sortedTypes(inv.decodedTLVs) { + if bolt12InUnsignedRange(t) { + continue + } + if !isKnownInvoiceTLVType(t) && t%2 == 0 { + return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t) + } + } + err := checkFeatures(inv.InvoiceFeatures, features.Invoice) + if err != nil { + return err + } + + // - if invoice_relative_expiry is present: + // - MUST reject the invoice if the current time since 1970-01-01 UTC + // is greater than invoice_created_at plus seconds_from_creation. + // - otherwise: + // - MUST reject the invoice if the current time since 1970-01-01 UTC + // is greater than invoice_created_at plus 7200. + // NOT CHECKED HERE: the comparison needs a clock the codec doesn't + // supply. Callers run ValidateInvoiceExpiry separately. + + // - MUST reject the invoice if invoice_paths is not present or is + // empty. + if !inv.InvoicePaths.IsSome() { + return ErrMissingPaths + } + + // - MUST reject the invoice if num_hops is 0 in any blinded_path in + // invoice_paths (checkBlindedPaths also rejects an empty path list). + if err := checkBlindedPaths(inv.InvoicePaths); err != nil { + return err + } + + // - MUST reject the invoice if invoice_blindedpay is not present. + bp, err := inv.InvoiceBlindedPay.ValOpt().UnwrapOrErr( + ErrMissingBlindedPay, + ) + if err != nil { + return err + } + + // - MUST reject the invoice if invoice_blindedpay does not contain + // exactly one blinded_payinfo per invoice_paths.blinded_path. + paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{}) + if len(paths.Paths) != len(bp.Infos) { + return ErrBlindedPayMismatch + } + + // - For each invoice_blindedpay.payinfo: + // - MUST NOT use the corresponding invoice_paths.path if + // payinfo.features has any unknown even bits set. + // - MUST reject the invoice if this leaves no usable paths. + // UsablePaths applies that filter; a caller selecting a path downstream + // should use it rather than the unfiltered invoice_paths. + if len(inv.UsablePaths(features.Blinded)) == 0 { + return ErrNoUsablePaths + } + + // - if the invoice is a response to an invoice_request: + // - MUST reject the invoice if all fields in ranges 0 to 159 and + // 1000000000 to 2999999999 (inclusive) do not exactly match the + // invoice request. + // - if offer_issuer_id is present: MUST reject the invoice if + // invoice_node_id is not equal to offer_issuer_id. + // - otherwise, if offer_paths is present: MUST reject the invoice if + // invoice_node_id is not equal to the final blinded_node_id it sent + // the invoice request to. + // The offer_issuer_id case is checked here by checkInvoiceNodeID (both + // fields live on the invoice). NOT CHECKED HERE: the byte-for-byte + // field mirror and the invreq_amount == invoice_amount rule are + // enforced by ValidateInvoiceAgainstRequest once the invoice is paired + // with its request; the offer_paths blinded_node_id case needs the + // arrival path and stays with the caller. + if err := checkInvoiceNodeID(inv); err != nil { + return err + } + + // - MUST reject the invoice if signature is not a valid signature using + // invoice_node_id as described in Signature Calculation. + // TODO(bolt12): implement signature verification. For now only + // presence is enforced, mirroring ValidateInvoiceRequestRead. + if !inv.Signature.IsSome() { + return ErrMissingSignature + } + + // - SHOULD prefer to use earlier invoice_paths over later ones if it + // has no other reason for preference. + // - if invoice_features contains the MPP/compulsory bit: MUST pay + // via multiple separate blinded paths; the MPP/optional bit MAY, + // otherwise MUST NOT use multiple parts. + // - if invreq_amount is present: MUST reject the invoice if + // invoice_amount is not equal to invreq_amount (otherwise SHOULD + // confirm invoice_amount.msat is within the authorized range). + // - for the bitcoin chain, if the invoice specifies invoice_fallbacks: + // - MUST ignore any fallback_address with version greater than 16, + // address shorter than 2 or longer than 40 bytes, or an address that + // does not meet known requirements for the given version. + // - the invreq_paths / blinded-path / reply_path arrival rules. + // NOT CHECKED HERE: these are payment-time or transport concerns + // handled outside this codec. invreq_amount equality is enforced by + // ValidateInvoiceAgainstRequest; the fallback ignore rules by + // UsableFallbackAddresses. + + return nil +} diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index e6240d658d9..6148e9acd9e 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -1,6 +1,7 @@ package bolt12 import ( + "math" "testing" "time" @@ -1721,3 +1722,981 @@ func TestCheckBip353Name(t *testing.T) { }) } } + +// encodeIRBypassValidate serialises an InvoiceRequest skipping the +// validate-on-encode gate. +func encodeIRBypassValidate(ir *InvoiceRequest) ([]byte, error) { + records := lnwire.ProduceRecordsSorted(ir.allRecordProducers()...) + return lnwire.EncodeRecords(records) +} + +// encodeInvBypassValidate is the Invoice analogue for encodeIRBypassValidate. +func encodeInvBypassValidate(inv *Invoice) ([]byte, error) { + records := lnwire.ProduceRecordsSorted(inv.allRecordProducers()...) + return lnwire.EncodeRecords(records) +} + +// TestValidateInvoiceRead table-drives every reader-side rejection in +// ValidateInvoiceRead. +func TestValidateInvoiceRead(t *testing.T) { + t.Parallel() + + _, intro := aliceKey() + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + baseline := func() *Invoice { + return validInvoice(t) + } + + tests := []struct { + name string + mutate func(*Invoice) + wantErr error + }{ + { + name: "missing amount", + mutate: func(inv *Invoice) { + inv.InvoiceAmount = tlv.OptionalRecordT[ + tlv.TlvType170, TUint64, + ]{} + }, + wantErr: ErrMissingAmount, + }, + { + name: "zero amount", + mutate: func(inv *Invoice) { + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[ + tlv.TlvType170, TUint64, + ](TUint64(0)), + ) + }, + wantErr: ErrZeroInvoiceAmount, + }, + { + name: "missing created_at", + mutate: func(inv *Invoice) { + inv.InvoiceCreatedAt = tlv.OptionalRecordT[ + tlv.TlvType164, TUint64, + ]{} + }, + wantErr: ErrMissingCreatedAt, + }, + { + name: "missing payment_hash", + mutate: func(inv *Invoice) { + inv.InvoicePaymentHash = tlv.OptionalRecordT[ + tlv.TlvType168, [32]byte, + ]{} + }, + wantErr: ErrMissingPaymentHash, + }, + { + name: "missing node_id", + mutate: func(inv *Invoice) { + inv.InvoiceNodeID = tlv.OptionalRecordT[ + tlv.TlvType176, *btcec.PublicKey, + ]{} + }, + wantErr: ErrMissingNodeID, + }, + { + name: "missing paths", + mutate: func(inv *Invoice) { + inv.InvoicePaths = tlv.OptionalRecordT[ + tlv.TlvType160, lnwire.BlindedPaths, + ]{} + }, + wantErr: ErrMissingPaths, + }, + { + name: "missing blinded_pay", + mutate: func(inv *Invoice) { + inv.InvoiceBlindedPay = tlv.OptionalRecordT[ + tlv.TlvType162, BlindedPayInfos, + ]{} + }, + wantErr: ErrMissingBlindedPay, + }, + { + name: "paths count exceeds blinded_pay count", + mutate: func(inv *Invoice) { + path := lnwire.BlindedPath{ + IntroductionNode: introNode, + Hops: []lnwire.BlindedHop{ + {}, + }, + } + paths := lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{path, path}, + } + inv.InvoicePaths = tlv.SomeRecordT( + tlv.NewRecordT[ + tlv.TlvType160, + lnwire.BlindedPaths, + ](paths), + ) + }, + wantErr: ErrBlindedPayMismatch, + }, + { + // The invoice reader defines no out-of-range type + // rejection, so an unknown odd type outside every known + // range is ignored rather than rejected: validation + // proceeds to the final signature check. + name: "unknown odd out-of-range type ignored", + mutate: func(inv *Invoice) { + inv.decodedTLVs = tlv.TypeMap{2001: nil} + }, + wantErr: ErrMissingSignature, + }, + { + name: "unknown even type", + mutate: func(inv *Invoice) { + inv.decodedTLVs = tlv.TypeMap{200: nil} + }, + wantErr: ErrUnknownEvenType, + }, + { + // Baseline invoice_node_id is bob; an offer_issuer_id + // of alice must be rejected as a mismatch. + name: "node_id does not match offer_issuer_id", + mutate: func(inv *Invoice) { + _, alice := aliceKey() + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22]( + alice, + ), + ) + }, + wantErr: ErrInvoiceNodeIDMismatch, + }, + { + // A present-but-nil invoice_node_id passes IsSome but + // would panic the codec on encode, so it must be + // rejected as ErrNilPublicKey rather than treated as a + // missing or mismatched field. + name: "present-but-nil node_id", + mutate: func(inv *Invoice) { + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176]( + (*btcec.PublicKey)(nil), + ), + ) + }, + wantErr: ErrNilPublicKey, + }, + { + name: "unsupported chain", + mutate: func(inv *Invoice) { + inv.InvreqChain = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80]( + [32]byte{0x01}, + ), + ) + }, + wantErr: ErrUnsupportedChain, + }, + { + name: "unknown even feature", + mutate: func(inv *Invoice) { + fv := *lnwire.NewRawFeatureVector(0) + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](fv), + ) + }, + wantErr: ErrUnknownEvenFeature, + }, + { + name: "empty invoice_paths", + mutate: func(inv *Invoice) { + paths := lnwire.BlindedPaths{Paths: nil} + inv.InvoicePaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160](paths), + ) + }, + wantErr: ErrEmptyBlindedPaths, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := baseline() + tc.mutate(inv) + + err := ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +// TestValidateInvoiceReadAcceptsSignatureRange pins the rule that an unknown +// odd TLV anywhere in the signature range (240-1000) is ignored rather than +// rejected. +func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { + t.Parallel() + + _, pub := bobKey() + + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + inv := &Invoice{ + InvoiceCreatedAt: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType164, TUint64](TUint64(123)), + ), + InvoiceAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64](TUint64(1000)), + ), + InvoicePaymentHash: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType168, [32]byte]( + [32]byte{}, + ), + ), + InvoiceNodeID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](pub), + ), + InvoicePaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths]( + lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{{ + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{{ + BlindedNodeID: hopPub, + }}, + }}, + }, + ), + ), + InvoiceBlindedPay: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos]( + BlindedPayInfos{Infos: []BlindedPayInfo{{}}}, + ), + ), + Signature: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]( + [64]byte{}, + ), + ), + } + + // An unknown odd type at 241 sits inside the signature range and must + // be ignored, not rejected as out-of-range or unknown-even. + inv.decodedTLVs = tlv.TypeMap{241: nil} + + err = ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + require.NoError(t, err) +} + +// TestValidateInvoiceExpiry covers the relative-expiry default, an explicit +// relative expiry, the expired/not-expired boundary, and the overflow guard +// that keeps an absurd created_at from wrapping into a spurious expiry. +func TestValidateInvoiceExpiry(t *testing.T) { + t.Parallel() + + invoice := func(createdAt uint64, relExp *uint32) *Invoice { + inv := &Invoice{ + InvoiceCreatedAt: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType164, TUint64]( + TUint64(createdAt), + ), + ), + } + if relExp != nil { + inv.InvoiceRelativeExp = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType166, TUint32]( + TUint32(*relExp), + ), + ) + } + + return inv + } + + relExp := func(v uint32) *uint32 { return &v } + + tests := []struct { + name string + inv *Invoice + now int64 + wantErr error + }{ + { + name: "missing created_at", + inv: &Invoice{}, + now: 1000, + wantErr: ErrMissingCreatedAt, + }, + { + name: "within default expiry", + inv: invoice(1000, nil), + now: 1000 + 7199, + }, + { + // The boundary second itself is still valid: the spec + // rejects only when now is strictly greater than + // created_at + expiry. + name: "at default expiry boundary", + inv: invoice(1000, nil), + now: 1000 + 7200, + }, + { + name: "past default expiry", + inv: invoice(1000, nil), + now: 1000 + 7201, + wantErr: ErrInvoiceExpired, + }, + { + name: "within explicit expiry", + inv: invoice(1000, relExp(100)), + now: 1099, + }, + { + // The exact expiry second is still valid (strict ">"). + name: "at explicit expiry boundary", + inv: invoice(1000, relExp(100)), + now: 1100, + }, + { + name: "past explicit expiry", + inv: invoice(1000, relExp(100)), + now: 1101, + wantErr: ErrInvoiceExpired, + }, + { + name: "overflow is not expired", + inv: invoice(math.MaxUint64, relExp(100)), + now: 9223372036854775807, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := ValidateInvoiceExpiry( + tc.inv, time.Unix(tc.now, 0), + ) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + require.NoError(t, err) + }) + } +} + +// TestValidateInvoiceAgainstRequest table-drives the mirror-field comparison +// between an invoice and the request it is responding to. +func TestValidateInvoiceAgainstRequest(t *testing.T) { + t.Parallel() + + // The request whose mirrored fields every invoice below is compared + // against: payer metadata plus offer_amount. + ir := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadata"), + ), + ), + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8, TUint64](1000), + ), + } + + irEncoded, err := encodeIRBypassValidate(ir) + require.NoError(t, err) + irDecoded, err := DecodeInvoiceRequest(irEncoded) + require.NoError(t, err) + + // baseline mirrors the request's fields exactly. Invoice-specific + // fields >= 160 (here invoice_amount) are excluded from the mirror + // comparison, so the baseline validates cleanly. + baseline := func() *Invoice { + return &Invoice{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadata"), + ), + ), + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8, TUint64](1000), + ), + InvoiceAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64](1000), + ), + } + } + + tests := []struct { + name string + mutate func(*Invoice) + wantErr error + errContains string + }{ + { + name: "matching mirrored fields", + mutate: func(inv *Invoice) {}, + }, + { + name: "missing mirrored field", + mutate: func(inv *Invoice) { + inv.OfferAmount = tlv.OptionalRecordT[ + tlv.TlvType8, TUint64, + ]{} + }, + wantErr: ErrInvoiceMismatch, + errContains: "missing 1 fields", + }, + { + name: "extra mirrored field", + mutate: func(inv *Invoice) { + inv.OfferDescription = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + []byte("extra"), + ), + ) + }, + wantErr: ErrInvoiceMismatch, + errContains: "unexpected field 10", + }, + { + name: "mismatched field data", + mutate: func(inv *Invoice) { + inv.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("different"), + ), + ) + }, + wantErr: ErrInvoiceMismatch, + errContains: "data mismatch", + }, + { + name: "equal length byte difference", + mutate: func(inv *Invoice) { + inv.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadatA"), + ), + ) + }, + wantErr: ErrInvoiceMismatch, + errContains: "data mismatch", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := baseline() + tc.mutate(inv) + + invEncoded, err := encodeInvBypassValidate(inv) + require.NoError(t, err) + invDecoded, err := DecodeInvoice(invEncoded) + require.NoError(t, err) + + err = ValidateInvoiceAgainstRequest( + invDecoded, irDecoded, + ) + if tc.wantErr == nil { + require.NoError(t, err) + + return + } + require.ErrorIs(t, err, tc.wantErr) + require.Contains(t, err.Error(), tc.errContains) + }) + } +} + +// TestValidateInvoiceAgainstRequestAmountMirror covers the cross-field +// invreq_amount (82) vs invoice_amount (170) equality rule. +func TestValidateInvoiceAgainstRequestAmountMirror(t *testing.T) { + t.Parallel() + + ir := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadata"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](2500), + ), + } + irEncoded, err := encodeIRBypassValidate(ir) + require.NoError(t, err) + irDecoded, err := DecodeInvoiceRequest(irEncoded) + require.NoError(t, err) + + build := func(invAmt uint64) *Invoice { + return &Invoice{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadata"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](2500), + ), + InvoiceAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64]( + TUint64(invAmt), + ), + ), + } + } + + // Equal amounts pass. + matchEnc, _ := encodeInvBypassValidate(build(2500)) + matchDec, _ := DecodeInvoice(matchEnc) + require.NoError(t, ValidateInvoiceAgainstRequest(matchDec, irDecoded)) + + // Mismatched amounts fail. + missEnc, _ := encodeInvBypassValidate(build(2501)) + missDec, _ := DecodeInvoice(missEnc) + err = ValidateInvoiceAgainstRequest(missDec, irDecoded) + require.ErrorIs(t, err, ErrInvoiceMismatch) + require.Contains(t, err.Error(), "invoice_amount") +} + +// TestValidateInvoiceAgainstRequestOfferAmount pins the offer-amount lower +// bound applied when invreq_amount is absent: the payee MUST NOT charge less +// than offer_amount * invreq_quantity for the native (non-offer_currency) case, +// while the offer_currency case is delegated to the caller. +func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { + t.Parallel() + + // build constructs a mirrored (invoice, request) pair carrying a fixed + // offer_amount and optional quantity/currency, with no invreq_amount so + // the offer-amount bound is what gets exercised. + build := func(offerAmt uint64, qty *uint64, currency []byte, + invAmt uint64) (*Invoice, *InvoiceRequest) { + + ir := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadata"), + ), + ), + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8, TUint64]( + TUint64(offerAmt), + ), + ), + } + inv := &Invoice{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + []byte("metadata"), + ), + ), + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8, TUint64]( + TUint64(offerAmt), + ), + ), + InvoiceAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170, TUint64]( + TUint64(invAmt), + ), + ), + } + if qty != nil { + ir.InvreqQuantity = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86, TUint64]( + TUint64(*qty), + ), + ) + inv.InvreqQuantity = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86, TUint64]( + TUint64(*qty), + ), + ) + } + if currency != nil { + ir.OfferCurrency = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](currency), + ) + inv.OfferCurrency = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](currency), + ) + } + + return inv, ir + } + + // roundtrip encodes and decodes both sides so the comparison runs over + // canonical wire bytes, mirroring the production flow. + roundtrip := func(inv *Invoice, ir *InvoiceRequest) error { + irEnc, err := encodeIRBypassValidate(ir) + require.NoError(t, err) + irDec, err := DecodeInvoiceRequest(irEnc) + require.NoError(t, err) + + invEnc, err := encodeInvBypassValidate(inv) + require.NoError(t, err) + invDec, err := DecodeInvoice(invEnc) + require.NoError(t, err) + + return ValidateInvoiceAgainstRequest(invDec, irDec) + } + + qty := func(v uint64) *uint64 { return &v } + + t.Run("at offer amount passes", func(t *testing.T) { + t.Parallel() + + inv, ir := build(1000, nil, nil, 1000) + require.NoError(t, roundtrip(inv, ir)) + }) + + t.Run("above offer amount passes", func(t *testing.T) { + t.Parallel() + + inv, ir := build(1000, nil, nil, 2000) + require.NoError(t, roundtrip(inv, ir)) + }) + + t.Run("below offer amount rejected", func(t *testing.T) { + t.Parallel() + + inv, ir := build(1000, nil, nil, 999) + require.ErrorIs(t, roundtrip(inv, ir), ErrAmountBelowExpected) + }) + + t.Run("quantity scales the bound", func(t *testing.T) { + t.Parallel() + + // 1000 * 3 = 3000 expected; 2999 is below, 3000 at the bound. + inv, ir := build(1000, qty(3), nil, 2999) + require.ErrorIs(t, roundtrip(inv, ir), ErrAmountBelowExpected) + + inv, ir = build(1000, qty(3), nil, 3000) + require.NoError(t, roundtrip(inv, ir)) + }) + + t.Run("offer_currency bound delegated", func(t *testing.T) { + t.Parallel() + + // With offer_currency present the bitcoin-unit bound does not + // apply, so an invoice_amount below offer_amount still passes + // this validator; the caller applies the exchange-rate check. + inv, ir := build(1000, nil, []byte("USD"), 1) + require.NoError(t, roundtrip(inv, ir)) + }) +} + +// TestValidateInvoiceWrite table-drives the writer-side checks of +// ValidateInvoiceWrite by clearing required fields on a valid baseline invoice. +func TestValidateInvoiceWrite(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Invoice) + wantErr error + }{ + { + name: "valid baseline invoice", + mutate: func(inv *Invoice) {}, + wantErr: nil, + }, + { + name: "missing created_at", + mutate: func(inv *Invoice) { + inv.InvoiceCreatedAt = tlv.OptionalRecordT[ + tlv.TlvType164, TUint64, + ]{} + }, + wantErr: ErrMissingCreatedAt, + }, + { + name: "missing amount", + mutate: func(inv *Invoice) { + inv.InvoiceAmount = tlv.OptionalRecordT[ + tlv.TlvType170, TUint64, + ]{} + }, + wantErr: ErrMissingAmount, + }, + { + name: "missing payment_hash", + mutate: func(inv *Invoice) { + inv.InvoicePaymentHash = tlv.OptionalRecordT[ + tlv.TlvType168, [32]byte, + ]{} + }, + wantErr: ErrMissingPaymentHash, + }, + { + name: "missing node_id", + mutate: func(inv *Invoice) { + inv.InvoiceNodeID = tlv.OptionalRecordT[ + tlv.TlvType176, *btcec.PublicKey, + ]{} + }, + wantErr: ErrMissingNodeID, + }, + { + name: "missing paths", + mutate: func(inv *Invoice) { + inv.InvoicePaths = tlv.OptionalRecordT[ + tlv.TlvType160, lnwire.BlindedPaths, + ]{} + }, + wantErr: ErrMissingPaths, + }, + { + name: "missing blinded_pay", + mutate: func(inv *Invoice) { + inv.InvoiceBlindedPay = tlv.OptionalRecordT[ + tlv.TlvType162, BlindedPayInfos, + ]{} + }, + wantErr: ErrMissingBlindedPay, + }, + { + name: "present non-nil payer_id and matching " + + "non-nil offer_issuer_id", + mutate: func(inv *Invoice) { + _, payerID := bobKey() + _, issuerID := aliceKey() + inv.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88]( + payerID, + ), + ) + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22]( + issuerID, + ), + ) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176]( + issuerID, + ), + ) + }, + wantErr: nil, + }, + { + name: "mismatched node_id and offer_issuer_id", + mutate: func(inv *Invoice) { + _, issuerID := aliceKey() + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22]( + issuerID, + ), + ) + }, + wantErr: ErrInvoiceNodeIDMismatch, + }, + { + name: "zero invoice_amount", + mutate: func(inv *Invoice) { + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170]( + TUint64(0), + ), + ) + }, + wantErr: ErrZeroInvoiceAmount, + }, + { + name: "blinded pay info mismatch", + mutate: func(inv *Invoice) { + infos := BlindedPayInfos{ + Infos: []BlindedPayInfo{{}, {}}, + } + inv.InvoiceBlindedPay = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162](infos), + ) + }, + wantErr: ErrBlindedPayMismatch, + }, + { + name: "empty invoice_paths", + mutate: func(inv *Invoice) { + paths := lnwire.BlindedPaths{Paths: nil} + inv.InvoicePaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160](paths), + ) + }, + wantErr: ErrEmptyBlindedPaths, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + tc.mutate(inv) + + err := ValidateInvoiceWrite(inv) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +// TestValidateFeaturesWithCatalogue verifies that both Role 1 endpoint features +// and Role 2 routing path features are correctly validated using injected +// catalogues. +func TestValidateFeaturesWithCatalogue(t *testing.T) { + t.Parallel() + + // Role 1 validation on ValidateInvoiceRead: + t.Run("endpoint features (Role 1)", func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + [64]byte{}, + ), + ) + + // Set MPP required (bit 16, even/required) + fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired) + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](fv), + ) + + // A: Unknown required bit -> reject + err := ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + require.ErrorIs(t, err, ErrUnknownEvenFeature) + + // B: Known required bit -> pass + known := map[lnwire.FeatureBit]string{ + lnwire.MPPRequired: "mpp", + } + err = ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{Invoice: known}, + ) + require.NoError(t, err) + }) + + // Role 2 validation on ValidateInvoiceRead (Usable blinded paths): + t.Run("routing path features (Role 2)", func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240]( + [64]byte{}, + ), + ) + + // Set an even required feature bit on the path's features (e.g. + // bit 16). + fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired) + inv.InvoiceBlindedPay = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162](BlindedPayInfos{ + Infos: []BlindedPayInfo{{ + Features: fv, + }}, + }), + ) + + // A: No known features catalogue -> 0 usable paths -> + // ErrNoUsablePaths + err := ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + require.ErrorIs(t, err, ErrNoUsablePaths) + + // B: Known features catalogue for blinded pay -> 1 usable path + // -> pass + knownBlinded := map[lnwire.FeatureBit]string{ + lnwire.MPPRequired: "mpp", + } + err = ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{Blinded: knownBlinded}, + ) + require.NoError(t, err) + }) + + // Writer side ignores features, as we set the features. + t.Run("writer side ignores features", func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired) + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](fv), + ) + + require.NoError(t, ValidateInvoiceWrite(inv)) + }) +} + +// TestValidateInvoiceWriteRejectsNilPubkeys verifies the writer rejects a +// present-but-nil mirrored pubkey field, which would otherwise panic the codec +// on encode. Symmetric with ValidateInvoiceRequestWrite. +func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { + t.Parallel() + + t.Run("present-but-nil payer_id", func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88]( + (*btcec.PublicKey)(nil), + ), + ) + require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + }) + + t.Run("present-but-nil offer_issuer_id", func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22]( + (*btcec.PublicKey)(nil), + ), + ) + require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + }) + + t.Run("present-but-nil node_id", func(t *testing.T) { + t.Parallel() + + inv := validInvoice(t) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176]( + (*btcec.PublicKey)(nil), + ), + ) + require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + }) +} From a5c47c0bb8dfdeb5b163c040331c2530d09b3bb4 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Mon, 29 Jun 2026 18:24:23 +0200 Subject: [PATCH 5/5] docs: add BOLT 12 invoice release notes Add release notes for the BOLT 12 invoice codec. --- docs/release-notes/release-notes-0.22.0.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 8c337765399..72b36edda77 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -99,6 +99,13 @@ in `SubscribeOnionMessages`, ensuring a nil reply path remains nil in the RPC response rather than being emitted as an empty struct. +* [BOLT 12 invoice + codec](https://github.com/lightningnetwork/lnd/pull/10941): add the + `invoice` TLV message to the `bolt12/` package with structural + reader/writer validation. Schnorr signature verification is not yet + performed; callers must verify the signature independently until the + Merkle and signing primitives land. + ## Testing ## Database