Skip to content

bolt12+lnwire: add codec foundation with Offer message#10789

Merged
ziggie1984 merged 8 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1a
Jun 16, 2026
Merged

bolt12+lnwire: add codec foundation with Offer message#10789
ziggie1984 merged 8 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1a

Conversation

@bitromortac

@bitromortac bitromortac commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Part of the first milestone in #10736 (total WIP of the first milestone can be found here).

This PR adds a new bolt12/ package skeleton, its first message struct (Offer), and the lnwire work needed to host it. Includes TLV subtypes as well as reader/write message validation.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label May 5, 2026
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

Automated classification | 9 files (non-test) | 1,335 lines changed

🔴 Critical (2 files)
  • lnwire/custom_records.go - Lightning wire protocol messages (lnwire/*)
  • lnwire/pure_tlv.go - Lightning wire protocol messages (lnwire/*)
🟡 Medium (7 files)
  • bolt12/decode.go - New BOLT12 package (uncategorized Go files → medium)
  • bolt12/doc.go - New BOLT12 package documentation
  • bolt12/offer.go - New BOLT12 offer types
  • bolt12/pure_tlv.go - New BOLT12 TLV utilities
  • bolt12/subtypes.go - New BOLT12 subtypes
  • bolt12/tlv_types.go - New BOLT12 TLV type definitions
  • bolt12/validate.go - New BOLT12 validation logic

Analysis

This PR introduces a new bolt12 package implementing BOLT12 (Offers) functionality, alongside modifications to two files in the lnwire package. The changes to lnwire/custom_records.go and lnwire/pure_tlv.go drive this PR to CRITICAL severity, as lnwire/* covers Lightning wire protocol messages and requires expert review.

The new bolt12 package itself does not fall into any predefined critical or high category and classifies as medium. However, the highest severity file governs, making this PR overall CRITICAL.

Additionally, with ~1,335 non-test lines changed (exceeding the 500-line bump threshold), a severity bump is triggered — though the PR is already at the maximum level of CRITICAL.


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

Automated classification | 9 files (non-test) | 1,335 lines changed

🔴 Critical (2 files)
  • lnwire/custom_records.go - Lightning wire protocol messages (lnwire/*)
  • lnwire/pure_tlv.go - Lightning wire protocol messages (lnwire/*)
🟡 Medium (7 files)
  • bolt12/decode.go - New BOLT12 package (uncategorized Go files → medium)
  • bolt12/doc.go - New BOLT12 package documentation
  • bolt12/offer.go - New BOLT12 offer types
  • bolt12/pure_tlv.go - New BOLT12 TLV utilities
  • bolt12/subtypes.go - New BOLT12 subtypes
  • bolt12/tlv_types.go - New BOLT12 TLV type definitions
  • bolt12/validate.go - New BOLT12 validation logic

Analysis

This PR introduces a new bolt12 package implementing BOLT12 (Offers) functionality, alongside modifications to two files in the lnwire package. The changes to lnwire/custom_records.go and lnwire/pure_tlv.go drive this PR to CRITICAL severity, as lnwire/* covers Lightning wire protocol messages and requires expert review.

The new bolt12 package does not fall into any predefined critical or high category and classifies as medium. However, the highest severity file governs, making this PR overall CRITICAL.

Additionally, with ~1,335 non-test lines changed (exceeding the 500-line bump threshold), a severity bump is triggered — though the PR is already at the maximum level of CRITICAL.


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes the foundational codec infrastructure for BOLT 12 support in LND. It introduces a dedicated bolt12 package to manage the serialization and validation of offer messages, while extending lnwire with necessary utilities to support the required TLV-based message structures.

Highlights

  • BOLT 12 Foundation: Introduced the bolt12 package skeleton to manage the encoding, decoding, and validation of BOLT 12 messages.
  • Offer Message Implementation: Implemented the Offer struct, supporting various optional TLV fields and providing a clean interface for message serialization.
  • lnwire Extensions: Added helper functions to lnwire to support PureTLVMessage handling and improved management of optional records.
  • Validation Logic: Included comprehensive validation rules to ensure BOLT 12 messages comply with spec-defined writer and reader requirements.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request implements the core BOLT 12 offer codec, including encoding, decoding, and validation logic. It introduces the Offer message type, support for blinded paths, and chain-specific validation. Additionally, it extends the lnwire package with generic TLV helpers and predicate-driven serialization to accommodate BOLT 12's specific signed-range requirements. Feedback includes a critical fix for a dangling pointer in the AddOpt helper and a recommendation to use unsigned comparisons for expiry timestamps to avoid potential overflow issues.

Comment thread lnwire/custom_records.go
Comment thread bolt12/validate.go Outdated
@saubyk saubyk added the bolt12 label May 6, 2026
@saubyk saubyk added this to lnd v0.22 May 6, 2026
@saubyk saubyk moved this to In progress in lnd v0.22 May 6, 2026
@bitromortac bitromortac marked this pull request as ready for review May 11, 2026 13:52

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

Had a first pass and left one question. Would take a deeper look.

Comment thread bolt12/validate.go

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

Still going through the diff. A few things worth tightening already flagged inline. Requesting changes for now so we can iterate on those while I finish the rest. Thanks!

Comment thread bolt12/offer.go Outdated
Comment thread bolt12/offer.go
Comment thread lnwire/intro_node.go
Comment on lines +89 to +96
func (p PubkeyIntro) validate() error {
switch p.Pubkey[0] {
case 0x02, 0x03:
return nil
}

return fmt.Errorf("%w: 0x%02x", ErrInvalidIntroNode, p.Pubkey[0])
}

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.

We could use the btcec.ParsePubKey to validate if the point is really in the curve.

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.

Yes, good point. I'm using *btcec.PublicKey for pubkey-based fields now. That way we get the validation when during decode. Currently this checks only for non-nilness, will probably also add the on-curve check here in the next round.

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 added the checks that keys are on curve.

Comment thread lnwire/blinded_path.go Outdated
Comment thread lnwire/blinded_path.go Outdated
Comment thread lnwire/blinded_path.go
Comment thread bolt12/offer_test.go
Comment thread rpcserver.go
EncryptedData: hop.CipherText,
}
bp.BlindedHops = append(bp.BlindedHops, rpcHop)
bp.IntroductionNode = oMsg.ReplyPath.IntroductionNode.Bytes()

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.

bp.IntroductionNode used to be a fixed 33-byte pubkey; now it's 33 bytes for PubkeyIntro or 9 bytes for SciddirIntro with no discriminator, so any client doing btcec.ParsePubKey(bp.IntroductionNode) (as the proto comment promises) breaks the first time a sciddir reply path arrives.

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.

That's a good point. I'd need to think more about how that's used on the client side. I'd tend towards updating the proto description and later add a one_of for the two introduction node types?

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.

To not increase the scope of this PR I added a TODO(bolt12) (will use this to keep track of lightweight things we need to fix) to SubscribeOnionMessages. We need to decide if we want to convert to a pubkey (which adds a db roundtrip) or if we just update the proto description that the field can contain both data types (together with adding a convenience one_of).

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.

Leaning more toward oneoff because, aside from the lookup cost, we'd also need a fallback when we fail to resolve the scid. Updating the proto description and maybe a follow-up adding the one_off is the cleanest path imo.

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.

Yes, I agree with that. I'll follow up with an RPC update 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.

I have experimented with this a bit more and if we add a one_of to lightning.proto:BlindedPath we would leak protocol internals to the RPCs. With this we would have to make other endpoints aware of the scid intro node, which doesn't make too much sense as it's just a pointer to the node id. For the RPCs where it's cheap (like DecodeOffer) we can do the graph lookup and skip over paths that don't resolve (with logging). Instead I'd update the SubscribeOnionMessages proto description that we may pass out scid-based intro nodes verbatim. If needed we can later add a bool resolveScidIntros or similar to that RPC. That way the behavior is constrained to a single RPC, which is anyhow more of a debugging kind.

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.

can you make sure you do not forget it during the BOLT12 saga

Comment thread bolt12/validate.go
Comment thread lnwire/blinded_path.go
Comment thread lnwire/onion_msg_payload.go
Comment thread bolt12/offer.go Outdated
// valid offer must run ValidateOfferRead. Unknown TLVs are preserved on the
// returned offer so a later Encode can re-emit signed-range extras and keep
// offer_id stable.
func DecodeOffer(data []byte) (*Offer, 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.

Q: Should we set a maximum ceiling size for offers to avoid decoding huge offers?

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 added a check to DecodeOfferString (included in future PR), to have this function be decode only without validation.

Comment thread bolt12/validate.go
Comment thread bolt12/validate.go

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

Another pass, mostly nits and a few suggestions.

Comment thread bolt12/subtypes.go
Comment thread bolt12/subtypes.go Outdated
Comment thread bolt12/validate.go Outdated
Comment thread rpcserver.go
EncryptedData: hop.CipherText,
}
bp.BlindedHops = append(bp.BlindedHops, rpcHop)
bp.IntroductionNode = oMsg.ReplyPath.IntroductionNode.Bytes()

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.

Leaning more toward oneoff because, aside from the lookup cost, we'd also need a fallback when we fail to resolve the scid. Updating the proto description and maybe a follow-up adding the one_off is the cleanest path imo.

Comment thread bolt12/pure_tlv.go Outdated

@erickcestari erickcestari 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 work!

LGTM! 🫴

@github-actions github-actions Bot added severity-critical Requires expert review - security/consensus critical and removed severity-critical Requires expert review - security/consensus critical labels May 20, 2026
@bitromortac bitromortac requested a review from Abdulkbk May 20, 2026 12:14

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

Overall this looks like a solid start for the BOLT 12 offer codec. The offer TLV mapping matches the spec fields, tu64 values use truncated encoding, unknown signed-range TLVs are preserved for re-encoding, and pubkeys are parsed with btcec.ParsePubKey rather than only length/prefix checked.

I left a few inline comments around onion message TLV handling and smaller validation/API edges. I also added a regression test for the unknown-even final-hop TLV case here: lnwire/onion_msg_payload_test.go.

)
}

if _, ok := tlvMap[InvoiceRequestNamespaceType]; ok {

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.

BOLT 4 says the final node must ignore an onion message containing more than one payload field.

This appends every recognized final-hop payload field that appears, and the test suite currently round-trips invoice_request, invoice, and invoice_error together.

Should decode reject/ignore when more than one final-hop payload namespace is present?

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.

Good catch! We should do that!

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, yes, will do that in the next PR (out of scope for this one).

}

// Add the payload to our message's final hop payloads.
payload := &FinalHopTLV{

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.

Does this need to reject unknown even final-hop TLVs?

Type 70, for example, is >=64 so it reaches this append path, but it is also even and not one of the known payload namespaces.

BOLT 1 treats unknown even TLVs as must-understand, and BOLT 4 says an onion message containing unknown even types MUST be ignored.

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.

👍 Will do that in the next PR, since it deals with onion messages.


// Skip any tlvs that have been recognized in our decoding (a
// zero entry means that we recognized the entry).
if len(tlvBytes) == 0 {

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.

Should this check tlvBytes == nil instead? TypeMap uses nil to mark known/parsed TLVs, while an unknown odd zero-length TLV is valid but would be dropped here.

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 call, will bundle a fix in the next PR.

Comment thread bolt12/validate.go
}

// Without offer_paths, MUST set offer_issuer_id.
if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() {

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.

Can we also reject a present-but-nil OfferIssuerID here? IsSome() satisfies this check, but encode later calls SerializeCompressed on the *btcec.PublicKey, so a nil key would panic instead of returning a validation 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.

Yes, makes sense, fixed.

Comment thread lnwire/intro_node.go
}

// Bytes returns the wire-format encoding of the pubkey variant.
func (p PubkeyIntro) Bytes() []byte {

@vctt94 vctt94 May 22, 2026

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.

Should Bytes return an error instead of silently dropping encode failures?

For example, PubkeyIntro{} currently returns an empty slice here, which can hide an invalid introduction node from RPC/logging callers. Since serialization can fail, Bytes() ([]byte, error) seems safer.

If changing the signature is too invasive for this PR, maybe this should at least be guarded at construction/validation time or documented clearly so callers do not treat an empty slice as a valid encoding.

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 fixed this by adding a constructor, which isn't bullet-proof as one can use the zero-value initializer, but have also added docstrings to use the constructor. I think this is nicer to not have to check the error at the call sites.

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

Almost there 👏, strong PR.

I had a couple of minor coments which should be addressed.

Comment thread lnwire/intro_node.go
Comment thread lnwire/intro_node.go
Comment thread bolt12/doc.go
@@ -0,0 +1,19 @@
// Package bolt12 implements encoding, decoding, and validation for BOLT 12

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.

normally we don't do somthing like this.

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.

Agree that we don't have the pattern, but I think it's a nice golang feature. And why not start it if we create a standalone package that could be useful for outside consumers?

Comment thread bolt12/validate.go
Comment thread docs/release-notes/release-notes-0.22.0.md
Comment thread bolt12/tlv_types.go
Comment thread bolt12/validate.go
var (
// ErrOutOfRangeType is returned when a TLV type falls outside the
// allowed offer ranges (1-79 and 1000000000-1999999999).
ErrOutOfRangeType = errors.New("TLV type outside allowed range")

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.

does an offer have a signed range which needs to be skipped ?

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.

Offers aren't signed in BOLT 12 (no signature TLV), so there's no signed range to skip here. The signed-range handling is relevant to invoice_request and invoice. An offer is validated purely against its allowed type ranges.

Comment thread bolt12/validate.go
}

// Check blinded paths have at least one hop.
if err := checkBlindedPaths(o.OfferPaths); err != nil {

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.

does this allow paths with just the introduction point ?

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.

It doesn't, the current spec forbids blinded paths with empty hops.

Comment thread bolt12/validate.go Outdated
Comment on lines +177 to +180
// Expiry check. A present-but-zero offer_absolute_expiry historically
// meant "no expiry" but that conflicts with the spec (zero is a valid
// past timestamp); treat it as already expired rather than ambiguous so
// a misuse fails closed.

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.

is this according to the spec or do we need to update the spec to not allow zero expiry ?

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.

Based on the writer rules, I understand that 0 should be considered expired.

https://github.com/lightning/bolts/blob/master/12-offer-encoding.md?plain=1#L260-L263

By the way, this might mean that, if we want an offer that doesn't expire, we should add a very large value to offer_absolute_expiry instead of 0.

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.

Yes, one needs to set an explicit timestamp according to the spec and it's interpreted as a real unix timestamp. I changed the inline comment to be more explicit.

Comment thread bolt12/validate_test.go
@@ -0,0 +1,402 @@
package bolt12

import (

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.

Non-Blocking test gaps:

  1. TLV type boundary cases — cheap to write, catches a real bug class (off-by-one in offerAllowedRange/invreqAllowedRange).
  2. UTF-8 invalid for offer_description and offer_issuer — uncovered code paths today.
  3. offer_quantity_max = 0 (unlimited) valid case — pins the three-state semantic.
  4. now == expiry rejected — boundary off-by-one.
  5. Symmetric explicit-bitcoin chain test — pins the inverted-default invariant.
  6. Multi-error determinism — pins the sortedTypes contract.

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.

Added those tests, thanks!

Add UnsignedRangeFunc and the SerialiseFieldsToSignFn /
ExtraSignedFieldsFromTypeMapFn variants so callers with non-BOLT 7 v2
signed ranges (e.g. BOLT 12, which reserves only 240-1000) can plug in
their own predicate. The existing SerialiseFieldsToSign and
ExtraSignedFieldsFromTypeMap entry points keep their behaviour by
delegating to the Fn variants with InUnsignedRange.
This gives us easier optional tlv field handling, which we will use for
the following message definitions.
Introduce the canonical lnwire.BlindedPath / BlindedPaths codec with a
sealed IntroductionNode sum-type covering both the BOLT 4 pubkey and
sciddir variants. The codec gates every variable-length subfield against
an io.LimitedReader. It fails closed on the encoder side so invalid
input never hits the wire.

This commit is a pure addition: no existing caller changes. Subsequent
commits migrate OnionMessagePayload and the bolt12 message structs to
consume the new codec.
Switch OnionMessagePayload.ReplyPath from *sphinx.BlindedPath to
*lnwire.BlindedPath. The reply-path TLV is now produced and consumed by
(*lnwire.BlindedPath).Record(), which honours the BOLT 4 sciddir_or_pubkey
introduction-node form. The legacy decoder gated on a 67-byte minimum
length and silently rejected reply paths whose introduction node used
the 9-byte sciddir variant.

The legacy replyPathRecord / replyPathSize / encodeReplyPath /
decodeReplyPath / blindedHopSize / encodeBlindedHop / decodeBlindedHop
helpers and the unused ErrNoHops sentinel are deleted.

Consumers update mechanically: routing/route's
OnionMessageBlindedPathToSphinxPath replyPath parameter, the
onionmessage.OnionMessageUpdate field, the rpcserver onion-message
subscription bridge, and the lnwire test utilities now use the lnwire
type directly. The new TestOnionMessagePayloadRoundTrip "sciddir intro
reply path" subtest pins the BOLT 4 spec fix.
Introduce the ChainsRecord subtype used by the offer_chains and
invoice_chains TLV fields. Decoding caps the count at maxOfferChains to
bound allocation.
The Offer struct models a long-lived, reusable BOLT 12 payment template.
It defines TLV fields as optional records and exposes Encode/DecodeOffer
for round-trip serialization. The struct implements
lnwire.PureTLVMessage; AllRecords filters the decoded TypeMap through
bolt12InUnsignedRange to derive any signed-range extras the encoder must
re-emit, keeping offer_id and the Merkle root stable across encoders
that understand a wider set of even/odd extensions.
ValidateOfferRead and ValidateOfferWrite enforce the codec-side portion
of the BOLT 12 offer reader and writer requirements. Reader rules cover
TLV range, even-feature-bit rejection, chain mismatch, dependency rules
between offer_amount/description/currency, missing issuer identity,
zero-hop blinded paths, and offer expiry. Writer rules mirror the same
dependency and identity guards plus a defense-in-depth empty-
offer_chains rejection.

offer_currency is validated against the ISO 4217 registry via
golang.org/x/text/currency (now a direct dependency); offer_issuer_id is
verified to be an on-curve SEC1 compressed point on both read and write
paths. Encode invokes Validate so invalid bytes never reach the wire.
@bitromortac bitromortac requested review from vctt94 and ziggie1984 June 3, 2026 10:13
@github-actions github-actions Bot added severity-critical Requires expert review - security/consensus critical and removed severity-critical Requires expert review - security/consensus critical labels Jun 3, 2026

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

Good job on this PR

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

Comment thread lnwire/intro_node.go
// PubkeyIntro is the 33-byte compressed-pubkey variant. The SEC1 parity byte
// (0x02 or 0x03) doubles as the wire discriminator. Use the constructor to
// ensure the non-nil, on-curve invariant is upheld.
type PubkeyIntro struct {

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.

Since PubkeyIntro and SciddirIntro all have exported fields (Pubkey, Direction, SCID), a caller can write PubkeyIntro{Pubkey: nil} directly and bypass the validator, after which Bytes() silently returns empty bytes, exactly the failure mode the constructor added was to prevent.

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.

see this #10789 (comment) for explanation

Comment thread lnwire/intro_node.go
var buf bytes.Buffer
buf.Grow(pubKeyLen)

// We ignore errors because we have validated that the pubkey is non nil

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.

micro nit: non-nil

@lightningnetwork lightningnetwork deleted a comment from Tboy1989 Jun 10, 2026

@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

Comment thread lnwire/blinded_path.go
// blindedPathSize returns the on-wire size of a single BlindedPath.
func blindedPathSize(p *BlindedPath) uint64 {
var introLen uint64
if p.IntroductionNode != nil {

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: Somehow it feels wrong validating in the size path, because a blindedPath without a intropoint is not a valid blinded route and should not happen. So maybe we assume that it is correct here ?

Comment thread bolt12/offer_test.go

"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
)

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.

What do you think adding this test-case:

bolt12.Offer should decode and re-encode unknown odd TLVs in the allowed offer ranges byte-for-byte.

Comment thread bolt12/validate.go
Comment on lines +131 to +141
var chainsEmpty bool
o.OfferChains.WhenSome(
func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) {
if len(r.Val.Chains) == 0 {
chainsEmpty = true
}
},
)
if chainsEmpty {
return ErrEmptyChains
}

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.

Suggested change
var chainsEmpty bool
o.OfferChains.WhenSome(
func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) {
if len(r.Val.Chains) == 0 {
chainsEmpty = true
}
},
)
if chainsEmpty {
return ErrEmptyChains
}
if err := fn.MapOptionZ(
o.OfferChains.ValOpt(),
func(r ChainsRecord) error {
if len(r.Chains) == 0 {
return ErrEmptyChains
}
return nil
},
); err != nil {
return err
}

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: normally we mostly use MapOptionZ for this stuff.

Comment thread bolt12/validate.go
if !o.OfferIssuerID.IsSome() && !o.OfferPaths.IsSome() {
return ErrNoIssuerIdentity
}

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 decoded offer_issuer_id has already passed the TLV point decoder, but
  // ValidateOfferRead may also be called on manually constructed offers. Reject a
  // present-but-nil key here so the reader-side identity check cannot be satisfied
  // by a value that would panic or fail later when used as a public key.
  if err := checkIssuerID(o.OfferIssuerID); err != nil {
  	return err
  }

Comment thread bolt12/validate.go
// getOfferChains returns the chains an offer is valid for. If offer_chains is
// absent, the spec defaults to Bitcoin mainnet.
func getOfferChains(o *Offer) [][32]byte {
chains := fn.MapOptionZ(

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.

hmm here you use the MapOptionZ ?

Comment thread bolt12/validate.go
// field set already excludes out-of-range types by construction, so a
// freshly-built offer cannot violate the range rule in the first place.
for _, t := range sortedTypes(o.decodedTLVs) {
if !offerAllowedRange(t) {

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.

check:

 if !isKnownOfferTLVType(t) && t%2 == 0 {
     	return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t)
     }

as well here ?

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

Labels

bolt12 severity-critical Requires expert review - security/consensus critical

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants