Skip to content

bolt12: add Merkle tree and BIP-340 message signatures - #11061

Merged
yyforyongyu merged 5 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1f
Sep 8, 2026
Merged

yyforyongyu merged 5 commits into
lightningnetwork:masterfrom
bitromortac:2604-bolt12-1f

Conversation

@bitromortac

Copy link
Copy Markdown
Collaborator

Part of #10736. Based on #11001.

Adds Merkle tree construction over TLV records (MerkleRoot) and BIP-340 Schnorr message signatures (signMessage, verifySignature) for BOLT 12 invoice requests and invoices, and verifies the signature on read so a decoded message with an invalid signature is rejected.

Additionally, this PR vendors upstream lightning/bolts signature test vectors (signature-test.json) to pin Merkle construction and signature verification byte-for-byte against the spec.

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

Copy link
Copy Markdown
Collaborator Author

/gateway review

Comment thread bolt12/validate.go
}

return nil
return VerifyInvoiceRequest(ir)

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.

These comments say the string encoder rejects unsigned messages, but the generic Bech32 Encode accepts any nonempty TLV bytes, so both typed Encode methods can emit a missing, stale, or wrong key signature.

Should final writer validation require and verify the signature now that the signing primitives exist?

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

Thanks, yes, I was planning to check this in another layer basically, in EncodeInvoiceRequestString and EncodeInvoiceString, which comes at a later stage and that one calls the Encode function. This will be part of the next PR. I've updated the comments a bit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'll un-export Encode/Decode, which makes a it a bit clearer hopefully.

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.

Encode/Decode are still exported. Fine to fold the un-export into the string layer PR, just flagging so it doesn't get lost.

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 will stay open after the 11146 is merged. I would first like to do a check of what needs to be exported looking at the package as a whole. So I decided to not do it here to keep diffs minimal.

@bitromortac

Copy link
Copy Markdown
Collaborator Author

/gateway review

@github-actions github-actions Bot added the severity-medium Focused review required label Aug 24, 2026
@github-actions

Copy link
Copy Markdown

🟡 PR Severity: MEDIUM

gh pr view | 10 files | 1716 lines changed (135 non-test/non-doc lines to core logic)

🟡 Medium (3 files)
  • bolt12/merkle.go - new file implementing BOLT12 merkle-tree hashing logic, uncategorized package defaults to medium
  • bolt12/signature.go - new file implementing BOLT12 signature construction/verification, uncategorized package defaults to medium
  • bolt12/validate.go - modifies existing BOLT12 validation logic
🟢 Low (6 files)
  • bolt12/helpers_test.go - test-only change
  • bolt12/invoice_test.go - test-only change
  • bolt12/merkle_test.go - test-only change
  • bolt12/signature_test.go - test-only change
  • bolt12/test-vectors/signature-test.json - test fixture data
  • docs/release-notes/release-notes-0.22.0.md - release notes

Analysis

This PR adds BOLT12 merkle-tree and signature helper logic (bolt12/merkle.go, bolt12/signature.go) plus supporting changes to bolt12/validate.go, backed by extensive test coverage and a release note. The bolt12 package isn't in the CRITICAL or HIGH file-path lists, so it defaults to MEDIUM ("other Go files not categorized above"). File count (3 non-test source files) and changed lines (~350 in source, well under the 20-file/500-line bump thresholds) don't warrant a severity bump, and no critical packages (lnwallet, htlcswitch, contractcourt, etc.) are touched.


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

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

Gateway review — 6 findings

🔴 0 Blocker · 🟠 2 Major · 🟡 4 Minor · 🔵 0 Nit

Summary

This PR completes the BOLT 12 signature story: Merkle root construction over TLV records, BIP-340 tagged-hash message signatures, and — the behavioural change that matters — ValidateInvoiceRead and ValidateInvoiceRequestRead now end in a real cryptographic check instead of a presence-only TODO. The primitives are pinned against the vendored spec vectors at leaf, nonce, branch, root, and digest granularity, and the pairwise reduction with odd-node promotion reproduces the pairing order both the 3-leaf n1 vector and the 6-leaf invoice_request vector document. That is a well-tested core.

