diff --git a/bolt12/invoice.go b/bolt12/invoice.go new file mode 100644 index 0000000000..6a04c01200 --- /dev/null +++ b/bolt12/invoice.go @@ -0,0 +1,440 @@ +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) + +const ( + // maxWitnessVersion is the highest segwit witness version a usable + // fallback address may carry; the BOLT 12 reader ignores anything + // above it. + maxWitnessVersion = 16 + + // minWitnessProgramLen and maxWitnessProgramLen bound the witness + // program length, in bytes, of a usable fallback address. + minWitnessProgramLen = 2 + maxWitnessProgramLen = 40 +) + +// 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 > maxWitnessVersion { + continue + } + + // MUST ignore any fallback_address for which address is less + // than 2 or greater than 40 bytes. + if len(a.Address) < minWitnessProgramLen || + len(a.Address) > maxWitnessProgramLen { + + 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) { + 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 + } + + 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 0000000000..f15e1a7532 --- /dev/null +++ b/bolt12/invoice_test.go @@ -0,0 +1,359 @@ +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) + + 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() + 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", + ) +} + +// 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/subtypes.go b/bolt12/subtypes.go index dcd5e41de1..e3c49ddc00 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,28 @@ 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", +) + +// ErrInvalidHtlcRange is returned when a decoded blinded_payinfo entry carries +// an htlc_minimum_msat greater than its htlc_maximum_msat. +var ErrInvalidHtlcRange = errors.New( + "blinded_payinfo htlc_minimum_msat exceeds htlc_maximum_msat", +) + +// 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 +45,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 +123,326 @@ 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 is the base fee, in millisatoshis, charged for relaying a + // payment over this blinded path. + FeeBaseMsat uint32 + + // FeeProportionalMillionths is the proportional fee, in millionths of a + // satoshi per relayed satoshi, charged over this blinded path. + FeeProportionalMillionths uint32 + + // CltvExpiryDelta is the CLTV expiry delta the path requires. + CltvExpiryDelta uint16 + + // HtlcMinimumMsat is the smallest HTLC, in millisatoshis, the path + // accepts. + HtlcMinimumMsat uint64 + + // HtlcMaximumMsat is the largest HTLC, in millisatoshis, the path + // accepts. + 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]) + + // Defense-in-depth decode check, mirroring the + // ErrNonMinimalFeatures guard below: reject an inverted HTLC + // range so the htlc_min <= htlc_max invariant holds for every + // downstream consumer instead of being re-derived per caller. + if info.HtlcMinimumMsat > info.HtlcMaximumMsat { + return ErrInvalidHtlcRange + } + + // 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 ceecd14a08..a7c6287a33 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,262 @@ 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", + }, + { + name: "inverted htlc range", + // htlc_min at bytes [10:18] = 1000, htlc_max at bytes + // [18:26] = 500, so min > max must be rejected before + // the flen/features are ever read. + data: func() []byte { + b := make([]byte, 26) + b[16], b[17] = 0x03, 0xe8 // htlc_min = 1000 + b[24], b[25] = 0x01, 0xf4 // htlc_max = 500 + + return b + }(), + declLen: 26, + errSubstr: "htlc_minimum_msat exceeds", + }, + } + + 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 b956477c11..b0918ac2a3 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, + ) +} diff --git a/bolt12/validate.go b/bolt12/validate.go index 7b2e421c34..86c7c7d319 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,11 +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. - if err := checkFeatures(ir.InvreqFeatures); 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") @@ -461,7 +527,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 +573,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 +865,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 +875,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 +888,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 +1097,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 @@ -1114,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 c31a060e4e..c4736fc47d 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -1,6 +1,7 @@ package bolt12 import ( + "math" "testing" "time" @@ -242,6 +243,7 @@ func TestValidateOfferRead(t *testing.T) { name string mutate func(*Offer) activeChain [32]byte + known map[lnwire.FeatureBit]string wantErr error }{ { @@ -632,6 +634,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 +660,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 +1217,7 @@ func TestValidateInvoiceRequestReadSentinels(t *testing.T) { tests := []struct { name string mutate func(*InvoiceRequest) + known map[lnwire.FeatureBit]string wantErr error }{ { @@ -1413,6 +1433,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 +1456,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 +1506,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 +1554,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 +1580,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 +1594,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) }) @@ -1680,3 +1722,982 @@ 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 verifies endpoint features 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), + ) + + // An unknown required bit must be rejected. + err := ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + require.ErrorIs(t, err, ErrUnknownEvenFeature) + + // A known required bit must pass. + known := map[lnwire.FeatureBit]string{ + lnwire.MPPRequired: "mpp", + } + err = ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{Invoice: known}, + ) + require.NoError(t, err) + }) + + // Role 2 validation verifies routing path features on + // ValidateInvoiceRead. + 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, + }}, + }), + ) + + // If there are no known features in the catalogue, there are + // zero usable paths and we expect ErrNoUsablePaths. + err := ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceFeatureCatalogues{}, + ) + require.ErrorIs(t, err, ErrNoUsablePaths) + + // A known features catalogue for blinded pay results in at + // least one usable path, which must 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) + }) +} diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 8c33776539..72b36edda7 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