From 0541094f6ac40e67f2fb11a3f589843c4fe7861d Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 10 Sep 2026 16:13:25 +0000 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 ba70bf8ed78e54965228cbfc01086f4b47c38165 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 11 Sep 2026 10:47:45 +0000 Subject: [PATCH 09/17] bolt12: un-export the symbols with no caller Three symbols are un-exported rather than deleted: the two invoice_request string wrappers and UsableFallbackAddresses. Nothing transports that message as a string today, and nothing dispatches an on-chain fallback yet, so none of the three has a caller. They stay because the wrappers carry the lnr prefix the spec defines, the helper encodes the reader's MUST-ignore rules for fallback addresses, and their tests exercise both. Finding F22. --- bolt12/fuzz_test.go | 2 +- bolt12/invoice.go | 4 ++-- bolt12/invoice_request.go | 8 ++++---- bolt12/invoice_request_test.go | 12 ++++++------ bolt12/invoice_test.go | 2 +- bolt12/validate.go | 28 +++++++++++++++++----------- 6 files changed, 31 insertions(+), 25 deletions(-) diff --git a/bolt12/fuzz_test.go b/bolt12/fuzz_test.go index f3bc741afd..5aba7cf3d1 100644 --- a/bolt12/fuzz_test.go +++ b/bolt12/fuzz_test.go @@ -186,7 +186,7 @@ func FuzzDecodeOfferString(f *testing.F) { // reader gates included. func FuzzDecodeInvoiceRequestString(f *testing.F) { fuzzStringCodec(f, func(s string) { - _, _ = DecodeInvoiceRequestString( + _, _ = decodeInvoiceRequestString( s, bitcoinMainnetGenesisHash, ) }, invreqStringSeeds(f)...) 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_request.go b/bolt12/invoice_request.go index e156260a3c..3baa6f6699 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -237,11 +237,11 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) { return &ir, nil } -// DecodeInvoiceRequestString decodes a BOLT 12 invoice request from its bech32 +// decodeInvoiceRequestString decodes a BOLT 12 invoice request from its bech32 // string representation (lnr1...). The spec reader gates (chain, features, // signature) are folded in via ValidateInvoiceRequestRead, with activeChain // gating the invreq_chain rule. -func DecodeInvoiceRequestString(s string, +func decodeInvoiceRequestString(s string, activeChain [32]byte) (*InvoiceRequest, error) { hrp, tlvBytes, err := decodeBech32(s) @@ -268,12 +268,12 @@ func DecodeInvoiceRequestString(s string, return ir, nil } -// EncodeInvoiceRequestString encodes a signed invoice request to its bech32 +// 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. -func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { +func encodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { if !ir.Signature.IsSome() { return "", ErrMissingSignature } diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 83f2cc7a09..4c6be0887a 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -279,7 +279,7 @@ func TestDecodeInvoiceRequestString(t *testing.T) { "k95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu0" + "4qz3slje2rfthc89vss" - ir, err := DecodeInvoiceRequestString(lnrStr, bitcoinMainnetGenesisHash) + ir, err := decodeInvoiceRequestString(lnrStr, bitcoinMainnetGenesisHash) require.NoError(t, err) // Verify invreq_metadata is set (8 zero bytes). @@ -327,11 +327,11 @@ func TestInvoiceRequestStringRoundTrip(t *testing.T) { ir := validInvoiceRequest(t) - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := encodeInvoiceRequestString(ir) require.NoError(t, err) require.NotEmpty(t, encoded) - decoded, err := DecodeInvoiceRequestString( + decoded, err := decodeInvoiceRequestString( encoded, bitcoinMainnetGenesisHash, ) require.NoError(t, err) @@ -353,7 +353,7 @@ func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { tlv.TlvType88, *btcec.PublicKey, ]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := encodeInvoiceRequestString(ir) require.ErrorIs(t, err, ErrMissingPayerID) require.Empty(t, encoded) } @@ -367,7 +367,7 @@ func TestEncodeInvoiceRequestStringUnsigned(t *testing.T) { ir := validInvoiceRequest(t) ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := encodeInvoiceRequestString(ir) require.ErrorIs(t, err, ErrMissingSignature) require.Empty(t, encoded) } @@ -386,7 +386,7 @@ func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(2000)), ) - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := encodeInvoiceRequestString(ir) require.ErrorIs(t, err, ErrInvalidSignature) require.Empty(t, encoded) } 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 741bd82ae205028f5cd1fd900ffe2ca2e94e8482 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:34:30 +0000 Subject: [PATCH 10/17] 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 3baa6f6699..c94f0a4148 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 f6dcdd808207ed3957c99e925612d88b78fdf5a3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:34:43 +0000 Subject: [PATCH 11/17] 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 ec9ffa4c05e801ebab84f56f151a11d7f89e28e1 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:34:55 +0000 Subject: [PATCH 12/17] 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 5aba7cf3d1..90f6e1bc95 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 c94f0a4148..c87adc1db6 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 4c6be0887a..3a517a279b 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 8ff4b4002ac80eb656955012e3da46d0cdad6289 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Sep 2026 15:35:15 +0000 Subject: [PATCH 13/17] bolt12: drop the invoice request string encoder Nothing transports an invoice request as a string. It reaches a peer as raw TLV inside an onion message, so the bech32 encoder had no caller once EncodeSigned took over the signature rule, leaving a tested function that no code path could reach. The decoder stays: another implementation may hand us an lnr string, and the fuzz harness exercises that path. The three tests that pinned the encoder's refusal to emit an invalid, unsigned or badly signed request move to EncodeSigned, which is where that rule now lives. --- bolt12/invoice_request.go | 22 ---------- bolt12/invoice_request_test.go | 51 ++++++---------------- docs/release-notes/release-notes-0.22.0.md | 6 +-- 3 files changed, 16 insertions(+), 63 deletions(-) diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index c87adc1db6..9332c7c891 100644 --- a/bolt12/invoice_request.go +++ b/bolt12/invoice_request.go @@ -295,28 +295,6 @@ func decodeInvoiceRequestString(s string, return ir, nil } -// 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. -func encodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { - if !ir.Signature.IsSome() { - return "", ErrMissingSignature - } - - tlvBytes, err := ir.encode() - if err != nil { - return "", err - } - - if err := verifyInvoiceRequest(ir); err != nil { - return "", err - } - - return encodeBech32(HRPInvoiceRequest, tlvBytes) -} - // NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying // (mirroring) all fields from the provided Offer. It assigns the payer ID and // payer metadata; the caller should subsequently sign the request. diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 3a517a279b..f3b36ef203 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -319,33 +319,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 +329,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 +361,7 @@ 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) } diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index b0913ff6bf..5ba6b0c9de 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -144,9 +144,9 @@ * [BOLT 12 string codecs and payment validation](https://github.com/lightningnetwork/lnd/pull/11146): add - validated `Decode`/`Encode` string entry points for offers, invoice - requests, and invoices, and `ValidateInvoiceForPayment` to bundle the - payer-side invoice checks into one call. + validated `Decode`/`Encode` string entry points for offers and invoices, + and `ValidateInvoiceForPayment` to bundle the payer-side invoice checks + into one call. ## Testing From ddea711d448ec05e0b5cb53545a2acbd2ae5258e Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 24 Sep 2026 13:28:27 +0000 Subject: [PATCH 14/17] bolt12: document the package entry points and flows --- bolt12/doc.go | 76 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/bolt12/doc.go b/bolt12/doc.go index 61cc552d65..45120ec4c5 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -1,33 +1,49 @@ // 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. +// +// # 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. There is deliberately no invoice_request string +// encoder, because nothing emits that form, while its decoder stays because +// another implementation may hand us an lnr1 string. +// +// # 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 From 43cdc7d457143f085e18830eec0d431b373febad Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 1 Sep 2026 11:16:31 +0000 Subject: [PATCH 15/17] sqldb: add offer store schema Add the offers table migration and its generated sqlc queries. Offers are long-lived reusable templates, a lifecycle with no BOLT 11 analog, so they get their own table rather than extending invoices. The table keeps the encoded offer and the local state only. The lno1 string is the content, and it never changes after creation. The local state is the data that no offer or invoice request carries: the disabled flag and the creation time. No column holds a typed copy of an offer field. This is enough because the invoice request carries the offer terms. The payer copies every offer field into the request, and the offer hash covers the whole offer range. A lookup by that hash therefore proves an exact match, so a spec extension needs no migration. The hash is UNIQUE, which also indexes it for the lookup. The queries are the insert and the lookup by hash, the two the offer store calls. A table with one row per TLV record was also considered. It needs a query over many rows per offer, and it loses the column types. --- sqldb/migrations.go | 5 ++ sqldb/sqlc/migrations/000016_offers.down.sql | 1 + sqldb/sqlc/migrations/000016_offers.up.sql | 21 ++++++++ sqldb/sqlc/models.go | 8 +++ sqldb/sqlc/offers.sql.go | 57 ++++++++++++++++++++ sqldb/sqlc/querier.go | 2 + sqldb/sqlc/queries/offers.sql | 11 ++++ 7 files changed, 105 insertions(+) create mode 100644 sqldb/sqlc/migrations/000016_offers.down.sql create mode 100644 sqldb/sqlc/migrations/000016_offers.up.sql create mode 100644 sqldb/sqlc/offers.sql.go create mode 100644 sqldb/sqlc/queries/offers.sql diff --git a/sqldb/migrations.go b/sqldb/migrations.go index 241e5c0d68..a174b40627 100644 --- a/sqldb/migrations.go +++ b/sqldb/migrations.go @@ -136,6 +136,11 @@ var ( Version: 18, SchemaVersion: 15, }, + { + Name: "000016_offers", + Version: 19, + SchemaVersion: 16, + }, }, migrationAdditions...) // ErrMigrationMismatch is returned when a migrated record does not diff --git a/sqldb/sqlc/migrations/000016_offers.down.sql b/sqldb/sqlc/migrations/000016_offers.down.sql new file mode 100644 index 0000000000..d47b5175cb --- /dev/null +++ b/sqldb/sqlc/migrations/000016_offers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS offers; diff --git a/sqldb/sqlc/migrations/000016_offers.up.sql b/sqldb/sqlc/migrations/000016_offers.up.sql new file mode 100644 index 0000000000..31021c9ff0 --- /dev/null +++ b/sqldb/sqlc/migrations/000016_offers.up.sql @@ -0,0 +1,21 @@ +-- offers stores long-lived BOLT 12 offer templates. Each offer can generate +-- many invoices over its lifetime. +CREATE TABLE IF NOT EXISTS offers ( + -- Primary key for the offer record. + id INTEGER PRIMARY KEY, + + -- The SHA256 hash of the TLV-encoded offer, used as a unique external + -- identifier. 32 bytes. + hash BLOB NOT NULL UNIQUE, + + -- The full bech32-encoded offer string (lno1...). This is the + -- authoritative source for all offer fields. + encoded TEXT NOT NULL, + + -- Whether the offer has been administratively disabled. A disabled + -- offer rejects new invoice requests. + is_disabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Timestamp of when this offer was created. + created_at TIMESTAMP NOT NULL +); diff --git a/sqldb/sqlc/models.go b/sqldb/sqlc/models.go index ef9aa9006f..159c041aee 100644 --- a/sqldb/sqlc/models.go +++ b/sqldb/sqlc/models.go @@ -214,6 +214,14 @@ type MigrationTracker struct { MigrationTime time.Time } +type Offer struct { + ID int64 + Hash []byte + Encoded string + IsDisabled bool + CreatedAt time.Time +} + type Payment struct { ID int64 AmountMsat int64 diff --git a/sqldb/sqlc/offers.sql.go b/sqldb/sqlc/offers.sql.go new file mode 100644 index 0000000000..9033c2e3bd --- /dev/null +++ b/sqldb/sqlc/offers.sql.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: offers.sql + +package sqlc + +import ( + "context" + "time" +) + +const getOfferByHash = `-- name: GetOfferByHash :one +SELECT id, hash, encoded, is_disabled, created_at +FROM offers +WHERE hash = $1 +` + +func (q *Queries) GetOfferByHash(ctx context.Context, hash []byte) (Offer, error) { + row := q.db.QueryRowContext(ctx, getOfferByHash, hash) + var i Offer + err := row.Scan( + &i.ID, + &i.Hash, + &i.Encoded, + &i.IsDisabled, + &i.CreatedAt, + ) + return i, err +} + +const insertOffer = `-- name: InsertOffer :one +INSERT INTO offers ( + hash, encoded, is_disabled, created_at +) VALUES ( + $1, $2, $3, $4 +) RETURNING id +` + +type InsertOfferParams struct { + Hash []byte + Encoded string + IsDisabled bool + CreatedAt time.Time +} + +func (q *Queries) InsertOffer(ctx context.Context, arg InsertOfferParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertOffer, + arg.Hash, + arg.Encoded, + arg.IsDisabled, + arg.CreatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 9b95a66991..f3655d7b61 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -143,6 +143,7 @@ type Querier interface { GetNodesByBlockHeightRange(ctx context.Context, arg GetNodesByBlockHeightRangeParams) ([]GraphNode, error) GetNodesByIDs(ctx context.Context, ids []int64) ([]GraphNode, error) GetNodesByLastUpdateRange(ctx context.Context, arg GetNodesByLastUpdateRangeParams) ([]GraphNode, error) + GetOfferByHash(ctx context.Context, hash []byte) (Offer, error) GetPruneEntriesForHeights(ctx context.Context, heights []int64) ([]GraphPruneLog, error) GetPruneHashByHeight(ctx context.Context, blockHeight int64) ([]byte, error) GetPruneTip(ctx context.Context) (GraphPruneLog, error) @@ -198,6 +199,7 @@ type Querier interface { // is used because of the constraint in that query that requires a node update // to have a newer last_update than the existing node). InsertNodeMig(ctx context.Context, arg InsertNodeMigParams) (int64, error) + InsertOffer(ctx context.Context, arg InsertOfferParams) (int64, error) // Insert a new payment and return its ID. // When creating a payment we don't have a fail reason because we start the // payment process. diff --git a/sqldb/sqlc/queries/offers.sql b/sqldb/sqlc/queries/offers.sql new file mode 100644 index 0000000000..57d361c90d --- /dev/null +++ b/sqldb/sqlc/queries/offers.sql @@ -0,0 +1,11 @@ +-- name: InsertOffer :one +INSERT INTO offers ( + hash, encoded, is_disabled, created_at +) VALUES ( + $1, $2, $3, $4 +) RETURNING id; + +-- name: GetOfferByHash :one +SELECT * +FROM offers +WHERE hash = $1; From 4e3ee79b9a74a2a1b0b2364c945196b0600bcdc2 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 14 Apr 2026 16:20:01 +0200 Subject: [PATCH 16/17] offers: add offer store with insert and lookup Introduce the offers package with a SQL-backed store for BOLT 12 offers. The store keeps the encoded offer and the local state that the offer string does not carry. It has an insert and a lookup by offer hash. CreateOffer validates the parameters, encodes the offer and inserts it. --- offers/create.go | 195 +++++++++++++++++++++++++++++++ offers/create_test.go | 217 +++++++++++++++++++++++++++++++++++ offers/interface.go | 50 ++++++++ offers/sql_store.go | 131 +++++++++++++++++++++ offers/sql_store_test.go | 97 ++++++++++++++++ offers/test_postgres_test.go | 21 ++++ offers/test_sqlite_test.go | 14 +++ 7 files changed, 725 insertions(+) create mode 100644 offers/create.go create mode 100644 offers/create_test.go create mode 100644 offers/interface.go create mode 100644 offers/sql_store.go create mode 100644 offers/sql_store_test.go create mode 100644 offers/test_postgres_test.go create mode 100644 offers/test_sqlite_test.go diff --git a/offers/create.go b/offers/create.go new file mode 100644 index 0000000000..dfdd70bc17 --- /dev/null +++ b/offers/create.go @@ -0,0 +1,195 @@ +package offers + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/bolt12" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +var ( + // ErrMissingDescription is returned when the offer has an amount but no + // description. + ErrMissingDescription = errors.New("description required when amount " + + "is set") + + // ErrMissingIssuerKey is returned when no issuer public key is + // provided. + ErrMissingIssuerKey = errors.New("issuer public key required") + + // ErrExpiryNotInFuture is returned when the absolute expiry is not + // after the creation time, so no payer could use the offer. + ErrExpiryNotInFuture = errors.New("absolute expiry is not in the " + + "future") +) + +// CreateOfferParams holds the fields of a new offer. Each field maps to a +// matching offer TLV record. +type CreateOfferParams struct { + // Identity specifies how the offer identifies the receiver. Left holds + // the issuer public key (offer_issuer_id), which reveals the node + // identity. Right holds blinded message paths (offer_paths), which + // preserve privacy. + Identity fn.Either[*btcec.PublicKey, []lnwire.BlindedPath] + + // Description is the UTF-8 description of the payment purpose. Required + // when Amount is set. + Description string + + // AmountMsat is the per-item amount in millisatoshis. Zero means no + // fixed amount (the payer must specify invreq_amount). + AmountMsat uint64 + + // AbsoluteExpiry is seconds since epoch after which the offer expires. + // Zero leaves offer_absolute_expiry out, so the offer never expires. + AbsoluteExpiry uint64 + + // QuantityMax is the maximum items per invoice. None means the offer + // does not support quantity selection. Some(0) means unlimited + // quantity. + QuantityMax fn.Option[uint64] + + // Chains specifies which blockchain networks this offer is valid for. + // When empty, the spec defaults to Bitcoin mainnet, so non-mainnet + // offers must set this. + Chains [][32]byte +} + +// CreateOfferResult reports a created offer. Hash is the lookup key in the +// offer store. +type CreateOfferResult struct { + // Hash is the SHA256 hash of the TLV-encoded offer. + Hash [32]byte + + // Encoded is the bech32-encoded offer string (lno1...). + Encoded string +} + +// CreateOffer creates and stores a BOLT 12 offer. It returns the encoded string +// and the offer hash. It rejects an absolute expiry at or before the time of +// clk. +func CreateOffer(ctx context.Context, store Store, clk clock.Clock, + params CreateOfferParams) (*CreateOfferResult, error) { + + var identityErr error + params.Identity.WhenLeft(func(key *btcec.PublicKey) { + if key == nil { + identityErr = ErrMissingIssuerKey + } + }) + params.Identity.WhenRight(func(paths []lnwire.BlindedPath) { + if len(paths) == 0 { + identityErr = fmt.Errorf("offer_paths must " + + "contain at least one path") + } + }) + if identityErr != nil { + return nil, identityErr + } + + // The spec requires offer_description when offer_amount is set. + if params.AmountMsat > 0 && params.Description == "" { + return nil, ErrMissingDescription + } + + if params.AbsoluteExpiry > 0 && + params.AbsoluteExpiry <= uint64(clk.Now().Unix()) { + + return nil, ErrExpiryNotInFuture + } + + offer := &bolt12.Offer{} + + params.Identity.WhenLeft(func(key *btcec.PublicKey) { + offer.OfferIssuerID = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]{ + Val: key, + }, + ) + }) + params.Identity.WhenRight(func(paths []lnwire.BlindedPath) { + offer.OfferPaths = tlv.SomeRecordT( + tlv.RecordT[ + tlv.TlvType16, lnwire.BlindedPaths, + ]{ + Val: lnwire.BlindedPaths{ + Paths: paths, + }, + }, + ) + }) + + if len(params.Chains) > 0 { + offer.OfferChains = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType2, bolt12.ChainsRecord]{ + Val: bolt12.ChainsRecord{ + Chains: params.Chains, + }, + }, + ) + } + + if params.Description != "" { + offer.OfferDescription = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType10, tlv.Blob]{ + Val: []byte(params.Description), + }, + ) + } + + if params.AmountMsat > 0 { + amount := bolt12.TUint64(params.AmountMsat) + offer.OfferAmount = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType8, bolt12.TUint64]{ + Val: amount, + }, + ) + } + + if params.AbsoluteExpiry > 0 { + expiry := bolt12.TUint64(params.AbsoluteExpiry) + offer.OfferAbsoluteExpiry = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType14, bolt12.TUint64]{ + Val: expiry, + }, + ) + } + + params.QuantityMax.WhenSome(func(qty uint64) { + offer.OfferQuantityMax = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType20, bolt12.TUint64]{ + Val: bolt12.TUint64(qty), + }, + ) + }) + + encoded, err := bolt12.EncodeOfferString(offer) + if err != nil { + return nil, fmt.Errorf("encode offer: %w", err) + } + + offerHash, err := bolt12.OfferHash(offer) + if err != nil { + return nil, fmt.Errorf("offer hash: %w", err) + } + + // The encoded string carries every offer field. + _, err = store.InsertOffer(ctx, &Offer{ + Hash: offerHash, + Encoded: encoded, + }) + if err != nil { + return nil, fmt.Errorf("persist offer: %w", err) + } + + return &CreateOfferResult{ + Hash: offerHash, + Encoded: encoded, + }, nil +} diff --git a/offers/create_test.go b/offers/create_test.go new file mode 100644 index 0000000000..b797a21e93 --- /dev/null +++ b/offers/create_test.go @@ -0,0 +1,217 @@ +package offers + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/lightningnetwork/lnd/bolt12" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// issuerIdentity wraps a pubkey as a Left Either for CreateOfferParams. +func issuerIdentity(key *btcec.PublicKey) fn.Either[*btcec.PublicKey, + []lnwire.BlindedPath] { + + return fn.NewLeft[*btcec.PublicKey, []lnwire.BlindedPath](key) +} + +// testIssuerKey generates a deterministic private key for testing. +func testIssuerKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + + // Use a fixed seed for deterministic tests. + var seed [32]byte + for i := range seed { + seed[i] = byte(i + 1) + } + + privKey, _ := btcec.PrivKeyFromBytes(seed[:]) + + return privKey +} + +// decodeStoredOffer looks up an offer by its offer hash and decodes the stored +// string. +func decodeStoredOffer(t *testing.T, store Store, + offerHash [32]byte) *bolt12.Offer { + + t.Helper() + + got, err := store.GetOfferByHash(t.Context(), offerHash) + require.NoError(t, err) + + // The time is before every expiry in these tests. + decoded, err := bolt12.DecodeOfferString( + got.Encoded, time.Unix(0, 0), + [32]byte(*chaincfg.MainNetParams.GenesisHash), + ) + require.NoError(t, err) + + return decoded +} + +// TestCreateOffer verifies that CreateOffer validates its parameters, and +// that a created offer is stored and decodes with the requested fields. +func TestCreateOffer(t *testing.T) { + t.Parallel() + + privKey := testIssuerKey(t) + identity := issuerIdentity(privKey.PubKey()) + now := uint64(testTime.Unix()) + later := uint64(testTime.Add(time.Hour).Unix()) + + testCases := []struct { + name string + params CreateOfferParams + wantErr error + + // check inspects the decoded offer of a successful case. + check func(t *testing.T, offer *bolt12.Offer) + }{{ + name: "fixed amount", + params: CreateOfferParams{ + Identity: identity, + Description: "coffee", + AmountMsat: 10000, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + amount := offer.OfferAmount.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(10000), amount) + require.True(t, offer.OfferQuantityMax.IsNone()) + require.True(t, offer.OfferAbsoluteExpiry.IsNone()) + }, + }, { + name: "any amount", + params: CreateOfferParams{ + Identity: identity, + Description: "tips", + }, + check: func(t *testing.T, offer *bolt12.Offer) { + require.True(t, offer.OfferAmount.IsNone()) + }, + }, { + name: "amount without description", + params: CreateOfferParams{ + Identity: identity, + AmountMsat: 10000, + }, + wantErr: ErrMissingDescription, + }, { + name: "missing issuer key", + params: CreateOfferParams{ + Description: "test", + }, + wantErr: ErrMissingIssuerKey, + }, { + name: "expiry in the future", + params: CreateOfferParams{ + Identity: identity, + Description: "limited time", + AmountMsat: 5000, + AbsoluteExpiry: later, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + expiry := offer.OfferAbsoluteExpiry.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(later), expiry) + }, + }, { + name: "expiry before now", + params: CreateOfferParams{ + Identity: identity, + Description: "expired", + AmountMsat: 5000, + AbsoluteExpiry: now - 1, + }, + wantErr: ErrExpiryNotInFuture, + }, { + name: "expiry at now", + params: CreateOfferParams{ + Identity: identity, + Description: "expired", + AmountMsat: 5000, + AbsoluteExpiry: now, + }, + wantErr: ErrExpiryNotInFuture, + }, { + name: "quantity limit", + params: CreateOfferParams{ + Identity: identity, + Description: "stickers", + AmountMsat: 1000, + QuantityMax: fn.Some[uint64](10), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + qty := offer.OfferQuantityMax.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(10), qty) + }, + }, { + name: "unlimited quantity", + params: CreateOfferParams{ + Identity: identity, + Description: "stickers", + AmountMsat: 1000, + QuantityMax: fn.Some[uint64](0), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + qty := offer.OfferQuantityMax.UnwrapOrFailV(t) + require.Zero(t, qty) + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + result, err := CreateOffer( + t.Context(), store, testClock, tc.params, + ) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + // The stored offer is the one CreateOffer returned, + // and it carries the issuer id it was created with. + got, err := store.GetOfferByHash( + t.Context(), result.Hash, + ) + require.NoError(t, err) + require.Equal(t, result.Encoded, got.Encoded) + + offer := decodeStoredOffer(t, store, result.Hash) + issuer := offer.OfferIssuerID.UnwrapOrFailV(t) + require.True(t, privKey.PubKey().IsEqual(issuer)) + + tc.check(t, offer) + }) + } +} + +// TestCreateOfferDuplicate verifies that creating the same offer twice fails, +// because the offer hash is unique. +func TestCreateOfferDuplicate(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + ctx := t.Context() + privKey := testIssuerKey(t) + + params := CreateOfferParams{ + Identity: issuerIdentity(privKey.PubKey()), + Description: "coffee", + AmountMsat: 10000, + } + + _, err := CreateOffer(ctx, store, testClock, params) + require.NoError(t, err) + + // The same parameters produce the same offer hash. + _, err = CreateOffer(ctx, store, testClock, params) + require.ErrorIs(t, err, ErrOfferExists) +} diff --git a/offers/interface.go b/offers/interface.go new file mode 100644 index 0000000000..49faaa8ca8 --- /dev/null +++ b/offers/interface.go @@ -0,0 +1,50 @@ +package offers + +import ( + "context" + "errors" + "time" +) + +var ( + // ErrOfferNotFound is returned when no offer has the requested offer + // hash. + ErrOfferNotFound = errors.New("offer not found") + + // ErrOfferExists is returned when an offer with the same offer hash is + // already stored. + ErrOfferExists = errors.New("offer already exists") +) + +// Offer represents a persisted BOLT 12 offer. It keeps the encoded offer and +// the local state that the offer string does not carry. Code that needs an +// offer field decodes Encoded. +type Offer struct { + // ID is the database primary key. + ID int64 + + // Hash is the SHA256 hash of the TLV-encoded offer, used as a unique + // external identifier. + Hash [32]byte + + // Encoded is the full bech32-encoded offer string (lno1...). + Encoded string + + // IsDisabled indicates the offer has been administratively disabled. + IsDisabled bool + + // CreatedAt is the time the store persisted the offer. + CreatedAt time.Time +} + +// Store defines the interface for persisting and querying BOLT 12 offers. +type Store interface { + // InsertOffer persists a new offer and returns its database ID. The + // store sets the creation time. It returns ErrOfferExists if an offer + // with the same offer hash is already stored. + InsertOffer(ctx context.Context, offer *Offer) (int64, error) + + // GetOfferByHash retrieves an offer by its 32-byte offer hash. It + // returns ErrOfferNotFound if no offer has that hash. + GetOfferByHash(ctx context.Context, offerHash [32]byte) (*Offer, error) +} diff --git a/offers/sql_store.go b/offers/sql_store.go new file mode 100644 index 0000000000..c77658f3f8 --- /dev/null +++ b/offers/sql_store.go @@ -0,0 +1,131 @@ +package offers + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/lightningnetwork/lnd/sqldb/sqlc" +) + +// SQLOfferQueries is the interface that defines the set of operations that can +// be executed against the offers SQL database. +type SQLOfferQueries interface { + // InsertOffer inserts an offer row and returns its primary key. It + // fails with a unique constraint violation when the offer hash is + // already stored. + InsertOffer(ctx context.Context, + arg sqlc.InsertOfferParams) (int64, error) + + // GetOfferByHash returns the offer row with the given offer hash, or + // sql.ErrNoRows when no row has it. + GetOfferByHash(ctx context.Context, + offerHash []byte) (sqlc.Offer, error) +} + +// BatchedSQLOfferQueries combines the offer queries interface with batched +// transaction execution. +type BatchedSQLOfferQueries interface { + SQLOfferQueries + + sqldb.BatchedTx[SQLOfferQueries] +} + +// SQLStore is the SQL-backed implementation of the Store interface. +type SQLStore struct { + db BatchedSQLOfferQueries + clock clock.Clock +} + +// NewSQLStore creates a new SQL-backed offer store. +func NewSQLStore(db BatchedSQLOfferQueries, clock clock.Clock) *SQLStore { + return &SQLStore{ + db: db, + clock: clock, + } +} + +// InsertOffer persists a new offer and returns its database ID. The store sets +// the creation time from its clock. It returns ErrOfferExists if an offer with +// the same offer hash is already stored. +func (s *SQLStore) InsertOffer(ctx context.Context, offer *Offer) (int64, + error) { + + var id int64 + + err := s.db.ExecTx( + ctx, sqldb.WriteTxOpt(), + func(q SQLOfferQueries) error { + var err error + id, err = q.InsertOffer(ctx, sqlc.InsertOfferParams{ + Hash: offer.Hash[:], + Encoded: offer.Encoded, + IsDisabled: offer.IsDisabled, + CreatedAt: s.clock.Now().UTC(), + }) + + return err + }, + sqldb.NoOpReset, + ) + if err != nil { + var uniqueErr *sqldb.ErrSQLUniqueConstraintViolation + if errors.As(sqldb.MapSQLError(err), &uniqueErr) { + return 0, ErrOfferExists + } + + return 0, fmt.Errorf("insert offer: %w", err) + } + + return id, nil +} + +// GetOfferByHash retrieves an offer by its 32-byte offer hash. It returns +// ErrOfferNotFound if no offer has that hash. +func (s *SQLStore) GetOfferByHash(ctx context.Context, offerHash [32]byte) ( + *Offer, error) { + + var offer *Offer + + err := s.db.ExecTx( + ctx, sqldb.ReadTxOpt(), + func(q SQLOfferQueries) error { + row, err := q.GetOfferByHash(ctx, offerHash[:]) + if err != nil { + return err + } + + offer = marshalOffer(row) + + return nil + }, + sqldb.NoOpReset, + ) + + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrOfferNotFound + + case err != nil: + return nil, fmt.Errorf("get offer by offer hash: %w", err) + } + + return offer, nil +} + +// marshalOffer converts a sqlc.Offer row to our domain Offer type. +func marshalOffer(row sqlc.Offer) *Offer { + offer := &Offer{ + ID: row.ID, + Encoded: row.Encoded, + IsDisabled: row.IsDisabled, + CreatedAt: row.CreatedAt, + } + + copy(offer.Hash[:], row.Hash) + + return offer +} diff --git a/offers/sql_store_test.go b/offers/sql_store_test.go new file mode 100644 index 0000000000..d62a97d968 --- /dev/null +++ b/offers/sql_store_test.go @@ -0,0 +1,97 @@ +package offers + +import ( + "crypto/sha256" + "database/sql" + "testing" + "time" + + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/stretchr/testify/require" +) + +// testTime is the time the test clock reports. It has no sub-second part, so +// it survives the database round trip unchanged. +var testTime = time.Unix(1735689600, 0).UTC() + +// testClock is the clock the tests give CreateOffer. It stands at testTime, +// like the clock of the test store. +var testClock = clock.NewTestClock(testTime) + +// newTestSQLStore creates an offer store on the database that the build tags +// select. +func newTestSQLStore(t *testing.T) *SQLStore { + t.Helper() + + db := newTestBaseDB(t) + + executor := sqldb.NewTransactionExecutor( + db, + func(tx *sql.Tx) SQLOfferQueries { + return db.WithTx(tx) + }, + ) + + return NewSQLStore(executor, clock.NewTestClock(testTime)) +} + +// testOffer returns an offer with a synthetic encoding and offer hash. +func testOffer() *Offer { + encoded := "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua" + + "9uve2rlrm9deu7xyfzrcgqyqs" + + return &Offer{ + Hash: sha256.Sum256([]byte(encoded)), + Encoded: encoded, + } +} + +// TestInsertAndGetOffer verifies that an offer round-trips through insert and +// lookup by offer hash, and that the store sets the creation time. +func TestInsertAndGetOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + ctx := t.Context() + offer := testOffer() + + id, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + + got, err := store.GetOfferByHash(ctx, offer.Hash) + require.NoError(t, err) + require.Equal(t, &Offer{ + ID: id, + Hash: offer.Hash, + Encoded: offer.Encoded, + CreatedAt: testTime, + }, got) +} + +// TestInsertDuplicateOffer verifies that a second offer with the same offer +// hash is rejected with ErrOfferExists. +func TestInsertDuplicateOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + ctx := t.Context() + offer := testOffer() + + _, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + + _, err = store.InsertOffer(ctx, offer) + require.ErrorIs(t, err, ErrOfferExists) +} + +// TestGetUnknownOffer verifies that a lookup of an unknown offer hash returns +// ErrOfferNotFound. +func TestGetUnknownOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + + _, err := store.GetOfferByHash(t.Context(), [32]byte{1}) + require.ErrorIs(t, err, ErrOfferNotFound) +} diff --git a/offers/test_postgres_test.go b/offers/test_postgres_test.go new file mode 100644 index 0000000000..56620269f4 --- /dev/null +++ b/offers/test_postgres_test.go @@ -0,0 +1,21 @@ +//go:build test_db_postgres + +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// newTestBaseDB creates a Postgres database for the offer store tests. +func newTestBaseDB(t *testing.T) *sqldb.BaseDB { + fixture := sqldb.NewTestPgFixture( + t, sqldb.DefaultPostgresFixtureLifetime, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + return sqldb.NewTestPostgresDB(t, fixture).BaseDB +} diff --git a/offers/test_sqlite_test.go b/offers/test_sqlite_test.go new file mode 100644 index 0000000000..c464242b06 --- /dev/null +++ b/offers/test_sqlite_test.go @@ -0,0 +1,14 @@ +//go:build !test_db_postgres + +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// newTestBaseDB creates a SQLite database for the offer store tests. +func newTestBaseDB(t *testing.T) *sqldb.BaseDB { + return sqldb.NewTestSqliteDB(t).BaseDB +} From bd49608ae5853348353903ff1de6a2f6dde8a07e Mon Sep 17 00:00:00 2001 From: bitromortac Date: Mon, 28 Sep 2026 18:27:20 +0000 Subject: [PATCH 17/17] docs: add release note for the offer store Add the offer store to the BOLT 12 section of the 0.22.0 release notes. The PR link holds a placeholder until the PR number is known. --- docs/release-notes/release-notes-0.22.0.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 5ba6b0c9de..8cd23b9382 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -148,6 +148,13 @@ and `ValidateInvoiceForPayment` to bundle the payer-side invoice checks into one call. +* [BOLT 12 offer + store](https://github.com/lightningnetwork/lnd/pull/XXXXX): add the + `offers` package with a SQL-backed store for BOLT 12 offers, and the + `offers` table migration. The store keeps the encoded offer, a disabled + flag and the creation time. It finds an offer by its hash. + `CreateOffer` validates the parameters, encodes the offer and stores it. + ## Testing * [BOLT 12 spec test vectors](https://github.com/lightningnetwork/lnd/pull/11001):