Skip to content

bolt12: finalize the codec package - #11191

Open
bitromortac wants to merge 29 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1h
Open

bitromortac wants to merge 29 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1h

Conversation

@bitromortac

@bitromortac bitromortac commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

bolt12: finalize the codec

Closes the Cleanups item of the codec milestone in the BOLT 12 epic. Based on #11146, so the diff is roughly half the size. It resolves the review feedback left open on the merged codec PRs, plus findings from a scoping pass over the package. No new protocol surface: the message types were already complete.

Behaviour

  • The invoice reader rejects an unknown even TLV in the signature range, which the invoice_request reader already did and CLN does. The spec exempts that range from the out-of-range rule only.
  • Decode no longer caps input length.
  • OfferID derives an offer identity from an offer, an invoice request or an invoice.

Surface: 15 symbols un-exported, 3 added, 119 to 107 exported. What stays is what a caller reaching the codec over an onion message needs.

Commits (Links to original comments in commit messages)

  • adddd6d reject unknown even types in sig range - vctt94, +1 ziggie1984 (F8)
  • b131156 reject unknown even types on offer write - ziggie1984 (F5)
  • 56b8999 share one features record in the codec - scoping pass (F21)
  • f7b9e9b leave decode length limits to the caller - ViktorT-11, seconded by vctt94 (F41)
  • 668a8a7 add OfferID over the offer TLV ranges - ziggie1984 (F40)
  • e2683db stop modelling an invalid invoice_error - ziggie1984 (F18)
  • 17a6bd7 follow spec stanzas in offer validators - bitromortac (F38)
  • 0f40729 round-trip an offer with every field - ziggie1984 (F31, F2)
  • 223c8fb round-trip a full invoice request - ziggie1984 (F31)
  • abab544 round-trip an invoice with every field - ziggie1984 (F31)
  • e57c4d9 cover the amount overflow guard - ziggie1984 (F9)
  • 67a49ad assert sentinels in subtype decoders - ziggie1984 (F10)
  • 4197e8b re-encode offer spec vectors on write - ViktorT-11 (F45)
  • 7f53882 align the invreq feature rule comment - ziggie1984 (F11)
  • 290f806 describe node id binding for the payer - ziggie1984 (F15)
  • 043b53c describe the lnwire dependency - scoping pass (F23)
  • 555824b fix the continuation error and README - ViktorT-11 (F43, F44)
  • ed5f7cb one name for the known feature bits - bitromortac, ziggie1984 (F39, F12)
  • 684d5f9 add an unvalidated invoice string decode - scoping pass (F22)
  • c5a9dfe un-export the bech32 primitives - scoping pass (F22)
  • 6ea998b un-export the verifiers and validators - scoping pass (F22)
  • f2841c4 un-export the symbols with no caller - scoping pass (F22)
  • c251a8a drop the offer vector layer census
  • d243339 correct the payment validator docstring
  • 8436a80 docs: note the BOLT 12 codec finalization

Not in this PR

  • Control characters in UTF-8 fields wait for lightning/bolts#1341. No implementation rejects them at parse time today.
  • The currency-conversion obligation on the payer, and sanitizing payer notes at the RPC boundary, belong to the PayOffer and RPC milestones.
  • A spec issue on the invoice_request signature asymmetry is still to be filed, as promised in 10832.

Bundle the payer-side invoice checks into one call so the sender flow
cannot forget any of them: the reader gates, the expiry gate, the
byte-for-byte mirror match against the originating request, and the
binding of invoice_node_id to the node the payer expected to answer.
Decode/EncodeOfferString, Decode/EncodeInvoiceRequestString, and
Decode/EncodeInvoiceString compose bech32, the per-message TLV codec,
and the previously-landed validators into a single reader/writer API
that consumers should use. Reader gates run against Bolt12Features, the
package's known feature-bit set, defined in this commit. The string
codecs that emit a wire-form message also gate Signature validity, since
pre-sign Encode (used to compute the Merkle root) is permitted on
(*X).Encode but not at the bech32 boundary.
The bolt12 codec parses bytes that arrive untrusted inside onion
messages, so every decoder gets a property-based check that no input
pattern drives a panic or breaks the encode/decode bijection. A
further harness asserts merkleRoot is a pure function of its record
set, since non-determinism there would silently break signature
reproducibility. Wiring bolt12 into FUZZPKG makes 'make fuzz'
exercise the package alongside the existing entries.
Add changes for the string-codec wrappers, the combined payment
validator, and the fuzz harnesses in bolt12.
The comment standing where the writer-side feature check used to be argued its
case in broken prose and used the word the reviewer found misleading. The file
already has a form for a rule the codec cannot enforce, so use it and name the
thing plainly: the bits are the caller's own and the reader checks them
against the known bits it is given.

Finding F11.
lightningnetwork#10941 (comment)
Two comments on the payer-side checks called the key "the final blinded_node_id
on the arrival path". Arrival is the receiver's view, which is how the spec
states the writer rule, and these run on the payer, where the same key is the
one it sent the request to. The phrase also collides with the separate
reply-path arrival rule. The writer comment keeps its wording, which is correct
there.

Finding F15.
lightningnetwork#10941 (comment)
The package doc claimed no LND dependencies while importing lnwire for the
pure-TLV framing, the blinded-path types and the feature vector. Reusing those
is deliberate, since it keeps one on-wire definition per structure, so say that
instead of denying it. What the package really avoids is reaching into the
daemon: the chain, the clock and the known feature bits all come from the
caller.

Finding F23.
The continuation error said a '+' must precede a non-whitespace character,
while the code skips whitespace after the marker and then requires a
neighbour. The rule it enforces is that a marker joins two characters, so say
that. The README line ran to 129 columns.

Findings F43 and F44.
lightningnetwork#11001 (comment)
lightningnetwork#11001 (comment)
The invoice path called a map of known feature bits a catalogue, a word that
appears nowhere else in lnd. The other two readers in the same file already
call it knownFeatures, and lnwire calls the same parameter featureNames, so
the invoice path was the outlier. The type becomes InvoiceKnownFeatures,
checkFeatures names its parameter like its callers do, and the word is gone
from the docstrings and the test name.

The struct itself stays: named fields are what stop a caller passing the
blinded-path bits where the invoice bits belong.

Findings F39 and F12.
lightningnetwork#10941 (comment)
The census asserted how many invalid vectors fail at each of the three layers,
counts that break on any re-vendoring of the spec vectors while proving
nothing the table above it does not: that test already requires every invalid
vector to be rejected and every valid one to pass.
The invoice reader skipped the must-understand check for types 240 to 1000,
so an invoice carrying unknown even type 242 was accepted while the same type
on an invoice_request was rejected. The spec exempts that range from the
out-of-range rule only, and BOLT 1 still makes an unknown even type fatal.
Core Lightning grants the range no exception either, so this was a divergence
rather than a choice.

Finding F8.
lightningnetwork#10941 (comment)
The offer writer checked the allowed range but not the must-understand rule,
while the offer reader checks both. The state is only reachable for an offer
decoded and then mutated, since the typed field set cannot express an unknown
type, but the asymmetry made a reader of the code work out why one of the two
rules was missing.

Finding F5.
lightningnetwork#10789 (comment)
The strict features record was built only on the decode paths, so the guard
that rejects a non-minimal feature vector ran on one side only. That guard
exists to keep the Merkle leaf bytes stable, so it has to hold in both
directions rather than depend on lnwire's encoder staying minimal. Encode now
goes through the same record. Output is unchanged, the spec signature vectors
still pass untouched, and the encoder is no longer dead code.

Finding F21.
Decode capped the raw and the cleaned string length, which no other
implementation does and which the spec does not ask for. The cap protected
nothing: a decode allocates on the order of its input, each record is already
bounded by tlv.MaxRecordSize and every subtype decoder bounds itself against
the bytes present, and the input exists in the caller's memory before Decode
runs. What it could do is reject a spec-valid message once unknown odd fields
push a string past a limit chosen today.

Encode keeps its payload bound, since there we choose what to emit, and the
constant now says it is a writer policy. The caller bounds its own medium: the
onion-message envelope for an invoice_request and an invoice, the RPC or CLI
for a pasted or scanned offer string.

Finding F41.
lightningnetwork#11001 (comment)
The receiver identifies the offer an invoice request answers by hashing the
request's offer fields and looking the result up in its store, so that lookup
is the exact-match check the reader requirements ask for. The rule belongs in
the codec, which owns the encoding and the range predicate, rather than in
each caller.

The hash covers the offer ranges of any pure-TLV message, so one function
serves the offer, the invoice_request and the invoice. For an offer it equals
the hash of the whole encoding, since every offer TLV already sits in those
ranges.

Finding F40.
lightningnetwork#10941 (comment)
The round-trip fixture paired erroneous_field 82, invreq_amount, with a
suggested_value of 00 01 86 a0. The leading zero makes that a non-minimal tu64,
so a peer decoding it as the field's own type fails and loses the correction the
message exists to carry. The fixture now uses the canonical encoding.

The writer cannot catch this yet, and the note claiming the schema is caller
context overstated the obstacle: the package defines every field number and
type for both messages, it just has no field-number to type table. That is now
a TODO, and Encode's contract says the value is unchecked.

Finding F18.
lightningnetwork#10958 (comment)
The invoice_request and invoice validators quote each spec bullet above the
check it authorises and run in the spec's order, so they can be read beside
the spec block. The two offer validators, which landed before that convention,
had none: 29 and 37 stanza lines now, against zero before.

The writer moves two checks to reach spec order, the chains rule ahead of the
amount rules and the nil-key guard down to the issuer stanza it belongs to.
Every check is otherwise the same one, and no test expectation changed, so no
input flipped to a different first error. Both functions now also state the
rules the codec cannot enforce and why, which is how the gaps stay visible.

Finding F38.
The round trip covered four of eleven fields and asserted byte identity only.
Byte identity cannot see a field that is wired into the encode path but not
into the decode path: such a field survives as an unknown TLV and re-encodes
cleanly while its typed value disappears. Comparing the decoded struct against
the fixture catches it, verified by dropping offer_quantity_max from the decode
stream. The fixture also carries an unknown odd TLV in the offer range, so the
offer keeps the byte-exact preservation its siblings already pin.

Findings F31 and F2.
lightningnetwork#10832 (comment)
lightningnetwork#10789 (comment)
The round trip covered four of twenty-one fields and asserted byte identity
only, which cannot see a field wired into the encode path but not into the
decode path. Comparing the decoded struct against the fixture catches it,
verified by dropping invreq_quantity from the decode stream. The request is
also signed rather than carrying a placeholder, so the fixture is one a reader
would accept.

Finding F31.
lightningnetwork#10832 (comment)
The round trip populated six of thirty fields despite its name, and asserted
byte identity only, which cannot see a field wired into the encode path but
not into the decode path. Comparing the decoded struct against the fixture
catches it, verified by dropping invoice_relative_expiry from the decode
stream. The invoice is signed and read-validated too, so the fixture is one a
payer would accept.

Finding F31.
lightningnetwork#10832 (comment)
The request side had a test for the offer_amount times quantity overflow, the
invoice side did not, and it was the only uncovered branch in the invoice
amount check. Verified by neutering the guard, which makes the new case accept
an invoice_amount of one against an authorized amount that wrapped to zero.

Finding F9.
lightningnetwork#10941 (comment)
Both decode tables matched error substrings where the decoder returns a
sentinel, so a reworded message would keep passing a test that no longer
proves anything. Four cases now use require.ErrorIs, and the rest stay on
substrings because they assert ad-hoc messages with no sentinel behind them.
The mixed idiom is the one TestDecodeChainsRecord already uses in this file.

