mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
|
||||
|
||||
+105
@@ -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, 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
|
||||
+19
-11
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+31
-13
@@ -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) {
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
+87
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user