Skip to content

bolt12: add Invoice codec and structural validators#10941

Open
bitromortac wants to merge 5 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1c
Open

bolt12: add Invoice codec and structural validators#10941
bitromortac wants to merge 5 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1c

Conversation

@bitromortac

Copy link
Copy Markdown
Collaborator

Based on #10832, part of #10736.

Adds the BOLT 12 Invoice message struct with PureTLVMessage-based Encode/Decode, alongside the structural ValidateInvoiceRead/Write and cross-message ValidateInvoiceAgainstRequest validators.

Additionally, this PR injects known feature-bit catalogues into the Offer and InvoiceRequest read-side validators to enable must-understand capability checks at runtime, because features are defined outside of bolt12's boundaries.

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

Copy link
Copy Markdown

PR Severity: CRITICAL

Highest-severity file determines label | 9 non-test/non-generated files | ~2,317 lines changed

Critical (1 file)
  • rpcserver.go - Core server coordination (explicitly listed as CRITICAL)
Medium (7 files)
  • bolt12/invoice.go - New BOLT 12 invoice type (uncategorized package, medium by default)
  • bolt12/invoice_request.go - New BOLT 12 invoice request type
  • bolt12/subtypes.go - BOLT 12 subtype definitions
  • bolt12/tlv_types.go - BOLT 12 TLV type constants
  • bolt12/validate.go - BOLT 12 validation logic (large: +1247/-11)
  • lnrpc/lightning.proto - API definition change (.proto files are medium)
  • lnrpc/lightning.swagger.json - Swagger API spec update
Low (1 file)
  • docs/release-notes/release-notes-0.22.0.md - Documentation
Excluded from counting (5 files)
  • bolt12/invoice_request_test.go - test file
  • bolt12/invoice_test.go - test file
  • bolt12/subtypes_test.go - test file
  • bolt12/validate_test.go - test file
  • lnrpc/lightning.pb.go - auto-generated protobuf file

Analysis

The critical classification is driven by changes to rpcserver.go (+30/-19 lines), which is the core RPC server and explicitly listed as a CRITICAL file requiring expert review.

The bulk of this PR (~2,300 non-test lines) is new BOLT 12 invoice parsing/validation logic in the bolt12 package. The BOLT 12 code adds new invoice/offer support without touching existing wallet, channel, or HTLC state machine code.

The rpcserver.go change wires the new BOLT 12 parsing into existing RPC handlers. Reviewers should pay particular attention to how new BOLT 12 paths interact with existing payment flows and whether error paths in the new validation code are handled safely before reaching the RPC layer.


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

@bitromortac bitromortac force-pushed the 2604-bolt12-1c branch 2 times, most recently from 03cc142 to 2ab1ae2 Compare June 30, 2026 17:53
@github-actions github-actions Bot added severity-high Requires knowledgeable engineer review and removed severity-critical Requires expert review - security/consensus critical labels Jun 30, 2026
@github-actions

Copy link
Copy Markdown

⚠️ Severity changed: severity-criticalseverity-high (files changed since last classification)

🟠 PR Severity: HIGH

Automated classification | 5 non-test files | 1,278 non-test lines changed