Finding F10.
lightningnetwork#10941 (comment)
The vector table decoded each vector and compared every record against the
expected hex, but never fed one back out. So the message-level Encode and the
bech32 writer were only ever tested against fixtures we wrote ourselves. Each
valid vector now has to reproduce its own string, which passes for all of them
today and fails if the writer gains an extra character.

Finding F45.
lightningnetwork#11001 (comment)
DecodeInvoiceString folds the reader gates into the decode, so a caller
that only wants to display an invoice it already validated when it stored
it has no entry point. Such a caller had to reach for the raw bech32
decode and parse the bytes itself, which accepts any of the three
prefixes.

DecodeInvoiceStringUnvalidated pins the lni prefix and skips the gates.
It is the one legitimate raw-decode caller, so the bech32 primitives can
leave the API next.

DecodeInvoiceString now delegates to it and adds the gates on top, so the
prefix check and the TLV decode have one home rather than two copies.

Finding F22.
Decode and Encode become decodeBech32 and encodeBech32. The bare names
read like the message codec they sit next to, and no caller outside the
package needs them: the string entry points fold bech32 into a validated
call, and a caller that wants a raw decode has
DecodeInvoiceStringUnvalidated.

The rename is mechanical. Four comments in bech32.go named the old
symbols, so they name the new ones now. The encoder docstring also stops
claiming its payload bound mirrors a reader limit, because the reader no
longer has one.

Finding F22.
The verifiers go, because the readers call them and nothing verifies a
signature on its own. The writer validators go, because Encode is the
gate and already runs them. The reader validators that an exported entry
point folds in go too: DecodeOfferString for the offer, and
ValidateInvoiceForPayment for the invoice against its request.

What stays exported is what a caller that reaches the codec over an onion
message needs, because an invoice_request and an invoice arrive as raw
TLV rather than as strings.

Finding F22.
Three symbols are un-exported rather than deleted: the two
invoice_request string wrappers and UsableFallbackAddresses. Nothing
transports that message as a string today, and nothing dispatches an
on-chain fallback yet, so none of the three has a caller.

They stay because the wrappers carry the lnr prefix the spec defines, the
helper encodes the reader's MUST-ignore rules for fallback addresses, and
their tests exercise both.

Finding F22.
Covers the changes a consumer can observe: the invoice reader tightening, the
decode length limits moving to the caller, the new OfferID operation and the
narrowed export surface.

The earlier 11146 entry drops invoice requests from its list of validated
string entry points. The narrowing leaves those two wrappers internal, so the
claim no longer holds for the shipped API.
@github-actions github-actions Bot added the severity-high Requires knowledgeable engineer review label Sep 11, 2026
@github-actions

Copy link
Copy Markdown

🟠 PR Severity: HIGH

bolt12 bech32/offer/invoice parsing library | 13 non-test/doc files | 826 lines changed (excl. tests)

🟡 Medium (10 files)
  • bolt12/bech32.go - other Go file, not otherwise categorized
  • bolt12/doc.go - package documentation
  • bolt12/invoice.go - BOLT12 invoice encoding/decoding
  • bolt12/invoice_error.go - BOLT12 invoice error types
  • bolt12/invoice_request.go - BOLT12 invoice request encoding/decoding
  • bolt12/offer.go - BOLT12 offer encoding/decoding
  • bolt12/offer_id.go - new BOLT12 offer ID type
  • bolt12/signature.go - BOLT12 message signing/verification
  • bolt12/subtypes.go - BOLT12 TLV subtype definitions
  • bolt12/validate.go - BOLT12 message validation logic (largest diff, 420 lines)
