diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fa5381d667..5aa0654337 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt new file mode 100644 index 0000000000..baffc70239 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt @@ -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()) + } +} diff --git a/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md b/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md new file mode 100644 index 0000000000..19374f4b8c --- /dev/null +++ b/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md @@ -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, 1001–1005, 22, 80–91, + 160–176. +- 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt index 70c1569822..32550dec82 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt @@ -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(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) } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt index 9ca2fc2dde..7116911109 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt @@ -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) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt index f9d338c0c9..bdb7574928 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt index 28f9b088e6..384d8d6452 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt @@ -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(validator.validate(tamperedSig, verifyEventSignature = false)) + } }