🟡 Medium (4 files)
  • bolt12/invoice.go — BOLT 12 invoice types/structures (similar to zpay32/*, not in CRITICAL/HIGH tiers)
  • bolt12/subtypes.go — BOLT 12 subtype definitions
  • bolt12/tlv_types.go — TLV type definitions for BOLT 12
  • bolt12/validate.go — BOLT 12 invoice/offer validation logic
🟢 Low (1 file)
  • docs/release-notes/release-notes-0.22.0.md — Release notes documentation

Analysis

The changed files are all in the bolt12/ package, which handles BOLT 12 offer/invoice protocol definitions and validation. This is analogous to zpay32/ (BOLT 11 invoices), which falls under MEDIUM. No files touch CRITICAL-tier packages (lnwallet/, htlcswitch/, channeldb/, lnwire/, etc.) or HIGH-tier packages (routing/, invoices/, lnrpc/, etc.).

Severity bump applied: 1,278 non-test lines changed exceeds the 500-line threshold, bumping the classification from MEDIUM → HIGH.

Test files excluded from counting: bolt12/invoice_test.go (286 lines), bolt12/subtypes_test.go (223 lines), bolt12/validate_test.go (864 lines).


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

@bitromortac

Copy link
Copy Markdown
Collaborator Author

/gateway review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 1, 2026

Copy link
Copy Markdown

✅ Review posted: #10941 (review)

6 finding(s); 6 inline, 0 in body.

🔁 Need a re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

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

This PR adds the BOLT 12 Invoice message, its blinded_payinfo/fallback_address subtype codecs, a truncated-uint32 TLV type, and the structural reader/writer/cross-message validators, and threads a runtime feature-bit catalogue into the offer and invoice_request readers. The codec work is careful and unusually well-tested: round-trip bijection, truncation rejection, unknown-field mirroring, and every reader/writer rejection path are all pinned by table tests, and the deferred concerns (signing, expiry clock, on-chain fallback rules) are documented inline.

The concerns worth resolving before merge are not in the new codec math but at its edges. First, the feature-catalogue refactor is a breaking signature change to three already-shipped validators, and this diff contains no non-test caller updates even though rpcserver.go was dropped from the PR since the last severity classification — confirm every caller compiles. Second, the same refactor silently removes the write-side unknown-even-feature-bit rejection that landed three weeks ago in #10832. Third, ValidateInvoiceRead authenticates nothing cryptographically yet; that is documented and intentional, but the identity binding it does perform has a gap when offer_issuer_id is absent, so callers must not mistake a passing invoice for an authentic one.

Findings: 🔴 0 Blocker · 🟠 3 Major · 🟡 3 Minor · 🔵 0 Nit

Comment thread bolt12/validate.go
return err
}

// check UTF-8 constraints and BIP 353

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 removed feature bit validation from writer validation as otherwise we'd need to pass in known features. I think it should be ok to rely on ourselves to set feature bits correctly.

Comment thread bolt12/validate.go
Comment thread bolt12/validate.go Outdated
// - MUST reject the invoice if this leaves no usable paths.
var usablePaths int
for i := range bp.Infos {
fv := bp.Infos[i].Features

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 docstring and signature validation will come later, in addition to checking against the blinded path.

Comment thread bolt12/subtypes.go
// BlindedPayInfos holds a list of BlindedPayInfo entries for the
// invoice_blindedpay field.
type BlindedPayInfos struct {
Infos []BlindedPayInfo

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.

This is a known issue as documented. I favored using the internal data structure for tlv mechanics over keeping the byte-wise representation. Added an error path to detect this explicitly.

Comment thread bolt12/subtypes.go
Comment thread bolt12/validate.go
return err
}

// - MUST reject the invoice if signature is not a valid signature using

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, documented explicitly. Imo validation should only read and not modify in this case, so this has to be checked by the pathfinding system later.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 1, 2026

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

Comment thread bolt12/validate.go
return err
}

// check UTF-8 constraints and BIP 353

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 removed feature bit validation from writer validation as otherwise we'd need to pass in known features. I think it should be ok to rely on ourselves to set feature bits correctly.

Comment thread bolt12/validate.go Outdated
// - MUST reject the invoice if this leaves no usable paths.
var usablePaths int
for i := range bp.Infos {
fv := bp.Infos[i].Features

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 docstring and signature validation will come later, in addition to checking against the blinded path.

Comment thread bolt12/subtypes.go
// BlindedPayInfos holds a list of BlindedPayInfo entries for the
// invoice_blindedpay field.
type BlindedPayInfos struct {
Infos []BlindedPayInfo

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.

This is a known issue as documented. I favored using the internal data structure for tlv mechanics over keeping the byte-wise representation. Added an error path to detect this explicitly.

Comment thread bolt12/validate.go
return err
}

// - MUST reject the invoice if signature is not a valid signature using

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, documented explicitly. Imo validation should only read and not modify in this case, so this has to be checked by the pathfinding system later.

Comment thread bolt12/subtypes.go
Comment on lines +133 to +135
// WARNING: RawFeatureVector re-encodes to minimal length, so setting
// non-minimal feature bytes (trailing zeros) yields different wire
// bytes than were read and invalidates the invoice signature.

@erickcestari erickcestari Jul 2, 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 believe it would be worth a change in the spec to only allow minimal encoded features in all lightning types.

This would be similar to what is being done with channel_announcement and node_announcement:
lightning/bolts#1341 (review)

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

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

LGTM! Nice work!🏅

There are only some nits that are non-blocking from my side.

RPC response rather than being emitted as an empty struct.

* [BOLT 12 invoice
codec](https://github.com/lightningnetwork/lnd/pull/10999): add the

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:

Suggested change
codec](https://github.com/lightningnetwork/lnd/pull/10999): add the
codec](https://github.com/lightningnetwork/lnd/pull/10941): add the

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, fixed the link to point at 10941.

Comment thread bolt12/invoice.go Outdated

// InvoiceNodeID is the public key of the recipient node, used to verify
// the signature.
InvoiceNodeID tlv.OptionalRecordT[tlv.TlvType176, [33]byte]

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:

Suggested change
InvoiceNodeID tlv.OptionalRecordT[tlv.TlvType176, [33]byte]
InvoiceNodeID tlv.OptionalRecordT[tlv.TlvType176, *btcec.PublicKey]

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, done. invoice_node_id is now a *btcec.PublicKey to match offer_issuer_id and invreq_payer_id. I added a present but nil guard in both the writer and reader validators, and the comparison against offer_issuer_id now uses IsEqual on the parsed keys. I also added rejection tests for the nil case on both validators.

Comment thread bolt12/validate.go
Comment on lines +1606 to +1625
// - For each invoice_blindedpay.payinfo:
// - MUST NOT use the corresponding invoice_paths.path if
// payinfo.features has any unknown even bits set.
// - MUST reject the invoice if this leaves no usable paths.
// NOTE: This loop only counts usable paths to ensure at least one
// exists. Callers MUST re-apply the same knownBlindedFeatures filter
// when selecting a path downstream to avoid using an unusable path,
// as the unfiltered list is returned.
var usablePaths int
for i := range bp.Infos {
fv := bp.Infos[i].Features
wrapped := lnwire.NewFeatureVector(&fv, knownBlindedFeatures)
if len(wrapped.UnknownRequiredFeatures()) == 0 {
usablePaths++
}
}

if usablePaths == 0 {
return ErrNoUsablePaths
}

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: We could have a similar method of UsableFallbackAddresses, but for the BlindedPaths fields.

// UsablePath pairs a blinded path with its payment parameters, as returned by
// UsablePaths after the BOLT 12 reader's feature filter has been applied.
type UsablePath struct {
	// Path is the blinded path to the recipient.
	Path lnwire.BlindedPath

	// PayInfo is the blinded_payinfo for Path.
	PayInfo BlindedPayInfo
}

// UsablePaths returns the invoice_paths entries a payer may use, each paired
// with its blinded_payinfo, after applying the BOLT 12 reader rule that a path
// MUST NOT be used when its payinfo.features has unknown required (even) bits
// set. knownBlindedFeatures names the feature bits the reader understands.
//
// The result is empty when invoice_paths or invoice_blindedpay is absent, or
// when the two lists differ in length; ValidateInvoiceRead rejects those cases
// separately, so a caller that validates first can treat an empty result as
// "no usable paths".
func (inv *Invoice) UsablePaths(
	knownBlindedFeatures map[lnwire.FeatureBit]string) []UsablePath {

	paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{})
	bp := inv.InvoiceBlindedPay.ValOpt().UnwrapOr(BlindedPayInfos{})

	// Entries pair by index; a length mismatch is rejected upstream by
	// ValidateInvoiceRead, so guard here to stay in bounds.
	if len(paths.Paths) != len(bp.Infos) {
		return nil
	}

	var usable []UsablePath
	for i := range bp.Infos {
		// MUST NOT use the path if payinfo.features has any unknown even
		// bits set.
		fv := bp.Infos[i].Features
		wrapped := lnwire.NewFeatureVector(&fv, knownBlindedFeatures)
		if len(wrapped.UnknownRequiredFeatures()) > 0 {
			continue
		}

		usable = append(usable, UsablePath{
			Path:    paths.Paths[i],
			PayInfo: bp.Infos[i],
		})
	}

	return usable
}

Then:

Suggested change
// - For each invoice_blindedpay.payinfo:
// - MUST NOT use the corresponding invoice_paths.path if
// payinfo.features has any unknown even bits set.
// - MUST reject the invoice if this leaves no usable paths.
// NOTE: This loop only counts usable paths to ensure at least one
// exists. Callers MUST re-apply the same knownBlindedFeatures filter
// when selecting a path downstream to avoid using an unusable path,
// as the unfiltered list is returned.
var usablePaths int
for i := range bp.Infos {
fv := bp.Infos[i].Features
wrapped := lnwire.NewFeatureVector(&fv, knownBlindedFeatures)
if len(wrapped.UnknownRequiredFeatures()) == 0 {
usablePaths++
}
}
if usablePaths == 0 {
return ErrNoUsablePaths
}
// - For each invoice_blindedpay.payinfo:
// - MUST NOT use the corresponding invoice_paths.path if
// payinfo.features has any unknown even bits set.
// - MUST reject the invoice if this leaves no usable paths.
// UsablePaths applies that filter; a caller selecting a path downstream
// should use it rather than the unfiltered invoice_paths.
if len(inv.UsablePaths(knownBlindedFeatures)) == 0 {
return ErrNoUsablePaths
}

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 suggestion. I added Invoice.UsablePaths, which returns each usable path paired with its blinded_payinfo, and ValidateInvoiceRead now uses it in place of the ad hoc counting loop. I added a test for it as well.

@saubyk

saubyk commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F1 per #10941 (comment)

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F1 (major) by @saubyk — per #10941 (comment)

Open findings on this PR: 🟠 F2 (major) · 🟠 F3 (major) · 🟡 F4 (minor) · 🟡 F5 (minor) · 🟡 F6 (minor)

@saubyk

saubyk commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F6 per #10941 (comment)

@saubyk

saubyk commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F4 per #10941 (comment)

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F4 (minor) by @saubyk — per #10941 (comment)

Open findings on this PR: 🟠 F2 (major) · 🟡 F5 (minor)

@saubyk

saubyk commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 4, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10941 (review)

Prior findings: 1 addressed, 1 still unresolved, 0 withdrawn.
New findings: 3.
Total inline: 4; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

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

The maintainer dismissed F1, F3, F4, and F6, and this revision resolved F5 by adding the entry-count caps to both list decoders. F2 remains open: the breaking arity change to ValidateOfferRead/ValidateInvoiceRequestRead/checkFeatures still carries no in-diff caller update, and independent change-impact analysis reconfirms the build-break risk cannot be cleared from the reach index alone. Three new, low-severity concerns surfaced in the invoice validators — an expiry boundary off-by-one, an unbounded invoice_amount for fixed-amount offers, and a feature-map parameter-transposition hazard.

Findings: 🔴 0 Blocker · 🟠 1 Major · 🟡 3 Minor · 🔵 0 Nit


Status of prior findings

  • F5 addressed: Fixed in bolt12/subtypes.godecodeBlindedPayInfos and decodeFallbackAddrs now cap entries at maxBlindedPayInfos/maxFallbackAddrs (32) and return ErrTooManyBlindedPayInfos/ErrTooManyFallbackAddrs, matching the sibling decodeChainsRecord bound, with "exceeds cap" test coverage added for both.

Comment thread bolt12/validate.go

return nil
}); err != nil {
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F7: Invoice expiry rejects one second early vs BOLT 12

ValidateInvoiceExpiry treats the invoice as expired when carry == 0 && uint64(now.Unix()) >= expiry, where expiry = invoice_created_at + invoice_relative_expiry. BOLT 12's reader rule rejects only when the current time is greater than created_at + seconds_from_creation, so >= expires the invoice at the exact boundary second — one second early — rejecting a payment the spec still considers valid. This is also internally inconsistent with ValidateOfferRead, which uses strict > (uint64(now.Unix()) > expiry) for offer_absolute_expiry. Change the comparator to >.

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. I changed the comparator from greater than or equal to a strict greater than, so the exact expiry second is still accepted. That matches the reader spec and the behavior of ValidateOfferRead. I also added boundary tests covering the exact second and one second past it.

Comment thread bolt12/validate.go Outdated
// Spec MUST: if invreq_amount (type 82) is present, invoice_amount
// (type 170) must equal it. The byte-mirror loop cannot relate fields
// with differing type numbers.
return fn.MapOptionZ(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor F8: invoice_amount unbounded to offer_amount when invreq_amount absent

ValidateInvoiceAgainstRequest's only amount cross-check is req.InvreqAmount present → inv.InvoiceAmount must equal it. When the payer relied on a fixed offer_amount and set no invreq_amount, invoice_amount is left unconstrained: the byte-mirror excludes it (type 170 is in the invoice-specific 160-176 range, outside invreqAllowedRange), and the WhenSome cross-check never fires. A payee can then return an invoice charging more than the offer advertised and this validator accepts it. BOLT 12 makes this a SHOULD ("confirm invoice_amount.msat is within the authorized range"), and the currency case genuinely needs a caller-supplied exchange rate — but the native fixed-amount case is offer_amount × invreq_quantity, computable here and already done for the request side in checkInvreqAmountMeetsOffer. Either add that comparison or state in this function's contract that the offer-amount bound is delegated.

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. When invreq_amount is absent I now enforce the native lower bound of offer_amount times invreq_quantity with an overflow guard, mirroring the request side check. The offer_currency case needs a caller supplied exchange rate, so it stays delegated and I documented that in the function contract. I added table tests for below, at, and above the bound plus the currency delegation skip.

Comment thread bolt12/validate.go
Comment thread bolt12/validate.go
Comment thread bolt12/validate.go
@bitromortac

Copy link
Copy Markdown
Collaborator Author

Thanks for you review @erickcestari, addressed your comments and also the ones for lightninglabs-gateway. @vctt94 @Abdulkbk your reviews are highly welcome, if you find the time (I can't request reviews from you on github).

@saubyk

saubyk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

/gateway dismiss F2 per #10941 (comment)

@lightninglabs-gateway

Copy link
Copy Markdown

🚫 Dismissed F2 (major) by @saubyk — per #10941 (comment)

Open findings on this PR: 🟡 F7 (minor) · 🟡 F8 (minor) · 🟡 F9 (minor)

@saubyk

saubyk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10941 (review)

Prior findings: 4 addressed, 0 still unresolved, 0 withdrawn.
New findings: 1.
Total inline: 1; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

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

The four outstanding non-dismissed findings from the prior review are all cleanly addressed: ValidateInvoiceExpiry now uses a strict > boundary comparison matching the reader spec (F7); ValidateInvoiceAgainstRequest enforces the native offer-amount lower bound via checkInvoiceAmountMeetsOffer with an overflow guard when invreq_amount is absent (F8); the two same-typed feature catalogues are grouped into the named InvoiceFeatureCatalogues struct so they can no longer be transposed positionally (F9); and the subtype decoders retain their explicit maxBlindedPayInfos/maxFallbackAddrs caps (F5). Boundary and delegation tests were added for each.

No new blocker- or major-severity issues survived verification. The specialists surfaced several major candidates, but each was either a restatement of a maintainer-dismissed finding, or an artifact of the reviewer working from a lossy digest rather than the source (verified against the actual diff: the mirror-range upper bound and the 7200s expiry default are both correct).

One genuinely-new minor remains: decoded blinded_payinfo HTLC bounds are accepted without a relational sanity check. This does not gate merge.

Findings: 🔴 0 Blocker · 🟠 0 Major · 🟡 1 Minor · 🔵 0 Nit


Status of prior findings

  • F5 addressed: Fixed — decodeBlindedPayInfos and decodeFallbackAddrs in bolt12/subtypes.go now carry explicit entry-count caps (if len(bp.Infos) >= maxBlindedPayInfos { return ErrTooManyBlindedPayInfos } and the maxFallbackAddrs equivalent), documenting the real bound rather than leaning on the enclosing record-size limit.
  • F7 addressed: Fixed in bolt12/validate.goValidateInvoiceExpiry now rejects only when carry == 0 && uint64(now.Unix()) > expiry (strict >), so the exact expiry second is still valid, matching the BOLT 12 reader rule and the strict comparison ValidateOfferRead uses. TestValidateInvoiceExpiry adds "at default expiry boundary" (now = created+7200, no error) and "past default expiry" (created+7201, expired) cases.
  • F8 addressed: Fixed in bolt12/validate.go — when invreq_amount is absent, ValidateInvoiceAgainstRequest now returns checkInvoiceAmountMeetsOffer(inv), which enforces invoice_amount >= offer_amount * invreq_quantity for the native case with a bits.Mul64 overflow guard, and delegates the offer_currency case to the caller as documented. TestValidateInvoiceAgainstRequestOfferAmount covers at/above/below the bound, quantity scaling, and the currency-delegation skip.
  • F9 addressed: Fixed in bolt12/validate.goValidateInvoiceRead(inv, activeChain, features InvoiceFeatureCatalogues) replaces the two adjacent map[lnwire.FeatureBit]string parameters with the named InvoiceFeatureCatalogues{Invoice, Blinded} struct, making a positional transposition of the invoice-features vs blinded-path catalogues impossible. TestValidateFeaturesWithCatalogue exercises both fields independently.

Comment thread bolt12/subtypes.go
@saubyk

saubyk commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

/gateway re-review

@lightninglabs-gateway

lightninglabs-gateway Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ Re-review posted: #10941 (review)

Prior findings: 5 addressed, 0 still unresolved, 0 withdrawn.
New findings: 0.
Total inline: 0; in body: 0.

🔁 Need another re-review after pushing changes? Reply with /gateway re-review.
Maintainers can also /gateway dismiss <id> to silence specific findings, or anyone can /gateway explain <id> for elaboration.

@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 bolt12/subtypes.go
Comment thread bolt12/subtypes_test.go Outdated
Comment thread bolt12/invoice.go Outdated
Add the truncated uint32 (tu32) TLV type used by invoice_relative_expiry
and the dynamic invoice subtypes BlindedPayInfo and FallbackAddress,
along with their encode/decode helpers and round-trip tests.

These primitives are the building blocks for the BOLT 12 Invoice message
struct that follows. Isolating them keeps that codec commit focused on
the message shape rather than its component records.
…alidators

Inject known feature-bit catalogues into the read-side validators to enable
correct must-understand capability checks, and remove write-side feature
enforcement entirely.

Whether a feature bit is "unknown" is a runtime property of the reading node,
not of the wire format or pure codec.
Add the BOLT 12 Invoice message: a struct mirroring the invoice_request
fields (types 0-91) plus the invoice-specific fields (types 160-176) and
the signature (type 240), together with its pure-TLV Encode/DecodeInvoice
codec and the UsableFallbackAddresses accessor that applies the spec's
MUST-ignore filter.

Additionally, add the NewInvoiceFromRequest constructor to build an Invoice
from a corresponding request. This copies all non-signature fields from the
request (including unknown signed-range TLVs via the decodedTLVs sidecar)
and mirrors invreq_amount into invoice_amount.
Implement the structural validators for the BOLT 12 invoice, adding
ValidateInvoiceWrite, ValidateInvoiceRead, ValidateInvoiceExpiry, and
ValidateInvoiceAgainstRequest.

The validators implement the spec writer and reader requirements in the
order the spec lists them. The reader confirms the signature TLV is
present but defers actual Schnorr verification until the merkle and
signing primitives land, mirroring the ValidateInvoiceRequestRead
precedent.
Add release notes for the BOLT 12 invoice codec.

@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! Left only a small nit.

great job!

Comment thread bolt12/validate.go
// be odd"). The signature range (240-1000) is exempt for the same
// reason, matching the invoice_request reader and the Merkle path.
for _, t := range sortedTypes(inv.decodedTLVs) {
if bolt12InUnsignedRange(t) {

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.

Non-blocking: should this match the invoice_request pattern and only exempt the signature range from range checks, not from unknown-even checks?

Right now an unknown even TLV in the 240-1000 signature range would be skipped before ErrUnknownEvenType is applied. ValidateInvoiceRequestRead still rejects unknown even types inside that range, which seems like the safer/more consistent behavior.

@saubyk

saubyk commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

/gateway re-review

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

All prior findings are resolved. F10 (the sole remaining unresolved concern — an unvalidated htlc_min > htlc_max range in decodeBlindedPayInfos) is now fixed with a typed ErrInvalidHtlcRange guard at decode time, matching the sibling ErrNonMinimalFeatures check. The five maintainer-dismissed findings are not re-raised. Specialist review surfaced no genuinely-new defects: the candidate findings were either false positives against the actual source or restatements of already-dismissed concerns.

One prior structural gap worth a maintainer's continued awareness (not a new finding, and already flagged non-blocking by a human reviewer): ValidateInvoiceRead skips both the range and unknown-even checks for TLV types in the signature range (240–1000), whereas ValidateInvoiceRequestRead still rejects unknown-even types there. This asymmetry was acknowledged in review and left as-is.


Status of prior findings

  • F5 addressed: Addressed. decodeBlindedPayInfos and decodeFallbackAddrs now enforce explicit entry caps (maxBlindedPayInfos/maxFallbackAddrs = 32) inside the for lr.N > 0 loop, consistent with decodeChainsRecord.
  • F7 addressed: Addressed in bolt12/validate.goValidateInvoiceExpiry uses the strict comparator uint64(now.Unix()) > expiry, so the exact boundary second is still valid, matching ValidateOfferRead. Boundary tests cover the exact second and one past it.
  • F8 addressed: Addressed. ValidateInvoiceAgainstRequest now calls checkInvoiceAmountMeetsOffer for the invreq_amount-absent case, enforcing invoice_amount >= offer_amount * invreq_quantity with a bits.Mul64 overflow guard; the offer_currency case is documented as delegated to the caller.
  • F9 addressed: Addressed. The two same-typed feature maps are grouped into the named InvoiceFeatureCatalogues{Invoice, Blinded} struct, making positional transposition impossible; in-package callers and tests updated.
  • F10 addressed: Addressed in bolt12/subtypes.godecodeBlindedPayInfos now rejects an inverted range with ErrInvalidHtlcRange when info.HtlcMinimumMsat > info.HtlcMaximumMsat, right at decode so the htlc_min <= htlc_max invariant holds for every downstream consumer. A decode test covers the inverted case.

@saubyk

saubyk commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

/gateway approve

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

✅ Approved — all prior findings addressed.

Findings recap

Findings:

  • F1 (major) — unresolved: This diff removes the checkFeatures(ir.InvreqFeatures) call from ValidateInvoiceRequestWrite while the reader (`ValidateInvoiceRequestRe…
  • F2 (major) — unresolved: ValidateOfferRead, ValidateInvoiceRequestRead, and the generic checkFeatures all gain a required `knownFeatures map[lnwire.FeatureBit]…
  • F3 (major) — unresolved: ValidateInvoiceRead only checks inv.Signature.IsSome(); Schnorr verification is a documented TODO(bolt12) deferred to the caller, and …
  • F4 (minor) — unresolved: BlindedPayInfo already carries a WARNING that RawFeatureVector re-encodes to minimal length. On the read path, decodeBlindedPayInfos a…
  • F5 (minor) — addressed: decodeBlindedPayInfos and decodeFallbackAddrs both loop for lr.N > 0 with no explicit entry-count bound, relying solely on the enclosi…
  • F6 (minor) — unresolved: The usable-paths loop in ValidateInvoiceRead increments usablePaths for each blinded_payinfo whose features carry no unknown required …
  • F7 (minor) — addressed: ValidateInvoiceExpiry treats the invoice as expired when carry == 0 && uint64(now.Unix()) >= expiry, where `expiry = invoice_created_at …
  • F8 (minor) — addressed: ValidateInvoiceAgainstRequest's only amount cross-check is req.InvreqAmount present → inv.InvoiceAmount must equal it. When the payer …
  • F9 (minor) — addressed: ValidateInvoiceRead(inv, activeChain, knownFeatures, knownBlindedFeatures map[lnwire.FeatureBit]string) places two parameters of identical…
  • F10 (minor) — addressed: decodeBlindedPayInfos reads htlc_minimum_msat and htlc_maximum_msat (and the fee fields) straight off the wire as raw uint64 with no…

Dismissed:


Approved by @saubyk via /gateway approve. Last reviewed at 1ed6719. Skill v0.3.0, model claude-opus-4-8.

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

Labels

gateway-active severity-high Requires knowledgeable engineer review

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

5 participants