🟢 Low (16 files)
  • bolt12/*_test.go (12 files, including new fuzz_test.go) - test-only changes
  • bolt12/test-vectors/README.md - documentation
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • make/fuzz_flags.mk - build config

Analysis

All changed source files live under bolt12/, a standalone BOLT12 offers/invoices encoding and validation library. None of the files match an explicitly listed critical or high-severity package (e.g. lnwire, channeldb, contractcourt), so individually they classify as MEDIUM ("other Go files not categorized above"). However, the non-test/non-doc diff totals 826 lines changed (validate.go alone changed 420 lines), which exceeds the 500-line bump threshold, so the severity is raised one level to HIGH. The bulk of the change is in validate.go, which governs BOLT12 message validation logic — worth a careful review given that a bug there could cause invoices or offers to pass or fail validation incorrectly.


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

@saubyk saubyk added this to lnd v0.22 Sep 11, 2026
@saubyk saubyk moved this to In progress in lnd v0.22 Sep 11, 2026
@saubyk saubyk added this to the v0.22.0 milestone Sep 11, 2026
@saubyk saubyk added the bolt12 label Sep 11, 2026
`ValidateInvoiceForPayment` to bundle the payer-side invoice checks into one
call.

* [BOLT 12 codec

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 bullet links to 11146 but the finalization is this PR. That is also why the release notes check fails, it greps for a link to 11191.

Comment thread bolt12/invoice_request.go
// transmission, so a populated signature is required and verified against
// invreq_payer_id. Writer-side validation is delegated to
// (*InvoiceRequest).Encode.
func encodeInvoiceRequestString(ir *InvoiceRequest) (string, error) {

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.

With this un-exported, nothing exported enforces the writer side signature MUST for the invoice_request wire form. Encode stays pre-sign permissive by design and the onion path emits raw TLV, so a wiring bug that forgets to sign only surfaces as a rejection at the remote peer. Same applies to an invoice emitted as raw TLV. Is the PayOffer milestone meant to own that, or should the codec keep one signed-encode entry point for the raw TLV forms, the way EncodeInvoiceString does for strings?

Comment thread bolt12/offer_id.go
// in the offer range changes the id. That is what makes a store lookup by id
// the exact-match check the reader requirements ask for.
//
// The id is a local store key, not an interop value: BOLT 12 defines no offer

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.

Was the Merkle root considered here? merkleRoot is already in the package and other implementations derive their offer id from it, so the same ids would agree with CLN and LDK. Once the id leaks into an RPC or a DB export the divergence is baked in. If SHA256 is deliberate that is fine, the doc already flags it, just checking it was weighed.

@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, this PR looks quite ready to be merged IMO, once the either mine or @GeorgeTsagk release note comment have been addressed.

In my opinion though, we could make this PR easier to review if we:

  1. Broke comment & test changes only commits into a separate PR. That'd make that PR easy to review and ready to be merged very quickly.
  2. Then also have the implementation logic changing commits such as for example 17a6bd7 or f7b9e9b into another separate PR. That'd make those commits quicker & easier to review.

Feel free to ignore the PR structure feedback though if you disagree.

Comment thread bolt12/offer_id.go
// The id is a local store key, not an interop value: BOLT 12 defines no offer
// identifier, and other implementations derive theirs from the offer's Merkle
// root, so the two disagree for the same offer.
func OfferID(m lnwire.PureTLVMessage) ([32]byte, error) {

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: Just noting that this is not used anywhere outside of tests, but probably to be expected at this stage.

Comment thread bolt12/invoice_error.go
// messages such as invoices, where unknown TLVs must be preserved to keep
// signatures valid).
//
// One writer rule stays unchecked: a suggested_value is not verified against

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.

Maybe add a NOTE: prefix for this comment, i.e.:
// NOTE: One writer rule stays unchecked: ....

`ValidateInvoiceForPayment` to bundle the payer-side invoice checks into one
call.

* [BOLT 12 codec

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: IMO, having a title called "BOLT 12 codec finalization" in the release notes is too implementation flow/PR specific, and for users which reads this it doesn't really make sense as they haven't been following the PR process.

IMO, if you're going to have release notes for this PR, break out the points that this specific PR fixed into their own bullet points. To be honest though, I'd also be supportive of just removing the release notes all together for this specific "cleanup" PR, as it doesn't add any meaningful functionality to v0.22.0 that wasn't already introduced by previous PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bolt12 severity-high Requires knowledgeable engineer review

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

4 participants