Merge pull request #11191 from bitromortac/2604-bolt12-1h
What changed, and why it matters
This commit finalizes LND's new BOLT 12 codec package. It is mostly a refactor: public Encode/Decode helpers are renamed to internal encodeBech32/decodeBech32, validation helpers are unexported, and new helpers such as EncodeSigned and OfferHash are added. The most user-visible change is that the bech32 decoder no longer rejects very long strings on its own; it now expects the caller (onion-message envelope, RPC, or CLI) to enforce size limits. The commit also adds a display-only DecodeInvoiceStringUnvalidated entry point and tightens signing so that SignInvoice/SignInvoiceRequest run writer validation before producing a signature. There is no explicit bug fix or security patch in the diff; the changes are defensive API hardening.
Treat this as a routine refactor/finalization commit rather than an urgent security patch. Review call sites that previously relied on Decode rejecting oversized strings to ensure they now enforce their own limits. Verify that new EncodeSigned paths are used wherever serialized BOLT 12 messages leave the node, and that DecodeInvoiceStringUnvalidated is only used for already-validated stored data.
Security signals we found
Removed decoder-side length limits on BOLT 12 bech32 strings; caller must bound input
Added EncodeSigned entry points that enforce signature presence and verification before serialization leaves the node
Signing now runs writer validation before producing a signature
Added DecodeInvoiceStringUnvalidated display helper that skips reader gates
Tightened invoice reader to reject unknown even TLVs inside the signature range (240-1000)
OfferHash added for exact-match offer lookup, including unknown TLVs in offer range
strictFeaturesRecord encoder changed to reuse lnwire minimal encoding
Evidence from the diff
The commit renames exported bech32 helpers Encode/Decode to unexported encodeBech32/decodeBech32 and removes the decoder-side maxBolt12StringLen/maxBolt12RawStringLen caps, moving the length policy to writers (encodeBech32) and to caller-enforced transport limits. It unexports most Validate* helpers and adds EncodeSigned methods on Invoice and InvoiceRequest that require and verify a signature before bytes leave the node. It adds DecodeInvoiceStringUnvalidated for database display use. It introduces OfferHash, matching Core Lightning’s offer_id construction. It makes SignInvoice/SignInvoiceRequest run writer validation before signing, and it fixes the strictFeaturesRecord encoder to delegate directly to lnwire’s minimal feature encoding. Several validation rules are tightened or clarified, including rejection of unknown even TLVs in the invoice signature range and proper handling of offerless invoice-request node-id validation.
Changed components
bolt12/bech32.gobolt12/invoice.gobolt12/invoice_request.gobolt12/offer.gobolt12/offer_hash.gobolt12/signature.gobolt12/subtypes.gobolt12/validate.goInspect captured patch +1275 / −512
### 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 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
-
- // 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
@@ -116,32 +103,18 @@ 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.
-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,
- )
- }
-
+// 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 decodeBech32(s string) (string, []byte, error) {
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)
}
@@ -183,19 +156,19 @@ 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)
}
// 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,
### 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,51 +293,53 @@ func TestDecodeUnprintableCharacter(t *testing.T) {
}
for _, input := range unprintable {
- _, _, err := Decode(input)
+ _, _, err := decodeBech32(input)
require.ErrorIs(t, err, ErrInvalidCharacter)
}
}
-// 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 := encodeBech32(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 := decodeBech32(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)
+ encoded, err := encodeBech32(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)
+ hrp, data, err := decodeBech32(wrapped)
require.NoError(t, err)
require.Equal(t, HRPOffer, hrp)
require.Equal(t, payload, data)
@@ -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",
@@ -410,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)
@@ -423,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)
@@ -438,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)
@@ -472,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)
### bolt12/decode_test.go
@@ -81,6 +81,122 @@ func TestDecodeRejectsNonMinimalFeatures(t *testing.T) {
}
}
+// TestEncodeWritesMinimalFeatures tests that every features field is written in
+// its minimal form on encode. The Merkle leaves commit to the encoded bytes, so
+// a padded vector would sign bytes the strict decoder rejects.
+func TestEncodeWritesMinimalFeatures(t *testing.T) {
+ t.Parallel()
+
+ // Clearing a high bit must not leave padding behind. Only bit 1 is
+ // left, whose minimal encoding is the single byte 0x02.
+ features := lnwire.NewRawFeatureVector()
+ features.Set(201)
+ features.Unset(201)
+ features.Set(1)
+ minimal := []byte{0x02}
+
+ offer := validBobOffer(t)
+ offer.OfferFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType12](*features),
+ )
+
+ invreq := validInvoiceRequest(t)
+ invreq.InvreqFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType84](*features),
+ )
+
+ // A request that answers an offer also mirrors offer_features.
+ _, payerPub := aliceKey()
+ offerReq, err := NewInvoiceRequestFromOffer(
+ offer, payerPub, []byte("metadata"), bitcoinMainnetGenesisHash,
+ )
+ require.NoError(t, err)
+ offerReq.InvreqFeatures = invreq.InvreqFeatures
+ offerReq.InvreqAmount = invreq.InvreqAmount
+
+ inv := validInvoice(t)
+ inv.OfferFeatures = offer.OfferFeatures
+ inv.InvreqFeatures = invreq.InvreqFeatures
+ inv.InvoiceFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType174](*features),
+ )
+
+ tests := []struct {
+ name string
+ encode func() ([]byte, error)
+ decode func([]byte) error
+ types []tlv.Type
+ }{
+ {
+ name: "offer",
+ encode: offer.encode,
+ decode: func(b []byte) error {
+ _, err := decodeOffer(b)
+ return err
+ },
+ types: []tlv.Type{offerFeaturesType},
+ },
+ {
+ name: "invoice_request for an offer",
+ encode: offerReq.encode,
+ decode: func(b []byte) error {
+ _, err := DecodeInvoiceRequest(b)
+ return err
+ },
+ types: []tlv.Type{
+ offerFeaturesType, invreqFeaturesType,
+ },
+ },
+ {
+ name: "invoice_request",
+ encode: invreq.encode,
+ decode: func(b []byte) error {
+ _, err := DecodeInvoiceRequest(b)
+ return err
+ },
+ types: []tlv.Type{invreqFeaturesType},
+ },
+ {
+ name: "invoice",
+ encode: inv.encode,
+ decode: func(b []byte) error {
+ _, err := DecodeInvoice(b)
+ return err
+ },
+ types: []tlv.Type{
+ offerFeaturesType, invreqFeaturesType,
+ invoiceFeaturesType,
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ encoded, err := tc.encode()
+ require.NoError(t, err)
+
+ stream, err := tlv.NewStream()
+ require.NoError(t, err)
+ typeMap, err := stream.DecodeWithParsedTypesP2P(
+ bytes.NewReader(encoded),
+ )
+ require.NoError(t, err)
+
+ for _, typ := range tc.types {
+ require.Equal(
+ t, minimal, typeMap[typ],
+ "type %d", typ,
+ )
+ }
+
+ // The strict decoder accepts what encode wrote.
+ require.NoError(t, tc.decode(encoded))
+ })
+ }
+}
+
// TestDecodeRejectsNonMinimalAmount tests that a non-minimally encoded
// amount is rejected at decode, so the canonical re-encode of an accepted
// message always reproduces the wire bytes.
### bolt12/doc.go
@@ -1,33 +1,59 @@
// Package bolt12 implements encoding, decoding, and validation for BOLT 12
// Offers, Invoice Requests, and Invoices. It is a codec library: it does not
-// reach into the daemon, and it takes the chain, the clock and the known
-// feature bits from its caller.
-//
-// BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant
-// and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields.
-//
-// Human-readable prefixes:
-// - lno: Offer
-// - lnr: Invoice Request
-// - lni: Invoice
-//
-// # Codec Contract
-//
-// Encode validates before serialising and refuses to emit bytes that would fail
-// the writer requirements, invalid bytes are unrepresentable on the wire.
-// Low-level decoders stay permissive so diagnostic and fuzz harnesses can
-// inspect malformed input.
-//
-// DecodeOfferString, DecodeInvoiceRequestString, and DecodeInvoiceString
-// (with their Encode counterparts) are the consumer entry point. Each folds
-// bech32, the per-message TLV codec, and the spec reader gates into one
-// validated call.
-//
-// An invoice that arrives as the response to an invoice request needs two
-// further bindings that the message alone cannot supply, the mirror match
-// against that request and the blinded-path node binding. A payer holding
-// that request gates the decoded invoice with ValidateInvoiceForPayment,
-// which the string wrappers cannot do for it. Such an invoice arrives as raw
-// TLV over an onion message rather than as a string, so the payer path
-// decodes with DecodeInvoice and validates separately.
+// reach into the daemon, and it takes the chain, the clock, the known feature
+// bits and the node it expected to answer from its caller. Anything a message
+// cannot prove about itself is the caller's to supply.
+//
+// BOLT 12 messages are TLV streams signed with BIP-340 Schnorr signatures over
+// a Merkle tree of their own fields.
+//
+// # The flow
+//
+// A merchant publishes an offer out of band. A payer reads it with
+// DecodeOfferString, mirrors its fields into a request with
+// NewInvoiceRequestFromOffer, signs that with SignInvoiceRequest, and sends the
+// EncodeSigned bytes inside an onion message. The receiver decodes them with
+// DecodeInvoiceRequest, gates the result with ValidateInvoiceRequestRead, and
+// answers with an invoice built by NewInvoiceFromRequest and signed with
+// SignInvoice. The payer decodes that reply with DecodeInvoice, gates it with
+// ValidateInvoiceForPayment, and pays the paths UsablePaths returns.
+// TestOfferPaymentFlow in offer_test.go runs these steps in order, one per
+// party.
+//
+// A payment can also start without an offer. The payer builds a request with no
+// offer_issuer_id or offer_paths, signs it with SignInvoiceRequest, and
+// publishes it with EncodeInvoiceRequestString as an lnr1 string, such as a QR
+// code. The payee reads it with DecodeInvoiceRequestString and answers with an
+// invoice built and signed as above, sent to invreq_paths or invreq_payer_id.
+// The payer gates that invoice with ValidateInvoiceForPayment, passing a nil
+// node id unless it confirmed the payee's key out of band.
+// TestOfferlessPaymentFlow in offer_test.go runs these steps.
+//
+// # Wire form or string form
+//
+// An offer only ever travels out of band, as an lno1 string. An invoice_request
+// and an invoice reach a peer as raw TLV inside an onion message, which is what
+// EncodeSigned emits. An invoice also has an lni1 string, for display and for
+// out-of-band delivery. An invoice_request that answers no offer is published
+// as an lnr1 string instead, which EncodeInvoiceRequestString emits signed.
+//
+// # Pitfalls
+//
+// Gating a reply invoice against itself is not enough. The read gates check an
+// invoice in isolation, but the mirror match against the request, the node
+// binding and the expiry need state only the payer holds, and only
+// ValidateInvoiceForPayment applies them. A payer that merely decodes and reads
+// will accept a correctly signed invoice from the wrong node.
+//
+// Signing and encoding are independent. Signing reads the struct, so encoding
+// never has to run first and does not require a signature. EncodeSigned and the
+// string encoders are where a signature becomes mandatory.
+//
+// Unknown fields must survive a round trip. A signature covers the Merkle root
+// over the message's own TLVs, so re-encoding has to reproduce the wire bytes.
+// That is why decoding rejects a non-minimal encoding rather than normalising
+// it, and why unknown TLVs are preserved verbatim.
+//
+// DecodeInvoiceStringUnvalidated skips every gate. Use it only to display an
+// invoice that was validated when it was stored.
package bolt12
### 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
}
@@ -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',
})
@@ -233,12 +227,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 "+
### 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]
@@ -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{})
@@ -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
}
@@ -284,10 +284,10 @@ 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) {
- if err := ValidateInvoiceWrite(inv); err != nil {
+func (inv *Invoice) encode() ([]byte, error) {
+ if err := validateInvoiceWrite(inv); err != nil {
return nil, fmt.Errorf("validate invoice: %w", err)
}
@@ -299,8 +299,35 @@ 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 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()
+ 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.
+// permissive: callers that need spec compliance must run validateInvoiceRead.
func DecodeInvoice(data []byte) (*Invoice, error) {
var inv Invoice
@@ -390,10 +417,29 @@ 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 := decodeBech32(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
-// 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
@@ -402,17 +448,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
}
@@ -421,11 +457,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)
}
@@ -434,23 +470,15 @@ 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 Encode(HRPInvoice, tlvBytes)
+ return encodeBech32(HRPInvoice, tlvBytes)
}
// NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all
### 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)
}
### bolt12/invoice_request.go
@@ -150,10 +150,10 @@ 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) {
- if err := ValidateInvoiceRequestWrite(ir); err != nil {
+func (ir *InvoiceRequest) encode() ([]byte, error) {
+ if err := validateInvoiceRequestWrite(ir); err != nil {
return nil, fmt.Errorf("validate invoice request: %w", err)
}
@@ -165,6 +165,33 @@ 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 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()
+ 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.
@@ -244,7 +271,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)
}
@@ -269,25 +296,14 @@ func DecodeInvoiceRequestString(s string,
}
// EncodeInvoiceRequestString encodes a signed invoice request to its bech32
-// string representation (lnr1...). The string form exists only for
-// transmission, so a populated signature is required and verified against
-// invreq_payer_id. Writer-side validation is delegated to
-// (*InvoiceRequest).Encode.
+// string representation (lnr1...).
func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) {
- if !ir.Signature.IsSome() {
- return "", ErrMissingSignature
- }
-
- tlvBytes, err := ir.Encode()
+ tlvBytes, err := ir.EncodeSigned()
if err != nil {
return "", err
}
- if err := VerifyInvoiceRequest(ir); err != nil {
- return "", err
- }
-
- return Encode(HRPInvoiceRequest, tlvBytes)
+ return encodeBech32(HRPInvoiceRequest, tlvBytes)
}
// NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying
@@ -297,7 +313,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.
//
### bolt12/invoice_request_test.go
@@ -2,6 +2,7 @@ package bolt12
import (
"bytes"
+ "strings"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
@@ -135,7 +136,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 +150,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 +228,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
@@ -319,63 +320,38 @@ 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)
ir.InvreqPayerID = tlv.OptionalRecordT[
tlv.TlvType88, *btcec.PublicKey,
]{}
- encoded, err := EncodeInvoiceRequestString(ir)
+ encoded, err := ir.EncodeSigned()
require.ErrorIs(t, err, ErrMissingPayerID)
require.Empty(t, encoded)
}
-// TestEncodeInvoiceRequestStringUnsigned asserts the wire-string layer
-// refuses to emit an unsigned invoice request: the signature becomes
-// mandatory at the bech32 boundary even though pre-sign Encode is permitted.
-func TestEncodeInvoiceRequestStringUnsigned(t *testing.T) {
+// TestEncodeSignedUnsigned asserts EncodeSigned refuses to emit an unsigned
+// invoice request. An invoice request reaches a peer as raw TLV, so this is
+// the boundary that makes the writer-side signature MUST unavoidable.
+func TestEncodeSignedUnsigned(t *testing.T) {
t.Parallel()
ir := validInvoiceRequest(t)
ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{}
- encoded, err := EncodeInvoiceRequestString(ir)
+ encoded, err := ir.EncodeSigned()
require.ErrorIs(t, err, ErrMissingSignature)
require.Empty(t, encoded)
}
-// TestEncodeInvoiceRequestStringInvalidSignature asserts the wire-string
-// layer refuses to emit a request whose signature does not verify against
-// invreq_payer_id.
-func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) {
+// TestEncodeSignedInvalidSignature asserts EncodeSigned refuses to emit a
+// request whose signature does not verify against invreq_payer_id.
+func TestEncodeSignedInvalidSignature(t *testing.T) {
t.Parallel()
ir := validInvoiceRequest(t)
@@ -386,7 +362,36 @@ func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) {
tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(2000)),
)
- encoded, err := EncodeInvoiceRequestString(ir)
+ encoded, err := ir.EncodeSigned()
require.ErrorIs(t, err, ErrInvalidSignature)
require.Empty(t, encoded)
}
+
+// TestEncodeInvoiceRequestString asserts that a signed request round-trips
+// through its lnr1 string, and that an unsigned one is refused, as it is in the
+// raw TLV form.
+func TestEncodeInvoiceRequestString(t *testing.T) {
+ t.Parallel()
+
+ ir := validInvoiceRequest(t)
+
+ lnr, err := EncodeInvoiceRequestString(ir)
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(lnr, HRPInvoiceRequest+"1"))
+
+ decoded, err := DecodeInvoiceRequestString(
+ lnr, bitcoinMainnetGenesisHash,
+ )
+ require.NoError(t, err)
+
+ want, err := ir.EncodeSigned()
+ require.NoError(t, err)
+ got, err := decoded.EncodeSigned()
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+
+ ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{}
+ lnr, err = EncodeInvoiceRequestString(ir)
+ require.ErrorIs(t, err, ErrMissingSignature)
+ require.Empty(t, lnr)
+}
### 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")
+}
### 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()
@@ -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)
@@ -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))
}
@@ -327,14 +327,14 @@ 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)
decoded, err := DecodeInvoice(encoded)
require.NoError(t, err)
- err = ValidateInvoiceRead(
+ err = validateInvoiceRead(
decoded, bitcoinMainnetGenesisHash,
InvoiceKnownFeatures{
Invoice: Bolt12Features,
@@ -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
@@ -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()
@@ -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)
}
@@ -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()
### 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)
### 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
}
@@ -96,9 +97,9 @@ func (o *Offer) allRecordProducers() []tlv.RecordProducer {
return p
}
-// Encode serialises the offer into a canonical TLV byte stream.
-func (o *Offer) Encode() ([]byte, error) {
- if err := ValidateOfferWrite(o); err != nil {
+// 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)
}
@@ -112,9 +113,9 @@ 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
-// offer_id stable.
+// 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
@@ -169,11 +170,11 @@ 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) {
- hrp, tlvBytes, err := Decode(s)
+ hrp, tlvBytes, err := decodeBech32(s)
if err != nil {
return nil, fmt.Errorf("bech32: %w", err)
}
@@ -188,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)
@@ -198,12 +199,12 @@ 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
}
- return Encode(HRPOffer, tlvBytes)
+ return encodeBech32(HRPOffer, tlvBytes)
}
### 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
+}
### 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 := decodeBech32(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 := decodeBech32(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 := decodeBech32(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[:]))
+}
### bolt12/offer_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/hex"
"testing"
+ "time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/lnwire"
@@ -86,7 +87,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 +101,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)
}
@@ -140,7 +141,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)
@@ -165,7 +166,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)
}
@@ -278,3 +279,206 @@ func TestOfferStringRoundTrip(t *testing.T) {
hex.EncodeToString(id2.SerializeCompressed()),
)
}
+
+// TestOfferPaymentFlow simulates a payment for an offer, one step per party.
+// The payee publishes an offer as an lno1 string, the payer answers it with a
+// signed invoice request, and the payee replies with an invoice that the payer
+// checks before it pays.
+func TestOfferPaymentFlow(t *testing.T) {
+ t.Parallel()
+
+ payeeKey, payeePub := aliceKey()
+ payerKey, payerPub := bobKey()
+ now := time.Unix(1234567890, 0).Add(time.Minute)
+
+ // Payee: create the offer and publish it as an lno1 string, for
+ // example in a QR code. offer_issuer_id names the node that will sign
+ // the invoice.
+ offer := &Offer{
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](TUint64(1000)),
+ ),
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("coffee"),
+ ),
+ ),
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](payeePub),
+ ),
+ }
+ lno, err := EncodeOfferString(offer)
+ require.NoError(t, err)
+ offerHash, err := OfferHash(offer)
+ require.NoError(t, err)
+
+ // Payer: read the offer and mirror its fields into a request under a
+ // transient payer key.
+ scanned, err := DecodeOfferString(lno, now, bitcoinMainnetGenesisHash)
+ require.NoError(t, err)
+ req, err := NewInvoiceRequestFromOffer(
+ scanned, payerPub, []byte("unpredictable"),
+ bitcoinMainnetGenesisHash,
+ )
+ require.NoError(t, err)
+
+ // Payer: sign the request and send the raw TLV in an onion message to
+ // the offer's node, with a reply path for the invoice.
+ reqSig, err := SignInvoiceRequest(req, payerKey)
+ require.NoError(t, err)
+ req.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240](reqSig),
+ )
+ reqWire, err := req.EncodeSigned()
+ require.NoError(t, err)
+
+ // Payee: read the request, gate it, and find the offer it answers. The
+ // request mirrors the offer's fields, so it carries the same offer
+ // hash.
+ received, err := DecodeInvoiceRequest(reqWire)
+ require.NoError(t, err)
+ require.NoError(t, ValidateInvoiceRequestRead(
+ received, bitcoinMainnetGenesisHash, Bolt12Features,
+ ))
+ receivedHash, err := OfferHash(received)
+ require.NoError(t, err)
+ require.Equal(t, offerHash, receivedHash)
+
+ // Payee: answer over the reply path with an invoice for the offer's
+ // amount, signed with the key offer_issuer_id names.
+ tmpl := validInvoice(t)
+ inv := NewInvoiceFromRequest(received)
+ inv.InvoiceCreatedAt = tmpl.InvoiceCreatedAt
+ inv.InvoicePaymentHash = tmpl.InvoicePaymentHash
+ inv.InvoicePaths = tmpl.InvoicePaths
+ inv.InvoiceBlindedPay = tmpl.InvoiceBlindedPay
+ inv.InvoiceAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170](
+ offer.OfferAmount.ValOpt().UnwrapOr(0),
+ ),
+ )
+ inv.InvoiceNodeID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType176](payeePub),
+ )
+ invSig, err := SignInvoice(inv, payeeKey)
+ require.NoError(t, err)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](invSig),
+ )
+ invWire, err := inv.EncodeSigned()
+ require.NoError(t, err)
+
+ // Payer: read the invoice and check it against its own request. The
+ // node id to expect comes from the offer's offer_issuer_id.
+ reply, err := DecodeInvoice(invWire)
+ require.NoError(t, err)
+ features := InvoiceKnownFeatures{
+ Invoice: Bolt12Features,
+ Blinded: Bolt12Features,
+ }
+ issuerID := scanned.OfferIssuerID.ValOpt().UnwrapOr(nil)
+ require.NoError(t, ValidateInvoiceForPayment(
+ reply, req, now, bitcoinMainnetGenesisHash, features, issuerID,
+ ))
+
+ // Payer: pay over the invoice's blinded paths.
+ require.NotEmpty(t, reply.UsablePaths(Bolt12Features))
+}
+
+// TestOfferlessPaymentFlow simulates a payment for an invoice request that
+// answers no offer, one step per party. The payer publishes a signed request as
+// an lnr1 string, the payee reads it and answers with an invoice, and the payer
+// checks that invoice before it pays.
+func TestOfferlessPaymentFlow(t *testing.T) {
+ t.Parallel()
+
+ payerKey, payerPub := bobKey()
+ payeeKey, payeePub := aliceKey()
+
+ // Payer: build the request. It carries no offer_issuer_id and no
+ // offer_paths. The payer's own key and the amount it will pay take
+ // the place of an offer.
+ req := &InvoiceRequest{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ tlv.Blob("unpredictable"),
+ ),
+ ),
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("refund"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(1000)),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](payerPub),
+ ),
+ }
+
+ // Payer: sign with the invreq_payer_id key and publish the request as
+ // an lnr1 string, for example in a QR code.
+ reqSig, err := SignInvoiceRequest(req, payerKey)
+ require.NoError(t, err)
+ req.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240](reqSig),
+ )
+ lnr, err := EncodeInvoiceRequestString(req)
+ require.NoError(t, err)
+
+ // Payee: read the scanned string. The reader gates run here, so a
+ // request without a valid signature stops at this step.
+ scanned, err := DecodeInvoiceRequestString(
+ lnr, bitcoinMainnetGenesisHash,
+ )
+ require.NoError(t, err)
+
+ // Payee: answer with an invoice for the requested amount, signed with
+ // its node key. It sends the encoded invoice in an onion message to
+ // invreq_paths, or to invreq_payer_id when there are none.
+ tmpl := validInvoice(t)
+ inv := NewInvoiceFromRequest(scanned)
+ inv.InvoiceCreatedAt = tmpl.InvoiceCreatedAt
+ inv.InvoicePaymentHash = tmpl.InvoicePaymentHash
+ inv.InvoicePaths = tmpl.InvoicePaths
+ inv.InvoiceBlindedPay = tmpl.InvoiceBlindedPay
+ inv.InvoiceAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170](
+ scanned.InvreqAmount.ValOpt().UnwrapOr(0),
+ ),
+ )
+ inv.InvoiceNodeID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType176](payeePub),
+ )
+ invSig, err := SignInvoice(inv, payeeKey)
+ require.NoError(t, err)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](invSig),
+ )
+ wire, err := inv.EncodeSigned()
+ require.NoError(t, err)
+
+ // Payer: read the invoice and check it against the request it
+ // published. It confirmed no payee key out of band, so it passes nil.
+ received, err := DecodeInvoice(wire)
+ require.NoError(t, err)
+ now := time.Unix(1234567890, 0).Add(time.Minute)
+ features := InvoiceKnownFeatures{
+ Invoice: Bolt12Features,
+ Blinded: Bolt12Features,
+ }
+ require.NoError(t, ValidateInvoiceForPayment(
+ received, req, now, bitcoinMainnetGenesisHash, features, nil,
+ ))
+
+ // Payer: pay over the invoice's blinded paths.
+ require.NotEmpty(t, received.UsablePaths(Bolt12Features))
+
+ // A payer that did confirm a key out of band still rejects an invoice
+ // that another node signed.
+ require.ErrorIs(t, ValidateInvoiceForPayment(
+ received, req, now, bitcoinMainnetGenesisHash, features,
+ payerPub,
+ ), ErrUnexpectedInvoiceNodeID)
+}
### 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")
@@ -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
@@ -102,9 +111,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
@@ -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
@@ -144,9 +160,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
### 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)
@@ -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()
@@ -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)
@@ -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(
### bolt12/subtypes.go
@@ -66,28 +66,23 @@ const (
// would change the Merkle leaf bytes and invalidate an otherwise valid
// signature. All three message types use it so the features fields decode
// through one path. The payinfo features guard in decodeBlindedPayInfos is the
-// same check one subtype level down.
+// same check one subtype level down. The encoder passes through to lnwire's
+// features record, which already writes the minimal form, so no second copy of
+// the encoding exists to drift from it.
func strictFeaturesRecord[T tlv.TlvType](
t *tlv.RecordT[T, lnwire.RawFeatureVector]) tlv.Record {
+ lnwireRec := t.Val.Record()
+
return tlv.MakeDynamicRecord(
- t.TlvType(), &t.Val,
- func() uint64 { return uint64(t.Val.SerializeSize()) },
- strictFeaturesEncoder, strictFeaturesDecoder,
+ t.TlvType(), &t.Val, lnwireRec.Size,
+ func(w io.Writer, _ any, _ *[8]byte) error {
+ return lnwireRec.Encode(w)
+ },
+ strictFeaturesDecoder,
)
}
-// strictFeaturesEncoder writes the minimal feature vector bytes, matching the
-// shared lnwire encoder.
-func strictFeaturesEncoder(w io.Writer, val any, _ *[8]byte) error {
- fv, ok := val.(*lnwire.RawFeatureVector)
- if !ok {
- return tlv.NewTypeForEncodingErr(val, "lnwire.RawFeatureVector")
- }
-
- return fv.EncodeBase256(w)
-}
-
// strictFeaturesDecoder decodes a feature vector and rejects a non-minimal
// encoding, so every accepted message re-encodes to the bytes the signer
// committed to.
### 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
@@ -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(
@@ -415,10 +415,11 @@ 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: 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
@@ -784,7 +785,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,24 +960,21 @@ 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 {
- // 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 +985,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 +1019,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 +1090,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
}
@@ -1098,51 +1138,35 @@ func getOfferChains(o *Offer) [][32]byte {
return chains
}
-// ValidateOfferWrite validates an offer per the BOLT 12 offer writer
-// requirements.
-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. This check
- // catches 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.
+// validateOfferWrite validates an offer per the BOLT 12 offer writer
+// requirements, in the order the spec states them.
+func validateOfferWrite(o *Offer) error {
+ // - 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) {
return fmt.Errorf("%w: type %d",
ErrOutOfRangeType, t)
}
- }
-
- // offer_amount requires offer_description.
- if o.OfferAmount.IsSome() && !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() && !o.OfferAmount.IsSome() {
- return ErrCurrencyWithoutAmount
- }
- // Without offer_paths, MUST set offer_issuer_id.
- if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() {
- return ErrNoIssuerIdentity
+ if !isKnownOfferTLVType(t) && t%2 == 0 {
+ return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t)
+ }
}
- // Defense in depth: writer-side mirrors of reader rejections for
- // present-but-empty offer_chains and offer_paths.
+ // - 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]) {
@@ -1155,12 +1179,96 @@ func ValidateOfferWrite(o *Offer) error {
return ErrEmptyChains
}
+ // - 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
+ }
+
+ if o.OfferAmount.IsSome() && !o.OfferDescription.IsSome() {
+ return ErrMissingDescription
+ }
+
+ if o.OfferCurrency.IsSome() && !o.OfferAmount.IsSome() {
+ return ErrCurrencyWithoutAmount
+ }
+
+ if err := checkISO4217(o.OfferCurrency); err != nil {
+ return err
+ }
+
+ // - 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
}
@@ -1175,10 +1283,6 @@ func ValidateOfferWrite(o *Offer) error {
return err
}
- if err := checkISO4217(o.OfferCurrency); err != nil {
- return err
- }
-
return nil
}
@@ -1308,12 +1412,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() {
@@ -1331,7 +1435,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
}
@@ -1346,7 +1450,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
@@ -1383,19 +1487,19 @@ 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: 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.
// seconds_from_creation to the number of seconds after
// invoice_created_at that payment should not be attempted.
// NOT CHECKED HERE: the writer chooses the expiry, so there is no rule
// to enforce on the encoded value. The time comparison needs a clock
- // (see ValidateInvoiceExpiry).
+ // (see validateInvoiceExpiry).
// - if it accepts onchain payments:
// - MAY specify invoice_fallbacks.
@@ -1406,7 +1510,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.
@@ -1446,7 +1550,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 {
@@ -1477,16 +1581,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,
)
@@ -1512,18 +1617,30 @@ func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error {
// validateInvoiceNodeID rejects an invoice that was not signed by the node the
// payer expected to answer. Which node that is comes from the payer's own
-// state: offer_issuer_id, the final blinded_node_id of the path it chose, or
-// the node it addressed an offerless request to. None of that is derivable
-// from the invoice, so ValidateInvoiceRead cannot make the comparison and
-// callers run this separately, as they already do for ValidateInvoiceExpiry.
+// state: offer_issuer_id, or the final blinded_node_id of the path it chose.
+// None of that is derivable from the invoice, so validateInvoiceRead cannot
+// make the comparison. ValidateInvoiceForPayment folds it in, as it does for
+// validateInvoiceExpiry.
//
-// Skipping it is not cosmetic. Every node on the blinded path can answer with
-// its own correctly signed invoice, and the reader accepts it, because the
-// signature only has to agree with whatever invoice_node_id the invoice itself
-// carries.
-func validateInvoiceNodeID(inv *Invoice,
+// Skipping it is not cosmetic for a response to an offer. Every node on the
+// blinded path can answer with its own correctly signed invoice, and the
+// reader accepts it, because the signature only has to agree with whatever
+// invoice_node_id the invoice itself carries.
+func validateInvoiceNodeID(inv *Invoice, req *InvoiceRequest,
expectedNodeID *btcec.PublicKey) error {
+ // - otherwise (invoice_request without an offer):
+ // - MAY reject the invoice if it cannot confirm that invoice_node_id
+ // is correct, out-of-band.
+ //
+ // The payer published the invoice request and never addressed the
+ // payee, so it has a key to compare against only if it learned one out
+ // of band. Without one it passes nil.
+ isOfferResponse := req.OfferIssuerID.IsSome() || req.OfferPaths.IsSome()
+ if expectedNodeID == nil && !isOfferResponse {
+ return nil
+ }
+
if expectedNodeID == nil {
return fmt.Errorf("%w: expected invoice_node_id",
ErrNilPublicKey)
@@ -1565,7 +1682,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
@@ -1582,7 +1699,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)
@@ -1710,7 +1827,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
@@ -1720,14 +1837,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
}
@@ -1772,16 +1889,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)
}
@@ -1798,7 +1910,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.
@@ -1849,7 +1962,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 {
@@ -1871,38 +1984,40 @@ 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
- // UsableFallbackAddresses.
+ // 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
// one call against an invoice and its originating request.
//
-// expectedNodeID is the node the payer expects to have signed the invoice,
-// and is always compared against invoice_node_id. It is offer_issuer_id for
-// an offer that carried one, the final blinded_node_id on the path the payer
-// chose for an offer that carried offer_paths, and the node it sent to for an
-// offerless request.
+// expectedNodeID is the node the payer expects to have signed the invoice, and
+// is compared against invoice_node_id. It is offer_issuer_id for an offer that
+// carried one, and the final blinded_node_id on the path the payer chose for an
+// offer that carried offer_paths. For a request that answers no offer it is a
+// key the payer confirmed out of band, or nil when it has none. Only that case
+// accepts nil. For a response to an offer a nil expectedNodeID returns
+// ErrNilPublicKey.
func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest,
now time.Time, activeChain [32]byte,
features InvoiceKnownFeatures,
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
}
- return validateInvoiceNodeID(inv, expectedNodeID)
+ return validateInvoiceNodeID(inv, req, expectedNodeID)
}
### 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.
@@ -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) {
@@ -221,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)
@@ -677,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 {
@@ -750,7 +762,7 @@ func signedInvoiceRequest(t testing.TB,
),
}
- encoded, err := ir.Encode()
+ encoded, err := ir.encode()
require.NoError(t, err)
decoded, err := DecodeInvoiceRequest(encoded)
@@ -825,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.
//
@@ -857,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
@@ -878,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{},
)
@@ -889,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
@@ -903,7 +915,7 @@ func TestValidateReadRejectsBadSignature(t *testing.T) {
))
require.NoError(t, err)
- return ValidateInvoiceRead(
+ return validateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceKnownFeatures{},
)
@@ -922,42 +934,70 @@ func TestValidateReadRejectsBadSignature(t *testing.T) {
// TestValidateInvoiceNodeID pins the binding the readers cannot check for
// themselves: invoice_node_id must name the node the payer expected to
-// answer, which is state only the payer holds.
+// answer, which is state only the payer holds. A request that answers no
+// offer is compared only against a key the payer confirmed out of band.
func TestValidateInvoiceNodeID(t *testing.T) {
t.Parallel()
_, alicePub := aliceKey()
_, bobPub := bobKey()
+ // An offer response carries offer_issuer_id or offer_paths, and a
+ // request that answers no offer carries neither.
+ offerResponse := &InvoiceRequest{
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub),
+ ),
+ }
+ offerless := &InvoiceRequest{}
+
tests := []struct {
name string
+ req *InvoiceRequest
nodeID fn.Option[*btcec.PublicKey]
expected *btcec.PublicKey
wantErr error
}{
{
name: "matches the path's final node",
+ req: offerResponse,
nodeID: fn.Some(bobPub),
expected: bobPub,
},
{
name: "another node on the path impersonates",
+ req: offerResponse,
nodeID: fn.Some(alicePub),
expected: bobPub,
wantErr: ErrUnexpectedInvoiceNodeID,
},
{
name: "invoice_node_id absent",
+ req: offerResponse,
nodeID: fn.None[*btcec.PublicKey](),
expected: bobPub,
wantErr: ErrMissingNodeID,
},
{
name: "caller supplies no final node",
+ req: offerResponse,
nodeID: fn.Some(bobPub),
expected: nil,
wantErr: ErrNilPublicKey,
},
+ {
+ name: "offerless request, no key confirmed",
+ req: offerless,
+ nodeID: fn.Some(alicePub),
+ expected: nil,
+ },
+ {
+ name: "offerless request, confirmed key differs",
+ req: offerless,
+ nodeID: fn.Some(alicePub),
+ expected: bobPub,
+ wantErr: ErrUnexpectedInvoiceNodeID,
+ },
}
for _, tc := range tests {
@@ -976,7 +1016,7 @@ func TestValidateInvoiceNodeID(t *testing.T) {
)
})
- err := validateInvoiceNodeID(inv, tc.expected)
+ err := validateInvoiceNodeID(inv, tc.req, tc.expected)
if tc.wantErr == nil {
require.NoError(t, err)
return
@@ -1135,7 +1175,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{},
))
@@ -1360,7 +1400,7 @@ func TestValidateInvoiceRequestWrite(t *testing.T) {
tc.mutate(ir)
- err := ValidateInvoiceRequestWrite(ir)
+ err := validateInvoiceRequestWrite(ir)
if tc.wantErr == nil {
require.NoError(t, err)
return
@@ -1404,14 +1444,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.
@@ -1433,14 +1473,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).
@@ -1457,22 +1497,22 @@ 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 ->
// invalid.
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
@@ -1497,11 +1537,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))
})
})
}
@@ -1541,21 +1581,21 @@ 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(
tlv.NewPrimitiveRecord[tlv.TlvType80](
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.
@@ -1590,15 +1630,15 @@ 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(
tlv.NewPrimitiveRecord[tlv.TlvType80](
bitcoinMainnetGenesisHash,
),
)
- require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ require.NoError(t, validateInvoiceRequestWrite(ir))
// InvreqChain != bitcoin (invalid).
ir.InvreqChain = tlv.SomeRecordT(
@@ -1607,7 +1647,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) {
),
)
require.ErrorIs(
- t, ValidateInvoiceRequestWrite(ir),
+ t, validateInvoiceRequestWrite(ir),
ErrUnsupportedChain,
)
})
@@ -1625,15 +1665,15 @@ 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(
tlv.NewPrimitiveRecord[tlv.TlvType80](
bitcoinMainnetGenesisHash,
),
)
- require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ require.NoError(t, validateInvoiceRequestWrite(ir))
// InvreqChain == testnet (invalid, not in offer
// chains).
@@ -1643,7 +1683,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) {
),
)
require.ErrorIs(
- t, ValidateInvoiceRequestWrite(ir),
+ t, validateInvoiceRequestWrite(ir),
ErrUnsupportedChain,
)
@@ -1659,7 +1699,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,
)
@@ -1669,7 +1709,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) {
testnetHash,
),
)
- require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ require.NoError(t, validateInvoiceRequestWrite(ir))
// InvreqChain == regtest (invalid, not in offer
// chains).
@@ -1679,7 +1719,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) {
),
)
require.ErrorIs(
- t, ValidateInvoiceRequestWrite(ir),
+ t, validateInvoiceRequestWrite(ir),
ErrUnsupportedChain,
)
})
@@ -2049,7 +2089,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)
}
@@ -2096,7 +2136,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)
}
@@ -2274,7 +2314,7 @@ func encodeInvBypassValidate(inv *Invoice) ([]byte, error) {
}
// TestValidateInvoiceRead table-drives every reader-side rejection in
-// ValidateInvoiceRead.
+// validateInvoiceRead.
func TestValidateInvoiceRead(t *testing.T) {
t.Parallel()
@@ -2464,7 +2504,7 @@ func TestValidateInvoiceRead(t *testing.T) {
inv := baseline()
tc.mutate(inv)
- err := ValidateInvoiceRead(
+ err := validateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceKnownFeatures{},
)
@@ -2473,10 +2513,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()
@@ -2536,11 +2576,21 @@ func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) {
tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](sig),
)
- err = ValidateInvoiceRead(
+ err = validateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
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
@@ -2629,7 +2679,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 {
@@ -2754,7 +2804,7 @@ func TestValidateInvoiceAgainstRequest(t *testing.T) {
invDecoded, err := DecodeInvoice(invEncoded)
require.NoError(t, err)
- err = ValidateInvoiceAgainstRequest(
+ err = validateInvoiceAgainstRequest(
invDecoded, irDecoded,
)
if tc.wantErr == nil {
@@ -2809,12 +2859,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")
}
@@ -2898,7 +2948,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 }
@@ -2947,7 +2997,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()
@@ -3093,7 +3143,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 {
@@ -3109,7 +3159,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()
@@ -3131,7 +3181,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) {
)
// An unknown required bit must be rejected.
- err = ValidateInvoiceRead(
+ err = validateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceKnownFeatures{},
)
@@ -3141,15 +3191,15 @@ func TestValidateFeaturesKnownBits(t *testing.T) {
known := map[lnwire.FeatureBit]string{
lnwire.MPPRequired: "mpp",
}
- err = ValidateInvoiceRead(
+ err = validateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceKnownFeatures{Invoice: known},
)
require.NoError(t, err)
})
// Role 2 validation verifies routing path features on
- // ValidateInvoiceRead.
+ // validateInvoiceRead.
t.Run("routing path features (Role 2)", func(t *testing.T) {
t.Parallel()
@@ -3177,7 +3227,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{},
)
@@ -3188,7 +3238,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) {
knownBlinded := map[lnwire.FeatureBit]string{
lnwire.MPPRequired: "mpp",
}
- err = ValidateInvoiceRead(
+ err = validateInvoiceRead(
inv, bitcoinMainnetGenesisHash,
InvoiceKnownFeatures{Blinded: knownBlinded},
)
@@ -3205,13 +3255,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()
@@ -3224,7 +3274,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) {
@@ -3236,7 +3286,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) {
@@ -3248,7 +3298,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) {
(*btcec.PublicKey)(nil),
),
)
- require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey)
+ require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey)
})
}
@@ -3314,7 +3364,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)
@@ -3341,7 +3391,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(
@@ -3376,7 +3426,7 @@ func TestValidateOfferReadVectors(t *testing.T) {
activeChain = c[0]
}
- valErr := ValidateOfferRead(
+ valErr := validateOfferRead(
offer, now, activeChain, nil,
)
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.