What changed, and why it matters
This commit adds a new encoder/decoder that turns BOLT 12 Lightning payment data (offers, invoice requests, invoices) into human-readable strings like 'lno1...' and back again. It is a feature addition, not a fix for a known vulnerability. The code includes careful input checks for length, character set, case rules, allowed prefixes, and line-continuation markers, plus extensive tests. There is no indication in the commit that this resolves a security incident or was reported by an outside researcher.
Review as a normal feature addition. No immediate security patch action is indicated. Continue monitoring for later commits that wire this codec into P2P/onion-message paths, where input validation and signature verification will become critical.
Security signals we found
New codec validates HRP against whitelist (lno/lnr/lni) on both encode and decode
Length limits applied before allocation to avoid unbounded input processing
Continuation marker parsing rejects leading, trailing, adjacent, and whitespace-only markers
Character set restricted to printable ASCII and valid bech32 alphabet
Case normalization enforced (all lower or all upper, no mixed case)
No checksum verification because BOLT 12 relies on signature over Merkle root
Extensive test coverage including spec vectors and property-based round-trip tests
Evidence from the diff
Commit 81d31862 introduces bolt12/bech32.go implementing BOLT 12’s human-readable string format (HRPs lno/lnr/lni) without a BCH checksum, relying instead on the BIP-340 signature over the Merkle root. It reuses btcd’s bech32.ConvertBits for base conversion but reimplements the alphabet layer because BOLT 12’s envelope omits the checksum and supports ‘+’ continuation markers with following whitespace. Decode enforces printable ASCII, all-lower/all-upper case, a valid separator, a whitelist of HRPs, and length caps (raw and cleaned). Encode enforces the same HRP whitelist, non-empty payload, and max payload size. Tests include spec vectors, round-trips, error cases, property-based checks, and a real offer string decode. No security bug fix, CVE, or vendor security disclosure is present in the materials.
Changed components
bolt12/bech32.gobolt12/bech32_test.gobolt12/helpers_test.gobolt12/offer_test.gobolt12/test-vectors/format-string-test.jsonbolt12/test-vectors/README.mdgo.modgo.sumInspect captured patch +972 / −0
### 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
+}
### 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)
+ })
+}
### bolt12/helpers_test.go
@@ -2,8 +2,13 @@ package bolt12
import (
"bytes"
+ "encoding/json"
+ "os"
+ "sync"
+ "testing"
"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 +27,40 @@ 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
+}
### bolt12/offer_test.go
@@ -2,8 +2,10 @@ package bolt12
import (
"bytes"
+ "encoding/hex"
"testing"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
)
@@ -72,3 +74,42 @@ func TestDecodeOversizedRecord(t *testing.T) {
"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)
+}
### 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)
### 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"
+ }
+]
### 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
### 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=Why this scored 23/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.