The concerns are at the edges of that core rather than in the hash construction. The most consequential is what the signature actually commits to: the root is derived from records this package re-encodes, not from the bytes the peer sent, so verification is only correct as long as decode-then-encode is byte-exact for every BOLT 12 field — and the new fixtures are structured so that a divergence there cannot fail a test. Second, the verify path's error classification is incomplete in a way a peer selects: a signature encoding that fails to parse does not return ErrInvalidSignature, and the one test that claims to cover malformed signatures passes without exercising that branch.

One limitation on this review: the working tree was not available, so bolt12/invoice.go, bolt12/invoice_request.go (AllRecords, decodedTLVs), lnwire.EncodeRecords, and the tlv codecs could not be read. F1 and F5 both hinge on those files and are stated conditionally and scored accordingly.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread bolt12/merkle.go
Comment thread bolt12/signature.go Outdated
Comment thread bolt12/merkle.go Outdated
Comment thread bolt12/merkle.go Outdated
Comment thread bolt12/merkle.go
Comment thread bolt12/signature.go Outdated
@lightninglabs-gateway

Copy link
Copy Markdown

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

@bitromortac

Copy link
Copy Markdown
Collaborator Author

Ready for the next round @GeorgeTsagk and @ViktorT-11 (cc @Abdulkbk @vctt94 if you have time).

Comment thread bolt12/validate_test.go
Comment thread bolt12/merkle.go Outdated

@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 looks great 🔥! Leaving some initial comments.

