Skip to content

bolt12: finalize the codec package - #11191

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

bitromortac wants to merge 16 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. Resolves the review feedback left open on the merged codec PRs, plus a scoping pass over the package.

Stacked on #11237, which carries the comment, test and refactor commits and is already approved.

Behaviour

  • The invoice reader rejects an unknown even TLV in the signature range, which the invoice_request reader already did.
  • Decoding no longer caps the input length; the encoders keep their own bound.
  • SignInvoiceRequest and SignInvoice validate before deriving the Merkle root, so a key cannot sign bytes a correct reader would reject.
  • follow spec stanzas in offer validators only reorders existing checks. No input changes between accepted and rejected, but an offer breaking two rules at once now reports a different sentinel.

Surface

115 exported symbols down to 100. Added OfferID, DecodeInvoiceStringUnvalidated, and EncodeSigned on InvoiceRequest and Invoice. EncodeSigned closes the gap @GeorgeTsagk found: every exported way out of the package now enforces the writer-side signature MUST, including the raw TLV form an onion message carries.

Removed the invoice_request string encoder, since nothing emits that form. Its decoder stays for an lnr1 string another implementation hands us.

Notes

The package docs land in one commit at the end, so they describe the final API rather than intermediate shapes.

Not in this PR

  • Control characters in UTF-8 fields wait for lightning/bolts#1341.
  • The payer's currency-conversion obligation, 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.

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

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.

Removed the entry rather than renumbering it, and applied no-changelog. Nothing here is reachable by an operator yet. Also fixed the 11146 bullet, which promised invoice_request string entry points that this PR removes.

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.

A small correction to my earlier reply: this PR keeps the invoice request string entry points after all. EncodeInvoiceRequestString and DecodeInvoiceRequestString stay exported for requests that answer no offer, and the 11146 bullet names invoice requests again.

Comment thread bolt12/invoice_request.go Outdated
// 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?

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.

You were right, and it was wider: the raw TLV path had no gate for either message. Added EncodeSigned on InvoiceRequest and Invoice, which requires the signature and verifies it, and signing now runs the write validator first.

Also fixed comments claiming a caller must encode before signing. Signing derives the Merkle root from AllRecords, so it never needs the encoded bytes.

Comment thread bolt12/offer_id.go Outdated
// 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.

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.

Weighed, and the docstring you read was wrong. CLN does not use the Merkle root: common/bolt12.c hashes the offer TLV spans exactly as we do. Verified against its doc/schemas/offer.json — we match c5cde029... byte for byte, while the Merkle root gives 00d1e836.... Switching would break the only agreement that exists, since LDK tags the root and eclair uses it bare.

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

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.

Correct here. It has four call sites on the integration branch, so it lands ahead of its consumers.

Comment thread bolt12/invoice_error.go Outdated
// 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: ....

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.

Done. The commit introducing that line moved to #11237, so the prefix is added here instead.

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

@bitromortac bitromortac Sep 24, 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.

Removed the entry, as you suggested.

@bitromortac

Copy link
Copy Markdown
Collaborator Author

Rebased on github.com//pull/11237, the first half of this PR is in there.

Comment thread bolt12/invoice_error.go Outdated
// against the type of the field erroneous_field names. Emitting a value the
// peer cannot decode is therefore possible, see the TODO in
// validateInvoiceErrorWrite.
func (ie *InvoiceError) encode() ([]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.

This un-export strands the outbound half of the message. The receiver flow emits an invoice_error as raw TLV when it rejects an invoice_request, and with encode internal there is no way to serialize one outside the package, while the fields stay exported so it can still be built. By this PR's own criterion that is something the onion caller needs. An exported encode here could also pick up the writer rule the NOTE above calls unchecked, the way EncodeSigned did for the signed messages. Or is the plan to add it together with its caller in the PayOffer PR?

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.

Good catch, thanks! InvoiceError keeps an exported Encode now, symmetric with DecodeInvoiceError, since an unsigned message has no signature check to skip. The suggested_value rule stays a TODO in validateInvoiceErrorWrite, because it needs the schema of the rejected message, so it fits better with the caller in the PayOffer PR.

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

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

The share-one-features-record change (F21) got lost in the split. It was in this PR before, 11237's description claims it moved there, but neither 11237 as merged nor this branch carries it. So encode still goes through lnwire's default features record and strictFeaturesEncoder in subtypes.go is dead code. The original issue stands: the guard that keeps the Merkle leaf bytes independent of lnwire staying minimal only holds on the decode side. Cherry-picking 56b8999 from the pre-split branch restores it.

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

Generally looking great, and I'm ready to ACK once my & @GeorgeTsagk latest feedback has been addressed 🔥.

Comment thread bolt12/validate.go
Comment on lines +1438 to 1441
// a missing-field violation. Symmetric with validateInvoiceRead.
if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 {
return ErrZeroInvoiceAmount
}

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.

Just pointing out that this check diverges from the spec, as the BOLT 12 spec only requires that the invoice_amount is present and represent the minimum accepted amount, it does not require it to be greater than zero. This is present for both read & writes.

This diverges from how this is handled in the other implementations as well, as they will accept offers with "zero" amounts.

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 don't see this as blocking though FYI.

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 that ErrZeroInvoiceAmount goes beyond the spec. The check predates this PR, and this PR does not touch it, so I would keep it for now and change it in a follow-up. Dropping it also needs a rule for the payer, because BOLT 2 requires an HTLC amount_msat greater than 0, so there is nothing valid to send for a zero minimum.

Comment thread bolt12/invoice_request.go
// reach a peer, so it is where the writer-side MUST is enforced. An invoice
// request travels as raw TLV inside an onion message, so that boundary is not
// the bech32 string form.
func (ir *InvoiceRequest) EncodeSigned() ([]byte, error) {

@ViktorT-11 ViktorT-11 Sep 29, 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.

Since this PR now unexports the "encode" function and only exports this new EncodeSigned function for invoice requests, we're effectively disabling this use case: https://github.com/lightning/bolts/blob/1aadb719b4007c4cea0ba6e36b08c4fb53788dee/12-offer-encoding.md?plain=1#L376

Such invoice requests which are not a response to an offer, should explicitly not include a signature:
https://github.com/lightning/bolts/blob/1aadb719b4007c4cea0ba6e36b08c4fb53788dee/12-offer-encoding.md?plain=1#L489

So I think we should still export an Encode function which doesn't sign the Invoice request?

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.

Thanks, good catch that this flow was blocked! I think the signature is not the obstacle, though. The reader rejects any invoice request without a correct signature using invreq_payer_id, and Core Lightning and Eclair also require one, so the payer signs an offer-less request too. What was missing is the transport: that request is published as an lnr1 string, and this PR had removed the encoder. EncodeInvoiceRequestString is back on top of EncodeSigned, and DecodeInvoiceRequestString is exported. ValidateInvoiceForPayment now accepts a nil expectedNodeID for an offer-less request, because the payer usually cannot confirm invoice_node_id out of band. TestOfferPaymentFlow and TestOfferlessPaymentFlow walk both flows end to end.

UsableFallbackAddresses is un-exported rather than deleted. Nothing
dispatches an on-chain fallback yet, so it has no caller. It stays because
it encodes the reader's MUST-ignore rules for fallback addresses, and its
tests exercise them.

Finding F22.
An invoice_request and an invoice reach a peer as raw TLV inside an onion
message, not as a bech32 string. Encode is permissive about the signature
by design, because a caller must encode before it can derive the Merkle
root it signs, so nothing enforced the writer-side MUST on the path the
bytes actually take. A caller that forgot to sign would find out only when
the remote peer rejected the message.

EncodeSigned is that entry point for both messages. It requires the
signature and verifies it against invreq_payer_id or invoice_node_id, and
EncodeInvoiceString now delegates to it, so the raw and string forms carry
one rule.
A signature over a message that breaks the writer requirements is
worthless, because the peer rejects the message on read. Running the write
validator before the Merkle root is derived keeps a key from signing bytes
no correct reader accepts, and it moves the check off the caller, which
previously had to encode once purely to trigger it.
EncodeSigned is the entry point for an invoice_request and an invoice that
leave the node, and the bech32 wrappers cover the string forms, so no
caller outside this package needs a serialisation that skips the signature
check. Un-exporting encode makes the writer-side requirement a property of
the API rather than a rule a caller has to remember.

Offer follows for consistency, since EncodeOfferString covers the only form
it travels in. InvoiceError keeps its exported Encode. It is unsigned, so
there is no check to skip, and raw TLV is the only form a receiver can send
it in.
A request that answers an offer reaches its peer as raw TLV inside an onion
message. A request that answers no offer is published by its payer as an
lnr1 string instead, such as a QR code, and the payee reads that string to
answer with an invoice, so the string encoder stays.

It now delegates to EncodeSigned, as EncodeInvoiceString does, so the
signature rule lives in one place. The three tests that pinned the
encoder's refusal to emit an invalid, unsigned or badly signed request move
to EncodeSigned accordingly.
For an invoice that answers a request with no offer, BOLT 12 lets the
payer reject it only if it cannot confirm invoice_node_id out of band. The
payer published that request and never addressed the payee, so it usually
has no key to compare against. ValidateInvoiceForPayment still required
one and failed on nil, which left a payer no honest argument to pass. Its
doc also named a node the payer sent to, which does not exist in this flow.

A nil expectedNodeID is now accepted for such a request. A payer that did
confirm a key passes it and gets the comparison, and a response to an offer
still requires one. TestValidateInvoiceNodeID covers all three cases.

Review: lightningnetwork#11191 (comment)
The strict features record only ever decodes. Encode goes through
lnwire's features record, so strictFeaturesEncoder never ran and
duplicated lnwire's encoding. The record now passes its encode side
through to lnwire's record, leaving one definition of the writer and the
minimality check where it matters, on decode.

That makes minimal output on encode an lnwire property we rely on, so a
test pins it: every features field of an offer, invoice request and
invoice is written minimally, and the strict decoder accepts the result.
@bitromortac

Copy link
Copy Markdown
Collaborator Author

@GeorgeTsagk thanks for tracking this down! You are right that the change got lost, and that strictFeaturesEncoder never ran. On a second look, the shared record adds no guard on encode, because the minimality check lives only in strictFeaturesDecoder. So I did not restore it. Instead, strictFeaturesEncoder is gone, and strictFeaturesRecord now passes its encode side through to lnwire's features record, so only one writer exists. TestEncodeWritesMinimalFeatures pins lnwire's minimal output for every features field of an offer, invoice request and invoice, and checks that the strict decoder accepts it. Happy to cherry-pick 56b8999 instead if you still prefer it.

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

LGTM! ☕

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

Great job, LGTM 🔥🎉!!

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

Labels

bolt12 no-changelog severity-high Requires knowledgeable engineer review

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

4 participants