Repository navigation
feat: Decode and Verify BOLT 12 Payer Proofs - #29
Merged
Merged
Conversation
A payer proof discloses a chosen subset of an invoice and proves payment of it. Decoding one means rebuilding the invoice's merkle root from partial data, then checking two signatures: signature against invoice_node_id over the rebuilt invoice root, and proof_signature against invreq_payer_id over the proof's own tree. Present fields hash their own bytes against a nonce supplied in proof_leaf_hashes, because the nonce tag needs invreq_metadata, which a proof must never carry. Omitted subtrees are filled from proof_missing_hashes. Those hashes are consumed post-order depth-first, not level by level. The two orders agree on three of the five valid vectors and diverge on the other two, so a level-order walk looks correct until it meets a tree that interleaves pulls across depths. merkle.js gains buildTree and reconstructRoot. A test pins the pairing rule against merkleRoot for leaf counts 1 through 20, since a shape mismatch between the two builders would only surface as a wrong root. Each of the 23 invalid vectors is asserted to fail on the rule its name describes rather than merely to throw. The README claim that bolt12 invoices have no bech32 form was too strong: the spec defines no prefix for them, but its own payer proof vectors serialise them as lni1 strings.
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
M9 of the BOLT 12 signed-forms plan. Depends on #27 and #28.
Problem
A payer proof discloses a chosen subset of an invoice and proves it was paid. Decoding one is harder than the other BOLT 12 forms because the verifier never sees the whole invoice:
H("LnNonce" || TLV0, type)andTLV0isinvreq_metadata, which a proof must never carry. The proof supplies those nonces inproof_leaf_hashes.proof_missing_hashes.proof_omitted_tlvs, which preserve order and count without revealing types.Two signatures then have to check out, over two different trees.
Solution
js/merkle.jsgainsbuildTreeandreconstructRoot.js/bolt12proof.jsadds the 36-entry type table, the marker rules, anddecodePayerProof.Reconstruction merges the disclosed types with the markers plus an implied
0, sorts, and walks the resulting slot list — the marker design guarantees that ordering reproduces the original include/omit pattern.The ordering detail worth reviewing
proof_missing_hashesare consumed post-order depth-first, not level by level. I nearly implemented the level-order version, since it is the obvious extension ofcombineLevelfrom #27 and it reproduces the spec's own worked example correctly. It is wrong, and the vectors catch it:The two orders agree whenever no tree interleaves pulls across depths, which is why the spec's example does not distinguish them. Three of the five vectors do.
Verification
All 5 valid vectors rebuild both published roots. The invoice root is independently cross-checked against
merkleRootover the full invoice hex, which the vectors also supply — so reconstruction is verified against a tree built the ordinary way, not just against a published constant.buildTreeandmerkleRootmust pair leaves identically or a fully-disclosed proof would rebuild a different root, so a test pins them against each other for leaf counts 1 through 20, covering every odd-count carry.All 23 invalid vectors fail on the rule their name describes, asserted per-vector rather than accepting any throw:
wrong_proof_preimageproof_preimage does not hash to invoice_payment_hashproof_omitted_tlvs_contains_signature_fieldentry 241 is outside 1 to 239 and 1000000000 to 3999999999proof_omitted_tlvs_not_sequentialentry 100 does not follow the previous entry or an included fieldproof_leaf_hashes_too_fewproof_leaf_hashes has 2 hashes for 3 disclosed fieldsproof_missing_hashes_too_many1 unused proof_missing_hasheswrong_invoice_signaturesignature does not verify against invoice_node_idwrong_proof_signatureproof_signature does not verify against invreq_payer_idcontains_invreq_metadatapayer proof must not include invreq_metadataThe remaining 15 map just as directly. A separate test asserts the reason list and the expectation table are the same set, so a new upstream vector cannot be silently unmapped.
No behaviour change: invoice and offer decoding is byte-identical to
master, and the page still renders 16/16 invoices and 20/20 offers.Two notes
lnpis not wired into the page.decodeRequeststill answersNot yet supported: bolt12 payer proof decoding; rendering is M11, per the plan. The decoder is complete and tested but not yet reachable from the UI.A README correction is included. I had written that BOLT 12 invoices "have no bech32 prefix at all". Too strong — the spec defines prefixes only for
lno,lnrandlnpand never describes encoding an invoice as a string, but its own payer proof vectors serialise invoices aslni1…, and implementations do emit that. The wording now says what the spec does and does not define.Field naming distinguishes the self-assigned experimental range: type
3000000001infull_disclosurerenders asexperimental_3000000001rather thanunknown_, since having no spec name is correct for that range rather than a gap.