mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(quartz): add BOLT12 zaps (NIP-2421) protocol layer
Implements the quartz-side of the proposed "BOLT12 Zaps" NIP (nostr-protocol/nips#2421): public, self-verifying zap events that prove a BOLT12 payment without an LNURL server or recipient-operated receipt publisher. Events (nip-88 style templates/tags/builders): - Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737) - shared tags: amount (msats), offer, proof, P (payer), zap_id, description - registered both kinds in EventFactory BOLT12 decoding (new, no existing KMP library): - Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum, no-length-limit bech32 for lno1 offers and lnp1 payer proofs - Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers - Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346) - Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest Validation: - Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match, payer-proof binding: invreq_payer_note == nostr:nipXX:<intent-id>, invoice_amount == amount) and returns a typed result with the payment-hash dedup key - Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported as unverified pending the (still-draft) lightning/bolts#1346 test vectors - Bolt12ZapBuilder assembles+signs the intent and the final zap Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag structure + factory typing, and validator accept/reject paths (self-signed BOLT12 fixtures exercise the full merkle + schnorr path). Note: this is the receive/verify + assembly layer only. Origination is blocked on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing lnp proofs; no Amethyst payment rail returns one today. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
|
||||
|
||||
/**
|
||||
* BOLT12 bech32 codec.
|
||||
*
|
||||
* BOLT12 reuses the bech32 character set and 8-to-5-bit conversion but differs
|
||||
* from BIP-173 in two ways this object handles:
|
||||
*
|
||||
* 1. **No checksum and no length limit.** Offers and proofs can be far longer
|
||||
* than the 90-char BIP-173 cap and carry no trailing 6-char checksum, so we
|
||||
* decode with [Bech32.decodeBytes] in `noChecksum` mode. (The bech32 data
|
||||
* alphabet excludes `1`, so the single `1` separating the human-readable
|
||||
* prefix from the data is unambiguous even for long strings.)
|
||||
* 2. **`+` continuations.** For transport, a long string may be split with `+`
|
||||
* separators optionally surrounded by whitespace (`lno1abc+ def`). The
|
||||
* canonical form removes every `+` and whitespace character — see
|
||||
* [canonicalize]. The NIP stores only canonical offers/proofs, but callers
|
||||
* should canonicalize any externally-sourced string before use.
|
||||
*
|
||||
* See https://github.com/lightning/bolts/blob/master/12-offer-encoding.md
|
||||
*/
|
||||
object Bolt12Bech32 {
|
||||
/** Human-readable prefix of a BOLT12 offer. */
|
||||
const val OFFER_HRP = "lno"
|
||||
|
||||
/** Human-readable prefix of a BOLT12 payer proof (lightning/bolts#1346). */
|
||||
const val PAYER_PROOF_HRP = "lnp"
|
||||
|
||||
/**
|
||||
* Removes BOLT12 `+` continuations and all whitespace and lowercases the
|
||||
* result, producing the canonical raw form the NIP stores and compares.
|
||||
*/
|
||||
fun canonicalize(raw: String): String {
|
||||
val sb = StringBuilder(raw.length)
|
||||
for (c in raw) {
|
||||
if (c == '+' || c == ' ' || c == '\t' || c == '\n' || c == '\r') continue
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.toString().lowercase()
|
||||
}
|
||||
|
||||
private fun hasHrp(
|
||||
canonical: String,
|
||||
hrp: String,
|
||||
): Boolean {
|
||||
val prefix = "${hrp}1"
|
||||
if (canonical.length <= prefix.length) return false
|
||||
if (!canonical.startsWith(prefix)) return false
|
||||
// every data char must be in the bech32 alphabet
|
||||
for (i in prefix.length until canonical.length) {
|
||||
if (Bech32.ALPHABET.indexOf(canonical[i]) < 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** True when [canonical] is a syntactically well-formed canonical `lno1...` offer. */
|
||||
fun isOffer(canonical: String) = hasHrp(canonical, OFFER_HRP)
|
||||
|
||||
/** True when [canonical] is a syntactically well-formed canonical `lnp1...` payer proof. */
|
||||
fun isPayerProof(canonical: String) = hasHrp(canonical, PAYER_PROOF_HRP)
|
||||
|
||||
/**
|
||||
* Decodes a BOLT12 bech32 string (offer or proof) into its raw TLV-stream
|
||||
* bytes, first canonicalizing it. Optionally asserts the human-readable
|
||||
* prefix. Throws [IllegalArgumentException] on malformed input or a prefix
|
||||
* mismatch.
|
||||
*/
|
||||
fun decodeToBytes(
|
||||
raw: String,
|
||||
expectedHrp: String? = null,
|
||||
): ByteArray {
|
||||
val (hrp, bytes, _) = Bech32.decodeBytes(canonicalize(raw), noChecksum = true)
|
||||
if (expectedHrp != null) {
|
||||
require(hrp == expectedHrp) { "Expected BOLT12 prefix $expectedHrp but obtained $hrp" }
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun decodeToBytesOrNull(
|
||||
raw: String,
|
||||
expectedHrp: String? = null,
|
||||
): ByteArray? =
|
||||
try {
|
||||
decodeToBytes(raw, expectedHrp)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes raw TLV-stream [bytes] as a canonical (no `+` continuation) BOLT12
|
||||
* bech32 string with the given [hrp]. Primarily an interop/testing helper —
|
||||
* production only ever decodes offers and proofs produced by wallets.
|
||||
*/
|
||||
fun encode(
|
||||
hrp: String,
|
||||
bytes: ByteArray,
|
||||
): String = Bech32.encodeBytes(hrp, bytes, Bech32.Encoding.Beck32WithoutChecksum)
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* BOLT12 "Signature Calculation" merkle root and the message digest that BOLT12
|
||||
* signatures (invoice `signature`, payer `proof_signature`) are computed over.
|
||||
*
|
||||
* Definitions (from 12-offer-encoding.md, matching the CLN/LDK reference
|
||||
* implementations):
|
||||
*
|
||||
* - Tagged hash: `H(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)`.
|
||||
* - For each signable TLV record (types outside the 240..1000 signature range),
|
||||
* two leaves are produced, in TLV-ascending order:
|
||||
* 1. `H("LnLeaf", tlv)`
|
||||
* 2. `H("LnNonce" || first-tlv, tlv)` where `first-tlv` is the encoded bytes
|
||||
* of the numerically-first signable record.
|
||||
* - Inner nodes: `H("LnBranch", lesser || greater)` (children sorted by their
|
||||
* 32-byte value). Odd nodes are promoted unchanged to the next level.
|
||||
* - The signature message digest is `H("lightning" || messagename || fieldname,
|
||||
* merkle_root)`, verified with BIP-340 against the signing key.
|
||||
*
|
||||
* NOTE: this computes the root over a **fully-disclosed** record set. Compressed
|
||||
* payer proofs (which omit some invoice TLVs and supply `proof_missing_hashes` /
|
||||
* `proof_leaf_hashes` to reconstruct the tree) are not reconstructed here; the
|
||||
* verifier reports those as unverifiable pending validation against the
|
||||
* 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()
|
||||
|
||||
/** Tagged hash `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */
|
||||
fun taggedHash(
|
||||
tag: ByteArray,
|
||||
msg: ByteArray,
|
||||
): ByteArray {
|
||||
val tagHash = sha256(tag)
|
||||
return sha256(tagHash + tagHash + msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the merkle root over [signableRecords] — the caller must have
|
||||
* already excluded the signature elements (types 240..1000). Records must be
|
||||
* in ascending type order.
|
||||
*/
|
||||
fun rootHash(signableRecords: List<TlvRecord>): ByteArray {
|
||||
require(signableRecords.isNotEmpty()) { "Cannot compute a merkle root over zero records" }
|
||||
|
||||
val firstTlv = signableRecords.first().encoded
|
||||
val nonceTag = 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))
|
||||
}
|
||||
|
||||
while (nodes.size > 1) {
|
||||
val next = ArrayList<ByteArray>((nodes.size + 1) / 2)
|
||||
var i = 0
|
||||
while (i < nodes.size) {
|
||||
if (i + 1 < nodes.size) {
|
||||
next.add(branch(nodes[i], nodes[i + 1]))
|
||||
i += 2
|
||||
} else {
|
||||
next.add(nodes[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
nodes = next
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
private fun branch(
|
||||
a: ByteArray,
|
||||
b: ByteArray,
|
||||
): ByteArray =
|
||||
if (compareUnsigned(a, b) <= 0) {
|
||||
taggedHash(LN_BRANCH, a + b)
|
||||
} else {
|
||||
taggedHash(LN_BRANCH, b + a)
|
||||
}
|
||||
|
||||
/**
|
||||
* The 32-byte BIP-340 message digest a BOLT12 signature signs:
|
||||
* `H("lightning" || messagename || fieldname, merkleRoot)`.
|
||||
*/
|
||||
fun signatureDigest(
|
||||
messageName: String,
|
||||
fieldName: String,
|
||||
merkleRoot: ByteArray,
|
||||
): ByteArray = taggedHash("lightning$messageName$fieldName".encodeToByteArray(), merkleRoot)
|
||||
|
||||
private fun compareUnsigned(
|
||||
a: ByteArray,
|
||||
b: ByteArray,
|
||||
): Int {
|
||||
val min = minOf(a.size, b.size)
|
||||
for (i in 0 until min) {
|
||||
val ai = a[i].toInt() and 0xff
|
||||
val bi = b[i].toInt() and 0xff
|
||||
if (ai != bi) return ai - bi
|
||||
}
|
||||
return a.size - b.size
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* A parsed BOLT12 offer (`lno1...`). Exposes the fields NIP-XX cares about; the
|
||||
* full TLV stream is retained for callers that need more.
|
||||
*
|
||||
* See https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#offer-fields
|
||||
*/
|
||||
class Bolt12Offer(
|
||||
val tlv: TlvStream,
|
||||
) {
|
||||
/** `offer_issuer_id` (type 22): the 33-byte compressed node id that signs invoices for this offer, if present. */
|
||||
fun issuerId(): ByteArray? = tlv.value(TYPE_ISSUER_ID)
|
||||
|
||||
/** `offer_amount` (type 8): the offer amount in the offer currency's minimal unit (msats when no currency). */
|
||||
fun amount(): Long? = tlv.tu64(TYPE_AMOUNT)
|
||||
|
||||
/** `offer_currency` (type 6): ISO 4217 code; absent means bitcoin (msats). */
|
||||
fun currency(): String? = tlv.value(TYPE_CURRENCY)?.decodeToString()
|
||||
|
||||
/** `offer_description` (type 10). */
|
||||
fun description(): String? = tlv.value(TYPE_DESCRIPTION)?.decodeToString()
|
||||
|
||||
fun hasPaths(): Boolean = tlv.has(TYPE_PATHS)
|
||||
|
||||
companion object {
|
||||
const val TYPE_CHAINS = 2L
|
||||
const val TYPE_METADATA = 4L
|
||||
const val TYPE_CURRENCY = 6L
|
||||
const val TYPE_AMOUNT = 8L
|
||||
const val TYPE_DESCRIPTION = 10L
|
||||
const val TYPE_FEATURES = 12L
|
||||
const val TYPE_ABSOLUTE_EXPIRY = 14L
|
||||
const val TYPE_PATHS = 16L
|
||||
const val TYPE_ISSUER = 18L
|
||||
const val TYPE_QUANTITY_MAX = 20L
|
||||
const val TYPE_ISSUER_ID = 22L
|
||||
|
||||
fun parse(canonicalOffer: String): Bolt12Offer? {
|
||||
val bytes = Bolt12Bech32.decodeToBytesOrNull(canonicalOffer, Bolt12Bech32.OFFER_HRP) ?: return null
|
||||
val tlv = TlvStream.readOrNull(bytes) ?: return null
|
||||
return Bolt12Offer(tlv)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* A parsed BOLT12 payer proof (`lnp1...`), per lightning/bolts#1346.
|
||||
*
|
||||
* A payer proof copies the relevant offer / invoice-request / invoice TLV fields,
|
||||
* plus the invoice's `signature`, and adds the payer's own `proof_signature`,
|
||||
* the `proof_preimage`, and (for compressed proofs) the merkle-reconstruction
|
||||
* fields `proof_missing_hashes` / `proof_leaf_hashes` / `proof_omitted_tlvs`.
|
||||
*
|
||||
* The type numbers below are the ones proposed in lightning/bolts#1346 and MUST
|
||||
* be reconciled against the final merged BOLT if they change.
|
||||
*/
|
||||
class Bolt12PayerProof(
|
||||
val tlv: TlvStream,
|
||||
) {
|
||||
fun offerIssuerId(): ByteArray? = tlv.value(TYPE_OFFER_ISSUER_ID)
|
||||
|
||||
fun invreqPayerId(): ByteArray? = tlv.value(TYPE_INVREQ_PAYER_ID)
|
||||
|
||||
fun invreqPayerNote(): String? = tlv.value(TYPE_INVREQ_PAYER_NOTE)?.decodeToString()
|
||||
|
||||
fun invreqAmount(): Long? = tlv.tu64(TYPE_INVREQ_AMOUNT)
|
||||
|
||||
fun invoicePaymentHash(): ByteArray? = tlv.value(TYPE_INVOICE_PAYMENT_HASH)
|
||||
|
||||
fun invoiceAmount(): Long? = tlv.tu64(TYPE_INVOICE_AMOUNT)
|
||||
|
||||
fun invoiceNodeId(): ByteArray? = tlv.value(TYPE_INVOICE_NODE_ID)
|
||||
|
||||
fun invoiceSignature(): ByteArray? = tlv.value(TYPE_SIGNATURE)
|
||||
|
||||
fun proofSignature(): ByteArray? = tlv.value(TYPE_PROOF_SIGNATURE)
|
||||
|
||||
fun proofPreimage(): ByteArray? = tlv.value(TYPE_PROOF_PREIMAGE)
|
||||
|
||||
fun proofOmittedTlvs(): ByteArray? = tlv.value(TYPE_PROOF_OMITTED_TLVS)
|
||||
|
||||
fun proofMissingHashes(): ByteArray? = tlv.value(TYPE_PROOF_MISSING_HASHES)
|
||||
|
||||
fun proofLeafHashes(): ByteArray? = tlv.value(TYPE_PROOF_LEAF_HASHES)
|
||||
|
||||
/**
|
||||
* True when the proof omits some of the original invoice's TLV fields and
|
||||
* relies on `proof_missing_hashes` to reconstruct the merkle tree. Such
|
||||
* proofs need the compressed-tree reconstruction to verify the invoice
|
||||
* signature (not yet implemented — see [Bolt12ProofVerifier]).
|
||||
*/
|
||||
fun isCompressed(): Boolean {
|
||||
if (tlv.has(TYPE_PROOF_OMITTED_TLVS)) return true
|
||||
val missing = proofMissingHashes()
|
||||
return missing != null && missing.isNotEmpty()
|
||||
}
|
||||
|
||||
/** The signable invoice records (types < 240) — used to recompute the invoice merkle root when fully disclosed. */
|
||||
fun invoiceSignableRecords(): List<TlvRecord> = tlv.records.filter { it.type < TlvRecord.SIGNATURE_TYPE_MIN }
|
||||
|
||||
/** The signable proof records (everything but the 240..1000 signature elements) — used for the payer proof signature. */
|
||||
fun proofSignableRecords(): List<TlvRecord> = tlv.records.filter { !it.isSignatureElement() }
|
||||
|
||||
/** True when every field NIP-XX validation requires is present. */
|
||||
fun hasAllRequiredFields(): Boolean =
|
||||
invreqPayerId() != null &&
|
||||
invreqPayerNote() != null &&
|
||||
invoicePaymentHash() != null &&
|
||||
invoiceNodeId() != null &&
|
||||
invoiceSignature()?.size == 64 &&
|
||||
proofSignature()?.size == 64 &&
|
||||
proofPreimage()?.size == 32
|
||||
|
||||
companion object {
|
||||
// Offer / invoice-request fields copied into the proof.
|
||||
const val TYPE_INVREQ_CHAIN = 80L
|
||||
const val TYPE_INVREQ_AMOUNT = 82L
|
||||
const val TYPE_INVREQ_FEATURES = 84L
|
||||
const val TYPE_INVREQ_QUANTITY = 86L
|
||||
const val TYPE_INVREQ_PAYER_ID = 88L
|
||||
const val TYPE_INVREQ_PAYER_NOTE = 89L
|
||||
const val TYPE_INVREQ_PATHS = 90L
|
||||
const val TYPE_INVREQ_BIP353_NAME = 91L
|
||||
const val TYPE_OFFER_ISSUER_ID = 22L
|
||||
|
||||
// Invoice fields copied into the proof.
|
||||
const val TYPE_INVOICE_PATHS = 160L
|
||||
const val TYPE_INVOICE_BLINDEDPAY = 162L
|
||||
const val TYPE_INVOICE_CREATED_AT = 164L
|
||||
const val TYPE_INVOICE_RELATIVE_EXPIRY = 166L
|
||||
const val TYPE_INVOICE_PAYMENT_HASH = 168L
|
||||
const val TYPE_INVOICE_AMOUNT = 170L
|
||||
const val TYPE_INVOICE_FALLBACKS = 172L
|
||||
const val TYPE_INVOICE_FEATURES = 174L
|
||||
const val TYPE_INVOICE_NODE_ID = 176L
|
||||
|
||||
// Signature elements (240..1000).
|
||||
const val TYPE_SIGNATURE = 240L
|
||||
const val TYPE_PROOF_SIGNATURE = 241L
|
||||
|
||||
// Payer-proof-specific fields (> 1000).
|
||||
const val TYPE_PROOF_PREIMAGE = 1001L
|
||||
const val TYPE_PROOF_OMITTED_TLVS = 1002L
|
||||
const val TYPE_PROOF_MISSING_HASHES = 1003L
|
||||
const val TYPE_PROOF_LEAF_HASHES = 1004L
|
||||
const val TYPE_PROOF_NOTE = 1005L
|
||||
|
||||
fun parse(canonicalProof: String): Bolt12PayerProof? {
|
||||
val bytes = Bolt12Bech32.decodeToBytesOrNull(canonicalProof, Bolt12Bech32.PAYER_PROOF_HRP) ?: return null
|
||||
val tlv = TlvStream.readOrNull(bytes) ?: return null
|
||||
return Bolt12PayerProof(tlv)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* BOLT-1 BigSize codec: a variable-length unsigned integer, big-endian, with a
|
||||
* length prefix byte (`0xfd`/`0xfe`/`0xff`) for the 2/4/8-byte forms. Used for
|
||||
* both TLV record types and lengths.
|
||||
*
|
||||
* See https://github.com/lightning/bolts/blob/master/01-messaging.md#appendix-a-bigsize-test-vectors
|
||||
*/
|
||||
object BigSize {
|
||||
fun encodedSize(value: Long): Int =
|
||||
when {
|
||||
value < 0xfdL -> 1
|
||||
value < 0x10000L -> 3
|
||||
value < 0x100000000L -> 5
|
||||
else -> 9
|
||||
}
|
||||
|
||||
fun encode(value: Long): ByteArray {
|
||||
require(value >= 0) { "BigSize cannot encode a negative value" }
|
||||
return when {
|
||||
value < 0xfdL -> byteArrayOf(value.toByte())
|
||||
value < 0x10000L -> byteArrayOf(0xfd.toByte(), (value shr 8).toByte(), value.toByte())
|
||||
value < 0x100000000L ->
|
||||
byteArrayOf(
|
||||
0xfe.toByte(),
|
||||
(value shr 24).toByte(),
|
||||
(value shr 16).toByte(),
|
||||
(value shr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
else ->
|
||||
byteArrayOf(
|
||||
0xff.toByte(),
|
||||
(value shr 56).toByte(),
|
||||
(value shr 48).toByte(),
|
||||
(value shr 40).toByte(),
|
||||
(value shr 32).toByte(),
|
||||
(value shr 24).toByte(),
|
||||
(value shr 16).toByte(),
|
||||
(value shr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cursor over a byte array for reading BigSize values and fixed-length byte
|
||||
* runs out of a TLV stream.
|
||||
*/
|
||||
class TlvReader(
|
||||
private val bytes: ByteArray,
|
||||
) {
|
||||
var pos: Int = 0
|
||||
private set
|
||||
|
||||
fun remaining(): Int = bytes.size - pos
|
||||
|
||||
fun readBytes(n: Int): ByteArray {
|
||||
require(n >= 0 && n <= remaining()) { "TLV read of $n bytes exceeds the remaining ${remaining()}" }
|
||||
val out = bytes.copyOfRange(pos, pos + n)
|
||||
pos += n
|
||||
return out
|
||||
}
|
||||
|
||||
private fun readByte(): Int {
|
||||
require(remaining() > 0) { "Unexpected end of TLV stream" }
|
||||
return bytes[pos++].toInt() and 0xff
|
||||
}
|
||||
|
||||
fun readBigSize(): Long {
|
||||
val first = readByte()
|
||||
return when (first) {
|
||||
0xff -> readUInt(8)
|
||||
0xfe -> readUInt(4)
|
||||
0xfd -> readUInt(2)
|
||||
else -> first.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUInt(n: Int): Long {
|
||||
var value = 0L
|
||||
repeat(n) {
|
||||
value = (value shl 8) or readByte().toLong()
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single TLV record: an unsigned [type], its [value] bytes, and a canonical
|
||||
* `type || length || value` [encoded] form (used both to re-serialize a stream
|
||||
* and as the leaf input for the BOLT12 signature merkle tree).
|
||||
*/
|
||||
class TlvRecord(
|
||||
val type: Long,
|
||||
val value: ByteArray,
|
||||
) {
|
||||
val encoded: ByteArray by lazy {
|
||||
BigSize.encode(type) + BigSize.encode(value.size.toLong()) + value
|
||||
}
|
||||
|
||||
/** BOLT12 signature TLV elements (types 240..1000 inclusive) are excluded from the merkle root. */
|
||||
fun isSignatureElement() = type in SIGNATURE_TYPE_MIN..SIGNATURE_TYPE_MAX
|
||||
|
||||
companion object {
|
||||
const val SIGNATURE_TYPE_MIN = 240L
|
||||
const val SIGNATURE_TYPE_MAX = 1000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed BOLT12 TLV stream (an offer, invoice request, invoice, or payer
|
||||
* proof). Records are kept in the order read; BOLT12 requires strictly
|
||||
* ascending, unique types, which [read] enforces.
|
||||
*/
|
||||
class TlvStream(
|
||||
val records: List<TlvRecord>,
|
||||
) {
|
||||
fun get(type: Long): TlvRecord? = records.firstOrNull { it.type == type }
|
||||
|
||||
fun value(type: Long): ByteArray? = get(type)?.value
|
||||
|
||||
fun has(type: Long): Boolean = get(type) != null
|
||||
|
||||
/** The truncated-uint64 value of a record, or null if absent. */
|
||||
fun tu64(type: Long): Long? = value(type)?.let { Bolt12Values.tu64(it) }
|
||||
|
||||
fun encode(): ByteArray {
|
||||
var size = 0
|
||||
for (r in records) size += r.encoded.size
|
||||
val out = ByteArray(size)
|
||||
var offset = 0
|
||||
for (r in records) {
|
||||
r.encoded.copyInto(out, offset)
|
||||
offset += r.encoded.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun read(bytes: ByteArray): TlvStream {
|
||||
val reader = TlvReader(bytes)
|
||||
val records = ArrayList<TlvRecord>()
|
||||
var lastType = -1L
|
||||
while (reader.remaining() > 0) {
|
||||
val type = reader.readBigSize()
|
||||
val length = reader.readBigSize()
|
||||
require(length <= reader.remaining()) { "TLV length $length exceeds the remaining stream" }
|
||||
val value = reader.readBytes(length.toInt())
|
||||
require(type > lastType) { "TLV records must be strictly ascending (saw $type after $lastType)" }
|
||||
lastType = type
|
||||
records.add(TlvRecord(type, value))
|
||||
}
|
||||
return TlvStream(records)
|
||||
}
|
||||
|
||||
fun readOrNull(bytes: ByteArray): TlvStream? =
|
||||
try {
|
||||
read(bytes)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encoders/decoders for the BOLT12 fundamental TLV value types we use. */
|
||||
object Bolt12Values {
|
||||
/**
|
||||
* Decodes a `tu64` (truncated uint64): a big-endian unsigned integer with
|
||||
* leading zero bytes removed, so its encoded length is 0..8 bytes.
|
||||
*/
|
||||
fun tu64(bytes: ByteArray): Long {
|
||||
require(bytes.size <= 8) { "tu64 must be at most 8 bytes, was ${bytes.size}" }
|
||||
var value = 0L
|
||||
for (b in bytes) {
|
||||
value = (value shl 8) or (b.toLong() and 0xff)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Minimal big-endian `tu64` encoding of [value] (leading zero bytes stripped). */
|
||||
fun tu64ToBytes(value: Long): ByteArray {
|
||||
require(value >= 0) { "tu64 cannot encode a negative value" }
|
||||
if (value == 0L) return ByteArray(0)
|
||||
val full =
|
||||
byteArrayOf(
|
||||
(value shr 56).toByte(),
|
||||
(value shr 48).toByte(),
|
||||
(value shr 40).toByte(),
|
||||
(value shr 32).toByte(),
|
||||
(value shr 24).toByte(),
|
||||
(value shr 16).toByte(),
|
||||
(value shr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
var start = 0
|
||||
while (start < full.size && full[start].toInt() == 0) start++
|
||||
return full.copyOfRange(start, full.size)
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.builder
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Assembles the two NIP-XX events in the order the payment flow requires:
|
||||
*
|
||||
* 1. [buildIntent] — sign a kind 9737 zap intent *before* paying.
|
||||
* 2. Pay the offer, putting [payerNote] in the BOLT12 `invreq_payer_note`, and
|
||||
* collect a settled `lnp` payer proof from the wallet.
|
||||
* 3. [buildZap] — wrap the signed intent and the proof into a kind 9736 zap.
|
||||
*
|
||||
* This is the on-Nostr assembly only; requesting the BOLT12 invoice, paying it,
|
||||
* and obtaining the payer proof are the wallet's job (no Amethyst payment rail
|
||||
* exposes `lnp` proofs yet).
|
||||
*/
|
||||
object Bolt12ZapBuilder {
|
||||
/** A fresh 128-bit `zap_id` as lowercase hex. */
|
||||
fun randomZapId(): String = Hex.encode(RandomInstance.bytes(16))
|
||||
|
||||
/**
|
||||
* The value the payer MUST place in the BOLT12 `invreq_payer_note` when paying
|
||||
* the offer, binding the settled payment to [intent].
|
||||
*/
|
||||
fun payerNote(intent: Bolt12ZapIntentEvent): String = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
|
||||
/** Sign a zap intent targeting a specific event. */
|
||||
suspend fun buildIntent(
|
||||
signer: NostrSigner,
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
zappedEvent: EventHintBundle<out Event>,
|
||||
comment: String = "",
|
||||
zapId: String = randomZapId(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12ZapIntentEvent =
|
||||
signer.sign(
|
||||
Bolt12ZapIntentEvent.build(recipientPubKey, amountInMillisats, offer, zapId, zappedEvent, comment, createdAt),
|
||||
)
|
||||
|
||||
/** Sign a zap intent targeting a recipient's profile (no event / address). */
|
||||
suspend fun buildProfileIntent(
|
||||
signer: NostrSigner,
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
comment: String = "",
|
||||
zapId: String = randomZapId(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12ZapIntentEvent =
|
||||
signer.sign(
|
||||
Bolt12ZapIntentEvent.buildProfileZap(recipientPubKey, amountInMillisats, offer, zapId, comment, createdAt),
|
||||
)
|
||||
|
||||
/**
|
||||
* Sign the final kind 9736 zap from a [signedIntent] and a settled [payerProof].
|
||||
*
|
||||
* @param anonymous when true, no `P` tag is added; the caller MUST pass an
|
||||
* ephemeral [signer] (the same key that signed [signedIntent]). When false,
|
||||
* the signer's pubkey is added as `P`, publicly attributing the zap.
|
||||
*/
|
||||
suspend fun buildZap(
|
||||
signer: NostrSigner,
|
||||
signedIntent: Bolt12ZapIntentEvent,
|
||||
payerProof: String,
|
||||
anonymous: Boolean = false,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12ZapEvent {
|
||||
val payerPubKey = if (anonymous) null else signer.pubKey
|
||||
return signer.sign(
|
||||
Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey, createdAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.intent
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.toETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-XX: BOLT12 Zaps — the **zap intent** (kind 9737).
|
||||
*
|
||||
* The payer creates and signs this event *before* paying the BOLT12 offer. It
|
||||
* binds the payer's Nostr key to the recipient, the target, the amount and the
|
||||
* offer. The payer then references this event's id in the BOLT12
|
||||
* `invreq_payer_note` (`nostr:nipXX:<zap-intent-event-id>`), and finally embeds
|
||||
* the whole serialized intent inside the kind 9736 zap event's `description` tag
|
||||
* — the same embedding pattern NIP-57 uses for the zap request.
|
||||
*
|
||||
* A zap intent is never counted on its own; only the kind 9736 zap that carries
|
||||
* both this intent and a settled payer proof is.
|
||||
*/
|
||||
@Immutable
|
||||
class Bolt12ZapIntentEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
EventHintProvider,
|
||||
AddressHintProvider,
|
||||
PubKeyHintProvider {
|
||||
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
|
||||
|
||||
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
|
||||
|
||||
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
|
||||
|
||||
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
|
||||
|
||||
/** The recipient pubkey (`p` tag). */
|
||||
fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The amount in millisatoshis (`amount` tag). */
|
||||
fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse)
|
||||
|
||||
/** The canonical raw BOLT12 offer (`offer` tag). */
|
||||
fun offer() = tags.firstNotNullOfOrNull(OfferTag::parse)
|
||||
|
||||
/** The random `zap_id`. */
|
||||
fun zapId() = tags.firstNotNullOfOrNull(ZapIdTag::parse)
|
||||
|
||||
/** The event being zapped, if any (`e` tag). */
|
||||
fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||
|
||||
/** The addressable event being zapped, if any (`a` tag). */
|
||||
fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
|
||||
|
||||
/** The kind of the target event, if declared (`k` tag). */
|
||||
fun zappedKind() = tags.firstNotNullOfOrNull(KindTag::parse)
|
||||
|
||||
/** True when neither `e` nor `a` is present — the intent targets the recipient's profile. */
|
||||
fun isProfileZap() = zappedEvent() == null && zappedAddress() == null
|
||||
|
||||
companion object {
|
||||
const val KIND = 9737
|
||||
|
||||
/** Build a zap intent that targets a specific event. */
|
||||
fun build(
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
zapId: String,
|
||||
zappedEvent: EventHintBundle<out Event>,
|
||||
comment: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Bolt12ZapIntentEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
recipient(recipientPubKey)
|
||||
amountInMillisats(amountInMillisats)
|
||||
offer(offer)
|
||||
zapId(zapId)
|
||||
if (zappedEvent.event is AddressableEvent) {
|
||||
zappedAddress(zappedEvent.toATag())
|
||||
} else {
|
||||
zappedEvent(zappedEvent.toETag())
|
||||
}
|
||||
zappedKind(zappedEvent.event.kind)
|
||||
initializer()
|
||||
}
|
||||
|
||||
/** Build a zap intent that targets a recipient's profile (no event / address). */
|
||||
fun buildProfileZap(
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
zapId: String,
|
||||
comment: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Bolt12ZapIntentEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
recipient(recipientPubKey)
|
||||
amountInMillisats(amountInMillisats)
|
||||
offer(offer)
|
||||
zapId(zapId)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.intent
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zapId(zapId: String) = addUnique(ZapIdTag.assemble(zapId))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedEvent(tag: ETag) = addUnique(tag.toTagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedAddress(tag: ATag) = addUnique(tag.toATagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind))
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `amount` tag of a NIP-XX BOLT12 zap or zap intent: the payment amount in
|
||||
* **millisatoshis** (not sats — this matches the BOLT12 `invoice_amount` field it
|
||||
* is validated against, and NIP-57's `amount` tag).
|
||||
*/
|
||||
class AmountTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "amount"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(amountInMillisats: Long) = arrayOf(TAG_NAME, amountInMillisats.toString())
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `description` tag of a BOLT12 zap event (kind 9736): the complete serialized
|
||||
* kind 9737 zap intent event, as JSON. Uses the same embedding pattern as NIP-57,
|
||||
* where the zap receipt carries the serialized zap request.
|
||||
*
|
||||
* This class only extracts the raw string; parsing it back into an event and
|
||||
* checking its signature is the validator's job.
|
||||
*/
|
||||
class DescriptionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "description"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(serializedIntentEventJson: String) = arrayOf(TAG_NAME, serializedIntentEventJson)
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `offer` tag: the **canonical raw BOLT12 offer** the payment was made to.
|
||||
*
|
||||
* The canonical form is the lowercase `lno1...` string with BOLT12 `+`
|
||||
* continuation separators and whitespace removed (see [Bolt12Bech32.canonicalize]).
|
||||
* Both the zap event (kind 9736) and the embedded zap intent (kind 9737) carry
|
||||
* the exact same canonical value; the validator compares them byte-for-byte.
|
||||
*/
|
||||
class OfferTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "offer"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isOffer(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(Bolt12Bech32.isOffer(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(canonicalOffer: String) = arrayOf(TAG_NAME, canonicalOffer)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The uppercase `P` tag: the **payer** pubkey of a publicly-attributed BOLT12 zap.
|
||||
*
|
||||
* When present it MUST equal the zap event `pubkey`. Anonymous zaps use an
|
||||
* ephemeral event pubkey and MUST omit this tag (mirrors NIP-57's uppercase-`P`
|
||||
* convention for the sender).
|
||||
*/
|
||||
class PayerTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "P"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(payerPubKey: HexKey) = arrayOf(TAG_NAME, payerPubKey)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `proof` tag: the bech32-encoded BOLT12 `lnp` **payer proof** for the settled
|
||||
* payment. Only present on the zap event (kind 9736), never on the intent.
|
||||
*
|
||||
* The proof is decoded and cryptographically checked by the validator; this tag
|
||||
* class only guards the surface syntax (`lnp1...`).
|
||||
*/
|
||||
class ProofTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "proof"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isPayerProof(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(Bolt12Bech32.isPayerProof(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(payerProof: String) = arrayOf(TAG_NAME, payerProof)
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `zap_id` tag of a zap intent (kind 9737): a random value with at least
|
||||
* 128 bits of entropy, encoded as lowercase hex. It only needs to be present and
|
||||
* well-formed; the anti-replay binding is enforced by the payer note referencing
|
||||
* the intent's event id, not by this value itself.
|
||||
*/
|
||||
class ZapIdTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "zap_id"
|
||||
|
||||
/** 128 bits of entropy = 16 bytes = 32 lowercase-hex characters. */
|
||||
const val MIN_HEX_LENGTH = 32
|
||||
|
||||
fun isValid(zapId: String) = zapId.length >= MIN_HEX_LENGTH && Hex.isHex(zapId) && zapId == zapId.lowercase()
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isValid(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isValid(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(zapId: String) = arrayOf(TAG_NAME, zapId)
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.verify
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/** Outcome of cryptographically verifying a BOLT12 payer proof. */
|
||||
@Immutable
|
||||
sealed interface Bolt12ProofResult {
|
||||
/**
|
||||
* The proof cryptographically checks out.
|
||||
*
|
||||
* @property paymentHash the `invoice_payment_hash` — the dedup key for zaps
|
||||
* sharing a target.
|
||||
* @property invoiceAmountMillisats the settled `invoice_amount`, if present.
|
||||
*/
|
||||
@Immutable
|
||||
data class Valid(
|
||||
val paymentHash: ByteArray,
|
||||
val invoiceAmountMillisats: Long?,
|
||||
) : Bolt12ProofResult {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Valid) return false
|
||||
return paymentHash.contentEquals(other.paymentHash) && invoiceAmountMillisats == other.invoiceAmountMillisats
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * paymentHash.contentHashCode() + (invoiceAmountMillisats?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
/** The proof is present but fails a check and MUST NOT be counted. */
|
||||
@Immutable
|
||||
data class Invalid(
|
||||
val reason: Reason,
|
||||
) : Bolt12ProofResult
|
||||
|
||||
/**
|
||||
* The proof could not be verified with the currently-implemented checks (e.g.
|
||||
* a compressed proof needing the not-yet-validated merkle reconstruction).
|
||||
* Whether to surface it as unverified or drop it is the caller's policy.
|
||||
*/
|
||||
@Immutable
|
||||
data class Unsupported(
|
||||
val reason: Reason,
|
||||
) : Bolt12ProofResult
|
||||
|
||||
enum class Reason {
|
||||
MISSING_REQUIRED_FIELDS,
|
||||
PREIMAGE_MISMATCH,
|
||||
INVOICE_SIGNATURE_INVALID,
|
||||
PROOF_SIGNATURE_INVALID,
|
||||
MALFORMED_KEY,
|
||||
COMPRESSED_PROOF_UNSUPPORTED,
|
||||
}
|
||||
}
|
||||
+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.quartz.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Merkle
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Cryptographic verification of a BOLT12 `lnp` payer proof, per lightning/bolts#1346:
|
||||
*
|
||||
* 1. `SHA256(proof_preimage) == invoice_payment_hash` — proves the payment settled.
|
||||
* 2. The invoice `signature` (240) is valid over the invoice merkle root, signed
|
||||
* by `invoice_node_id`.
|
||||
* 3. The `proof_signature` (241) is valid over the proof merkle root, signed by
|
||||
* `invreq_payer_id`.
|
||||
*
|
||||
* The merkle machinery ([Bolt12Merkle]) and the BIP-340 checks ([Nip01Crypto.verify])
|
||||
* are exercised end-to-end by the round-trip tests. **Interop caveat:** the exact
|
||||
* signature field names and, especially, the compressed-proof merkle
|
||||
* reconstruction (`proof_missing_hashes` / `proof_leaf_hashes` / `proof_omitted_tlvs`)
|
||||
* have not been checked against lightning/bolts#1346's `payer-proof-test.json`
|
||||
* vectors — that spec is still an unmerged draft. Until then, this verifier only
|
||||
* fully validates signatures for **fully-disclosed** proofs and reports
|
||||
* compressed proofs as [Bolt12ProofResult.Unsupported]. Callers decide whether an
|
||||
* unsupported crypto check may still be surfaced (labeled unverified) or dropped.
|
||||
*/
|
||||
class Bolt12ProofVerifier {
|
||||
fun verify(proof: Bolt12PayerProof): Bolt12ProofResult {
|
||||
if (!proof.hasAllRequiredFields()) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS)
|
||||
}
|
||||
|
||||
val preimage = proof.proofPreimage() ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS)
|
||||
val paymentHash = proof.invoicePaymentHash() ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS)
|
||||
|
||||
// 1. Settlement proof: the preimage must hash to the invoice payment hash.
|
||||
if (!sha256(preimage).contentEquals(paymentHash)) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.PREIMAGE_MISMATCH)
|
||||
}
|
||||
|
||||
// 2/3. Signature checks require reconstructing the invoice merkle root; for a
|
||||
// compressed proof that needs the (unverified) missing-hash reconstruction.
|
||||
if (proof.isCompressed()) {
|
||||
return Bolt12ProofResult.Unsupported(Bolt12ProofResult.Reason.COMPRESSED_PROOF_UNSUPPORTED)
|
||||
}
|
||||
|
||||
val invoiceSig = proof.invoiceSignature()!!
|
||||
val nodeId = xOnly(proof.invoiceNodeId()!!) ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MALFORMED_KEY)
|
||||
val invoiceRoot = Bolt12Merkle.rootHash(proof.invoiceSignableRecords())
|
||||
val invoiceDigest = Bolt12Merkle.signatureDigest(INVOICE_MESSAGE, SIGNATURE_FIELD, invoiceRoot)
|
||||
if (!Nip01Crypto.verify(invoiceSig, invoiceDigest, nodeId)) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.INVOICE_SIGNATURE_INVALID)
|
||||
}
|
||||
|
||||
val proofSig = proof.proofSignature()!!
|
||||
val payerId = xOnly(proof.invreqPayerId()!!) ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MALFORMED_KEY)
|
||||
val proofRoot = Bolt12Merkle.rootHash(proof.proofSignableRecords())
|
||||
val proofDigest = Bolt12Merkle.signatureDigest(PROOF_MESSAGE, SIGNATURE_FIELD, proofRoot)
|
||||
if (!Nip01Crypto.verify(proofSig, proofDigest, payerId)) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.PROOF_SIGNATURE_INVALID)
|
||||
}
|
||||
|
||||
return Bolt12ProofResult.Valid(paymentHash = paymentHash, invoiceAmountMillisats = proof.invoiceAmount())
|
||||
}
|
||||
|
||||
/**
|
||||
* A BOLT12 `point` is a 33-byte compressed secp256k1 key; BIP-340 uses the
|
||||
* 32-byte x-only form. Drop the parity prefix. (Already-x-only 32-byte input
|
||||
* is passed through for convenience in tests.)
|
||||
*/
|
||||
private fun xOnly(point: ByteArray): ByteArray? =
|
||||
when (point.size) {
|
||||
33 -> point.copyOfRange(1, 33)
|
||||
32 -> point
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
// BOLT12 signature digest tags are "lightning" || messagename || fieldname.
|
||||
// These strings track lightning/bolts#1346 and must be reconciled on merge.
|
||||
const val INVOICE_MESSAGE = "invoice"
|
||||
const val PROOF_MESSAGE = "payer_proof"
|
||||
const val SIGNATURE_FIELD = "signature"
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.verify
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/** Result of validating a NIP-XX BOLT12 zap event (kind 9736). */
|
||||
@Immutable
|
||||
sealed interface Bolt12ZapValidation {
|
||||
/**
|
||||
* The zap event is well-formed, its embedded intent is signed by the same key
|
||||
* and matches, and the payer proof is bound to that intent.
|
||||
*
|
||||
* @property recipient the zapped author (`p`).
|
||||
* @property payer the payer (`P`), or null for an anonymous zap.
|
||||
* @property amountMillisats the amount to count.
|
||||
* @property paymentHashHex the proof's `invoice_payment_hash`, hex-encoded —
|
||||
* the key clients MUST deduplicate on before summing.
|
||||
* @property proofCryptoVerified true when the BOLT12 payer-proof signatures
|
||||
* were fully verified; false when the proof is structurally valid and bound
|
||||
* but its signatures could not yet be checked (a compressed proof — see
|
||||
* [Bolt12ProofVerifier]). Callers decide whether to count or merely display
|
||||
* the latter, and MUST label it as unverified.
|
||||
*/
|
||||
@Immutable
|
||||
data class Valid(
|
||||
val recipient: HexKey,
|
||||
val payer: HexKey?,
|
||||
val amountMillisats: Long,
|
||||
val paymentHashHex: String,
|
||||
val zappedEventId: String?,
|
||||
val zappedAddress: String?,
|
||||
val zappedKind: Int?,
|
||||
val proofCryptoVerified: Boolean,
|
||||
) : Bolt12ZapValidation {
|
||||
val isProfileZap: Boolean get() = zappedEventId == null && zappedAddress == null
|
||||
}
|
||||
|
||||
/** The event failed validation and MUST NOT be counted. */
|
||||
@Immutable
|
||||
data class Invalid(
|
||||
val reason: Reason,
|
||||
) : Bolt12ZapValidation
|
||||
|
||||
enum class Reason {
|
||||
WRONG_KIND,
|
||||
BAD_EVENT_SIGNATURE,
|
||||
MISSING_DESCRIPTION,
|
||||
NOT_EXACTLY_ONE_DESCRIPTION,
|
||||
MISSING_RECIPIENT,
|
||||
NOT_EXACTLY_ONE_RECIPIENT,
|
||||
MISSING_AMOUNT,
|
||||
NON_POSITIVE_AMOUNT,
|
||||
MISSING_OR_INVALID_OFFER,
|
||||
MISSING_OR_INVALID_PROOF,
|
||||
MULTIPLE_EVENT_TARGETS,
|
||||
MULTIPLE_ADDRESS_TARGETS,
|
||||
BOTH_EVENT_AND_ADDRESS_TARGET,
|
||||
PAYER_TAG_MISMATCH,
|
||||
MISSING_OR_INVALID_INTENT,
|
||||
BAD_INTENT_SIGNATURE,
|
||||
INTENT_PUBKEY_MISMATCH,
|
||||
INVALID_ZAP_ID,
|
||||
INTENT_STRUCTURE_INVALID,
|
||||
CONTENT_MISMATCH,
|
||||
RECIPIENT_MISMATCH,
|
||||
AMOUNT_MISMATCH,
|
||||
OFFER_MISMATCH,
|
||||
TARGET_MISMATCH,
|
||||
UNPARSEABLE_OFFER,
|
||||
UNPARSEABLE_PROOF,
|
||||
PROOF_NOTE_MISMATCH,
|
||||
MISSING_INVOICE_AMOUNT,
|
||||
PROOF_AMOUNT_MISMATCH,
|
||||
OFFER_PROOF_MISMATCH,
|
||||
PROOF_MISSING_REQUIRED_FIELDS,
|
||||
PROOF_PREIMAGE_MISMATCH,
|
||||
PROOF_INVOICE_SIGNATURE_INVALID,
|
||||
PROOF_SIGNATURE_INVALID,
|
||||
PROOF_MALFORMED_KEY,
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* NIP-XX validator: runs the spec's validation steps over a [Bolt12ZapEvent] and
|
||||
* returns a [Bolt12ZapValidation]. A client MUST validate an event with this (and
|
||||
* deduplicate the results by [Bolt12ZapValidation.Valid.paymentHashHex]) before
|
||||
* counting a BOLT12 zap.
|
||||
*
|
||||
* The steps mirror the spec:
|
||||
* 1. kind 9736 and a valid event signature;
|
||||
* 2. zap-event structure (exactly one `description`/`p`, positive `amount`,
|
||||
* canonical `offer`, `lnp` `proof`, at most one `e`/`a` and never both,
|
||||
* `P` == `pubkey` when present);
|
||||
* 3. the embedded kind 9737 intent parses, is signed by the same pubkey, and is
|
||||
* itself well-formed;
|
||||
* 4. the zap and intent match on `content`, `p`, `amount`, `offer`, `e`, `a`, `k`;
|
||||
* 5. the raw BOLT12 offer parses;
|
||||
* 6. the `lnp` payer proof decodes and its crypto checks pass (see
|
||||
* [Bolt12ProofVerifier]);
|
||||
* 7. the proof binds to this zap: `invreq_payer_note == nostr:nipXX:<intent-id>`,
|
||||
* `invoice_amount == amount`, and the proof matches the offer.
|
||||
*/
|
||||
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)
|
||||
|
||||
// Step 2 — zap-event structure.
|
||||
if (event.tags.count(DescriptionTag::isTag) != 1) return invalid(Reason.NOT_EXACTLY_ONE_DESCRIPTION)
|
||||
if (event.description() == null) return invalid(Reason.MISSING_DESCRIPTION)
|
||||
|
||||
val recipientCount = event.tags.count { PTag.parseKey(it) != null }
|
||||
if (recipientCount != 1) return invalid(Reason.NOT_EXACTLY_ONE_RECIPIENT)
|
||||
val recipient = event.recipient() ?: return invalid(Reason.MISSING_RECIPIENT)
|
||||
|
||||
val amount = event.amount() ?: return invalid(Reason.MISSING_AMOUNT)
|
||||
if (amount <= 0) return invalid(Reason.NON_POSITIVE_AMOUNT)
|
||||
|
||||
val offer = event.offer() ?: return invalid(Reason.MISSING_OR_INVALID_OFFER)
|
||||
val proofStr = event.payerProof() ?: return invalid(Reason.MISSING_OR_INVALID_PROOF)
|
||||
|
||||
val cardinality = checkTargetCardinality(event.tags)
|
||||
if (cardinality != null) return invalid(cardinality)
|
||||
|
||||
val payer = event.payer()
|
||||
if (payer != null && payer != event.pubKey) return invalid(Reason.PAYER_TAG_MISMATCH)
|
||||
|
||||
// Step 3 — embedded intent.
|
||||
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)
|
||||
|
||||
val intentRecipientCount = intent.tags.count { PTag.parseKey(it) != null }
|
||||
if (intentRecipientCount != 1) return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
val intentAmount = intent.amount() ?: return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
if (intentAmount <= 0) return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
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.
|
||||
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)
|
||||
if (offer != intent.offer()) return invalid(Reason.OFFER_MISMATCH)
|
||||
if (event.zappedEvent() != intent.zappedEvent()) return invalid(Reason.TARGET_MISMATCH)
|
||||
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.
|
||||
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.
|
||||
val expectedNote = NIP_URI_PREFIX + intent.id
|
||||
if (proof.invreqPayerNote() != expectedNote) return invalid(Reason.PROOF_NOTE_MISMATCH)
|
||||
|
||||
val invoiceAmount = proof.invoiceAmount() ?: return invalid(Reason.MISSING_INVOICE_AMOUNT)
|
||||
if (invoiceAmount != amount) return invalid(Reason.PROOF_AMOUNT_MISMATCH)
|
||||
|
||||
offerBindingFailure(offerParsed, proof)?.let { return invalid(it) }
|
||||
|
||||
// Step 6 (crypto) — verify the payer proof signatures.
|
||||
val cryptoResult = proofVerifier.verify(proof)
|
||||
val cryptoVerified =
|
||||
when (cryptoResult) {
|
||||
is Bolt12ProofResult.Valid -> true
|
||||
is Bolt12ProofResult.Unsupported -> false
|
||||
is Bolt12ProofResult.Invalid -> return invalid(mapProofReason(cryptoResult.reason))
|
||||
}
|
||||
|
||||
val paymentHash = proof.invoicePaymentHash() ?: return invalid(Reason.PROOF_MISSING_REQUIRED_FIELDS)
|
||||
|
||||
return Bolt12ZapValidation.Valid(
|
||||
recipient = recipient,
|
||||
payer = payer,
|
||||
amountMillisats = amount,
|
||||
paymentHashHex = Hex.encode(paymentHash),
|
||||
zappedEventId = event.zappedEvent(),
|
||||
zappedAddress = event.zappedAddress(),
|
||||
zappedKind = event.zappedKind(),
|
||||
proofCryptoVerified = cryptoVerified,
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns the relevant reason when the `e`/`a` target cardinality is illegal, or null when fine. */
|
||||
private fun checkTargetCardinality(tags: Array<Array<String>>): Reason? {
|
||||
val eCount = tags.count(ETag::isTagged)
|
||||
val aCount = tags.count(ATag::isTagged)
|
||||
if (eCount > 1) return Reason.MULTIPLE_EVENT_TARGETS
|
||||
if (aCount > 1) return Reason.MULTIPLE_ADDRESS_TARGETS
|
||||
if (eCount >= 1 && aCount >= 1) return Reason.BOTH_EVENT_AND_ADDRESS_TARGET
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft binding of the proof to the offer without processing blinded paths:
|
||||
* when the offer publishes an `offer_issuer_id` and no blinded paths, the
|
||||
* invoice must be signed by that same node id, and any `offer_issuer_id`
|
||||
* copied into the proof must match. Offers that route through blinded paths
|
||||
* carry a per-path node id we can't check here, so they are left to the
|
||||
* signature verification alone.
|
||||
*/
|
||||
private fun offerBindingFailure(
|
||||
offer: Bolt12Offer,
|
||||
proof: Bolt12PayerProof,
|
||||
): Reason? {
|
||||
val issuerId = offer.issuerId() ?: return null
|
||||
|
||||
proof.offerIssuerId()?.let { proofIssuer ->
|
||||
if (!proofIssuer.contentEquals(issuerId)) return Reason.OFFER_PROOF_MISMATCH
|
||||
}
|
||||
|
||||
if (!offer.hasPaths()) {
|
||||
val nodeId = proof.invoiceNodeId() ?: return Reason.PROOF_MISSING_REQUIRED_FIELDS
|
||||
if (!nodeId.contentEquals(issuerId)) return Reason.OFFER_PROOF_MISMATCH
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun mapProofReason(reason: Bolt12ProofResult.Reason): Reason =
|
||||
when (reason) {
|
||||
Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS -> Reason.PROOF_MISSING_REQUIRED_FIELDS
|
||||
Bolt12ProofResult.Reason.PREIMAGE_MISMATCH -> Reason.PROOF_PREIMAGE_MISMATCH
|
||||
Bolt12ProofResult.Reason.INVOICE_SIGNATURE_INVALID -> Reason.PROOF_INVOICE_SIGNATURE_INVALID
|
||||
Bolt12ProofResult.Reason.PROOF_SIGNATURE_INVALID -> Reason.PROOF_SIGNATURE_INVALID
|
||||
Bolt12ProofResult.Reason.MALFORMED_KEY -> Reason.PROOF_MALFORMED_KEY
|
||||
// A compressed proof never reaches here (it returns Unsupported, not Invalid).
|
||||
Bolt12ProofResult.Reason.COMPRESSED_PROOF_UNSUPPORTED -> Reason.PROOF_MISSING_REQUIRED_FIELDS
|
||||
}
|
||||
|
||||
private fun invalid(reason: Reason) = Bolt12ZapValidation.Invalid(reason)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The NIP binds the Lightning payment to the signed intent through the
|
||||
* BOLT12 `invreq_payer_note`, which MUST equal this prefix followed by the
|
||||
* intent event id. The `nipXX` segment tracks the final NIP number.
|
||||
*/
|
||||
const val NIP_URI_PREFIX = "nostr:nipXX:"
|
||||
}
|
||||
}
|
||||
|
||||
private typealias Reason = Bolt12ZapValidation.Reason
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.zap
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-XX: BOLT12 Zaps — the **zap event** (kind 9736).
|
||||
*
|
||||
* A public, self-verifying proof that a BOLT12 payment was made to the author of
|
||||
* a profile, event, or addressable event. It carries:
|
||||
* - the serialized kind 9737 zap intent in its `description` tag,
|
||||
* - the recipient / amount / offer copied from that intent, and
|
||||
* - the settled BOLT12 `lnp` payer proof in its `proof` tag.
|
||||
*
|
||||
* This is the only event counted as a BOLT12 zap. Counting clients MUST run it
|
||||
* through the validator (structure + intent match + payer-proof binding) and
|
||||
* deduplicate by the proof's `invoice_payment_hash` before adding its amount.
|
||||
*/
|
||||
@Immutable
|
||||
class Bolt12ZapEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
EventHintProvider,
|
||||
AddressHintProvider,
|
||||
PubKeyHintProvider,
|
||||
SearchableEvent {
|
||||
// The public zap comment; it mirrors the embedded intent's content.
|
||||
override fun indexableContent() = content
|
||||
|
||||
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
|
||||
|
||||
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
|
||||
|
||||
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
|
||||
|
||||
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
|
||||
|
||||
/** The raw serialized zap intent JSON from the `description` tag. */
|
||||
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
|
||||
|
||||
/** The parsed & typed embedded zap intent, or null if it isn't a valid kind 9737 event. */
|
||||
val zapIntent: Bolt12ZapIntentEvent? by lazy { containedIntent() }
|
||||
|
||||
private fun containedIntent(): Bolt12ZapIntentEvent? =
|
||||
try {
|
||||
description()?.ifBlank { null }?.let { Event.fromJson(it) } as? Bolt12ZapIntentEvent
|
||||
} catch (e: Exception) {
|
||||
Log.w("Bolt12ZapEvent", "Failed to parse embedded zap intent in event $id", e)
|
||||
null
|
||||
}
|
||||
|
||||
/** The recipient pubkey (`p` tag). */
|
||||
fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The claimed amount in millisatoshis (`amount` tag). Verified against the payer proof. */
|
||||
fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse)
|
||||
|
||||
/** The canonical raw BOLT12 offer (`offer` tag). */
|
||||
fun offer() = tags.firstNotNullOfOrNull(OfferTag::parse)
|
||||
|
||||
/** The bech32 `lnp` payer proof (`proof` tag). */
|
||||
fun payerProof() = tags.firstNotNullOfOrNull(ProofTag::parse)
|
||||
|
||||
/** The payer pubkey (`P` tag), present only for publicly-attributed zaps. */
|
||||
fun payer() = tags.firstNotNullOfOrNull(PayerTag::parse)
|
||||
|
||||
/** The event being zapped, if any (`e` tag). */
|
||||
fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||
|
||||
/** The addressable event being zapped, if any (`a` tag). */
|
||||
fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
|
||||
|
||||
/** The kind of the target event, if declared (`k` tag). */
|
||||
fun zappedKind() = tags.firstNotNullOfOrNull(KindTag::parse)
|
||||
|
||||
/** True when neither `e` nor `a` is present — the zap targets the recipient's profile. */
|
||||
fun isProfileZap() = zappedEvent() == null && zappedAddress() == null
|
||||
|
||||
/** True when the zap is anonymous: it carries no `P` tag. */
|
||||
fun isAnonymous() = payer() == null
|
||||
|
||||
companion object {
|
||||
const val KIND = 9736
|
||||
|
||||
/**
|
||||
* Assemble a kind 9736 zap event from a **signed** zap intent and a settled
|
||||
* payer proof. The recipient, amount, offer, and target (`e`/`a`/`k`) tags
|
||||
* and the content are copied from the intent so they match, as the NIP
|
||||
* requires.
|
||||
*
|
||||
* @param payerPubKey when non-null, added as the uppercase `P` tag (a
|
||||
* publicly-attributed zap). It MUST be the same key that will sign this
|
||||
* event. Anonymous zaps pass null and sign with an ephemeral key.
|
||||
*/
|
||||
fun build(
|
||||
signedIntent: Bolt12ZapIntentEvent,
|
||||
payerProof: String,
|
||||
payerPubKey: HexKey? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Bolt12ZapEvent>.() -> Unit = {},
|
||||
): EventTemplate<Bolt12ZapEvent> {
|
||||
val recipient = requireNotNull(signedIntent.recipient()) { "zap intent is missing its p tag" }
|
||||
val amount = requireNotNull(signedIntent.amount()) { "zap intent is missing its amount tag" }
|
||||
val offer = requireNotNull(signedIntent.offer()) { "zap intent is missing its offer tag" }
|
||||
|
||||
return eventTemplate(KIND, signedIntent.content, createdAt) {
|
||||
description(signedIntent.toJson())
|
||||
recipient(recipient)
|
||||
amountInMillisats(amount)
|
||||
offer(offer)
|
||||
proof(payerProof)
|
||||
payerPubKey?.let { payer(it) }
|
||||
// Copy the target tags verbatim so the zap and intent match exactly.
|
||||
signedIntent.tags.firstOrNull { ETag.isTagged(it) }?.let { add(it) }
|
||||
signedIntent.tags.firstOrNull { ATag.isTagged(it) }?.let { add(it) }
|
||||
signedIntent.tags.firstOrNull { KindTag.match(it) }?.let { add(it) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.zap
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.description(serializedIntentEventJson: String) = addUnique(DescriptionTag.assemble(serializedIntentEventJson))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.proof(payerProof: String) = addUnique(ProofTag.assemble(payerProof))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.payer(payerPubKey: HexKey) = addUnique(PayerTag.assemble(payerPubKey))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.zappedEvent(tag: ETag) = addUnique(tag.toTagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.zappedAddress(tag: ATag) = addUnique(tag.toATagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind))
|
||||
@@ -319,6 +319,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
@@ -569,6 +571,8 @@ class EventFactory {
|
||||
NIP90EventPowDelegationRequestEvent.KIND -> NIP90EventPowDelegationRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NIP90EventPowDelegationResponseEvent.KIND -> NIP90EventPowDelegationResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
OnchainZapEvent.KIND -> OnchainZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
Bolt12ZapEvent.KIND -> Bolt12ZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
Bolt12ZapIntentEvent.KIND -> Bolt12ZapIntentEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12ZapEventTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4, 5, 6))
|
||||
private val proof = Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, byteArrayOf(7, 8, 9, 10))
|
||||
private val zapId = "ab".repeat(16)
|
||||
|
||||
private fun Array<Array<String>>.tag(name: String) = firstOrNull { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
@Test
|
||||
fun profileZapIntentCarriesTheRequiredTags() {
|
||||
val template = Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "excellent note")
|
||||
|
||||
assertEquals(Bolt12ZapIntentEvent.KIND, template.kind)
|
||||
assertEquals("excellent note", template.content)
|
||||
assertEquals(listOf("p", recipient), template.tags.tag("p")?.toList())
|
||||
assertEquals(listOf("amount", "21000"), template.tags.tag("amount")?.toList())
|
||||
assertEquals(listOf("offer", offer), template.tags.tag("offer")?.toList())
|
||||
assertEquals(listOf("zap_id", zapId), template.tags.tag("zap_id")?.toList())
|
||||
assertNull(template.tags.tag("e"))
|
||||
assertNull(template.tags.tag("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventTargetedIntentCarriesEAndKTags() {
|
||||
val zapped =
|
||||
Event("b".repeat(64), "c".repeat(64), 1_700_000_000L, 1, emptyArray(), "hi", "d".repeat(128))
|
||||
val template = Bolt12ZapIntentEvent.build(recipient, 21_000L, offer, zapId, EventHintBundle(zapped))
|
||||
|
||||
val eTag = template.tags.tag("e")
|
||||
assertTrue(eTag != null && eTag[1] == "b".repeat(64))
|
||||
assertEquals(listOf("k", "1"), template.tags.tag("k")?.toList())
|
||||
assertNull(template.tags.tag("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zapEventEmbedsIntentAndCopiesFields() =
|
||||
runTest {
|
||||
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "nice"))
|
||||
val template = Bolt12ZapEvent.build(intent, proof, payerPubKey = signer.pubKey)
|
||||
|
||||
assertEquals(Bolt12ZapEvent.KIND, template.kind)
|
||||
assertEquals("nice", template.content)
|
||||
assertEquals(listOf("description", intent.toJson()), template.tags.tag("description")?.toList())
|
||||
assertEquals(listOf("p", recipient), template.tags.tag("p")?.toList())
|
||||
assertEquals(listOf("amount", "21000"), template.tags.tag("amount")?.toList())
|
||||
assertEquals(listOf("offer", offer), template.tags.tag("offer")?.toList())
|
||||
assertEquals(listOf("proof", proof), template.tags.tag("proof")?.toList())
|
||||
assertEquals(listOf("P", signer.pubKey), template.tags.tag("P")?.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anonymousZapOmitsThePayerTag() =
|
||||
runTest {
|
||||
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 1_000L, offer, zapId))
|
||||
val template = Bolt12ZapEvent.build(intent, proof, payerPubKey = null)
|
||||
assertNull(template.tags.tag("P"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsedBackAccessorsAndFactoryTypesAreCorrect() =
|
||||
runTest {
|
||||
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "nice"))
|
||||
val zap = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = signer.pubKey))
|
||||
|
||||
assertEquals(recipient, zap.recipient())
|
||||
assertEquals(21_000L, zap.amount())
|
||||
assertEquals(offer, zap.offer())
|
||||
assertEquals(proof, zap.payerProof())
|
||||
assertEquals(signer.pubKey, zap.payer())
|
||||
assertTrue(zap.isProfileZap())
|
||||
assertEquals(intent.id, zap.zapIntent?.id)
|
||||
|
||||
// The factory (used by fromJson / relay ingestion) resolves the right types.
|
||||
assertTrue(Event.fromJson(zap.toJson()) is Bolt12ZapEvent)
|
||||
assertTrue(Event.fromJson(intent.toJson()) is Bolt12ZapIntentEvent)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12Bech32Test {
|
||||
@Test
|
||||
fun canonicalizeStripsContinuationsAndWhitespaceAndLowercases() {
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("LNO1ABC+ DEF"))
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("lno1abc+\n def"))
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("lno1abc + def"))
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize(" lno1abcdef "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsArbitraryBytesWithoutChecksumOrLengthCap() {
|
||||
// 200 bytes — far beyond the BIP-173 90-char cap that plain bech32 enforces.
|
||||
val bytes = ByteArray(200) { (it * 7 + 3).toByte() }
|
||||
val encoded = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, bytes)
|
||||
assertTrue(encoded.startsWith("lno1"))
|
||||
assertContentEquals(bytes, Bolt12Bech32.decodeToBytes(encoded, Bolt12Bech32.OFFER_HRP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recognizesOfferAndProofPrefixes() {
|
||||
val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4))
|
||||
val proof = Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, byteArrayOf(1, 2, 3, 4))
|
||||
|
||||
assertTrue(Bolt12Bech32.isOffer(offer))
|
||||
assertFalse(Bolt12Bech32.isPayerProof(offer))
|
||||
assertTrue(Bolt12Bech32.isPayerProof(proof))
|
||||
assertFalse(Bolt12Bech32.isOffer(proof))
|
||||
|
||||
// Non-BOLT12 or malformed strings are rejected.
|
||||
assertFalse(Bolt12Bech32.isOffer("not an offer"))
|
||||
assertFalse(Bolt12Bech32.isOffer("lno1"))
|
||||
assertFalse(Bolt12Bech32.isPayerProof("lnbc1abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodingWithMismatchedPrefixFails() {
|
||||
val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(9, 9, 9))
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
Bolt12Bech32.decodeToBytes(offer, Bolt12Bech32.PAYER_PROOF_HRP)
|
||||
}
|
||||
assertEquals(null, Bolt12Bech32.decodeToBytesOrNull("garbage", Bolt12Bech32.OFFER_HRP))
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12MerkleTest {
|
||||
@Test
|
||||
fun taggedHashMatchesTheDefinition() {
|
||||
val tag = "LnLeaf".encodeToByteArray()
|
||||
val msg = byteArrayOf(1, 2, 3, 4)
|
||||
val tagHash = sha256(tag)
|
||||
val expected = sha256(tagHash + tagHash + msg)
|
||||
assertContentEquals(expected, Bolt12Merkle.taggedHash(tag, msg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rootHashIsDeterministicAndDependsOnEveryRecord() {
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(22, ByteArray(33) { 2 }),
|
||||
TlvRecord(170, Bolt12Values.tu64ToBytes(21_000)),
|
||||
TlvRecord(176, ByteArray(33) { 3 }),
|
||||
)
|
||||
val root = Bolt12Merkle.rootHash(records)
|
||||
assertEquals(32, root.size)
|
||||
assertContentEquals(root, Bolt12Merkle.rootHash(records))
|
||||
|
||||
val altered =
|
||||
records.toMutableList().also {
|
||||
it[1] = TlvRecord(170, Bolt12Values.tu64ToBytes(21_001))
|
||||
}
|
||||
assertFalse(root.contentEquals(Bolt12Merkle.rootHash(altered)))
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end check of the tagged-hash → merkle-root → signature-digest → BIP-340
|
||||
* pipeline: a signature made over the digest of a record set verifies, and any
|
||||
* tampering with the records (which changes the root) makes it fail. This
|
||||
* validates the composition; byte-exact interop with CLN/LDK proofs additionally
|
||||
* needs the lightning/bolts#1346 test vectors.
|
||||
*/
|
||||
@Test
|
||||
fun signatureOverTheMerkleRootVerifiesAndTamperingBreaksIt() {
|
||||
val key = KeyPair()
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(88, ByteArray(33) { 2 }),
|
||||
TlvRecord(168, ByteArray(32) { it.toByte() }),
|
||||
TlvRecord(176, ByteArray(33) { 3 }),
|
||||
)
|
||||
|
||||
val root = Bolt12Merkle.rootHash(records)
|
||||
val digest = Bolt12Merkle.signatureDigest("invoice", "signature", root)
|
||||
val sig = Nip01Crypto.sign(digest, key.privKey!!)
|
||||
|
||||
assertTrue(Nip01Crypto.verify(sig, digest, key.pubKey))
|
||||
|
||||
val tamperedRoot = Bolt12Merkle.rootHash(records.dropLast(1))
|
||||
val tamperedDigest = Bolt12Merkle.signatureDigest("invoice", "signature", tamperedRoot)
|
||||
assertFalse(Nip01Crypto.verify(sig, tamperedDigest, key.pubKey))
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class TlvTest {
|
||||
private fun hex(bytes: ByteArray) = Hex.encode(bytes)
|
||||
|
||||
@Test
|
||||
fun bigSizeEncodesTheFourFormsAtTheirBoundaries() {
|
||||
assertEquals("00", hex(BigSize.encode(0)))
|
||||
assertEquals("fc", hex(BigSize.encode(0xfc)))
|
||||
assertEquals("fd00fd", hex(BigSize.encode(0xfd)))
|
||||
assertEquals("fdffff", hex(BigSize.encode(0xffff)))
|
||||
assertEquals("fe00010000", hex(BigSize.encode(0x10000)))
|
||||
assertEquals("feffffffff", hex(BigSize.encode(0xffffffffL)))
|
||||
assertEquals("ff0000000100000000", hex(BigSize.encode(0x100000000L)))
|
||||
|
||||
assertEquals(1, BigSize.encodedSize(0xfc))
|
||||
assertEquals(3, BigSize.encodedSize(0xfd))
|
||||
assertEquals(5, BigSize.encodedSize(0x10000))
|
||||
assertEquals(9, BigSize.encodedSize(0x100000000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bigSizeRoundTripsThroughTheReader() {
|
||||
for (v in listOf(0L, 1L, 0xfcL, 0xfdL, 0x1234L, 0xffffL, 0x10000L, 0xdeadbeefL, 9736L, 1001L)) {
|
||||
val reader = TlvReader(BigSize.encode(v))
|
||||
assertEquals(v, reader.readBigSize())
|
||||
assertEquals(0, reader.remaining())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tu64StripsAndRestoresLeadingZeroes() {
|
||||
assertEquals(0, Bolt12Values.tu64ToBytes(0).size)
|
||||
assertContentEquals(byteArrayOf(0x03, 0xe8.toByte()), Bolt12Values.tu64ToBytes(1000))
|
||||
for (v in listOf(0L, 1L, 21_000L, 0xffffffL, Long.MAX_VALUE)) {
|
||||
assertEquals(v, Bolt12Values.tu64(Bolt12Values.tu64ToBytes(v)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tlvStreamRoundTrips() {
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(8, Bolt12Values.tu64ToBytes(21_000)),
|
||||
TlvRecord(22, ByteArray(33) { it.toByte() }),
|
||||
TlvRecord(1001, ByteArray(32) { (it + 1).toByte() }),
|
||||
)
|
||||
val stream = TlvStream(records)
|
||||
val decoded = TlvStream.read(stream.encode())
|
||||
|
||||
assertEquals(records.map { it.type }, decoded.records.map { it.type })
|
||||
assertEquals(21_000L, decoded.tu64(8))
|
||||
assertContentEquals(records[1].value, decoded.value(22))
|
||||
assertNull(decoded.get(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tlvStreamRejectsNonAscendingTypes() {
|
||||
val outOfOrder = TlvRecord(22, byteArrayOf(1)).encoded + TlvRecord(8, byteArrayOf(2)).encoded
|
||||
assertFailsWith<IllegalArgumentException> { TlvStream.read(outOfOrder) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatureElementRangeIsRecognized() {
|
||||
assertEquals(false, TlvRecord(176, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(true, TlvRecord(240, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(true, TlvRecord(241, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(true, TlvRecord(1000, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(false, TlvRecord(1001, byteArrayOf()).isSignatureElement())
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Merkle
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Builds matched BOLT12 offers and payer proofs for tests, self-signing them with
|
||||
* Quartz's own secp256k1 so the whole merkle + BIP-340 path is exercised. This is
|
||||
* a self-consistent construction, not a CLN/LDK interop vector — see
|
||||
* [Bolt12ProofVerifier].
|
||||
*/
|
||||
object Bolt12ProofFixture {
|
||||
/** A 33-byte compressed point (even parity) wrapping an x-only key. */
|
||||
private fun point(xOnly: ByteArray) = byteArrayOf(0x02) + xOnly
|
||||
|
||||
fun buildOffer(
|
||||
nodeKey: KeyPair,
|
||||
amountMillisats: Long,
|
||||
): String {
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats)),
|
||||
TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "zap".encodeToByteArray()),
|
||||
TlvRecord(Bolt12Offer.TYPE_ISSUER_ID, point(nodeKey.pubKey)),
|
||||
)
|
||||
return Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, TlvStream(records).encode())
|
||||
}
|
||||
|
||||
fun buildProof(
|
||||
nodeKey: KeyPair,
|
||||
payerLightningKey: KeyPair,
|
||||
preimage: ByteArray,
|
||||
amountMillisats: Long,
|
||||
payerNote: String,
|
||||
compressed: Boolean = false,
|
||||
breakProofSignature: Boolean = false,
|
||||
): String {
|
||||
val nodePoint = point(nodeKey.pubKey)
|
||||
val payerPoint = point(payerLightningKey.pubKey)
|
||||
val paymentHash = sha256(preimage)
|
||||
|
||||
// The invoice's signed records (types < 240), in ascending order.
|
||||
val invoiceRecords =
|
||||
listOf(
|
||||
TlvRecord(Bolt12PayerProof.TYPE_OFFER_ISSUER_ID, nodePoint),
|
||||
TlvRecord(Bolt12PayerProof.TYPE_INVREQ_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats)),
|
||||
TlvRecord(Bolt12PayerProof.TYPE_INVREQ_PAYER_ID, payerPoint),
|
||||
TlvRecord(Bolt12PayerProof.TYPE_INVREQ_PAYER_NOTE, payerNote.encodeToByteArray()),
|
||||
TlvRecord(Bolt12PayerProof.TYPE_INVOICE_PAYMENT_HASH, paymentHash),
|
||||
TlvRecord(Bolt12PayerProof.TYPE_INVOICE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats)),
|
||||
TlvRecord(Bolt12PayerProof.TYPE_INVOICE_NODE_ID, nodePoint),
|
||||
)
|
||||
val invoiceRoot = Bolt12Merkle.rootHash(invoiceRecords)
|
||||
val invoiceSig =
|
||||
Nip01Crypto.sign(
|
||||
Bolt12Merkle.signatureDigest(Bolt12ProofVerifier.INVOICE_MESSAGE, Bolt12ProofVerifier.SIGNATURE_FIELD, invoiceRoot),
|
||||
nodeKey.privKey!!,
|
||||
)
|
||||
|
||||
val preimageRecord = TlvRecord(Bolt12PayerProof.TYPE_PROOF_PREIMAGE, preimage)
|
||||
|
||||
// The payer proof signs everything but the 240..1000 signature elements.
|
||||
val proofSignable = invoiceRecords + preimageRecord
|
||||
val proofRoot = Bolt12Merkle.rootHash(proofSignable)
|
||||
val proofSigningKey = if (breakProofSignature) nodeKey else payerLightningKey
|
||||
val proofSig =
|
||||
Nip01Crypto.sign(
|
||||
Bolt12Merkle.signatureDigest(Bolt12ProofVerifier.PROOF_MESSAGE, Bolt12ProofVerifier.SIGNATURE_FIELD, proofRoot),
|
||||
proofSigningKey.privKey!!,
|
||||
)
|
||||
|
||||
val records =
|
||||
buildList {
|
||||
addAll(invoiceRecords)
|
||||
add(TlvRecord(Bolt12PayerProof.TYPE_SIGNATURE, invoiceSig))
|
||||
add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_SIGNATURE, proofSig))
|
||||
add(preimageRecord)
|
||||
if (compressed) {
|
||||
// A non-empty proof_missing_hashes marks the proof as compressed.
|
||||
add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_MISSING_HASHES, ByteArray(32) { 9 }))
|
||||
}
|
||||
}
|
||||
return Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, TlvStream(records).encode())
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.quartz.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12ZapValidatorTest {
|
||||
private val validator = Bolt12ZapValidator()
|
||||
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val amount = 21_000L
|
||||
private val zapId = "ab".repeat(16)
|
||||
|
||||
private suspend fun signedIntent(
|
||||
signer: NostrSigner,
|
||||
offer: String,
|
||||
) = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, amount, offer, zapId, comment = "nice"))
|
||||
|
||||
private suspend fun signedZap(
|
||||
signer: NostrSigner,
|
||||
intent: Bolt12ZapIntentEvent,
|
||||
proof: String,
|
||||
attributed: Boolean = true,
|
||||
) = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = if (attributed) signer.pubKey else null))
|
||||
|
||||
@Test
|
||||
fun acceptsAWellFormedFullyVerifiedZap() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val payerLnKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 7).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, payerLnKey, preimage, amount, note)
|
||||
val zap = signedZap(signer, intent, proof)
|
||||
|
||||
val result = validator.validate(zap)
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(result.proofCryptoVerified)
|
||||
assertEquals(recipient, result.recipient)
|
||||
assertEquals(signer.pubKey, result.payer)
|
||||
assertEquals(amount, result.amountMillisats)
|
||||
assertEquals(Hex.encode(sha256(preimage)), result.paymentHashHex)
|
||||
assertTrue(result.isProfileZap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsButFlagsACompressedProofAsUnverified() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 1).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, compressed = true)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(!result.proofCryptoVerified, "a compressed proof is bound but not yet crypto-verified")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAProofBoundToTheWrongIntent() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 2).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val wrongNote = Bolt12ZapValidator.NIP_URI_PREFIX + "f".repeat(64)
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, wrongNote)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_NOTE_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenTheProofAmountDiffersFromTheZapAmount() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 3).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 + 1, note)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_AMOUNT_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAnInvalidPayerProofSignature() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 4).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, breakProofSignature = true)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_SIGNATURE_INVALID), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenTheEmbeddedIntentWasSignedByAnotherKey() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val otherSigner = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 5).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
// Intent signed by someone other than the zap author.
|
||||
val intent = signedIntent(otherSigner, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.INTENT_PUBKEY_MISMATCH), result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user