Skip to content

feat: Decode and Verify BOLT 12 Payer Proofs - #29

Merged
Xtrimmer merged 1 commit into
masterfrom
xtrimmer/bolt12-payer-proof
Aug 5, 2026
Merged

Xtrimmer merged 1 commit into
masterfrom
xtrimmer/bolt12-payer-proof

Conversation

@Xtrimmer

@Xtrimmer Xtrimmer commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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:

  • present fields cannot compute their own nonce leaf, because the nonce tag is H("LnNonce" || TLV0, type) and TLV0 is invreq_metadata, which a proof must never carry. The proof supplies those nonces in proof_leaf_hashes.
  • omitted subtrees are replaced by entries from proof_missing_hashes.
  • the real field numbers are hidden behind marker numbers in 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.js gains buildTree and reconstructRoot. js/bolt12proof.js adds the 36-entry type table, the marker rules, and decodePayerProof.

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_hashes are consumed post-order depth-first, not level by level. I nearly implemented the level-order version, since it is the obvious extension of combineLevel from #27 and it reproduces the spec's own worked example correctly. It is wrong, and the vectors catch it:

full_disclosure                    post=MATCH  level=MATCH
minimal_disclosure                 post=MATCH  level=4181dd70fc9392feeea521d558ba16
with_note                          post=MATCH  level=4181dd70fc9392feeea521d558ba16
left_subtree_omitted               post=MATCH  level=e046698d78d1a4dca517281b1ad03e
empty_proof_omitted_tlvs_explicit  post=MATCH  level=MATCH

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

npm test  →  463 tests, 463 pass, 0 fail, 0 todo   (95 new)

All 5 valid vectors rebuild both published roots. The invoice root is independently cross-checked against merkleRoot over 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.

buildTree and merkleRoot must 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:

vector error
wrong_proof_preimage proof_preimage does not hash to invoice_payment_hash
proof_omitted_tlvs_contains_signature_field entry 241 is outside 1 to 239 and 1000000000 to 3999999999
proof_omitted_tlvs_not_sequential entry 100 does not follow the previous entry or an included field
proof_leaf_hashes_too_few proof_leaf_hashes has 2 hashes for 3 disclosed fields
proof_missing_hashes_too_many 1 unused proof_missing_hashes
wrong_invoice_signature signature does not verify against invoice_node_id
wrong_proof_signature proof_signature does not verify against invreq_payer_id
contains_invreq_metadata payer proof must not include invreq_metadata

The 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

lnp is not wired into the page. decodeRequest still answers Not 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, lnr and lnp and never describes encoding an invoice as a string, but its own payer proof vectors serialise invoices as lni1…, 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 3000000001 in full_disclosure renders as experimental_3000000001 rather than unknown_, since having no spec name is correct for that range rather than a gap.

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.
@Xtrimmer
Xtrimmer merged commit 5a65cee into master Aug 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant