perf(bolt12): fail-fast validation, skip redundant verify, precompute merkle tags

Speed:
- Bolt12ZapValidator reorders checks cheap-to-expensive: all structural, cross-event,
  and payer-proof binding checks run first; the schnorr signature + proof crypto
  verifications run only once an event has passed them. A malformed or mismatched
  event now rejects with zero schnorr ops. Cannot change accept/reject, only which
  reason a doubly-invalid event reports.
- validate() gains verifyEventSignature (default true); LocalCache.consume passes
  false since the relay pipeline already verified the outer event — removing a
  redundant schnorr on every ingested zap (3 verifies instead of 4).
- Bolt12Merkle precomputes SHA256("LnLeaf")/SHA256("LnBranch") once and hashes the
  per-call "LnNonce"||first-tlv tag once per rootHash instead of once per record.

Tests:
- New validator rejections: preimage-mismatch, invalid-invoice-signature,
  payer-tag-mismatch, proof-does-not-match-offer, plus the verifyEventSignature
  skip-flag both ways (fixture gains corruptPaymentHash / breakInvoiceSignature).
- New commons NoteBolt12ZapTest: millisat→sat total, dedup by payment_hash,
  verified-not-downgraded-by-unverified, remove-by-source, clearChildLinks, and
  combined totals.

Docs: quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md captures the two
upstream-gated follow-ups (vector-driven interop test + compressed-proof merkle
reconstruction) for when lightning/bolts#1346 merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-23 20:43:02 +00:00
parent 33fb3a54b2
commit 3e19b76343
7 changed files with 319 additions and 27 deletions
@@ -2419,7 +2419,9 @@ object LocalCache : ILocalCache, ICacheProvider {
// NIP-XX validation is fully synchronous: zap-event structure, the embedded
// kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed
// validation drops the zap entirely — it never contributes to a zap total.
val validation = bolt12ZapValidator.validate(event)
// The outer event signature was already verified above (wasVerified/justVerify),
// so skip the redundant re-check inside the validator.
val validation = bolt12ZapValidator.validate(event, verifyEventSignature = false)
if (validation !is Bolt12ZapValidation.Valid) {
Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" }
return false
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Verifies how validated NIP-XX BOLT12 zaps fold into a Note's aggregate zap
* accounting — the model contract the LocalCache ingest path and the reaction-row
* counter depend on.
*/
class NoteBolt12ZapTest {
private fun note(id: String) = Note(id)
@Test
fun addsMillisatAmountsAsSatsToTheZapTotal() {
val target = note("a".repeat(64))
target.addBolt12Zap(note("b".repeat(64)), "hash1", amountMillisats = 21_000_000L, cryptoVerified = true)
target.addBolt12Zap(note("c".repeat(64)), "hash2", amountMillisats = 1_000_000L, cryptoVerified = true)
// 21_000_000 + 1_000_000 millisats = 22_000 sats.
assertEquals(22_000L, target.zapsAmount.toLong())
assertEquals(2, target.bolt12Zaps.size)
}
@Test
fun deduplicatesByPaymentHash() {
val target = note("a".repeat(64))
target.addBolt12Zap(note("b".repeat(64)), "samehash", amountMillisats = 21_000_000L, cryptoVerified = true)
// A relay echo (or a re-publish from another source) of the same settled payment.
target.addBolt12Zap(note("c".repeat(64)), "samehash", amountMillisats = 21_000_000L, cryptoVerified = true)
assertEquals(1, target.bolt12Zaps.size)
assertEquals(21_000L, target.zapsAmount.toLong())
}
@Test
fun aVerifiedEntryIsNotDowngradedByAnUnverifiedRepublish() {
val target = note("a".repeat(64))
target.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
target.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = false)
assertEquals(1, target.bolt12Zaps.size)
assertTrue(target.bolt12Zaps["h"]!!.cryptoVerified, "a verified entry must not be overwritten by an unverified one")
}
@Test
fun removingBySourceDropsTheEntryAndUpdatesTheTotal() {
val target = note("a".repeat(64))
val source = note("b".repeat(64))
target.addBolt12Zap(source, "h", amountMillisats = 7_000_000L, cryptoVerified = true)
assertEquals(7_000L, target.zapsAmount.toLong())
target.removeBolt12ZapBySource(source)
assertTrue(target.bolt12Zaps.isEmpty())
assertEquals(0L, target.zapsAmount.toLong())
}
@Test
fun clearChildLinksDropsBolt12ZapsAndReturnsTheirSources() {
val target = note("a".repeat(64))
val source = note("b".repeat(64))
target.addBolt12Zap(source, "h", amountMillisats = 3_000_000L, cryptoVerified = true)
assertTrue(target.hasZapsBoostsOrReactions())
val removed = target.clearChildLinks()
assertTrue(source in removed, "the source note must be returned so the cache can prune it")
assertTrue(target.bolt12Zaps.isEmpty())
assertEquals(0L, target.zapsAmount.toLong())
assertFalse(target.hasZapsBoostsOrReactions())
}
@Test
fun bolt12ZapsCombineWithLightningTotalsIndependently() {
val target = note("a".repeat(64))
// Two BOLT12 zaps; no lightning receipts on this note.
target.addBolt12Zap(note("b".repeat(64)), "h1", amountMillisats = 2_000_000L, cryptoVerified = true)
target.addBolt12Zap(note("c".repeat(64)), "h2", amountMillisats = 500_000L, cryptoVerified = false)
// Both count (validated == counted), regardless of crypto-verification state.
assertEquals(2_500L, target.zapsAmount.toLong())
}
}
@@ -0,0 +1,67 @@
# BOLT12 zap proof verification — interop test vectors (follow-up)
Status: **blocked on upstream.** The NIP-XX BOLT12-zap layer (`quartz/…/nipXXBolt12Zaps/`)
verifies fully-disclosed payer proofs and reports compressed ones as
`Bolt12ProofResult.Unsupported` (surfaced as `cryptoVerified = false`). Two pieces
of work are gated on the BOLT12 payer-proof spec ([lightning/bolts#1346]) merging
with published test vectors.
## Why it's gated
Today the crypto path (`Bolt12ProofVerifier` + `Bolt12Merkle`) is validated only by
**self-consistent round-trips** (our own encoder ↔ our own verifier, see
`Bolt12ProofFixture` + `Bolt12MerkleTest` + `Bolt12ZapValidatorTest`). That proves
internal correctness, not agreement with CLN/LDK. Several constants are our best
reading of the still-draft spec and MUST be reconciled against real vectors before
we trust wallet-produced proofs:
- TLV type numbers (`Bolt12PayerProof` companion): 240/241, 10011005, 22, 8091,
160176.
- Signature digest tags (`Bolt12ProofVerifier`): `"lightning" + messagename + fieldname`
`INVOICE_MESSAGE`/`PROOF_MESSAGE`/`SIGNATURE_FIELD`. The proof-signature field
name especially is a guess.
- Merkle leaf/branch tag strings + odd-node promotion (`Bolt12Merkle`) — believed to
match LDK, not checked byte-for-byte.
- 33-byte compressed `point` → BIP-340 x-only handling / even-y convention for
`invoice_node_id` and `invreq_payer_id`.
## Work item 1 — vector-driven interop test
When `bolt12/payer-proof-test.json` exists in #1346:
1. Vendor the vectors into `quartz/src/commonTest/resources/` (or inline the hex).
2. Add `Bolt12PayerProofVectorTest`: for each `valid` proof assert
`Bolt12ProofVerifier.verify(...) is Valid`; for each `invalid` proof assert the
specific rejection reason.
3. Fix any constant above that the vectors disprove. If a fix is needed, the
round-trip tests will still pass (they move with our encoder) — the vector test
is the real gate.
## Work item 2 — compressed-proof merkle reconstruction
Real wallet proofs omit non-required invoice TLVs (blinded paths, etc.), which still
contributed to the invoice signature's merkle root — so `Bolt12ProofVerifier.verify`
currently returns `Unsupported` for them. Implement the reconstruction in
`Bolt12Merkle`, rebuilding the invoice root from:
- disclosed invoice TLVs → compute their `LnLeaf` hashes locally;
- `proof_leaf_hashes` (1004) → the `LnNonce` leaves for disclosed fields (can't be
computed locally — the nonce tag embeds the possibly-omitted first TLV);
- `proof_omitted_tlvs` (1002) → markers for where omitted fields sit in
TLV-ascending order;
- `proof_missing_hashes` (1003) → sibling subtree hashes for omitted branches,
consumed post-order DFS smallest-to-largest.
Then verify the invoice signature against the reconstructed root and drop the
`isCompressed()` short-circuit. Gate acceptance behind Work item 1's vectors — a
reconstruction that only round-trips against our own encoder proves nothing about
real-wallet interop.
## Not gated on this
Runtime validation is fully offline (no network) and everything else in the feature
— events, accounting, display, the fully-disclosed crypto path — is done. This
document only covers making compressed real-wallet proofs count as
`cryptoVerified = true`.
[lightning/bolts#1346]: https://github.com/lightning/bolts/pull/1346
@@ -47,18 +47,26 @@ import com.vitorpamplona.quartz.utils.sha256.sha256
* lightning/bolts#1346 test vectors.
*/
object Bolt12Merkle {
private val LN_LEAF = "LnLeaf".encodeToByteArray()
private val LN_NONCE = "LnNonce".encodeToByteArray()
private val LN_BRANCH = "LnBranch".encodeToByteArray()
// The "LnLeaf" and "LnBranch" tags are constants, so their SHA-256 (the inner
// hash of a tagged hash) is precomputed once instead of per leaf/branch. The
// "LnNonce" tag isn't constant (it embeds the first TLV), so it is hashed once
// per rootHash() call rather than once per record.
private val LN_LEAF_TAG_HASH = sha256("LnLeaf".encodeToByteArray())
private val LN_BRANCH_TAG_HASH = sha256("LnBranch".encodeToByteArray())
/** Tagged hash `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */
fun taggedHash(
tag: ByteArray,
msg: ByteArray,
): ByteArray {
val tagHash = sha256(tag)
return sha256(tagHash + tagHash + msg)
}
): ByteArray = taggedHashPrecomputed(sha256(tag), msg)
/** Tagged hash when the caller already holds `SHA256(tag)` (the inner tag hash). */
private fun taggedHashPrecomputed(
tagHash: ByteArray,
msg: ByteArray,
): ByteArray = sha256(tagHash + tagHash + msg)
/**
* Computes the merkle root over [signableRecords] the caller must have
@@ -69,12 +77,12 @@ object Bolt12Merkle {
require(signableRecords.isNotEmpty()) { "Cannot compute a merkle root over zero records" }
val firstTlv = signableRecords.first().encoded
val nonceTag = LN_NONCE + firstTlv
val nonceTagHash = sha256(LN_NONCE + firstTlv)
var nodes = ArrayList<ByteArray>(signableRecords.size * 2)
for (record in signableRecords) {
nodes.add(taggedHash(LN_LEAF, record.encoded))
nodes.add(taggedHash(nonceTag, record.encoded))
nodes.add(taggedHashPrecomputed(LN_LEAF_TAG_HASH, record.encoded))
nodes.add(taggedHashPrecomputed(nonceTagHash, record.encoded))
}
while (nodes.size > 1) {
@@ -99,9 +107,9 @@ object Bolt12Merkle {
b: ByteArray,
): ByteArray =
if (compareUnsigned(a, b) <= 0) {
taggedHash(LN_BRANCH, a + b)
taggedHashPrecomputed(LN_BRANCH_TAG_HASH, a + b)
} else {
taggedHash(LN_BRANCH, b + a)
taggedHashPrecomputed(LN_BRANCH_TAG_HASH, b + a)
}
/**
@@ -53,12 +53,28 @@ import com.vitorpamplona.quartz.utils.Hex
class Bolt12ZapValidator(
private val proofVerifier: Bolt12ProofVerifier = Bolt12ProofVerifier(),
) {
fun validate(event: Bolt12ZapEvent): Bolt12ZapValidation {
// Step 1 — kind and event signature.
if (event.kind != Bolt12ZapEvent.KIND) return invalid(Reason.WRONG_KIND)
if (!event.verify()) return invalid(Reason.BAD_EVENT_SIGNATURE)
/**
* @param verifyEventSignature verify the zap event's own signature. Defaults to
* true for standalone callers. The `LocalCache` ingest path passes false
* because the relay-client pipeline already verified it before dispatch
* avoiding a redundant schnorr check on the hot path.
*
* Checks are ordered cheap-to-expensive: all structural, cross-event, and
* binding checks (tag reads, string/number compares) run first, and the
* expensive signature/crypto verifications run only once an event has passed
* them so a malformed or mismatched event is rejected without paying for a
* schnorr verification. This cannot change an accept/reject outcome (a
* badly-signed event still fails the later verify), only which reason a
* doubly-invalid event reports.
*/
fun validate(
event: Bolt12ZapEvent,
verifyEventSignature: Boolean = true,
): Bolt12ZapValidation {
// --- Cheap checks (no crypto) --------------------------------------------
// Step 2 — zap-event structure.
// Zap-event kind + structure.
if (event.kind != Bolt12ZapEvent.KIND) return invalid(Reason.WRONG_KIND)
if (event.tags.count(DescriptionTag::isTag) != 1) return invalid(Reason.NOT_EXACTLY_ONE_DESCRIPTION)
if (event.description() == null) return invalid(Reason.MISSING_DESCRIPTION)
@@ -78,9 +94,8 @@ class Bolt12ZapValidator(
val payer = event.payer()
if (payer != null && payer != event.pubKey) return invalid(Reason.PAYER_TAG_MISMATCH)
// Step 3 — embedded intent.
// Embedded intent — parse + structure (signature verified later).
val intent = event.zapIntent ?: return invalid(Reason.MISSING_OR_INVALID_INTENT)
if (!intent.verify()) return invalid(Reason.BAD_INTENT_SIGNATURE)
if (intent.pubKey != event.pubKey) return invalid(Reason.INTENT_PUBKEY_MISMATCH)
if (intent.zapId() == null) return invalid(Reason.INVALID_ZAP_ID)
@@ -91,7 +106,7 @@ class Bolt12ZapValidator(
if (intent.offer() == null) return invalid(Reason.INTENT_STRUCTURE_INVALID)
if (checkTargetCardinality(intent.tags) != null) return invalid(Reason.INTENT_STRUCTURE_INVALID)
// Step 4 — the zap and its intent must agree.
// The zap and its intent must agree.
if (event.content != intent.content) return invalid(Reason.CONTENT_MISMATCH)
if (recipient != intent.recipient()) return invalid(Reason.RECIPIENT_MISMATCH)
if (amount != intentAmount) return invalid(Reason.AMOUNT_MISMATCH)
@@ -100,13 +115,12 @@ class Bolt12ZapValidator(
if (event.zappedAddress() != intent.zappedAddress()) return invalid(Reason.TARGET_MISMATCH)
if (event.zappedKind() != intent.zappedKind()) return invalid(Reason.TARGET_MISMATCH)
// Step 5 — parse the raw offer.
// Parse the raw offer + payer proof.
val offerParsed = Bolt12Offer.parse(offer) ?: return invalid(Reason.UNPARSEABLE_OFFER)
// Step 6 — parse & decode the payer proof.
val proof = Bolt12PayerProof.parse(proofStr) ?: return invalid(Reason.UNPARSEABLE_PROOF)
// Step 7 — bind the proof to this zap.
// Bind the proof to this zap. Uses intent.id — a forged id can't survive the
// intent signature check below, so binding-before-verify is safe.
val expectedNote = NIP_URI_PREFIX + intent.id
if (proof.invreqPayerNote() != expectedNote) return invalid(Reason.PROOF_NOTE_MISMATCH)
@@ -115,7 +129,11 @@ class Bolt12ZapValidator(
offerBindingFailure(offerParsed, proof)?.let { return invalid(it) }
// Step 6 (crypto) — verify the payer proof signatures.
// --- Expensive checks (signatures + proof crypto) ------------------------
if (verifyEventSignature && !event.verify()) return invalid(Reason.BAD_EVENT_SIGNATURE)
if (!intent.verify()) return invalid(Reason.BAD_INTENT_SIGNATURE)
val cryptoResult = proofVerifier.verify(proof)
val cryptoVerified =
when (cryptoResult) {
@@ -62,10 +62,14 @@ object Bolt12ProofFixture {
payerNote: String,
compressed: Boolean = false,
breakProofSignature: Boolean = false,
breakInvoiceSignature: Boolean = false,
corruptPaymentHash: Boolean = false,
): String {
val nodePoint = point(nodeKey.pubKey)
val payerPoint = point(payerLightningKey.pubKey)
val paymentHash = sha256(preimage)
// A corrupt hash still yields a valid signature over the corrupted records —
// the preimage check (SHA256(preimage) != invoice_payment_hash) is what rejects it.
val paymentHash = sha256(preimage).also { if (corruptPaymentHash) it[0] = (it[0] + 1).toByte() }
// The invoice's signed records (types < 240), in ascending order.
val invoiceRecords =
@@ -79,10 +83,11 @@ object Bolt12ProofFixture {
TlvRecord(Bolt12PayerProof.TYPE_INVOICE_NODE_ID, nodePoint),
)
val invoiceRoot = Bolt12Merkle.rootHash(invoiceRecords)
val invoiceSigningKey = if (breakInvoiceSignature) payerLightningKey else nodeKey
val invoiceSig =
Nip01Crypto.sign(
Bolt12Merkle.signatureDigest(Bolt12ProofVerifier.INVOICE_MESSAGE, Bolt12ProofVerifier.SIGNATURE_FIELD, invoiceRoot),
nodeKey.privKey!!,
invoiceSigningKey.privKey!!,
)
val preimageRecord = TlvRecord(Bolt12PayerProof.TYPE_PROOF_PREIMAGE, preimage)
@@ -152,4 +152,91 @@ class Bolt12ZapValidatorTest {
val result = validator.validate(signedZap(signer, intent, proof))
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.INTENT_PUBKEY_MISMATCH), result)
}
@Test
fun rejectsWhenThePreimageDoesNotHashToThePaymentHash() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val nodeKey = KeyPair()
val preimage = ByteArray(32) { (it + 6).toByte() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, corruptPaymentHash = true)
val result = validator.validate(signedZap(signer, intent, proof))
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_PREIMAGE_MISMATCH), result)
}
@Test
fun rejectsAnInvalidInvoiceSignature() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val nodeKey = KeyPair()
val preimage = ByteArray(32) { (it + 8).toByte() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, breakInvoiceSignature = true)
val result = validator.validate(signedZap(signer, intent, proof))
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_INVOICE_SIGNATURE_INVALID), result)
}
@Test
fun rejectsAPayerTagThatIsNotTheEventAuthor() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val nodeKey = KeyPair()
val preimage = ByteArray(32) { (it + 9).toByte() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
// Attribute the zap to someone other than its signer.
val zap = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = "c".repeat(64)))
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PAYER_TAG_MISMATCH), validator.validate(zap))
}
@Test
fun rejectsWhenTheProofDoesNotMatchTheOffer() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val offerNodeKey = KeyPair()
val proofNodeKey = KeyPair() // a different node than the offer's issuer
val preimage = ByteArray(32) { (it + 10).toByte() }
val offer = Bolt12ProofFixture.buildOffer(offerNodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(proofNodeKey, KeyPair(), preimage, amount, note)
val result = validator.validate(signedZap(signer, intent, proof))
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.OFFER_PROOF_MISMATCH), result)
}
@Test
fun skipsTheEventSignatureCheckWhenTheCallerAlreadyVerifiedIt() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val nodeKey = KeyPair()
val preimage = ByteArray(32) { (it + 11).toByte() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
val validZap = signedZap(signer, intent, proof)
// Same event, but its own signature is corrupted (id still matches content).
val tamperedSig =
Bolt12ZapEvent(validZap.id, validZap.pubKey, validZap.createdAt, validZap.tags, validZap.content, "0".repeat(128))
// Default: the bad event signature is caught.
assertEquals(
Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.BAD_EVENT_SIGNATURE),
validator.validate(tamperedSig),
)
// Ingest path: the pipeline already verified the event, so skipping is safe and it validates.
assertIs<Bolt12ZapValidation.Valid>(validator.validate(tamperedSig, verifyEventSignature = false))
}
}