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:
Claude
2026-07-23 19:15:30 +00:00
parent b2fa1d3b92
commit 2ec1744c92
27 changed files with 2603 additions and 0 deletions
@@ -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)
}
@@ -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
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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),
)
}
}
@@ -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()
}
}
}
@@ -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))
@@ -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())
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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,
}
}
@@ -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"
}
}
@@ -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,
}
}
@@ -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
@@ -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()
}
}
}
}
@@ -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)
@@ -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)
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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())
}
}
@@ -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())
}
}
@@ -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)
}
}