Comment thread bolt12/subtypes.go Outdated
Comment thread bolt12/subtypes.go
return err
}
if vec.SerializeSize() != int(l) {
return ErrNonMinimalFeatures

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 a note here that I double checked, and the no other Lightning implementation rejects valid feature vectors with leading zero bytes. So if we go ahead and add this, LND will be the outlier when it comes to this behaviour.

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 are right that this deviates from other's behavior. I think in practice it won't be a problem because the spec mandates minimal encoding of features and all implementations use minimal encoding when writing. If this should become an issue, we can add a bolt12 local type that relaxes the minimal encoding requirement, which would still be compatible with minimally encoded invoices/requests, but the motivation is to reuse lnd-native types as much as possible.

Comment thread bolt12/signature.go
tag := signatureTagPrefix + messageName + fieldName
digest := taggedHash(tag, root[:])

sig, err := schnorr.Sign(privKey, digest[:])

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.

One recommendation by Codex here:


Hardening Concern

This does not pass auxiliary randomness to schnorr.Sign. BIP-340 recommends fresh auxiliary randomness because it improves protection against fault injection and side channels. The btcec implementation also explicitly warns that its signer has variable-time operations involving the key and nonce. See the btcec.Sign warning and BIP-340 signing guidance.

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.

Deferred. keychain/btcwallet.go calls schnorr.Sign with no auxiliary data. I looked at other implementations, all three of them don't add aux data.

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

Great work so far! I can feel BOLT 12 getting closer 🚀

I reviewed the Merkle construction, signature flow, and the invoice/request validation paths. The core implementation looks solid, and the official test vectors give good confidence around the cryptographic pieces.

I left one comment around offer_paths and the selected-path identity binding. Other than that, things look good from my side!

Comment thread bolt12/validate.go
return nil
// - MUST reject the invoice if signature is not a valid signature using
// invoice_node_id as described in Signature Calculation.
return VerifyInvoice(inv)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we may still need a way to verify that the invoice was signed by the final node of the offer_path the payer selected.

I tested a multi-hop path Alice → Bob, with Alice as an intermediate blinded node and Bob as the final node. If the returned invoice uses Alice as invoice_node_id and carries a valid Alice signature, both ValidateInvoiceRead and ValidateInvoiceAgainstRequest return nil.

As a control, the equivalent offer_issuer_id = Bob / invoice_node_id = Alice case is correctly rejected with ErrInvoiceNodeIDMismatch.

For offer_paths, BOLT 12 requires invoice_node_id to match the final blinded_node_id of the path the payer actually sent the invoice_request to. Since the selected path is state held by the caller, would it make sense to expose a helper or validation API that accepts that final blinded node and enforces this check?

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

Good catch! 11146 will contain a high-level checker that a invoice requester should use to validate against.

Edit: this commit has it ea88ddd

Add strictFeaturesRecord, a TLV record for the top-level features fields
whose decoder rejects non-minimally encoded feature vectors with
ErrNonMinimalFeatures. RawFeatureVector re-encodes to minimal length, so
accepting a padded encoding would change the Merkle leaf bytes and
invalidate an otherwise valid signature. The payinfo features guard in
decodeBlindedPayInfos is the same check one subtype level down.

All three message types use it, so the features fields decode through a
single path. An offer holds no signature of its own, so the record buys
it nothing directly, but no producer emits a padded vector and a second
lenient path would earn its keep only if one did.

Dropping this guard would not make lnd accept a padded vector on a
signed message: the vector decodes, re-encodes one byte shorter, and the
message is then refused at signature verification, which is the same
rejection behind a less obvious error. Accepting one requires deriving
the root from the bytes the peer sent rather than from re-encoded
records.
BOLT 12 signs the Merkle root of a tree built from the message's TLV
records. MerkleRoot constructs the tree from a []tlv.Record by hashing
each record into an LnLeaf and a uniqueness LnNonce, combining them
into LnBranch nodes, and recursing to a single root.

signableTLVs selects the records that enter the root, excluding the
240-1000 range the spec reserves for signature fields. The vendored
signature-test.json vectors pin the construction byte-for-byte against
the spec, down to every intermediate LnLeaf, LnNonce, and LnBranch
digest.
@bitromortac
bitromortac requested a review from vctt94 September 2, 2026 14:18

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

Did a second round deep review, and I think this looks solid given that we're ok with the tradeoff mentioned in #11061 (comment).
LGTM 🔥🎉!!

Only leaving a test related comment.

Comment thread bolt12/signature_test.go Outdated

@vctt94 vctt94 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM! 🔥 🚀

great job!!

@lightningnetwork lightningnetwork deleted a comment from Tboy1989 Sep 5, 2026

@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, ty

SignMessage and VerifySignature wrap btcec's Schnorr API around the
Merkle root with the spec's tagged-hash domain separator. Typed
helpers bind the construction to invoice requests and invoices, so
callers sign and verify messages rather than raw roots.

The signed vectors in signature-test.json verify the construction
against the spec, and a tamper matrix pins rejection of modified
roots, signatures, keys, and tags.
ValidateInvoiceRequestRead and ValidateInvoiceRead now run
VerifyInvoiceRequest and VerifyInvoice as their final step, so a
decoded message whose BIP-340 signature does not verify against
invreq_payer_id or invoice_node_id is rejected instead of only
checked for presence. This satisfies the reader-side MUSTs of the
BOLT 12 invoice_request and invoice requirements, and the docstrings
now state the contract instead of deferring it to callers.

The invoice_request wiring is pinned by the spec's signed vector; no
signed invoice vector exists, so the invoice side is pinned by
round-trip tests. Reader-validation fixtures now sign with Bob's
key, and the happy-path invoice_request test reuses the shared
validInvoiceRequest fixture.
Add changes for the Merkle tree and BIP-340 signature work in bolt12.
@yyforyongyu
yyforyongyu merged commit 1049d30 into lightningnetwork:master Sep 8, 2026
40 of 43 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in lnd v0.22 Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants