Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata` is always withheld and other invoice fields may be elided for privacy, with `proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying enough to rebuild the invoice signature's merkle root. The verifier previously reported these as unsupported (cryptoVerified = false), so a zap paid through a real wallet never counted locally. Implement the lightning/bolts#1346 reader: - Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest). Add emitMissingHashes as the writer dual, unify both on one tree builder. - Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the record's type bytes (not the full encoded TLV), and the payer proof signs under fieldname `proof_signature` (not `signature`). - Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field range predicate; the verifier reconstructs on every proof (type 0 is always the implied first omitted leaf) and drops the Unsupported result. - Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop), and rewire Bolt12ProofFixture onto it. Validated byte-for-byte against the draft's own conformance suite (bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are rejected, and the writer reproduces every vector's compression fields exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2.3 KiB
BOLT12 zap proof verification — interop test vectors (follow-up)
Status: done (both work items shipped), with one standing upstream caveat.
Bolt12ProofVerifier now reconstructs and fully verifies compressed BOLT12
payer proofs — the selective-disclosure case real wallets emit — so they count as
cryptoVerified = true (subject to the usual offer-binding rule). Verified
byte-for-byte against the draft's own conformance vectors.
What shipped
-
Vector-driven interop test.
bolt12/payer-proof-test.jsonfrom lightning/bolts#1346 is vendored intoquartz/src/commonTest/resources/bolt12/and driven byBolt12PayerProofVectorTest: all 5valid_vectorsverify, all 23invalid_vectorsare rejected, and the writer (Bolt12ProofBuilder) reproduces each valid vector'sproof_omitted_tlvs/proof_missing_hashes/proof_leaf_hashesexactly. The vectors disproved two of our earlier guesses, now fixed:- the nonce leaf hashes the record's type bytes, not the full encoded TLV
(
Bolt12Merkle.nonceLeafHash); - the proof signature field name is
proof_signature, notsignature(Bolt12ProofVerifier.PROOF_SIG_FIELD).
- the nonce leaf hashes the record's type bytes, not the full encoded TLV
(
-
Compressed-proof merkle reconstruction.
Bolt12Merkle.reconstructRootrebuilds the invoice root from the disclosedLnLeafhashes +proof_leaf_hashes(nonce leaves) +proof_omitted_tlvsmarkers +proof_missing_hashes(consumed post-order DFS smallest-to-largest).invreq_metadata(type 0) is always the implied first omitted leaf. TheisCompressed()short-circuit is gone; every proof now goes through reconstruction.
Standing caveat (upstream)
#1346 is still an unmerged draft. The TLV type numbers, signature digest tag
strings, leaf/branch tags, and the invoice/proof field ranges track the current
PR head (vincenzopalazzo/bolts@1be97b2) and MUST be re-checked if the spec
changes before it merges. The vector test is the tripwire: refresh the resource
from the merged BOLT and it will flag any drift.
Not covered here
Offer↔recipient-identity binding is still out of scope — a verified proof only
proves payment to the embedded offer, not that the offer belongs to the
p-tagged recipient (see Bolt12ZapValidator.isInvoiceBoundToOffer).