From 34cf77f839f999850312383737e0c66931ad4300 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:24:55 +0000 Subject: [PATCH 01/13] bolt12: align the invreq feature rule comment The comment standing where the writer-side feature check used to be argued its case in broken prose and used the word the reviewer found misleading. The file already has a form for a rule the codec cannot enforce, so use it and name the thing plainly: the bits are the caller's own and the reader checks them against the known bits it is given. Finding F11. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3633544439 --- bolt12/validate.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index e700b90e976..c9b513be4dd 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -535,10 +535,8 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { // - if it supports bolt12 invoice request features: // - MUST set invreq_features.features to the bitmap of features. - // 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. + // NOT CHECKED HERE: the bits are the caller's own, and the reader + // rejects unknown even bits using the known bits passed to it. // check UTF-8 constraints and BIP 353 err := checkUTF8(ir.InvreqPayerNote, "invreq_payer_note") From 25b8d5731c51b39b058dda3f23efaf7baa85a813 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:25:19 +0000 Subject: [PATCH 02/13] bolt12: describe node id binding for the payer Two comments on the payer-side checks called the key "the final blinded_node_id on the arrival path". Arrival is the receiver's view, which is how the spec states the writer rule, and these run on the payer, where the same key is the one it sent the request to. The phrase also collides with the separate reply-path arrival rule. The writer comment keeps its wording, which is correct there. Finding F15. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3639940412 --- bolt12/validate.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index c9b513be4dd..b65dd93eb36 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -1284,10 +1284,10 @@ func checkPubKeyNotNil[T tlv.TlvType]( // 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. +// (invoice_node_id equals the final blinded_node_id the payer sent the invoice +// request to) 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 @@ -1850,8 +1850,8 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // 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. + // with its request; the offer_paths blinded_node_id case needs the path + // the payer sent the request to and stays with the caller. if err := checkInvoiceNodeID(inv); err != nil { return err } From a2f7f1edcd4e07795fef7a36ba95d49343232dcb Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:26:19 +0000 Subject: [PATCH 03/13] bolt12: describe the lnwire dependency The package doc claimed no LND dependencies while importing lnwire for the pure-TLV framing, the blinded-path types and the feature vector. --- bolt12/doc.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bolt12/doc.go b/bolt12/doc.go index 809c31ad2bc..61cc552d656 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -1,6 +1,7 @@ // Package bolt12 implements encoding, decoding, and validation for BOLT 12 -// Offers, Invoice Requests, and Invoices. It provides a pure codec library -// with no LND daemon dependencies. +// Offers, Invoice Requests, and Invoices. It is a codec library: it does not +// reach into the daemon, and it takes the chain, the clock and the known +// feature bits from its caller. // // BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant // and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields. From a5dd967b5669f371407a491e669a6ba0f427f5c1 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:26:45 +0000 Subject: [PATCH 04/13] bolt12: fix the continuation error and README The continuation error said a '+' must precede a non-whitespace character, while the code skips whitespace after the marker and then requires a neighbour. The rule it enforces is that a marker joins two characters, so say that. The README line ran to 129 columns. Findings F43 and F44. https://github.com/lightningnetwork/lnd/pull/11001#discussion_r3804624503 https://github.com/lightningnetwork/lnd/pull/11001#discussion_r3804849595 --- bolt12/bech32.go | 4 ++-- bolt12/test-vectors/README.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bolt12/bech32.go b/bolt12/bech32.go index bd12b73a70d..b8680225469 100644 --- a/bolt12/bech32.go +++ b/bolt12/bech32.go @@ -266,8 +266,8 @@ func stripContinuation(s string) (string, error) { } if j >= len(s) || !isContinuationNeighbour(s[j]) { return "", fmt.Errorf( - "bolt12: %w: '+' must precede a "+ - "non-whitespace character", + "bolt12: %w: '+' must join two "+ + "characters", ErrInvalidContinuation, ) } diff --git a/bolt12/test-vectors/README.md b/bolt12/test-vectors/README.md index 0659a3597bf..eed09f64eae 100644 --- a/bolt12/test-vectors/README.md +++ b/bolt12/test-vectors/README.md @@ -1,6 +1,8 @@ # BOLT 12 Spec Test Vectors -These test vectors are vendored from the upstream [lightning/bolts](https://github.com/lightning/bolts) specification repository. +These test vectors are vendored from the upstream +[lightning/bolts](https://github.com/lightning/bolts) specification +repository. - **Source**: `bolt12/` directory in `lightning/bolts` - **Upstream Commit**: `311119388a46dfa859da3d2eda0ca836cfc5f078` From 170e625b945ed64712e46e6edcf3a501daeca2c4 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:29:24 +0000 Subject: [PATCH 05/13] bolt12: one name for the known feature bits The invoice path called a map of known feature bits a catalogue, a word that appears nowhere else in lnd. The other two readers in the same file already call it knownFeatures, and lnwire calls the same parameter featureNames, so the invoice path was the outlier. The type becomes InvoiceKnownFeatures, checkFeatures names its parameter like its callers do, and the word is gone from the docstrings and the test name. The struct itself stays: named fields are what stop a caller passing the blinded-path bits where the invoice bits belong. Findings F39 and F12. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3633603006 --- bolt12/invoice.go | 2 +- bolt12/invoice_test.go | 4 ++-- bolt12/validate.go | 14 ++++++------ bolt12/validate_test.go | 48 ++++++++++++++++++++--------------------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 002da217e36..a27db95d65d 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -417,7 +417,7 @@ func DecodeInvoiceString(s string, now time.Time, return nil, err } - features := InvoiceFeatureCatalogues{ + features := InvoiceKnownFeatures{ Invoice: Bolt12Features, Blinded: Bolt12Features, } diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index 12a4930d725..da48f837af9 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -157,7 +157,7 @@ func TestUsablePaths(t *testing.T) { ), } - // Empty catalogue: the MPPRequired bit is unknown, so path 0 is + // No known bits: 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) @@ -204,7 +204,7 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { require.NoError(t, err) err = ValidateInvoiceRead(decoded, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}) + InvoiceKnownFeatures{}) require.NoError(t, err) // Re-encode the decoded copy and confirm canonicality. diff --git a/bolt12/validate.go b/bolt12/validate.go index b65dd93eb36..6d031e28b1c 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -1198,12 +1198,12 @@ 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], - known map[lnwire.FeatureBit]string) error { + knownFeatures map[lnwire.FeatureBit]string) error { return fn.MapOptionZ( opt.ValOpt(), func(fv lnwire.RawFeatureVector) error { - wrapped := lnwire.NewFeatureVector(&fv, known) + wrapped := lnwire.NewFeatureVector(&fv, knownFeatures) unknown := wrapped.UnknownRequiredFeatures() if len(unknown) == 0 { return nil @@ -1694,13 +1694,13 @@ func isKnownInvoiceTLVType(typ tlv.Type) bool { } } -// InvoiceFeatureCatalogues names the two feature-bit catalogues the invoice +// InvoiceKnownFeatures names the two sets of known feature bits 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 +// invoice_features against the blinded-path bits and vice versa compiles // cleanly but misvalidates. Named fields make the swap impossible. -type InvoiceFeatureCatalogues struct { +type InvoiceKnownFeatures struct { // Invoice names the feature bits the reader understands for the // top-level invoice_features field. Invoice map[lnwire.FeatureBit]string @@ -1721,7 +1721,7 @@ type InvoiceFeatureCatalogues struct { // selection time (via Invoice.UsablePaths) to avoid selecting paths with // unknown required features. func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, - features InvoiceFeatureCatalogues) error { + features InvoiceKnownFeatures) error { // - MUST reject the invoice if invoice_amount is not present. if !inv.InvoiceAmount.IsSome() { return ErrMissingAmount @@ -1889,7 +1889,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // offerless request. func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, now time.Time, activeChain [32]byte, - features InvoiceFeatureCatalogues, + features InvoiceKnownFeatures, expectedNodeID *btcec.PublicKey) error { if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 7de328400df..8156150209c 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -879,7 +879,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { return ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) }, }, @@ -904,7 +904,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { return ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) }, }, @@ -1038,7 +1038,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // Blinded mode, happy path: the final node matches invoice_node_id. require.NoError(t, ValidateInvoiceForPayment( invDecoded, blindedIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, )) // The expiry gate is part of the composite, so a caller that only calls @@ -1046,7 +1046,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // window is long past 8000s after creation. err = ValidateInvoiceForPayment( invDecoded, blindedIRDecoded, time.Unix(1234567890+8000, 0), - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, ) require.ErrorIs(t, err, ErrInvoiceExpired) @@ -1090,7 +1090,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { err = ValidateInvoiceForPayment( overchargedDecoded, amountIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, ) require.ErrorIs(t, err, ErrInvoiceMismatch) require.ErrorContains( @@ -1101,7 +1101,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // Bob answered, but the payer believes it addressed Alice. err = ValidateInvoiceForPayment( invDecoded, blindedIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, alicePub, ) require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) @@ -1136,12 +1136,12 @@ func TestValidateInvoiceForPayment(t *testing.T) { // invoice is what shows the second step cannot be skipped. require.NoError(t, ValidateInvoiceRead( forgedDecoded, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, )) err = ValidateInvoiceForPayment( forgedDecoded, blindedIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, ) require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) @@ -1188,14 +1188,14 @@ func TestValidateInvoiceForPayment(t *testing.T) { // the offer it built the request from. require.NoError(t, ValidateInvoiceForPayment( invDecoded, cleartextIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, bobPub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, bobPub, )) // The check is unconditional, so a wrong expectation is caught even // though offer_issuer_id is present and the readers already bound it. err = ValidateInvoiceForPayment( invDecoded, cleartextIRDecoded, validNow, - bitcoinMainnetGenesisHash, InvoiceFeatureCatalogues{}, alicePub, + bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, alicePub, ) require.ErrorIs(t, err, ErrUnexpectedInvoiceNodeID) } @@ -2418,7 +2418,7 @@ func TestValidateInvoiceRead(t *testing.T) { err := ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.ErrorIs(t, err, tc.wantErr) }) @@ -2490,7 +2490,7 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.NoError(t, err) } @@ -3055,10 +3055,10 @@ func TestValidateInvoiceWrite(t *testing.T) { } } -// 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) { +// TestValidateFeaturesKnownBits verifies that both Role 1 endpoint features +// and Role 2 routing path features are correctly validated against the known +// bits the caller injects. +func TestValidateFeaturesKnownBits(t *testing.T) { t.Parallel() // Role 1 validation verifies endpoint features on ValidateInvoiceRead. @@ -3085,7 +3085,7 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { // An unknown required bit must be rejected. err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.ErrorIs(t, err, ErrUnknownEvenFeature) @@ -3095,7 +3095,7 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { } err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{Invoice: known}, + InvoiceKnownFeatures{Invoice: known}, ) require.NoError(t, err) }) @@ -3127,22 +3127,22 @@ func TestValidateFeaturesWithCatalogue(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), ) - // If there are no known features in the catalogue, there are - // zero usable paths and we expect ErrNoUsablePaths. + // With no known feature bits there are zero usable paths, so + // we expect ErrNoUsablePaths. err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{}, + InvoiceKnownFeatures{}, ) require.ErrorIs(t, err, ErrNoUsablePaths) - // A known features catalogue for blinded pay results in at - // least one usable path, which must pass. + // Known blinded-pay bits leave at least one usable path, which + // must pass. knownBlinded := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } err = ValidateInvoiceRead( inv, bitcoinMainnetGenesisHash, - InvoiceFeatureCatalogues{Blinded: knownBlinded}, + InvoiceKnownFeatures{Blinded: knownBlinded}, ) require.NoError(t, err) }) From e78a274d022dd3675caac67cdb53882955d9f9d7 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:34:14 +0000 Subject: [PATCH 06/13] bolt12: drop the offer vector layer census The census asserted how many invalid vectors fail at each of the three layers, counts that break on any re-vendoring of the spec vectors while proving nothing the table above it does not: that test already requires every invalid vector to be rejected and every valid one to pass. --- bolt12/validate_test.go | 62 ----------------------------------------- 1 file changed, 62 deletions(-) diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 8156150209c..beae39e53b5 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -3387,68 +3387,6 @@ func TestValidateOfferReadVectors(t *testing.T) { } } -// TestOfferVectorsLayerCensus verifies that every invalid vector in -// offers-test.json is rejected at the expected layer, pinning the distribution -// of failure modes across bech32 decode, TLV decode, and semantic validation. -func TestOfferVectorsLayerCensus(t *testing.T) { - t.Parallel() - - vectors := loadOffersVectors(t) - now := farFutureNow() - - var ( - bech32Rejections int - tlvRejections int - valRejections int - falseAccepts int - ) - - for _, tc := range vectors { - if tc.Valid { - continue - } - - _, tlvBytes, bech32Err := Decode(tc.Bolt12) - if bech32Err != nil { - bech32Rejections++ - continue - } - - offer, decodeErr := decodeOffer(tlvBytes) - if decodeErr != nil { - tlvRejections++ - continue - } - - valErr := ValidateOfferRead( - offer, now, bitcoinMainnetGenesisHash, nil, - ) - if valErr != nil { - valRejections++ - continue - } - - t.Errorf( - "invalid vector falsely accepted: %s", - tc.Description, - ) - falseAccepts++ - } - - require.Equal( - t, 2, bech32Rejections, "bech32 rejections mismatch", - ) - require.Equal( - t, 16, tlvRejections, "TLV decode rejections mismatch", - ) - require.Equal( - t, 15, valRejections, "validation rejections mismatch", - ) - require.Equal( - t, 0, falseAccepts, "false accepts count mismatch", - ) -} - // findRecord searches a slice of TLV records for a record with the given type. func findRecord(records []tlv.Record, typ uint64) (*tlv.Record, bool) { for i := range records { From 559da5afa521e644ec263c146ea61bfaaec51381 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:26:01 +0000 Subject: [PATCH 07/13] bolt12: stop modelling an invalid invoice_error The round-trip fixture paired erroneous_field 82, invreq_amount, with a suggested_value of 00 01 86 a0. The leading zero makes that a non-minimal tu64, so a peer decoding it as the field's own type fails and loses the correction the message exists to carry. The fixture now uses the canonical encoding. The writer cannot catch this yet, and the note claiming the schema is caller context overstated the obstacle: the package defines every field number and type for both messages, it just has no field-number to type table. That is now a TODO, and Encode's contract says the value is unchecked. Finding F18. https://github.com/lightningnetwork/lnd/pull/10958#discussion_r3646600149 --- bolt12/invoice_error.go | 5 +++++ bolt12/invoice_error_test.go | 8 ++++++-- bolt12/validate.go | 8 ++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/bolt12/invoice_error.go b/bolt12/invoice_error.go index 051b7d85e8e..80bcd727744 100644 --- a/bolt12/invoice_error.go +++ b/bolt12/invoice_error.go @@ -46,6 +46,11 @@ func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer { // signature to invalidate by dropping unrecognized TLVs (unlike signed // messages such as invoices, where unknown TLVs must be preserved to keep // signatures valid). +// +// One writer rule stays unchecked: a suggested_value is not verified against +// the type of the field erroneous_field names. Emitting a value the peer +// cannot decode is therefore possible, see the TODO in +// ValidateInvoiceErrorWrite. func (ie *InvoiceError) Encode() ([]byte, error) { if err := ValidateInvoiceErrorWrite(ie); err != nil { return nil, fmt.Errorf("validate invoice error: %w", err) diff --git a/bolt12/invoice_error_test.go b/bolt12/invoice_error_test.go index e050126906d..f18f05d347d 100644 --- a/bolt12/invoice_error_test.go +++ b/bolt12/invoice_error_test.go @@ -49,15 +49,19 @@ func TestInvoiceErrorRoundTrip(t *testing.T) { name: "all fields", ie: &InvoiceError{ ErroneousField: someErrField(82), + // Field 82 is invreq_amount, a tu64, so the + // suggested value has to be minimal: a + // leading zero byte would make it undecodable + // for the peer. SuggestedValue: someSuggested( - []byte{0x00, 0x01, 0x86, 0xa0}, + []byte{0x01, 0x86, 0xa0}, ), Error: someError("amount too low"), }, wantMsg: "amount too low", wantHasField: true, wantFieldNum: 82, - wantSuggest: []byte{0x00, 0x01, 0x86, 0xa0}, + wantSuggest: []byte{0x01, 0x86, 0xa0}, }, { name: "minimal error only", diff --git a/bolt12/validate.go b/bolt12/validate.go index 6d031e28b1c..e305f181e66 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -313,10 +313,10 @@ func ValidateInvoiceErrorWrite(ie *InvoiceError) error { // - if it sets suggested_value: // - MUST set suggested_value to a valid field for that // tlv_fieldnum. - // NOT CHECKED HERE: verifying the replacement is a valid encoding for - // the erroneous field needs the schema of the rejected invoice or - // invoice_request, which is caller context this validator does not - // have. + // TODO(bitromortac): enforce this once we send invoice_error. It needs + // a field-number to type table for the invoice_request and invoice + // fields, so a tu64 field rejects a non-minimal value, type 80 + // requires 32 bytes and type 88 a compressed point. return nil } From 49bcf47fa7e8e6aa35b3a916517beaaa964c6d52 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:18:50 +0000 Subject: [PATCH 08/13] bolt12: round-trip an offer with every field The round trip covered four of eleven fields and asserted byte identity only. Byte identity cannot see a field that is wired into the encode path but not into the decode path: such a field survives as an unknown TLV and re-encodes cleanly while its typed value disappears. Comparing the decoded struct against the fixture catches it, verified by dropping offer_quantity_max from the decode stream. The fixture also carries an unknown odd TLV in the offer range, so the offer keeps the byte-exact preservation its siblings already pin. Findings F31 and F2. https://github.com/lightningnetwork/lnd/pull/10832#discussion_r3422236322 https://github.com/lightningnetwork/lnd/pull/10789#discussion_r3416301143 --- bolt12/offer_test.go | 66 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index ce786f02579..509d5e34d0e 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -6,33 +6,84 @@ import ( "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) -// TestOfferRoundTrip pins encode→decode→re-encode for an Offer with a -// representative subset of optional fields. A byte-identical re-encode is the -// invariant that keeps offer_id stable across the codec boundary. +// TestOfferRoundTrip pins encode, decode and re-encode for an Offer with every +// field set, plus an unknown odd TLV in the offer range. Comparing the decoded +// struct against the original catches a field that is wired into the encode +// path but not into the decode path, which byte identity alone cannot see: +// such a field survives as an unknown TLV and re-encodes cleanly while its +// typed value silently disappears. func TestOfferRoundTrip(t *testing.T) { t.Parallel() desc := tlv.Blob("coffee") issuer := tlv.Blob("alice") + currency := tlv.Blob("USD") + metadata := tlv.Blob("opaque") _, bobPub := bobKey() + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() + + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) o := &Offer{ + OfferChains: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2](ChainsRecord{ + Chains: [][32]byte{bitcoinMainnetGenesisHash}, + }), + ), + OfferMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](metadata), + ), + OfferCurrency: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](currency), + ), OfferAmount: tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType8](TUint64(1500)), ), OfferDescription: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType10](desc), ), + OfferFeatures: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12]( + *lnwire.NewRawFeatureVector(lnwire.MPPOptional), + ), + ), + OfferAbsoluteExpiry: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14](TUint64(1 << 32)), + ), + OfferPaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType16](lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{{ + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{{ + BlindedNodeID: hopPub, + EncryptedData: []byte{1, 2}, + }}, + }}, + }), + ), OfferIssuer: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType18](issuer), ), + OfferQuantityMax: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(5)), + ), OfferIssuerID: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), ), + + // An unknown odd type in the offer range must survive the round + // trip byte for byte, so that offer_id stays stable across + // encoders that understand a wider set of extensions. + decodedTLVs: tlv.TypeMap{13: []byte{0xde, 0xad}}, } encoded, err := o.Encode() @@ -42,9 +93,12 @@ func TestOfferRoundTrip(t *testing.T) { decoded, err := decodeOffer(encoded) require.NoError(t, err) - require.Equal(t, TUint64(1500), decoded.OfferAmount.UnwrapOrFailV(t)) - require.Equal(t, desc, decoded.OfferDescription.UnwrapOrFailV(t)) - require.Equal(t, issuer, decoded.OfferIssuer.UnwrapOrFailV(t)) + // The sidecar is a decode artifact: it names every type seen on the + // wire, whereas the fixture above only carries the unknown one. Adopt + // the decoded view so the comparison below covers the typed fields. + require.Equal(t, []byte{0xde, 0xad}, decoded.decodedTLVs[13]) + o.decodedTLVs = decoded.decodedTLVs + require.Equal(t, o, decoded) reencoded, err := decoded.Encode() require.NoError(t, err) From 57146fac81ef5a832b2eebe255b9a0d43fc226de Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:20:02 +0000 Subject: [PATCH 09/13] bolt12: round-trip a full invoice request The round trip covered four of twenty-one fields and asserted byte identity only, which cannot see a field wired into the encode path but not into the decode path. Comparing the decoded struct against the fixture catches it, verified by dropping invreq_quantity from the decode stream. The request is also signed rather than carrying a placeholder, so the fixture is one a reader would accept. Finding F31. https://github.com/lightningnetwork/lnd/pull/10832#discussion_r3422236322 --- bolt12/invoice_request_test.go | 133 ++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 20 deletions(-) diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index af489758b25..83f2cc7a090 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -5,41 +5,136 @@ import ( "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) -// TestInvoiceRequestRoundTrip pins encode→decode→re-encode for an -// InvoiceRequest with a representative subset of optional fields. +// TestInvoiceRequestRoundTrip pins encode, decode and re-encode for an +// InvoiceRequest with every field set, plus an unknown odd TLV. Comparing the +// decoded struct against the fixture catches a field that is wired into the +// encode path but not into the decode path, which byte identity alone cannot +// see. func TestInvoiceRequestRoundTrip(t *testing.T) { t.Parallel() - _, bobPub := bobKey() + priv, bobPub := bobKey() + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() - metadata := tlv.Blob("payer-metadata") + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + paths := lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{ + { + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{ + { + BlindedNodeID: hopPub, + EncryptedData: []byte{1, 2}, + }, + }, + }, + }, + } + + // name_len, name, domain_len, domain. + bip353 := append( + []byte{3, 'b', 'o', 'b', 6}, []byte("ex.com")..., + ) ir := &InvoiceRequest{ + OfferChains: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2](ChainsRecord{ + Chains: [][32]byte{bitcoinMainnetGenesisHash}, + }), + ), + OfferMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4]( + tlv.Blob("opaque"), + ), + ), + OfferCurrency: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](tlv.Blob("USD")), + ), + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8](TUint64(1500)), + ), OfferDescription: tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType10]( - tlv.Blob("description"), + tlv.Blob("coffee"), ), ), - InvreqPayerID: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub), + OfferFeatures: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12]( + *lnwire.NewRawFeatureVector(lnwire.MPPOptional), + ), + ), + OfferAbsoluteExpiry: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14](TUint64(1 << 32)), + ), + OfferPaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType16](paths), + ), + OfferIssuer: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType18]( + tlv.Blob("alice"), + ), + ), + OfferQuantityMax: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(5)), + ), + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), ), InvreqMetadata: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType0](metadata), + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("payer-metadata"), + ), + ), + InvreqChain: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80]( + bitcoinMainnetGenesisHash, + ), ), InvreqAmount: tlv.SomeRecordT( - tlv.NewRecordT[tlv.TlvType82, TUint64](1000), + tlv.NewRecordT[tlv.TlvType82](TUint64(3000)), ), - Signature: tlv.SomeRecordT( - tlv.NewPrimitiveRecord[tlv.TlvType240]( - [64]byte{0x01}, + InvreqFeatures: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84]( + *lnwire.NewRawFeatureVector(lnwire.MPPOptional), ), ), + InvreqQuantity: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86](TUint64(2)), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub), + ), + InvreqPayerNote: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType89](tlv.Blob("tip")), + ), + InvreqPaths: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType90](paths), + ), + InvreqBip353Name: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType91](bip353), + ), + + // An unknown odd type in the signed range must survive the + // round trip byte for byte. + decodedTLVs: tlv.TypeMap{93: []byte{0xde, 0xad}}, } + sig, err := SignInvoiceRequest(ir, priv) + require.NoError(t, err) + ir.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + encoded, err := ir.Encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -47,14 +142,12 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { decoded, err := DecodeInvoiceRequest(encoded) require.NoError(t, err) - require.Equal( - t, bobPub.SerializeCompressed(), - decoded.InvreqPayerID.UnwrapOrFailV(t).SerializeCompressed(), - ) - require.Equal(t, metadata, decoded.InvreqMetadata.UnwrapOrFailV(t)) - require.Equal( - t, TUint64(1000), decoded.InvreqAmount.UnwrapOrFailV(t), - ) + // The sidecar names every type seen on the wire, whereas the fixture + // carries only the unknown one. Adopt the decoded view so the + // comparison below covers the typed fields. + require.Equal(t, []byte{0xde, 0xad}, decoded.decodedTLVs[93]) + ir.decodedTLVs = decoded.decodedTLVs + require.Equal(t, ir, decoded) reencoded, err := decoded.Encode() require.NoError(t, err) From ce2c205aa8b77dd59320865b5e76eb2cc0658f12 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:22:04 +0000 Subject: [PATCH 10/13] bolt12: round-trip an invoice with every field The round trip populated six of thirty fields despite its name, and asserted byte identity only, which cannot see a field wired into the encode path but not into the decode path. Comparing the decoded struct against the fixture catches it, verified by dropping invoice_relative_expiry from the decode stream. The invoice is signed and read-validated too, so the fixture is one a payer would accept. Finding F31. https://github.com/lightningnetwork/lnd/pull/10832#discussion_r3422236322 --- bolt12/invoice_test.go | 168 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 13 deletions(-) diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index da48f837af9..e3369b41484 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -177,19 +177,150 @@ func TestUsablePaths(t *testing.T) { 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. +// TestInvoiceRoundTripPreservesAllTypes pins encode, decode and re-encode for +// an Invoice with every field set, plus an unknown odd TLV. Byte identity +// keeps the Merkle root stable, and comparing the decoded struct against the +// fixture catches a field wired into the encode path but not into the decode +// path, which byte identity alone cannot see. func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { t.Parallel() + priv, pub := bobKey() + _, intro := aliceKey() + _, blinding := bobKey() + _, hopPub := aliceKey() + + introNode, err := lnwire.NewPubkeyIntro(intro) + require.NoError(t, err) + + paths := lnwire.BlindedPaths{ + Paths: []lnwire.BlindedPath{ + { + IntroductionNode: introNode, + BlindingPoint: blinding, + Hops: []lnwire.BlindedHop{ + { + BlindedNodeID: hopPub, + EncryptedData: []byte{1, 2}, + }, + }, + }, + }, + } + + // name_len, name, domain_len, domain. + bip353 := append( + []byte{3, 'b', 'o', 'b', 6}, []byte("ex.com")..., + ) + + features := *lnwire.NewRawFeatureVector(lnwire.MPPOptional) + + // The required fields come from the shared fixture, the rest are set + // here so the round trip covers every field. inv := validInvoice(t) - // Sign with the fixture's node id (Bob) so the read path's signature - // check accepts the invoice. - priv, _ := bobKey() + // The shared fixture leaves the hop payload and the payinfo feature + // vector at their zero values, which decode as empty rather than nil, + // so set both explicitly here. + inv.InvoicePaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType160](paths), + ) + payInfo := BlindedPayInfo{ + FeeBaseMsat: 1000, + FeeProportionalMillionths: 10, + CltvExpiryDelta: 80, + HtlcMinimumMsat: 1, + HtlcMaximumMsat: 100_000, + Features: *lnwire.NewRawFeatureVector(), + } + inv.InvoiceBlindedPay = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType162](BlindedPayInfos{ + Infos: []BlindedPayInfo{payInfo}, + }), + ) + inv.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("payer-meta")), + ) + inv.OfferChains = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2](ChainsRecord{ + Chains: [][32]byte{bitcoinMainnetGenesisHash}, + }), + ) + inv.OfferMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](tlv.Blob("opaque")), + ) + inv.OfferCurrency = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](tlv.Blob("USD")), + ) + inv.OfferAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8](TUint64(1500)), + ) + inv.OfferDescription = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("coffee")), + ) + inv.OfferFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12](features), + ) + inv.OfferAbsoluteExpiry = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14](TUint64(1 << 32)), + ) + inv.OfferPaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType16](paths), + ) + inv.OfferIssuer = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType18](tlv.Blob("alice")), + ) + inv.OfferQuantityMax = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(5)), + ) + inv.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](pub), + ) + inv.InvreqChain = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80]( + bitcoinMainnetGenesisHash, + ), + ) + inv.InvreqAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82](TUint64(100_000)), + ) + inv.InvreqFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84](features), + ) + inv.InvreqQuantity = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86](TUint64(2)), + ) + inv.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ) + inv.InvreqPayerNote = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType89](tlv.Blob("tip")), + ) + inv.InvreqPaths = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType90](paths), + ) + inv.InvreqBip353Name = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType91](bip353), + ) + inv.InvoiceRelativeExp = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType166](TUint32(3600)), + ) + inv.InvoiceFallbacks = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType172](FallbackAddresses{ + Addrs: []FallbackAddress{{ + Version: 1, + Address: []byte{3, 4, 5}, + }}, + }), + ) + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](features), + ) + + // An unknown odd type in the signed range must survive the round trip + // byte for byte, because the Merkle root covers it. + inv.decodedTLVs = tlv.TypeMap{93: []byte{0xde, 0xad}} + sig, err := SignInvoice(inv, priv) require.NoError(t, err) inv.Signature = tlv.SomeRecordT( @@ -203,15 +334,26 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { decoded, err := DecodeInvoice(encoded) require.NoError(t, err) - err = ValidateInvoiceRead(decoded, bitcoinMainnetGenesisHash, - InvoiceKnownFeatures{}) + err = ValidateInvoiceRead( + decoded, bitcoinMainnetGenesisHash, + InvoiceKnownFeatures{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + }, + ) require.NoError(t, err) + // The sidecar names every type seen on the wire, whereas the fixture + // carries only the unknown one. Adopt the decoded view so the + // comparison below covers the typed fields. + require.Equal(t, []byte{0xde, 0xad}, decoded.decodedTLVs[93]) + inv.decodedTLVs = decoded.decodedTLVs + require.Equal(t, inv, decoded) + // Re-encode the decoded copy and confirm canonicality. - // decode(encode(decode(encode(x)))) must equal decode(encode(x)). - encoded2, err := decoded.Encode() + reencoded, err := decoded.Encode() require.NoError(t, err) - require.Equal(t, encoded, encoded2) + require.Equal(t, encoded, reencoded) } // TestDecodeInvoiceRejectsTruncated locks in that DecodeInvoice surfaces an From 58f7f43867431c4444d084cb05e04e44525ac655 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:22:59 +0000 Subject: [PATCH 11/13] bolt12: cover the amount overflow guard The request side had a test for the offer_amount times quantity overflow, the invoice side did not, and it was the only uncovered branch in the invoice amount check. Verified by neutering the guard, which makes the new case accept an invoice_amount of one against an authorized amount that wrapped to zero. Finding F9. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3599755895 --- bolt12/validate_test.go | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index beae39e53b5..dca57594276 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -2052,6 +2052,53 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) { require.ErrorIs(t, writeErr, ErrAmountBelowExpected) } +// TestValidateInvoiceAmountOverflow is the invoice-side twin of +// TestValidateInvoiceRequestAmountOverflow: with invreq_amount absent the +// authorized amount is offer_amount times invreq_quantity, and that product +// must not wrap. An unguarded multiply would truncate to zero and accept any +// invoice_amount as "at least zero". +func TestValidateInvoiceAmountOverflow(t *testing.T) { + t.Parallel() + + _, pub := bobKey() + + req := &InvoiceRequest{} + req.OfferIssuerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](pub), + ) + req.OfferAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8](TUint64(2)), + ) + + // quantity_max zero means unlimited, so the bound check does not cap + // the quantity below. + req.OfferQuantityMax = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType20](TUint64(0)), + ) + + // We request to pay 2^63 units, which would overflow the uint64 product + // with offer_amount(2). + req.InvreqQuantity = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType86](TUint64(1 << 63)), + ) + req.InvreqPayerID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](pub), + ) + req.InvreqMetadata = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("m")), + ) + + // An invoice setting the overflow value of 0 would be accepted by an + // unguarded validator. + inv := NewInvoiceFromRequest(req) + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170](TUint64(0)), + ) + + err := ValidateInvoiceAgainstRequest(inv, req) + require.ErrorIs(t, err, ErrAmountBelowExpected) +} + // TestValidateInvoiceRequestReadChain pins the spec invreq_chain rule: // an absent invreq_chain defaults to Bitcoin mainnet and must be // rejected on a non-mainnet node, while a present invreq_chain that From 0a54b0c81a9819f4abdce332284a26b4ba5b6be9 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:23:50 +0000 Subject: [PATCH 12/13] bolt12: assert sentinels in subtype decoders Both decode tables matched error substrings where the decoder returns a sentinel, so a reworded message would keep passing a test that no longer proves anything. Four cases now use require.ErrorIs, and the rest stay on substrings because they assert ad-hoc messages with no sentinel behind them. The mixed idiom is the one TestDecodeChainsRecord already uses in this file. Finding F10. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3599756449 --- bolt12/subtypes_test.go | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/bolt12/subtypes_test.go b/bolt12/subtypes_test.go index a7c6287a33c..293e8a5ad8b 100644 --- a/bolt12/subtypes_test.go +++ b/bolt12/subtypes_test.go @@ -259,6 +259,7 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { data []byte declLen uint64 errSubstr string + wantErr error }{ { name: "missing fee_base", @@ -277,10 +278,10 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { errSubstr: "exceeds remaining", }, { - name: "exceeds cap", - data: make([]byte, (maxBlindedPayInfos+1)*28), - declLen: (maxBlindedPayInfos + 1) * 28, - errSubstr: "exceeds maxBlindedPayInfos", + name: "exceeds cap", + data: make([]byte, (maxBlindedPayInfos+1)*28), + declLen: (maxBlindedPayInfos + 1) * 28, + wantErr: ErrTooManyBlindedPayInfos, }, { name: "non-minimal features", @@ -290,8 +291,8 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { data: append( make([]byte, 26), []byte{0x00, 0x01, 0x00}..., ), - declLen: 29, - errSubstr: "non-minimal", + declLen: 29, + wantErr: ErrNonMinimalFeatures, }, { name: "inverted htlc range", @@ -305,8 +306,8 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { return b }(), - declLen: 26, - errSubstr: "htlc_minimum_msat exceeds", + declLen: 26, + wantErr: ErrInvalidHtlcRange, }, } @@ -320,6 +321,13 @@ func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) { tc.declLen, ) require.Error(t, err) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + require.Contains(t, err.Error(), tc.errSubstr) }) } @@ -361,6 +369,7 @@ func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { data []byte declLen uint64 errSubstr string + wantErr error }{ { name: "missing version byte", @@ -392,10 +401,10 @@ func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { errSubstr: "exceeds remaining", }, { - name: "exceeds cap", - data: make([]byte, (maxFallbackAddrs+1)*3), - declLen: (maxFallbackAddrs + 1) * 3, - errSubstr: "exceeds maxFallbackAddrs", + name: "exceeds cap", + data: make([]byte, (maxFallbackAddrs+1)*3), + declLen: (maxFallbackAddrs + 1) * 3, + wantErr: ErrTooManyFallbackAddrs, }, } @@ -409,6 +418,13 @@ func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) { tc.declLen, ) require.Error(t, err) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + require.Contains(t, err.Error(), tc.errSubstr) }) } From 59739d66fed0dfad767aee87aceaf6f49da44cd3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:24:36 +0000 Subject: [PATCH 13/13] bolt12: re-encode offer spec vectors on write The vector table decoded each vector and compared every record against the expected hex, but never fed one back out. So the message-level Encode and the bech32 writer were only ever tested against fixtures we wrote ourselves. Each valid vector now has to reproduce its own string, which passes for all of them today and fails if the writer gains an extra character. Finding F45. https://github.com/lightningnetwork/lnd/pull/11001#discussion_r3804868912 --- bolt12/validate_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index dca57594276..1f05216f2c1 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "math" + "strings" "testing" "time" @@ -3422,6 +3423,23 @@ func TestValidateOfferReadVectors(t *testing.T) { ) } + // Feed the vector back out through the + // writer. Nothing else runs Encode or the + // bech32 writer against spec data, so a + // record-ordering or writer bug would + // otherwise only show against fixtures we + // wrote ourselves. + restrung, encErr := EncodeOfferString(offer) + require.NoError(t, encErr) + require.True( + t, + strings.EqualFold( + tc.Bolt12, restrung, + ), + "re-encode mismatch: want %s, got %s", + tc.Bolt12, restrung, + ) + return }