From 0541094f6ac40e67f2fb11a3f589843c4fe7861d Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:13:25 +0000 Subject: [PATCH 01/16] bolt12: reject unknown even types in sig range The invoice reader skipped the must-understand check for types 240 to 1000, so an invoice carrying unknown even type 242 was accepted while the same type on an invoice_request was rejected. The spec exempts that range from the out-of-range rule only, and BOLT 1 still makes an unknown even type fatal. Core Lightning grants the range no exception either, so this was a divergence rather than a choice. Finding F8. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3552921727 --- bolt12/validate.go | 13 ++++--------- bolt12/validate_test.go | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index e305f181e6..2e6945355a 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -1772,16 +1772,11 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - 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. + // Separately, BOLT 1 makes unknown even TLV types must-understand. + // The invoice reader defines no out-of-range rule, so an unknown odd + // type is ignored at any value and an unknown even type is rejected at + // any value, the signature range (240-1000) included. for _, t := range sortedTypes(inv.decodedTLVs) { - if bolt12InUnsignedRange(t) { - continue - } if !isKnownInvoiceTLVType(t) && t%2 == 0 { return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t) } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 1f05216f2c..1f28d7b8d6 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -2473,10 +2473,10 @@ func TestValidateInvoiceRead(t *testing.T) { } } -// 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) { +// TestValidateInvoiceReadSignatureRange pins both rules for the signature +// range (240-1000): an unknown odd TLV there is ignored, an unknown even one +// is rejected. +func TestValidateInvoiceReadSignatureRange(t *testing.T) { t.Parallel() priv, pub := bobKey() @@ -2541,6 +2541,16 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) { InvoiceKnownFeatures{}, ) require.NoError(t, err) + + // An unknown even type in the signature range is must-understand: + // the range is exempt from the out-of-range rule only. + inv.decodedTLVs = tlv.TypeMap{242: nil} + + err = ValidateInvoiceRead( + inv, bitcoinMainnetGenesisHash, + InvoiceKnownFeatures{}, + ) + require.ErrorIs(t, err, ErrUnknownEvenType) } // TestValidateInvoiceExpiry covers the relative-expiry default, an explicit From 2ecac9c60ffb7eed9022db0ee1192d3a4dd637ab Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:14:36 +0000 Subject: [PATCH 02/16] bolt12: reject unknown even types on offer write The offer writer checked the allowed range but not the must-understand rule, while the offer reader checks both. The state is only reachable for an offer decoded and then mutated, since the typed field set cannot express an unknown type, but the asymmetry made a reader of the code work out why one of the two rules was missing. Finding F5. https://github.com/lightningnetwork/lnd/pull/10789#discussion_r3416452628 --- bolt12/validate.go | 10 ++++++++-- bolt12/validate_test.go | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index 2e6945355a..978c3ae00f 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -1109,16 +1109,22 @@ func ValidateOfferWrite(o *Offer) error { return err } - // Writer MUST NOT set TLV fields outside allowed ranges. This check - // catches a decoded-then-mutated offer: a freshly-built struct has no + // Writer MUST NOT set TLV fields outside allowed ranges, and BOLT 1 + // makes an unknown even type must-understand. Both checks catch a + // decoded-then-mutated offer: a freshly-built struct has no // decodedTLVs (Decode is the only writer of that field). The typed // field set already excludes out-of-range types by construction, so a // freshly-built offer cannot violate the range rule in the first place. + // The reader applies the same two rules in this order. for _, t := range sortedTypes(o.decodedTLVs) { if !offerAllowedRange(t) { return fmt.Errorf("%w: type %d", ErrOutOfRangeType, t) } + + if !isKnownOfferTLVType(t) && t%2 == 0 { + return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t) + } } // offer_amount requires offer_description. diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 1f28d7b8d6..e35967aaba 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -151,6 +151,18 @@ func TestValidateOfferWrite(t *testing.T) { }, wantErr: ErrOutOfRangeType, }, + { + // Same path as above, but for the must-understand + // rule: type 24 is in range and unknown to the offer + // schema, so the writer refuses to re-emit it. + name: "unknown even TLV in decoded extras", + mutate: func(o *Offer) { + o.decodedTLVs = tlv.TypeMap{ + 24: nil, + } + }, + wantErr: ErrUnknownEvenType, + }, { name: "empty blinded paths list", mutate: func(o *Offer) { From 89654301d90137b09bcc4cf1d006b3f808fde721 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:16:25 +0000 Subject: [PATCH 03/16] bolt12: leave decode length limits to the caller Decode capped the raw and the cleaned string length, which no other implementation does and which the spec does not ask for. The cap protected nothing: a decode allocates on the order of its input, each record is already bounded by tlv.MaxRecordSize and every subtype decoder bounds itself against the bytes present, and the input exists in the caller's memory before Decode runs. What it could do is reject a spec-valid message once unknown odd fields push a string past a limit chosen today. Encode keeps its payload bound, since there we choose what to emit, and the constant now says it is a writer policy. The caller bounds its own medium: the onion-message envelope for an invoice_request and an invoice, the RPC or CLI for a pasted or scanned offer string. Finding F41. https://github.com/lightningnetwork/lnd/pull/11001#discussion_r3804778868 --- bolt12/bech32.go | 59 ++++++++++++------------------------------- bolt12/bech32_test.go | 54 ++++++++++++++++++++------------------- 2 files changed, 44 insertions(+), 69 deletions(-) diff --git a/bolt12/bech32.go b/bolt12/bech32.go index b868022546..a715f0b030 100644 --- a/bolt12/bech32.go +++ b/bolt12/bech32.go @@ -10,10 +10,8 @@ import ( ) var ( - // ErrStringTooLong is returned when a raw string is longer than - // maxBolt12RawStringLen or a cleaned string is longer than - // maxBolt12StringLen. It is also returned when a payload is larger - // than maxBolt12DataLen. + // ErrStringTooLong is returned when a payload is larger than + // maxBolt12DataLen. ErrStringTooLong = errors.New("input length exceeds limit") // ErrEmptyString is returned when a string has no characters. It is @@ -72,30 +70,19 @@ const ( maxPrintableASCII = 126 // bolt12HRPLen is the length of a BOLT 12 human-readable prefix. All - // three prefixes have it, so the limit below counts it as a fixed cost. + // three prefixes have it. bolt12HRPLen = 3 - // maxBolt12DataLen is the largest TLV stream that one BOLT 12 string - // can hold. The spec limits neither a field nor the stream, so the - // limit comes from this package: the P2P decoder rejects a record above - // tlv.MaxRecordSize. Eleven offer fields at that size give 704 - // kibibytes, and one mebibyte leaves room for unknown odd fields. Only - // an offer needs the room, because an invoice travels in a smaller - // onion message. + // maxBolt12DataLen is the largest TLV stream Encode emits. It is a + // writer policy, not a protocol rule: the spec limits neither a field + // nor the stream, and no other implementation caps either. The P2P + // decoder rejects a record above tlv.MaxRecordSize, eleven offer + // fields at that size give 704 kibibytes, and one mebibyte leaves room + // for unknown odd fields. Decode enforces no length limit, because a + // decode allocates on the order of its input and the input already + // exists in the caller's memory, so a limit there would reject a + // spec-valid message without protecting anything. maxBolt12DataLen = 1 << 20 - - // maxBolt12StringLen is the largest cleaned BOLT 12 bech32 string the - // codec accepts, once continuation markers and their whitespace are - // stripped. Each character of the data part holds 5 of the 8 bits of - // a payload byte. The limit therefore comes from maxBolt12DataLen. It - // counts the prefix, the separator, and one character for each group - // of 5 bits. Encode and Decode use the same limit, so every string - // that Encode makes is a string that Decode accepts. - maxBolt12StringLen = bolt12HRPLen + 1 + (maxBolt12DataLen*8+4)/5 - - // maxBolt12RawStringLen is the largest raw BOLT 12 string the codec - // accepts, continuation markers and whitespace included. - maxBolt12RawStringLen = 2 * maxBolt12StringLen ) // validHRPs holds the prefixes the BOLT 12 codec accepts, in the order the @@ -118,30 +105,16 @@ func unsupportedHRPError(hrp string) error { // Decode reads a BOLT 12 bech32 string. It returns the human-readable prefix // and the data bytes. A BOLT 12 string has no checksum. A '+' character can -// join two parts of the string, and whitespace can follow it. Decode rejects -// a raw string above maxBolt12RawStringLen and a cleaned string above -// maxBolt12StringLen, but the caller must set a smaller limit for its own -// medium. See the caller obligations in the package documentation. +// join two parts of the string, and whitespace can follow it. Decode enforces +// the BOLT 12 encoding rules and no length limit, so the caller bounds its own +// medium: the onion-message envelope bounds an invoice_request and an invoice, +// and the RPC or CLI bounds a pasted or scanned offer string. func Decode(s string) (string, []byte, error) { - if len(s) > maxBolt12RawStringLen { - return "", nil, fmt.Errorf( - "bolt12: %w: input length %d exceeds limit %d", - ErrStringTooLong, len(s), maxBolt12RawStringLen, - ) - } - cleaned, err := stripContinuation(s) if err != nil { return "", nil, err } - if len(cleaned) > maxBolt12StringLen { - return "", nil, fmt.Errorf( - "bolt12: %w: cleaned length %d exceeds limit %d", - ErrStringTooLong, len(cleaned), maxBolt12StringLen, - ) - } - if len(cleaned) == 0 { return "", nil, fmt.Errorf("bolt12: %w", ErrEmptyString) } diff --git a/bolt12/bech32_test.go b/bolt12/bech32_test.go index c1b8ed97f6..4b1d38d8e0 100644 --- a/bolt12/bech32_test.go +++ b/bolt12/bech32_test.go @@ -298,44 +298,46 @@ func TestDecodeUnprintableCharacter(t *testing.T) { } } -// TestDecodeOversizeInput asserts the input length cap fires before any -// allocation. -func TestDecodeOversizeInput(t *testing.T) { +// maxEncodedLen is the length of the string Encode makes from the largest +// payload it accepts: the prefix, the separator, and one character per group +// of five payload bits. +const maxEncodedLen = bolt12HRPLen + 1 + (maxBolt12DataLen*8+4)/5 + +// TestDecodeAcceptsAboveWriterLimit asserts Decode enforces no length limit of +// its own, so a string longer than anything Encode emits still decodes. The +// bound belongs to the caller's medium, not to the codec. +func TestDecodeAcceptsAboveWriterLimit(t *testing.T) { t.Parallel() - // A raw string above the transport limit is rejected. - huge := strings.Repeat("a", maxBolt12RawStringLen+1) - _, _, err := Decode(huge) - require.ErrorIs(t, err, ErrStringTooLong) - - // A string under the raw limit but over the cleaned limit is - // rejected after stripping. - oversize := strings.Repeat("a", maxBolt12StringLen+1) - _, _, err = Decode(oversize) - require.ErrorIs(t, err, ErrStringTooLong) - - // A string at the cleaned limit is accepted, but here leads to a - // parsing error. - oversize = strings.Repeat("a", maxBolt12StringLen) - _, _, err = Decode(oversize) - require.ErrorIs(t, err, ErrInvalidSeparator) + payload := make([]byte, maxBolt12DataLen) + encoded, err := Encode(HRPOffer, payload) + require.NoError(t, err) + + // Eight more data characters carry five more payload bytes, so the + // string and its payload both exceed what Encode would emit. + oversize := encoded + strings.Repeat("q", 8) + require.Greater(t, len(oversize), maxEncodedLen) + + hrp, data, err := Decode(oversize) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Greater(t, len(data), maxBolt12DataLen) } // TestDecodeWrappedMaxPayload asserts that a legal continuation wrapping of the -// longest string Encode can make still decodes. The cleaned limit governs the -// payload, and the raw limit leaves room for the wrapping. +// longest string Encode can make still decodes. func TestDecodeWrappedMaxPayload(t *testing.T) { t.Parallel() payload := make([]byte, maxBolt12DataLen) encoded, err := Encode(HRPOffer, payload) require.NoError(t, err) - require.Len(t, encoded, maxBolt12StringLen) + require.Len(t, encoded, maxEncodedLen) - // Insert a marker and a whitespace run into the data part. The raw - // string grows past the cleaned limit but stays under the raw one. + // Insert a marker and a whitespace run into the data part, so the raw + // string grows past the string Encode made. wrapped := encoded[:100] + "+ \n\t" + encoded[100:] - require.Greater(t, len(wrapped), maxBolt12StringLen) + require.Greater(t, len(wrapped), len(encoded)) hrp, data, err := Decode(wrapped) require.NoError(t, err) @@ -397,7 +399,7 @@ func TestEncodePayloadSize(t *testing.T) { { name: "longest payload", payload: make([]byte, maxBolt12DataLen), - wantLen: maxBolt12StringLen, + wantLen: maxEncodedLen, }, { name: "one byte above the longest payload", From 7465b809678e658094e4669aaf71f67efaa6e3ed Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:17:14 +0000 Subject: [PATCH 04/16] bolt12: add OfferHash over the offer TLV ranges The receiver identifies the offer an invoice request answers by hashing the request's offer fields and looking the result up in its store, so that lookup is the exact-match check the reader requirements ask for. The rule belongs in the codec, which owns the encoding and the range predicate, rather than in each caller. The hash covers the offer ranges of any pure-TLV message, so one function serves the offer, the invoice_request and the invoice. For an offer it equals the hash of the whole encoding, since every offer TLV already sits in those ranges. Finding F40. https://github.com/lightningnetwork/lnd/pull/10941#discussion_r3639973514 --- bolt12/offer.go | 5 +- bolt12/offer_hash.go | 39 +++++++++++ bolt12/offer_hash_test.go | 132 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 bolt12/offer_hash.go create mode 100644 bolt12/offer_hash_test.go diff --git a/bolt12/offer.go b/bolt12/offer.go index 7405322494..0f9bfa5a07 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -61,7 +61,8 @@ type Offer struct { // Handled types map to nil; unhandled types map to their value bytes. // Encoding and validation both derive their view from this single field // so they cannot drift apart, and so signed-range extras the decoder - // did not understand are re-emitted on encode and preserve offer_id. + // did not understand are re-emitted on encode and preserve the offer + // hash. decodedTLVs tlv.TypeMap } @@ -114,7 +115,7 @@ func (o *Offer) Encode() ([]byte, error) { // the spec writer requirements are not enforced here, so callers that need a // valid offer must run ValidateOfferRead. Unknown TLVs are preserved on the // returned offer so a later Encode can re-emit signed-range extras and keep -// offer_id stable. +// offer hash stable. func decodeOffer(data []byte) (*Offer, error) { var o Offer diff --git a/bolt12/offer_hash.go b/bolt12/offer_hash.go new file mode 100644 index 0000000000..821f540227 --- /dev/null +++ b/bolt12/offer_hash.go @@ -0,0 +1,39 @@ +package bolt12 + +import ( + "crypto/sha256" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +// OfferHash returns the offer hash a BOLT 12 message carries: the SHA-256 of +// its records in the offer TLV ranges. An offer hashes to its own offer hash, +// and an invoice_request or an invoice hashes to the offer hash of the offer it +// mirrors, so a receiver can find the offer a request answers. +// +// The hash covers a range rather than a set of known fields, so an unknown TLV +// in the offer range changes the hash. That is what makes a store lookup by +// offer hash the exact-match check the reader requirements ask for. +// +// The offer hash is a local store key, not an interop value: BOLT 12 defines +// no offer identifier, so each implementation picks its own. This construction +// is the one Core Lightning picked for its offer_id, and the two values agree +// byte for byte, which is why the Merkle root already in this package is not +// used here. LDK and eclair derive their offer id from that root instead, so +// those values differ for the same offer. +func OfferHash(m lnwire.PureTLVMessage) ([32]byte, error) { + var records []tlv.Record + for _, r := range m.AllRecords() { + if offerAllowedRange(r.Type()) { + records = append(records, r) + } + } + + encoded, err := lnwire.EncodeRecords(records) + if err != nil { + return [32]byte{}, err + } + + return sha256.Sum256(encoded), nil +} diff --git a/bolt12/offer_hash_test.go b/bolt12/offer_hash_test.go new file mode 100644 index 0000000000..45b4e959ce --- /dev/null +++ b/bolt12/offer_hash_test.go @@ -0,0 +1,132 @@ +package bolt12 + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestOfferHashMatchesOfferEncoding asserts that for an offer the offer hash is +// the hash of its whole encoding. Every offer TLV already sits in the hashed +// ranges, so the range filter is a no-op there. +func TestOfferHashMatchesOfferEncoding(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "Minimal bolt12 offer") + _, tlvBytes, err := Decode(vec.Bolt12) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + encoded, err := offer.Encode() + require.NoError(t, err) + + id, err := OfferHash(offer) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(encoded), id) +} + +// TestOfferHashCoversUnknownOfferRangeTLVs asserts that an unknown TLV in the +// offer range changes the id. A caller that rebuilt the offer from its known +// fields instead would compute the same id for both requests and accept a +// request whose offer fields differ from the offer. +func TestOfferHashCoversUnknownOfferRangeTLVs(t *testing.T) { + t.Parallel() + + _, pub := bobKey() + pubBytes := pub.SerializeCompressed() + + // Build two invoice requests that differ only by an unknown odd TLV in + // the offer range. + request := func(withUnknown bool) *InvoiceRequest { + var buf bytes.Buffer + appendRawRecord(t, &buf, 0, []byte("meta")) + appendRawRecord(t, &buf, 10, []byte("coffee")) + if withUnknown { + appendRawRecord(t, &buf, 13, []byte{0xde, 0xad}) + } + appendRawRecord(t, &buf, 22, pubBytes) + appendRawRecord(t, &buf, 88, pubBytes) + + ir, err := DecodeInvoiceRequest(buf.Bytes()) + require.NoError(t, err) + + return ir + } + + plain, err := OfferHash(request(false)) + require.NoError(t, err) + + withUnknown, err := OfferHash(request(true)) + require.NoError(t, err) + + require.NotEqual(t, plain, withUnknown) +} + +// TestOfferHashSkipsFieldsOutsideTheOfferRange asserts an invoice request +// mirroring an offer hashes to that offer's id, which is the lookup a receiver +// performs. The filter drops the payer's own fields, so a signature or an +// invreq field cannot move the id. +func TestOfferHashSkipsFieldsOutsideTheOfferRange(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "Minimal bolt12 offer") + _, tlvBytes, err := Decode(vec.Bolt12) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + want, err := OfferHash(offer) + require.NoError(t, err) + + priv, pub := bobKey() + ir, err := NewInvoiceRequestFromOffer( + offer, pub, []byte("meta"), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + ir.InvreqAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(1000)), + ) + _, err = SignInvoiceRequest(ir, priv) + require.NoError(t, err) + + got, err := OfferHash(ir) + require.NoError(t, err) + require.Equal(t, want, got) +} + +// TestOfferHashMatchesCoreLightning pins the offer hash against the offer Core +// Lightning publishes in its own RPC schema, together with the offer_id it +// reports for it. +func TestOfferHashMatchesCoreLightning(t *testing.T) { + t.Parallel() + + // Offer and expected offer_id taken from Core Lightning's + // doc/schemas/offer.json. + const ( + clnOffer = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlr" + + "m9deu7xyfzrcgqgn3qzs2ge5hx6pqwdskcefpzcssxwz9sqk" + + "jtd8qwnx06lxckvu6g8w8t0ue0zsrfqqygj636s4sw7v6" + + clnOfferHash = "c5cde0292d56941940f8b10a4c9bdd1f8846d6041a61b" + + "3f7e0f87e105aa88121" + ) + + _, tlvBytes, err := Decode(clnOffer) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + id, err := OfferHash(offer) + require.NoError(t, err) + + require.Equal(t, clnOfferHash, hex.EncodeToString(id[:])) +} From 99e0072c4ed5fc68c7126d5cf82710939e2f4ddf Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:28:41 +0000 Subject: [PATCH 05/16] bolt12: follow spec stanzas in offer validators The invoice_request and invoice validators quote each spec bullet above the check it authorises and run in the spec's order, so they can be read beside the spec block. The two offer validators, which landed before that convention, had none: 29 and 37 stanza lines now, against zero before. The writer moves two checks to reach spec order, the chains rule ahead of the amount rules and the nil-key guard down to the issuer stanza it belongs to. Every check is otherwise the same one, and no test expectation changed, so no input flipped to a different first error. Both functions now also state the rules the codec cannot enforce and why, which is how the gaps stay visible. Finding F38. --- bolt12/validate.go | 233 ++++++++++++++++++++++++++++++++------------- 1 file changed, 165 insertions(+), 68 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index 978c3ae00f..c67733d19c 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -968,15 +968,12 @@ func isKnownOfferTLVType(typ tlv.Type) bool { 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( - o.OfferIssuerID, "offer_issuer_id", - ); err != nil { - return err - } - // Check TLV types are in allowed range and that unknown even types are - // rejected (even = must-understand). + // - if the offer contains any TLV fields outside the inclusive ranges: + // 1 to 79 and 1000000000 to 1999999999: + // - MUST NOT respond to the offer. + // + // BOLT 1 adds that an unknown even type is must-understand. An offer + // carries no signature, so no range is exempt from either rule. for _, t := range sortedTypes(o.decodedTLVs) { if !offerAllowedRange(t) { return fmt.Errorf("%w: type %d", ErrOutOfRangeType, t) @@ -987,12 +984,28 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, } } - // Check for unknown even feature bits. + // - if offer_features contains unknown odd bits that are non-zero: + // - MUST ignore the bit. + // - if offer_features contains unknown even bits that are non-zero: + // - MUST NOT respond to the offer. + // - SHOULD indicate the unknown bit to the user. + // NOT CHECKED HERE: surfacing the bit to a user is the caller's, and + // the error names the offending bit for it. if err := checkFeatures(o.OfferFeatures, knownFeatures); err != nil { return err } - // offer_chains present but empty. + // - if offer_chains is not set: + // - if the node does not accept bitcoin invoices: + // - MUST NOT respond to the offer + // - otherwise (offer_chains is set): + // - if the node does not accept invoices for at least one of the + // chains: + // - MUST NOT respond to the offer + // + // A present-but-empty offer_chains lists no chain the node could + // accept, so it is rejected before the comparison. getOfferChains + // normalises an absent field to Bitcoin mainnet. var chainsEmpty bool o.OfferChains.WhenSome( func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) { @@ -1005,44 +1018,63 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, return ErrEmptyChains } - // Validate the offer's chain against the active chain. An absent - // offer_chains TLV means "Bitcoin mainnet" per spec, normalised by - // getOfferChains. - offerChains := getOfferChains(o) - found := slices.Contains(offerChains, activeChain) - if !found { + if !slices.Contains(getOfferChains(o), activeChain) { return ErrUnsupportedChain } - // offer_amount set requires offer_description. + // - if offer_amount is set and offer_description is not set: + // - MUST NOT respond to the offer. + // - if offer_amount is set and is not greater than zero: + // - MUST NOT respond to the offer. + // - if offer_currency is set and offer_amount is not set: + // - MUST NOT respond to the offer. hasAmount := o.OfferAmount.IsSome() if hasAmount && !o.OfferDescription.IsSome() { return ErrMissingDescription } - // offer_amount, if set, must be strictly greater than zero. if err := checkAmountPositive(o.OfferAmount); err != nil { return err } - // offer_currency requires offer_amount. if o.OfferCurrency.IsSome() && !hasAmount { return ErrCurrencyWithoutAmount } - // Must have either offer_issuer_id or offer_paths. + // - if neither offer_issuer_id nor offer_paths are set: + // - MUST NOT respond to the offer. + // + // A present-but-nil key passes IsSome but would panic the codec when + // used, so reject it before the presence rule. + if err := checkPubKeyNotNil( + o.OfferIssuerID, "offer_issuer_id", + ); err != nil { + return err + } + if !o.OfferIssuerID.IsSome() && !o.OfferPaths.IsSome() { return ErrNoIssuerIdentity } - // Check blinded paths have at least one hop. + // - if num_hops is 0 in any blinded_path in offer_paths: + // - MUST NOT respond to the offer. if err := checkBlindedPaths(o.OfferPaths); err != nil { return err } - // Expiry check. A present-but-zero offer_absolute_expiry is as a valid - // timestamp in the past, it doesn't have the special meaning of "no - // expiry". + // - if it uses offer_amount to provide the user with a cost estimate: + // - MUST take into account the currency units for offer_amount. + // - MUST warn the user if the received invoice_amount differs + // significantly from that estimate. + // NOT CHECKED HERE: the estimate and the warning belong to the caller, + // and a non-bitcoin currency needs an exchange rate the codec has no + // source for. + + // - if the current time is after offer_absolute_expiry: + // - MUST NOT respond to the offer. + // + // A present-but-zero offer_absolute_expiry is a valid timestamp in the + // past. It has no special "never expires" meaning. var ( expiry uint64 hasExpiry bool @@ -1057,7 +1089,14 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte, return ErrOfferExpired } - // Validate UTF-8 fields. + // - if it chooses to send an invoice request, it sends an onion + // message via offer_paths when set, otherwise to offer_issuer_id. + // NOT CHECKED HERE: sending is the caller's, and the path it used is + // the binding the payer later checks against invoice_node_id. + + // The spec states no encoding rule for the text fields, but a field + // that is not valid UTF-8 cannot be shown to a user or compared, and + // offer_currency has to parse as an ISO 4217 code to mean anything. if err := checkUTF8(o.OfferCurrency, "offer_currency"); err != nil { return err } @@ -1099,22 +1138,15 @@ func getOfferChains(o *Offer) [][32]byte { } // ValidateOfferWrite validates an offer per the BOLT 12 offer writer -// requirements. +// requirements, in the order the spec states them. func ValidateOfferWrite(o *Offer) error { - // A present-but-nil offer_issuer_id passes IsSome but would panic the - // codec on encode, so reject it here. - if err := checkPubKeyNotNil( - o.OfferIssuerID, "offer_issuer_id", - ); err != nil { - return err - } - - // Writer MUST NOT set TLV fields outside allowed ranges, and BOLT 1 - // makes an unknown even type must-understand. Both checks catch a - // decoded-then-mutated offer: a freshly-built struct has no - // decodedTLVs (Decode is the only writer of that field). The typed - // field set already excludes out-of-range types by construction, so a - // freshly-built offer cannot violate the range rule in the first place. + // - MUST NOT set any TLV fields outside the inclusive ranges: 1 to 79 + // and 1000000000 to 1999999999. + // + // BOLT 1 adds that an unknown even type is must-understand. Both + // checks catch a decoded-then-mutated offer: a freshly-built struct + // has no decodedTLVs (Decode is the only writer of that field), and + // the typed field set cannot express an out-of-range or unknown type. // The reader applies the same two rules in this order. for _, t := range sortedTypes(o.decodedTLVs) { if !offerAllowedRange(t) { @@ -1127,46 +1159,115 @@ func ValidateOfferWrite(o *Offer) error { } } - // offer_amount requires offer_description. - if o.OfferAmount.IsSome() && !o.OfferDescription.IsSome() { - return ErrMissingDescription + // - if the chain for the invoice is not solely bitcoin: + // - MUST specify offer_chains the offer is valid for. + // - otherwise: + // - SHOULD omit offer_chains, implying that bitcoin is only chain. + // NOT CHECKED HERE: which chain the writer settles on is caller + // context. A present-but-empty offer_chains says nothing, so mirror + // the reader and reject it. + var chainsEmpty bool + o.OfferChains.WhenSome( + func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) { + if len(r.Val.Chains) == 0 { + chainsEmpty = true + } + }, + ) + if chainsEmpty { + return ErrEmptyChains } - // offer_amount, if set, must be strictly greater than zero. + // - if a specific minimum offer_amount is required for successful + // payment: + // - MUST set offer_amount to the amount expected (per item). + // - MUST set offer_amount greater than zero. + // - if the currency for offer_amount is that of all entries in + // chains: + // - MUST specify offer_amount in multiples of the minimum + // lightning-payable unit. + // - otherwise: + // - MUST specify offer_currency iso4217 as an ISO 4217 + // three-letter code. + // - MUST specify offer_amount in the currency unit adjusted by the + // ISO 4217 exponent. + // - MUST set offer_description to a complete description of the + // purpose of the payment. + // - otherwise: + // - MUST NOT set offer_amount + // - MUST NOT set offer_currency + // - MAY set offer_description + // NOT CHECKED HERE: the unit of a bitcoin amount is trivially + // satisfied in msat, and the ISO 4217 exponent needs the currency's + // own scale. if err := checkAmountPositive(o.OfferAmount); err != nil { return err } - // offer_currency requires offer_amount. + if o.OfferAmount.IsSome() && !o.OfferDescription.IsSome() { + return ErrMissingDescription + } + if o.OfferCurrency.IsSome() && !o.OfferAmount.IsSome() { return ErrCurrencyWithoutAmount } - // Without offer_paths, MUST set offer_issuer_id. - if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() { - return ErrNoIssuerIdentity + if err := checkISO4217(o.OfferCurrency); err != nil { + return err } - // Defense in depth: writer-side mirrors of reader rejections for - // present-but-empty offer_chains and offer_paths. - var chainsEmpty bool - o.OfferChains.WhenSome( - func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) { - if len(r.Val.Chains) == 0 { - chainsEmpty = true - } - }, - ) - if chainsEmpty { - return ErrEmptyChains - } + // - MAY set offer_metadata for its own use. + // - if it supports bolt12 offer features: + // - MUST set offer_features.features to the bitmap of bolt12 + // features. + // - if the offer expires: + // - MUST set offer_absolute_expiry seconds_from_epoch. + // NOT CHECKED HERE: all three are the writer's own decisions, with no + // state the codec could contradict. + // - if it is connected only by private channels: + // - MUST include offer_paths containing one or more paths to the + // node from publicly reachable nodes. + // - otherwise: + // - MAY include offer_paths. + // NOT CHECKED HERE: connectivity is caller context. A path with no + // hops cannot carry a message, so mirror the reader and reject it. if err := checkBlindedPaths(o.OfferPaths); err != nil { return err } - // Defense in depth: writer-side mirrors of the reader UTF-8 checks - // for offer_currency, offer_description, and offer_issuer. + // - if it includes offer_paths: + // - MAY set offer_issuer_id. + // - otherwise: + // - MUST set offer_issuer_id to the node's public key to request the + // invoice from. + // + // A present-but-nil key passes IsSome but would panic the codec on + // encode, so reject it before the presence rule. + if err := checkPubKeyNotNil( + o.OfferIssuerID, "offer_issuer_id", + ); err != nil { + return err + } + + if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() { + return ErrNoIssuerIdentity + } + + // - if it sets offer_issuer: + // - SHOULD set it to identify the issuer of the invoice clearly. + // - if it can supply more than one item for a single invoice: + // - MUST set offer_quantity_max, and MUST NOT set it to 0 when the + // maximum is known. + // - otherwise: + // - MUST NOT set offer_quantity_max. + // NOT CHECKED HERE: both describe the writer's own inventory and + // naming, which the codec cannot see. offer_quantity_max carries a + // three-state meaning (absent, zero for unlimited, a bound), so no + // value of it is invalid on its own. + + // Defense in depth: the reader rejects a non-UTF-8 text field, so the + // writer does not emit one. if err := checkUTF8(o.OfferCurrency, "offer_currency"); err != nil { return err } @@ -1181,10 +1282,6 @@ func ValidateOfferWrite(o *Offer) error { return err } - if err := checkISO4217(o.OfferCurrency); err != nil { - return err - } - return nil } From 4c3cb7fc5dd6686f9e98387cb4620e1b2a824d09 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 11 Sep 2026 10:47:16 +0000 Subject: [PATCH 06/16] bolt12: add an unvalidated invoice string decode DecodeInvoiceString folds the reader gates into the decode, so a caller that only wants to display an invoice it already validated when it stored it has no entry point. Such a caller had to reach for the raw bech32 decode and parse the bytes itself, which accepts any of the three prefixes. DecodeInvoiceStringUnvalidated pins the lni prefix and skips the gates. It is the one legitimate raw-decode caller, so the bech32 primitives can leave the API next. DecodeInvoiceString now delegates to it and adds the gates on top, so the prefix check and the TLV decode have one home rather than two copies. Finding F22. --- bolt12/invoice.go | 31 +++++++++++++-------- bolt12/invoice_string_test.go | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 bolt12/invoice_string_test.go diff --git a/bolt12/invoice.go b/bolt12/invoice.go index a27db95d65..51a9d3bbb2 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -390,6 +390,25 @@ func DecodeInvoice(data []byte) (*Invoice, error) { return &inv, nil } +// DecodeInvoiceStringUnvalidated decodes a BOLT 12 invoice from its bech32 +// string representation (lni1...) without running the reader gates. It exists +// for displaying an invoice that was already validated when it was stored, +// such as one read back from a database column. Every other caller wants +// DecodeInvoiceString. +func DecodeInvoiceStringUnvalidated(s string) (*Invoice, error) { + hrp, tlvBytes, err := Decode(s) + if err != nil { + return nil, fmt.Errorf("bech32: %w", err) + } + + if hrp != HRPInvoice { + return nil, fmt.Errorf("expected HRP %q, got %q", + HRPInvoice, hrp) + } + + return DecodeInvoice(tlvBytes) +} + // DecodeInvoiceString decodes a BOLT 12 invoice from its bech32 string // representation (lni1...). The spec reader gates (chain, features, signature) // are folded in via ValidateInvoiceRead, and the expiry gate is enforced via @@ -402,17 +421,7 @@ func DecodeInvoice(data []byte) (*Invoice, error) { func DecodeInvoiceString(s string, now time.Time, activeChain [32]byte) (*Invoice, error) { - hrp, tlvBytes, err := Decode(s) - if err != nil { - return nil, fmt.Errorf("bech32: %w", err) - } - - if hrp != HRPInvoice { - return nil, fmt.Errorf("expected HRP %q, got %q", - HRPInvoice, hrp) - } - - inv, err := DecodeInvoice(tlvBytes) + inv, err := DecodeInvoiceStringUnvalidated(s) if err != nil { return nil, err } diff --git a/bolt12/invoice_string_test.go b/bolt12/invoice_string_test.go new file mode 100644 index 0000000000..649c05b8f9 --- /dev/null +++ b/bolt12/invoice_string_test.go @@ -0,0 +1,51 @@ +package bolt12 + +import ( + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestDecodeInvoiceStringUnvalidated asserts the display entry point skips the +// reader gates but still pins the prefix, so an offer string cannot be read +// back as an invoice. +func TestDecodeInvoiceStringUnvalidated(t *testing.T) { + t.Parallel() + + // An invoice that the reader would reject, here for a chain the node + // does not accept, still decodes for display. + inv := validInvoice(t) + + var altChain [32]byte + for i := range altChain { + altChain[i] = 0xaa + } + inv.InvreqChain = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80](altChain), + ) + + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + + encoded, err := EncodeInvoiceString(inv) + require.NoError(t, err) + + _, err = DecodeInvoiceString( + encoded, farFutureNow(), bitcoinMainnetGenesisHash, + ) + require.ErrorIs(t, err, ErrUnsupportedChain) + + decoded, err := DecodeInvoiceStringUnvalidated(encoded) + require.NoError(t, err) + require.Equal(t, altChain, decoded.InvreqChain.UnwrapOrFailV(t)) + + // The prefix still has to be an invoice one. + offerStr := findTestVector(t, "Minimal bolt12 offer").Bolt12 + _, err = DecodeInvoiceStringUnvalidated(offerStr) + require.ErrorContains(t, err, "expected HRP") +} From 9be2c5ba6a52053ce9884eac8de58398879d0ad6 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 24 Sep 2026 12:05:10 +0000 Subject: [PATCH 07/16] bolt12: un-export the bech32 primitives Decode and Encode become decodeBech32 and encodeBech32. The bare names read like the message codec they sit next to, and no caller outside the package needs them: the string entry points fold bech32 into a validated call, and a caller that wants a raw decode has DecodeInvoiceStringUnvalidated. The rename is mechanical. Four comments in bech32.go named the old symbols, so they name the new ones now. The encoder docstring also stops claiming its payload bound mirrors a reader limit, because the reader no longer has one. --- bolt12/bech32.go | 36 ++++++++++++++++----------------- bolt12/bech32_test.go | 42 +++++++++++++++++++-------------------- bolt12/fuzz_test.go | 6 +++--- bolt12/invoice.go | 4 ++-- bolt12/invoice_request.go | 4 ++-- bolt12/merkle_test.go | 2 +- bolt12/offer.go | 4 ++-- bolt12/offer_hash_test.go | 6 +++--- bolt12/offer_test.go | 2 +- bolt12/signature_test.go | 4 ++-- bolt12/validate_test.go | 2 +- 11 files changed, 56 insertions(+), 56 deletions(-) diff --git a/bolt12/bech32.go b/bolt12/bech32.go index a715f0b030..ec0cdfe3e4 100644 --- a/bolt12/bech32.go +++ b/bolt12/bech32.go @@ -73,15 +73,15 @@ const ( // three prefixes have it. bolt12HRPLen = 3 - // maxBolt12DataLen is the largest TLV stream Encode emits. It is a - // writer policy, not a protocol rule: the spec limits neither a field - // nor the stream, and no other implementation caps either. The P2P - // decoder rejects a record above tlv.MaxRecordSize, eleven offer - // fields at that size give 704 kibibytes, and one mebibyte leaves room - // for unknown odd fields. Decode enforces no length limit, because a - // decode allocates on the order of its input and the input already - // exists in the caller's memory, so a limit there would reject a - // spec-valid message without protecting anything. + // maxBolt12DataLen is the largest TLV stream encodeBech32 emits. It + // is a writer policy, not a protocol rule: the spec limits neither a + // field nor the stream, and no other implementation caps either. The + // P2P decoder rejects a record above tlv.MaxRecordSize, eleven offer + // fields at that size give 704 kibibytes, and one mebibyte leaves + // room for unknown odd fields. decodeBech32 enforces no length limit, + // because a decode allocates on the order of its input and the input + // already exists in the caller's memory, so a limit there would + // reject a spec-valid message without protecting anything. maxBolt12DataLen = 1 << 20 ) @@ -103,13 +103,13 @@ func unsupportedHRPError(hrp string) error { ) } -// Decode reads a BOLT 12 bech32 string. It returns the human-readable prefix -// and the data bytes. A BOLT 12 string has no checksum. A '+' character can -// join two parts of the string, and whitespace can follow it. Decode enforces +// decodeBech32 reads a BOLT 12 bech32 string. It returns the human-readable +// prefix and the data bytes. A BOLT 12 string has no checksum. A '+' character +// can join two parts of the string, and whitespace can follow it. It enforces // the BOLT 12 encoding rules and no length limit, so the caller bounds its own // medium: the onion-message envelope bounds an invoice_request and an invoice, // and the RPC or CLI bounds a pasted or scanned offer string. -func Decode(s string) (string, []byte, error) { +func decodeBech32(s string) (string, []byte, error) { cleaned, err := stripContinuation(s) if err != nil { return "", nil, err @@ -156,11 +156,11 @@ func Decode(s string) (string, []byte, error) { return hrp, data8bit, nil } -// Encode makes a BOLT 12 bech32 string from the data bytes and the given +// encodeBech32 makes a BOLT 12 bech32 string from the data bytes and the given // human-readable prefix. It adds no checksum. It changes the prefix to -// lowercase and takes only lno, lnr, and lni. The payload size must be a size -// that Decode also takes, so a caller can make only strings that Decode reads. -func Encode(hrp string, data []byte) (string, error) { +// lowercase and takes only lno, lnr, and lni. It refuses a payload above +// maxBolt12DataLen, a writer policy the reader does not mirror. +func encodeBech32(hrp string, data []byte) (string, error) { hrp = strings.ToLower(hrp) if !isValidHRP(hrp) { return "", unsupportedHRPError(hrp) @@ -168,7 +168,7 @@ func Encode(hrp string, data []byte) (string, error) { // A BOLT 12 string holds a TLV stream, and the stream must hold at // least one record. An empty payload gives a string with only the - // prefix and the separator, which Decode rejects. + // prefix and the separator, which decodeBech32 rejects. if len(data) == 0 { return "", fmt.Errorf( "bolt12: %w: nothing to encode", ErrEmptyString, diff --git a/bolt12/bech32_test.go b/bolt12/bech32_test.go index 4b1d38d8e0..b9b5b1b492 100644 --- a/bolt12/bech32_test.go +++ b/bolt12/bech32_test.go @@ -22,7 +22,7 @@ func TestBech32FormatStringVectors(t *testing.T) { t.Run(tc.Comment, func(t *testing.T) { t.Parallel() - hrp, decoded, err := Decode(tc.String) + hrp, decoded, err := decodeBech32(tc.String) if !tc.Valid { require.Error(t, err, "expected error for: %s", @@ -37,10 +37,10 @@ func TestBech32FormatStringVectors(t *testing.T) { require.NotEmpty(t, decoded) // Round-trip: re-encode and decode again. - encoded, err := Encode(hrp, decoded) + encoded, err := encodeBech32(hrp, decoded) require.NoError(t, err) - hrp2, decoded2, err := Decode(encoded) + hrp2, decoded2, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, hrp2) require.Equal(t, decoded, decoded2) @@ -59,11 +59,11 @@ func TestBech32RoundTrip(t *testing.T) { t.Run(hrp, func(t *testing.T) { t.Parallel() - encoded, err := Encode(hrp, testData) + encoded, err := encodeBech32(hrp, testData) require.NoError(t, err) require.True(t, len(encoded) > len(hrp)+1) - gotHRP, gotData, err := Decode(encoded) + gotHRP, gotData, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, gotHRP) require.Equal(t, testData, gotData) @@ -105,7 +105,7 @@ func TestBech32DecodeErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, _, err := Decode(tc.input) + _, _, err := decodeBech32(tc.input) require.Error(t, err) }) } @@ -239,13 +239,13 @@ func TestDecodeContinuationAnywhere(t *testing.T) { t.Parallel() payload := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) for i := 1; i < len(encoded); i++ { split := encoded[:i] + "+" + encoded[i:] - hrp, data, err := Decode(split) + hrp, data, err := decodeBech32(split) require.NoError(t, err, "marker at position %d", i) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -259,7 +259,7 @@ func TestDecodeContinuationAnywhere(t *testing.T) { func TestEncodeUnknownHRP(t *testing.T) { t.Parallel() - _, err := Encode("bogus", []byte{0x00}) + _, err := encodeBech32("bogus", []byte{0x00}) require.ErrorIs(t, err, ErrUnsupportedHRP) for _, hrp := range validHRPs { @@ -272,7 +272,7 @@ func TestEncodeUnknownHRP(t *testing.T) { func TestDecodeUnknownHRP(t *testing.T) { t.Parallel() - _, _, err := Decode("bogus1pqps7sjq") + _, _, err := decodeBech32("bogus1pqps7sjq") require.ErrorIs(t, err, ErrUnsupportedHRP) } @@ -293,7 +293,7 @@ func TestDecodeUnprintableCharacter(t *testing.T) { } for _, input := range unprintable { - _, _, err := Decode(input) + _, _, err := decodeBech32(input) require.ErrorIs(t, err, ErrInvalidCharacter) } } @@ -310,7 +310,7 @@ func TestDecodeAcceptsAboveWriterLimit(t *testing.T) { t.Parallel() payload := make([]byte, maxBolt12DataLen) - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) // Eight more data characters carry five more payload bytes, so the @@ -318,7 +318,7 @@ func TestDecodeAcceptsAboveWriterLimit(t *testing.T) { oversize := encoded + strings.Repeat("q", 8) require.Greater(t, len(oversize), maxEncodedLen) - hrp, data, err := Decode(oversize) + hrp, data, err := decodeBech32(oversize) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Greater(t, len(data), maxBolt12DataLen) @@ -330,7 +330,7 @@ func TestDecodeWrappedMaxPayload(t *testing.T) { t.Parallel() payload := make([]byte, maxBolt12DataLen) - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) require.Len(t, encoded, maxEncodedLen) @@ -339,7 +339,7 @@ func TestDecodeWrappedMaxPayload(t *testing.T) { wrapped := encoded[:100] + "+ \n\t" + encoded[100:] require.Greater(t, len(wrapped), len(encoded)) - hrp, data, err := Decode(wrapped) + hrp, data, err := decodeBech32(wrapped) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -412,7 +412,7 @@ func TestEncodePayloadSize(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - encoded, err := Encode(HRPOffer, tc.payload) + encoded, err := encodeBech32(HRPOffer, tc.payload) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) @@ -425,7 +425,7 @@ func TestEncodePayloadSize(t *testing.T) { } // Decode takes each string that Encode makes. - hrp, data, err := Decode(encoded) + hrp, data, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Equal(t, tc.payload, data) @@ -440,13 +440,13 @@ func TestDecodeUppercase(t *testing.T) { t.Parallel() payload := []byte{0x01, 0x23, 0x45, 0x67} - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) uppered := strings.ToUpper(encoded) require.NotEqual(t, encoded, uppered) - hrp, data, err := Decode(uppered) + hrp, data, err := decodeBech32(uppered) require.NoError(t, err) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -474,10 +474,10 @@ func TestPropertyBech32RoundTrip(t *testing.T) { rapid.Byte(), size, size, ).Draw(t, "data") - encoded, err := Encode(hrp, data) + encoded, err := encodeBech32(hrp, data) require.NoError(t, err) - decodedHRP, decodedData, err := Decode(encoded) + decodedHRP, decodedData, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, decodedHRP) require.Equal(t, data, decodedData) diff --git a/bolt12/fuzz_test.go b/bolt12/fuzz_test.go index 35f18fb675..f3bc741afd 100644 --- a/bolt12/fuzz_test.go +++ b/bolt12/fuzz_test.go @@ -50,7 +50,7 @@ func tlvStreams(t testing.TB, strings []string) [][]byte { var seeds [][]byte for _, s := range strings { - _, tlvBytes, err := Decode(s) + _, tlvBytes, err := decodeBech32(s) if err != nil { continue } @@ -233,12 +233,12 @@ func FuzzBech32RoundTrip(f *testing.F) { } hrp := hrps[int(hrpIdx)%len(hrps)] - encoded, err := Encode(hrp, data) + encoded, err := encodeBech32(hrp, data) if err != nil { return } - gotHRP, gotData, err := Decode(encoded) + gotHRP, gotData, err := decodeBech32(encoded) if err != nil { t.Fatalf( "decode after successful encode "+ diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 51a9d3bbb2..7c95fcc886 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -396,7 +396,7 @@ func DecodeInvoice(data []byte) (*Invoice, error) { // such as one read back from a database column. Every other caller wants // DecodeInvoiceString. func DecodeInvoiceStringUnvalidated(s string) (*Invoice, error) { - hrp, tlvBytes, err := Decode(s) + hrp, tlvBytes, err := decodeBech32(s) if err != nil { return nil, fmt.Errorf("bech32: %w", err) } @@ -459,7 +459,7 @@ func EncodeInvoiceString(inv *Invoice) (string, error) { return "", err } - return Encode(HRPInvoice, tlvBytes) + return encodeBech32(HRPInvoice, tlvBytes) } // NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 65a3f035dd..2152b07a31 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -244,7 +244,7 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) { func DecodeInvoiceRequestString(s string, activeChain [32]byte) (*InvoiceRequest, error) { - hrp, tlvBytes, err := Decode(s) + hrp, tlvBytes, err := decodeBech32(s) if err != nil { return nil, fmt.Errorf("bech32: %w", err) } @@ -287,7 +287,7 @@ func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { return "", err } - return Encode(HRPInvoiceRequest, tlvBytes) + return encodeBech32(HRPInvoiceRequest, tlvBytes) } // NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying diff --git a/bolt12/merkle_test.go b/bolt12/merkle_test.go index c66e778e8b..934f3476de 100644 --- a/bolt12/merkle_test.go +++ b/bolt12/merkle_test.go @@ -30,7 +30,7 @@ func TestMerkleRootVectors(t *testing.T) { // Decode the bech32 string to get TLV bytes, // then convert into the record view merkleRoot // consumes. - _, tlvBytes, err := Decode(tc.Bolt12) + _, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) records = streamToRecords(t, tlvBytes) diff --git a/bolt12/offer.go b/bolt12/offer.go index 0f9bfa5a07..06eccf7997 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -174,7 +174,7 @@ func decodeOffer(data []byte) (*Offer, error) { func DecodeOfferString(s string, now time.Time, activeChain [32]byte) (*Offer, error) { - hrp, tlvBytes, err := Decode(s) + hrp, tlvBytes, err := decodeBech32(s) if err != nil { return nil, fmt.Errorf("bech32: %w", err) } @@ -206,5 +206,5 @@ func EncodeOfferString(o *Offer) (string, error) { return "", err } - return Encode(HRPOffer, tlvBytes) + return encodeBech32(HRPOffer, tlvBytes) } diff --git a/bolt12/offer_hash_test.go b/bolt12/offer_hash_test.go index 45b4e959ce..1dcea18ad9 100644 --- a/bolt12/offer_hash_test.go +++ b/bolt12/offer_hash_test.go @@ -17,7 +17,7 @@ func TestOfferHashMatchesOfferEncoding(t *testing.T) { t.Parallel() vec := findTestVector(t, "Minimal bolt12 offer") - _, tlvBytes, err := Decode(vec.Bolt12) + _, tlvBytes, err := decodeBech32(vec.Bolt12) require.NoError(t, err) offer, err := decodeOffer(tlvBytes) @@ -76,7 +76,7 @@ func TestOfferHashSkipsFieldsOutsideTheOfferRange(t *testing.T) { t.Parallel() vec := findTestVector(t, "Minimal bolt12 offer") - _, tlvBytes, err := Decode(vec.Bolt12) + _, tlvBytes, err := decodeBech32(vec.Bolt12) require.NoError(t, err) offer, err := decodeOffer(tlvBytes) @@ -119,7 +119,7 @@ func TestOfferHashMatchesCoreLightning(t *testing.T) { "3f7e0f87e105aa88121" ) - _, tlvBytes, err := Decode(clnOffer) + _, tlvBytes, err := decodeBech32(clnOffer) require.NoError(t, err) offer, err := decodeOffer(tlvBytes) diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 509d5e34d0..bb00483046 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -140,7 +140,7 @@ func TestDecodeMinimalOfferString(t *testing.T) { offerStr := "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49p" + "x20665dqwmn4p72pksese" - _, tlvBytes, err := Decode(offerStr) + _, tlvBytes, err := decodeBech32(offerStr) require.NoError(t, err) offer, err := decodeOffer(tlvBytes) diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go index f7f438e6b9..6305f1abb8 100644 --- a/bolt12/signature_test.go +++ b/bolt12/signature_test.go @@ -54,7 +54,7 @@ func verifyInvoiceRequestSigVector(t *testing.T, tc sigTestVector, // Decode the bech32 string and convert the TLV bytes into the record // view merkleRoot consumes. - _, tlvBytes, err := Decode(tc.Bolt12) + _, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) records := streamToRecords(t, tlvBytes) @@ -128,7 +128,7 @@ func TestVerifyInvoiceRequestVector(t *testing.T) { } require.NotEmpty(t, tc.Bolt12) - hrp, tlvBytes, err := Decode(tc.Bolt12) + hrp, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) require.Equal(t, "lnr", hrp) diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index e35967aaba..fe88773726 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -3363,7 +3363,7 @@ func TestValidateOfferReadVectors(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { t.Parallel() - _, tlvBytes, bech32Err := Decode(tc.Bolt12) + _, tlvBytes, bech32Err := decodeBech32(tc.Bolt12) if bech32Err != nil { if tc.Valid { require.NoError( From 69078cb8f867bdbad63a989baaf05a159901e1ac Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 11 Sep 2026 10:47:38 +0000 Subject: [PATCH 08/16] bolt12: un-export the verifiers and validators The verifiers go, because the readers call them and nothing verifies a signature on its own. The writer validators go, because Encode is the gate and already runs them. The reader validators that an exported entry point folds in go too: DecodeOfferString for the offer, and ValidateInvoiceForPayment for the invoice against its request. What stays exported is what a caller that reaches the codec over an onion message needs, because an invoice_request and an invoice arrive as raw TLV rather than as strings. Finding F22. --- bolt12/invoice.go | 20 +++---- bolt12/invoice_error.go | 10 ++-- bolt12/invoice_request.go | 6 +-- bolt12/invoice_test.go | 8 +-- bolt12/offer.go | 10 ++-- bolt12/signature.go | 10 ++-- bolt12/signature_test.go | 10 ++-- bolt12/validate.go | 78 ++++++++++++++------------- bolt12/validate_test.go | 110 +++++++++++++++++++------------------- 9 files changed, 132 insertions(+), 130 deletions(-) diff --git a/bolt12/invoice.go b/bolt12/invoice.go index 7c95fcc886..c7d6931dcc 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -21,7 +21,7 @@ import ( 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. + // match is enforced by validateInvoiceAgainstRequest. // InvreqMetadata is the payer metadata. InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob] @@ -209,7 +209,7 @@ type UsablePath struct { // 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 +// 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( @@ -219,7 +219,7 @@ func (inv *Invoice) UsablePaths( 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. + // validateInvoiceRead, so guard here to stay in bounds. if len(paths.Paths) != len(bp.Infos) { return nil } @@ -287,7 +287,7 @@ func (inv *Invoice) allRecordProducers() []tlv.RecordProducer { // Encode validates the invoice per writer requirements and serialises it via // the PureTLVMessage shape. func (inv *Invoice) Encode() ([]byte, error) { - if err := ValidateInvoiceWrite(inv); err != nil { + if err := validateInvoiceWrite(inv); err != nil { return nil, fmt.Errorf("validate invoice: %w", err) } @@ -300,7 +300,7 @@ func (inv *Invoice) Encode() ([]byte, error) { } // DecodeInvoice deserializes an invoice from a TLV byte stream. Decoding is -// permissive: callers that need spec compliance must run ValidateInvoiceRead. +// permissive: callers that need spec compliance must run validateInvoiceRead. func DecodeInvoice(data []byte) (*Invoice, error) { var inv Invoice @@ -411,8 +411,8 @@ func DecodeInvoiceStringUnvalidated(s string) (*Invoice, error) { // DecodeInvoiceString decodes a BOLT 12 invoice from its bech32 string // representation (lni1...). The spec reader gates (chain, features, signature) -// are folded in via ValidateInvoiceRead, and the expiry gate is enforced via -// ValidateInvoiceExpiry. +// are folded in via validateInvoiceRead, and the expiry gate is enforced via +// validateInvoiceExpiry. // // These gates check the invoice against itself. An invoice that answers an // invoice_request needs two further bindings that the message alone cannot @@ -430,11 +430,11 @@ func DecodeInvoiceString(s string, now time.Time, Invoice: Bolt12Features, Blinded: Bolt12Features, } - if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + if err := validateInvoiceRead(inv, activeChain, features); err != nil { return nil, fmt.Errorf("validate: %w", err) } - if err := ValidateInvoiceExpiry(inv, now); err != nil { + if err := validateInvoiceExpiry(inv, now); err != nil { return nil, fmt.Errorf("validate: %w", err) } @@ -455,7 +455,7 @@ func EncodeInvoiceString(inv *Invoice) (string, error) { return "", err } - if err := VerifyInvoice(inv); err != nil { + if err := verifyInvoice(inv); err != nil { return "", err } diff --git a/bolt12/invoice_error.go b/bolt12/invoice_error.go index 80bcd72774..c882658105 100644 --- a/bolt12/invoice_error.go +++ b/bolt12/invoice_error.go @@ -47,12 +47,12 @@ func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer { // 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. +// NOTE: 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 { + if err := validateInvoiceErrorWrite(ie); err != nil { return nil, fmt.Errorf("validate invoice error: %w", err) } diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 2152b07a31..e156260a3c 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -153,7 +153,7 @@ func (ir *InvoiceRequest) allRecordProducers() []tlv.RecordProducer { // Encode validates the invoice request per writer requirements and serialises // it via the PureTLVMessage shape. func (ir *InvoiceRequest) Encode() ([]byte, error) { - if err := ValidateInvoiceRequestWrite(ir); err != nil { + if err := validateInvoiceRequestWrite(ir); err != nil { return nil, fmt.Errorf("validate invoice request: %w", err) } @@ -283,7 +283,7 @@ func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { return "", err } - if err := VerifyInvoiceRequest(ir); err != nil { + if err := verifyInvoiceRequest(ir); err != nil { return "", err } @@ -297,7 +297,7 @@ func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { // Per "MUST copy all fields from the offer (including unknown fields)", the // offer's unknown TLVs are carried via the decodedTLVs sidecar so they are // signed and mirrored into the invoice. Note that because unknown even TLV -// types in the offer would have already been rejected by ValidateOfferRead, any +// types in the offer would have already been rejected by validateOfferRead, any // unknown TLVs mirrored here are guaranteed to be unknown odd TLVs ("it's ok to // be odd") which are safe to ignore and carry forward. // diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index e3369b4148..9bd1e5d014 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -11,7 +11,7 @@ import ( ) // validInvoice returns an Invoice populated with the minimum set of fields -// required to satisfy ValidateInvoiceWrite. +// required to satisfy validateInvoiceWrite. func validInvoice(t testing.TB) *Invoice { t.Helper() @@ -172,7 +172,7 @@ func TestUsablePaths(t *testing.T) { require.Equal(t, uint32(2), got[1].PayInfo.FeeBaseMsat) // A length mismatch between paths and payinfos yields no usable paths - // (rejected upstream by ValidateInvoiceRead). + // (rejected upstream by validateInvoiceRead). inv.InvoiceBlindedPay = payRecord(BlindedPayInfo{}) require.Empty(t, inv.UsablePaths(known)) } @@ -334,7 +334,7 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { decoded, err := DecodeInvoice(encoded) require.NoError(t, err) - err = ValidateInvoiceRead( + err = validateInvoiceRead( decoded, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{ Invoice: Bolt12Features, @@ -494,7 +494,7 @@ func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) { } // TestInvoiceEncodeValidationGate verifies that Encode runs -// ValidateInvoiceWrite and rejects invalid invoices. +// validateInvoiceWrite and rejects invalid invoices. func TestInvoiceEncodeValidationGate(t *testing.T) { t.Parallel() diff --git a/bolt12/offer.go b/bolt12/offer.go index 06eccf7997..fd503b376f 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -99,7 +99,7 @@ func (o *Offer) allRecordProducers() []tlv.RecordProducer { // Encode serialises the offer into a canonical TLV byte stream. func (o *Offer) Encode() ([]byte, error) { - if err := ValidateOfferWrite(o); err != nil { + if err := validateOfferWrite(o); err != nil { return nil, fmt.Errorf("validate offer: %w", err) } @@ -113,8 +113,8 @@ func (o *Offer) Encode() ([]byte, error) { // decodeOffer parses a TLV byte stream into an Offer. Decoding is permissive — // the spec writer requirements are not enforced here, so callers that need a -// valid offer must run ValidateOfferRead. Unknown TLVs are preserved on the -// returned offer so a later Encode can re-emit signed-range extras and keep +// valid offer must run validateOfferRead. Unknown TLVs are preserved on the +// returned offer so a later encode can re-emit signed-range extras and keep // offer hash stable. func decodeOffer(data []byte) (*Offer, error) { var o Offer @@ -170,7 +170,7 @@ func decodeOffer(data []byte) (*Offer, error) { // DecodeOfferString decodes a BOLT 12 offer from its bech32 string // representation (lno1...). The spec reader gates (chain, expiry, features) are -// folded in via ValidateOfferRead. +// folded in via validateOfferRead. func DecodeOfferString(s string, now time.Time, activeChain [32]byte) (*Offer, error) { @@ -189,7 +189,7 @@ func DecodeOfferString(s string, now time.Time, return nil, err } - if err := ValidateOfferRead( + if err := validateOfferRead( offer, now, activeChain, Bolt12Features, ); err != nil { return nil, fmt.Errorf("validate: %w", err) diff --git a/bolt12/signature.go b/bolt12/signature.go index c4dda72418..83fa471053 100644 --- a/bolt12/signature.go +++ b/bolt12/signature.go @@ -25,7 +25,7 @@ const ( tagFieldSignature = "signature" ) -// ErrInvalidSignature is returned by VerifyInvoice and VerifyInvoiceRequest +// ErrInvalidSignature is returned by verifyInvoice and verifyInvoiceRequest // when the BIP-340 Schnorr signature does not validate against the message's // Merkle root and signing key. var ErrInvalidSignature = errors.New("BOLT 12 signature is invalid") @@ -102,9 +102,9 @@ func SignInvoiceRequest(ir *InvoiceRequest, privKey *btcec.PrivateKey) ( ) } -// VerifyInvoiceRequest verifies the signature on an invoice request using its +// verifyInvoiceRequest verifies the signature on an invoice request using its // invreq_payer_id public key. -func VerifyInvoiceRequest(ir *InvoiceRequest) error { +func verifyInvoiceRequest(ir *InvoiceRequest) error { pubKey, err := ir.InvreqPayerID.UnwrapOrErrV(ErrMissingPayerID) if err != nil { return err @@ -144,9 +144,9 @@ func SignInvoice(inv *Invoice, privKey *btcec.PrivateKey) ([64]byte, error) { return signMessage(tagMsgInvoice, tagFieldSignature, root, privKey) } -// VerifyInvoice verifies the signature on an invoice using its invoice_node_id +// verifyInvoice verifies the signature on an invoice using its invoice_node_id // public key. -func VerifyInvoice(inv *Invoice) error { +func verifyInvoice(inv *Invoice) error { pubKey, err := inv.InvoiceNodeID.UnwrapOrErrV(ErrMissingNodeID) if err != nil { return err diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go index 6305f1abb8..ab799862fa 100644 --- a/bolt12/signature_test.go +++ b/bolt12/signature_test.go @@ -145,7 +145,7 @@ func TestVerifyInvoiceRequestVector(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - require.NoError(t, VerifyInvoiceRequest(ir)) + require.NoError(t, verifyInvoiceRequest(ir)) } // TestSignatureVerifyRejectsTampering asserts that every way a malicious @@ -311,7 +311,7 @@ func TestNilKeyGuards(t *testing.T) { } } -// TestVerifyInvoiceDirect drives VerifyInvoice end to end using a minimal valid +// TestVerifyInvoiceDirect drives verifyInvoice end to end using a minimal valid // Invoice constructed via validInvoice. func TestVerifyInvoiceDirect(t *testing.T) { t.Parallel() @@ -389,7 +389,7 @@ func TestVerifyInvoiceDirect(t *testing.T) { ) tc.mutate(t, inv) - err := VerifyInvoice(inv) + err := verifyInvoice(inv) require.ErrorIs(t, err, tc.wantErr) if tc.wantContains != "" { require.Contains( @@ -400,7 +400,7 @@ func TestVerifyInvoiceDirect(t *testing.T) { } } -// TestVerifyInvoiceRequestDirect drives VerifyInvoiceRequest end to end using a +// TestVerifyInvoiceRequestDirect drives verifyInvoiceRequest end to end using a // minimal valid InvoiceRequest constructed via validInvoiceRequest. func TestVerifyInvoiceRequestDirect(t *testing.T) { t.Parallel() @@ -476,7 +476,7 @@ func TestVerifyInvoiceRequestDirect(t *testing.T) { ) tc.mutate(t, ir) - err := VerifyInvoiceRequest(ir) + err := verifyInvoiceRequest(ir) require.ErrorIs(t, err, tc.wantErr) if tc.wantContains != "" { require.Contains( diff --git a/bolt12/validate.go b/bolt12/validate.go index c67733d19c..8810c96eeb 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -176,14 +176,14 @@ var ( // ErrMissingPaths is returned when invoice_paths is absent. ErrMissingPaths = errors.New("missing invoice_paths") - // ErrNoUsablePaths is returned by ValidateInvoiceRead when every + // 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 + // 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") @@ -276,11 +276,11 @@ const ( invoiceErrorErrorType tlv.Type = 5 ) -// ValidateInvoiceErrorWrite validates an invoice_error per the BOLT 12 writer +// validateInvoiceErrorWrite validates an invoice_error per the BOLT 12 writer // requirements. The checks follow the spec's writer section in order. The // caller must check that the suggested value, if present, contains a valid // type. -func ValidateInvoiceErrorWrite(ie *InvoiceError) error { +func validateInvoiceErrorWrite(ie *InvoiceError) error { // - MUST set error to an explanatory string. if !ie.Error.IsSome() { return ErrMissingError @@ -371,14 +371,14 @@ func isKnownInvreqTLVType(typ tlv.Type) bool { } } -// ValidateInvoiceRequestWrite ensures an invoice request adheres to the BOLT 12 +// validateInvoiceRequestWrite ensures an invoice request adheres to the BOLT 12 // writer requirements. // // Note: This writer validation assumes that for requests responding to an // offer, the caller/constructor has already mirrored the offer's fields exactly // by using the NewInvoiceRequestFromOffer constructor, as an invoice request // can also be created without an offer. -func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error { +func validateInvoiceRequestWrite(ir *InvoiceRequest) error { // A present-but-nil pubkey passes IsSome but would panic the codec on // encode, so reject both pubkey fields. if err := checkPubKeyNotNil( @@ -784,7 +784,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest, // - MUST reject the invoice request if signature is not correct as // detailed in Signature Calculation using the invreq_payer_id. - return VerifyInvoiceRequest(ir) + return verifyInvoiceRequest(ir) } // getInvoiceRequestOfferChains returns the chains an invoice request's mirrored @@ -959,13 +959,13 @@ func isKnownOfferTLVType(typ tlv.Type) bool { } } -// ValidateOfferRead validates an offer per the BOLT 12 offer reader +// validateOfferRead validates an offer per the BOLT 12 offer reader // requirements. The now parameter is used for expiry checks and can be // overridden in tests. activeChain is required: per spec, absent offer_chains // 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, +func validateOfferRead(o *Offer, now time.Time, activeChain [32]byte, knownFeatures map[lnwire.FeatureBit]string) error { // - if the offer contains any TLV fields outside the inclusive ranges: @@ -1137,9 +1137,9 @@ func getOfferChains(o *Offer) [][32]byte { return chains } -// ValidateOfferWrite validates an offer per the BOLT 12 offer writer +// validateOfferWrite validates an offer per the BOLT 12 offer writer // requirements, in the order the spec states them. -func ValidateOfferWrite(o *Offer) error { +func validateOfferWrite(o *Offer) error { // - MUST NOT set any TLV fields outside the inclusive ranges: 1 to 79 // and 1000000000 to 1999999999. // @@ -1411,12 +1411,12 @@ func checkInvoiceNodeID(inv *Invoice) error { return nil } -// ValidateInvoiceWrite validates an invoice per the BOLT 12 invoice writer +// 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 { +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() { @@ -1434,7 +1434,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // ("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. + // a missing-field violation. Symmetric with validateInvoiceRead. if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 { return ErrZeroInvoiceAmount } @@ -1449,7 +1449,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // 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" + // 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 @@ -1490,7 +1490,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // pre-sign Encode is permitted, so an unsigned invoice passes this // validator and Encode. The wire-string layer rejects an unsigned // invoice, and the reader verifies correctness, mirroring - // ValidateInvoiceRequestWrite. + // validateInvoiceRequestWrite. // - if the expiry for accepting payment is not 7200 seconds after // invoice_created_at: MUST set invoice_relative_expiry. @@ -1498,7 +1498,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // 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). + // (see validateInvoiceExpiry). // - if it accepts onchain payments: // - MAY specify invoice_fallbacks. @@ -1549,7 +1549,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // A present-but-nil pubkey passes IsSome but would panic the codec on // encode, so reject the mirrored pubkey fields. Symmetric with - // ValidateInvoiceRequestWrite. + // validateInvoiceRequestWrite. if err := fn.MapOptionZ(inv.InvreqPayerID.ValOpt(), func(pk *btcec.PublicKey) error { if pk == nil { @@ -1580,16 +1580,17 @@ func ValidateInvoiceWrite(inv *Invoice) error { // omits invoice_relative_expiry: two hours from creation. const defaultInvoiceRelativeExpiry uint32 = 7200 -// ValidateInvoiceExpiry rejects an invoice whose effective expiry is strictly +// 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 { +// itself is still valid; this matches the strict comparison validateOfferRead +// uses for offer_absolute_expiry. validateInvoiceRead cannot make the check, +// because the time comparison needs a clock the codec does not supply, so the +// exported entry points fold this in: DecodeInvoiceString for the string form +// and ValidateInvoiceForPayment for an invoice answering a request. +func validateInvoiceExpiry(inv *Invoice, now time.Time) error { createdAt, err := inv.InvoiceCreatedAt.ValOpt().UnwrapOrErr( ErrMissingCreatedAt, ) @@ -1617,8 +1618,8 @@ func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error { // payer expected to answer. Which node that is comes from the payer's own // state: offer_issuer_id, the final blinded_node_id of the path it chose, or // the node it addressed an offerless request to. None of that is derivable -// from the invoice, so ValidateInvoiceRead cannot make the comparison and -// callers run this separately, as they already do for ValidateInvoiceExpiry. +// from the invoice, so validateInvoiceRead cannot make the comparison. +// ValidateInvoiceForPayment folds it in, as it does for validateInvoiceExpiry. // // Skipping it is not cosmetic. Every node on the blinded path can answer with // its own correctly signed invoice, and the reader accepts it, because the @@ -1668,7 +1669,7 @@ func mirroredRecordBytes(records []tlv.Record) (map[tlv.Type][]byte, error) { return out, nil } -// ValidateInvoiceAgainstRequest performs a byte-for-byte comparison of the +// 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 @@ -1685,7 +1686,7 @@ func mirroredRecordBytes(records []tlv.Record) (map[tlv.Type][]byte, error) { // 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 { +func validateInvoiceAgainstRequest(inv *Invoice, req *InvoiceRequest) error { reqFields, err := mirroredRecordBytes(req.AllRecords()) if err != nil { return fmt.Errorf("encode request fields: %w", err) @@ -1813,7 +1814,7 @@ type InvoiceKnownFeatures struct { Blinded map[lnwire.FeatureBit]string } -// ValidateInvoiceRead validates an invoice against the BOLT 12 reader +// validateInvoiceRead validates an invoice against the BOLT 12 reader // requirements, running the stateless structural checks against activeChain // (the chain the reader supports). The final check is cryptographic: the // reader rejects an invoice whose BIP-340 Schnorr signature does not verify @@ -1823,14 +1824,14 @@ type InvoiceKnownFeatures struct { // 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, +func validateInvoiceRead(inv *Invoice, activeChain [32]byte, features InvoiceKnownFeatures) error { // - MUST reject the invoice if invoice_amount is not present. if !inv.InvoiceAmount.IsSome() { return ErrMissingAmount } - // Policy extension. See ValidateInvoiceWrite. + // Policy extension. See validateInvoiceWrite. if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 { return ErrZeroInvoiceAmount } @@ -1896,7 +1897,8 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - 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. + // supply. DecodeInvoiceString and ValidateInvoiceForPayment fold in + // validateInvoiceExpiry, which makes the check. // - MUST reject the invoice if invoice_paths is not present or is // empty. @@ -1947,7 +1949,7 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // 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 + // enforced by validateInvoiceAgainstRequest once the invoice is paired // 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 { @@ -1969,12 +1971,12 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - 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 + // validateInvoiceAgainstRequest; the fallback ignore rules by // UsableFallbackAddresses. // - MUST reject the invoice if signature is not a valid signature using // invoice_node_id as described in Signature Calculation. - return VerifyInvoice(inv) + return verifyInvoice(inv) } // ValidateInvoiceForPayment runs the full set of payer-side invoice checks in @@ -1990,15 +1992,15 @@ func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, features InvoiceKnownFeatures, expectedNodeID *btcec.PublicKey) error { - if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + if err := validateInvoiceRead(inv, activeChain, features); err != nil { return err } - if err := ValidateInvoiceExpiry(inv, now); err != nil { + if err := validateInvoiceExpiry(inv, now); err != nil { return err } - if err := ValidateInvoiceAgainstRequest(inv, req); err != nil { + if err := validateInvoiceAgainstRequest(inv, req); err != nil { return err } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index fe88773726..5aebd09665 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -139,7 +139,7 @@ func TestValidateOfferWrite(t *testing.T) { wantErr: ErrInvalidCurrency, }, { - // Pins the docstring claim that ValidateOfferWrite's + // Pins the docstring claim that validateOfferWrite's // offerAllowedRange loop exists to catch a // decoded-then-mutated offer with an out-of-range TLV // resurfacing via decodedTLVs. @@ -233,7 +233,7 @@ func TestValidateOfferWrite(t *testing.T) { o := validBobOffer(t) tc.mutate(o) - err := ValidateOfferWrite(o) + err := validateOfferWrite(o) if tc.wantErr == nil { require.NoError(t, err) @@ -689,7 +689,7 @@ func TestValidateOfferRead(t *testing.T) { o := validBobOffer(t) tc.mutate(o) - err := ValidateOfferRead( + err := validateOfferRead( o, now, tc.activeChain, tc.known, ) if tc.wantErr == nil { @@ -837,7 +837,7 @@ func flipValueByte(t *testing.T, encoded, needle []byte) []byte { } // TestValidateReadRejectsBadSignature pins the reader-side signature gate on -// both message types. ValidateInvoiceRequestRead and ValidateInvoiceRead key +// both message types. ValidateInvoiceRequestRead and validateInvoiceRead key // the check on different public keys, so covering one does not cover the // other. // @@ -890,7 +890,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { validate: func(t *testing.T) error { inv := signedInvoice(t, alicePriv) - return ValidateInvoiceRead( + return validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -915,7 +915,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { )) require.NoError(t, err) - return ValidateInvoiceRead( + return validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -1147,7 +1147,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // The reader accepts it, which is the half that makes the composite // necessary rather than redundant. Asserting both halves on the same // invoice is what shows the second step cannot be skipped. - require.NoError(t, ValidateInvoiceRead( + require.NoError(t, validateInvoiceRead( forgedDecoded, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, )) @@ -1372,7 +1372,7 @@ func TestValidateInvoiceRequestWrite(t *testing.T) { tc.mutate(ir) - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) if tc.wantErr == nil { require.NoError(t, err) return @@ -1416,14 +1416,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir := baseRequest() // Absent invreq_amount -> invalid. - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrMissingAmount) // Present invreq_amount -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // 2. Responding to an offer. @@ -1445,14 +1445,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir := baseResponseRequest() // InvreqAmount absent -> invalid. - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrMissingAmount) // InvreqAmount present -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // Case B: OfferAmount present, OfferCurrency absent (Bitcoin). @@ -1469,7 +1469,7 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ) // InvreqAmount is optional (MAY omit it). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // If set, it MUST be >= OfferAmount * Quantity // (1000 * 2 = 2000). InvreqAmount < expected -> @@ -1477,14 +1477,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1999), ) - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrAmountBelowExpected) // InvreqAmount >= expected -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](2000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // Case C: OfferAmount present, OfferCurrency present @@ -1509,11 +1509,11 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { // InvreqAmount < OfferAmount * Quantity is allowed // because currency conversion is checked dynamically // at runtime, not statically inside - // ValidateInvoiceRequestWrite. + // validateInvoiceRequestWrite. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](100), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) }) } @@ -1553,7 +1553,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { } // Absent chain is OK. - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // Bitcoin chain is OK. ir.InvreqChain = tlv.SomeRecordT( @@ -1561,13 +1561,13 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // Non-bitcoin chain is OK. ir.InvreqChain = tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType80](testnetHash), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // 2. Responding to an offer. @@ -1602,7 +1602,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ir := baseRequest() // InvreqChain absent (valid, defaults to bitcoin). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == bitcoin (valid). ir.InvreqChain = tlv.SomeRecordT( @@ -1610,7 +1610,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain != bitcoin (invalid). ir.InvreqChain = tlv.SomeRecordT( @@ -1619,7 +1619,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) }) @@ -1637,7 +1637,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ) // InvreqChain absent (valid, defaults to bitcoin). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == bitcoin (valid). ir.InvreqChain = tlv.SomeRecordT( @@ -1645,7 +1645,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == testnet (invalid, not in offer // chains). @@ -1655,7 +1655,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) @@ -1671,7 +1671,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { // InvreqChain absent (invalid, defaults to bitcoin // which is not in offer chains). require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) @@ -1681,7 +1681,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { testnetHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == regtest (invalid, not in offer // chains). @@ -1691,7 +1691,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) }) @@ -2061,7 +2061,7 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) { require.ErrorIs(t, readErr, ErrAmountBelowExpected) // The writer MUST reject it too (same rule, both sides). - writeErr := ValidateInvoiceRequestWrite(newRequest()) + writeErr := validateInvoiceRequestWrite(newRequest()) require.ErrorIs(t, writeErr, ErrAmountBelowExpected) } @@ -2108,7 +2108,7 @@ func TestValidateInvoiceAmountOverflow(t *testing.T) { tlv.NewRecordT[tlv.TlvType170](TUint64(0)), ) - err := ValidateInvoiceAgainstRequest(inv, req) + err := validateInvoiceAgainstRequest(inv, req) require.ErrorIs(t, err, ErrAmountBelowExpected) } @@ -2286,7 +2286,7 @@ func encodeInvBypassValidate(inv *Invoice) ([]byte, error) { } // TestValidateInvoiceRead table-drives every reader-side rejection in -// ValidateInvoiceRead. +// validateInvoiceRead. func TestValidateInvoiceRead(t *testing.T) { t.Parallel() @@ -2476,7 +2476,7 @@ func TestValidateInvoiceRead(t *testing.T) { inv := baseline() tc.mutate(inv) - err := ValidateInvoiceRead( + err := validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -2548,7 +2548,7 @@ func TestValidateInvoiceReadSignatureRange(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig), ) - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -2558,7 +2558,7 @@ func TestValidateInvoiceReadSignatureRange(t *testing.T) { // the range is exempt from the out-of-range rule only. inv.decodedTLVs = tlv.TypeMap{242: nil} - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -2651,7 +2651,7 @@ func TestValidateInvoiceExpiry(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateInvoiceExpiry( + err := validateInvoiceExpiry( tc.inv, time.Unix(tc.now, 0), ) if tc.wantErr != nil { @@ -2776,7 +2776,7 @@ func TestValidateInvoiceAgainstRequest(t *testing.T) { invDecoded, err := DecodeInvoice(invEncoded) require.NoError(t, err) - err = ValidateInvoiceAgainstRequest( + err = validateInvoiceAgainstRequest( invDecoded, irDecoded, ) if tc.wantErr == nil { @@ -2831,12 +2831,12 @@ func TestValidateInvoiceAgainstRequestAmountMirror(t *testing.T) { // Equal amounts pass. matchEnc, _ := encodeInvBypassValidate(build(2500)) matchDec, _ := DecodeInvoice(matchEnc) - require.NoError(t, ValidateInvoiceAgainstRequest(matchDec, irDecoded)) + require.NoError(t, validateInvoiceAgainstRequest(matchDec, irDecoded)) // Mismatched amounts fail. missEnc, _ := encodeInvBypassValidate(build(2501)) missDec, _ := DecodeInvoice(missEnc) - err = ValidateInvoiceAgainstRequest(missDec, irDecoded) + err = validateInvoiceAgainstRequest(missDec, irDecoded) require.ErrorIs(t, err, ErrInvoiceMismatch) require.Contains(t, err.Error(), "invoice_amount") } @@ -2920,7 +2920,7 @@ func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { invDec, err := DecodeInvoice(invEnc) require.NoError(t, err) - return ValidateInvoiceAgainstRequest(invDec, irDec) + return validateInvoiceAgainstRequest(invDec, irDec) } qty := func(v uint64) *uint64 { return &v } @@ -2969,7 +2969,7 @@ func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { } // TestValidateInvoiceWrite table-drives the writer-side checks of -// ValidateInvoiceWrite by clearing required fields on a valid baseline invoice. +// validateInvoiceWrite by clearing required fields on a valid baseline invoice. func TestValidateInvoiceWrite(t *testing.T) { t.Parallel() @@ -3115,7 +3115,7 @@ func TestValidateInvoiceWrite(t *testing.T) { inv := validInvoice(t) tc.mutate(inv) - err := ValidateInvoiceWrite(inv) + err := validateInvoiceWrite(inv) if tc.wantErr == nil { require.NoError(t, err) } else { @@ -3131,7 +3131,7 @@ func TestValidateInvoiceWrite(t *testing.T) { func TestValidateFeaturesKnownBits(t *testing.T) { t.Parallel() - // Role 1 validation verifies endpoint features on ValidateInvoiceRead. + // Role 1 validation verifies endpoint features on validateInvoiceRead. t.Run("endpoint features (Role 1)", func(t *testing.T) { t.Parallel() @@ -3153,7 +3153,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { ) // An unknown required bit must be rejected. - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -3163,7 +3163,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { known := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{Invoice: known}, ) @@ -3171,7 +3171,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { }) // Role 2 validation verifies routing path features on - // ValidateInvoiceRead. + // validateInvoiceRead. t.Run("routing path features (Role 2)", func(t *testing.T) { t.Parallel() @@ -3199,7 +3199,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { // With no known feature bits there are zero usable paths, so // we expect ErrNoUsablePaths. - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -3210,7 +3210,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { knownBlinded := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{Blinded: knownBlinded}, ) @@ -3227,13 +3227,13 @@ func TestValidateFeaturesKnownBits(t *testing.T) { tlv.NewRecordT[tlv.TlvType174](fv), ) - require.NoError(t, ValidateInvoiceWrite(inv)) + 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. +// on encode. Symmetric with validateInvoiceRequestWrite. func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { t.Parallel() @@ -3246,7 +3246,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) t.Run("present-but-nil offer_issuer_id", func(t *testing.T) { @@ -3258,7 +3258,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) t.Run("present-but-nil node_id", func(t *testing.T) { @@ -3270,7 +3270,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) } @@ -3336,7 +3336,7 @@ func TestValidateInvoiceErrorWrite(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateInvoiceErrorWrite(tc.ie) + err := validateInvoiceErrorWrite(tc.ie) if tc.wantErr == nil { require.NoError(t, err) @@ -3398,7 +3398,7 @@ func TestValidateOfferReadVectors(t *testing.T) { activeChain = c[0] } - valErr := ValidateOfferRead( + valErr := validateOfferRead( offer, now, activeChain, nil, ) From 791713c6322ec26efe4b9a008be955650ea694b1 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 11 Sep 2026 10:47:45 +0000 Subject: [PATCH 09/16] bolt12: un-export UsableFallbackAddresses UsableFallbackAddresses is un-exported rather than deleted. Nothing dispatches an on-chain fallback yet, so it has no caller. It stays because it encodes the reader's MUST-ignore rules for fallback addresses, and its tests exercise them. Finding F22. --- bolt12/invoice.go | 4 ++-- bolt12/invoice_test.go | 2 +- bolt12/validate.go | 28 +++++++++++++++++----------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/bolt12/invoice.go b/bolt12/invoice.go index c7d6931dcc..d589b19a9d 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -159,9 +159,9 @@ const ( maxWitnessProgramLen = 40 ) -// UsableFallbackAddresses returns the invoice_fallbacks entries a payer may use +// 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 { +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{}) diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index 9bd1e5d014..5e702cfe84 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -94,7 +94,7 @@ func TestUsableFallbackAddresses(t *testing.T) { ), } - got := inv.UsableFallbackAddresses() + got := inv.usableFallbackAddresses() require.Len(t, got, 2) require.Equal(t, byte(0), got[0].Version) require.Equal(t, byte(16), got[1].Version) diff --git a/bolt12/validate.go b/bolt12/validate.go index 8810c96eeb..1fa372de68 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -415,10 +415,14 @@ func validateInvoiceRequestWrite(ir *InvoiceRequest) error { } // - MUST set signature.sig using the invreq_payer_id. - // NOT CHECKED HERE: signing happens after this validator runs; - // pre-sign Encode is permitted, so an unsigned request passes - // this validator and Encode. The wire-string layer rejects an - // unsigned request, and the reader verifies correctness. + // NOT CHECKED HERE: a caller signs after building the struct. + // Encode is therefore permitted before signing, and an + // unsigned request passes this validator and Encode. No + // exported entry point enforces the MUST on write: the bech32 + // wrapper that checks it is package-internal, and an + // invoice_request reaches a peer as raw TLV inside an onion + // message. The caller that emits the bytes owns the signing + // step. The reader verifies correctness either way. // - MUST set invreq_payer_id to a transient public key. // NOT CHECKED HERE: only presence is checked below; the caller @@ -1486,11 +1490,13 @@ func validateInvoiceWrite(inv *Invoice) error { // - 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; - // pre-sign Encode is permitted, so an unsigned invoice passes this - // validator and Encode. The wire-string layer rejects an unsigned - // invoice, and the reader verifies correctness, mirroring - // validateInvoiceRequestWrite. + // NOT CHECKED HERE: a caller signs after building the struct. Encode + // is therefore permitted before signing, and an unsigned invoice + // passes this validator and Encode. EncodeInvoiceString does reject + // an unsigned invoice, but the raw TLV form an onion message carries + // never reaches that gate, so the caller that emits the bytes owns + // the signing step, mirroring validateInvoiceRequestWrite. The + // reader verifies correctness. // - if the expiry for accepting payment is not 7200 seconds after // invoice_created_at: MUST set invoice_relative_expiry. @@ -1509,7 +1515,7 @@ func validateInvoiceWrite(inv *Invoice) error { // 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. + // side by usableFallbackAddresses. // - MUST include invoice_paths containing one or more paths to the // node. @@ -1972,7 +1978,7 @@ func validateInvoiceRead(inv *Invoice, activeChain [32]byte, // 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. + // usableFallbackAddresses. // - MUST reject the invoice if signature is not a valid signature using // invoice_node_id as described in Signature Calculation. From fd403530ab4588be60a0af8735c63267fb1bfe8a Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:34:30 +0000 Subject: [PATCH 10/16] bolt12: add a signed-encode entry point for the wire forms An invoice_request and an invoice reach a peer as raw TLV inside an onion message, not as a bech32 string. Encode is permissive about the signature by design, because a caller must encode before it can derive the Merkle root it signs, so nothing enforced the writer-side MUST on the path the bytes actually take. A caller that forgot to sign would find out only when the remote peer rejected the message. EncodeSigned is that entry point for both messages. It requires the signature and verifies it against invreq_payer_id or invoice_node_id, and EncodeInvoiceString now delegates to it, so the raw and string forms carry one rule. --- bolt12/invoice.go | 40 ++++++++++++++++++++++++++++----------- bolt12/invoice_request.go | 26 +++++++++++++++++++++++++ bolt12/invoice_test.go | 4 ++-- bolt12/validate.go | 33 ++++++++++++++------------------ 4 files changed, 71 insertions(+), 32 deletions(-) diff --git a/bolt12/invoice.go b/bolt12/invoice.go index d589b19a9d..fe35ca1391 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -299,6 +299,32 @@ func (inv *Invoice) Encode() ([]byte, error) { return buf.Bytes(), nil } +// EncodeSigned serialises an invoice that is ready to leave the node. It +// requires the signature the writer requirements make mandatory and verifies +// it against invoice_node_id. +// +// Encode stays permissive about the signature because a caller must encode +// before it can sign: the Merkle root it signs is derived from the records. +// EncodeSigned is the entry point for bytes that reach a peer, so it is where +// the writer-side MUST is enforced. An invoice travels as raw TLV inside an +// onion message, so that boundary is not the bech32 string form. +func (inv *Invoice) EncodeSigned() ([]byte, error) { + if !inv.Signature.IsSome() { + return nil, ErrMissingSignature + } + + tlvBytes, err := inv.Encode() + if err != nil { + return nil, err + } + + if err := verifyInvoice(inv); err != nil { + return nil, err + } + + return tlvBytes, 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) { @@ -443,22 +469,14 @@ func DecodeInvoiceString(s string, now time.Time, // EncodeInvoiceString encodes a signed invoice to its bech32 string // representation (lni1...). The string form exists only for transmission, so -// a populated signature is required and verified against invoice_node_id. -// Writer-side validation is delegated to (*Invoice).Encode. +// it carries the same signature requirement as the raw TLV form and adds the +// human-readable prefix. func EncodeInvoiceString(inv *Invoice) (string, error) { - if !inv.Signature.IsSome() { - return "", ErrMissingSignature - } - - tlvBytes, err := inv.Encode() + tlvBytes, err := inv.EncodeSigned() if err != nil { return "", err } - if err := verifyInvoice(inv); err != nil { - return "", err - } - return encodeBech32(HRPInvoice, tlvBytes) } diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index e156260a3c..4b7cff32f5 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -165,6 +165,32 @@ func (ir *InvoiceRequest) Encode() ([]byte, error) { return buf.Bytes(), nil } +// EncodeSigned serialises an invoice request that is ready to leave the node. +// It requires the signature the writer requirements make mandatory and +// verifies it against invreq_payer_id. +// +// Encode stays permissive about the signature because a caller must encode +// before it can sign: the Merkle root it signs is derived from the records. +// EncodeSigned is the entry point for bytes that reach a peer, so it is where +// the writer-side MUST is enforced. An invoice request travels as raw TLV +// inside an onion message, so that boundary is not the bech32 string form. +func (ir *InvoiceRequest) EncodeSigned() ([]byte, error) { + if !ir.Signature.IsSome() { + return nil, ErrMissingSignature + } + + tlvBytes, err := ir.Encode() + if err != nil { + return nil, err + } + + if err := verifyInvoiceRequest(ir); err != nil { + return nil, err + } + + return tlvBytes, nil +} + // DecodeInvoiceRequest deserializes an invoice request from a TLV byte stream. // Decoding is permissive: callers that need spec compliance must run // ValidateInvoiceRequestRead. diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index 5e702cfe84..1b5ee25fd8 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -563,8 +563,8 @@ func TestEncodeInvoiceStringInvalid(t *testing.T) { } // TestEncodeInvoiceStringUnsigned asserts the wire-string layer refuses to -// emit an unsigned invoice: the signature becomes mandatory at the bech32 -// boundary even though pre-sign Encode is permitted. +// emit an unsigned invoice: the signature is mandatory at every exported +// way out, while encode itself does not require one. func TestEncodeInvoiceStringUnsigned(t *testing.T) { t.Parallel() diff --git a/bolt12/validate.go b/bolt12/validate.go index 1fa372de68..f1a41830a0 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -141,10 +141,10 @@ var ( ErrInvalidBip353Name = errors.New("invalid invreq_bip_353_name") // ErrMissingSignature is returned when an invoice or invoice_request - // is encoded to its wire string or verified without a populated - // signature TLV. Pre-sign Encode (used to compute the Merkle root) - // is permitted to run without a signature; the wire-string layer is - // where the signature becomes mandatory. + // is emitted or verified without a populated signature TLV. Signing + // reads the struct directly, so encode never needs to run first and + // is free to serialise an unsigned message. EncodeSigned and the + // string encoders are where the signature becomes mandatory. ErrMissingSignature = errors.New("missing signature") // ErrOfferFieldsOnSpontaneous is returned when an invoice request @@ -415,14 +415,11 @@ func validateInvoiceRequestWrite(ir *InvoiceRequest) error { } // - MUST set signature.sig using the invreq_payer_id. - // NOT CHECKED HERE: a caller signs after building the struct. - // Encode is therefore permitted before signing, and an - // unsigned request passes this validator and Encode. No - // exported entry point enforces the MUST on write: the bech32 - // wrapper that checks it is package-internal, and an - // invoice_request reaches a peer as raw TLV inside an onion - // message. The caller that emits the bytes owns the signing - // step. The reader verifies correctness either way. + // NOT CHECKED HERE: signing reads the struct, not the encoded + // bytes, so encode does not require a signature and an + // unsigned request passes it and this validator. EncodeSigned + // is the exported gate: it requires the signature and + // verifies it. The reader verifies correctness too. // - MUST set invreq_payer_id to a transient public key. // NOT CHECKED HERE: only presence is checked below; the caller @@ -1490,13 +1487,11 @@ func validateInvoiceWrite(inv *Invoice) error { // - 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: a caller signs after building the struct. Encode - // is therefore permitted before signing, and an unsigned invoice - // passes this validator and Encode. EncodeInvoiceString does reject - // an unsigned invoice, but the raw TLV form an onion message carries - // never reaches that gate, so the caller that emits the bytes owns - // the signing step, mirroring validateInvoiceRequestWrite. The - // reader verifies correctness. + // NOT CHECKED HERE: signing reads the struct, not the encoded bytes, + // so encode does not require a signature and an unsigned invoice + // passes it and this validator. EncodeSigned is the exported gate: + // it requires the signature and verifies it, mirroring + // validateInvoiceRequestWrite. The reader verifies correctness too. // - if the expiry for accepting payment is not 7200 seconds after // invoice_created_at: MUST set invoice_relative_expiry. From 22676e54d0e5f7ea255e0b9f457fd0e0b0be2e9b Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:34:43 +0000 Subject: [PATCH 11/16] bolt12: validate a message before signing it A signature over a message that breaks the writer requirements is worthless, because the peer rejects the message on read. Running the write validator before the Merkle root is derived keeps a key from signing bytes no correct reader accepts, and it moves the check off the caller, which previously had to encode once purely to trigger it. --- bolt12/signature.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bolt12/signature.go b/bolt12/signature.go index 83fa471053..56f7e6e202 100644 --- a/bolt12/signature.go +++ b/bolt12/signature.go @@ -92,6 +92,15 @@ func SignInvoiceRequest(ir *InvoiceRequest, privKey *btcec.PrivateKey) ( return [64]byte{}, ErrNilPrivateKey } + // A signature over a message that breaks the writer requirements is + // worthless: the peer rejects it on read. Refusing here keeps a key + // from signing bytes no correct reader accepts. + if err := validateInvoiceRequestWrite(ir); err != nil { + return [64]byte{}, fmt.Errorf( + "validate invoice request: %w", err, + ) + } + root, err := merkleRoot(signableTLVs(ir.AllRecords())) if err != nil { return [64]byte{}, err @@ -136,6 +145,13 @@ func SignInvoice(inv *Invoice, privKey *btcec.PrivateKey) ([64]byte, error) { return [64]byte{}, ErrNilPrivateKey } + // A signature over a message that breaks the writer requirements is + // worthless: the peer rejects it on read. Refusing here keeps a key + // from signing bytes no correct reader accepts. + if err := validateInvoiceWrite(inv); err != nil { + return [64]byte{}, fmt.Errorf("validate invoice: %w", err) + } + root, err := merkleRoot(signableTLVs(inv.AllRecords())) if err != nil { return [64]byte{}, err From c41fde68f3a9f0184941c9d504da80e81815bd7a Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:34:55 +0000 Subject: [PATCH 12/16] bolt12: un-export the encode methods EncodeSigned is the entry point for an invoice_request and an invoice that leave the node, and the bech32 wrappers cover the string forms, so no caller outside this package needs a serialisation that skips the signature check. Un-exporting encode makes the writer-side requirement a property of the API rather than a rule a caller has to remember. Offer follows for consistency, since EncodeOfferString covers the only form it travels in. InvoiceError keeps its exported Encode. It is unsigned, so there is no check to skip, and raw TLV is the only form a receiver can send it in. --- bolt12/fuzz_test.go | 28 +++++++++++----------------- bolt12/invoice.go | 17 +++++++++-------- bolt12/invoice_request.go | 19 ++++++++++--------- bolt12/invoice_request_test.go | 10 +++++----- bolt12/invoice_test.go | 14 +++++++------- bolt12/offer.go | 8 ++++---- bolt12/offer_hash_test.go | 2 +- bolt12/offer_test.go | 6 +++--- bolt12/signature_test.go | 2 +- bolt12/validate_test.go | 6 +++--- 10 files changed, 54 insertions(+), 58 deletions(-) diff --git a/bolt12/fuzz_test.go b/bolt12/fuzz_test.go index f3bc741afd..557bd36595 100644 --- a/bolt12/fuzz_test.go +++ b/bolt12/fuzz_test.go @@ -75,21 +75,12 @@ func invreqTLVSeeds(t testing.TB) [][]byte { return tlvStreams(t, invreqStringSeeds(t)) } -// byteCodec constrains PM to *M with an Encode method, the shape every message -// decoder returns. The pointer core type makes PM nilable, so the harness can -// compare a decoded message against nil. A method-only constraint would admit -// non-pointer types and the check would not compile. -type byteCodec[M any] interface { - *M - Encode() ([]byte, error) -} - // fuzzByteCodec registers a byte-level decode harness on f. Decode must never // panic, and a nil message with nil error is fatal. A decoded message that // passes writer validation must round-trip encode→decode→encode // byte-identically. -func fuzzByteCodec[M any, PM byteCodec[M]](f *testing.F, - decode func([]byte) (PM, error), seeds ...[]byte) { +func fuzzByteCodec[M any](f *testing.F, decode func([]byte) (*M, error), + encode func(*M) ([]byte, error), seeds ...[]byte) { for _, seed := range seeds { f.Add(seed) @@ -104,7 +95,7 @@ func fuzzByteCodec[M any, PM byteCodec[M]](f *testing.F, t.Fatal("nil message with nil error") } - encoded, err := msg.Encode() + encoded, err := encode(msg) if err != nil { // Read accepts constraints write rejects, so a decoded // message may fail writer validation. Skip the @@ -116,7 +107,7 @@ func fuzzByteCodec[M any, PM byteCodec[M]](f *testing.F, if err != nil { t.Fatalf("round-trip decode failed: %v", err) } - encoded2, err := again.Encode() + encoded2, err := encode(again) if err != nil { t.Fatalf("second encode failed: %v", err) } @@ -141,20 +132,23 @@ func fuzzStringCodec(f *testing.F, decode func(string), seeds ...string) { // FuzzDecodeOffer fuzzes decodeOffer with offers-test.json corpus seeds. Decode // must never panic and valid decodes round-trip byte-identically. func FuzzDecodeOffer(f *testing.F) { - fuzzByteCodec(f, decodeOffer, offerTLVSeeds(f)...) + fuzzByteCodec(f, decodeOffer, (*Offer).encode, offerTLVSeeds(f)...) } // FuzzDecodeInvoiceRequest fuzzes DecodeInvoiceRequest with signature-test.json // corpus seeds. Decode must never panic and valid decodes round-trip // byte-identically. func FuzzDecodeInvoiceRequest(f *testing.F) { - fuzzByteCodec(f, DecodeInvoiceRequest, invreqTLVSeeds(f)...) + fuzzByteCodec( + f, DecodeInvoiceRequest, (*InvoiceRequest).encode, + invreqTLVSeeds(f)..., + ) } // FuzzDecodeInvoice fuzzes DecodeInvoice with a minimal type-168 seed. Decode // must never panic and valid decodes round-trip byte-identically. func FuzzDecodeInvoice(f *testing.F) { - fuzzByteCodec(f, DecodeInvoice, []byte{ + fuzzByteCodec(f, DecodeInvoice, (*Invoice).encode, []byte{ 0xa8, 0x20, // type=168, length=32 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, @@ -166,7 +160,7 @@ func FuzzDecodeInvoice(f *testing.F) { // FuzzDecodeInvoiceError fuzzes DecodeInvoiceError with a type-5 error seed. // Decode must never panic and valid decodes round-trip byte-identically. func FuzzDecodeInvoiceError(f *testing.F) { - fuzzByteCodec(f, DecodeInvoiceError, []byte{ + fuzzByteCodec(f, DecodeInvoiceError, (*InvoiceError).Encode, []byte{ 0x05, 0x05, // type=5 error, length=5 'h', 'e', 'l', 'l', 'o', }) diff --git a/bolt12/invoice.go b/bolt12/invoice.go index fe35ca1391..d306eebac9 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -284,9 +284,9 @@ func (inv *Invoice) allRecordProducers() []tlv.RecordProducer { return p } -// Encode validates the invoice per writer requirements and serialises it via +// encode validates the invoice per writer requirements and serialises it via // the PureTLVMessage shape. -func (inv *Invoice) Encode() ([]byte, error) { +func (inv *Invoice) encode() ([]byte, error) { if err := validateInvoiceWrite(inv); err != nil { return nil, fmt.Errorf("validate invoice: %w", err) } @@ -303,17 +303,18 @@ func (inv *Invoice) Encode() ([]byte, error) { // requires the signature the writer requirements make mandatory and verifies // it against invoice_node_id. // -// Encode stays permissive about the signature because a caller must encode -// before it can sign: the Merkle root it signs is derived from the records. -// EncodeSigned is the entry point for bytes that reach a peer, so it is where -// the writer-side MUST is enforced. An invoice travels as raw TLV inside an -// onion message, so that boundary is not the bech32 string form. +// encode stays permissive about the signature because signing does not need +// it: SignInvoice derives the Merkle root from the records, so a caller never +// has to encode first. EncodeSigned is the entry point for bytes that reach a +// peer, so it is where the writer-side MUST is enforced. An invoice travels as +// raw TLV inside an onion message, so that boundary is not the bech32 string +// form. func (inv *Invoice) EncodeSigned() ([]byte, error) { if !inv.Signature.IsSome() { return nil, ErrMissingSignature } - tlvBytes, err := inv.Encode() + tlvBytes, err := inv.encode() if err != nil { return nil, err } diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 4b7cff32f5..dafe7a1672 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -150,9 +150,9 @@ func (ir *InvoiceRequest) allRecordProducers() []tlv.RecordProducer { return p } -// Encode validates the invoice request per writer requirements and serialises +// encode validates the invoice request per writer requirements and serialises // it via the PureTLVMessage shape. -func (ir *InvoiceRequest) Encode() ([]byte, error) { +func (ir *InvoiceRequest) encode() ([]byte, error) { if err := validateInvoiceRequestWrite(ir); err != nil { return nil, fmt.Errorf("validate invoice request: %w", err) } @@ -169,17 +169,18 @@ func (ir *InvoiceRequest) Encode() ([]byte, error) { // It requires the signature the writer requirements make mandatory and // verifies it against invreq_payer_id. // -// Encode stays permissive about the signature because a caller must encode -// before it can sign: the Merkle root it signs is derived from the records. -// EncodeSigned is the entry point for bytes that reach a peer, so it is where -// the writer-side MUST is enforced. An invoice request travels as raw TLV -// inside an onion message, so that boundary is not the bech32 string form. +// encode stays permissive about the signature because signing does not need +// it: SignInvoiceRequest derives the Merkle root from the records, so a caller +// never has to encode first. EncodeSigned is the entry point for bytes that +// reach a peer, so it is where the writer-side MUST is enforced. An invoice +// request travels as raw TLV inside an onion message, so that boundary is not +// the bech32 string form. func (ir *InvoiceRequest) EncodeSigned() ([]byte, error) { if !ir.Signature.IsSome() { return nil, ErrMissingSignature } - tlvBytes, err := ir.Encode() + tlvBytes, err := ir.encode() if err != nil { return nil, err } @@ -304,7 +305,7 @@ func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { return "", ErrMissingSignature } - tlvBytes, err := ir.Encode() + tlvBytes, err := ir.encode() if err != nil { return "", err } diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 83f2cc7a09..4036eba427 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -135,7 +135,7 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - encoded, err := ir.Encode() + encoded, err := ir.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -149,7 +149,7 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { ir.decodedTLVs = decoded.decodedTLVs require.Equal(t, ir, decoded) - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -227,7 +227,7 @@ func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType22](pub), ), } - encoded, err := offer.Encode() + encoded, err := offer.encode() require.NoError(t, err) const unknownType = 33 @@ -336,9 +336,9 @@ func TestInvoiceRequestStringRoundTrip(t *testing.T) { ) require.NoError(t, err) - originalBytes, err := ir.Encode() + originalBytes, err := ir.encode() require.NoError(t, err) - decodedBytes, err := decoded.Encode() + decodedBytes, err := decoded.encode() require.NoError(t, err) require.Equal(t, originalBytes, decodedBytes) } diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index 1b5ee25fd8..ef3992b8b0 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -327,7 +327,7 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - encoded, err := inv.Encode() + encoded, err := inv.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -351,7 +351,7 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) { require.Equal(t, inv, decoded) // Re-encode the decoded copy and confirm canonicality. - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -364,7 +364,7 @@ func TestDecodeInvoiceRejectsTruncated(t *testing.T) { t.Parallel() inv := validInvoice(t) - encoded, err := inv.Encode() + encoded, err := inv.encode() require.NoError(t, err) // Chop off the last byte. The truncation lands in the middle of the @@ -450,7 +450,7 @@ func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) { tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ), } - encoded, err := req.Encode() + encoded, err := req.encode() require.NoError(t, err) // Fill in an unknown odd TLV (type 93, within the invreq signed range @@ -503,7 +503,7 @@ func TestInvoiceEncodeValidationGate(t *testing.T) { tlv.TlvType164, TUint64, ]{} - _, err := inv.Encode() + _, err := inv.encode() require.ErrorIs(t, err, ErrMissingCreatedAt) } @@ -534,9 +534,9 @@ func TestInvoiceStringRoundTrip(t *testing.T) { ) require.NoError(t, err) - originalBytes, err := inv.Encode() + originalBytes, err := inv.encode() require.NoError(t, err) - decodedBytes, err := decoded.Encode() + decodedBytes, err := decoded.encode() require.NoError(t, err) require.Equal(t, originalBytes, decodedBytes) } diff --git a/bolt12/offer.go b/bolt12/offer.go index fd503b376f..823a141d07 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -97,8 +97,8 @@ func (o *Offer) allRecordProducers() []tlv.RecordProducer { return p } -// Encode serialises the offer into a canonical TLV byte stream. -func (o *Offer) Encode() ([]byte, error) { +// encode serialises the offer into a canonical TLV byte stream. +func (o *Offer) encode() ([]byte, error) { if err := validateOfferWrite(o); err != nil { return nil, fmt.Errorf("validate offer: %w", err) } @@ -199,9 +199,9 @@ func DecodeOfferString(s string, now time.Time, } // EncodeOfferString encodes an offer to its bech32 string representation -// (lno1...). Writer-side validation is delegated to (*Offer).Encode. +// (lno1...). Writer-side validation is delegated to (*Offer).encode. func EncodeOfferString(o *Offer) (string, error) { - tlvBytes, err := o.Encode() + tlvBytes, err := o.encode() if err != nil { return "", err } diff --git a/bolt12/offer_hash_test.go b/bolt12/offer_hash_test.go index 1dcea18ad9..be2136ffa6 100644 --- a/bolt12/offer_hash_test.go +++ b/bolt12/offer_hash_test.go @@ -23,7 +23,7 @@ func TestOfferHashMatchesOfferEncoding(t *testing.T) { offer, err := decodeOffer(tlvBytes) require.NoError(t, err) - encoded, err := offer.Encode() + encoded, err := offer.encode() require.NoError(t, err) id, err := OfferHash(offer) diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index bb00483046..302394dcd1 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -86,7 +86,7 @@ func TestOfferRoundTrip(t *testing.T) { decodedTLVs: tlv.TypeMap{13: []byte{0xde, 0xad}}, } - encoded, err := o.Encode() + encoded, err := o.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -100,7 +100,7 @@ func TestOfferRoundTrip(t *testing.T) { o.decodedTLVs = decoded.decodedTLVs require.Equal(t, o, decoded) - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -165,7 +165,7 @@ func TestDecodeMinimalOfferString(t *testing.T) { hex.EncodeToString(issuerKey.SerializeCompressed())) // Re-encode and verify bytes match. - reencoded, err := offer.Encode() + reencoded, err := offer.encode() require.NoError(t, err) require.Equal(t, tlvBytes, reencoded) } diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go index ab799862fa..e0b1174da0 100644 --- a/bolt12/signature_test.go +++ b/bolt12/signature_test.go @@ -333,7 +333,7 @@ func TestVerifyInvoiceDirect(t *testing.T) { { name: "valid round-trip verifies", mutate: func(t *testing.T, inv *Invoice) { - _, err := inv.Encode() + _, err := inv.encode() require.NoError(t, err) sig, err := SignInvoice(inv, priv) diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 5aebd09665..2e2b89384a 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -762,7 +762,7 @@ func signedInvoiceRequest(t testing.TB, ), } - encoded, err := ir.Encode() + encoded, err := ir.encode() require.NoError(t, err) decoded, err := DecodeInvoiceRequest(encoded) @@ -869,7 +869,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { validate: func(t *testing.T) error { encoded, err := signedInvoiceRequest( t, bobPriv, - ).Encode() + ).encode() require.NoError(t, err) // invreq_metadata is a signed opaque blob, so @@ -901,7 +901,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { validate: func(t *testing.T) error { signed := signedInvoice(t, bobPriv) - encoded, err := signed.Encode() + encoded, err := signed.encode() require.NoError(t, err) // invoice_payment_hash is a signed fixed-width From b2b4de8eb468defe87c8d17ad641eeb74c8ba954 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:35:15 +0000 Subject: [PATCH 13/16] bolt12: build the lnr1 encoder on EncodeSigned A request that answers an offer reaches its peer as raw TLV inside an onion message. A request that answers no offer is published by its payer as an lnr1 string instead, such as a QR code, and the payee reads that string to answer with an invoice, so the string encoder stays. It now delegates to EncodeSigned, as EncodeInvoiceString does, so the signature rule lives in one place. The three tests that pinned the encoder's refusal to emit an invalid, unsigned or badly signed request move to EncodeSigned accordingly. --- bolt12/invoice_request.go | 15 +------ bolt12/invoice_request_test.go | 81 ++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 51 deletions(-) diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index dafe7a1672..d5e96b781a 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -296,24 +296,13 @@ func DecodeInvoiceRequestString(s string, } // EncodeInvoiceRequestString encodes a signed invoice request to its bech32 -// string representation (lnr1...). The string form exists only for -// transmission, so a populated signature is required and verified against -// invreq_payer_id. Writer-side validation is delegated to -// (*InvoiceRequest).Encode. +// string representation (lnr1...). func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { - if !ir.Signature.IsSome() { - return "", ErrMissingSignature - } - - tlvBytes, err := ir.encode() + tlvBytes, err := ir.EncodeSigned() if err != nil { return "", err } - if err := verifyInvoiceRequest(ir); err != nil { - return "", err - } - return encodeBech32(HRPInvoiceRequest, tlvBytes) } diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 4036eba427..28ff819ee7 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -2,6 +2,7 @@ package bolt12 import ( "bytes" + "strings" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -319,33 +320,9 @@ func TestDecodeInvoiceRequestString(t *testing.T) { require.Equal(t, "A Mathematical Treatise", string(desc)) } -// TestInvoiceRequestStringRoundTrip pins the encode→decode identity of the -// lnr wrapper pair: the recovered request must re-encode to the original TLV -// stream byte-for-byte. -func TestInvoiceRequestStringRoundTrip(t *testing.T) { - t.Parallel() - - ir := validInvoiceRequest(t) - - encoded, err := EncodeInvoiceRequestString(ir) - require.NoError(t, err) - require.NotEmpty(t, encoded) - - decoded, err := DecodeInvoiceRequestString( - encoded, bitcoinMainnetGenesisHash, - ) - require.NoError(t, err) - - originalBytes, err := ir.encode() - require.NoError(t, err) - decodedBytes, err := decoded.encode() - require.NoError(t, err) - require.Equal(t, originalBytes, decodedBytes) -} - -// TestEncodeInvoiceRequestStringInvalid asserts the wrapper refuses to emit -// a request that fails writer validation. -func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { +// TestEncodeSignedInvalid asserts EncodeSigned refuses to emit a request +// that fails writer validation. +func TestEncodeSignedInvalid(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) @@ -353,29 +330,28 @@ func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { tlv.TlvType88, *btcec.PublicKey, ]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrMissingPayerID) require.Empty(t, encoded) } -// TestEncodeInvoiceRequestStringUnsigned asserts the wire-string layer -// refuses to emit an unsigned invoice request: the signature becomes -// mandatory at the bech32 boundary even though pre-sign Encode is permitted. -func TestEncodeInvoiceRequestStringUnsigned(t *testing.T) { +// TestEncodeSignedUnsigned asserts EncodeSigned refuses to emit an unsigned +// invoice request. An invoice request reaches a peer as raw TLV, so this is +// the boundary that makes the writer-side signature MUST unavoidable. +func TestEncodeSignedUnsigned(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrMissingSignature) require.Empty(t, encoded) } -// TestEncodeInvoiceRequestStringInvalidSignature asserts the wire-string -// layer refuses to emit a request whose signature does not verify against -// invreq_payer_id. -func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { +// TestEncodeSignedInvalidSignature asserts EncodeSigned refuses to emit a +// request whose signature does not verify against invreq_payer_id. +func TestEncodeSignedInvalidSignature(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) @@ -386,7 +362,36 @@ func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(2000)), ) - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrInvalidSignature) require.Empty(t, encoded) } + +// TestEncodeInvoiceRequestString asserts that a signed request round-trips +// through its lnr1 string, and that an unsigned one is refused, as it is in the +// raw TLV form. +func TestEncodeInvoiceRequestString(t *testing.T) { + t.Parallel() + + ir := validInvoiceRequest(t) + + lnr, err := EncodeInvoiceRequestString(ir) + require.NoError(t, err) + require.True(t, strings.HasPrefix(lnr, HRPInvoiceRequest+"1")) + + decoded, err := DecodeInvoiceRequestString( + lnr, bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + want, err := ir.EncodeSigned() + require.NoError(t, err) + got, err := decoded.EncodeSigned() + require.NoError(t, err) + require.Equal(t, want, got) + + ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} + lnr, err = EncodeInvoiceRequestString(ir) + require.ErrorIs(t, err, ErrMissingSignature) + require.Empty(t, lnr) +} From dd6af337c532cb30cc8f6d106e53296d18931880 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 29 Sep 2026 20:26:49 +0000 Subject: [PATCH 14/16] bolt12: follow the offer-less node id rule For an invoice that answers a request with no offer, BOLT 12 lets the payer reject it only if it cannot confirm invoice_node_id out of band. The payer published that request and never addressed the payee, so it usually has no key to compare against. ValidateInvoiceForPayment still required one and failed on nil, which left a payer no honest argument to pass. Its doc also named a node the payer sent to, which does not exist in this flow. A nil expectedNodeID is now accepted for such a request. A payer that did confirm a key passes it and gets the comparison, and a response to an offer still requires one. TestValidateInvoiceNodeID covers all three cases. Review: https://github.com/lightningnetwork/lnd/pull/11191#discussion_r4133985071 --- bolt12/validate.go | 44 +++++++++++++++++++++++++++-------------- bolt12/validate_test.go | 32 ++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/bolt12/validate.go b/bolt12/validate.go index f1a41830a0..8f06973eb7 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -1617,18 +1617,30 @@ func validateInvoiceExpiry(inv *Invoice, now time.Time) error { // validateInvoiceNodeID rejects an invoice that was not signed by the node the // payer expected to answer. Which node that is comes from the payer's own -// state: offer_issuer_id, the final blinded_node_id of the path it chose, or -// the node it addressed an offerless request to. None of that is derivable -// from the invoice, so validateInvoiceRead cannot make the comparison. -// ValidateInvoiceForPayment folds it in, as it does for validateInvoiceExpiry. +// state: offer_issuer_id, or the final blinded_node_id of the path it chose. +// None of that is derivable from the invoice, so validateInvoiceRead cannot +// make the comparison. ValidateInvoiceForPayment folds it in, as it does for +// validateInvoiceExpiry. // -// Skipping it is not cosmetic. Every node on the blinded path can answer with -// its own correctly signed invoice, and the reader accepts it, because the -// signature only has to agree with whatever invoice_node_id the invoice itself -// carries. -func validateInvoiceNodeID(inv *Invoice, +// Skipping it is not cosmetic for a response to an offer. Every node on the +// blinded path can answer with its own correctly signed invoice, and the +// reader accepts it, because the signature only has to agree with whatever +// invoice_node_id the invoice itself carries. +func validateInvoiceNodeID(inv *Invoice, req *InvoiceRequest, expectedNodeID *btcec.PublicKey) error { + // - otherwise (invoice_request without an offer): + // - MAY reject the invoice if it cannot confirm that invoice_node_id + // is correct, out-of-band. + // + // The payer published the invoice request and never addressed the + // payee, so it has a key to compare against only if it learned one out + // of band. Without one it passes nil. + isOfferResponse := req.OfferIssuerID.IsSome() || req.OfferPaths.IsSome() + if expectedNodeID == nil && !isOfferResponse { + return nil + } + if expectedNodeID == nil { return fmt.Errorf("%w: expected invoice_node_id", ErrNilPublicKey) @@ -1983,11 +1995,13 @@ func validateInvoiceRead(inv *Invoice, activeChain [32]byte, // ValidateInvoiceForPayment runs the full set of payer-side invoice checks in // one call against an invoice and its originating request. // -// expectedNodeID is the node the payer expects to have signed the invoice, -// and is always compared against invoice_node_id. It is offer_issuer_id for -// an offer that carried one, the final blinded_node_id on the path the payer -// chose for an offer that carried offer_paths, and the node it sent to for an -// offerless request. +// expectedNodeID is the node the payer expects to have signed the invoice, and +// is compared against invoice_node_id. It is offer_issuer_id for an offer that +// carried one, and the final blinded_node_id on the path the payer chose for an +// offer that carried offer_paths. For a request that answers no offer it is a +// key the payer confirmed out of band, or nil when it has none. Only that case +// accepts nil. For a response to an offer a nil expectedNodeID returns +// ErrNilPublicKey. func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, now time.Time, activeChain [32]byte, features InvoiceKnownFeatures, @@ -2005,5 +2019,5 @@ func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, return err } - return validateInvoiceNodeID(inv, expectedNodeID) + return validateInvoiceNodeID(inv, req, expectedNodeID) } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 2e2b89384a..f7ed86b935 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -934,42 +934,70 @@ func TestValidateReadRejectsBadSignature(t *testing.T) { // TestValidateInvoiceNodeID pins the binding the readers cannot check for // themselves: invoice_node_id must name the node the payer expected to -// answer, which is state only the payer holds. +// answer, which is state only the payer holds. A request that answers no +// offer is compared only against a key the payer confirmed out of band. func TestValidateInvoiceNodeID(t *testing.T) { t.Parallel() _, alicePub := aliceKey() _, bobPub := bobKey() + // An offer response carries offer_issuer_id or offer_paths, and a + // request that answers no offer carries neither. + offerResponse := &InvoiceRequest{ + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub), + ), + } + offerless := &InvoiceRequest{} + tests := []struct { name string + req *InvoiceRequest nodeID fn.Option[*btcec.PublicKey] expected *btcec.PublicKey wantErr error }{ { name: "matches the path's final node", + req: offerResponse, nodeID: fn.Some(bobPub), expected: bobPub, }, { name: "another node on the path impersonates", + req: offerResponse, nodeID: fn.Some(alicePub), expected: bobPub, wantErr: ErrUnexpectedInvoiceNodeID, }, { name: "invoice_node_id absent", + req: offerResponse, nodeID: fn.None[*btcec.PublicKey](), expected: bobPub, wantErr: ErrMissingNodeID, }, { name: "caller supplies no final node", + req: offerResponse, nodeID: fn.Some(bobPub), expected: nil, wantErr: ErrNilPublicKey, }, + { + name: "offerless request, no key confirmed", + req: offerless, + nodeID: fn.Some(alicePub), + expected: nil, + }, + { + name: "offerless request, confirmed key differs", + req: offerless, + nodeID: fn.Some(alicePub), + expected: bobPub, + wantErr: ErrUnexpectedInvoiceNodeID, + }, } for _, tc := range tests { @@ -988,7 +1016,7 @@ func TestValidateInvoiceNodeID(t *testing.T) { ) }) - err := validateInvoiceNodeID(inv, tc.expected) + err := validateInvoiceNodeID(inv, tc.req, tc.expected) if tc.wantErr == nil { require.NoError(t, err) return From 5789e68b1fc075203d8f23767c005f603ed754bc Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 24 Sep 2026 13:28:27 +0000 Subject: [PATCH 15/16] bolt12: document the package entry points and flows --- bolt12/doc.go | 86 +++++++++++------- bolt12/offer_test.go | 204 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 30 deletions(-) diff --git a/bolt12/doc.go b/bolt12/doc.go index 61cc552d65..58bd84f088 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -1,33 +1,59 @@ // Package bolt12 implements encoding, decoding, and validation for BOLT 12 // 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. -// -// Human-readable prefixes: -// - lno: Offer -// - lnr: Invoice Request -// - lni: Invoice -// -// # Codec Contract -// -// Encode validates before serialising and refuses to emit bytes that would fail -// the writer requirements, invalid bytes are unrepresentable on the wire. -// Low-level decoders stay permissive so diagnostic and fuzz harnesses can -// inspect malformed input. -// -// DecodeOfferString, DecodeInvoiceRequestString, and DecodeInvoiceString -// (with their Encode counterparts) are the consumer entry point. Each folds -// bech32, the per-message TLV codec, and the spec reader gates into one -// validated call. -// -// An invoice that arrives as the response to an invoice request needs two -// further bindings that the message alone cannot supply, the mirror match -// against that request and the blinded-path node binding. A payer holding -// that request gates the decoded invoice with ValidateInvoiceForPayment, -// which the string wrappers cannot do for it. Such an invoice arrives as raw -// TLV over an onion message rather than as a string, so the payer path -// decodes with DecodeInvoice and validates separately. +// reach into the daemon, and it takes the chain, the clock, the known feature +// bits and the node it expected to answer from its caller. Anything a message +// cannot prove about itself is the caller's to supply. +// +// BOLT 12 messages are TLV streams signed with BIP-340 Schnorr signatures over +// a Merkle tree of their own fields. +// +// # The flow +// +// A merchant publishes an offer out of band. A payer reads it with +// DecodeOfferString, mirrors its fields into a request with +// NewInvoiceRequestFromOffer, signs that with SignInvoiceRequest, and sends the +// EncodeSigned bytes inside an onion message. The receiver decodes them with +// DecodeInvoiceRequest, gates the result with ValidateInvoiceRequestRead, and +// answers with an invoice built by NewInvoiceFromRequest and signed with +// SignInvoice. The payer decodes that reply with DecodeInvoice, gates it with +// ValidateInvoiceForPayment, and pays the paths UsablePaths returns. +// TestOfferPaymentFlow in offer_test.go runs these steps in order, one per +// party. +// +// A payment can also start without an offer. The payer builds a request with no +// offer_issuer_id or offer_paths, signs it with SignInvoiceRequest, and +// publishes it with EncodeInvoiceRequestString as an lnr1 string, such as a QR +// code. The payee reads it with DecodeInvoiceRequestString and answers with an +// invoice built and signed as above, sent to invreq_paths or invreq_payer_id. +// The payer gates that invoice with ValidateInvoiceForPayment, passing a nil +// node id unless it confirmed the payee's key out of band. +// TestOfferlessPaymentFlow in offer_test.go runs these steps. +// +// # Wire form or string form +// +// An offer only ever travels out of band, as an lno1 string. An invoice_request +// and an invoice reach a peer as raw TLV inside an onion message, which is what +// EncodeSigned emits. An invoice also has an lni1 string, for display and for +// out-of-band delivery. An invoice_request that answers no offer is published +// as an lnr1 string instead, which EncodeInvoiceRequestString emits signed. +// +// # Pitfalls +// +// Gating a reply invoice against itself is not enough. The read gates check an +// invoice in isolation, but the mirror match against the request, the node +// binding and the expiry need state only the payer holds, and only +// ValidateInvoiceForPayment applies them. A payer that merely decodes and reads +// will accept a correctly signed invoice from the wrong node. +// +// Signing and encoding are independent. Signing reads the struct, so encoding +// never has to run first and does not require a signature. EncodeSigned and the +// string encoders are where a signature becomes mandatory. +// +// Unknown fields must survive a round trip. A signature covers the Merkle root +// over the message's own TLVs, so re-encoding has to reproduce the wire bytes. +// That is why decoding rejects a non-minimal encoding rather than normalising +// it, and why unknown TLVs are preserved verbatim. +// +// DecodeInvoiceStringUnvalidated skips every gate. Use it only to display an +// invoice that was validated when it was stored. package bolt12 diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 302394dcd1..9746e7265a 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/lnwire" @@ -278,3 +279,206 @@ func TestOfferStringRoundTrip(t *testing.T) { hex.EncodeToString(id2.SerializeCompressed()), ) } + +// TestOfferPaymentFlow simulates a payment for an offer, one step per party. +// The payee publishes an offer as an lno1 string, the payer answers it with a +// signed invoice request, and the payee replies with an invoice that the payer +// checks before it pays. +func TestOfferPaymentFlow(t *testing.T) { + t.Parallel() + + payeeKey, payeePub := aliceKey() + payerKey, payerPub := bobKey() + now := time.Unix(1234567890, 0).Add(time.Minute) + + // Payee: create the offer and publish it as an lno1 string, for + // example in a QR code. offer_issuer_id names the node that will sign + // the invoice. + offer := &Offer{ + OfferAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType8, TUint64](TUint64(1000)), + ), + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("coffee"), + ), + ), + OfferIssuerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType22](payeePub), + ), + } + lno, err := EncodeOfferString(offer) + require.NoError(t, err) + offerHash, err := OfferHash(offer) + require.NoError(t, err) + + // Payer: read the offer and mirror its fields into a request under a + // transient payer key. + scanned, err := DecodeOfferString(lno, now, bitcoinMainnetGenesisHash) + require.NoError(t, err) + req, err := NewInvoiceRequestFromOffer( + scanned, payerPub, []byte("unpredictable"), + bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + // Payer: sign the request and send the raw TLV in an onion message to + // the offer's node, with a reply path for the invoice. + reqSig, err := SignInvoiceRequest(req, payerKey) + require.NoError(t, err) + req.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](reqSig), + ) + reqWire, err := req.EncodeSigned() + require.NoError(t, err) + + // Payee: read the request, gate it, and find the offer it answers. The + // request mirrors the offer's fields, so it carries the same offer + // hash. + received, err := DecodeInvoiceRequest(reqWire) + require.NoError(t, err) + require.NoError(t, ValidateInvoiceRequestRead( + received, bitcoinMainnetGenesisHash, Bolt12Features, + )) + receivedHash, err := OfferHash(received) + require.NoError(t, err) + require.Equal(t, offerHash, receivedHash) + + // Payee: answer over the reply path with an invoice for the offer's + // amount, signed with the key offer_issuer_id names. + tmpl := validInvoice(t) + inv := NewInvoiceFromRequest(received) + inv.InvoiceCreatedAt = tmpl.InvoiceCreatedAt + inv.InvoicePaymentHash = tmpl.InvoicePaymentHash + inv.InvoicePaths = tmpl.InvoicePaths + inv.InvoiceBlindedPay = tmpl.InvoiceBlindedPay + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170]( + offer.OfferAmount.ValOpt().UnwrapOr(0), + ), + ) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](payeePub), + ) + invSig, err := SignInvoice(inv, payeeKey) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](invSig), + ) + invWire, err := inv.EncodeSigned() + require.NoError(t, err) + + // Payer: read the invoice and check it against its own request. The + // node id to expect comes from the offer's offer_issuer_id. + reply, err := DecodeInvoice(invWire) + require.NoError(t, err) + features := InvoiceKnownFeatures{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + } + issuerID := scanned.OfferIssuerID.ValOpt().UnwrapOr(nil) + require.NoError(t, ValidateInvoiceForPayment( + reply, req, now, bitcoinMainnetGenesisHash, features, issuerID, + )) + + // Payer: pay over the invoice's blinded paths. + require.NotEmpty(t, reply.UsablePaths(Bolt12Features)) +} + +// TestOfferlessPaymentFlow simulates a payment for an invoice request that +// answers no offer, one step per party. The payer publishes a signed request as +// an lnr1 string, the payee reads it and answers with an invoice, and the payer +// checks that invoice before it pays. +func TestOfferlessPaymentFlow(t *testing.T) { + t.Parallel() + + payerKey, payerPub := bobKey() + payeeKey, payeePub := aliceKey() + + // Payer: build the request. It carries no offer_issuer_id and no + // offer_paths. The payer's own key and the amount it will pay take + // the place of an offer. + req := &InvoiceRequest{ + InvreqMetadata: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType0]( + tlv.Blob("unpredictable"), + ), + ), + OfferDescription: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType10]( + tlv.Blob("refund"), + ), + ), + InvreqAmount: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(1000)), + ), + InvreqPayerID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType88](payerPub), + ), + } + + // Payer: sign with the invreq_payer_id key and publish the request as + // an lnr1 string, for example in a QR code. + reqSig, err := SignInvoiceRequest(req, payerKey) + require.NoError(t, err) + req.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](reqSig), + ) + lnr, err := EncodeInvoiceRequestString(req) + require.NoError(t, err) + + // Payee: read the scanned string. The reader gates run here, so a + // request without a valid signature stops at this step. + scanned, err := DecodeInvoiceRequestString( + lnr, bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + + // Payee: answer with an invoice for the requested amount, signed with + // its node key. It sends the encoded invoice in an onion message to + // invreq_paths, or to invreq_payer_id when there are none. + tmpl := validInvoice(t) + inv := NewInvoiceFromRequest(scanned) + inv.InvoiceCreatedAt = tmpl.InvoiceCreatedAt + inv.InvoicePaymentHash = tmpl.InvoicePaymentHash + inv.InvoicePaths = tmpl.InvoicePaths + inv.InvoiceBlindedPay = tmpl.InvoiceBlindedPay + inv.InvoiceAmount = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType170]( + scanned.InvreqAmount.ValOpt().UnwrapOr(0), + ), + ) + inv.InvoiceNodeID = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType176](payeePub), + ) + invSig, err := SignInvoice(inv, payeeKey) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](invSig), + ) + wire, err := inv.EncodeSigned() + require.NoError(t, err) + + // Payer: read the invoice and check it against the request it + // published. It confirmed no payee key out of band, so it passes nil. + received, err := DecodeInvoice(wire) + require.NoError(t, err) + now := time.Unix(1234567890, 0).Add(time.Minute) + features := InvoiceKnownFeatures{ + Invoice: Bolt12Features, + Blinded: Bolt12Features, + } + require.NoError(t, ValidateInvoiceForPayment( + received, req, now, bitcoinMainnetGenesisHash, features, nil, + )) + + // Payer: pay over the invoice's blinded paths. + require.NotEmpty(t, received.UsablePaths(Bolt12Features)) + + // A payer that did confirm a key out of band still rejects an invoice + // that another node signed. + require.ErrorIs(t, ValidateInvoiceForPayment( + received, req, now, bitcoinMainnetGenesisHash, features, + payerPub, + ), ErrUnexpectedInvoiceNodeID) +} From 3dda4c7b2d51452ca4537e1798af54a4de6e97fa Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 29 Sep 2026 11:11:59 +0000 Subject: [PATCH 16/16] bolt12: drop the unused strict features encoder The strict features record only ever decodes. Encode goes through lnwire's features record, so strictFeaturesEncoder never ran and duplicated lnwire's encoding. The record now passes its encode side through to lnwire's record, leaving one definition of the writer and the minimality check where it matters, on decode. That makes minimal output on encode an lnwire property we rely on, so a test pins it: every features field of an offer, invoice request and invoice is written minimally, and the strict decoder accepts the result. --- bolt12/decode_test.go | 116 ++++++++++++++++++++++++++++++++++++++++++ bolt12/subtypes.go | 25 ++++----- 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/bolt12/decode_test.go b/bolt12/decode_test.go index d1ff88d067..f51376277a 100644 --- a/bolt12/decode_test.go +++ b/bolt12/decode_test.go @@ -81,6 +81,122 @@ func TestDecodeRejectsNonMinimalFeatures(t *testing.T) { } } +// TestEncodeWritesMinimalFeatures tests that every features field is written in +// its minimal form on encode. The Merkle leaves commit to the encoded bytes, so +// a padded vector would sign bytes the strict decoder rejects. +func TestEncodeWritesMinimalFeatures(t *testing.T) { + t.Parallel() + + // Clearing a high bit must not leave padding behind. Only bit 1 is + // left, whose minimal encoding is the single byte 0x02. + features := lnwire.NewRawFeatureVector() + features.Set(201) + features.Unset(201) + features.Set(1) + minimal := []byte{0x02} + + offer := validBobOffer(t) + offer.OfferFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12](*features), + ) + + invreq := validInvoiceRequest(t) + invreq.InvreqFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType84](*features), + ) + + // A request that answers an offer also mirrors offer_features. + _, payerPub := aliceKey() + offerReq, err := NewInvoiceRequestFromOffer( + offer, payerPub, []byte("metadata"), bitcoinMainnetGenesisHash, + ) + require.NoError(t, err) + offerReq.InvreqFeatures = invreq.InvreqFeatures + offerReq.InvreqAmount = invreq.InvreqAmount + + inv := validInvoice(t) + inv.OfferFeatures = offer.OfferFeatures + inv.InvreqFeatures = invreq.InvreqFeatures + inv.InvoiceFeatures = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType174](*features), + ) + + tests := []struct { + name string + encode func() ([]byte, error) + decode func([]byte) error + types []tlv.Type + }{ + { + name: "offer", + encode: offer.encode, + decode: func(b []byte) error { + _, err := decodeOffer(b) + return err + }, + types: []tlv.Type{offerFeaturesType}, + }, + { + name: "invoice_request for an offer", + encode: offerReq.encode, + decode: func(b []byte) error { + _, err := DecodeInvoiceRequest(b) + return err + }, + types: []tlv.Type{ + offerFeaturesType, invreqFeaturesType, + }, + }, + { + name: "invoice_request", + encode: invreq.encode, + decode: func(b []byte) error { + _, err := DecodeInvoiceRequest(b) + return err + }, + types: []tlv.Type{invreqFeaturesType}, + }, + { + name: "invoice", + encode: inv.encode, + decode: func(b []byte) error { + _, err := DecodeInvoice(b) + return err + }, + types: []tlv.Type{ + offerFeaturesType, invreqFeaturesType, + invoiceFeaturesType, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + encoded, err := tc.encode() + require.NoError(t, err) + + stream, err := tlv.NewStream() + require.NoError(t, err) + typeMap, err := stream.DecodeWithParsedTypesP2P( + bytes.NewReader(encoded), + ) + require.NoError(t, err) + + for _, typ := range tc.types { + require.Equal( + t, minimal, typeMap[typ], + "type %d", typ, + ) + } + + // The strict decoder accepts what encode wrote. + require.NoError(t, tc.decode(encoded)) + }) + } +} + // TestDecodeRejectsNonMinimalAmount tests that a non-minimally encoded // amount is rejected at decode, so the canonical re-encode of an accepted // message always reproduces the wire bytes. diff --git a/bolt12/subtypes.go b/bolt12/subtypes.go index 33f99a038f..4e5c30a5d8 100644 --- a/bolt12/subtypes.go +++ b/bolt12/subtypes.go @@ -66,28 +66,23 @@ const ( // would change the Merkle leaf bytes and invalidate an otherwise valid // signature. All three message types use it so the features fields decode // through one path. The payinfo features guard in decodeBlindedPayInfos is the -// same check one subtype level down. +// same check one subtype level down. The encoder passes through to lnwire's +// features record, which already writes the minimal form, so no second copy of +// the encoding exists to drift from it. func strictFeaturesRecord[T tlv.TlvType]( t *tlv.RecordT[T, lnwire.RawFeatureVector]) tlv.Record { + lnwireRec := t.Val.Record() + return tlv.MakeDynamicRecord( - t.TlvType(), &t.Val, - func() uint64 { return uint64(t.Val.SerializeSize()) }, - strictFeaturesEncoder, strictFeaturesDecoder, + t.TlvType(), &t.Val, lnwireRec.Size, + func(w io.Writer, _ any, _ *[8]byte) error { + return lnwireRec.Encode(w) + }, + strictFeaturesDecoder, ) } -// strictFeaturesEncoder writes the minimal feature vector bytes, matching the -// shared lnwire encoder. -func strictFeaturesEncoder(w io.Writer, val any, _ *[8]byte) error { - fv, ok := val.(*lnwire.RawFeatureVector) - if !ok { - return tlv.NewTypeForEncodingErr(val, "lnwire.RawFeatureVector") - } - - return fv.EncodeBase256(w) -} - // strictFeaturesDecoder decodes a feature vector and rejects a non-minimal // encoding, so every accepted message re-encodes to the bytes the signer // committed to.