diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt new file mode 100644 index 0000000000..f58f2e73fc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt @@ -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) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt new file mode 100644 index 0000000000..70c1569822 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt @@ -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): 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(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((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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt new file mode 100644 index 0000000000..535a6a2d21 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt @@ -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) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt new file mode 100644 index 0000000000..479c12cd5e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt @@ -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 = 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 = 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) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt new file mode 100644 index 0000000000..1628d9dd13 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt @@ -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, +) { + 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() + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt new file mode 100644 index 0000000000..a829e6ca6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt @@ -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, + 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), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt new file mode 100644 index 0000000000..ab5962dec4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt @@ -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:`), 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>, + 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, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> 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.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + recipient(recipientPubKey) + amountInMillisats(amountInMillisats) + offer(offer) + zapId(zapId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ff8109009c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt @@ -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.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null)) + +fun TagArrayBuilder.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats)) + +fun TagArrayBuilder.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer)) + +fun TagArrayBuilder.zapId(zapId: String) = addUnique(ZapIdTag.assemble(zapId)) + +fun TagArrayBuilder.zappedEvent(tag: ETag) = addUnique(tag.toTagArray()) + +fun TagArrayBuilder.zappedAddress(tag: ATag) = addUnique(tag.toATagArray()) + +fun TagArrayBuilder.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt new file mode 100644 index 0000000000..f3d762a207 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt new file mode 100644 index 0000000000..611dfac3b2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt new file mode 100644 index 0000000000..19f24595e5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isOffer(tag[1]) + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt new file mode 100644 index 0000000000..22749bb829 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt new file mode 100644 index 0000000000..cc0d88d22c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isPayerProof(tag[1]) + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt new file mode 100644 index 0000000000..f556908a16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && isValid(tag[1]) + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt new file mode 100644 index 0000000000..8002ce5bc5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt @@ -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, + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt new file mode 100644 index 0000000000..d73c35ebea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt new file mode 100644 index 0000000000..757a65a03e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt @@ -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, + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt new file mode 100644 index 0000000000..9ca2fc2dde --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt @@ -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:`, + * `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>): 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt new file mode 100644 index 0000000000..50b4874e13 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt @@ -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>, + 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.() -> Unit = {}, + ): EventTemplate { + 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() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d442fec654 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt @@ -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.description(serializedIntentEventJson: String) = addUnique(DescriptionTag.assemble(serializedIntentEventJson)) + +fun TagArrayBuilder.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null)) + +fun TagArrayBuilder.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats)) + +fun TagArrayBuilder.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer)) + +fun TagArrayBuilder.proof(payerProof: String) = addUnique(ProofTag.assemble(payerProof)) + +fun TagArrayBuilder.payer(payerPubKey: HexKey) = addUnique(PayerTag.assemble(payerPubKey)) + +fun TagArrayBuilder.zappedEvent(tag: ETag) = addUnique(tag.toTagArray()) + +fun TagArrayBuilder.zappedAddress(tag: ATag) = addUnique(tag.toATagArray()) + +fun TagArrayBuilder.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index ec26f341a9..ce2501c884 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt new file mode 100644 index 0000000000..c66cef592d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt @@ -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>.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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt new file mode 100644 index 0000000000..11792873f3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt @@ -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 { + Bolt12Bech32.decodeToBytes(offer, Bolt12Bech32.PAYER_PROOF_HRP) + } + assertEquals(null, Bolt12Bech32.decodeToBytesOrNull("garbage", Bolt12Bech32.OFFER_HRP)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt new file mode 100644 index 0000000000..c19f37e10e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt @@ -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)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt new file mode 100644 index 0000000000..8ef07e889a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt @@ -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 { 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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt new file mode 100644 index 0000000000..f9d338c0c9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt @@ -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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt new file mode 100644 index 0000000000..28f9b088e6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt @@ -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(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(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) + } +}