diff --git a/bolt12/bech32.go b/bolt12/bech32.go index b8680225469..ec0cdfe3e4c 100644 --- a/bolt12/bech32.go +++ b/bolt12/bech32.go @@ -10,10 +10,8 @@ import ( ) var ( - // ErrStringTooLong is returned when a raw string is longer than - // maxBolt12RawStringLen or a cleaned string is longer than - // maxBolt12StringLen. It is also returned when a payload is larger - // than maxBolt12DataLen. + // ErrStringTooLong is returned when a payload is larger than + // maxBolt12DataLen. ErrStringTooLong = errors.New("input length exceeds limit") // ErrEmptyString is returned when a string has no characters. It is @@ -72,30 +70,19 @@ const ( maxPrintableASCII = 126 // bolt12HRPLen is the length of a BOLT 12 human-readable prefix. All - // three prefixes have it, so the limit below counts it as a fixed cost. + // three prefixes have it. bolt12HRPLen = 3 - // maxBolt12DataLen is the largest TLV stream that one BOLT 12 string - // can hold. The spec limits neither a field nor the stream, so the - // limit comes from this package: the P2P decoder rejects a record above - // tlv.MaxRecordSize. Eleven offer fields at that size give 704 - // kibibytes, and one mebibyte leaves room for unknown odd fields. Only - // an offer needs the room, because an invoice travels in a smaller - // onion message. + // maxBolt12DataLen is the largest TLV stream 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,11 +156,11 @@ func Decode(s string) (string, []byte, error) { return hrp, data8bit, nil } -// Encode makes a BOLT 12 bech32 string from the data bytes and the given +// encodeBech32 makes a BOLT 12 bech32 string from the data bytes and the given // human-readable prefix. It adds no checksum. It changes the prefix to -// lowercase and takes only lno, lnr, and lni. The payload size must be a size -// that Decode also takes, so a caller can make only strings that Decode reads. -func Encode(hrp string, data []byte) (string, error) { +// lowercase and takes only lno, lnr, and lni. It refuses a payload above +// maxBolt12DataLen, a writer policy the reader does not mirror. +func encodeBech32(hrp string, data []byte) (string, error) { hrp = strings.ToLower(hrp) if !isValidHRP(hrp) { return "", unsupportedHRPError(hrp) @@ -195,7 +168,7 @@ func Encode(hrp string, data []byte) (string, error) { // A BOLT 12 string holds a TLV stream, and the stream must hold at // least one record. An empty payload gives a string with only the - // prefix and the separator, which Decode rejects. + // prefix and the separator, which decodeBech32 rejects. if len(data) == 0 { return "", fmt.Errorf( "bolt12: %w: nothing to encode", ErrEmptyString, diff --git a/bolt12/bech32_test.go b/bolt12/bech32_test.go index c1b8ed97f6e..b9b5b1b4924 100644 --- a/bolt12/bech32_test.go +++ b/bolt12/bech32_test.go @@ -22,7 +22,7 @@ func TestBech32FormatStringVectors(t *testing.T) { t.Run(tc.Comment, func(t *testing.T) { t.Parallel() - hrp, decoded, err := Decode(tc.String) + hrp, decoded, err := decodeBech32(tc.String) if !tc.Valid { require.Error(t, err, "expected error for: %s", @@ -37,10 +37,10 @@ func TestBech32FormatStringVectors(t *testing.T) { require.NotEmpty(t, decoded) // Round-trip: re-encode and decode again. - encoded, err := Encode(hrp, decoded) + encoded, err := encodeBech32(hrp, decoded) require.NoError(t, err) - hrp2, decoded2, err := Decode(encoded) + hrp2, decoded2, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, hrp2) require.Equal(t, decoded, decoded2) @@ -59,11 +59,11 @@ func TestBech32RoundTrip(t *testing.T) { t.Run(hrp, func(t *testing.T) { t.Parallel() - encoded, err := Encode(hrp, testData) + encoded, err := encodeBech32(hrp, testData) require.NoError(t, err) require.True(t, len(encoded) > len(hrp)+1) - gotHRP, gotData, err := Decode(encoded) + gotHRP, gotData, err := decodeBech32(encoded) require.NoError(t, err) require.Equal(t, hrp, gotHRP) require.Equal(t, testData, gotData) @@ -105,7 +105,7 @@ func TestBech32DecodeErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, _, err := Decode(tc.input) + _, _, err := decodeBech32(tc.input) require.Error(t, err) }) } @@ -239,13 +239,13 @@ func TestDecodeContinuationAnywhere(t *testing.T) { t.Parallel() payload := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} - encoded, err := Encode(HRPOffer, payload) + encoded, err := encodeBech32(HRPOffer, payload) require.NoError(t, err) for i := 1; i < len(encoded); i++ { split := encoded[:i] + "+" + encoded[i:] - hrp, data, err := Decode(split) + hrp, data, err := decodeBech32(split) require.NoError(t, err, "marker at position %d", i) require.Equal(t, HRPOffer, hrp) require.Equal(t, payload, data) @@ -259,7 +259,7 @@ func TestDecodeContinuationAnywhere(t *testing.T) { func TestEncodeUnknownHRP(t *testing.T) { t.Parallel() - _, err := Encode("bogus", []byte{0x00}) + _, err := encodeBech32("bogus", []byte{0x00}) require.ErrorIs(t, err, ErrUnsupportedHRP) for _, hrp := range validHRPs { @@ -272,7 +272,7 @@ func TestEncodeUnknownHRP(t *testing.T) { func TestDecodeUnknownHRP(t *testing.T) { t.Parallel() - _, _, err := Decode("bogus1pqps7sjq") + _, _, err := decodeBech32("bogus1pqps7sjq") require.ErrorIs(t, err, ErrUnsupportedHRP) } @@ -293,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) diff --git a/bolt12/doc.go b/bolt12/doc.go index 61cc552d656..45120ec4c5e 100644 --- a/bolt12/doc.go +++ b/bolt12/doc.go @@ -1,33 +1,49 @@ // Package bolt12 implements encoding, decoding, and validation for BOLT 12 // Offers, Invoice Requests, and Invoices. It is a codec library: it does not -// reach into the daemon, and it takes the chain, the clock and the known -// feature bits from its caller. -// -// BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant -// and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields. -// -// Human-readable prefixes: -// - lno: Offer -// - lnr: Invoice Request -// - lni: Invoice -// -// # Codec Contract -// -// Encode validates before serialising and refuses to emit bytes that would fail -// the writer requirements, invalid bytes are unrepresentable on the wire. -// Low-level decoders stay permissive so diagnostic and fuzz harnesses can -// inspect malformed input. -// -// DecodeOfferString, DecodeInvoiceRequestString, and DecodeInvoiceString -// (with their Encode counterparts) are the consumer entry point. Each folds -// bech32, the per-message TLV codec, and the spec reader gates into one -// validated call. -// -// An invoice that arrives as the response to an invoice request needs two -// further bindings that the message alone cannot supply, the mirror match -// against that request and the blinded-path node binding. A payer holding -// that request gates the decoded invoice with ValidateInvoiceForPayment, -// which the string wrappers cannot do for it. Such an invoice arrives as raw -// TLV over an onion message rather than as a string, so the payer path -// decodes with DecodeInvoice and validates separately. +// reach into the daemon, and it takes the chain, the clock, the known feature +// bits and the node it expected to answer from its caller. Anything a message +// cannot prove about itself is the caller's to supply. +// +// BOLT 12 messages are TLV streams signed with BIP-340 Schnorr signatures over +// a Merkle tree of their own fields. +// +// # The flow +// +// A merchant publishes an offer out of band. A payer reads it with +// DecodeOfferString, mirrors its fields into a request with +// NewInvoiceRequestFromOffer, signs that with SignInvoiceRequest, and sends the +// EncodeSigned bytes inside an onion message. The receiver decodes them with +// DecodeInvoiceRequest, gates the result with ValidateInvoiceRequestRead, and +// answers with an invoice built by NewInvoiceFromRequest and signed with +// SignInvoice. The payer decodes that reply with DecodeInvoice, gates it with +// ValidateInvoiceForPayment, and pays the paths UsablePaths returns. +// +// # Wire form or string form +// +// An offer only ever travels out of band, as an lno1 string. An invoice_request +// and an invoice reach a peer as raw TLV inside an onion message, which is what +// EncodeSigned emits. An invoice also has an lni1 string, for display and for +// out-of-band delivery. There is deliberately no invoice_request string +// encoder, because nothing emits that form, while its decoder stays because +// another implementation may hand us an lnr1 string. +// +// # Pitfalls +// +// Gating a reply invoice against itself is not enough. The read gates check an +// invoice in isolation, but the mirror match against the request, the node +// binding and the expiry need state only the payer holds, and only +// ValidateInvoiceForPayment applies them. A payer that merely decodes and reads +// will accept a correctly signed invoice from the wrong node. +// +// Signing and encoding are independent. Signing reads the struct, so encoding +// never has to run first and does not require a signature. EncodeSigned and the +// string encoders are where a signature becomes mandatory. +// +// Unknown fields must survive a round trip. A signature covers the Merkle root +// over the message's own TLVs, so re-encoding has to reproduce the wire bytes. +// That is why decoding rejects a non-minimal encoding rather than normalising +// it, and why unknown TLVs are preserved verbatim. +// +// DecodeInvoiceStringUnvalidated skips every gate. Use it only to display an +// invoice that was validated when it was stored. package bolt12 diff --git a/bolt12/fuzz_test.go b/bolt12/fuzz_test.go index 35f18fb6754..90f6e1bc95c 100644 --- a/bolt12/fuzz_test.go +++ b/bolt12/fuzz_test.go @@ -50,7 +50,7 @@ func tlvStreams(t testing.TB, strings []string) [][]byte { var seeds [][]byte for _, s := range strings { - _, tlvBytes, err := Decode(s) + _, tlvBytes, err := decodeBech32(s) if err != nil { continue } @@ -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', }) @@ -186,7 +180,7 @@ func FuzzDecodeOfferString(f *testing.F) { // reader gates included. func FuzzDecodeInvoiceRequestString(f *testing.F) { fuzzStringCodec(f, func(s string) { - _, _ = DecodeInvoiceRequestString( + _, _ = decodeInvoiceRequestString( s, bitcoinMainnetGenesisHash, ) }, invreqStringSeeds(f)...) @@ -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 "+ diff --git a/bolt12/invoice.go b/bolt12/invoice.go index a27db95d65d..d306eebac95 100644 --- a/bolt12/invoice.go +++ b/bolt12/invoice.go @@ -21,7 +21,7 @@ import ( type Invoice struct { // Fields in the 0-91 range are mirrored verbatim from the // invoice_request (which carries the offer's fields); the byte-for-byte - // match is enforced by ValidateInvoiceAgainstRequest. + // match is enforced by validateInvoiceAgainstRequest. // InvreqMetadata is the payer metadata. InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob] @@ -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 diff --git a/bolt12/invoice_error.go b/bolt12/invoice_error.go index 80bcd727744..c8826581055 100644 --- a/bolt12/invoice_error.go +++ b/bolt12/invoice_error.go @@ -47,12 +47,12 @@ func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer { // messages such as invoices, where unknown TLVs must be preserved to keep // signatures valid). // -// One writer rule stays unchecked: a suggested_value is not verified against -// the type of the field erroneous_field names. Emitting a value the peer -// cannot decode is therefore possible, see the TODO in -// ValidateInvoiceErrorWrite. +// NOTE: One writer rule stays unchecked: a suggested_value is not verified +// against the type of the field erroneous_field names. Emitting a value the +// peer cannot decode is therefore possible, see the TODO in +// validateInvoiceErrorWrite. func (ie *InvoiceError) Encode() ([]byte, error) { - if err := ValidateInvoiceErrorWrite(ie); err != nil { + if err := validateInvoiceErrorWrite(ie); err != nil { return nil, fmt.Errorf("validate invoice error: %w", err) } diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 65a3f035ddd..9332c7c8912 100644 --- a/bolt12/invoice_request.go +++ b/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. @@ -237,14 +264,14 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) { return &ir, nil } -// DecodeInvoiceRequestString decodes a BOLT 12 invoice request from its bech32 +// decodeInvoiceRequestString decodes a BOLT 12 invoice request from its bech32 // string representation (lnr1...). The spec reader gates (chain, features, // signature) are folded in via ValidateInvoiceRequestRead, with activeChain // gating the invreq_chain rule. -func DecodeInvoiceRequestString(s string, +func decodeInvoiceRequestString(s string, activeChain [32]byte) (*InvoiceRequest, error) { - hrp, tlvBytes, err := Decode(s) + hrp, tlvBytes, err := decodeBech32(s) if err != nil { return nil, fmt.Errorf("bech32: %w", err) } @@ -268,28 +295,6 @@ func DecodeInvoiceRequestString(s string, return ir, nil } -// EncodeInvoiceRequestString encodes a signed invoice request to its bech32 -// string representation (lnr1...). The string form exists only for -// transmission, so a populated signature is required and verified against -// invreq_payer_id. Writer-side validation is delegated to -// (*InvoiceRequest).Encode. -func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { - if !ir.Signature.IsSome() { - return "", ErrMissingSignature - } - - tlvBytes, err := ir.Encode() - if err != nil { - return "", err - } - - if err := VerifyInvoiceRequest(ir); err != nil { - return "", err - } - - return Encode(HRPInvoiceRequest, tlvBytes) -} - // NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying // (mirroring) all fields from the provided Offer. It assigns the payer ID and // payer metadata; the caller should subsequently sign the request. @@ -297,7 +302,7 @@ func EncodeInvoiceRequestString(ir *InvoiceRequest) (string, error) { // Per "MUST copy all fields from the offer (including unknown fields)", the // offer's unknown TLVs are carried via the decodedTLVs sidecar so they are // signed and mirrored into the invoice. Note that because unknown even TLV -// types in the offer would have already been rejected by ValidateOfferRead, any +// types in the offer would have already been rejected by validateOfferRead, any // unknown TLVs mirrored here are guaranteed to be unknown odd TLVs ("it's ok to // be odd") which are safe to ignore and carry forward. // diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 83f2cc7a090..f3b36ef2035 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -135,7 +135,7 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType240](sig), ) - encoded, err := ir.Encode() + encoded, err := ir.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -149,7 +149,7 @@ func TestInvoiceRequestRoundTrip(t *testing.T) { ir.decodedTLVs = decoded.decodedTLVs require.Equal(t, ir, decoded) - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -227,7 +227,7 @@ func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) { tlv.NewPrimitiveRecord[tlv.TlvType22](pub), ), } - encoded, err := offer.Encode() + encoded, err := offer.encode() require.NoError(t, err) const unknownType = 33 @@ -279,7 +279,7 @@ func TestDecodeInvoiceRequestString(t *testing.T) { "k95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu0" + "4qz3slje2rfthc89vss" - ir, err := DecodeInvoiceRequestString(lnrStr, bitcoinMainnetGenesisHash) + ir, err := decodeInvoiceRequestString(lnrStr, bitcoinMainnetGenesisHash) require.NoError(t, err) // Verify invreq_metadata is set (8 zero bytes). @@ -319,33 +319,9 @@ func TestDecodeInvoiceRequestString(t *testing.T) { require.Equal(t, "A Mathematical Treatise", string(desc)) } -// TestInvoiceRequestStringRoundTrip pins the encode→decode identity of the -// lnr wrapper pair: the recovered request must re-encode to the original TLV -// stream byte-for-byte. -func TestInvoiceRequestStringRoundTrip(t *testing.T) { - t.Parallel() - - ir := validInvoiceRequest(t) - - encoded, err := EncodeInvoiceRequestString(ir) - require.NoError(t, err) - require.NotEmpty(t, encoded) - - decoded, err := DecodeInvoiceRequestString( - encoded, bitcoinMainnetGenesisHash, - ) - require.NoError(t, err) - - originalBytes, err := ir.Encode() - require.NoError(t, err) - decodedBytes, err := decoded.Encode() - require.NoError(t, err) - require.Equal(t, originalBytes, decodedBytes) -} - -// TestEncodeInvoiceRequestStringInvalid asserts the wrapper refuses to emit -// a request that fails writer validation. -func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { +// TestEncodeSignedInvalid asserts EncodeSigned refuses to emit a request +// that fails writer validation. +func TestEncodeSignedInvalid(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) @@ -353,29 +329,28 @@ func TestEncodeInvoiceRequestStringInvalid(t *testing.T) { tlv.TlvType88, *btcec.PublicKey, ]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrMissingPayerID) require.Empty(t, encoded) } -// TestEncodeInvoiceRequestStringUnsigned asserts the wire-string layer -// refuses to emit an unsigned invoice request: the signature becomes -// mandatory at the bech32 boundary even though pre-sign Encode is permitted. -func TestEncodeInvoiceRequestStringUnsigned(t *testing.T) { +// TestEncodeSignedUnsigned asserts EncodeSigned refuses to emit an unsigned +// invoice request. An invoice request reaches a peer as raw TLV, so this is +// the boundary that makes the writer-side signature MUST unavoidable. +func TestEncodeSignedUnsigned(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) ir.Signature = tlv.OptionalRecordT[tlv.TlvType240, [64]byte]{} - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrMissingSignature) require.Empty(t, encoded) } -// TestEncodeInvoiceRequestStringInvalidSignature asserts the wire-string -// layer refuses to emit a request whose signature does not verify against -// invreq_payer_id. -func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { +// TestEncodeSignedInvalidSignature asserts EncodeSigned refuses to emit a +// request whose signature does not verify against invreq_payer_id. +func TestEncodeSignedInvalidSignature(t *testing.T) { t.Parallel() ir := validInvoiceRequest(t) @@ -386,7 +361,7 @@ func TestEncodeInvoiceRequestStringInvalidSignature(t *testing.T) { tlv.NewRecordT[tlv.TlvType82, TUint64](TUint64(2000)), ) - encoded, err := EncodeInvoiceRequestString(ir) + encoded, err := ir.EncodeSigned() require.ErrorIs(t, err, ErrInvalidSignature) require.Empty(t, encoded) } diff --git a/bolt12/invoice_string_test.go b/bolt12/invoice_string_test.go new file mode 100644 index 00000000000..649c05b8f92 --- /dev/null +++ b/bolt12/invoice_string_test.go @@ -0,0 +1,51 @@ +package bolt12 + +import ( + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestDecodeInvoiceStringUnvalidated asserts the display entry point skips the +// reader gates but still pins the prefix, so an offer string cannot be read +// back as an invoice. +func TestDecodeInvoiceStringUnvalidated(t *testing.T) { + t.Parallel() + + // An invoice that the reader would reject, here for a chain the node + // does not accept, still decodes for display. + inv := validInvoice(t) + + var altChain [32]byte + for i := range altChain { + altChain[i] = 0xaa + } + inv.InvreqChain = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType80](altChain), + ) + + priv, _ := bobKey() + sig, err := SignInvoice(inv, priv) + require.NoError(t, err) + inv.Signature = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType240](sig), + ) + + encoded, err := EncodeInvoiceString(inv) + require.NoError(t, err) + + _, err = DecodeInvoiceString( + encoded, farFutureNow(), bitcoinMainnetGenesisHash, + ) + require.ErrorIs(t, err, ErrUnsupportedChain) + + decoded, err := DecodeInvoiceStringUnvalidated(encoded) + require.NoError(t, err) + require.Equal(t, altChain, decoded.InvreqChain.UnwrapOrFailV(t)) + + // The prefix still has to be an invoice one. + offerStr := findTestVector(t, "Minimal bolt12 offer").Bolt12 + _, err = DecodeInvoiceStringUnvalidated(offerStr) + require.ErrorContains(t, err, "expected HRP") +} diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go index e3369b41484..ef3992b8b0a 100644 --- a/bolt12/invoice_test.go +++ b/bolt12/invoice_test.go @@ -11,7 +11,7 @@ import ( ) // validInvoice returns an Invoice populated with the minimum set of fields -// required to satisfy ValidateInvoiceWrite. +// required to satisfy validateInvoiceWrite. func validInvoice(t testing.TB) *Invoice { t.Helper() @@ -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() diff --git a/bolt12/merkle_test.go b/bolt12/merkle_test.go index c66e778e8b7..934f3476ded 100644 --- a/bolt12/merkle_test.go +++ b/bolt12/merkle_test.go @@ -30,7 +30,7 @@ func TestMerkleRootVectors(t *testing.T) { // Decode the bech32 string to get TLV bytes, // then convert into the record view merkleRoot // consumes. - _, tlvBytes, err := Decode(tc.Bolt12) + _, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) records = streamToRecords(t, tlvBytes) diff --git a/bolt12/offer.go b/bolt12/offer.go index 7405322494a..823a141d079 100644 --- a/bolt12/offer.go +++ b/bolt12/offer.go @@ -61,7 +61,8 @@ type Offer struct { // Handled types map to nil; unhandled types map to their value bytes. // Encoding and validation both derive their view from this single field // so they cannot drift apart, and so signed-range extras the decoder - // did not understand are re-emitted on encode and preserve offer_id. + // did not understand are re-emitted on encode and preserve the offer + // hash. decodedTLVs tlv.TypeMap } @@ -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) } diff --git a/bolt12/offer_hash.go b/bolt12/offer_hash.go new file mode 100644 index 00000000000..821f5402272 --- /dev/null +++ b/bolt12/offer_hash.go @@ -0,0 +1,39 @@ +package bolt12 + +import ( + "crypto/sha256" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +// OfferHash returns the offer hash a BOLT 12 message carries: the SHA-256 of +// its records in the offer TLV ranges. An offer hashes to its own offer hash, +// and an invoice_request or an invoice hashes to the offer hash of the offer it +// mirrors, so a receiver can find the offer a request answers. +// +// The hash covers a range rather than a set of known fields, so an unknown TLV +// in the offer range changes the hash. That is what makes a store lookup by +// offer hash the exact-match check the reader requirements ask for. +// +// The offer hash is a local store key, not an interop value: BOLT 12 defines +// no offer identifier, so each implementation picks its own. This construction +// is the one Core Lightning picked for its offer_id, and the two values agree +// byte for byte, which is why the Merkle root already in this package is not +// used here. LDK and eclair derive their offer id from that root instead, so +// those values differ for the same offer. +func OfferHash(m lnwire.PureTLVMessage) ([32]byte, error) { + var records []tlv.Record + for _, r := range m.AllRecords() { + if offerAllowedRange(r.Type()) { + records = append(records, r) + } + } + + encoded, err := lnwire.EncodeRecords(records) + if err != nil { + return [32]byte{}, err + } + + return sha256.Sum256(encoded), nil +} diff --git a/bolt12/offer_hash_test.go b/bolt12/offer_hash_test.go new file mode 100644 index 00000000000..be2136ffa6f --- /dev/null +++ b/bolt12/offer_hash_test.go @@ -0,0 +1,132 @@ +package bolt12 + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestOfferHashMatchesOfferEncoding asserts that for an offer the offer hash is +// the hash of its whole encoding. Every offer TLV already sits in the hashed +// ranges, so the range filter is a no-op there. +func TestOfferHashMatchesOfferEncoding(t *testing.T) { + t.Parallel() + + vec := findTestVector(t, "Minimal bolt12 offer") + _, tlvBytes, err := 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[:])) +} diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 509d5e34d0e..302394dcd1d 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -86,7 +86,7 @@ func TestOfferRoundTrip(t *testing.T) { decodedTLVs: tlv.TypeMap{13: []byte{0xde, 0xad}}, } - encoded, err := o.Encode() + encoded, err := o.encode() require.NoError(t, err) require.NotEmpty(t, encoded) @@ -100,7 +100,7 @@ func TestOfferRoundTrip(t *testing.T) { o.decodedTLVs = decoded.decodedTLVs require.Equal(t, o, decoded) - reencoded, err := decoded.Encode() + reencoded, err := decoded.encode() require.NoError(t, err) require.Equal(t, encoded, reencoded) } @@ -140,7 +140,7 @@ func TestDecodeMinimalOfferString(t *testing.T) { offerStr := "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49p" + "x20665dqwmn4p72pksese" - _, tlvBytes, err := Decode(offerStr) + _, tlvBytes, err := decodeBech32(offerStr) require.NoError(t, err) offer, err := decodeOffer(tlvBytes) @@ -165,7 +165,7 @@ func TestDecodeMinimalOfferString(t *testing.T) { hex.EncodeToString(issuerKey.SerializeCompressed())) // Re-encode and verify bytes match. - reencoded, err := offer.Encode() + reencoded, err := offer.encode() require.NoError(t, err) require.Equal(t, tlvBytes, reencoded) } diff --git a/bolt12/signature.go b/bolt12/signature.go index c4dda72418f..56f7e6e2029 100644 --- a/bolt12/signature.go +++ b/bolt12/signature.go @@ -25,7 +25,7 @@ const ( tagFieldSignature = "signature" ) -// ErrInvalidSignature is returned by VerifyInvoice and VerifyInvoiceRequest +// ErrInvalidSignature is returned by verifyInvoice and verifyInvoiceRequest // when the BIP-340 Schnorr signature does not validate against the message's // Merkle root and signing key. var ErrInvalidSignature = errors.New("BOLT 12 signature is invalid") @@ -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 diff --git a/bolt12/signature_test.go b/bolt12/signature_test.go index f7f438e6b95..e0b1174da0b 100644 --- a/bolt12/signature_test.go +++ b/bolt12/signature_test.go @@ -54,7 +54,7 @@ func verifyInvoiceRequestSigVector(t *testing.T, tc sigTestVector, // Decode the bech32 string and convert the TLV bytes into the record // view merkleRoot consumes. - _, tlvBytes, err := Decode(tc.Bolt12) + _, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) records := streamToRecords(t, tlvBytes) @@ -128,7 +128,7 @@ func TestVerifyInvoiceRequestVector(t *testing.T) { } require.NotEmpty(t, tc.Bolt12) - hrp, tlvBytes, err := Decode(tc.Bolt12) + hrp, tlvBytes, err := decodeBech32(tc.Bolt12) require.NoError(t, err) require.Equal(t, "lnr", hrp) @@ -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( diff --git a/bolt12/validate.go b/bolt12/validate.go index e305f181e66..f1a41830a04 100644 --- a/bolt12/validate.go +++ b/bolt12/validate.go @@ -141,10 +141,10 @@ var ( ErrInvalidBip353Name = errors.New("invalid invreq_bip_353_name") // ErrMissingSignature is returned when an invoice or invoice_request - // is encoded to its wire string or verified without a populated - // signature TLV. Pre-sign Encode (used to compute the Merkle root) - // is permitted to run without a signature; the wire-string layer is - // where the signature becomes mandatory. + // is emitted or verified without a populated signature TLV. Signing + // reads the struct directly, so encode never needs to run first and + // is free to serialise an unsigned message. EncodeSigned and the + // string encoders are where the signature becomes mandatory. ErrMissingSignature = errors.New("missing signature") // ErrOfferFieldsOnSpontaneous is returned when an invoice request @@ -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,11 +1487,11 @@ func ValidateInvoiceWrite(inv *Invoice) error { // - MUST specify exactly one signature TLV element: signature. // - MUST set sig to the signature using invoice_node_id as described // in Signature Calculation. - // NOT CHECKED HERE: 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. @@ -1395,7 +1499,7 @@ func ValidateInvoiceWrite(inv *Invoice) error { // invoice_created_at that payment should not be attempted. // NOT CHECKED HERE: the writer chooses the expiry, so there is no rule // to enforce on the encoded value. The time comparison needs a clock - // (see ValidateInvoiceExpiry). + // (see validateInvoiceExpiry). // - if it accepts onchain payments: // - MAY specify invoice_fallbacks. @@ -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, ) @@ -1514,8 +1619,8 @@ func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error { // payer expected to answer. Which node that is comes from the payer's own // state: offer_issuer_id, the final blinded_node_id of the path it chose, or // the node it addressed an offerless request to. None of that is derivable -// from the invoice, so ValidateInvoiceRead cannot make the comparison and -// callers run this separately, as they already do for ValidateInvoiceExpiry. +// from the invoice, so validateInvoiceRead cannot make the comparison. +// ValidateInvoiceForPayment folds it in, as it does for validateInvoiceExpiry. // // Skipping it is not cosmetic. Every node on the blinded path can answer with // its own correctly signed invoice, and the reader accepts it, because the @@ -1565,7 +1670,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 +1687,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 +1815,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 +1825,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 +1877,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 +1898,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 +1950,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,12 +1972,12 @@ func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte, // - the invreq_paths / blinded-path / reply_path arrival rules. // NOT CHECKED HERE: these are payment-time or transport concerns // handled outside this codec. invreq_amount equality is enforced by - // ValidateInvoiceAgainstRequest; the fallback ignore rules by - // 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 @@ -1892,15 +1993,15 @@ func ValidateInvoiceForPayment(inv *Invoice, req *InvoiceRequest, features InvoiceKnownFeatures, expectedNodeID *btcec.PublicKey) error { - if err := ValidateInvoiceRead(inv, activeChain, features); err != nil { + if err := validateInvoiceRead(inv, activeChain, features); err != nil { return err } - if err := ValidateInvoiceExpiry(inv, now); err != nil { + if err := validateInvoiceExpiry(inv, now); err != nil { return err } - if err := ValidateInvoiceAgainstRequest(inv, req); err != nil { + if err := validateInvoiceAgainstRequest(inv, req); err != nil { return err } diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index 1f05216f2c1..2e2b89384aa 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -139,7 +139,7 @@ func TestValidateOfferWrite(t *testing.T) { wantErr: ErrInvalidCurrency, }, { - // Pins the docstring claim that ValidateOfferWrite's + // Pins the docstring claim that validateOfferWrite's // offerAllowedRange loop exists to catch a // decoded-then-mutated offer with an out-of-range TLV // resurfacing via decodedTLVs. @@ -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{}, ) @@ -1135,7 +1147,7 @@ func TestValidateInvoiceForPayment(t *testing.T) { // The reader accepts it, which is the half that makes the composite // necessary rather than redundant. Asserting both halves on the same // invoice is what shows the second step cannot be skipped. - require.NoError(t, ValidateInvoiceRead( + require.NoError(t, validateInvoiceRead( forgedDecoded, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, )) @@ -1360,7 +1372,7 @@ func TestValidateInvoiceRequestWrite(t *testing.T) { tc.mutate(ir) - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) if tc.wantErr == nil { require.NoError(t, err) return @@ -1404,14 +1416,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir := baseRequest() // Absent invreq_amount -> invalid. - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrMissingAmount) // Present invreq_amount -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // 2. Responding to an offer. @@ -1433,14 +1445,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir := baseResponseRequest() // InvreqAmount absent -> invalid. - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrMissingAmount) // InvreqAmount present -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // Case B: OfferAmount present, OfferCurrency absent (Bitcoin). @@ -1457,7 +1469,7 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ) // InvreqAmount is optional (MAY omit it). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // If set, it MUST be >= OfferAmount * Quantity // (1000 * 2 = 2000). InvreqAmount < expected -> @@ -1465,14 +1477,14 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](1999), ) - err := ValidateInvoiceRequestWrite(ir) + err := validateInvoiceRequestWrite(ir) require.ErrorIs(t, err, ErrAmountBelowExpected) // InvreqAmount >= expected -> valid. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](2000), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // Case C: OfferAmount present, OfferCurrency present @@ -1497,11 +1509,11 @@ func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) { // InvreqAmount < OfferAmount * Quantity is allowed // because currency conversion is checked dynamically // at runtime, not statically inside - // ValidateInvoiceRequestWrite. + // validateInvoiceRequestWrite. ir.InvreqAmount = tlv.SomeRecordT( tlv.NewRecordT[tlv.TlvType82, TUint64](100), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) }) } @@ -1541,7 +1553,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { } // Absent chain is OK. - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // Bitcoin chain is OK. ir.InvreqChain = tlv.SomeRecordT( @@ -1549,13 +1561,13 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // Non-bitcoin chain is OK. ir.InvreqChain = tlv.SomeRecordT( tlv.NewPrimitiveRecord[tlv.TlvType80](testnetHash), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) }) // 2. Responding to an offer. @@ -1590,7 +1602,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ir := baseRequest() // InvreqChain absent (valid, defaults to bitcoin). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == bitcoin (valid). ir.InvreqChain = tlv.SomeRecordT( @@ -1598,7 +1610,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain != bitcoin (invalid). ir.InvreqChain = tlv.SomeRecordT( @@ -1607,7 +1619,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) }) @@ -1625,7 +1637,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ) // InvreqChain absent (valid, defaults to bitcoin). - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == bitcoin (valid). ir.InvreqChain = tlv.SomeRecordT( @@ -1633,7 +1645,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { bitcoinMainnetGenesisHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == testnet (invalid, not in offer // chains). @@ -1643,7 +1655,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) @@ -1659,7 +1671,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { // InvreqChain absent (invalid, defaults to bitcoin // which is not in offer chains). require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) @@ -1669,7 +1681,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { testnetHash, ), ) - require.NoError(t, ValidateInvoiceRequestWrite(ir)) + require.NoError(t, validateInvoiceRequestWrite(ir)) // InvreqChain == regtest (invalid, not in offer // chains). @@ -1679,7 +1691,7 @@ func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) { ), ) require.ErrorIs( - t, ValidateInvoiceRequestWrite(ir), + t, validateInvoiceRequestWrite(ir), ErrUnsupportedChain, ) }) @@ -2049,7 +2061,7 @@ func TestValidateInvoiceRequestAmountOverflow(t *testing.T) { require.ErrorIs(t, readErr, ErrAmountBelowExpected) // The writer MUST reject it too (same rule, both sides). - writeErr := ValidateInvoiceRequestWrite(newRequest()) + writeErr := validateInvoiceRequestWrite(newRequest()) require.ErrorIs(t, writeErr, ErrAmountBelowExpected) } @@ -2096,7 +2108,7 @@ func TestValidateInvoiceAmountOverflow(t *testing.T) { tlv.NewRecordT[tlv.TlvType170](TUint64(0)), ) - err := ValidateInvoiceAgainstRequest(inv, req) + err := validateInvoiceAgainstRequest(inv, req) require.ErrorIs(t, err, ErrAmountBelowExpected) } @@ -2274,7 +2286,7 @@ func encodeInvBypassValidate(inv *Invoice) ([]byte, error) { } // TestValidateInvoiceRead table-drives every reader-side rejection in -// ValidateInvoiceRead. +// validateInvoiceRead. func TestValidateInvoiceRead(t *testing.T) { t.Parallel() @@ -2464,7 +2476,7 @@ func TestValidateInvoiceRead(t *testing.T) { inv := baseline() tc.mutate(inv) - err := ValidateInvoiceRead( + err := validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -2473,10 +2485,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 +2548,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 +2651,7 @@ func TestValidateInvoiceExpiry(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateInvoiceExpiry( + err := validateInvoiceExpiry( tc.inv, time.Unix(tc.now, 0), ) if tc.wantErr != nil { @@ -2754,7 +2776,7 @@ func TestValidateInvoiceAgainstRequest(t *testing.T) { invDecoded, err := DecodeInvoice(invEncoded) require.NoError(t, err) - err = ValidateInvoiceAgainstRequest( + err = validateInvoiceAgainstRequest( invDecoded, irDecoded, ) if tc.wantErr == nil { @@ -2809,12 +2831,12 @@ func TestValidateInvoiceAgainstRequestAmountMirror(t *testing.T) { // Equal amounts pass. matchEnc, _ := encodeInvBypassValidate(build(2500)) matchDec, _ := DecodeInvoice(matchEnc) - require.NoError(t, ValidateInvoiceAgainstRequest(matchDec, irDecoded)) + require.NoError(t, validateInvoiceAgainstRequest(matchDec, irDecoded)) // Mismatched amounts fail. missEnc, _ := encodeInvBypassValidate(build(2501)) missDec, _ := DecodeInvoice(missEnc) - err = ValidateInvoiceAgainstRequest(missDec, irDecoded) + err = validateInvoiceAgainstRequest(missDec, irDecoded) require.ErrorIs(t, err, ErrInvoiceMismatch) require.Contains(t, err.Error(), "invoice_amount") } @@ -2898,7 +2920,7 @@ func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { invDec, err := DecodeInvoice(invEnc) require.NoError(t, err) - return ValidateInvoiceAgainstRequest(invDec, irDec) + return validateInvoiceAgainstRequest(invDec, irDec) } qty := func(v uint64) *uint64 { return &v } @@ -2947,7 +2969,7 @@ func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) { } // TestValidateInvoiceWrite table-drives the writer-side checks of -// ValidateInvoiceWrite by clearing required fields on a valid baseline invoice. +// validateInvoiceWrite by clearing required fields on a valid baseline invoice. func TestValidateInvoiceWrite(t *testing.T) { t.Parallel() @@ -3093,7 +3115,7 @@ func TestValidateInvoiceWrite(t *testing.T) { inv := validInvoice(t) tc.mutate(inv) - err := ValidateInvoiceWrite(inv) + err := validateInvoiceWrite(inv) if tc.wantErr == nil { require.NoError(t, err) } else { @@ -3109,7 +3131,7 @@ func TestValidateInvoiceWrite(t *testing.T) { func TestValidateFeaturesKnownBits(t *testing.T) { t.Parallel() - // Role 1 validation verifies endpoint features on ValidateInvoiceRead. + // Role 1 validation verifies endpoint features on validateInvoiceRead. t.Run("endpoint features (Role 1)", func(t *testing.T) { t.Parallel() @@ -3131,7 +3153,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { ) // An unknown required bit must be rejected. - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -3141,7 +3163,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { known := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{Invoice: known}, ) @@ -3149,7 +3171,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { }) // Role 2 validation verifies routing path features on - // ValidateInvoiceRead. + // validateInvoiceRead. t.Run("routing path features (Role 2)", func(t *testing.T) { t.Parallel() @@ -3177,7 +3199,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { // With no known feature bits there are zero usable paths, so // we expect ErrNoUsablePaths. - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{}, ) @@ -3188,7 +3210,7 @@ func TestValidateFeaturesKnownBits(t *testing.T) { knownBlinded := map[lnwire.FeatureBit]string{ lnwire.MPPRequired: "mpp", } - err = ValidateInvoiceRead( + err = validateInvoiceRead( inv, bitcoinMainnetGenesisHash, InvoiceKnownFeatures{Blinded: knownBlinded}, ) @@ -3205,13 +3227,13 @@ func TestValidateFeaturesKnownBits(t *testing.T) { tlv.NewRecordT[tlv.TlvType174](fv), ) - require.NoError(t, ValidateInvoiceWrite(inv)) + require.NoError(t, validateInvoiceWrite(inv)) }) } // TestValidateInvoiceWriteRejectsNilPubkeys verifies the writer rejects a // present-but-nil mirrored pubkey field, which would otherwise panic the codec -// on encode. Symmetric with ValidateInvoiceRequestWrite. +// on encode. Symmetric with validateInvoiceRequestWrite. func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { t.Parallel() @@ -3224,7 +3246,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) t.Run("present-but-nil offer_issuer_id", func(t *testing.T) { @@ -3236,7 +3258,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) t.Run("present-but-nil node_id", func(t *testing.T) { @@ -3248,7 +3270,7 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) { (*btcec.PublicKey)(nil), ), ) - require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey) + require.ErrorIs(t, validateInvoiceWrite(inv), ErrNilPublicKey) }) } @@ -3314,7 +3336,7 @@ func TestValidateInvoiceErrorWrite(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateInvoiceErrorWrite(tc.ie) + err := validateInvoiceErrorWrite(tc.ie) if tc.wantErr == nil { require.NoError(t, err) @@ -3341,7 +3363,7 @@ func TestValidateOfferReadVectors(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { t.Parallel() - _, tlvBytes, bech32Err := Decode(tc.Bolt12) + _, tlvBytes, bech32Err := decodeBech32(tc.Bolt12) if bech32Err != nil { if tc.Valid { require.NoError( @@ -3376,7 +3398,7 @@ func TestValidateOfferReadVectors(t *testing.T) { activeChain = c[0] } - valErr := ValidateOfferRead( + valErr := validateOfferRead( offer, now, activeChain, nil, ) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index b0913ff6bf3..8cd23b9382a 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -144,9 +144,16 @@ * [BOLT 12 string codecs and payment validation](https://github.com/lightningnetwork/lnd/pull/11146): add - validated `Decode`/`Encode` string entry points for offers, invoice - requests, and invoices, and `ValidateInvoiceForPayment` to bundle the - payer-side invoice checks into one call. + validated `Decode`/`Encode` string entry points for offers and invoices, + and `ValidateInvoiceForPayment` to bundle the payer-side invoice checks + into one call. + +* [BOLT 12 offer + store](https://github.com/lightningnetwork/lnd/pull/XXXXX): add the + `offers` package with a SQL-backed store for BOLT 12 offers, and the + `offers` table migration. The store keeps the encoded offer, a disabled + flag and the creation time. It finds an offer by its hash. + `CreateOffer` validates the parameters, encodes the offer and stores it. ## Testing diff --git a/offers/create.go b/offers/create.go new file mode 100644 index 00000000000..dfdd70bc17f --- /dev/null +++ b/offers/create.go @@ -0,0 +1,195 @@ +package offers + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/bolt12" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" +) + +var ( + // ErrMissingDescription is returned when the offer has an amount but no + // description. + ErrMissingDescription = errors.New("description required when amount " + + "is set") + + // ErrMissingIssuerKey is returned when no issuer public key is + // provided. + ErrMissingIssuerKey = errors.New("issuer public key required") + + // ErrExpiryNotInFuture is returned when the absolute expiry is not + // after the creation time, so no payer could use the offer. + ErrExpiryNotInFuture = errors.New("absolute expiry is not in the " + + "future") +) + +// CreateOfferParams holds the fields of a new offer. Each field maps to a +// matching offer TLV record. +type CreateOfferParams struct { + // Identity specifies how the offer identifies the receiver. Left holds + // the issuer public key (offer_issuer_id), which reveals the node + // identity. Right holds blinded message paths (offer_paths), which + // preserve privacy. + Identity fn.Either[*btcec.PublicKey, []lnwire.BlindedPath] + + // Description is the UTF-8 description of the payment purpose. Required + // when Amount is set. + Description string + + // AmountMsat is the per-item amount in millisatoshis. Zero means no + // fixed amount (the payer must specify invreq_amount). + AmountMsat uint64 + + // AbsoluteExpiry is seconds since epoch after which the offer expires. + // Zero leaves offer_absolute_expiry out, so the offer never expires. + AbsoluteExpiry uint64 + + // QuantityMax is the maximum items per invoice. None means the offer + // does not support quantity selection. Some(0) means unlimited + // quantity. + QuantityMax fn.Option[uint64] + + // Chains specifies which blockchain networks this offer is valid for. + // When empty, the spec defaults to Bitcoin mainnet, so non-mainnet + // offers must set this. + Chains [][32]byte +} + +// CreateOfferResult reports a created offer. Hash is the lookup key in the +// offer store. +type CreateOfferResult struct { + // Hash is the SHA256 hash of the TLV-encoded offer. + Hash [32]byte + + // Encoded is the bech32-encoded offer string (lno1...). + Encoded string +} + +// CreateOffer creates and stores a BOLT 12 offer. It returns the encoded string +// and the offer hash. It rejects an absolute expiry at or before the time of +// clk. +func CreateOffer(ctx context.Context, store Store, clk clock.Clock, + params CreateOfferParams) (*CreateOfferResult, error) { + + var identityErr error + params.Identity.WhenLeft(func(key *btcec.PublicKey) { + if key == nil { + identityErr = ErrMissingIssuerKey + } + }) + params.Identity.WhenRight(func(paths []lnwire.BlindedPath) { + if len(paths) == 0 { + identityErr = fmt.Errorf("offer_paths must " + + "contain at least one path") + } + }) + if identityErr != nil { + return nil, identityErr + } + + // The spec requires offer_description when offer_amount is set. + if params.AmountMsat > 0 && params.Description == "" { + return nil, ErrMissingDescription + } + + if params.AbsoluteExpiry > 0 && + params.AbsoluteExpiry <= uint64(clk.Now().Unix()) { + + return nil, ErrExpiryNotInFuture + } + + offer := &bolt12.Offer{} + + params.Identity.WhenLeft(func(key *btcec.PublicKey) { + offer.OfferIssuerID = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]{ + Val: key, + }, + ) + }) + params.Identity.WhenRight(func(paths []lnwire.BlindedPath) { + offer.OfferPaths = tlv.SomeRecordT( + tlv.RecordT[ + tlv.TlvType16, lnwire.BlindedPaths, + ]{ + Val: lnwire.BlindedPaths{ + Paths: paths, + }, + }, + ) + }) + + if len(params.Chains) > 0 { + offer.OfferChains = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType2, bolt12.ChainsRecord]{ + Val: bolt12.ChainsRecord{ + Chains: params.Chains, + }, + }, + ) + } + + if params.Description != "" { + offer.OfferDescription = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType10, tlv.Blob]{ + Val: []byte(params.Description), + }, + ) + } + + if params.AmountMsat > 0 { + amount := bolt12.TUint64(params.AmountMsat) + offer.OfferAmount = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType8, bolt12.TUint64]{ + Val: amount, + }, + ) + } + + if params.AbsoluteExpiry > 0 { + expiry := bolt12.TUint64(params.AbsoluteExpiry) + offer.OfferAbsoluteExpiry = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType14, bolt12.TUint64]{ + Val: expiry, + }, + ) + } + + params.QuantityMax.WhenSome(func(qty uint64) { + offer.OfferQuantityMax = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType20, bolt12.TUint64]{ + Val: bolt12.TUint64(qty), + }, + ) + }) + + encoded, err := bolt12.EncodeOfferString(offer) + if err != nil { + return nil, fmt.Errorf("encode offer: %w", err) + } + + offerHash, err := bolt12.OfferHash(offer) + if err != nil { + return nil, fmt.Errorf("offer hash: %w", err) + } + + // The encoded string carries every offer field. + _, err = store.InsertOffer(ctx, &Offer{ + Hash: offerHash, + Encoded: encoded, + }) + if err != nil { + return nil, fmt.Errorf("persist offer: %w", err) + } + + return &CreateOfferResult{ + Hash: offerHash, + Encoded: encoded, + }, nil +} diff --git a/offers/create_test.go b/offers/create_test.go new file mode 100644 index 00000000000..b797a21e930 --- /dev/null +++ b/offers/create_test.go @@ -0,0 +1,217 @@ +package offers + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/lightningnetwork/lnd/bolt12" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// issuerIdentity wraps a pubkey as a Left Either for CreateOfferParams. +func issuerIdentity(key *btcec.PublicKey) fn.Either[*btcec.PublicKey, + []lnwire.BlindedPath] { + + return fn.NewLeft[*btcec.PublicKey, []lnwire.BlindedPath](key) +} + +// testIssuerKey generates a deterministic private key for testing. +func testIssuerKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + + // Use a fixed seed for deterministic tests. + var seed [32]byte + for i := range seed { + seed[i] = byte(i + 1) + } + + privKey, _ := btcec.PrivKeyFromBytes(seed[:]) + + return privKey +} + +// decodeStoredOffer looks up an offer by its offer hash and decodes the stored +// string. +func decodeStoredOffer(t *testing.T, store Store, + offerHash [32]byte) *bolt12.Offer { + + t.Helper() + + got, err := store.GetOfferByHash(t.Context(), offerHash) + require.NoError(t, err) + + // The time is before every expiry in these tests. + decoded, err := bolt12.DecodeOfferString( + got.Encoded, time.Unix(0, 0), + [32]byte(*chaincfg.MainNetParams.GenesisHash), + ) + require.NoError(t, err) + + return decoded +} + +// TestCreateOffer verifies that CreateOffer validates its parameters, and +// that a created offer is stored and decodes with the requested fields. +func TestCreateOffer(t *testing.T) { + t.Parallel() + + privKey := testIssuerKey(t) + identity := issuerIdentity(privKey.PubKey()) + now := uint64(testTime.Unix()) + later := uint64(testTime.Add(time.Hour).Unix()) + + testCases := []struct { + name string + params CreateOfferParams + wantErr error + + // check inspects the decoded offer of a successful case. + check func(t *testing.T, offer *bolt12.Offer) + }{{ + name: "fixed amount", + params: CreateOfferParams{ + Identity: identity, + Description: "coffee", + AmountMsat: 10000, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + amount := offer.OfferAmount.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(10000), amount) + require.True(t, offer.OfferQuantityMax.IsNone()) + require.True(t, offer.OfferAbsoluteExpiry.IsNone()) + }, + }, { + name: "any amount", + params: CreateOfferParams{ + Identity: identity, + Description: "tips", + }, + check: func(t *testing.T, offer *bolt12.Offer) { + require.True(t, offer.OfferAmount.IsNone()) + }, + }, { + name: "amount without description", + params: CreateOfferParams{ + Identity: identity, + AmountMsat: 10000, + }, + wantErr: ErrMissingDescription, + }, { + name: "missing issuer key", + params: CreateOfferParams{ + Description: "test", + }, + wantErr: ErrMissingIssuerKey, + }, { + name: "expiry in the future", + params: CreateOfferParams{ + Identity: identity, + Description: "limited time", + AmountMsat: 5000, + AbsoluteExpiry: later, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + expiry := offer.OfferAbsoluteExpiry.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(later), expiry) + }, + }, { + name: "expiry before now", + params: CreateOfferParams{ + Identity: identity, + Description: "expired", + AmountMsat: 5000, + AbsoluteExpiry: now - 1, + }, + wantErr: ErrExpiryNotInFuture, + }, { + name: "expiry at now", + params: CreateOfferParams{ + Identity: identity, + Description: "expired", + AmountMsat: 5000, + AbsoluteExpiry: now, + }, + wantErr: ErrExpiryNotInFuture, + }, { + name: "quantity limit", + params: CreateOfferParams{ + Identity: identity, + Description: "stickers", + AmountMsat: 1000, + QuantityMax: fn.Some[uint64](10), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + qty := offer.OfferQuantityMax.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(10), qty) + }, + }, { + name: "unlimited quantity", + params: CreateOfferParams{ + Identity: identity, + Description: "stickers", + AmountMsat: 1000, + QuantityMax: fn.Some[uint64](0), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + qty := offer.OfferQuantityMax.UnwrapOrFailV(t) + require.Zero(t, qty) + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + result, err := CreateOffer( + t.Context(), store, testClock, tc.params, + ) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + // The stored offer is the one CreateOffer returned, + // and it carries the issuer id it was created with. + got, err := store.GetOfferByHash( + t.Context(), result.Hash, + ) + require.NoError(t, err) + require.Equal(t, result.Encoded, got.Encoded) + + offer := decodeStoredOffer(t, store, result.Hash) + issuer := offer.OfferIssuerID.UnwrapOrFailV(t) + require.True(t, privKey.PubKey().IsEqual(issuer)) + + tc.check(t, offer) + }) + } +} + +// TestCreateOfferDuplicate verifies that creating the same offer twice fails, +// because the offer hash is unique. +func TestCreateOfferDuplicate(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + ctx := t.Context() + privKey := testIssuerKey(t) + + params := CreateOfferParams{ + Identity: issuerIdentity(privKey.PubKey()), + Description: "coffee", + AmountMsat: 10000, + } + + _, err := CreateOffer(ctx, store, testClock, params) + require.NoError(t, err) + + // The same parameters produce the same offer hash. + _, err = CreateOffer(ctx, store, testClock, params) + require.ErrorIs(t, err, ErrOfferExists) +} diff --git a/offers/interface.go b/offers/interface.go new file mode 100644 index 00000000000..49faaa8ca80 --- /dev/null +++ b/offers/interface.go @@ -0,0 +1,50 @@ +package offers + +import ( + "context" + "errors" + "time" +) + +var ( + // ErrOfferNotFound is returned when no offer has the requested offer + // hash. + ErrOfferNotFound = errors.New("offer not found") + + // ErrOfferExists is returned when an offer with the same offer hash is + // already stored. + ErrOfferExists = errors.New("offer already exists") +) + +// Offer represents a persisted BOLT 12 offer. It keeps the encoded offer and +// the local state that the offer string does not carry. Code that needs an +// offer field decodes Encoded. +type Offer struct { + // ID is the database primary key. + ID int64 + + // Hash is the SHA256 hash of the TLV-encoded offer, used as a unique + // external identifier. + Hash [32]byte + + // Encoded is the full bech32-encoded offer string (lno1...). + Encoded string + + // IsDisabled indicates the offer has been administratively disabled. + IsDisabled bool + + // CreatedAt is the time the store persisted the offer. + CreatedAt time.Time +} + +// Store defines the interface for persisting and querying BOLT 12 offers. +type Store interface { + // InsertOffer persists a new offer and returns its database ID. The + // store sets the creation time. It returns ErrOfferExists if an offer + // with the same offer hash is already stored. + InsertOffer(ctx context.Context, offer *Offer) (int64, error) + + // GetOfferByHash retrieves an offer by its 32-byte offer hash. It + // returns ErrOfferNotFound if no offer has that hash. + GetOfferByHash(ctx context.Context, offerHash [32]byte) (*Offer, error) +} diff --git a/offers/sql_store.go b/offers/sql_store.go new file mode 100644 index 00000000000..c77658f3f8b --- /dev/null +++ b/offers/sql_store.go @@ -0,0 +1,131 @@ +package offers + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/lightningnetwork/lnd/sqldb/sqlc" +) + +// SQLOfferQueries is the interface that defines the set of operations that can +// be executed against the offers SQL database. +type SQLOfferQueries interface { + // InsertOffer inserts an offer row and returns its primary key. It + // fails with a unique constraint violation when the offer hash is + // already stored. + InsertOffer(ctx context.Context, + arg sqlc.InsertOfferParams) (int64, error) + + // GetOfferByHash returns the offer row with the given offer hash, or + // sql.ErrNoRows when no row has it. + GetOfferByHash(ctx context.Context, + offerHash []byte) (sqlc.Offer, error) +} + +// BatchedSQLOfferQueries combines the offer queries interface with batched +// transaction execution. +type BatchedSQLOfferQueries interface { + SQLOfferQueries + + sqldb.BatchedTx[SQLOfferQueries] +} + +// SQLStore is the SQL-backed implementation of the Store interface. +type SQLStore struct { + db BatchedSQLOfferQueries + clock clock.Clock +} + +// NewSQLStore creates a new SQL-backed offer store. +func NewSQLStore(db BatchedSQLOfferQueries, clock clock.Clock) *SQLStore { + return &SQLStore{ + db: db, + clock: clock, + } +} + +// InsertOffer persists a new offer and returns its database ID. The store sets +// the creation time from its clock. It returns ErrOfferExists if an offer with +// the same offer hash is already stored. +func (s *SQLStore) InsertOffer(ctx context.Context, offer *Offer) (int64, + error) { + + var id int64 + + err := s.db.ExecTx( + ctx, sqldb.WriteTxOpt(), + func(q SQLOfferQueries) error { + var err error + id, err = q.InsertOffer(ctx, sqlc.InsertOfferParams{ + Hash: offer.Hash[:], + Encoded: offer.Encoded, + IsDisabled: offer.IsDisabled, + CreatedAt: s.clock.Now().UTC(), + }) + + return err + }, + sqldb.NoOpReset, + ) + if err != nil { + var uniqueErr *sqldb.ErrSQLUniqueConstraintViolation + if errors.As(sqldb.MapSQLError(err), &uniqueErr) { + return 0, ErrOfferExists + } + + return 0, fmt.Errorf("insert offer: %w", err) + } + + return id, nil +} + +// GetOfferByHash retrieves an offer by its 32-byte offer hash. It returns +// ErrOfferNotFound if no offer has that hash. +func (s *SQLStore) GetOfferByHash(ctx context.Context, offerHash [32]byte) ( + *Offer, error) { + + var offer *Offer + + err := s.db.ExecTx( + ctx, sqldb.ReadTxOpt(), + func(q SQLOfferQueries) error { + row, err := q.GetOfferByHash(ctx, offerHash[:]) + if err != nil { + return err + } + + offer = marshalOffer(row) + + return nil + }, + sqldb.NoOpReset, + ) + + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrOfferNotFound + + case err != nil: + return nil, fmt.Errorf("get offer by offer hash: %w", err) + } + + return offer, nil +} + +// marshalOffer converts a sqlc.Offer row to our domain Offer type. +func marshalOffer(row sqlc.Offer) *Offer { + offer := &Offer{ + ID: row.ID, + Encoded: row.Encoded, + IsDisabled: row.IsDisabled, + CreatedAt: row.CreatedAt, + } + + copy(offer.Hash[:], row.Hash) + + return offer +} diff --git a/offers/sql_store_test.go b/offers/sql_store_test.go new file mode 100644 index 00000000000..d62a97d968c --- /dev/null +++ b/offers/sql_store_test.go @@ -0,0 +1,97 @@ +package offers + +import ( + "crypto/sha256" + "database/sql" + "testing" + "time" + + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/stretchr/testify/require" +) + +// testTime is the time the test clock reports. It has no sub-second part, so +// it survives the database round trip unchanged. +var testTime = time.Unix(1735689600, 0).UTC() + +// testClock is the clock the tests give CreateOffer. It stands at testTime, +// like the clock of the test store. +var testClock = clock.NewTestClock(testTime) + +// newTestSQLStore creates an offer store on the database that the build tags +// select. +func newTestSQLStore(t *testing.T) *SQLStore { + t.Helper() + + db := newTestBaseDB(t) + + executor := sqldb.NewTransactionExecutor( + db, + func(tx *sql.Tx) SQLOfferQueries { + return db.WithTx(tx) + }, + ) + + return NewSQLStore(executor, clock.NewTestClock(testTime)) +} + +// testOffer returns an offer with a synthetic encoding and offer hash. +func testOffer() *Offer { + encoded := "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua" + + "9uve2rlrm9deu7xyfzrcgqyqs" + + return &Offer{ + Hash: sha256.Sum256([]byte(encoded)), + Encoded: encoded, + } +} + +// TestInsertAndGetOffer verifies that an offer round-trips through insert and +// lookup by offer hash, and that the store sets the creation time. +func TestInsertAndGetOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + ctx := t.Context() + offer := testOffer() + + id, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + + got, err := store.GetOfferByHash(ctx, offer.Hash) + require.NoError(t, err) + require.Equal(t, &Offer{ + ID: id, + Hash: offer.Hash, + Encoded: offer.Encoded, + CreatedAt: testTime, + }, got) +} + +// TestInsertDuplicateOffer verifies that a second offer with the same offer +// hash is rejected with ErrOfferExists. +func TestInsertDuplicateOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + ctx := t.Context() + offer := testOffer() + + _, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + + _, err = store.InsertOffer(ctx, offer) + require.ErrorIs(t, err, ErrOfferExists) +} + +// TestGetUnknownOffer verifies that a lookup of an unknown offer hash returns +// ErrOfferNotFound. +func TestGetUnknownOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t) + + _, err := store.GetOfferByHash(t.Context(), [32]byte{1}) + require.ErrorIs(t, err, ErrOfferNotFound) +} diff --git a/offers/test_postgres_test.go b/offers/test_postgres_test.go new file mode 100644 index 00000000000..56620269f44 --- /dev/null +++ b/offers/test_postgres_test.go @@ -0,0 +1,21 @@ +//go:build test_db_postgres + +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// newTestBaseDB creates a Postgres database for the offer store tests. +func newTestBaseDB(t *testing.T) *sqldb.BaseDB { + fixture := sqldb.NewTestPgFixture( + t, sqldb.DefaultPostgresFixtureLifetime, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + return sqldb.NewTestPostgresDB(t, fixture).BaseDB +} diff --git a/offers/test_sqlite_test.go b/offers/test_sqlite_test.go new file mode 100644 index 00000000000..c464242b061 --- /dev/null +++ b/offers/test_sqlite_test.go @@ -0,0 +1,14 @@ +//go:build !test_db_postgres + +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// newTestBaseDB creates a SQLite database for the offer store tests. +func newTestBaseDB(t *testing.T) *sqldb.BaseDB { + return sqldb.NewTestSqliteDB(t).BaseDB +} diff --git a/sqldb/migrations.go b/sqldb/migrations.go index 241e5c0d683..a174b406278 100644 --- a/sqldb/migrations.go +++ b/sqldb/migrations.go @@ -136,6 +136,11 @@ var ( Version: 18, SchemaVersion: 15, }, + { + Name: "000016_offers", + Version: 19, + SchemaVersion: 16, + }, }, migrationAdditions...) // ErrMigrationMismatch is returned when a migrated record does not diff --git a/sqldb/sqlc/migrations/000016_offers.down.sql b/sqldb/sqlc/migrations/000016_offers.down.sql new file mode 100644 index 00000000000..d47b5175cbc --- /dev/null +++ b/sqldb/sqlc/migrations/000016_offers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS offers; diff --git a/sqldb/sqlc/migrations/000016_offers.up.sql b/sqldb/sqlc/migrations/000016_offers.up.sql new file mode 100644 index 00000000000..31021c9ff07 --- /dev/null +++ b/sqldb/sqlc/migrations/000016_offers.up.sql @@ -0,0 +1,21 @@ +-- offers stores long-lived BOLT 12 offer templates. Each offer can generate +-- many invoices over its lifetime. +CREATE TABLE IF NOT EXISTS offers ( + -- Primary key for the offer record. + id INTEGER PRIMARY KEY, + + -- The SHA256 hash of the TLV-encoded offer, used as a unique external + -- identifier. 32 bytes. + hash BLOB NOT NULL UNIQUE, + + -- The full bech32-encoded offer string (lno1...). This is the + -- authoritative source for all offer fields. + encoded TEXT NOT NULL, + + -- Whether the offer has been administratively disabled. A disabled + -- offer rejects new invoice requests. + is_disabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Timestamp of when this offer was created. + created_at TIMESTAMP NOT NULL +); diff --git a/sqldb/sqlc/models.go b/sqldb/sqlc/models.go index ef9aa9006f9..159c041aee7 100644 --- a/sqldb/sqlc/models.go +++ b/sqldb/sqlc/models.go @@ -214,6 +214,14 @@ type MigrationTracker struct { MigrationTime time.Time } +type Offer struct { + ID int64 + Hash []byte + Encoded string + IsDisabled bool + CreatedAt time.Time +} + type Payment struct { ID int64 AmountMsat int64 diff --git a/sqldb/sqlc/offers.sql.go b/sqldb/sqlc/offers.sql.go new file mode 100644 index 00000000000..9033c2e3bd7 --- /dev/null +++ b/sqldb/sqlc/offers.sql.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: offers.sql + +package sqlc + +import ( + "context" + "time" +) + +const getOfferByHash = `-- name: GetOfferByHash :one +SELECT id, hash, encoded, is_disabled, created_at +FROM offers +WHERE hash = $1 +` + +func (q *Queries) GetOfferByHash(ctx context.Context, hash []byte) (Offer, error) { + row := q.db.QueryRowContext(ctx, getOfferByHash, hash) + var i Offer + err := row.Scan( + &i.ID, + &i.Hash, + &i.Encoded, + &i.IsDisabled, + &i.CreatedAt, + ) + return i, err +} + +const insertOffer = `-- name: InsertOffer :one +INSERT INTO offers ( + hash, encoded, is_disabled, created_at +) VALUES ( + $1, $2, $3, $4 +) RETURNING id +` + +type InsertOfferParams struct { + Hash []byte + Encoded string + IsDisabled bool + CreatedAt time.Time +} + +func (q *Queries) InsertOffer(ctx context.Context, arg InsertOfferParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertOffer, + arg.Hash, + arg.Encoded, + arg.IsDisabled, + arg.CreatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 9b95a669917..f3655d7b615 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -143,6 +143,7 @@ type Querier interface { GetNodesByBlockHeightRange(ctx context.Context, arg GetNodesByBlockHeightRangeParams) ([]GraphNode, error) GetNodesByIDs(ctx context.Context, ids []int64) ([]GraphNode, error) GetNodesByLastUpdateRange(ctx context.Context, arg GetNodesByLastUpdateRangeParams) ([]GraphNode, error) + GetOfferByHash(ctx context.Context, hash []byte) (Offer, error) GetPruneEntriesForHeights(ctx context.Context, heights []int64) ([]GraphPruneLog, error) GetPruneHashByHeight(ctx context.Context, blockHeight int64) ([]byte, error) GetPruneTip(ctx context.Context) (GraphPruneLog, error) @@ -198,6 +199,7 @@ type Querier interface { // is used because of the constraint in that query that requires a node update // to have a newer last_update than the existing node). InsertNodeMig(ctx context.Context, arg InsertNodeMigParams) (int64, error) + InsertOffer(ctx context.Context, arg InsertOfferParams) (int64, error) // Insert a new payment and return its ID. // When creating a payment we don't have a fail reason because we start the // payment process. diff --git a/sqldb/sqlc/queries/offers.sql b/sqldb/sqlc/queries/offers.sql new file mode 100644 index 00000000000..57d361c90d4 --- /dev/null +++ b/sqldb/sqlc/queries/offers.sql @@ -0,0 +1,11 @@ +-- name: InsertOffer :one +INSERT INTO offers ( + hash, encoded, is_disabled, created_at +) VALUES ( + $1, $2, $3, $4 +) RETURNING id; + +-- name: GetOfferByHash :one +SELECT * +FROM offers +WHERE hash = $1;