Skip to content

bolt12: add Bech32 string codec and spec test vectors - #11001

Merged
ziggie1984 merged 5 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1e
Aug 18, 2026
Merged

bolt12: add Bech32 string codec and spec test vectors#11001
ziggie1984 merged 5 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1e

Conversation

@bitromortac

Copy link
Copy Markdown
Collaborator

Part of #10736.

Adds checksumless Bech32 string encoding and decoding (Encode, Decode) for BOLT 12 objects (lno, lnr, lni), including + continuation line stripping, case normalization, and input length bounding before allocation.

Additionally, this PR vendors upstream lightning/bolts test vectors (offers-test.json, format-string-test.json) to verify Bech32 parsing, TLV decoding, and semantic validation with exact field-level hex and length assertions across all test cases.

@saubyk saubyk added this to lnd v0.22 Jul 28, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in lnd v0.22 Jul 28, 2026
@saubyk saubyk moved this from Backlog to In progress in lnd v0.22 Jul 28, 2026
@saubyk saubyk added this to the v0.22.0 milestone Jul 28, 2026
@saubyk saubyk added the bolt12 label Jul 28, 2026
@bitromortac

Copy link
Copy Markdown
Collaborator Author

Ready for review @vctt94 @Abdulkbk (if you have time for it as always 🙏).

Comment thread bolt12/bech32.go
return "", nil, fmt.Errorf("bolt12: %w", ErrEmptyString)
}

// The characters must be either all lowercase or all uppercase.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Since && short-circuits, we could avoid allocating the uppercase copy unless it's actually needed by doing something like:

lower := strings.ToLower(cleaned)
if cleaned != lower && cleaned != strings.ToUpper(cleaned) {
	return "", nil, fmt.Errorf("bolt12: %w", ErrMixedCase)
}
cleaned = lower

This preserves the existing behavior while avoiding the extra ToUpper allocation when the input is already lowercase.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, strings.ToUpper now sits inside the condition.

Comment thread bolt12/bech32.go Outdated
hrp := cleaned[:one]
if _, ok := validHRPs[hrp]; !ok {
return "", nil, fmt.Errorf(
"bolt12: %w %q (want lno/lnr/lni)",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The accepted HRPs are already defined in validHRPs, but the error message duplicates them as string literals. It might be worth deriving the list from validHRPs so there's a single source of truth if the accepted HRPs ever change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have changed it such that we reuse the definition.

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really awesome progress on this 🔥🎉!

Just posting some initial findings when crosschecking with the spec & the other Lightning implementations 🚀.

Comment thread bolt12/bech32.go Outdated
// ~210,000 characters. A cap of 300,000 accommodates all valid payloads
// while preventing hostile inputs from forcing excessive allocations
// during decoding.
maxBolt12StringLen = 300_000

@ViktorT-11 ViktorT-11 Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we should include this cap, and if we do, I think we may need to raise the cap. BOLT12 doesn't specify any strict cap that should be enforced, and I double checked (I checked through LLM) in Eclair, Core Lightning & LDK, and I think none of those repos include such a cap.

Additionally, if these fields are set to the max:

offer_metadata: 65,535 bytes
offer_description: 65,535 valid UTF-8 bytes
offer_issuer: 65,535 valid UTF-8 bytes

The Encode function will accept that request and produce a string longer than 300k characters, while the Decode function will error with ErrStringTooLong.

So if we keep it, we should at least cap both Encode & Decode at the same length.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I wanted to be conservative here, but we can discuss if it makes sense to remove the limit. I have increased the limit and added some explanation to the constant's docstring.

Comment thread bolt12/bech32.go Outdated
Comment on lines +202 to +220
if i == 0 || !isBech32Char(s[i-1]) {
return "", fmt.Errorf(
"bolt12: %w: '+' must follow a bech32 "+
"character",
ErrInvalidContinuation,
)
}

// Skip '+' and any following whitespace.
j := i + 1
for j < len(s) && isWhitespace(s[j]) {
j++
}
if j >= len(s) || !isBech32Char(s[j]) {
return "", fmt.Errorf(
"bolt12: %w: '+' must precede a bech32 "+
"character", ErrInvalidContinuation,
)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This specifically ties the left & right neighbouring characters after the + char to be a "Bech32Char". I don't think that should be required for the lno1 prefix part of the of the string.

The test vectors do define that using the + char in the prefix is allowed:
https://github.com/lightning/bolts/blob/311119388a46dfa859da3d2eda0ca836cfc5f078/bolt12/format-string-test.json#L13-L15

That testcase inserts the + char at:
l+no1....

However if the + char was instead inserted at:
ln+o1....

Our implementation would now fail as the o char is not a "Bech32Char".

I crosschecked with Eclair, Core Lightning, and LDK, and they'd all allow the + char to be inserted anywhere in the prefix, so I therefore think that's the correct implementation.

If you think it makes sense, I can open a PR to add a testcase to the spec repo's test vectors, which inserts the + char at ln+o1...., just to get consensus on that this should be a valid case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, looking at the test vectors, it says '+ can join anywhere', so changed it to not taking the spec literally.

Comment thread bolt12/bech32_test.go
Comment on lines +173 to +182
{
name: "plus right neighbour not bech32",
input: "ln+o1pqps7sjq",
wantErr: true,
},
{
name: "plus left and right neighbour not bech32",
input: "lno+1pqps7sjq",
wantErr: true,
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

following up on #11001 (comment):

I.e. I don't think these test cases should error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, have updated the tests.

@vctt94 vctt94 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome progress on this 🔥!

I reviewed the PR locally, all five commits passed the focused bolt12 tests independently, and the overall structure looks solid.

I left a few comments around keeping the Encode/Decode contract symmetric, along with a small release-note link issue.

While reviewing the new lni path, I also reproduced the unknown-even signature-range behavior previously discussed in #10941. I’m not duplicating the finding here, but the new string decoding path makes the existing asymmetry directly observable.

Thanks again for pushing this forward 🚀

Comment thread bolt12/bech32.go
Comment thread docs/release-notes/release-notes-0.22.0.md Outdated
Comment thread docs/release-notes/release-notes-0.22.0.md Outdated
Comment thread bolt12/bech32.go Outdated
@github-actions github-actions Bot added the severity-medium Focused review required label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

🟡 PR Severity: MEDIUM

gh pr view | 10 files | 1958 lines changed (320 lines / 1 file excluding tests & docs)

🟡 Medium (1 file)
  • bolt12/bech32.go - new Go source file implementing Bech32 encode/decode logic; not in any specifically categorized package, falls under "other Go files"
🟢 Low (9 files)
  • bolt12/bech32_test.go - test file
  • bolt12/helpers_test.go - test file
  • bolt12/invoice_request_test.go - test file
  • bolt12/offer_test.go - test file
  • bolt12/validate_test.go - test file
  • bolt12/test-vectors/README.md - docs
  • bolt12/test-vectors/format-string-test.json - test fixture data
  • docs/release-notes/release-notes-0.22.0.md - release notes

Analysis

This PR adds a new bolt12/bech32.go file implementing checksumless Bech32 encoding/decoding for BOLT 12 objects, plus a large body of test-only additions (unit tests and vendored upstream spec test vectors) and a release-notes update. The bolt12 package isn't one of the explicitly listed CRITICAL or HIGH packages, so the single non-test Go file (320 lines) is classified as MEDIUM ("other Go files not categorized above"). Excluding tests, docs, and test-vector fixtures, the change is well under the file-count (>20) and line-count (>500) bump thresholds, and it doesn't touch multiple critical packages, so no severity bump applies. Overall this looks like a focused, self-contained codec addition with strong test coverage against upstream spec vectors.


To override, add a severity-override-{critical,high,medium,low} label.

@bitromortac
bitromortac force-pushed the 2604-bolt12-1e branch 2 times, most recently from ba47762 to 70cd09c Compare August 6, 2026 09:49
@bitromortac

bitromortac commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks all for your reviews 🙏

Rebased on master and fixed a go mod issue.

@vctt94 vctt94 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Great job 🚀

@ziggie1984 ziggie1984 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM nice and clean

Comment thread bolt12/bech32.go Outdated
// 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) > maxBolt12StringLen {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice-to-have / non-blocking: this limit is checked before stripContinuation, so continuation formatting counts toward the payload-derived string limit. At the boundary, Encode can produce an unwrapped string of exactly maxBolt12StringLen, while inserting a legal + (and optional whitespace) makes Decode return ErrStringTooLong even though the cleaned string and decoded payload are unchanged. This only affects unusually large strings near the 1 MiB limit, so I do not consider it blocking. It may still be worth applying the canonical/payload limit after normalization, keeping a separate raw transport limit if desired, and adding a wrapped-max-payload test.

Comment thread bolt12/bech32.go Outdated
Comment on lines +288 to +297
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 %s",
ErrInvalidCharacter, s[i], i, s,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could we avoid including the full data string in this error since it can be about 1.68 MB and may expose invoice contents if logged?

@TechLateef TechLateef left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

The bolt12 package can already encode and decode the TLV layer but has
no way to carry an offer as a human-transportable string, which is the
form the spec specifies for QR codes, URLs and email signatures. BOLT
12's envelope is subtractive relative to BIP-173: there is no BCH
checksum, because the BIP-340 signature over the Merkle root already
secures the payload, and a '+' continuation marker may split the string
across lines. btcutil/bech32's public API always wraps the checksum, so
the alphabet layer is duplicated here rather than reused. Enforce a
whitelist of BOLT 12 prefixes (lno, lnr, lni) on both Encode and Decode.
Vendor the BOLT 12 offers-test.json fixtures so the offer decoder and
validator are checked against the specification's own strings rather
than hand-authored ones, which cannot drift from the spec without
someone noticing. Invalid vectors are tested to verify they are rejected
at some layer, and an aggregate stage census pins the distribution
across bech32 decode, TLV decode, and validation.
The invoice_request codec has round-trip coverage against locally-
constructed messages only, so a canonical-encoding bug would go
unnoticed until a real peer rejected a signature. Drive the decoder from
the spec's signature-test invoice_request and assert that re-encoding is
byte-identical to the wire bytes, because the signature commits to the
Merkle root of that exact encoding.
Add changes for the bech32 work in bolt12.

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for the updates. LGTM 🔥!

Adding a few non-blocking comments below, where I think the comment regarding the offer length cap for decoding is important feedback.

Comment thread bolt12/bech32.go
Comment on lines +264 to +273
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,
)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if: "bolt12: %w: '+' must precede a non-whitespace character" is the correct error message to use here, as you're actually skipping the whitespaces after the "+" char above.

Comment thread bolt12/bech32.go

// maxBolt12RawStringLen is the largest raw BOLT 12 string the codec
// accepts, continuation markers and whitespace included.
maxBolt12RawStringLen = 2 * maxBolt12StringLen

Copy link
Copy Markdown
Collaborator

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 lnd to 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 lnd implementations, despite them perhaps being fully payable by lnd.

One such use case would for example be a scheme that specifies:

  • IF you can read and interpret the "undefined unknown odd fields", then pay via a today undefined payment method.

OR

  • IF NOT, then proceed with the payment as per the BOLT12 specification today.

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.

Copy link
Copy Markdown
Contributor

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.

@bitromortac bitromortac Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Line length

Comment thread bolt12/validate_test.go
t.Run(tc.Description, func(t *testing.T) {
t.Parallel()

_, tlvBytes, bech32Err := Decode(tc.Bolt12)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think potentially there would be value in expanding this test to call the bech32 Encode function with the contents of this, and then also call offer.Encode below, just to include test coverage of those functions with the contents of the test vectors.

@GeorgeTsagk GeorgeTsagk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@ziggie1984
ziggie1984 merged commit ae9e86a into lightningnetwork:master Aug 18, 2026
54 of 56 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in lnd v0.22 Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bolt12 severity-medium Focused review required

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants