-
Notifications
You must be signed in to change notification settings - Fork 2.3k
bolt12: add Bech32 string codec and spec test vectors #11001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7415b08
81d3186
0c8592a
fa39cd7
c73b0d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Since lower := strings.ToLower(cleaned)
if cleaned != lower && cleaned != strings.ToUpper(cleaned) {
return "", nil, fmt.Errorf("bolt12: %w", ErrMixedCase)
}
cleaned = lowerThis preserves the existing behavior while avoiding the extra
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice, |
||
| 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) { | ||
|
bitromortac marked this conversation as resolved.
|
||
| 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, | ||
| ) | ||
| } | ||
|
Comment on lines
+264
to
+273
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if: |
||
|
|
||
| // 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still not fully convinced that it's a great idea for only
lndto include this cap (at least for Decoding), and IMO we should increase this cap quite a bit if we do.The risk I see with including it, is that there'll for some reason in the future be use cases where undefined unknown odd fields become used which pushes the length over our current limit. If that becomes the case, they'll become incompatible with old
lndimplementations, despite them perhaps being fully payable bylnd.One such use case would for example be a scheme that specifies:
OR
I.e. an idea similar to what "BIP 21/321" offers for on-chain & lightning payments, but using BOLT12 offers with lightning + a currently unknown payment method.
Ultimately, I don't see this issue as fully blocking, but I think it's worth discussing among the reviewers if this is something we actually want to include or not.
Personally, I'd be in favour of removing this cap or at least increase the cap quite a bit just to minimize the above ever becoming an issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with Viktor here. My main concern is that this turns an lnd resource limit into a restriction of the generic BOLT12 decoder.
Since unknown odd fields are explicitly meant to allow forward-compatible extensions, we could eventually reject otherwise valid/payable objects just because they exceed a limit chosen today.
I'd prefer Decode to enforce BOLT12 encoding rules and leave resource limits to the caller/transport.
Not blocking from my side either, but I'd lean toward removing the decoding cap.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll research the tradeoffs here and will get back to it 🙏. Will address this in a follow-up.