diff --git a/bolt12/bech32.go b/bolt12/bech32.go new file mode 100644 index 0000000000..bd12b73a70 --- /dev/null +++ b/bolt12/bech32.go @@ -0,0 +1,334 @@ +package bolt12 + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/btcsuite/btcd/btcutil/bech32" +) + +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 = errors.New("input length exceeds limit") + + // ErrEmptyString is returned when a string has no characters. It is + // also returned when a payload has no bytes. + ErrEmptyString = errors.New("empty string") + + // ErrMixedCase is returned when a bech32 string contains both + // uppercase and lowercase characters. + ErrMixedCase = errors.New("string not all lowercase or all uppercase") + + // ErrInvalidSeparator is returned when the '1' separator is missing + // or misplaced. + ErrInvalidSeparator = errors.New("missing or invalid separator") + + // ErrUnsupportedHRP is returned when the human-readable prefix is not + // in validHRPs (lno/lnr/lni). + ErrUnsupportedHRP = errors.New("unsupported HRP") + + // ErrInvalidCharacter is returned when a character outside printable + // ASCII or outside the bech32 charset is encountered. + ErrInvalidCharacter = errors.New("invalid character") + + // ErrInvalidContinuation is returned when '+' placement violates BOLT + // 12 rules. + ErrInvalidContinuation = errors.New("invalid continuation") + + // ErrBaseConversion is returned when base 32 / base 256 conversion + // fails. + ErrBaseConversion = errors.New("base conversion failed") + + // ErrCharConversion is returned when a 5-bit value exceeds the bech32 + // alphabet bounds. + ErrCharConversion = errors.New("char conversion failed") +) + +const ( + // HRPOffer is the human-readable prefix for BOLT 12 offers. + HRPOffer = "lno" + + // HRPInvoiceRequest is the human-readable prefix for BOLT 12 invoice + // requests. + HRPInvoiceRequest = "lnr" + + // HRPInvoice is the human-readable prefix for BOLT 12 invoices. + HRPInvoice = "lni" + + // charset is the set of valid bech32 characters. + charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + // minPrintableASCII is the lower bound for printable ASCII characters + // ('!'). + minPrintableASCII = 33 + + // maxPrintableASCII is the upper bound for printable ASCII characters + // ('~'). + 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. + 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 = 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 +// error messages name them. +var validHRPs = []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} + +// isValidHRP tells the caller if hrp is a BOLT 12 prefix. +func isValidHRP(hrp string) bool { + return slices.Contains(validHRPs, hrp) +} + +// unsupportedHRPError reports that hrp is not a BOLT 12 prefix. The message +// names the permitted prefixes from the one list that holds them. +func unsupportedHRPError(hrp string) error { + return fmt.Errorf( + "bolt12: %w %q (want %s)", ErrUnsupportedHRP, hrp, + strings.Join(validHRPs, "/"), + ) +} + +// 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, + ) + } + + 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) + } + + // The characters must be either all lowercase or all uppercase. + lower := strings.ToLower(cleaned) + if cleaned != lower && cleaned != strings.ToUpper(cleaned) { + return "", nil, fmt.Errorf("bolt12: %w", ErrMixedCase) + } + + cleaned = lower + + // Find the separator. The last '1' separates the HRP from data. + one := strings.LastIndexByte(cleaned, '1') + if one < 1 || one+1 >= len(cleaned) { + return "", nil, fmt.Errorf("bolt12: %w", ErrInvalidSeparator) + } + + hrp := cleaned[:one] + if !isValidHRP(hrp) { + return "", nil, unsupportedHRPError(hrp) + } + dataStr := cleaned[one+1:] + + // Validate and convert each character to its bech32 value. + data5bit, err := toBech32Bytes(dataStr) + if err != nil { + return "", nil, err + } + + // Convert from base32 (5-bit groups) to base256 (8-bit bytes). + data8bit, err := bech32.ConvertBits(data5bit, 5, 8, false) + if err != nil { + return "", nil, fmt.Errorf( + "bolt12: %w: %w", ErrBaseConversion, err, + ) + } + + return hrp, data8bit, nil +} + +// Encode 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) { + hrp = strings.ToLower(hrp) + if !isValidHRP(hrp) { + return "", unsupportedHRPError(hrp) + } + + // A BOLT 12 string holds a TLV stream, and the stream must hold at + // least one record. An empty payload gives a string with only the + // prefix and the separator, which Decode rejects. + if len(data) == 0 { + return "", fmt.Errorf( + "bolt12: %w: nothing to encode", ErrEmptyString, + ) + } + + if len(data) > maxBolt12DataLen { + return "", fmt.Errorf( + "bolt12: %w: payload length %d exceeds limit %d", + ErrStringTooLong, len(data), maxBolt12DataLen, + ) + } + + // Convert from base256 to base32. + data5bit, err := bech32.ConvertBits(data, 8, 5, true) + if err != nil { + return "", fmt.Errorf("bolt12: %w: %w", ErrBaseConversion, err) + } + + chars, err := toBech32Chars(data5bit) + if err != nil { + return "", fmt.Errorf("bolt12: %w: %w", ErrCharConversion, err) + } + + return hrp + "1" + chars, nil +} + +// stripContinuation removes each '+' marker and the whitespace after it, and +// rejects each byte outside the printable ASCII range. A marker joins two parts +// of one string, so a character that is neither whitespace nor a second marker +// must stand on each side. This rejects a marker at the start or the end, and +// two markers together. +// +// The two characters need not be bech32 characters. The spec does not say what +// to do inside the prefix, and the prefix check and the alphabet scan run after +// this step, so a marker there cannot make an invalid string valid. +func stripContinuation(s string) (string, error) { + var b strings.Builder + b.Grow(len(s)) + + for i := 0; i < len(s); i++ { + c := s[i] + if c != '+' { + if c < minPrintableASCII || c > maxPrintableASCII { + return "", fmt.Errorf( + "bolt12: %w: invalid byte 0x%02x at "+ + "position %d", + ErrInvalidCharacter, c, i, + ) + } + b.WriteByte(c) + + continue + } + + if i == 0 || !isContinuationNeighbour(s[i-1]) { + return "", fmt.Errorf( + "bolt12: %w: '+' must follow a "+ + "non-whitespace character", + ErrInvalidContinuation, + ) + } + + // Skip '+' and any following whitespace. + j := i + 1 + for j < len(s) && isWhitespace(s[j]) { + j++ + } + if j >= len(s) || !isContinuationNeighbour(s[j]) { + return "", fmt.Errorf( + "bolt12: %w: '+' must precede a "+ + "non-whitespace character", + ErrInvalidContinuation, + ) + } + + // Resume at the character the '+' joined to. + i = j - 1 + } + + return b.String(), nil +} + +// isContinuationNeighbour tells the caller if c can stand next to a '+' marker. +// A marker joins string content, so whitespace and a second marker cannot. +func isContinuationNeighbour(c byte) bool { + return c != '+' && !isWhitespace(c) +} + +// isWhitespace tells the caller if c is one of the six ASCII whitespace +// characters: space, tab, line feed, vertical tab, form feed, and carriage +// return. The spec narrows the class nowhere, and this is the set in +// strings.asciiSpace. unicode.IsSpace is the wrong test here, because it also +// accepts the byte 0x85 and the byte 0xA0, which a BOLT 12 string cannot +// hold. +func isWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || + c == '\f' || c == '\r' +} + +// toBech32Bytes converts a string of bech32 characters to their 5-bit integer +// values. Reported position offsets are relative to the normalized string after +// continuation stripping. +func toBech32Bytes(s string) ([]byte, error) { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + idx := strings.IndexByte(charset, s[i]) + if idx < 0 { + return nil, fmt.Errorf( + "bolt12: %w: invalid character 0x%02x at "+ + "position %d of the cleaned data "+ + "string", + ErrInvalidCharacter, s[i], i, + ) + } + result[i] = byte(idx) + } + + return result, nil +} + +// toBech32Chars converts 5-bit values to their bech32 character representation. +func toBech32Chars(data []byte) (string, error) { + result := make([]byte, len(data)) + for i, b := range data { + if int(b) >= len(charset) { + return "", fmt.Errorf( + "bolt12: %w: invalid data byte: %d", + ErrCharConversion, b, + ) + } + result[i] = charset[b] + } + + return string(result), nil +} diff --git a/bolt12/bech32_test.go b/bolt12/bech32_test.go new file mode 100644 index 0000000000..c1b8ed97f6 --- /dev/null +++ b/bolt12/bech32_test.go @@ -0,0 +1,483 @@ +package bolt12 + +import ( + "math" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestBech32FormatStringVectors runs through every test case in the spec's +// format-string-test.json to verify our bech32 encoder/decoder handles +// continuations, case, and edge cases correctly. +func TestBech32FormatStringVectors(t *testing.T) { + t.Parallel() + + vectors := loadFormatStringVectors(t) + require.NotEmpty(t, vectors) + + for _, tc := range vectors { + t.Run(tc.Comment, func(t *testing.T) { + t.Parallel() + + hrp, decoded, err := Decode(tc.String) + + if !tc.Valid { + require.Error(t, err, "expected error for: %s", + tc.Comment) + + return + } + + require.NoError(t, err, "unexpected error for: %s", + tc.Comment) + require.Equal(t, HRPOffer, hrp) + require.NotEmpty(t, decoded) + + // Round-trip: re-encode and decode again. + encoded, err := Encode(hrp, decoded) + require.NoError(t, err) + + hrp2, decoded2, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, hrp, hrp2) + require.Equal(t, decoded, decoded2) + }) + } +} + +// TestBech32RoundTrip verifies that encoding then decoding returns the original +// data for each supported HRP. +func TestBech32RoundTrip(t *testing.T) { + t.Parallel() + + testData := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd} + + for _, hrp := range []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} { + t.Run(hrp, func(t *testing.T) { + t.Parallel() + + encoded, err := Encode(hrp, testData) + require.NoError(t, err) + require.True(t, len(encoded) > len(hrp)+1) + + gotHRP, gotData, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, hrp, gotHRP) + require.Equal(t, testData, gotData) + }) + } +} + +// TestBech32DecodeErrors verifies that various malformed inputs produce errors. +func TestBech32DecodeErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + { + name: "empty string", + input: "", + }, + { + name: "no separator", + input: "lnoabcdef", + }, + { + name: "separator only", + input: "1", + }, + { + name: "no data after separator", + input: "lno1", + }, + { + name: "invalid character", + input: "lno1b", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, err := Decode(tc.input) + require.Error(t, err) + }) + } +} + +// TestStripContinuation verifies the '+' stripping logic in isolation. +func TestStripContinuation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "no continuation", + input: "lno1acd", + want: "lno1acd", + }, + { + name: "simple continuation", + input: "lno1a+cd", + want: "lno1acd", + }, + { + name: "continuation with whitespace", + input: "lno1a+ cd", + want: "lno1acd", + }, + { + name: "continuation with newline", + input: "lno1a+\ncd", + want: "lno1acd", + }, + { + name: "continuation with crlf and space", + input: "lno1a+\r\n cd", + want: "lno1acd", + }, + { + name: "continuation with vertical tab", + input: "lno1a+\vcd", + want: "lno1acd", + }, + { + name: "continuation with form feed", + input: "lno1a+\fcd", + want: "lno1acd", + }, + { + name: "continuation with every ascii whitespace", + input: "lno1a+ \t\n\v\f\rcd", + want: "lno1acd", + }, + { + name: "trailing plus", + input: "lno1acd+", + wantErr: true, + }, + { + name: "trailing plus with space", + input: "lno1acd+ ", + wantErr: true, + }, + { + name: "leading plus", + input: "+lno1acd", + wantErr: true, + }, + { + name: "leading plus with whitespace", + input: "\n+lno1acd", + wantErr: true, + }, + { + name: "consecutive plus", + input: "lno1a++cd", + wantErr: true, + }, + { + name: "plus joined to plus by whitespace", + input: "lno1a+ +cd", + wantErr: true, + }, + { + name: "plus inside the prefix", + input: "ln+o1pqps7sjq", + want: "lno1pqps7sjq", + }, + { + name: "plus before the separator", + input: "lno+1pqps7sjq", + want: "lno1pqps7sjq", + }, + { + name: "plus after the separator", + input: "lno1+pqps7sjq", + want: "lno1pqps7sjq", + }, + { + name: "plus inside the prefix with whitespace", + input: "ln+\r\n o1pqps7sjq", + want: "lno1pqps7sjq", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := stripContinuation(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestDecodeContinuationAnywhere asserts that a marker at each interior +// position keeps the decoded data the same. The positions include the +// prefix and both sides of the '1' separator. The spec requires removal only +// between two bech32 characters. A writer, however, wraps a line where the +// medium makes it necessary, and the other implementations join anywhere. A +// marker must therefore never change the meaning of a string. +func TestDecodeContinuationAnywhere(t *testing.T) { + t.Parallel() + + payload := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} + encoded, err := Encode(HRPOffer, payload) + require.NoError(t, err) + + for i := 1; i < len(encoded); i++ { + split := encoded[:i] + "+" + encoded[i:] + + hrp, data, err := Decode(split) + require.NoError(t, err, "marker at position %d", i) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, payload, data) + } +} + +// TestEncodeUnknownHRP asserts that Encode takes only the prefixes in +// validHRPs, so a caller cannot make a string that Decode refuses. The message +// must also name each accepted prefix, because the message and the membership +// test read one list. +func TestEncodeUnknownHRP(t *testing.T) { + t.Parallel() + + _, err := Encode("bogus", []byte{0x00}) + require.ErrorIs(t, err, ErrUnsupportedHRP) + + for _, hrp := range validHRPs { + require.Contains(t, err.Error(), hrp) + } +} + +// TestDecodeUnknownHRP asserts that Decode rejects strings with unsupported +// HRPs. +func TestDecodeUnknownHRP(t *testing.T) { + t.Parallel() + + _, _, err := Decode("bogus1pqps7sjq") + require.ErrorIs(t, err, ErrUnsupportedHRP) +} + +// TestDecodeUnprintableCharacter asserts that Decode rejects characters outside +// printable ASCII range (33..126). +func TestDecodeUnprintableCharacter(t *testing.T) { + t.Parallel() + + // The last three characters are whitespace. A string can hold + // whitespace only after a '+' marker. In each other position it is a + // byte below the printable range. + unprintable := []string{ + "l\x1b[31mno1pqps7sjq", + "l\x00no1pqps7sjq", + "ln\no1pqps7sjq", + "ln\vo1pqps7sjq", + "ln\fo1pqps7sjq", + } + + for _, input := range unprintable { + _, _, err := Decode(input) + require.ErrorIs(t, err, ErrInvalidCharacter) + } +} + +// TestDecodeOversizeInput asserts the input length cap fires before any +// allocation. +func TestDecodeOversizeInput(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) +} + +// 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. +func TestDecodeWrappedMaxPayload(t *testing.T) { + t.Parallel() + + payload := make([]byte, maxBolt12DataLen) + encoded, err := Encode(HRPOffer, payload) + require.NoError(t, err) + require.Len(t, encoded, maxBolt12StringLen) + + // 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. + wrapped := encoded[:100] + "+ \n\t" + encoded[100:] + require.Greater(t, len(wrapped), maxBolt12StringLen) + + hrp, data, err := Decode(wrapped) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, payload, data) +} + +// TestHRPLenMatchesBudget asserts the fixed prefix cost that the character +// limit assumes. A prefix longer than bolt12HRPLen would let Encode make one +// more character than Decode accepts. The shared limit exists to prevent this +// difference. +func TestHRPLenMatchesBudget(t *testing.T) { + t.Parallel() + + for _, hrp := range validHRPs { + require.Len(t, hrp, bolt12HRPLen) + } +} + +// TestEncodePayloadSize asserts which payload sizes Encode takes and which it +// rejects. The rows walk the size axis from below the shortest legal payload to +// above the longest, and each accepted row decodes back to its input. The table +// therefore holds both ends of the size contract in one place. +func TestEncodePayloadSize(t *testing.T) { + t.Parallel() + + // maxOfferFields is the payload of an offer that holds a metadata + // field, a description field, and an issuer field, each at the largest + // record the decoder takes. + const maxOfferFields = 3 * (1 + 3 + math.MaxUint16) + + tests := []struct { + name string + payload []byte + wantErr error + + // wantLen, when set, is the exact length of the string that + // Encode must make. + wantLen int + }{ + { + name: "nil payload", + payload: nil, + wantErr: ErrEmptyString, + }, + { + name: "empty payload", + payload: []byte{}, + wantErr: ErrEmptyString, + }, + { + name: "one byte", + payload: make([]byte, 1), + }, + { + name: "three maximal offer fields", + payload: make([]byte, maxOfferFields), + }, + { + name: "longest payload", + payload: make([]byte, maxBolt12DataLen), + wantLen: maxBolt12StringLen, + }, + { + name: "one byte above the longest payload", + payload: make([]byte, maxBolt12DataLen+1), + wantErr: ErrStringTooLong, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + encoded, err := Encode(HRPOffer, tc.payload) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + return + } + + require.NoError(t, err) + if tc.wantLen != 0 { + require.Len(t, encoded, tc.wantLen) + } + + // Decode takes each string that Encode makes. + hrp, data, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, tc.payload, data) + }) + } +} + +// TestDecodeUppercase pins the spec MUST that readers handle both all-lowercase +// and all-uppercase strings: a payload encoded lowercase, then ToUpper'd in +// transit (e.g. QR code), must decode back to the same HRP and bytes. +func TestDecodeUppercase(t *testing.T) { + t.Parallel() + + payload := []byte{0x01, 0x23, 0x45, 0x67} + encoded, err := Encode(HRPOffer, payload) + require.NoError(t, err) + + uppered := strings.ToUpper(encoded) + require.NotEqual(t, encoded, uppered) + + hrp, data, err := Decode(uppered) + require.NoError(t, err) + require.Equal(t, HRPOffer, hrp) + require.Equal(t, payload, data) +} + +// TestPropertyBech32RoundTrip asserts Encode and Decode form a bijection for +// arbitrary data payloads under each of the three BOLT 12 HRPs. The codec's +// correctness depends on this property. A hand-rolled table can only hit a +// small number of payload sizes, while rapid drives shrinking generators across +// the whole input space and minimizes any counter-example it finds. +func TestPropertyBech32RoundTrip(t *testing.T) { + t.Parallel() + + hrps := []string{HRPOffer, HRPInvoiceRequest, HRPInvoice} + + rapid.Check(t, func(t *rapid.T) { + hrp := hrps[rapid.IntRange(0, len(hrps)-1).Draw(t, "hrp")] + // Draw the payload from the range that both ends of the + // codec accept, because the bijection holds in that range. + // The upper bound here stays far below the limit, so rapid + // works on the content of the payload and not on its + // length. + size := rapid.IntRange(1, 1024).Draw(t, "size") + data := rapid.SliceOfN( + rapid.Byte(), size, size, + ).Draw(t, "data") + + encoded, err := Encode(hrp, data) + require.NoError(t, err) + + decodedHRP, decodedData, err := Decode(encoded) + require.NoError(t, err) + require.Equal(t, hrp, decodedHRP) + require.Equal(t, data, decodedData) + }) +} diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go index 78bbdfdffd..f1291c3708 100644 --- a/bolt12/helpers_test.go +++ b/bolt12/helpers_test.go @@ -2,8 +2,14 @@ package bolt12 import ( "bytes" + "encoding/json" + "os" + "sync" + "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/stretchr/testify/require" ) // bobKey returns the deterministic spec test key for Bob, whose 32-byte scalar @@ -22,3 +28,90 @@ func aliceKey() (*btcec.PrivateKey, *btcec.PublicKey) { return priv, pub } + +// formatStringTestVector represents a single test case from the BOLT 12 +// format-string-test.json file. +type formatStringTestVector struct { + Comment string `json:"comment"` + Valid bool `json:"valid"` + String string `json:"string"` +} + +// loadFormatStringVectorsOnce parses format-string-test.json once. +var loadFormatStringVectorsOnce = sync.OnceValues( + func() ([]formatStringTestVector, error) { + data, err := os.ReadFile( + "test-vectors/format-string-test.json", + ) + if err != nil { + return nil, err + } + + var vectors []formatStringTestVector + if err := json.Unmarshal(data, &vectors); err != nil { + return nil, err + } + + return vectors, nil + }, +) + +// loadFormatStringVectors returns the parsed format-string-test.json vectors. +func loadFormatStringVectors(t *testing.T) []formatStringTestVector { + t.Helper() + + vectors, err := loadFormatStringVectorsOnce() + require.NoError(t, err) + + return vectors +} + +// farFutureNow returns a time well past every spec fixture's expiry, so +// structural validation runs without expiry interference. +func farFutureNow() time.Time { + return time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) +} + +// offersTestVector represents a single test case from offers-test.json. +type offersTestVector struct { + Description string `json:"description"` + Valid bool `json:"valid"` + Bolt12 string `json:"bolt12"` + Fields []offersTestField `json:"fields"` +} + +// offersTestField represents an expected TLV field in the test vector. +type offersTestField struct { + Type uint64 `json:"type"` + Length uint64 `json:"length"` + Hex string `json:"hex"` +} + +// loadOffersVectorsOnce parses test-vectors/offers-test.json once and memoizes +// the result for all callers. +var loadOffersVectorsOnce = sync.OnceValues( + func() ([]offersTestVector, error) { + data, err := os.ReadFile("test-vectors/offers-test.json") + if err != nil { + return nil, err + } + + var vectors []offersTestVector + if err := json.Unmarshal(data, &vectors); err != nil { + return nil, err + } + + return vectors, nil + }, +) + +// loadOffersVectors returns the parsed offers-test.json vectors, failing the +// test if the file is unreadable or malformed. +func loadOffersVectors(t *testing.T) []offersTestVector { + t.Helper() + + vectors, err := loadOffersVectorsOnce() + require.NoError(t, err) + + return vectors +} diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go index 71eac71465..2e642e0715 100644 --- a/bolt12/invoice_request_test.go +++ b/bolt12/invoice_request_test.go @@ -2,6 +2,7 @@ package bolt12 import ( "bytes" + "encoding/hex" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -169,3 +170,103 @@ func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) { } require.True(t, found, "unknown offer TLV not mirrored into request") } + +// TestDecodeInvoiceRequestBech32String decodes the invoice_request string and +// verifies key fields. This exercises the low-level Decode plus +// DecodeInvoiceRequest path. +func TestDecodeInvoiceRequestBech32String(t *testing.T) { + t.Parallel() + + // From upstream lightning/bolts signature-test.json: the + // invoice_request bolt12 string. + lnrStr := "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5d" + + "pjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jej" + + "es8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvj" + + "x204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfac" + + "z43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxz" + + "k95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu0" + + "4qz3slje2rfthc89vss" + + _, tlvBytes, err := Decode(lnrStr) + require.NoError(t, err) + + ir, err := DecodeInvoiceRequest(tlvBytes) + require.NoError(t, err) + + // Verify invreq_metadata is set (8 zero bytes). + var metadata []byte + ir.InvreqMetadata.WhenSome( + func(r tlv.RecordT[tlv.TlvType0, tlv.Blob]) { + metadata = r.Val + }, + ) + require.Equal(t, make([]byte, 8), metadata) + + // Verify offer_currency is "USD". + var currency []byte + ir.OfferCurrency.WhenSome( + func(r tlv.RecordT[tlv.TlvType6, tlv.Blob]) { + currency = r.Val + }, + ) + require.Equal(t, "USD", string(currency)) + + // Verify offer_amount is 100. + var amount TUint64 + ir.OfferAmount.WhenSome( + func(r tlv.RecordT[tlv.TlvType8, TUint64]) { + amount = r.Val + }, + ) + require.Equal(t, TUint64(100), amount) + + // Verify offer_description is "A Mathematical Treatise". + var desc []byte + ir.OfferDescription.WhenSome( + func(r tlv.RecordT[tlv.TlvType10, tlv.Blob]) { + desc = r.Val + }, + ) + require.Equal(t, "A Mathematical Treatise", string(desc)) + + // Verify invreq_payer_id is Bob's compressed pubkey (0x424242... + // privkey). + var payerIDSet bool + ir.InvreqPayerID.WhenSome( + func(r tlv.RecordT[tlv.TlvType88, *btcec.PublicKey]) { + payerIDSet = true + }, + ) + require.True(t, payerIDSet) + + // Verify signature is present. + var ( + sig [64]byte + sigSet bool + ) + ir.Signature.WhenSome( + func(r tlv.RecordT[tlv.TlvType240, [64]byte]) { + sig = r.Val + sigSet = true + }, + ) + require.True(t, sigSet) + + expectedSig := "b8f83ea3288cfd6ea510cdb481472575141e8d87" + + "44157f98562d162cc1c472526fdb24befefbdebab4dbb" + + "726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" + require.Equal(t, expectedSig, hex.EncodeToString(sig[:])) + + // Verify decode populated the canonical record set used by the Merkle + // tree, so every wire TLV must be reachable through AllRecords for + // signature verification to find them. + require.NotEmpty(t, ir.AllRecords()) + + // Re-encode must be byte-identical to the decoded wire bytes: the + // signature is over the Merkle root of this canonical encoding, so any + // reordering, dropped TLV, or non-canonical integer would invalidate + // it. + reencoded, err := ir.Encode() + require.NoError(t, err) + require.Equal(t, tlvBytes, reencoded) +} diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go index 2a9ae325bc..4bf65ec02f 100644 --- a/bolt12/offer_test.go +++ b/bolt12/offer_test.go @@ -1,8 +1,11 @@ package bolt12 import ( + "bytes" + "encoding/hex" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -47,3 +50,66 @@ func TestOfferRoundTrip(t *testing.T) { require.NoError(t, err) require.Equal(t, encoded, reencoded) } + +// TestDecodeOversizedRecord pins the per-record cap by feeding the decoder a +// TLV declaring a length one byte over tlv.MaxRecordSize. +func TestDecodeOversizedRecord(t *testing.T) { + t.Parallel() + + // Build a synthetic TLV with type=22 (offer_issuer_id, known by the + // offer decoder) and declared length one byte over the cap. The value + // bytes are present so the framing itself is consistent. + const oversize = tlv.MaxRecordSize + 1 + var ( + buf [8]byte + w bytes.Buffer + ) + require.NoError(t, tlv.WriteVarInt(&w, 22, &buf)) + require.NoError(t, tlv.WriteVarInt(&w, oversize, &buf)) + w.Write(make([]byte, oversize)) + + _, err := decodeOffer(w.Bytes()) + require.ErrorIs( + t, err, tlv.ErrRecordTooLarge, + "expected an oversize-record rejection, got %v", err, + ) +} + +// TestDecodeOfferString decodes a minimal offer string and verifies the +// issuer ID field is correctly parsed. +func TestDecodeOfferString(t *testing.T) { + t.Parallel() + + // Minimal offer: just offer_issuer_id (type 22). + offerStr := "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49p" + + "x20665dqwmn4p72pksese" + + _, tlvBytes, err := Decode(offerStr) + require.NoError(t, err) + + offer, err := decodeOffer(tlvBytes) + require.NoError(t, err) + + // Verify issuer ID is present and correctly typed. + var ( + issuerKey *btcec.PublicKey + set bool + ) + offer.OfferIssuerID.WhenSome( + func(r tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]) { + issuerKey = r.Val + set = true + }, + ) + require.True(t, set, "expected offer_issuer_id to be set") + + expectedHex := "02eec7245d6b7d2ccb30380bfbe2a3648cd7a94" + + "2653f5aa340edcea1f283686619" + require.Equal(t, expectedHex, + hex.EncodeToString(issuerKey.SerializeCompressed())) + + // Re-encode and verify bytes match. + reencoded, err := offer.Encode() + require.NoError(t, err) + require.Equal(t, tlvBytes, reencoded) +} diff --git a/bolt12/test-vectors/README.md b/bolt12/test-vectors/README.md new file mode 100644 index 0000000000..0659a3597b --- /dev/null +++ b/bolt12/test-vectors/README.md @@ -0,0 +1,7 @@ +# BOLT 12 Spec Test Vectors + +These test vectors are vendored from the upstream [lightning/bolts](https://github.com/lightning/bolts) specification repository. + +- **Source**: `bolt12/` directory in `lightning/bolts` +- **Upstream Commit**: `311119388a46dfa859da3d2eda0ca836cfc5f078` +- **License**: Creative Commons Attribution 4.0 International (CC-BY 4.0) diff --git a/bolt12/test-vectors/format-string-test.json b/bolt12/test-vectors/format-string-test.json new file mode 100644 index 0000000000..46e543b8b2 --- /dev/null +++ b/bolt12/test-vectors/format-string-test.json @@ -0,0 +1,62 @@ +[ + { + "comment": "A complete string is valid", + "valid": true, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Uppercase is valid", + "valid": true, + "string": "LNO1PQPS7SJQPGTYZM3QV4UXZMTSD3JJQER9WD3HY6TSW35K7MSJZFPY7NZ5YQCNYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD5XVXG" + }, + { + "comment": "+ can join anywhere", + "valid": true, + "string": "l+no1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Multiple + can join", + "valid": true, + "string": "lno1pqps7sjqpgt+yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+5k7msjzfpy7nz5yqcn+ygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+5xvxg" + }, + { + "comment": "+ can be followed by whitespace", + "valid": true, + "string": "lno1pqps7sjqpgt+ yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+ 5k7msjzfpy7nz5yqcn+\nygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+\r\n 5xvxg" + }, + { + "comment": "+ can be followed by whitespace, UPPERCASE", + "valid": true, + "string": "LNO1PQPS7SJQPGT+ YZM3QV4UXZMTSD3JJQER9WD3HY6TSW3+ 5K7MSJZFPY7NZ5YQCN+\nYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD+\r\n 5XVXG" + }, + { + "comment": "Mixed case is invalid", + "valid": false, + "string": "LnO1PqPs7sJqPgTyZm3qV4UxZmTsD3JjQeR9Wd3hY6TsW35k7mSjZfPy7nZ5YqCnYgRfDeJ82uM5Wf5k2uCkYyPwA3EyT44h6tXtXqUqH7Lz5dJgE4AfGfJn7k4rGrKuAg0jSd5xVxG" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+ " + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+ lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "ln++o1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + } +] diff --git a/bolt12/test-vectors/offers-test.json b/bolt12/test-vectors/offers-test.json new file mode 100644 index 0000000000..db6a108c31 --- /dev/null +++ b/bolt12/test-vectors/offers-test.json @@ -0,0 +1,652 @@ +[ + { + "description": "Minimal bolt12 offer", + "valid": true, + "bolt12": "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "fields": [ + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with description (but no amount)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "description is 'Test vectors'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for testnet", + "valid": true, + "bolt12": "lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is testnet", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin (redundant)", + "valid": true, + "bolt12": "lno1qgsxlc5vp2m0rvmjcxn2y34wv0m5lyc7sdj7zksgn35dvxgqqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is bitcoin", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin or liquidv1", + "valid": true, + "bolt12": "lno1qfqpge38tqmzyrdjj3x2qkdr5y80dlfw56ztq6yd9sme995g3gsxqqm0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq9qc4r9wd6zqan9vd6x7unnzcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "chains[0] is liquidv1, chains[1] is bitcoin", + "fields": [ + { + "type": 2, + "length": 64, + "hex": "1466275836220db2944ca059a3a10ef6fd2ea684b0688d2c379296888a2060036fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with metadata", + "valid": true, + "bolt12": "lno1qsgqqqqqqqqqqqqqqqqqqqqqqqqqqzsv23jhxapqwejkxar0wfe3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "metadata is 16 zero bytes", + "fields": [ + { + "type": 4, + "length": 16, + "hex": "00000000000000000000000000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with amount", + "valid": true, + "bolt12": "lno1pqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is 10000msat", + "fields": [ + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with currency", + "valid": true, + "bolt12": "lno1qcp4256ypqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is USD $100.00", + "fields": [ + { + "type": 6, + "length": 3, + "hex": "555344" + }, + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with expiry", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucwq3ay997czcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "expiry is 2035-01-01", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 14, + "length": 4, + "hex": "7a4297d8" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with issuer", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucjy358garswvaz7tmzdak8gvfj9ehhyeeqgf85c4p3xgsxjmnyw4ehgunfv4e3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "issuer is 'https://bolt12.org BOLT12 industries'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 18, + "length": 36, + "hex": "68747470733a2f2f626f6c7431322e6f726720424f4c54313220696e6475737472696573" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyz3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 5", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "05" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with unlimited (or unknown) quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qqtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "quantity_max is unknown/unlimited", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with single quantity (weird but valid)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyq3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 1", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "01" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with feature", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvp5yqqqqqqqqqqqqqqqqqqqqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "feature bit 99 set", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 12, + "length": 13, + "hex": "08000000000000000000000000" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zyg3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "same, with blinded path first_node_id using sciddir", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs3yqqqqqqqqqqqqp2qgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqgzyg3zyg3zyg3z93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "short_channel_id is 0x0x42, direction is 0", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 137, + "hex": "00000000000000002a0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with no issuer_id and blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + } + ] + }, + { + "description": "... and with second blinded path via 1x2x3 (direction 1), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsl5qj5qeyv5l2cs6y3qqzesrth7mlzrlp3xg7xhulusczm04x6g6nms9trspqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqsqqqqqqqqqqqqqqqqqqqqqqqqqqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqpqg3zyg3zyg3zygpqqqqzqqqqgqqxqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqqgqqqqqqqqqqqqqqqqqqqqqqqqqqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqqsg3zyg3zyg3zygtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x22*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 298, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202001000000000000000000000000000000000020202020202020202020202020202020202020202020202020202020202020202000811111111111111110100000100000200030202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200082222222222222222" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "unknown odd field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxfppf5x2mrvdamk7unvvs", + "field info": "type 33 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 33, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "unknown odd experimental field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx078wdv5gg2dpjkcmr0wahhymry", + "field info": "type 1000000033 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 1000000033, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "Malformed: fields out of order", + "valid": false, + "bolt12": "lno1zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszpgz5znzfgdzs" + }, + { + "description": "Malformed: unknown even TLV type 78", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpysgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Malformed: empty", + "valid": false, + "bolt12": "lno1" + }, + { + "description": "Malformed: truncated at type", + "valid": false, + "bolt12": "lno1pg" + }, + { + "description": "Malformed: truncated in length", + "valid": false, + "bolt12": "lno1pt7s" + }, + { + "description": "Malformed: truncated after length", + "valid": false, + "bolt12": "lno1pgpq" + }, + { + "description": "Malformed: truncated in description", + "valid": false, + "bolt12": "lno1pgpyz" + }, + { + "description": "Malformed: invalid offer_chains length", + "valid": false, + "bolt12": "lno1qgqszzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated currency UTF-8", + "valid": false, + "bolt12": "lno1qcqcqzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid currency UTF-8", + "valid": false, + "bolt12": "lno1qcplllhapqpq86q2q4qkc6trv5tzzq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9s" + }, + { + "description": "Malformed: truncated description UTF-8", + "valid": false, + "bolt12": "lno1pgqcq93pqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqy" + }, + { + "description": "Malformed: invalid description UTF-8", + "valid": false, + "bolt12": "lno1pgpgqsgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: truncated offer_paths", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqgpzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: zero num_hops in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated onionmsg_hop in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: bad first_node_id in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad path_key in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcpqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad blinded_node_id in onionmsg_hop", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yqvqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yq5qgytzzqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqg" + }, + { + "description": "Malformed: invalid offer_issuer_id", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps" + }, + { + "description": "Contains type >= 80", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains type > 1999999999", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06ae4jsq9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown even type (1000000002)", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06wu6egp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown feature 122", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvzqzqqqqqqqqqqqqqqqqqqqqqqqqpvggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs" + }, + { + "description": "Missing offer_description, but has offer_amount", + "valid": false, + "bolt12": "lno1pqpzwyqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Missing offer_amount with offer_currency", + "valid": false, + "bolt12": "lno1qcp4256ypgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Invalid: zero offer_amount", + "valid": false, + "bolt12": "lno1pqqq5qqkyyp4he0fg7pqje62jmnq78cr0ashv4q06qql58tyd9rhp3t2wuyugtq", + "field info": "offer_amount is 0", + "fields": [ + { + "type": 8, + "length": 0, + "hex": "" + }, + { + "type": 10, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c" + } + ] + }, + { + "description": "Invalid: zero offer_amount with currency", + "valid": false, + "bolt12": "lno1qcp4256ypqqq5qqkyyp4he0fg7pqje62jmnq78cr0ashv4q06qql58tyd9rhp3t2wuyugtq", + "field info": "offer_amount is 0, offer_currency is USD", + "fields": [ + { + "type": 6, + "length": 3, + "hex": "555344" + }, + { + "type": 8, + "length": 0, + "hex": "" + }, + { + "type": 10, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c" + } + ] + }, + { + "description": "Missing offer_issuer_id and no offer_path", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc" + }, + { + "description": "Second offer_path is empty", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsespjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygszqqqqyqqqqsqqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsq" + }, + { + "description": "offer_chains with zero entries", + "valid": false, + "bolt12": "lno1qgqpvggrt0j7j3uzp9n549hxpu0sxlmpwe2ql5qplgwkg628wrzk5acfcskq" + }, + { + "description": "Bech32 padding exceeds 4-bit limit", + "valid": false, + "bolt12": "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pkseseq" + } +] diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go index f0650e6d61..4fd32e0a5d 100644 --- a/bolt12/validate_test.go +++ b/bolt12/validate_test.go @@ -1,6 +1,8 @@ package bolt12 import ( + "bytes" + "encoding/hex" "math" "testing" "time" @@ -599,6 +601,19 @@ func TestValidateOfferRead(t *testing.T) { activeChain: bitcoinMainnetGenesisHash, wantErr: nil, }, + { + name: "unexpired offer (future expiry)", + mutate: func(o *Offer) { + expiry := uint64(now.Unix()) + 3600 + o.OfferAbsoluteExpiry = tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType14]( + TUint64(expiry), + ), + ) + }, + activeChain: bitcoinMainnetGenesisHash, + wantErr: nil, + }, { name: "symmetric explicit bitcoin chain list " + "(inverted-default invariant)", @@ -2774,3 +2789,186 @@ func TestValidateInvoiceErrorWrite(t *testing.T) { }) } } + +// TestValidateOfferReadVectors parses and evaluates all test vectors in +// offers-test.json to verify that every valid vector passes all decoding and +// validation stages and every invalid vector is rejected at some stage. +func TestValidateOfferReadVectors(t *testing.T) { + t.Parallel() + + vectors := loadOffersVectors(t) + + // Far-future time so expiry checks don't interfere with structural + // tests. + now := farFutureNow() + + for _, tc := range vectors { + t.Run(tc.Description, func(t *testing.T) { + t.Parallel() + + _, tlvBytes, bech32Err := Decode(tc.Bolt12) + if bech32Err != nil { + if tc.Valid { + require.NoError( + t, bech32Err, + "valid offer should pass "+ + "bech32 decode", + ) + } + + return + } + + offer, decodeErr := decodeOffer(tlvBytes) + if decodeErr != nil { + if tc.Valid { + require.NoError( + t, decodeErr, + "valid offer should pass "+ + "TLV decode", + ) + } + + return + } + + // If the offer specifies a chain, use that for + // validation, otherwise default to mainnet. This is + // necessary because some test vectors are for different + // chains. + activeChain := bitcoinMainnetGenesisHash + if c := getOfferChains(offer); len(c) > 0 { + activeChain = c[0] + } + + valErr := ValidateOfferRead( + offer, now, activeChain, nil, + ) + + if tc.Valid { + require.NoError( + t, valErr, + "valid offer should pass", + ) + + // Verify expected fields are present in decoded + // TLV map with matching length and hex + // encoding. + haveRecords := offer.AllRecords() + require.Equal( + t, len(tc.Fields), len(haveRecords), + "record count mismatch in valid offer", + ) + for _, expectedField := range tc.Fields { + rec, found := findRecord( + haveRecords, expectedField.Type, + ) + require.True( + t, found, + "field type %d missing in "+ + "valid offer", + expectedField.Type, + ) + + var buf bytes.Buffer + require.NoError(t, rec.Encode(&buf)) + gotValBytes := buf.Bytes() + + require.Equal( + t, expectedField.Length, + uint64(len(gotValBytes)), + "field type %d length mismatch", + expectedField.Type, + ) + require.Equal( + t, expectedField.Hex, + hex.EncodeToString(gotValBytes), + "field type %d hex mismatch", + expectedField.Type, + ) + } + + return + } + + require.Error( + t, valErr, + "invalid offer should fail validation: %s", + tc.Description, + ) + }) + } +} + +// TestOfferVectorsLayerCensus verifies that every invalid vector in +// offers-test.json is rejected at the expected layer, pinning the distribution +// of failure modes across bech32 decode, TLV decode, and semantic validation. +func TestOfferVectorsLayerCensus(t *testing.T) { + t.Parallel() + + vectors := loadOffersVectors(t) + now := farFutureNow() + + var ( + bech32Rejections int + tlvRejections int + valRejections int + falseAccepts int + ) + + for _, tc := range vectors { + if tc.Valid { + continue + } + + _, tlvBytes, bech32Err := Decode(tc.Bolt12) + if bech32Err != nil { + bech32Rejections++ + continue + } + + offer, decodeErr := decodeOffer(tlvBytes) + if decodeErr != nil { + tlvRejections++ + continue + } + + valErr := ValidateOfferRead( + offer, now, bitcoinMainnetGenesisHash, nil, + ) + if valErr != nil { + valRejections++ + continue + } + + t.Errorf( + "invalid vector falsely accepted: %s", + tc.Description, + ) + falseAccepts++ + } + + require.Equal( + t, 2, bech32Rejections, "bech32 rejections mismatch", + ) + require.Equal( + t, 16, tlvRejections, "TLV decode rejections mismatch", + ) + require.Equal( + t, 15, valRejections, "validation rejections mismatch", + ) + require.Equal( + t, 0, falseAccepts, "false accepts count mismatch", + ) +} + +// findRecord searches a slice of TLV records for a record with the given type. +func findRecord(records []tlv.Record, typ uint64) (*tlv.Record, bool) { + for i := range records { + if uint64(records[i].Type()) == typ { + return &records[i], true + } + } + + return nil, false +} diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 750a20069c..f10390991d 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -134,8 +134,16 @@ codec](https://github.com/lightningnetwork/lnd/pull/10958): add the `invoice_error` TLV message to `bolt12/` for onion-message replies. +* [BOLT 12 string codec](https://github.com/lightningnetwork/lnd/pull/11001): + add checksumless bech32 encoding/decoding for BOLT 12 `lno`, `lnr`, and `lni` + strings with continuation line handling. + ## Testing +* [BOLT 12 spec test vectors](https://github.com/lightningnetwork/lnd/pull/11001): + add spec test vectors for offer decoding and format string parsing in + `bolt12/test-vectors/`. + ## Database ## Code Health diff --git a/go.mod b/go.mod index 16e0922c6e..da81cc378c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/btcsuite/btcd v0.26.0 github.com/btcsuite/btcd/address/v2 v2.0.0 github.com/btcsuite/btcd/btcec/v2 v2.5.0 + github.com/btcsuite/btcd/btcutil v1.2.0 github.com/btcsuite/btcd/btcutil/v2 v2.0.0 github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 github.com/btcsuite/btcd/chainhash/v2 v2.0.0 diff --git a/go.sum b/go.sum index b0772e0a9c..de75029950 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/btcsuite/btcd/address/v2 v2.0.0 h1:UVu8Hal6Siu4XastFe+JX5JkeBYONbDUIY github.com/btcsuite/btcd/address/v2 v2.0.0/go.mod h1:htJK1AtaeK3bKNfZY63ep2oN8LbrI6qvmPGe1vekb3I= github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= +github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= +github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= github.com/btcsuite/btcd/btcutil/v2 v2.0.0 h1:77pgf/4tjWaSBLdos8yiWVWL3rSphxWNqkLwcyONExA= github.com/btcsuite/btcd/btcutil/v2 v2.0.0/go.mod h1:ZF8MMdsx1JGgvHJUanxbigekSO+8bN/ai34LBk/lg3c= github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 h1:M/RTtXfXA9odC1RUEOyZFXj/NXKVHPYZXVjb60xTOok=