From 2ec1744c9282e9eb7f7f9b289526e7bc0eff1eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 19:15:30 +0000 Subject: [PATCH 01/23] feat(quartz): add BOLT12 zaps (NIP-2421) protocol layer Implements the quartz-side of the proposed "BOLT12 Zaps" NIP (nostr-protocol/nips#2421): public, self-verifying zap events that prove a BOLT12 payment without an LNURL server or recipient-operated receipt publisher. Events (nip-88 style templates/tags/builders): - Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737) - shared tags: amount (msats), offer, proof, P (payer), zap_id, description - registered both kinds in EventFactory BOLT12 decoding (new, no existing KMP library): - Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum, no-length-limit bech32 for lno1 offers and lnp1 payer proofs - Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers - Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346) - Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest Validation: - Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match, payer-proof binding: invreq_payer_note == nostr:nipXX:, invoice_amount == amount) and returns a typed result with the payment-hash dedup key - Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported as unverified pending the (still-draft) lightning/bolts#1346 test vectors - Bolt12ZapBuilder assembles+signs the intent and the final zap Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag structure + factory typing, and validator accept/reject paths (self-signed BOLT12 fixtures exercise the full merkle + schnorr path). Note: this is the receive/verify + assembly layer only. Origination is blocked on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing lnp proofs; no Amethyst payment rail returns one today. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt | 120 ++++++++++ .../nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt | 129 ++++++++++ .../nipXXBolt12Zaps/bolt12/Bolt12Offer.kt | 65 +++++ .../bolt12/Bolt12PayerProof.kt | 131 +++++++++++ .../quartz/nipXXBolt12Zaps/bolt12/Tlv.kt | 222 ++++++++++++++++++ .../builder/Bolt12ZapBuilder.kt | 104 ++++++++ .../intent/Bolt12ZapIntentEvent.kt | 149 ++++++++++++ .../intent/TagArrayBuilderExt.kt | 45 ++++ .../quartz/nipXXBolt12Zaps/tags/AmountTag.kt | 46 ++++ .../nipXXBolt12Zaps/tags/DescriptionTag.kt | 49 ++++ .../quartz/nipXXBolt12Zaps/tags/OfferTag.kt | 50 ++++ .../quartz/nipXXBolt12Zaps/tags/PayerTag.kt | 49 ++++ .../quartz/nipXXBolt12Zaps/tags/ProofTag.kt | 49 ++++ .../quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt | 53 +++++ .../verify/Bolt12ProofResult.kt | 73 ++++++ .../verify/Bolt12ProofVerifier.kt | 105 +++++++++ .../verify/Bolt12ZapValidation.kt | 101 ++++++++ .../verify/Bolt12ZapValidator.kt | 199 ++++++++++++++++ .../nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt | 170 ++++++++++++++ .../nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt | 51 ++++ .../quartz/utils/EventFactory.kt | 4 + .../nipXXBolt12Zaps/Bolt12ZapEventTest.kt | 113 +++++++++ .../bolt12/Bolt12Bech32Test.kt | 72 ++++++ .../bolt12/Bolt12MerkleTest.kt | 88 +++++++ .../quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt | 98 ++++++++ .../verify/Bolt12ProofFixture.kt | 113 +++++++++ .../verify/Bolt12ZapValidatorTest.kt | 155 ++++++++++++ 27 files changed, 2603 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Offer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilder.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/Bolt12ZapIntentEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/intent/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/AmountTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/OfferTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/PayerTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ProofTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/tags/ZapIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/Bolt12ZapEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/zap/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/Bolt12ZapEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32Test.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12MerkleTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt 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) + } +} From 33fb3a54b295ef5d797bed5cfc613ee9e87ef103 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 20:08:46 +0000 Subject: [PATCH 02/23] feat: account for and display BOLT12 zaps everywhere lightning zaps are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a NIP-57 lightning zap is counted or shown. Sending is intentionally left for later. Modeled on the lightning-zap scheme (synchronous, the proof carries the amount, counted the moment it validates) rather than the onchain scheme (async chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is a self-contained synchronous check, so no resolver/backend is needed. Model (commons): - Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash (the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts; wired into clearChildLinks, moveAllReferencesTo, removeNote, hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family. Ingestion (LocalCache): - consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and live-activity channel routing branches; dispatch case. Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND (notifications, replies/reactions to notes & addresses, profile received-zaps, live-activity goal + messages, nest room + collectors, notification dispatcher, shared NotificationKinds, app-functions). Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState (both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author resolution, muted-thread + own-event gates), NotificationKinds own-event exception, ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators (RoomZapsState, LiveStreamTopZappers, NestViewModel). UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the reactions row (payer avatars + amounts, unverified/compressed proofs dimmed); reaction-row counter gate; KindNames / KindDisplayName entries. Note: validated BOLT12 zaps are counted immediately; a compressed proof whose signatures aren't yet verifiable (pending lightning/bolts#1346 merkle reconstruction) is stored with cryptoVerified=false and dimmed in the gallery. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/model/LocalCache.kt | 77 +++++++++ .../notifications/NotificationDispatcher.kt | 2 + .../FilterNotificationsToPubkey.kt | 3 + .../FilterRepliesAndReactionsToAddresses.kt | 2 + .../FilterRepliesAndReactionsToNotes.kt | 2 + .../amethyst/ui/note/Bolt12ZapGallery.kt | 151 ++++++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/ReactionsRow.kt | 4 +- .../amethyst/ui/note/types/Bolt12ZapEvent.kt | 100 ++++++++++++ .../FilterGoalForLiveActivity.kt | 3 +- .../FilterMessagesToLiveStream.kt | 2 + .../datasource/NestRoomFilterAssembler.kt | 2 + .../room/lifecycle/NestRoomEventCollectors.kt | 4 +- .../notifications/NotificationSummaryState.kt | 24 +++ .../dal/NotificationFeedFilter.kt | 12 +- .../FilterUserProfileZapReceived.kt | 3 +- .../zaps/dal/UserProfileZapsViewModel.kt | 14 +- .../screen/loggedIn/relays/KindDisplayName.kt | 2 + .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../appfunctions/AmethystAppFunctions.kt | 3 +- .../amethyst/commons/model/Bolt12ZapEntry.kt | 51 ++++++ .../amethyst/commons/model/Note.kt | 104 +++++++++++- .../amethyst/commons/model/ThreadAssembler.kt | 3 +- .../notifications/NotificationKinds.kt | 5 +- .../LiveStreamTopZappersViewModel.kt | 25 ++- .../commons/viewmodels/RoomZapsState.kt | 28 +++- .../commons/viewmodels/NestViewModel.kt | 11 ++ .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + 28 files changed, 627 insertions(+), 22 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3138eee1d8..fa5381d667 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -318,6 +318,9 @@ 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.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.utils.DualCase @@ -385,6 +388,13 @@ object LocalCache : ILocalCache, ICacheProvider { */ val onchainZapResolver = OnchainZapResolver(this) + /** + * NIP-XX BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification + * is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)` + * validates inline and needs no async resolver. + */ + val bolt12ZapValidator = Bolt12ZapValidator() + /** * Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to * validate NIP-57 Appendix F. `null` skips the receipt-signer check (the @@ -1181,6 +1191,17 @@ object LocalCache : ILocalCache, ICacheProvider { } } + is Bolt12ZapEvent -> { + // NIP-XX BOLT12 zaps target an event (e), an addressable event (a), + // or just the recipient profile (p) — same shape as onchain zaps. + buildList { + event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } } + event.zappedAddress()?.let { coord -> + Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) } + } + } + } + is NutzapEvent -> { // The zapped event is carried in the kind:9321's `e` tags // (and optionally an `a` tag for addressables). Whichever @@ -2380,6 +2401,41 @@ object LocalCache : ILocalCache, ICacheProvider { return !alreadyLoaded } + fun consume( + event: Bolt12ZapEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + + // Already processed — still route it into any live-activity channel it references. + if (note.event != null) { + attachZapToLiveActivityChannel(event, note, relay) + return false + } + + if (!(wasVerified || justVerify(event))) return false + + // NIP-XX validation is fully synchronous: zap-event structure, the embedded + // kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed + // validation drops the zap entirely — it never contributes to a zap total. + val validation = bolt12ZapValidator.validate(event) + if (validation !is Bolt12ZapValidation.Valid) { + Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" } + return false + } + + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) + repliesTo.forEach { + it.addBolt12Zap(note, validation.paymentHashHex, validation.amountMillisats, validation.proofCryptoVerified) + } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) + return true + } + /** * Consume a NIP-61 nutzap (kind 9321). Resolves the e-tagged target * note(s), parses the proof amounts once, and attaches a `NutzapEntry` @@ -2435,6 +2491,23 @@ object LocalCache : ILocalCache, ICacheProvider { } } + private fun attachZapToLiveActivityChannel( + event: Bolt12ZapEvent, + note: Note, + relay: NormalizedRelayUrl?, + ) { + // Only surface zaps whose recipient is the live activity host. + val host = event.recipient() ?: return + event.tags + .asSequence() + .mapNotNull(ATag::parseAddress) + .filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex == host } + .distinct() + .forEach { address -> + getOrCreateLiveChannel(address).addNote(note, relay) + } + } + fun consume( event: LnZapRequestEvent, relay: NormalizedRelayUrl?, @@ -4301,6 +4374,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is Bolt12ZapEvent -> { + consume(event, relay, wasVerified) + } + is NIP90StatusEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 1f583ded83..47dd5258d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException @@ -102,6 +103,7 @@ class NotificationDispatcher( PrivateDmEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, ReactionEvent.KIND, TextNoteEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 579eb13aa5..1842233e05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies @@ -74,6 +75,7 @@ val GroupNotificationKinds = RepostEvent.KIND, GenericRepostEvent.KIND, LnZapEvent.KIND, + Bolt12ZapEvent.KIND, ReportEvent.KIND, ) @@ -85,6 +87,7 @@ val SummaryKinds = GenericRepostEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, ) val NotificationsPerKeyKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 9015be8e3f..8041f4f964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsToAddressesKinds1 = @@ -47,6 +48,7 @@ val RepliesAndReactionsToAddressesKinds1 = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, + Bolt12ZapEvent.KIND, ZapPollEvent.KIND, CommentEvent.KIND, AttestationEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index f9a5052356..7d620418f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = @@ -55,6 +56,7 @@ val RepliesAndReactionsKinds = ReportEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt new file mode 100644 index 0000000000..5f58edc3df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import com.vitorpamplona.amethyst.commons.model.Bolt12ZapEntry +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier +import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +private fun onBolt12ZapEntryClick( + entry: Bolt12ZapEntry, + nav: INav, +) { + entry.source.author?.let { nav.nav(routeFor(it)) } +} + +/** + * Reactions-row gallery of the payers who BOLT12-zapped this note. Mirrors the + * onchain gallery but simpler: BOLT12 zaps are validated synchronously at consume + * time, so there is no async re-verification and no pending state — every entry + * here is already counted. `Note.addBolt12Zap` invalidates `flowSet.zaps`, so this + * refreshes on arrival; memoizing on the `bolt12Zaps` map reference keeps a busy + * lightning thread from churning it. + */ +@Composable +internal fun WatchBolt12ZapsAndRenderGallery( + baseNote: Note, + nav: INav, + accountViewModel: AccountViewModel, +) { + val zapsState by observeNoteZaps(baseNote, accountViewModel) + val bolt12ZapsMap = zapsState?.note?.bolt12Zaps + val entries = + remember(bolt12ZapsMap) { + bolt12ZapsMap?.values?.toImmutableList() ?: persistentListOf() + } + + if (entries.isNotEmpty()) { + RenderBolt12ZapGallery(entries, nav, accountViewModel) + } +} + +@Composable +private fun RenderBolt12ZapGallery( + entries: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Row(Modifier.fillMaxWidth()) { + Box(modifier = WidthAuthorPictureModifier) { + ZappedIcon( + modifier = Modifier.size(Size25dp).align(Alignment.TopEnd), + ) + } + + Bolt12ZapAuthorGallery(entries, nav, accountViewModel) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Bolt12ZapAuthorGallery( + entries: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Column(modifier = StdStartPadding) { + FlowRow { + entries.forEach { entry -> + Bolt12ZapEntryRow(entry, nav, accountViewModel) + } + } + } +} + +@Composable +private fun Bolt12ZapEntryRow( + entry: Bolt12ZapEntry, + nav: INav, + accountViewModel: AccountViewModel, +) { + val user = entry.source.author + + // The amount is validated (checked against the proof's invoice_amount), so it + // is safe to show for any sender. A not-yet-crypto-verified (compressed) proof + // still dims its avatar so the viewer can tell it apart from a fully-verified one. + val avatarAlpha = if (entry.cryptoVerified) 1f else 0.6f + val amountText = + remember(entry.amountMillisats) { + val sats = entry.amountMillisats / 1000 + if (sats > 0L) showAmount(BigDecimal.valueOf(sats)) else "" + } + + Box( + modifier = Size35Modifier.clickable { onBolt12ZapEntryClick(entry, nav) }, + contentAlignment = Alignment.BottomCenter, + ) { + Box(modifier = Modifier.alpha(avatarAlpha)) { + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + user, + accountViewModel, + ) + } + + if (amountText.isNotEmpty()) { + CrossfadeToDisplayAmount(amountText) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 854554860e..db3d6bf147 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -126,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex +import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent @@ -351,6 +352,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -1091,6 +1093,10 @@ private fun RenderNoteRow( RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } + is Bolt12ZapEvent -> { + RenderBolt12Zap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + is LiveActivitiesClipEvent -> { RenderChatClip(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 188994e900..1272b021c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -582,6 +582,7 @@ private fun ReactionDetailGallery( WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) + WatchBolt12ZapsAndRenderGallery(baseNote, nav, accountViewModel) WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) if (relays.isNotEmpty()) { @@ -1509,7 +1510,8 @@ fun ObserveZapIconState( zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true || zapsState?.note?.nutzaps?.isNotEmpty() == true || - zapsState?.note?.onchainZaps?.isNotEmpty() == true + zapsState?.note?.onchainZaps?.isNotEmpty() == true || + zapsState?.note?.bolt12Zaps?.isNotEmpty() == true val wasZapped = if (hasZapData) { accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt new file mode 100644 index 0000000000..364836cc60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.note.ActivityAmountRow +import com.vitorpamplona.amethyst.commons.ui.note.ActivityBadge +import com.vitorpamplona.amethyst.commons.ui.note.ActivityCardFrame +import com.vitorpamplona.amethyst.commons.ui.note.ActivityHeaderRow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import java.text.NumberFormat + +/** + * Standalone card for a NIP-XX BOLT12 zap (kind 9736), styled like the NIP-57 + * lightning-zap card but labeled BOLT12. The sender is the `P` payer tag (or the + * event pubkey when anonymous); the amount comes straight off the validated + * `amount` tag — no LNURL provider or private-zap decryption is involved. + */ +@Composable +fun RenderBolt12Zap( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? Bolt12ZapEvent ?: return + + val senderKey = event.payer() + val recipientKey = event.recipient() + val amountSats = event.amount()?.div(1000) + val comment = event.content.takeIf { it.isNotBlank() } + + val orange = MaterialTheme.colorScheme.bitcoinColor + + ActivityCardFrame(orange) { cardBackground -> + ActivityHeaderRow( + tint = orange, + pillLabel = "BOLT12", + badge = { + ActivityBadge(orange) { + ZapIcon(Modifier.size(18.dp), Color.White) + } + }, + senderAvatar = { + if (senderKey != null) { + UserPicture(senderKey, Size25dp, Modifier, accountViewModel, nav) + } else { + // Anonymous zap — no attributable payer. + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + recipientKey?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, + ) + + RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + + amountSats?.let { ActivityAmountRow(NumberFormat.getNumberInstance().format(it), orange) } + + comment?.let { + CrossfadeToDisplayComment(it, cardBackground, nav, accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 7880c23d9e..16ce66b533 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts @@ -56,7 +57,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = Filter( - kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 93c9984855..12a8396a9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -46,6 +47,7 @@ fun filterMessagesToLiveActivities( LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 74432b8466..e14ee14a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Per-room state for every wire subscription scoped to a single @@ -96,6 +97,7 @@ class NestRoomFilterSubAssembler( MeetingRoomPresenceEvent.KIND, ReactionEvent.KIND, LnZapEvent.KIND, + Bolt12ZapEvent.KIND, ), tags = mapOf("a" to listOf(key.note.idHex)), since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index 4ac548b7a4..c1c2c8ef9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -168,7 +169,7 @@ private fun ZapsCollector( LaunchedEffect(viewModel, roomATag) { val filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("a" to listOf(roomATag)), ) LocalCache.observeNotes(filter).collect { notes -> @@ -176,6 +177,7 @@ private fun ZapsCollector( notes.forEach { note -> viewModel.onChatEvent(note) (note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } + (note.event as? Bolt12ZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 7c4cbbe909..6ab60f051e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -136,6 +137,17 @@ class NotificationSummaryState( } } + noteEvent is Bolt12ZapEvent -> { + if (noteEvent.isTaggedUser(currentUser)) { + val amount = noteEvent.amount() + if (amount != null) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + BigDecimal.valueOf(amount / 1000) + takenIntoAccount.add(noteEvent.id) + } + } + } + noteEvent is BaseThreadedEvent && noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser -> { @@ -222,6 +234,18 @@ class NotificationSummaryState( } } + noteEvent is Bolt12ZapEvent -> { + if (noteEvent.isTaggedUser(currentUser)) { + val amount = noteEvent.amount() + if (amount != null) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + BigDecimal.valueOf(amount / 1000) + takenIntoAccount.add(noteEvent.id) + hasNewElements = true + } + } + } + noteEvent is BaseThreadedEvent && noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 7f2e2ca265..f49c2c198c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -163,6 +164,7 @@ class NotificationFeedFilter( LnZapEvent.KIND, NutzapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, PollEvent.KIND, @@ -264,7 +266,8 @@ class NotificationFeedFilter( // on the user marks it as theirs. val targetsZapReceipt = event.hasScopeKind(LnZapEvent.KIND.toString()) || - note.replyTo?.any { it.event is LnZapEvent } == true + event.hasScopeKind(Bolt12ZapEvent.KIND.toString()) || + note.replyTo?.any { it.event is LnZapEvent || it.event is Bolt12ZapEvent } == true if (targetsZapReceipt && event.isTaggedUser(authorHex)) { return true @@ -424,6 +427,9 @@ class NotificationFeedFilter( } else { noteEvent.pubKey } + } else if (noteEvent is Bolt12ZapEvent) { + // The sender is the `P` payer tag; anonymous zaps sign with an ephemeral key. + noteEvent.payer() ?: noteEvent.pubKey } else { if (it is AddressableNote) { it.address.pubKeyHex @@ -434,7 +440,7 @@ class NotificationFeedFilter( // Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags, // so isNotInMutedThread on the wrapper event misses them. - if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || + if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent ) { val target = it.replyTo?.lastOrNull() @@ -492,7 +498,7 @@ class NotificationFeedFilter( // relevance check (tagsAnEventByUser is skipped below); it still scopes // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && - (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && + (noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) && (isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index 2cc37d239b..13f7d1f4d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -27,8 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent -val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND) +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND) fun filterUserProfileZapsReceived( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 6ec8803b4b..5a8292d3c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -54,7 +55,7 @@ class UserProfileZapsViewModel( ) : ViewModel() { val zapsToUser = Filter( - kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), ) @@ -105,6 +106,16 @@ class UserProfileZapsViewModel( ) } + private fun mapBolt12Zap(event: Bolt12ZapEvent): ZapAmount { + // The payer is the `P` tag; anonymous zaps fall back to the event pubkey. + // amount() is in millisats — divide to sats for the profile total. + val amountSats = (event.amount() ?: 0L) / 1000 + return ZapAmount( + LocalCache.getOrCreateUser(event.payer() ?: event.pubKey), + BigDecimal(amountSats), + ) + } + suspend fun List.sumAmountsByUser(): List { val results = mutableMapOf() @@ -113,6 +124,7 @@ class UserProfileZapsViewModel( when (zapEvent) { is LnZapEvent -> mapRequest(zapEvent) is OnchainZapEvent -> mapOnchainZap(zapEvent) + is Bolt12ZapEvent -> mapBolt12Zap(zapEvent) else -> null } if (zapAmount != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt index cdd630e3c0..59108d02b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -169,6 +169,7 @@ 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.zap.Bolt12ZapEvent /** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */ @Suppress("DEPRECATION") @@ -261,6 +262,7 @@ fun kindDisplayName(kind: Int): Int = LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats LiveActivitiesEvent.KIND -> R.string.kind_live_streams LnZapEvent.KIND -> R.string.kind_zaps + Bolt12ZapEvent.KIND -> R.string.kind_zaps LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response LnZapPrivateEvent.KIND -> R.string.kind_private_zaps diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 2f92fbe65f..264741738b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -162,6 +162,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex +import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage @@ -348,6 +349,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -919,6 +921,8 @@ private fun FullBleedNoteCompose( RenderNutzap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is OnchainZapEvent) { RenderOnchainZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) + } else if (noteEvent is Bolt12ZapEvent) { + RenderBolt12Zap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is ReactionEvent) { RenderReaction(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index a9b1ef80cd..2cd7d8c514 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async @@ -776,7 +777,7 @@ class AmethystAppFunctions { val filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("p" to listOf(myPub)), since = sinceSecs, limit = 500, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt new file mode 100644 index 0000000000..5a5cf7c015 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.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.amethyst.commons.model + +import androidx.compose.runtime.Stable + +/** + * Per-payment NIP-XX BOLT12 zap entry attached to a target Note. + * + * Unlike NIP-BC onchain zaps (which carry an async chain-verification state + * machine), a BOLT12 zap is validated **synchronously** at consumption time — + * the `lnp` payer proof is a self-contained cryptographic settlement proof — so + * every entry stored here has already passed [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator] + * and its amount is counted directly, the same way a NIP-57 lightning zap + * receipt's amount is. + * + * @property source The kind:9736 Bolt12ZapEvent note. `source.author` is the + * payer shown in the reactions gallery and notifications card + * (the `P` tag; an anonymous zap uses an ephemeral key). + * @property amountMillisats The validated amount in **millisatoshis** (the + * `amount` tag, checked against the proof's `invoice_amount`). + * @property cryptoVerified True when the payer proof's signatures were fully + * verified. False when the zap is structurally valid and bound + * to its intent but the proof is compressed and its signatures + * can't yet be checked (pending the lightning/bolts#1346 merkle + * reconstruction). The UI SHOULD label the latter as unverified. + */ +@Stable +data class Bolt12ZapEntry( + val source: Note, + val amountMillisats: Long, + val cryptoVerified: Boolean, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 802ba935b7..435718deef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -169,6 +169,7 @@ open class Note( removeLabel(note) removeNutzap(note) removeOnchainZapBySource(note) + removeBolt12ZapBySource(note) } var poll: PollResponsesCache? = null @@ -249,6 +250,22 @@ open class Note( var nutzaps = mapOf() private set + /** + * NIP-XX BOLT12 zaps (kind 9736) targeting this note. + * Key: the payer proof's `invoice_payment_hash` (hex) — the spec's dedup key, + * so two zap events proving the same settled payment collapse to one entry. + * Value: entry with the source Bolt12ZapEvent note (so `source.author` is the + * payer), the validated amount in millisats, and whether the proof's crypto + * was fully verified. Every entry here has already passed the synchronous + * `Bolt12ZapValidator`, so all are counted by `updateZapTotal` (there is no + * async pending state like onchain zaps have). + * + * `@Volatile` for the same cross-thread visibility reason as [onchainZaps]. + */ + @Volatile + var bolt12Zaps = mapOf() + private set + var zapPayments = mapOf() private set @@ -376,7 +393,8 @@ open class Note( zaps.isNotEmpty() || boosts.isNotEmpty() || onchainZaps.isNotEmpty() || - nutzaps.isNotEmpty() + nutzaps.isNotEmpty() || + bolt12Zaps.isNotEmpty() fun countReactions(): Int { var total = 0 @@ -408,7 +426,7 @@ open class Note( fun clearChildLinks(): List { val repliesChanged = replies.isNotEmpty() val reactionsChanged = reactions.isNotEmpty() - val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() + val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() || bolt12Zaps.isNotEmpty() val boostsChanged = boosts.isNotEmpty() val reportsChanged = reports.isNotEmpty() val labelsChanged = labels.isNotEmpty() @@ -424,7 +442,8 @@ open class Note( zapPayments.keys + zapPayments.values.filterNotNull() + nutzaps.values.map { it.source } + - onchainZaps.values.map { it.source } + onchainZaps.values.map { it.source } + + bolt12Zaps.values.map { it.source } replies = listOf() reactions = mapOf() @@ -435,6 +454,7 @@ open class Note( onchainZaps = mapOf() onchainZapResolved = false nutzaps = mapOf() + bolt12Zaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal(0) relays = listOf() @@ -713,6 +733,59 @@ open class Note( } } + private fun innerAddBolt12Zap( + paymentHashHex: String, + entry: Bolt12ZapEntry, + ): Boolean = + syncLock.withLock { + val existing = bolt12Zaps[paymentHashHex] + if (existing != null) { + // Same settled payment (dedup by invoice_payment_hash) — a relay echo. + if (entry == existing) return@withLock false + // Prefer a fully crypto-verified entry; never let an unverified + // (compressed-proof) republish overwrite a verified one. + if (!entry.cryptoVerified && existing.cryptoVerified) return@withLock false + } + bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, entry) + return@withLock true + } + + private fun innerRemoveBolt12ZapBySource(source: Note): Boolean = + syncLock.withLock { + val newMap = bolt12Zaps.filterValues { it.source != source } + if (newMap.size == bolt12Zaps.size) return@withLock false + bolt12Zaps = newMap + return@withLock true + } + + /** + * Register a NIP-XX BOLT12 zap targeting this note. [source] is the kind:9736 + * event's own note — `source.author` is the payer shown in the reactions + * gallery and notifications. [amountMillisats] and [cryptoVerified] come from + * the synchronous [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator] + * verdict; the caller MUST only call this for a `Valid` result. Deduplicated by + * [paymentHashHex] (the proof's `invoice_payment_hash`). + */ + fun addBolt12Zap( + source: Note, + paymentHashHex: String, + amountMillisats: Long, + cryptoVerified: Boolean, + ) { + if (innerAddBolt12Zap(paymentHashHex, Bolt12ZapEntry(source, amountMillisats, cryptoVerified))) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + + /** Detach every BOLT12-zap entry contributed by [source] — used when the source note is pruned or deleted. */ + fun removeBolt12ZapBySource(source: Note) { + if (innerRemoveBolt12ZapBySource(source)) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + private fun innerAddZapPayment( zapPaymentRequest: Note, zapPayment: Note?, @@ -906,6 +979,7 @@ open class Note( // zap requests). if (isNutzappedBy(user, afterTimeInSeconds)) return true if (isOnchainZappedBy(user, afterTimeInSeconds)) return true + if (isBolt12ZappedBy(user, afterTimeInSeconds)) return true val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps) if (first) return true @@ -933,6 +1007,15 @@ open class Note( entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds } + private fun isBolt12ZappedBy( + user: User, + afterTimeInSeconds: Long, + ): Boolean = + bolt12Zaps.values.any { entry -> + val sourceEvent = entry.source.event ?: return@any false + entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds + } + /** * Extra sats to add on top of [zapsAmount] for the reaction-row * counter when the signed-in user has outgoing onchain zaps on @@ -1019,6 +1102,13 @@ open class Note( sumOfAmounts += BigDecimal(entry.claimedSats) } + // NIP-XX BOLT12 zaps — validated synchronously at consume time (the `lnp` + // payer proof is a self-contained settlement proof), so every stored entry + // counts, converting its millisat amount to sats like the lightning path. + bolt12Zaps.values.forEach { entry -> + sumOfAmounts += BigDecimal(entry.amountMillisats / 1000) + } + zapsAmount = sumOfAmounts } @@ -1186,7 +1276,8 @@ open class Note( fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn } || - nutzaps.values.any { it.source.author == loggedIn } + nutzaps.values.any { it.source.author == loggedIn } || + bolt12Zaps.values.any { it.source.author == loggedIn } fun hasReacted( loggedIn: User, @@ -1251,6 +1342,10 @@ open class Note( note.addOnchainZap(entry.source, txid, entry.claimedSats, entry.verifiedSats, entry.status) entry.source.replyTo = entry.source.replyTo?.replace(this, note) } + bolt12Zaps.forEach { (paymentHash, entry) -> + note.addBolt12Zap(entry.source, paymentHash, entry.amountMillisats, entry.cryptoVerified) + entry.source.replyTo = entry.source.replyTo?.replace(this, note) + } zapPayments.forEach { note.addZapPayment(it.key, it.value) it.key.replyTo = it.key.replyTo?.replace(this, note) @@ -1271,6 +1366,7 @@ open class Note( zaps = emptyMap() nutzaps = emptyMap() onchainZaps = emptyMap() + bolt12Zaps = emptyMap() zapPayments = emptyMap() labels = emptyMap() zapsAmount = BigDecimal(0) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index fa37cf253b..ddffadc87c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet @@ -182,7 +183,7 @@ class ThreadAssembler( */ fun Event?.anchorsItsOwnThread(): Boolean = when (this) { - is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> true + is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent, is Bolt12ZapEvent -> true else -> false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt index ee651383e9..9fc760dd29 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Nostr event kinds that can generate a notification when they tag the @@ -70,6 +71,7 @@ object NotificationKinds { NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap LnZapEvent.KIND, // 9735 — NIP-57 zap receipt OnchainZapEvent.KIND, // 8333 — onchain zap + Bolt12ZapEvent.KIND, // 9736 — NIP-XX BOLT12 zap // NIP-17 file-header messages (encrypted file DMs) ChatMessageEncryptedFileHeaderEvent.KIND, ) @@ -114,7 +116,8 @@ object NotificationKinds { if (event.pubKey == myPubKeyHex && event !is LnZapEvent && event !is NutzapEvent && - event !is OnchainZapEvent + event !is OnchainZapEvent && + event !is Bolt12ZapEvent ) { return false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 66a790c31a..51e0ccfb18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -144,6 +145,11 @@ class LiveStreamTopZappersViewModel( goalContributions[it.receiptId] = it } } + goal?.bolt12Zaps?.values?.forEach { entry -> + contributionFromStreamZap(entry.source)?.let { + goalContributions[it.receiptId] = it + } + } } } @@ -152,12 +158,19 @@ class LiveStreamTopZappersViewModel( _topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit) } - private fun contributionFromStreamZap(note: Note): ZapContribution? { - val ev = note.event as? LnZapEvent ?: return null - val request = ev.zapRequest ?: return null - val sats = ev.amount()?.toLong() ?: return null - return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) - } + private fun contributionFromStreamZap(note: Note): ZapContribution? = + when (val ev = note.event) { + is LnZapEvent -> { + val request = ev.zapRequest ?: return null + val sats = ev.amount()?.toLong() ?: return null + ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) + } + is Bolt12ZapEvent -> { + val sats = ev.amount()?.div(1000) ?: return null + ZapContribution(note.idHex, ev.payer() ?: ev.pubKey, ev.isAnonymous(), sats) + } + else -> null + } private fun contributionFromGoalZap( zapRequestNote: Note, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 89deb9d672..052d1d09ac 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * One in-flight kind-9735 zap to render as a floating overlay on the @@ -61,6 +62,20 @@ data class RoomZap( amountSats = event.amount?.toLong(), createdAtSec = event.createdAt, ) + + /** + * Project a kind-9736 [Bolt12ZapEvent] into a [RoomZap]. The zapper is + * the `P` payer tag (or the event pubkey for an anonymous zap); the amount + * is the `amount` tag in millisats converted to sats. + */ + fun from(event: Bolt12ZapEvent): RoomZap = + RoomZap( + eventId = event.id, + sourcePubkey = event.payer() ?: event.pubKey, + targetPubkey = event.recipient(), + amountSats = event.amount()?.div(1000), + createdAtSec = event.createdAt, + ) } } @@ -82,8 +97,19 @@ class RoomZapsAggregator { event: LnZapEvent, nowSec: Long, windowSec: Long, + ): Map> = apply(RoomZap.from(event), nowSec, windowSec) + + fun apply( + event: Bolt12ZapEvent, + nowSec: Long, + windowSec: Long, + ): Map> = apply(RoomZap.from(event), nowSec, windowSec) + + private fun apply( + incoming: RoomZap, + nowSec: Long, + windowSec: Long, ): Map> { - val incoming = RoomZap.from(event) // Dedup: a relay re-delivery (or LocalCache.observeNotes's // full-list re-emit) of the same receipt must not stack. byEventId[incoming.eventId] = incoming diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 0cd319ccd1..39e7d8743f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker import com.vitorpamplona.nestsclient.transport.WebTransportFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -636,6 +637,16 @@ class NestViewModel( _recentZaps.value = zapsAgg.apply(event, nowSec, windowSec) } + /** Apply one kind-9736 BOLT12 zap to the same sliding-window aggregator. */ + fun onZapEvent( + event: Bolt12ZapEvent, + nowSec: Long, + windowSec: Long = REACTION_WINDOW_SEC, + ) { + if (closed) return + _recentZaps.value = zapsAgg.apply(event, nowSec, windowSec) + } + /** * Drop zaps older than the staleness threshold. Platform layer * drives this on a 1-s tick — the same cadence as [evictReactions] diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 25d3a9bf8e..b5a13b6e88 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -303,6 +303,7 @@ 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.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -539,6 +540,7 @@ object KindNames { RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"), RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), + Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"), PutUserEvent.KIND to KindName("Group Put User", "29"), RemoveUserEvent.KIND to KindName("Group Remove User", "29"), EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), From 3e19b76343f90523c349508dc03518be1fed8d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 20:43:02 +0000 Subject: [PATCH 03/23] perf(bolt12): fail-fast validation, skip redundant verify, precompute merkle tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speed: - Bolt12ZapValidator reorders checks cheap-to-expensive: all structural, cross-event, and payer-proof binding checks run first; the schnorr signature + proof crypto verifications run only once an event has passed them. A malformed or mismatched event now rejects with zero schnorr ops. Cannot change accept/reject, only which reason a doubly-invalid event reports. - validate() gains verifyEventSignature (default true); LocalCache.consume passes false since the relay pipeline already verified the outer event — removing a redundant schnorr on every ingested zap (3 verifies instead of 4). - Bolt12Merkle precomputes SHA256("LnLeaf")/SHA256("LnBranch") once and hashes the per-call "LnNonce"||first-tlv tag once per rootHash instead of once per record. Tests: - New validator rejections: preimage-mismatch, invalid-invoice-signature, payer-tag-mismatch, proof-does-not-match-offer, plus the verifyEventSignature skip-flag both ways (fixture gains corruptPaymentHash / breakInvoiceSignature). - New commons NoteBolt12ZapTest: millisat→sat total, dedup by payment_hash, verified-not-downgraded-by-unverified, remove-by-source, clearChildLinks, and combined totals. Docs: quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md captures the two upstream-gated follow-ups (vector-driven interop test + compressed-proof merkle reconstruction) for when lightning/bolts#1346 merges. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/model/LocalCache.kt | 4 +- .../commons/model/NoteBolt12ZapTest.kt | 105 ++++++++++++++++++ .../2026-07-23-bolt12-zap-interop-vectors.md | 67 +++++++++++ .../nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt | 30 +++-- .../verify/Bolt12ZapValidator.kt | 44 +++++--- .../verify/Bolt12ProofFixture.kt | 9 +- .../verify/Bolt12ZapValidatorTest.kt | 87 +++++++++++++++ 7 files changed, 319 insertions(+), 27 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt create mode 100644 quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fa5381d667..5aa0654337 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2419,7 +2419,9 @@ object LocalCache : ILocalCache, ICacheProvider { // NIP-XX validation is fully synchronous: zap-event structure, the embedded // kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed // validation drops the zap entirely — it never contributes to a zap total. - val validation = bolt12ZapValidator.validate(event) + // The outer event signature was already verified above (wasVerified/justVerify), + // so skip the redundant re-check inside the validator. + val validation = bolt12ZapValidator.validate(event, verifyEventSignature = false) if (validation !is Bolt12ZapValidation.Valid) { Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" } return false diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt new file mode 100644 index 0000000000..baffc70239 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Verifies how validated NIP-XX BOLT12 zaps fold into a Note's aggregate zap + * accounting — the model contract the LocalCache ingest path and the reaction-row + * counter depend on. + */ +class NoteBolt12ZapTest { + private fun note(id: String) = Note(id) + + @Test + fun addsMillisatAmountsAsSatsToTheZapTotal() { + val target = note("a".repeat(64)) + target.addBolt12Zap(note("b".repeat(64)), "hash1", amountMillisats = 21_000_000L, cryptoVerified = true) + target.addBolt12Zap(note("c".repeat(64)), "hash2", amountMillisats = 1_000_000L, cryptoVerified = true) + + // 21_000_000 + 1_000_000 millisats = 22_000 sats. + assertEquals(22_000L, target.zapsAmount.toLong()) + assertEquals(2, target.bolt12Zaps.size) + } + + @Test + fun deduplicatesByPaymentHash() { + val target = note("a".repeat(64)) + target.addBolt12Zap(note("b".repeat(64)), "samehash", amountMillisats = 21_000_000L, cryptoVerified = true) + // A relay echo (or a re-publish from another source) of the same settled payment. + target.addBolt12Zap(note("c".repeat(64)), "samehash", amountMillisats = 21_000_000L, cryptoVerified = true) + + assertEquals(1, target.bolt12Zaps.size) + assertEquals(21_000L, target.zapsAmount.toLong()) + } + + @Test + fun aVerifiedEntryIsNotDowngradedByAnUnverifiedRepublish() { + val target = note("a".repeat(64)) + target.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true) + target.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = false) + + assertEquals(1, target.bolt12Zaps.size) + assertTrue(target.bolt12Zaps["h"]!!.cryptoVerified, "a verified entry must not be overwritten by an unverified one") + } + + @Test + fun removingBySourceDropsTheEntryAndUpdatesTheTotal() { + val target = note("a".repeat(64)) + val source = note("b".repeat(64)) + target.addBolt12Zap(source, "h", amountMillisats = 7_000_000L, cryptoVerified = true) + assertEquals(7_000L, target.zapsAmount.toLong()) + + target.removeBolt12ZapBySource(source) + assertTrue(target.bolt12Zaps.isEmpty()) + assertEquals(0L, target.zapsAmount.toLong()) + } + + @Test + fun clearChildLinksDropsBolt12ZapsAndReturnsTheirSources() { + val target = note("a".repeat(64)) + val source = note("b".repeat(64)) + target.addBolt12Zap(source, "h", amountMillisats = 3_000_000L, cryptoVerified = true) + assertTrue(target.hasZapsBoostsOrReactions()) + + val removed = target.clearChildLinks() + + assertTrue(source in removed, "the source note must be returned so the cache can prune it") + assertTrue(target.bolt12Zaps.isEmpty()) + assertEquals(0L, target.zapsAmount.toLong()) + assertFalse(target.hasZapsBoostsOrReactions()) + } + + @Test + fun bolt12ZapsCombineWithLightningTotalsIndependently() { + val target = note("a".repeat(64)) + // Two BOLT12 zaps; no lightning receipts on this note. + target.addBolt12Zap(note("b".repeat(64)), "h1", amountMillisats = 2_000_000L, cryptoVerified = true) + target.addBolt12Zap(note("c".repeat(64)), "h2", amountMillisats = 500_000L, cryptoVerified = false) + + // Both count (validated == counted), regardless of crypto-verification state. + assertEquals(2_500L, target.zapsAmount.toLong()) + } +} diff --git a/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md b/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md new file mode 100644 index 0000000000..19374f4b8c --- /dev/null +++ b/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md @@ -0,0 +1,67 @@ +# BOLT12 zap proof verification — interop test vectors (follow-up) + +Status: **blocked on upstream.** The NIP-XX BOLT12-zap layer (`quartz/…/nipXXBolt12Zaps/`) +verifies fully-disclosed payer proofs and reports compressed ones as +`Bolt12ProofResult.Unsupported` (surfaced as `cryptoVerified = false`). Two pieces +of work are gated on the BOLT12 payer-proof spec ([lightning/bolts#1346]) merging +with published test vectors. + +## Why it's gated + +Today the crypto path (`Bolt12ProofVerifier` + `Bolt12Merkle`) is validated only by +**self-consistent round-trips** (our own encoder ↔ our own verifier, see +`Bolt12ProofFixture` + `Bolt12MerkleTest` + `Bolt12ZapValidatorTest`). That proves +internal correctness, not agreement with CLN/LDK. Several constants are our best +reading of the still-draft spec and MUST be reconciled against real vectors before +we trust wallet-produced proofs: + +- TLV type numbers (`Bolt12PayerProof` companion): 240/241, 1001–1005, 22, 80–91, + 160–176. +- Signature digest tags (`Bolt12ProofVerifier`): `"lightning" + messagename + fieldname` + — `INVOICE_MESSAGE`/`PROOF_MESSAGE`/`SIGNATURE_FIELD`. The proof-signature field + name especially is a guess. +- Merkle leaf/branch tag strings + odd-node promotion (`Bolt12Merkle`) — believed to + match LDK, not checked byte-for-byte. +- 33-byte compressed `point` → BIP-340 x-only handling / even-y convention for + `invoice_node_id` and `invreq_payer_id`. + +## Work item 1 — vector-driven interop test + +When `bolt12/payer-proof-test.json` exists in #1346: + +1. Vendor the vectors into `quartz/src/commonTest/resources/` (or inline the hex). +2. Add `Bolt12PayerProofVectorTest`: for each `valid` proof assert + `Bolt12ProofVerifier.verify(...) is Valid`; for each `invalid` proof assert the + specific rejection reason. +3. Fix any constant above that the vectors disprove. If a fix is needed, the + round-trip tests will still pass (they move with our encoder) — the vector test + is the real gate. + +## Work item 2 — compressed-proof merkle reconstruction + +Real wallet proofs omit non-required invoice TLVs (blinded paths, etc.), which still +contributed to the invoice signature's merkle root — so `Bolt12ProofVerifier.verify` +currently returns `Unsupported` for them. Implement the reconstruction in +`Bolt12Merkle`, rebuilding the invoice root from: + +- disclosed invoice TLVs → compute their `LnLeaf` hashes locally; +- `proof_leaf_hashes` (1004) → the `LnNonce` leaves for disclosed fields (can't be + computed locally — the nonce tag embeds the possibly-omitted first TLV); +- `proof_omitted_tlvs` (1002) → markers for where omitted fields sit in + TLV-ascending order; +- `proof_missing_hashes` (1003) → sibling subtree hashes for omitted branches, + consumed post-order DFS smallest-to-largest. + +Then verify the invoice signature against the reconstructed root and drop the +`isCompressed()` short-circuit. Gate acceptance behind Work item 1's vectors — a +reconstruction that only round-trips against our own encoder proves nothing about +real-wallet interop. + +## Not gated on this + +Runtime validation is fully offline (no network) and everything else in the feature +— events, accounting, display, the fully-disclosed crypto path — is done. This +document only covers making compressed real-wallet proofs count as +`cryptoVerified = true`. + +[lightning/bolts#1346]: https://github.com/lightning/bolts/pull/1346 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt index 70c1569822..32550dec82 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt @@ -47,18 +47,26 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * lightning/bolts#1346 test vectors. */ object Bolt12Merkle { - private val LN_LEAF = "LnLeaf".encodeToByteArray() private val LN_NONCE = "LnNonce".encodeToByteArray() - private val LN_BRANCH = "LnBranch".encodeToByteArray() + + // The "LnLeaf" and "LnBranch" tags are constants, so their SHA-256 (the inner + // hash of a tagged hash) is precomputed once instead of per leaf/branch. The + // "LnNonce" tag isn't constant (it embeds the first TLV), so it is hashed once + // per rootHash() call rather than once per record. + private val LN_LEAF_TAG_HASH = sha256("LnLeaf".encodeToByteArray()) + private val LN_BRANCH_TAG_HASH = sha256("LnBranch".encodeToByteArray()) /** Tagged hash `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */ fun taggedHash( tag: ByteArray, msg: ByteArray, - ): ByteArray { - val tagHash = sha256(tag) - return sha256(tagHash + tagHash + msg) - } + ): ByteArray = taggedHashPrecomputed(sha256(tag), msg) + + /** Tagged hash when the caller already holds `SHA256(tag)` (the inner tag hash). */ + private fun taggedHashPrecomputed( + tagHash: ByteArray, + msg: ByteArray, + ): ByteArray = sha256(tagHash + tagHash + msg) /** * Computes the merkle root over [signableRecords] — the caller must have @@ -69,12 +77,12 @@ object Bolt12Merkle { require(signableRecords.isNotEmpty()) { "Cannot compute a merkle root over zero records" } val firstTlv = signableRecords.first().encoded - val nonceTag = LN_NONCE + firstTlv + val nonceTagHash = sha256(LN_NONCE + firstTlv) var nodes = ArrayList(signableRecords.size * 2) for (record in signableRecords) { - nodes.add(taggedHash(LN_LEAF, record.encoded)) - nodes.add(taggedHash(nonceTag, record.encoded)) + nodes.add(taggedHashPrecomputed(LN_LEAF_TAG_HASH, record.encoded)) + nodes.add(taggedHashPrecomputed(nonceTagHash, record.encoded)) } while (nodes.size > 1) { @@ -99,9 +107,9 @@ object Bolt12Merkle { b: ByteArray, ): ByteArray = if (compareUnsigned(a, b) <= 0) { - taggedHash(LN_BRANCH, a + b) + taggedHashPrecomputed(LN_BRANCH_TAG_HASH, a + b) } else { - taggedHash(LN_BRANCH, b + a) + taggedHashPrecomputed(LN_BRANCH_TAG_HASH, b + a) } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt index 9ca2fc2dde..7116911109 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt @@ -53,12 +53,28 @@ import com.vitorpamplona.quartz.utils.Hex class Bolt12ZapValidator( private val proofVerifier: Bolt12ProofVerifier = Bolt12ProofVerifier(), ) { - fun validate(event: Bolt12ZapEvent): Bolt12ZapValidation { - // Step 1 — kind and event signature. - if (event.kind != Bolt12ZapEvent.KIND) return invalid(Reason.WRONG_KIND) - if (!event.verify()) return invalid(Reason.BAD_EVENT_SIGNATURE) + /** + * @param verifyEventSignature verify the zap event's own signature. Defaults to + * true for standalone callers. The `LocalCache` ingest path passes false + * because the relay-client pipeline already verified it before dispatch — + * avoiding a redundant schnorr check on the hot path. + * + * Checks are ordered cheap-to-expensive: all structural, cross-event, and + * binding checks (tag reads, string/number compares) run first, and the + * expensive signature/crypto verifications run only once an event has passed + * them — so a malformed or mismatched event is rejected without paying for a + * schnorr verification. This cannot change an accept/reject outcome (a + * badly-signed event still fails the later verify), only which reason a + * doubly-invalid event reports. + */ + fun validate( + event: Bolt12ZapEvent, + verifyEventSignature: Boolean = true, + ): Bolt12ZapValidation { + // --- Cheap checks (no crypto) -------------------------------------------- - // Step 2 — zap-event structure. + // Zap-event kind + structure. + if (event.kind != Bolt12ZapEvent.KIND) return invalid(Reason.WRONG_KIND) if (event.tags.count(DescriptionTag::isTag) != 1) return invalid(Reason.NOT_EXACTLY_ONE_DESCRIPTION) if (event.description() == null) return invalid(Reason.MISSING_DESCRIPTION) @@ -78,9 +94,8 @@ class Bolt12ZapValidator( val payer = event.payer() if (payer != null && payer != event.pubKey) return invalid(Reason.PAYER_TAG_MISMATCH) - // Step 3 — embedded intent. + // Embedded intent — parse + structure (signature verified later). val intent = event.zapIntent ?: return invalid(Reason.MISSING_OR_INVALID_INTENT) - if (!intent.verify()) return invalid(Reason.BAD_INTENT_SIGNATURE) if (intent.pubKey != event.pubKey) return invalid(Reason.INTENT_PUBKEY_MISMATCH) if (intent.zapId() == null) return invalid(Reason.INVALID_ZAP_ID) @@ -91,7 +106,7 @@ class Bolt12ZapValidator( if (intent.offer() == null) return invalid(Reason.INTENT_STRUCTURE_INVALID) if (checkTargetCardinality(intent.tags) != null) return invalid(Reason.INTENT_STRUCTURE_INVALID) - // Step 4 — the zap and its intent must agree. + // The zap and its intent must agree. if (event.content != intent.content) return invalid(Reason.CONTENT_MISMATCH) if (recipient != intent.recipient()) return invalid(Reason.RECIPIENT_MISMATCH) if (amount != intentAmount) return invalid(Reason.AMOUNT_MISMATCH) @@ -100,13 +115,12 @@ class Bolt12ZapValidator( if (event.zappedAddress() != intent.zappedAddress()) return invalid(Reason.TARGET_MISMATCH) if (event.zappedKind() != intent.zappedKind()) return invalid(Reason.TARGET_MISMATCH) - // Step 5 — parse the raw offer. + // Parse the raw offer + payer proof. val offerParsed = Bolt12Offer.parse(offer) ?: return invalid(Reason.UNPARSEABLE_OFFER) - - // Step 6 — parse & decode the payer proof. val proof = Bolt12PayerProof.parse(proofStr) ?: return invalid(Reason.UNPARSEABLE_PROOF) - // Step 7 — bind the proof to this zap. + // Bind the proof to this zap. Uses intent.id — a forged id can't survive the + // intent signature check below, so binding-before-verify is safe. val expectedNote = NIP_URI_PREFIX + intent.id if (proof.invreqPayerNote() != expectedNote) return invalid(Reason.PROOF_NOTE_MISMATCH) @@ -115,7 +129,11 @@ class Bolt12ZapValidator( offerBindingFailure(offerParsed, proof)?.let { return invalid(it) } - // Step 6 (crypto) — verify the payer proof signatures. + // --- Expensive checks (signatures + proof crypto) ------------------------ + + if (verifyEventSignature && !event.verify()) return invalid(Reason.BAD_EVENT_SIGNATURE) + if (!intent.verify()) return invalid(Reason.BAD_INTENT_SIGNATURE) + val cryptoResult = proofVerifier.verify(proof) val cryptoVerified = when (cryptoResult) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt index f9d338c0c9..bdb7574928 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt @@ -62,10 +62,14 @@ object Bolt12ProofFixture { payerNote: String, compressed: Boolean = false, breakProofSignature: Boolean = false, + breakInvoiceSignature: Boolean = false, + corruptPaymentHash: Boolean = false, ): String { val nodePoint = point(nodeKey.pubKey) val payerPoint = point(payerLightningKey.pubKey) - val paymentHash = sha256(preimage) + // A corrupt hash still yields a valid signature over the corrupted records — + // the preimage check (SHA256(preimage) != invoice_payment_hash) is what rejects it. + val paymentHash = sha256(preimage).also { if (corruptPaymentHash) it[0] = (it[0] + 1).toByte() } // The invoice's signed records (types < 240), in ascending order. val invoiceRecords = @@ -79,10 +83,11 @@ object Bolt12ProofFixture { TlvRecord(Bolt12PayerProof.TYPE_INVOICE_NODE_ID, nodePoint), ) val invoiceRoot = Bolt12Merkle.rootHash(invoiceRecords) + val invoiceSigningKey = if (breakInvoiceSignature) payerLightningKey else nodeKey val invoiceSig = Nip01Crypto.sign( Bolt12Merkle.signatureDigest(Bolt12ProofVerifier.INVOICE_MESSAGE, Bolt12ProofVerifier.SIGNATURE_FIELD, invoiceRoot), - nodeKey.privKey!!, + invoiceSigningKey.privKey!!, ) val preimageRecord = TlvRecord(Bolt12PayerProof.TYPE_PROOF_PREIMAGE, preimage) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt index 28f9b088e6..384d8d6452 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt @@ -152,4 +152,91 @@ class Bolt12ZapValidatorTest { val result = validator.validate(signedZap(signer, intent, proof)) assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.INTENT_PUBKEY_MISMATCH), result) } + + @Test + fun rejectsWhenThePreimageDoesNotHashToThePaymentHash() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 6).toByte() } + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount) + val intent = signedIntent(signer, offer) + val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id + val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, corruptPaymentHash = true) + + val result = validator.validate(signedZap(signer, intent, proof)) + assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_PREIMAGE_MISMATCH), result) + } + + @Test + fun rejectsAnInvalidInvoiceSignature() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 8).toByte() } + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount) + val intent = signedIntent(signer, offer) + val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id + val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, breakInvoiceSignature = true) + + val result = validator.validate(signedZap(signer, intent, proof)) + assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_INVOICE_SIGNATURE_INVALID), result) + } + + @Test + fun rejectsAPayerTagThatIsNotTheEventAuthor() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 9).toByte() } + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount) + val intent = signedIntent(signer, offer) + val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id + val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note) + + // Attribute the zap to someone other than its signer. + val zap = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = "c".repeat(64))) + assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PAYER_TAG_MISMATCH), validator.validate(zap)) + } + + @Test + fun rejectsWhenTheProofDoesNotMatchTheOffer() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val offerNodeKey = KeyPair() + val proofNodeKey = KeyPair() // a different node than the offer's issuer + val preimage = ByteArray(32) { (it + 10).toByte() } + val offer = Bolt12ProofFixture.buildOffer(offerNodeKey, amount) + val intent = signedIntent(signer, offer) + val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id + val proof = Bolt12ProofFixture.buildProof(proofNodeKey, KeyPair(), preimage, amount, note) + + val result = validator.validate(signedZap(signer, intent, proof)) + assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.OFFER_PROOF_MISMATCH), result) + } + + @Test + fun skipsTheEventSignatureCheckWhenTheCallerAlreadyVerifiedIt() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 11).toByte() } + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount) + val intent = signedIntent(signer, offer) + val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id + val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note) + val validZap = signedZap(signer, intent, proof) + + // Same event, but its own signature is corrupted (id still matches content). + val tamperedSig = + Bolt12ZapEvent(validZap.id, validZap.pubKey, validZap.createdAt, validZap.tags, validZap.content, "0".repeat(128)) + + // Default: the bad event signature is caught. + assertEquals( + Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.BAD_EVENT_SIGNATURE), + validator.validate(tamperedSig), + ) + // Ingest path: the pipeline already verified the event, so skipping is safe and it validates. + assertIs(validator.validate(tamperedSig, verifyEventSignature = false)) + } } From 0b6a70ad26d28fd39cbb2a4399320d1bc32086f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 21:17:01 +0000 Subject: [PATCH 04/23] =?UTF-8?q?fix(bolt12):=20audit=20fixes=20=E2=80=94?= =?UTF-8?q?=20offer=20binding,=20verified-only=20counting,=20lower-amount?= =?UTF-8?q?=20dedup,=20codec=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an adversarial audit of the BOLT12-zap feature. Security / correctness: - Offer↔invoice binding: `cryptoVerified=true` was asserted even when the offer had no `offer_issuer_id` or used blinded paths — cases where the invoice's node key is payer-chosen and can't be tied to the offer. An attacker could self-sign a "verified" proof having paid nothing. Now cryptoVerified requires the invoice to be provably the offer's (issuer_id present, no paths, invoice_node_id == issuer); unbindable proofs are accepted but flagged unverified, not verified. Definite contradictions still hard-reject. - Counting: `updateZapTotal` now counts ONLY crypto-verified BOLT12 zaps. An unverified (compressed / unbindable) proof carries a self-chosen preimage+amount with no settled- payment guarantee, so counting it let anyone inflate a note's total for free. Unverified entries stay stored + shown (dimmed), never summed. - Dedup: `innerAddBolt12Zap` now honors the NIP's "count the LOWER amount for the same payment hash" rule (was order-dependent last-writer-wins, inflatable by re-publishing a bigger amount tag). Keeps the stronger verification flag. - Precision: divide millisats in BigDecimal, so fractional sats survive and match the millisat-native lightning column (was integer `/1000`, flooring sub-sat zaps to 0). Codec hardening (quartz): - TLV length now range-checked (was a signed compare that let a high-bit BigSize length slip through and get truncated by toInt()). - BigSize enforces minimal encoding (also rejects >=2^63 values that read back negative). - bech32 alphabet membership is O(1) via a lookup table (was O(32n) indexOf per char). UI: - ReusableZapButton's "you zapped" gate now includes bolt12Zaps (and nutzaps/onchain), so a BOLT12-only zap correctly shows the zapped state. - The reactions gallery renders the blank/unknown author for anonymous zaps, matching the standalone card (was showing the throwaway ephemeral key's avatar). Tests: validator issuer-less-offer downgrade; TLV non-minimal-BigSize + oversized-length rejection; model lower-amount dedup (both orderings), verified-only counting, and fractional-sat survival. NoteBolt12ZapTest 6→8, Bolt12ZapValidatorTest 11→12, TlvTest 6→8. The audit also surfaced a NIP-level gap that is NOT fixable in code and is captured in the plan doc: there is no offer↔recipient-identity binding, so even a crypto-verified proof only proves payment to the *embedded* offer, not to the p-tagged recipient. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/ui/note/Bolt12ZapGallery.kt | 6 ++- .../amethyst/ui/note/ReactionsRow.kt | 7 ++- .../amethyst/commons/model/Note.kt | 35 +++++++++----- .../commons/model/NoteBolt12ZapTest.kt | 33 +++++++++++-- .../nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt | 10 +++- .../quartz/nipXXBolt12Zaps/bolt12/Tlv.kt | 11 +++-- .../verify/Bolt12ZapValidator.kt | 48 +++++++++++++++---- .../quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt | 19 ++++++++ .../verify/Bolt12ProofFixture.kt | 12 +++-- .../verify/Bolt12ZapValidatorTest.kt | 18 +++++++ 10 files changed, 162 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt index 5f58edc3df..2720728061 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -121,7 +122,10 @@ private fun Bolt12ZapEntryRow( nav: INav, accountViewModel: AccountViewModel, ) { - val user = entry.source.author + // Anonymous zaps carry no `P` payer tag — show the blank/unknown author (as the + // standalone card does) instead of the throwaway ephemeral key's default avatar. + val isAnonymous = (entry.source.event as? Bolt12ZapEvent)?.isAnonymous() == true + val user = if (isAnonymous) null else entry.source.author // The amount is validated (checked against the proof's invoice_amount), so it // is safe to show for any sender. A not-yet-crypto-verified (compressed) proof diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 1272b021c7..653d4580e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1551,7 +1551,12 @@ fun ObserveZapIcon( } LaunchedEffect(key1 = zapsState) { - if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) { + if (zapsState?.note?.zapPayments?.isNotEmpty() == true || + zapsState?.note?.zaps?.isNotEmpty() == true || + zapsState?.note?.nutzaps?.isNotEmpty() == true || + zapsState?.note?.onchainZaps?.isNotEmpty() == true || + zapsState?.note?.bolt12Zaps?.isNotEmpty() == true + ) { val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds) if (wasZappedByLoggedInUser.value != newWasZapped) { wasZappedByLoggedInUser.value = newWasZapped diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 435718deef..9704682c33 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -739,14 +739,20 @@ open class Note( ): Boolean = syncLock.withLock { val existing = bolt12Zaps[paymentHashHex] - if (existing != null) { - // Same settled payment (dedup by invoice_payment_hash) — a relay echo. - if (entry == existing) return@withLock false - // Prefer a fully crypto-verified entry; never let an unverified - // (compressed-proof) republish overwrite a verified one. - if (!entry.cryptoVerified && existing.cryptoVerified) return@withLock false - } - bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, entry) + val merged = + if (existing == null) { + entry + } else { + // Same settled payment (dedup by invoice_payment_hash). NIP-XX: count + // only one, and if amounts differ, keep the LOWER — so a re-publish + // with a bigger amount tag can't inflate the total. Keep the stronger + // verification flag, and the source of whichever entry we keep the + // amount from. + val keepEntry = if (entry.amountMillisats < existing.amountMillisats) entry else existing + keepEntry.copy(cryptoVerified = entry.cryptoVerified || existing.cryptoVerified) + } + if (merged == existing) return@withLock false + bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, merged) return@withLock true } @@ -1102,11 +1108,16 @@ open class Note( sumOfAmounts += BigDecimal(entry.claimedSats) } - // NIP-XX BOLT12 zaps — validated synchronously at consume time (the `lnp` - // payer proof is a self-contained settlement proof), so every stored entry - // counts, converting its millisat amount to sats like the lightning path. + // NIP-XX BOLT12 zaps — count only the crypto-verified ones. An unverified + // (compressed, or offer-unbindable) proof carries a self-chosen preimage, + // amount, and payment hash with no settled-payment guarantee, so counting it + // would let anyone inflate a note's total for free. Unverified entries stay + // stored and shown (dimmed) but never sum. Divide in BigDecimal so fractional + // sats survive and match the millisat-native lightning column above. bolt12Zaps.values.forEach { entry -> - sumOfAmounts += BigDecimal(entry.amountMillisats / 1000) + if (entry.cryptoVerified) { + sumOfAmounts += BigDecimal(entry.amountMillisats).divide(BigDecimal(1000)) + } } zapsAmount = sumOfAmounts diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt index baffc70239..1217bdf50b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt @@ -55,6 +55,21 @@ class NoteBolt12ZapTest { assertEquals(21_000L, target.zapsAmount.toLong()) } + @Test + fun keepsTheLowerAmountWhenTwoProofsShareAPaymentHash() { + // NIP-XX: same proof identifier, differing amounts → count the LOWER, in either order. + val a = note("a".repeat(64)) + a.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 21_000_000L, cryptoVerified = true) + a.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true) + assertEquals(1, a.bolt12Zaps.size) + assertEquals(5_000L, a.zapsAmount.toLong()) + + val b = note("a".repeat(64)) + b.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true) + b.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 21_000_000L, cryptoVerified = true) + assertEquals(5_000L, b.zapsAmount.toLong(), "order must not change the counted amount") + } + @Test fun aVerifiedEntryIsNotDowngradedByAnUnverifiedRepublish() { val target = note("a".repeat(64)) @@ -93,13 +108,23 @@ class NoteBolt12ZapTest { } @Test - fun bolt12ZapsCombineWithLightningTotalsIndependently() { + fun onlyCryptoVerifiedBolt12ZapsCountTowardTheTotal() { val target = note("a".repeat(64)) - // Two BOLT12 zaps; no lightning receipts on this note. target.addBolt12Zap(note("b".repeat(64)), "h1", amountMillisats = 2_000_000L, cryptoVerified = true) + // An unverified (compressed / unbindable) proof is stored + shown but MUST NOT count — + // its amount is self-chosen with no settled-payment guarantee. target.addBolt12Zap(note("c".repeat(64)), "h2", amountMillisats = 500_000L, cryptoVerified = false) - // Both count (validated == counted), regardless of crypto-verification state. - assertEquals(2_500L, target.zapsAmount.toLong()) + assertEquals(2, target.bolt12Zaps.size, "both are stored (the unverified one still renders, dimmed)") + assertEquals(2_000L, target.zapsAmount.toLong(), "only the verified 2000-sat zap counts") + } + + @Test + fun fractionalSatAmountsSurviveInTheTotal() { + val target = note("a".repeat(64)) + // 1500 msat = 1.5 sat; two of them = 3 sat. Integer-dividing each first drops to 2. + target.addBolt12Zap(note("b".repeat(64)), "h1", amountMillisats = 1_500L, cryptoVerified = true) + target.addBolt12Zap(note("c".repeat(64)), "h2", amountMillisats = 1_500L, cryptoVerified = true) + assertEquals(3L, target.zapsAmount.toLong(), "fractional sats must not be truncated per-entry") } } 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 index f58f2e73fc..a4395a0f57 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Bech32.kt @@ -61,6 +61,14 @@ object Bolt12Bech32 { return sb.toString().lowercase() } + // O(1) bech32 data-alphabet membership, indexed by char code (ASCII only). + private val IS_BECH32_CHAR = + BooleanArray(128).also { table -> + for (c in Bech32.ALPHABET) table[c.code] = true + } + + private fun isBech32Char(c: Char): Boolean = c.code < 128 && IS_BECH32_CHAR[c.code] + private fun hasHrp( canonical: String, hrp: String, @@ -70,7 +78,7 @@ object Bolt12Bech32 { 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 + if (!isBech32Char(canonical[i])) return false } return true } 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 index 1628d9dd13..2a7285d679 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt @@ -91,10 +91,13 @@ class TlvReader( fun readBigSize(): Long { val first = readByte() + // BOLT-1 requires minimal encoding. Rejecting non-minimal forms also rejects + // the multi-byte forms whose value would overflow a signed Long (≥ 2^63 reads + // back negative, failing the `>=` bound), so callers get a non-negative Long. return when (first) { - 0xff -> readUInt(8) - 0xfe -> readUInt(4) - 0xfd -> readUInt(2) + 0xff -> readUInt(8).also { require(it >= 0x100000000L) { "non-minimal or out-of-range BigSize" } } + 0xfe -> readUInt(4).also { require(it >= 0x10000L) { "non-minimal BigSize" } } + 0xfd -> readUInt(2).also { require(it >= 0xfdL) { "non-minimal BigSize" } } else -> first.toLong() } } @@ -167,7 +170,7 @@ class TlvStream( while (reader.remaining() > 0) { val type = reader.readBigSize() val length = reader.readBigSize() - require(length <= reader.remaining()) { "TLV length $length exceeds the remaining stream" } + require(length in 0..reader.remaining().toLong()) { "TLV length $length out of range (remaining ${reader.remaining()})" } val value = reader.readBytes(length.toInt()) require(type > lastType) { "TLV records must be strictly ascending (saw $type after $lastType)" } lastType = type diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt index 7116911109..e05acb5d62 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt @@ -127,7 +127,7 @@ class Bolt12ZapValidator( 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) } + offerBindingHardFailure(offerParsed, proof)?.let { return invalid(it) } // --- Expensive checks (signatures + proof crypto) ------------------------ @@ -135,13 +135,26 @@ class Bolt12ZapValidator( if (!intent.verify()) return invalid(Reason.BAD_INTENT_SIGNATURE) val cryptoResult = proofVerifier.verify(proof) - val cryptoVerified = + val cryptoOk = when (cryptoResult) { is Bolt12ProofResult.Valid -> true is Bolt12ProofResult.Unsupported -> false is Bolt12ProofResult.Invalid -> return invalid(mapProofReason(cryptoResult.reason)) } + // "Crypto verified" requires BOTH that the invoice signature was checked AND + // that the signed invoice is provably the offer's — i.e. signed by the offer's + // `offer_issuer_id`. When the offer hides its destination behind blinded paths, + // or publishes no issuer id, the invoice node key is one the payer chose and + // can't be tied to the offer here, so "paid this offer" is NOT proven; such a + // proof is downgraded to unverified rather than asserted verified. + // + // NB: even a bound proof only proves payment to the *embedded* offer. The NIP + // has no offer↔recipient-identity binding, so nothing here proves the offer + // belongs to the p-tagged recipient — see + // quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md. + val cryptoVerified = cryptoOk && isInvoiceBoundToOffer(offerParsed, proof) + val paymentHash = proof.invoicePaymentHash() ?: return invalid(Reason.PROOF_MISSING_REQUIRED_FIELDS) return Bolt12ZapValidation.Valid( @@ -167,14 +180,13 @@ class Bolt12ZapValidator( } /** - * 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. + * A definite contradiction between the proof and the offer — the proof either + * copies a different `offer_issuer_id`, or (for a directly-addressed offer) + * carries an `invoice_node_id` that isn't the offer's issuer. These are hard + * rejects. Note the *absence* of a check is NOT a pass here — that only means + * we can't bind, which [isInvoiceBoundToOffer] reports separately. */ - private fun offerBindingFailure( + private fun offerBindingHardFailure( offer: Bolt12Offer, proof: Bolt12PayerProof, ): Reason? { @@ -191,6 +203,24 @@ class Bolt12ZapValidator( return null } + /** + * True only when the settled invoice can be cryptographically tied to the offer: + * the offer publishes an `offer_issuer_id`, uses no blinded paths, and the proof's + * `invoice_node_id` equals that issuer (so the invoice signature the verifier + * checks was made by the offer's own node). Blinded-path or issuer-less offers + * expose a payer-chosen node key that can't be bound to the offer here, so a + * self-consistent proof against such an offer proves nothing about paying it. + */ + private fun isInvoiceBoundToOffer( + offer: Bolt12Offer, + proof: Bolt12PayerProof, + ): Boolean { + val issuerId = offer.issuerId() ?: return false + if (offer.hasPaths()) return false + val nodeId = proof.invoiceNodeId() ?: return false + return nodeId.contentEquals(issuerId) + } + private fun mapProofReason(reason: Bolt12ProofResult.Reason): Reason = when (reason) { Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS -> Reason.PROOF_MISSING_REQUIRED_FIELDS 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 index 8ef07e889a..c7d67ce438 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt @@ -87,6 +87,25 @@ class TlvTest { assertFailsWith { TlvStream.read(outOfOrder) } } + @Test + fun bigSizeRejectsNonMinimalEncodings() { + // 5 encoded in the 3-byte (0xfd) form instead of a single byte. + assertFailsWith { TlvReader(byteArrayOf(0xfd.toByte(), 0x00, 0x05)).readBigSize() } + // 0x100 encoded in the 5-byte (0xfe) form instead of 3. + assertFailsWith { + TlvReader(byteArrayOf(0xfe.toByte(), 0x00, 0x00, 0x01, 0x00)).readBigSize() + } + } + + @Test + fun tlvStreamRejectsAnOversizedOrHighBitLength() { + // type=1 (minimal), length=0xff FF FF FF FF 00 00 00 05 — reads back negative / huge. + val hostile = + byteArrayOf(0x01) + + byteArrayOf(0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0x00, 0x00, 0x00, 0x05) + assertFailsWith { TlvStream.read(hostile) } + } + @Test fun signatureElementRangeIsRecognized() { assertEquals(false, TlvRecord(176, 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 index bdb7574928..ba41644604 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt @@ -44,13 +44,15 @@ object Bolt12ProofFixture { fun buildOffer( nodeKey: KeyPair, amountMillisats: Long, + withIssuerId: Boolean = true, ): String { + // TLV types must be strictly ascending: amount(8), description(10), issuer_id(22). val records = - listOf( - TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats)), - TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "zap".encodeToByteArray()), - TlvRecord(Bolt12Offer.TYPE_ISSUER_ID, point(nodeKey.pubKey)), - ) + buildList { + add(TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats))) + add(TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "zap".encodeToByteArray())) + if (withIssuerId) add(TlvRecord(Bolt12Offer.TYPE_ISSUER_ID, point(nodeKey.pubKey))) + } return Bolt12Bech32.encode(Bolt12Bech32.OFFER_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 index 384d8d6452..e7e11a8663 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt @@ -199,6 +199,24 @@ class Bolt12ZapValidatorTest { assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PAYER_TAG_MISMATCH), validator.validate(zap)) } + @Test + fun anIssuerlessOfferCannotBindTheInvoiceSoTheZapIsAcceptedButUnverified() = + runTest { + // The offer publishes no offer_issuer_id, so the invoice's node key can't be + // tied to the offer — a self-consistent proof proves nothing about paying it. + val signer = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 12).toByte() } + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount, withIssuerId = false) + val intent = signedIntent(signer, 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)) + assertIs(result) + assertTrue(!result.proofCryptoVerified, "an offer with no issuer id cannot be crypto-verified") + } + @Test fun rejectsWhenTheProofDoesNotMatchTheOffer() = runTest { From 9075fb39b85b68fe562ea060c8ae9b25c867f192 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:18:27 +0000 Subject: [PATCH 05/23] feat(quartz): add BOLT12 offer list (NIP-2421 kind 10058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP was updated to publish a recipient's BOLT12 offer(s) in a dedicated replaceable event (kind 10058, `bolt12_offer`) instead of a kind:0 field — this is what ties an offer to a Nostr identity (the author's signature) and gives BOLT12 zaps the send-side addressability that lightning zaps get from lud16. - Bolt12OfferListEvent (kind 10058): a BaseReplaceableEvent holding one or more `["offer","lno1..."]` tags (reusing OfferTag), with offers()/firstOffer() accessors and create/updateOffers factories (mirrors ChatMessageRelayListEvent). - Registered in EventFactory + a KindNames display entry. - Tests: offers round-trip + factory typing, malformed-offer-tag filtering, and updateOffers replacing the offer set while keeping other tags. Discovery: a payer fetches the recipient's latest 10058 and picks an offer. The app-side caching, subscription, editor, and payment intent follow in later commits. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../offer/Bolt12OfferListEvent.kt | 102 ++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../offer/Bolt12OfferListEventTest.kt | 82 ++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index b5a13b6e88..a7904e51aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -303,6 +303,7 @@ 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.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -541,6 +542,7 @@ object KindNames { RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"), + Bolt12OfferListEvent.KIND to KindName("Bolt12 Offers", "XX"), PutUserEvent.KIND to KindName("Group Put User", "29"), RemoveUserEvent.KIND to KindName("Group Remove User", "29"), EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt new file mode 100644 index 0000000000..3c16edb97d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEvent.kt @@ -0,0 +1,102 @@ +/* + * 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.offer + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-XX: BOLT12 Zaps — a user's **BOLT12 offer list** (kind 10058). + * + * A replaceable event, anchored to the author's pubkey, that publishes one or more + * canonical raw BOLT12 offers (`["offer", "lno1..."]`). This is how a payer + * discovers a recipient's offer before sending a BOLT12 zap — the on-Nostr + * analogue of the `lud16` metadata field NIP-57 lightning zaps use, and the thing + * that ties an offer to a Nostr identity (the author's signature). + * + * Fetch a recipient's latest 10058 and pick one of its offers; when more than one + * is present, a payer MAY select any it supports. Content SHOULD be empty. + */ +@Immutable +class Bolt12OfferListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** All canonical raw BOLT12 offers (`lno1...`) this user publishes, in tag order. */ + fun offers(): List = tags.mapNotNull(OfferTag::parse) + + /** The first valid offer, or null when the list carries none. */ + fun firstOffer(): String? = tags.firstNotNullOfOrNull(OfferTag::parse) + + companion object { + const val KIND = 10058 + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createTagArray(offers: List): Array> = + offers + .map { OfferTag.assemble(it) } + .toTypedArray() + + /** Replace the offer set, preserving any non-offer tags an earlier version carried. */ + suspend fun updateOffers( + earlierVersion: Bolt12OfferListEvent, + offers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): Bolt12OfferListEvent { + val tags = + earlierVersion.tags + .filter { !OfferTag.isTag(it) } + .plus(offers.map { OfferTag.assemble(it) }) + .toTypedArray() + + return signer.sign(createdAt, KIND, tags, earlierVersion.content) + } + + suspend fun create( + offers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "") + + fun create( + offers: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "") + } +} 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 1100748727..bec4ccc5fb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -395,6 +395,7 @@ 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.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -733,6 +734,7 @@ class EventFactory { 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) + Bolt12OfferListEvent.KIND -> Bolt12OfferListEvent(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/offer/Bolt12OfferListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt new file mode 100644 index 0000000000..104ce802e9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/offer/Bolt12OfferListEventTest.kt @@ -0,0 +1,82 @@ +/* + * 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.offer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Bolt12OfferListEventTest { + private val signer = NostrSignerInternal(KeyPair()) + private val offerA = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4)) + private val offerB = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(5, 6, 7, 8)) + + @Test + fun holdsOffersAndResolvesTheFactoryType() = + runTest { + val event = Bolt12OfferListEvent.create(listOf(offerA, offerB), signer) + + assertEquals(Bolt12OfferListEvent.KIND, event.kind) + assertEquals("", event.content) + assertEquals(listOf(offerA, offerB), event.offers()) + assertEquals(offerA, event.firstOffer()) + assertTrue(Event.fromJson(event.toJson()) is Bolt12OfferListEvent) + } + + @Test + fun ignoresMalformedOfferTags() = + runTest { + // Manually build a list with one good and one junk offer tag. + val event = + Bolt12OfferListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("offer", offerA), arrayOf("offer", "not-an-offer")), + content = "", + sig = "c".repeat(128), + ) + assertEquals(listOf(offerA), event.offers()) + } + + @Test + fun updateOffersReplacesTheOfferSetKeepingOtherTags() = + runTest { + val original = + signer.sign( + 1_700_000_000L, + Bolt12OfferListEvent.KIND, + arrayOf(arrayOf("offer", offerA), arrayOf("alt", "my offers")), + "", + ) as Bolt12OfferListEvent + + val updated = Bolt12OfferListEvent.updateOffers(original, listOf(offerB), signer) + + assertEquals(listOf(offerB), updated.offers()) + // The non-offer tag survives the update. + assertTrue(updated.tags.any { it.isNotEmpty() && it[0] == "alt" && it[1] == "my offers" }) + } +} From c5d89ff7a0ddfee5e49c1f26ad90ba239c8a5f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:28:43 +0000 Subject: [PATCH 06/23] feat(amethyst): cache + keep-live the BOLT12 offer list (kind 10058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads and keeps kind 10058 fresh the way the per-user relay/payment lists do, so a recipient's BOLT12 offer is available for discovery before a zap. - LocalCache consumes 10058 as an addressable note (consumeBaseReplaceable), keyed by Address(10058, pubkey, ""). - User gains a pinned bolt12OfferListNote + bolt12OfferList()/bolt12Offers() accessors — this is how a payer reads a recipient's offers. - Bolt12OfferListState: the logged-in user's own offer list as live account state (a StateFlow> of offers), persisted across restarts and published via saveOffers() — cloned from NipA3PaymentTargetsState. Wired into Account (bolt12OfferList + saveBolt12Offers), AccountSettings (backup + updater), and LocalPreferences (persist/restore). - Subscriptions: kind 10058 added to FilterUserMetadataForKey (fetches OTHER users' offers — the key edit for zap recipients) and to the account's FilterAccountInfoAndListsFromKey (fetches your own at login). Editor UI and the payment intent follow next. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/LocalPreferences.kt | 7 ++ .../vitorpamplona/amethyst/model/Account.kt | 5 ++ .../amethyst/model/AccountSettings.kt | 12 +++ .../amethyst/model/LocalCache.kt | 5 ++ .../bolt12Offers/Bolt12OfferListState.kt | 90 +++++++++++++++++++ .../FilterAccountInfoAndListsFromKey.kt | 2 + .../user/watchers/FilterUserMetadataForKey.kt | 2 + .../amethyst/commons/model/User.kt | 9 ++ 8 files changed, 132 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 43bb84c9a2..ef75600b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -211,6 +212,7 @@ private object PrefKeys { const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" const val SHARED_SETTINGS = "shared_settings" const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets" + const val LATEST_BOLT12_OFFERS = "latestBolt12Offers" const val LATEST_CASHU_WALLET = "latestCashuWallet" const val LATEST_NUTZAP_INFO = "latestNutzapInfo" } @@ -588,6 +590,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList) putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList) putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets) + putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers) putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet) putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo) @@ -777,6 +780,7 @@ object LocalPreferences { val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null) val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null) val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null) + val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null) val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null) val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null) val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null) @@ -840,6 +844,7 @@ object LocalPreferences { val latestKeyPackageRelayList = async { parseEventOrNull(latestKeyPackageRelayListStr) } val latestFavoriteAlgoFeedsList = async { parseEventOrNull(latestFavoriteAlgoFeedsListStr) } val latestPaymentTargets = async { parseEventOrNull(latestPaymentTargetsStr) } + val latestBolt12Offers = async { parseEventOrNull(latestBolt12OffersStr) } val latestCashuWallet = async { parseEventOrNull(latestCashuWalletStr) @@ -895,6 +900,7 @@ object LocalPreferences { val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await() val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await() val latestPaymentTargetsResolved = latestPaymentTargets.await() + val latestBolt12OffersResolved = latestBolt12Offers.await() val latestCashuWalletResolved = latestCashuWallet.await() val latestNutzapInfoResolved = latestNutzapInfo.await() @@ -997,6 +1003,7 @@ object LocalPreferences { viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved), pendingAttestations = MutableStateFlow(pendingAttestationsResolved), backupNipA3PaymentTargets = latestPaymentTargetsResolved, + backupBolt12Offers = latestBolt12OffersResolved, backupCashuWallet = latestCashuWalletResolved, backupNutzapInfo = latestNutzapInfoResolved, callsEnabled = MutableStateFlow(callsEnabled), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a8ba363c3d..4ce4e79953 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator +import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -760,6 +761,8 @@ class Account( val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) + val bolt12OfferList = Bolt12OfferListState(signer, cache, scope, settings) + val feedDecryptionCaches = FeedDecryptionCaches( peopleListCache = peopleListDecryptionCache, @@ -5541,6 +5544,8 @@ class Account( suspend fun savePaymentTargets(targets: List) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets)) + suspend fun saveBolt12Offers(offers: List) = sendMyPublicAndPrivateOutbox(bolt12OfferList.saveOffers(offers)) + fun markAsRead( route: String, timestampInSecs: Long, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 9ffc3e7d31..cf62e2454c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -322,6 +323,7 @@ class AccountSettings( val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, + var backupBolt12Offers: Bolt12OfferListEvent? = null, var callTurnServers: List = emptyList(), var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, @@ -1260,6 +1262,16 @@ class AccountSettings( } } + fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) { + if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupBolt12Offers?.id != newBolt12Offers.id) { + backupBolt12Offers = newBolt12Offers + saveAccountSettings() + } + } + fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) { if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d203316fb8..01b4a46a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -404,6 +404,7 @@ 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.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent @@ -4260,6 +4261,10 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is Bolt12OfferListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is ClassifiedsEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt new file mode 100644 index 0000000000..d32b024393 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/bolt12Offers/Bolt12OfferListState.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.bolt12Offers + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * The logged-in user's NIP-XX BOLT12 offer list (kind 10058) as live account state, + * mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState]. + * Exposes the current offers as a [flow], persists them across restarts (via + * [AccountSettings]), and publishes updates with [saveOffers]. + */ +class Bolt12OfferListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + val bolt12OfferListNote = cache.getOrCreateAddressableNote(getBolt12OfferListAddress()) + + fun getBolt12OfferListFlow(): StateFlow = bolt12OfferListNote.flow().metadata.stateFlow + + fun getBolt12OfferListAddress() = Bolt12OfferListEvent.createAddress(signer.pubKey) + + fun getBolt12OfferListEvent(): Bolt12OfferListEvent? = bolt12OfferListNote.event as? Bolt12OfferListEvent + + /** The user's currently-published canonical raw BOLT12 offers. */ + val flow: StateFlow> = + getBolt12OfferListFlow() + .map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + suspend fun saveOffers(offers: List): Bolt12OfferListEvent { + val existing = getBolt12OfferListEvent() + return if (existing != null && existing.tags.isNotEmpty()) { + Bolt12OfferListEvent.updateOffers(existing, offers, signer) + } else { + Bolt12OfferListEvent.create(offers, signer) + } + } + + init { + settings.backupBolt12Offers?.let { + Log.d("AccountRegisterObservers") { "Loading saved BOLT12 offer list ${it.toJson()}" } + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + getBolt12OfferListFlow().collect { + (it.note.event as? Bolt12OfferListEvent)?.let { offerListEvent -> + settings.updateBolt12Offers(offerListEvent) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index ae7323972e..8299f85493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent val AccountInfoAndListsFromKeyKinds = listOf( @@ -80,6 +81,7 @@ val AccountInfoAndListsFromKeyKinds2 = GeohashListEvent.KIND, TrustProviderListEvent.KIND, PaymentTargetsEvent.KIND, + Bolt12OfferListEvent.KIND, RelayFeedsListEvent.KIND, InterestSetEvent.KIND, // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 6de480a111..294ca849df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.mapOfSet val UserMetadataForKeyKinds = @@ -46,6 +47,7 @@ val UserMetadataForKeyKinds = ChatMessageRelayListEvent.KIND, KeyPackageRelayListEvent.KIND, PaymentTargetsEvent.KIND, + Bolt12OfferListEvent.KIND, // NIP-61 nutzap-info. Telegraphs which mints + P2PK pubkey this // user accepts nutzaps at. Co-loaded with kind:0 so the zap-picker // can decide whether to show the Nutzap chip the moment a note's diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index d9787fd2cd..9b48c1555c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.Hex import kotlin.concurrent.Volatile @@ -77,6 +78,8 @@ class User( val nutzapInfoNote: Note = context.addressableNote(NutzapInfoEvent.createAddress(pubkeyHex)) + val bolt12OfferListNote: Note = context.addressableNote(Bolt12OfferListEvent.createAddress(pubkeyHex)) + // These objects are designed to keep the cache // while this user obj is being used anywhere. // @@ -113,6 +116,12 @@ class User( fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent + /** This user's published BOLT12 offer list (NIP-XX kind 10058), or null if none seen. */ + fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent + + /** The canonical raw BOLT12 offers (`lno1...`) this user accepts, empty when none published. */ + fun bolt12Offers(): List = bolt12OfferList()?.offers().orEmpty() + /** True when this user has published a kind:10019 with a P2PK pubkey. */ fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null From 999bb14032102becafa5df48aec517128b648997 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:43:07 +0000 Subject: [PATCH 07/23] feat(bolt12): add editor to publish own kind:10058 BOLT12 offer list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Settings entry (mirroring the Payment Targets editor) that lets the logged-in user add/remove reusable BOLT12 offers (lno1…) and publishes them as a replaceable kind:10058 offer list. Offers are validated against Bolt12Bech32 before being accepted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../bolt12Offers/Bolt12OffersScreen.kt | 251 ++++++++++++++++++ .../bolt12Offers/Bolt12OffersViewModel.kt | 98 +++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 9 + 6 files changed, 363 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt new file mode 100644 index 0000000000..f482a2b626 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.bolt12Offers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun Bolt12OffersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: Bolt12OffersViewModel = viewModel() + viewModel.init(accountViewModel) + + LaunchedEffect(key1 = accountViewModel) { + viewModel.load() + } + + Bolt12OffersScaffold(viewModel) { + nav.popBack() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Bolt12OffersScaffold( + viewModel: Bolt12OffersViewModel, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.bolt12_offers, + onCancel = { + viewModel.refresh() + onClose() + }, + onPost = { + viewModel.saveOffers() + onClose() + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringRes(id = R.string.bolt12_offers_explainer), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + Bolt12OffersBody(viewModel) + } + } +} + +@Composable +fun Bolt12OffersBody(viewModel: Bolt12OffersViewModel) { + val offers by viewModel.offers.collectAsStateWithLifecycle() + + LazyColumn( + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = FeedPadding, + ) { + item { + SettingsCategory( + R.string.bolt12_offers, + R.string.bolt12_offers_section_explainer, + SettingsCategoryFirstModifier, + ) + } + + if (offers.isEmpty()) { + item { + Text( + text = stringRes(id = R.string.no_bolt12_offers_message), + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } else { + items(offers, key = { it }) { offer -> + Bolt12OfferEntry(offer = offer, onDelete = { viewModel.removeOffer(offer) }) + } + } + + item { + Spacer(modifier = StdVertSpacer) + Bolt12OfferAddField { raw -> viewModel.addOffer(raw) } + } + } +} + +@Composable +fun Bolt12OfferEntry( + offer: String, + onDelete: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, + ) { + Text( + text = "${offer.take(14)}…${offer.takeLast(6)}", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDelete) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(id = R.string.delete_bolt12_offer), + ) + } + } +} + +@Composable +fun Bolt12OfferAddField(onAdd: (raw: String) -> Boolean) { + var offer by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(Size10dp)) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.bolt12_offer)) }, + modifier = Modifier.fillMaxWidth(), + value = offer, + onValueChange = { + offer = it + isError = false + }, + isError = isError, + supportingText = + if (isError) { + { Text(text = stringRes(R.string.invalid_bolt12_offer)) } + } else { + null + }, + placeholder = { + Text( + text = "lno1…", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + if (onAdd(offer)) { + offer = "" + isError = false + } else { + isError = true + } + }, + shape = ButtonBorder, + enabled = offer.isNotBlank(), + ) { + Text(text = stringRes(id = R.string.add), color = Color.White) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt new file mode 100644 index 0000000000..bc594dcbff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.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.amethyst.ui.actions.bolt12Offers + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Edits the logged-in user's NIP-XX BOLT12 offer list (kind 10058). Mirrors + * [com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel]; + * each entry is a canonical raw `lno1...` offer string. + */ +@Stable +class Bolt12OffersViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + private val _offers = MutableStateFlow>(emptyList()) + val offers = _offers.asStateFlow() + private var isModified = false + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { + refresh() + } + + fun refresh() { + isModified = false + viewModelScope.launch { + _offers.update { account.bolt12OfferList.flow.value } + } + } + + /** Returns the canonical offer if [raw] is a well-formed BOLT12 offer, else null. */ + fun canonicalOfferOrNull(raw: String): String? { + val canonical = Bolt12Bech32.canonicalize(raw) + return if (Bolt12Bech32.isOffer(canonical)) canonical else null + } + + /** Adds [raw] if it's a valid offer not already present; returns true when added. */ + fun addOffer(raw: String): Boolean { + val canonical = canonicalOfferOrNull(raw) ?: return false + if (_offers.value.contains(canonical)) return false + _offers.update { it.plus(canonical) } + isModified = true + return true + } + + fun removeOffer(offer: String) { + _offers.update { it.minus(offer) } + isModified = true + } + + fun saveOffers() { + if (isModified) { + accountViewModel.launchSigner { + saveOffersSuspend() + } + } + } + + suspend fun saveOffersSuspend() { + if (isModified) { + account.saveBolt12Offers(_offers.value) + refresh() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6811206223..2a2b43ff4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen +import com.vitorpamplona.amethyst.ui.actions.bolt12Offers.Bolt12OffersScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress @@ -591,6 +592,7 @@ fun BuildNavigation( } composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } + composableFromEnd { Bolt12OffersScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9401c1916d..fcd8179e4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -484,6 +484,8 @@ sealed class Route { @Serializable object EditPaymentTargets : Route() + @Serializable object EditBolt12Offers : Route() + @Serializable object UpdateReactionType : Route() @Serializable data class Nip47NWCSetup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index b63e323eef..d6c3644a7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -74,6 +74,7 @@ fun buildSettingsCatalog( symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), + symEntry(R.string.bolt12_offers, MaterialSymbols.Payment, R.string.bolt12_offers_search_keywords, Route.EditBolt12Offers), symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d820e271eb..bd5521bde7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1718,6 +1718,14 @@ No app installed to handle %1$s payments. Please install a compatible wallet. Unable to open payment + BOLT12 Offers + Publish reusable BOLT12 offers so others can zap you over Lightning without a separate invoice each time. + Add one or more BOLT12 offers (lno1…). They are shared publicly so anyone can pay you. + No BOLT12 offers set. Add one below ↓ + BOLT12 offer (lno1…) + Not a valid BOLT12 offer + Delete BOLT12 offer + Not Started Compressing Uploading @@ -2155,6 +2163,7 @@ video, player, playback, autoplay, mute audio, visualizer, spectrum, bars, waves, radial, aurora, animation zap split, split, recipients, forward zaps + bolt12, bolt 12, offer, lno, lightning, zap, reusable invoice webrtc, video call, voice call, calls language, translate, locale opentimestamps, timestamp, ots, proof From 3a906e8b6b5571816f87d37c15e8291580c78504 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:45:31 +0000 Subject: [PATCH 08/23] feat(bolt12): pay a recipient's BOLT12 offer via wallet intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a profile action that reads the recipient's kind:10058 offer list and, for each published BOLT12 offer, hands it to an installed wallet via the lightning: scheme (payViaBolt12Intent, sibling to the BOLT11 payViaIntent). This is the first payment step — a plain wallet handoff, not a NIP-XX zap, so it produces no Nostr receipt. NWC payment instructions are left for later. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/ui/note/ZapCustomDialog.kt | 24 +++ .../profile/header/Bolt12PayButton.kt | 199 ++++++++++++++++++ .../loggedIn/profile/header/ProfileActions.kt | 2 + 3 files changed, 225 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 6a62b3c88b..31d0baf0bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -543,6 +543,30 @@ fun payViaIntent( } } +/** + * Hands a reusable BOLT12 offer (`lno1…`, from a recipient's kind:10058) off to an + * installed wallet via the `lightning:` scheme — the same handoff [payViaIntent] uses + * for a BOLT11 invoice. The wallet collects the amount and completes the payment; this + * is a plain intent, not a NIP-57/NIP-XX zap, so it produces no Nostr receipt. + */ +fun payViaBolt12Intent( + offer: String, + context: Context, + onPaid: () -> Unit, + onError: (String) -> Unit, +) { + try { + val intent = Intent(Intent.ACTION_VIEW, "lightning:$offer".toUri()) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + context.startActivity(intent) + onPaid() + } catch (e: Exception) { + if (e is CancellationException) throw e + onError(stringRes(context, R.string.no_wallet_found)) + } +} + @Composable fun PayButton( isActive: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt new file mode 100644 index 0000000000..8b8a84d482 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.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.amethyst.ui.screen.loggedIn.profile.header + +import android.widget.Toast +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import kotlinx.coroutines.launch + +@Composable +fun Bolt12PayButton( + user: User, + accountViewModel: AccountViewModel, +) { + val address = + remember(user.pubkeyHex) { + Bolt12OfferListEvent.createAddress(user.pubkeyHex) + } + + LoadAddressableNote(address, accountViewModel) { note -> + if (note != null) { + EventFinderFilterAssemblerSubscription(note, accountViewModel) + val event by observeNoteEvent(note, accountViewModel) + val offers = + remember(event) { + event?.offers() ?: emptyList() + } + if (offers.isNotEmpty()) { + Bolt12PayButtonWithOffers(offers) + } + } + } +} + +@Composable +fun Bolt12PayButtonWithOffers(offers: List) { + var expanded by remember { mutableStateOf(false) } + + FilledTonalButton( + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = { expanded = true }, + contentPadding = ZeroPadding, + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = stringRes(R.string.bolt12_offers), + ) + } + + if (expanded) { + Bolt12OffersDialog( + offers = offers, + onDismiss = { expanded = false }, + ) + } +} + +@Composable +fun Bolt12OffersDialog( + offers: List, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + M3ActionDialog( + title = stringRes(R.string.bolt12_offers), + onDismiss = onDismiss, + ) { + M3ActionSection { + offers.forEach { offer -> + Bolt12OfferRow( + offer = offer, + onCopy = { + scope.launch { + clipboardManager.setText(offer) + Toast + .makeText( + context, + stringRes(context, R.string.copied_to_clipboard), + Toast.LENGTH_SHORT, + ).show() + } + }, + onPay = { + payViaBolt12Intent( + offer = offer, + context = context, + onPaid = { onDismiss() }, + onError = { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + }, + ) + }, + ) + } + } + } +} + +@Composable +private fun Bolt12OfferRow( + offer: String, + onCopy: () -> Unit, + onPay: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Text( + text = "${offer.take(14)}…${offer.takeLast(6)}", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onCopy) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.copy_to_clipboard), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onPay) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = stringRes(R.string.bolt12_offers), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index 0e13044795..caf19ded86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -42,6 +42,8 @@ fun ProfileActions( PaymentButton(baseUser, accountViewModel, nav) + Bolt12PayButton(baseUser, accountViewModel) + val isMe by remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } From 70d38b205e3be1eaf460eee6e86e4e43402f80a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:53:38 +0000 Subject: [PATCH 09/23] fix(bolt12): hand offers off as bitcoin:?lno= (BIP21), not lightning: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BOLT12 offer (lno1…) is not a lightning: payload — that scheme is for BOLT11 lnbc… invoices. Per BIP21/BIP321 an offer travels as the lno parameter of a bitcoin URI (bitcoin:?lno=lno1…), with the on-chain address optional so a Lightning-only offer stands alone. The bech32 offer value is URL-safe, so no percent-encoding is needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 31d0baf0bd..9efe9bc434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -545,9 +545,12 @@ fun payViaIntent( /** * Hands a reusable BOLT12 offer (`lno1…`, from a recipient's kind:10058) off to an - * installed wallet via the `lightning:` scheme — the same handoff [payViaIntent] uses - * for a BOLT11 invoice. The wallet collects the amount and completes the payment; this - * is a plain intent, not a NIP-57/NIP-XX zap, so it produces no Nostr receipt. + * installed wallet. Unlike a BOLT11 invoice, a BOLT12 offer is NOT a `lightning:` + * payload — that scheme is defined for `lnbc…` invoices. Offers travel as the `lno` + * parameter of a BIP21/BIP321 bitcoin URI (`bitcoin:?lno=lno1…`), where the on-chain + * address is optional so a Lightning-only offer stands on its own. The wallet resolves + * the offer, collects the amount, and completes the payment; this is a plain intent, + * not a NIP-57/NIP-XX zap, so it produces no Nostr receipt. */ fun payViaBolt12Intent( offer: String, @@ -556,7 +559,7 @@ fun payViaBolt12Intent( onError: (String) -> Unit, ) { try { - val intent = Intent(Intent.ACTION_VIEW, "lightning:$offer".toUri()) + val intent = Intent(Intent.ACTION_VIEW, "bitcoin:?lno=$offer".toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK context.startActivity(intent) From 6d111626ee10b0149fd976ac359d1abe177dbe49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 20:16:38 +0000 Subject: [PATCH 10/23] fix(bolt12): don't let an unverified proof grief a verified zap total The payment-hash dedup kept the LOWER amount and OR'd cryptoVerified across entries. Because a payer proof publishes its proof_preimage, once a BOLT12 zap is public anyone can replay its payment hash in a compressed (unverifiable) proof with a 1-msat amount; the merge would keep that amount AND inherit the verified flag, driving the counted total to ~zero and mislabeling a fabricated amount as verified. Per the NIP, dedup by invoice_payment_hash applies among *validated* proofs. Only a crypto-verified proof has a signature-bound amount, so a verified entry now always wins over an unverified duplicate; the lower-amount rule applies only between entries of the same verification status. Adds a two-order regression test for the replay-griefing case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/commons/model/Note.kt | 23 +++++++++++++------ .../commons/model/NoteBolt12ZapTest.kt | 22 ++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 9704682c33..910adf0a82 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -742,14 +742,23 @@ open class Note( val merged = if (existing == null) { entry + } else if (entry.cryptoVerified != existing.cryptoVerified) { + // Cross-verification dedup. Only a crypto-verified proof has an amount + // bound by the invoice signature; an unverified one (a compressed proof, + // or an offer we can't bind) carries a self-chosen amount and payment + // hash. Because a payer proof publishes its `proof_preimage`, once any + // BOLT12 zap is public anyone can replay that preimage in a fresh, + // unverifiable proof with a 1-msat amount and the same payment hash — so + // if we let the lower amount win here and inherited the verified flag, a + // griefer could drive the counted total to ~zero and mislabel a fake + // amount as verified. Keep the verified entry outright, whichever amount + // it carries; never let an unverified duplicate override it. + if (entry.cryptoVerified) entry else existing } else { - // Same settled payment (dedup by invoice_payment_hash). NIP-XX: count - // only one, and if amounts differ, keep the LOWER — so a re-publish - // with a bigger amount tag can't inflate the total. Keep the stronger - // verification flag, and the source of whichever entry we keep the - // amount from. - val keepEntry = if (entry.amountMillisats < existing.amountMillisats) entry else existing - keepEntry.copy(cryptoVerified = entry.cryptoVerified || existing.cryptoVerified) + // Same verification status: dedup by the settled payment and, if amounts + // differ, keep the LOWER — so a re-publish with a bigger amount tag can't + // inflate the total (NIP-XX). Both share the same cryptoVerified flag. + if (entry.amountMillisats < existing.amountMillisats) entry else existing } if (merged == existing) return@withLock false bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, merged) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt index 1217bdf50b..72f17ca609 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteBolt12ZapTest.kt @@ -80,6 +80,28 @@ class NoteBolt12ZapTest { assertTrue(target.bolt12Zaps["h"]!!.cryptoVerified, "a verified entry must not be overwritten by an unverified one") } + @Test + fun aLowerUnverifiedRepublishCannotGriefAVerifiedTotal() { + // A payer proof publishes its proof_preimage, so once a BOLT12 zap is public + // anyone can replay the same payment hash in an UNVERIFIABLE (e.g. compressed) + // proof carrying a self-chosen 1-msat amount. The lower-amount dedup MUST apply + // only among validated proofs — an unverified duplicate can neither lower the + // counted amount nor inherit the verified flag. Both arrival orders. + val verifiedFirst = note("a".repeat(64)) + verifiedFirst.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true) + verifiedFirst.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 1L, cryptoVerified = false) + assertEquals(1, verifiedFirst.bolt12Zaps.size) + assertTrue(verifiedFirst.bolt12Zaps["h"]!!.cryptoVerified) + assertEquals(5_000L, verifiedFirst.zapsAmount.toLong(), "an unverified 1-msat replay must not grief the total down") + + val unverifiedFirst = note("a".repeat(64)) + unverifiedFirst.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 1L, cryptoVerified = false) + unverifiedFirst.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true) + assertEquals(1, unverifiedFirst.bolt12Zaps.size) + assertTrue(unverifiedFirst.bolt12Zaps["h"]!!.cryptoVerified) + assertEquals(5_000L, unverifiedFirst.zapsAmount.toLong(), "order must not matter") + } + @Test fun removingBySourceDropsTheEntryAndUpdatesTheTotal() { val target = note("a".repeat(64)) From 802652dd4f519d0cc8619fceddde773d16fe8067 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:01:20 +0000 Subject: [PATCH 11/23] docs(bolt12): scope NWC BOLT12 pay/receive (nwc#2) integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the design for paying BOLT12 offers over NIP-47 and — via the pay result's payer_proof — sending real kind:9736 zaps. Maps the reusable NIP-47 plumbing, breaks the work into phases, and flags the linchpin risk: nwc#2 does not guarantee payer_note lands in the BOLT12 invreq_payer_note that NIP-2421's zap binding depends on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 124 ++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 amethyst/plans/2026-07-24-nwc-bolt12-pay.md diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md new file mode 100644 index 0000000000..e689f0424e --- /dev/null +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -0,0 +1,124 @@ +# NWC BOLT12 payments (nostr-wallet-connect/nwc#2) + +Status: **scoping** — no code yet. Depends on NIP-2421 (this branch) and the +unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47). + +## What nwc#2 adds + +Two generalized methods replace the bolt11-only `pay_invoice`: + +- **`pay`** — params `{ payment: "bitcoin:?lno=lno1…", amount?, payer_note?, metadata? }`. + `payment` is a BIP321 URI (so `bitcoin:?lno=` — exactly what + `payViaBolt12Intent` already builds — or `lightning=`/on-chain). `amount` + (msats) is required only when the instruction has no amount. Result: + `{ transaction_id, state, instruction_type, amount, fees_paid, payment_hash, + preimage, payer_proof: "lnp1…", txid, failure_reason, created_at, settled_at }`. +- **`receive`** — params `{ amount?, description?, metadata? }` → result + `{ bip321: "bitcoin:?lightning=lnbc…&lno=lno1…", transaction_id }`. Lets a + wallet mint our own unified offer; ties to the kind-10058 editor (auto-fill + offers) — later phase. + +New errors: `UNSUPPORTED_PAYMENT_INSTRUCTION`, `UNSUPPORTED_NETWORK`. Wallets +advertise support in their kind-13194 info event + `get_info.methods`. + +## Two capabilities this unlocks + +**A. In-app payment of an offer** — the "extra payment instruction" half. Today +`Bolt12PayButton` fires a `bitcoin:?lno=` intent to an external wallet. With `pay` +we can settle the offer over the user's already-configured NWC connection, no app +switch. No Nostr receipt. + +**B. Sending real BOLT12 zaps** — the half deferred since the start of the branch. +The `pay` result carries **`payer_proof: "lnp1…"`**, which is precisely the input +`Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey)` needs. So NWC `pay` +is the payment rail that produces the proof for a kind-9736 zap. This is the +strategic reason to do this now. + +## Existing NIP-47 infra we reuse (from the code map) + +The send/await/correlate plumbing is **method-agnostic** — it keys on request id +and dispatches the decrypted `Response`, so a new method needs no changes there: + +- `NwcSignerState.sendNwcRequestToWallet(uri, request, onResponse)` + (`amethyst/…/model/nip47WalletConnect/NwcSignerState.kt`) — builds the 23194, + synchronous REQ-before-EVENT via `NWCPaymentFilterAssembler`, 60 s timeout, + decrypt-on-arrival. +- `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the + author-spoof gate. +- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback. +- Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`; + `PaymentSourceResolver`. +- Pay rail entry: `ZapPaymentHandler.zap()` → `payViaNWC()` (the `PaymentSource.Nwc` + branch). + +Capability discovery exists (`NwcInfoEvent.supportsMethod`, `GetInfoResult.methods`) +but is **not** wired into the pay path — we'd add the gate ourselves. + +## Work breakdown + +### Phase 0 — quartz protocol (`nip47WalletConnect`) +- `NwcMethod.PAY = "pay"`, `NwcMethod.RECEIVE = "receive"`. +- `rpc/Request.kt`: `PayMethod` + `PayParams(payment, amount, payerNote, metadata)` + with `create(…)`; `ReceiveMethod`/`ReceiveParams`. +- `rpc/Response.kt`: `PaySuccessResponse` (all result fields above, `payerProof` + nullable) and `ReceiveSuccessResponse(bip321, transactionId)`. +- `rpc/NwcErrorCode.kt`: add the two new codes. +- Serializer branches in **both** `Nip47RequestKSerializer` / `Nip47ResponseKSerializer` + **and** the jvmAndroid Jackson variants. +- Optional `Nip47Client.pay(...)` builder. +- Tests: request/response round-trip; a real captured `pay` result fixture. +- No new third-party deps (pure protocol) → licensing clean. + +### Phase 1 — in-app offer payment (capability A) +- `Account.sendNwcPayRequest(payment, amount, payerNote, onResponse)` wrapper + (mirrors `sendZapPaymentRequestFor`, reuses `NwcSignerState`). +- In `Bolt12PayButton`'s dialog: when an NWC wallet is configured, add a "Pay with + connected wallet" action (amount-entry sheet, since offers are often amountless) + → `pay` with `payment=bitcoin:?lno=`. Keep the external-intent path as + fallback. +- Surface `UNSUPPORTED_PAYMENT_INSTRUCTION`/`PAYMENT_FAILED`. + +### Phase 2 — send BOLT12 zaps (capability B) +- New `Bolt12ZapSender` (or extend `ZapPaymentHandler`): when the recipient has a + kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap. + 1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content). + 2. `pay` with `payment=bitcoin:?lno=`, `amount`, + `payer_note="nostr:nipXX:"`. + 3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof, + payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox + relays. + 4. Our own `LocalCache` consumes the 9736 and counts it. +- Anonymous vs attributed (`P` tag) toggle, mirroring lightning-zap anonymity. + +### Phase 3 — gating + receive (later) +- Read `NwcInfoEvent.supportsMethod("pay")` / `get_info.methods` to show the NWC + BOLT12 options only when supported; else fall back to the intent. +- `receive` to mint the user's own offer and pre-fill the kind-10058 editor. + +## Risks / open questions (decide before Phase 2) + +1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only + says "if `payer_note` is not empty, the selected instruction MUST support + payer-provided messages" — it never states the note lands in the BOLT12 + `invreq_payer_note`. NIP-2421 binds the zap to the intent through exactly that + field (`invreq_payer_note == nostr:nipXX:`). If a wallet routes + `payer_note` elsewhere, the returned `payer_proof` fails our validator and the + 9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation + in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down. +2. **`payer_proof` is best-effort** (`"optional if unavailable"`, no wallet mandate). + A wallet may settle the offer and return no proof → payment succeeds but we + can't publish a zap. Phase 1 is unaffected; Phase 2 must degrade gracefully + ("paid, but no zap receipt available"). +3. **Our verifier can't check compressed proofs yet** (see + `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet proofs are + compressed, so a zap we send may show locally as unverified. Publishing is fine; + local counting waits on the merkle-reconstruction work. +4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement + `pay` today. Gate hard on capability (Phase 3) and keep the intent fallback. + +## Recommendation + +Phase 0 is safe, self-contained, and unblocks everything — do it regardless. +Phase 1 (in-app pay) is low-risk and immediately useful. **Hold Phase 2 until +risk #1 is resolved** — it's the high-value piece (real zap sending) but its +correctness depends on a binding the current nwc#2 text doesn't guarantee. From 7d3f7f7ca4d7d6d8f7ecdbf56bd3f2852c0defa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:38:32 +0000 Subject: [PATCH 12/23] feat(nwc): add pay/receive methods for BOLT12 (nostr-wallet-connect/nwc#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the generalized `pay` (BIP321 payment instruction, incl. BOLT12 `lno=`) and `receive` NIP-47 methods, plus the UNSUPPORTED_PAYMENT_INSTRUCTION / UNSUPPORTED_NETWORK error codes. The `pay` result carries `payer_proof` (lnp1…) — the proof a kind:9736 BOLT12 zap needs. Wired through both serialization backends (kotlinx + Jackson) with round-trip tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../Nip47RequestKSerializer.kt | 56 ++++++++++++++ .../Nip47ResponseKSerializer.kt | 74 +++++++++++++++++++ .../nip47WalletConnect/rpc/NwcErrorCode.kt | 4 + .../nip47WalletConnect/rpc/NwcMethod.kt | 4 + .../quartz/nip47WalletConnect/rpc/Request.kt | 42 +++++++++++ .../quartz/nip47WalletConnect/rpc/Response.kt | 32 ++++++++ .../nip47WalletConnect/NwcMethodTest.kt | 6 +- .../quartz/nip47WalletConnect/RequestTest.kt | 63 ++++++++++++++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 49 ++++++++++++ .../jackson/RequestDeserializer.kt | 4 + .../jackson/RequestSerializer.kt | 14 ++++ .../jackson/ResponseDeserializer.kt | 10 +++ .../jackson/ResponseSerializer.kt | 14 ++++ 13 files changed, 371 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index e3d905a0ff..a26a3fae6d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -40,6 +40,10 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams @@ -85,6 +89,14 @@ object Nip47RequestKSerializer : KSerializer { value.params?.let { put("params", serializePayInvoiceParams(it)) } } + is PayMethod -> { + value.params?.let { put("params", serializePayParams(it)) } + } + + is ReceiveMethod -> { + value.params?.let { put("params", serializeReceiveParams(it)) } + } + is PayKeysendMethod -> { value.params?.let { put("params", serializePayKeysendParams(it)) } } @@ -138,6 +150,21 @@ object Nip47RequestKSerializer : KSerializer { params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } } + private fun serializePayParams(params: PayParams): JsonObject = + buildJsonObject { + params.payment?.let { put("payment", it) } + params.amount?.let { put("amount", it) } + params.payer_note?.let { put("payer_note", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializeReceiveParams(params: ReceiveParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.description?.let { put("description", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject = buildJsonObject { params.amount?.let { put("amount", it) } @@ -236,6 +263,8 @@ object Nip47RequestKSerializer : KSerializer { return when (method) { NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject) + NwcMethod.PAY -> parsePay(jsonObject) + NwcMethod.RECEIVE -> parseReceive(jsonObject) NwcMethod.PAY_KEYSEND -> parsePayKeysend(jsonObject) NwcMethod.MAKE_INVOICE -> parseMakeInvoice(jsonObject) NwcMethod.LOOKUP_INVOICE -> parseLookupInvoice(jsonObject) @@ -265,6 +294,33 @@ object Nip47RequestKSerializer : KSerializer { ) } + private fun parsePay(json: JsonObject): PayMethod { + val params = json["params"]?.jsonObject + return PayMethod( + params?.let { + PayParams( + payment = it["payment"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + payer_note = it["payer_note"]?.jsonPrimitive?.content, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + + private fun parseReceive(json: JsonObject): ReceiveMethod { + val params = json["params"]?.jsonObject + return ReceiveMethod( + params?.let { + ReceiveParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + description = it["description"]?.jsonPrimitive?.content, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + private fun parsePayKeysend(json: JsonObject): PayKeysendMethod { val params = json["params"]?.jsonObject return PayKeysendMethod( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index 30922c5439..ce77e90fa5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -37,6 +37,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -81,6 +83,14 @@ object Nip47ResponseKSerializer : KSerializer { value.result?.let { put("result", serializePayInvoiceResult(it)) } } + is PaySuccessResponse -> { + value.result?.let { put("result", serializePayResult(it)) } + } + + is ReceiveSuccessResponse -> { + value.result?.let { put("result", serializeReceiveResult(it)) } + } + is PayInvoiceErrorResponse -> { value.error?.let { put("error", serializePayInvoiceErrorParams(it)) } } @@ -155,6 +165,28 @@ object Nip47ResponseKSerializer : KSerializer { error.message?.let { put("message", it) } } + private fun serializePayResult(result: PaySuccessResponse.PayResult): JsonObject = + buildJsonObject { + result.transaction_id?.let { put("transaction_id", it) } + result.state?.let { put("state", it) } + result.instruction_type?.let { put("instruction_type", it) } + result.amount?.let { put("amount", it) } + result.fees_paid?.let { put("fees_paid", it) } + result.payment_hash?.let { put("payment_hash", it) } + result.preimage?.let { put("preimage", it) } + result.payer_proof?.let { put("payer_proof", it) } + result.txid?.let { put("txid", it) } + result.failure_reason?.let { put("failure_reason", it) } + result.created_at?.let { put("created_at", it) } + result.settled_at?.let { put("settled_at", it) } + } + + private fun serializeReceiveResult(result: ReceiveSuccessResponse.ReceiveResult): JsonObject = + buildJsonObject { + result.bip321?.let { put("bip321", it) } + result.transaction_id?.let { put("transaction_id", it) } + } + private fun serializePayKeysendResult(result: PayKeysendSuccessResponse.PayKeysendResult): JsonObject = buildJsonObject { result.preimage?.let { put("preimage", it) } @@ -242,6 +274,14 @@ object Nip47ResponseKSerializer : KSerializer { parsePayInvoiceSuccess(jsonObject) } + NwcMethod.PAY -> { + parsePaySuccess(jsonObject) + } + + NwcMethod.RECEIVE -> { + parseReceiveSuccess(jsonObject) + } + NwcMethod.PAY_KEYSEND -> { parsePayKeysendSuccess(jsonObject) } @@ -387,6 +427,40 @@ object Nip47ResponseKSerializer : KSerializer { ) } + private fun parsePaySuccess(json: JsonObject): PaySuccessResponse { + val result = json["result"]?.jsonObject + return PaySuccessResponse( + result?.let { + PaySuccessResponse.PayResult( + transaction_id = it["transaction_id"]?.jsonPrimitive?.content, + state = it["state"]?.jsonPrimitive?.content, + instruction_type = it["instruction_type"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + preimage = it["preimage"]?.jsonPrimitive?.content, + payer_proof = it["payer_proof"]?.jsonPrimitive?.content, + txid = it["txid"]?.jsonPrimitive?.content, + failure_reason = it["failure_reason"]?.jsonPrimitive?.content, + created_at = it["created_at"]?.jsonPrimitive?.longOrNull, + settled_at = it["settled_at"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseReceiveSuccess(json: JsonObject): ReceiveSuccessResponse { + val result = json["result"]?.jsonObject + return ReceiveSuccessResponse( + result?.let { + ReceiveSuccessResponse.ReceiveResult( + bip321 = it["bip321"]?.jsonPrimitive?.content, + transaction_id = it["transaction_id"]?.jsonPrimitive?.content, + ) + }, + ) + } + private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse { val result = json["result"]?.jsonObject return PayKeysendSuccessResponse( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt index 0a27468342..0ae9ed28cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt @@ -33,6 +33,10 @@ enum class NwcErrorCode { BAD_REQUEST, NOT_FOUND, EXPIRED, + + // nostr-wallet-connect/nwc#2 — pay/receive payment-instruction errors. + UNSUPPORTED_PAYMENT_INSTRUCTION, + UNSUPPORTED_NETWORK, OTHER, } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt index 94d3470f14..6441c74927 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt @@ -34,4 +34,8 @@ object NwcMethod { const val MAKE_HOLD_INVOICE = "make_hold_invoice" const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" + + // nostr-wallet-connect/nwc#2 — generalized payment instructions (BOLT12/BIP321). + const val PAY = "pay" + const val RECEIVE = "receive" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt index a0b3867d69..dfe5031ff3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt @@ -47,6 +47,48 @@ class PayInvoiceMethod( } } +// pay (nostr-wallet-connect/nwc#2) — settle any BIP321 payment instruction +// (bolt11 `lightning=`, BOLT12 `lno=`, or on-chain). `payment` is the BIP321 URI; +// `amount` (msats) is required only when the instruction has no amount; `payer_note` +// is delivered to the payee when the instruction supports payer messages (for a +// BOLT12 zap this carries `nostr:nipXX:`). +class PayParams( + var payment: String? = null, + var amount: Long? = null, + var payer_note: String? = null, + var metadata: Map? = null, +) + +class PayMethod( + var params: PayParams? = null, +) : Request(NwcMethod.PAY) { + companion object { + fun create( + payment: String, + amount: Long? = null, + payerNote: String? = null, + ): PayMethod = PayMethod(PayParams(payment, amount, payerNote)) + } +} + +// receive (nostr-wallet-connect/nwc#2) — mint a payment instruction to be paid. +class ReceiveParams( + var amount: Long? = null, + var description: String? = null, + var metadata: Map? = null, +) + +class ReceiveMethod( + var params: ReceiveParams? = null, +) : Request(NwcMethod.RECEIVE) { + companion object { + fun create( + amount: Long? = null, + description: String? = null, + ): ReceiveMethod = ReceiveMethod(ReceiveParams(amount, description)) + } +} + // pay_keysend class PayKeysendParams( var amount: Long? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index 13110f3c74..363c65b47b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -68,6 +68,38 @@ class PayInvoiceErrorResponse( override fun errorMessage() = error?.message } +// pay success response (nostr-wallet-connect/nwc#2). For a settled BOLT12 payment +// `payer_proof` carries the `lnp1...` proof — the input a kind:9736 BOLT12 zap needs. +// It is best-effort ("optional if unavailable"), so callers must tolerate its absence. +class PaySuccessResponse( + val result: PayResult? = null, +) : Response(NwcMethod.PAY) { + class PayResult( + val transaction_id: String? = null, + val state: String? = null, + val instruction_type: String? = null, + val amount: Long? = null, + val fees_paid: Long? = null, + val payment_hash: String? = null, + val preimage: String? = null, + val payer_proof: String? = null, + val txid: String? = null, + val failure_reason: String? = null, + val created_at: Long? = null, + val settled_at: Long? = null, + ) +} + +// receive success response (nostr-wallet-connect/nwc#2). +class ReceiveSuccessResponse( + val result: ReceiveResult? = null, +) : Response(NwcMethod.RECEIVE) { + class ReceiveResult( + val bip321: String? = null, + val transaction_id: String? = null, + ) +} + // pay_keysend success response class PayKeysendSuccessResponse( val result: PayKeysendResult? = null, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt index 7da485fb8a..532d38f692 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -48,6 +48,8 @@ class NwcMethodTest { assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) + assertEquals("pay", NwcMethod.PAY) + assertEquals("receive", NwcMethod.RECEIVE) } @Test @@ -60,7 +62,7 @@ class NwcMethodTest { @Test fun testErrorCodeValues() { val codes = NwcErrorCode.entries - assertEquals(13, codes.size) + assertEquals(15, codes.size) assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED")) assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED")) assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE")) @@ -73,6 +75,8 @@ class NwcMethodTest { assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST")) assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND")) assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED")) + assertEquals(NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, NwcErrorCode.valueOf("UNSUPPORTED_PAYMENT_INSTRUCTION")) + assertEquals(NwcErrorCode.UNSUPPORTED_NETWORK, NwcErrorCode.valueOf("UNSUPPORTED_NETWORK")) assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index 8fbaa3a31c..fe0b9ae39d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod @@ -89,6 +91,67 @@ class RequestTest { assertEquals(1000L, request.params?.amount) } + // --- Pay (nwc#2 BOLT12/BIP321) --- + + @Test + fun testPayCreateWithOfferAndPayerNote() { + val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef") + assertEquals(NwcMethod.PAY, request.method) + assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) + assertEquals(21_000L, request.params?.amount) + assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note) + } + + @Test + fun testPaySerialization() { + val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay\"")) + assertTrue(json.contains("\"payment\":\"bitcoin:?lno=lno1abc\"")) + assertTrue(json.contains("\"amount\":21000")) + assertTrue(json.contains("\"payer_note\":\"nostr:nipXX:deadbeef\"")) + } + + @Test + fun testPayDeserialization() { + val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"nostr:nipXX:deadbeef"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) + assertEquals(21_000L, request.params?.amount) + assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note) + } + + @Test + fun testPayAmountlessDeserialization() { + val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) + assertNull(request.params?.amount) + assertNull(request.params?.payer_note) + } + + // --- Receive (nwc#2) --- + + @Test + fun testReceiveSerialization() { + val request = ReceiveMethod.create(amount = 21_000L, description = "tip jar") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"receive\"")) + assertTrue(json.contains("\"amount\":21000")) + assertTrue(json.contains("\"description\":\"tip jar\"")) + } + + @Test + fun testReceiveDeserialization() { + val json = """{"method":"receive","params":{"amount":21000,"description":"tip jar"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(21_000L, request.params?.amount) + assertEquals("tip jar", request.params?.description) + } + // --- PayKeysend --- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index 21680a2197..49951ead95 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -91,6 +93,53 @@ class ResponseTest { assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code) } + // --- Pay Success (nwc#2 BOLT12) --- + + @Test + fun testPaySuccessWithPayerProofDeserialization() { + val json = + """{"result_type":"pay","result":{"state":"settled","instruction_type":"bolt12","amount":21000,"fees_paid":100,"payment_hash":"abc","preimage":"def","payer_proof":"lnp1xyz","settled_at":1693876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("settled", response.result?.state) + assertEquals("bolt12", response.result?.instruction_type) + assertEquals(21_000L, response.result?.amount) + assertEquals(100L, response.result?.fees_paid) + assertEquals("abc", response.result?.payment_hash) + assertEquals("def", response.result?.preimage) + assertEquals("lnp1xyz", response.result?.payer_proof) + assertEquals(1693876497L, response.result?.settled_at) + } + + @Test + fun testPaySuccessWithoutPayerProof() { + // payer_proof is best-effort ("optional if unavailable") — absence must parse fine. + val json = """{"result_type":"pay","result":{"state":"settled","preimage":"def"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("def", response.result?.preimage) + assertNull(response.result?.payer_proof) + } + + @Test + fun testPayErrorUnsupportedInstruction() { + val json = """{"result_type":"pay","error":{"code":"UNSUPPORTED_PAYMENT_INSTRUCTION","message":"no bolt12"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, response.error?.code) + } + + // --- Receive Success (nwc#2) --- + + @Test + fun testReceiveSuccessDeserialization() { + val json = """{"result_type":"receive","result":{"bip321":"bitcoin:?lightning=lnbc1&lno=lno1","transaction_id":"tx1"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("bitcoin:?lightning=lnbc1&lno=lno1", response.result?.bip321) + assertEquals("tx1", response.result?.transaction_id) + } + // --- PayKeysend Success --- @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 2fe07f584e..27ec2cc38f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod @@ -51,6 +53,8 @@ class RequestDeserializer : StdDeserializer(Request::class.java) { return when (method) { NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + NwcMethod.PAY -> jp.codec.treeToValue(jsonObject, PayMethod::class.java) + NwcMethod.RECEIVE -> jp.codec.treeToValue(jsonObject, ReceiveMethod::class.java) NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java) NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java) NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt index 098740be17..053e2be001 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod @@ -52,6 +54,18 @@ class RequestSerializer : StdSerializer(Request::class.java) { } } + is PayMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is ReceiveMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + is PayKeysendMethod -> { if (value.params != null) { gen.writeObjectField("params", value.params) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index 275daa5c1a..8c503e7140 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -73,6 +75,14 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) } + NwcMethod.PAY -> { + jp.codec.treeToValue(jsonObject, PaySuccessResponse::class.java) + } + + NwcMethod.RECEIVE -> { + jp.codec.treeToValue(jsonObject, ReceiveSuccessResponse::class.java) + } + NwcMethod.PAY_KEYSEND -> { jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt index 94fa792972..99bc7613b8 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -69,6 +71,18 @@ class ResponseSerializer : StdSerializer(Response::class.java) { } } + is PaySuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is ReceiveSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + is PayKeysendSuccessResponse -> { if (value.result != null) { gen.writeObjectField("result", value.result) From d58bca417761a3e36361e9b60258d1f622769e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:45:56 +0000 Subject: [PATCH 13/23] feat(bolt12): pay a recipient's offer in-app over NWC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "pay with connected wallet" action to the BOLT12 offer dialog on a profile: when the account has a NIP-47 wallet configured, an amount sheet collects sats and settles the offer over the wallet via the nwc#2 `pay` method (BIP321 bitcoin:?lno=). Falls back to the external bitcoin: intent otherwise. Outcome is surfaced as a toast. This is a plain payment, not a NIP-XX zap (no receipt) — that's the deferred Phase 2. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../ui/screen/loggedIn/AccountViewModel.kt | 28 +++++ .../profile/header/Bolt12PayButton.kt | 105 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 4 + 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index ede2817c11..20c295b6ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -163,6 +163,9 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -1201,6 +1204,31 @@ class AccountViewModel( ) } + /** True when the account has at least one NIP-47 wallet configured. */ + fun hasNwcWallet(): Boolean = + account.settings.nwcWallets.value + .isNotEmpty() + + /** + * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 + * `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a + * plain payment, not a NIP-XX zap (no Nostr receipt); the outcome is surfaced as + * a toast. Callers should gate on [hasNwcWallet]. + */ + fun payBolt12OfferViaNwc( + offer: String, + amountMillisats: Long, + ) = launchSigner { + account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + when (response) { + is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent) + is IErrorResponseLike -> + toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, response.errorMessage() ?: "") + else -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, "") + } + } + } + /** * Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in * the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt index 8b8a84d482..4ac9bcc062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt @@ -21,14 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -41,8 +48,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -56,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent @@ -80,14 +90,17 @@ fun Bolt12PayButton( event?.offers() ?: emptyList() } if (offers.isNotEmpty()) { - Bolt12PayButtonWithOffers(offers) + Bolt12PayButtonWithOffers(offers, accountViewModel) } } } } @Composable -fun Bolt12PayButtonWithOffers(offers: List) { +fun Bolt12PayButtonWithOffers( + offers: List, + accountViewModel: AccountViewModel, +) { var expanded by remember { mutableStateOf(false) } FilledTonalButton( @@ -107,6 +120,7 @@ fun Bolt12PayButtonWithOffers(offers: List) { if (expanded) { Bolt12OffersDialog( offers = offers, + accountViewModel = accountViewModel, onDismiss = { expanded = false }, ) } @@ -115,12 +129,20 @@ fun Bolt12PayButtonWithOffers(offers: List) { @Composable fun Bolt12OffersDialog( offers: List, + accountViewModel: AccountViewModel, onDismiss: () -> Unit, ) { val context = LocalContext.current val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() + // Whether we can settle in-app over the user's NIP-47 wallet (nwc#2 `pay`), or + // must hand off to an external wallet via a bitcoin: intent. + val canPayInApp = remember { accountViewModel.hasNwcWallet() } + + // The offer the user chose to pay over NWC; drives the amount-entry dialog. + var nwcPayOffer by remember { mutableStateOf(null) } + M3ActionDialog( title = stringRes(R.string.bolt12_offers), onDismiss = onDismiss, @@ -129,6 +151,7 @@ fun Bolt12OffersDialog( offers.forEach { offer -> Bolt12OfferRow( offer = offer, + canPayInApp = canPayInApp, onCopy = { scope.launch { clipboardManager.setText(offer) @@ -140,7 +163,7 @@ fun Bolt12OffersDialog( ).show() } }, - onPay = { + onPayViaIntent = { payViaBolt12Intent( offer = offer, context = context, @@ -150,17 +173,31 @@ fun Bolt12OffersDialog( }, ) }, + onPayInApp = { nwcPayOffer = offer }, ) } } } + + nwcPayOffer?.let { offer -> + Bolt12NwcAmountDialog( + onDismiss = { nwcPayOffer = null }, + onPay = { amountMillisats -> + accountViewModel.payBolt12OfferViaNwc(offer, amountMillisats) + nwcPayOffer = null + onDismiss() + }, + ) + } } @Composable private fun Bolt12OfferRow( offer: String, + canPayInApp: Boolean, onCopy: () -> Unit, - onPay: () -> Unit, + onPayViaIntent: () -> Unit, + onPayInApp: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -187,7 +224,17 @@ private fun Bolt12OfferRow( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - IconButton(onClick = onPay) { + if (canPayInApp) { + IconButton(onClick = onPayInApp) { + Icon( + symbol = MaterialSymbols.AccountBalanceWallet, + contentDescription = stringRes(R.string.bolt12_pay_with_wallet), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onPayViaIntent) { Icon( symbol = MaterialSymbols.Bolt, contentDescription = stringRes(R.string.bolt12_offers), @@ -197,3 +244,51 @@ private fun Bolt12OfferRow( } } } + +/** Collects an amount (in sats) and returns it to [onPay] in millisats. */ +@Composable +private fun Bolt12NwcAmountDialog( + onDismiss: () -> Unit, + onPay: (amountMillisats: Long) -> Unit, +) { + var sats by remember { mutableStateOf("") } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringRes(R.string.bolt12_pay_with_wallet), + style = MaterialTheme.typography.titleMedium, + ) + OutlinedTextField( + value = sats, + onValueChange = { new -> sats = new.filter { it.isDigit() } }, + label = { Text(text = stringRes(R.string.bolt12_payment_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + val amountSats = sats.toLongOrNull() + if (amountSats != null && amountSats > 0) { + onPay(amountSats * 1000) + } + }, + shape = ButtonBorder, + enabled = (sats.toLongOrNull() ?: 0) > 0, + ) { + Text(text = stringRes(R.string.bolt12_pay_with_wallet)) + } + } + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bd5521bde7..a86c147f8d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1725,6 +1725,10 @@ BOLT12 offer (lno1…) Not a valid BOLT12 offer Delete BOLT12 offer + Pay with connected wallet + Amount (sats) + BOLT12 payment sent + BOLT12 payment failed: %1$s Not Started Compressing From c7415f1559305d6fce98de5d2b42a34ceb0d0d5d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:29:30 +0000 Subject: [PATCH 14/23] =?UTF-8?q?docs(bolt12):=20record=20nwc#2=20resoluti?= =?UTF-8?q?on=20=E2=80=94=20Phase=202=20unblocked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer confirmed payer_note maps to invreq_payer_note for BOLT12 and payer_proof is returned for successful BOLT12 payments. Notes the validate-before-publish fail-safe so a non-conforming wallet never yields an invalid receipt. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 23 +++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index e689f0424e..e68efd4b6c 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -116,9 +116,24 @@ but is **not** wired into the pay path — we'd add the gate ourselves. 4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement `pay` today. Gate hard on capability (Phase 3) and keep the intent fallback. +## Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24) + +Asked on the nwc#2 thread. Maintainer confirmed: + +- **`payer_note` → `invreq_payer_note`**: "that is the intention" for BOLT12 (the + field doubles as a general memo). So our zap binding + (`invreq_payer_note == nostr:nipXX:`) is the intended target. +- **`payer_proof`**: "should be returned for successful bolt12 payments"; the + "optional if unavailable" wording only covers non-BOLT12 instruction types. + +Both are informal maintainer intent, not yet spec text, so a non-conforming wallet +is still possible. That's fine: after a `pay` returns, we run the returned +`payer_proof` through `Bolt12ZapValidator` **before** publishing a kind:9736. A +wallet that misroutes the note fails the binding check and we publish nothing — +Phase 2 fails safe, never emitting an invalid receipt. + ## Recommendation -Phase 0 is safe, self-contained, and unblocks everything — do it regardless. -Phase 1 (in-app pay) is low-risk and immediately useful. **Hold Phase 2 until -risk #1 is resolved** — it's the high-value piece (real zap sending) but its -correctness depends on a binding the current nwc#2 text doesn't guarantee. +Phase 0 (done) and Phase 1 (done) shipped. **Phase 2 is now unblocked.** Build it +with the validate-before-publish gate above; degrade to "paid, no zap receipt" +when the proof is absent or fails validation. From 26272a6c3b50c42743aff0df88d29afb6f3bdd4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:48:36 +0000 Subject: [PATCH 15/23] feat(bolt12): send BOLT12 zaps over NWC, preferred when offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates BOLT12 zap-sending into the zap pipeline (nwc#2 `pay` returns the payer proof). Account.sendBolt12Zap signs a 9737 intent, pays the offer over NWC with the intent-bound payer_note, and — only if the returned proof passes Bolt12ZapValidator — self-consumes and publishes the 9736; otherwise reports "paid, no receipt" (fail-safe against a wallet that misroutes the note). ZapPaymentHandler.zap now resolves each recipient's kind:10058 offer and partitions recipients into a BOLT12 lane (offer present + NWC wallet configured) and the existing lightning lane, sharing split weight across both so mixed splits stay proportional. Anonymous/public follows the account zap type. Adds Bolt12ZapBuilderTest proving the send-side assembly round-trips to a validator-accepted, crypto-verified zap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 29 +++ .../vitorpamplona/amethyst/model/Account.kt | 70 ++++++ .../amethyst/service/ZapPaymentHandler.kt | 220 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 3 + .../builder/Bolt12ZapBuilderTest.kt | 97 ++++++++ 5 files changed, 356 insertions(+), 63 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index e68efd4b6c..a0320b675d 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -137,3 +137,32 @@ Phase 2 fails safe, never emitting an invalid receipt. Phase 0 (done) and Phase 1 (done) shipped. **Phase 2 is now unblocked.** Build it with the validate-before-publish gate above; degrade to "paid, no zap receipt" when the proof is absent or fails validation. + +## Phase 2 as shipped (full zap-button integration, BOLT12-first) + +Chosen: full integration into the zap pipeline, preferring BOLT12 when the +recipient offers it. + +- `Account.sendBolt12Zap(...)` — signs a 9737 intent (ephemeral key for anonymous), + pays over NWC with `payer_note = nostr:nipXX:`, and only on a proof + that passes `Bolt12ZapValidator` self-consumes + publishes the 9736 via + `computeRelayListToBroadcast`. No proof / invalid proof → "paid, no receipt". +- `ZapPaymentHandler.zap()` resolves each recipient's kind:10058 offer and + **partitions** recipients into a BOLT12 lane (offer present AND an NWC wallet is + configured) and the existing lightning lane. Split weights are summed across both + lanes (`totalWeight` threaded into `signAllZapRequests`/`assembleAllInvoices`) so + mixed splits stay proportional. Anonymous/public follows the account zap type. +- `Bolt12ZapBuilderTest` proves the send-side assembly round-trips to a + validator-accepted, crypto-verified zap (attributed and anonymous). + +Known limitations (follow-ons, not blockers): + +- **No capability gate yet (Phase 3).** "NWC wallet present" is the proxy for + "wallet supports `pay`". If the wallet lacks `pay`, a BOLT12-first recipient's zap + fails with the wallet's error rather than falling back to lightning. Reading + `get_info.methods` / the 13194 info event to gate (and to auto-fall-back) is the + next step. +- **Compressed proofs aren't locally counted.** A zap we send with a compressed + proof is published and valid, but our own `updateZapTotal` won't count it until + the merkle-reconstruction lands (see the interop-vectors plan). Other clients with + full BOLT12 support can count it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4ce4e79953..84ec9df9ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle @@ -290,6 +291,9 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -360,6 +364,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance @@ -1416,6 +1422,70 @@ class Account( client.publish(event, setOf(relay)) } + /** + * Sends a NIP-XX BOLT12 zap to [recipientPubKey] over the default NWC wallet. + * + * Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the + * intent-bound `payer_note`, then — only if the wallet returns a payer proof that + * validates — builds, self-consumes, and publishes the kind 9736 zap. Validation + * is the fail-safe: a wallet that drops or misroutes the note yields a proof that + * fails the binding check, so no invalid receipt is ever published (the payment + * still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for + * a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no + * external-wallet or LNURL fallback because only NWC returns the proof. + */ + suspend fun sendBolt12Zap( + zappedEvent: Event?, + recipientPubKey: HexKey, + offer: String, + amountMillisats: Long, + message: String, + zapType: LnZapEvent.ZapType, + // (messageResId, detail) — the caller localizes; detail carries a wallet error, if any. + onError: (Int, String?) -> Unit, + onProcessed: () -> Unit, + ) { + val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS + // The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous + // zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable. + val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else signer + + val intent = + if (zappedEvent == null) { + Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message) + } else { + Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message) + } + + val payerNote = Bolt12ZapBuilder.payerNote(intent) + + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response -> + scope.launch { + when (response) { + is PaySuccessResponse -> { + val proof = response.result?.payer_proof + if (proof.isNullOrBlank()) { + onError(R.string.bolt12_zap_paid_no_receipt, null) + } else { + val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous) + if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { + cache.justConsumeMyOwnEvent(zap) + client.publish(zap, computeRelayListToBroadcast(zap)) + } else { + onError(R.string.bolt12_zap_invalid_receipt, null) + } + } + } + + is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) + + else -> onError(R.string.bolt12_zap_paid_no_receipt, null) + } + onProcessed() + } + } + } + suspend fun createZapRequestFor( user: User, message: String = "", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 977c0d5959..3881a65f21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -67,6 +67,7 @@ class ZapPaymentHandler( val weight: Double = 1.0, val relay: NormalizedRelayUrl? = null, val user: User? = null, + val bolt12Offer: String? = null, ) data class MyZapSplitSetup( @@ -76,6 +77,13 @@ class ZapPaymentHandler( val user: User? = null, ) + /** A recipient routed over BOLT12 (NIP-XX): they publish a kind:10058 [offer] and we hold an NWC wallet. */ + data class Bolt12Recipient( + val user: User, + val offer: String, + val weight: Double = 1.0, + ) + suspend fun zap( note: Note, amountMilliSats: Long, @@ -111,6 +119,7 @@ class ZapPaymentHandler( weight = setup.weight, relay = setup.relay, user = user, + bolt12Offer = user?.bolt12Offers()?.firstOrNull(), ) } } @@ -121,7 +130,7 @@ class ZapPaymentHandler( noteEvent.hosts().map { val user = LocalCache.checkGetOrCreateUser(it.pubKey) val lnAddress = user?.lnAddress() - UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user) + UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user, bolt12Offer = user?.bolt12Offers()?.firstOrNull()) } } @@ -130,23 +139,60 @@ class ZapPaymentHandler( if (appLud16 != null) { listOf(UnverifiedZapSplitSetup(appLud16)) } else { - val lud16 = - note.author?.lnAddress() - listOf(UnverifiedZapSplitSetup(lud16)) + val author = note.author + listOf(UnverifiedZapSplitSetup(author?.lnAddress(), user = author, bolt12Offer = author?.bolt12Offers()?.firstOrNull())) } } else -> { + val author = note.author listOf( UnverifiedZapSplitSetup( - note.author?.lnAddress(), + lnAddress = author?.lnAddress(), + user = author, + bolt12Offer = author?.bolt12Offers()?.firstOrNull(), ), ) } } + // BOLT12-first: a recipient who publishes a kind:10058 offer is zapped over + // BOLT12 when we hold an NWC wallet to obtain the payer proof (nwc#2 `pay`). + // Recipients without an offer (or when we have no NWC wallet) stay on lightning. + val canBolt12 = + account.settings.nwcWallets.value + .isNotEmpty() + + val bolt12Recipients = + unverifiedZapsToSend.mapNotNull { + val user = it.user + if (canBolt12 && it.bolt12Offer != null && user != null) { + Bolt12Recipient(user, it.bolt12Offer, it.weight) + } else { + null + } + } + val bolt12Users = bolt12Recipients.mapTo(HashSet()) { it.user } + + val zapsToSend = + unverifiedZapsToSend.mapNotNull { + // Never lightning-zap a recipient already routed over BOLT12. + if (it.user != null && it.user in bolt12Users) { + null + } else if (it.lnAddress != null) { + MyZapSplitSetup(it.lnAddress, it.weight, it.relay, it.user) + } else { + null + } + } + if (showErrorIfNoLnAddress) { - val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() } + // Only a recipient with neither a BOLT12 route nor an lnAddress is unpayable. + val errors = + unverifiedZapsToSend.filter { + val routedBolt12 = canBolt12 && it.bolt12Offer != null && it.user != null + !routedBolt12 && it.lnAddress.isNullOrBlank() + } errors.forEach { val message = if (it.user != null) { @@ -167,71 +213,77 @@ class ZapPaymentHandler( } } - val zapsToSend = - unverifiedZapsToSend.mapNotNull { - if (it.lnAddress != null) { - MyZapSplitSetup( - it.lnAddress, - it.weight, - it.relay, - it.user, - ) - } else { - null - } - } + // Weight is shared across both lanes so splits stay proportional regardless of rail. + val totalWeight = bolt12Recipients.sumOf { it.weight } + zapsToSend.sumOf { it.weight } + if (totalWeight <= 0.0) { + onProgress(0.00f) + return@withContext + } onProgress(0.02f) - val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats) + // --- Lightning lane ----------------------------------------------------------- + if (zapsToSend.isNotEmpty()) { + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight) - if (splitZapRequests.isEmpty()) { - onProgress(0.00f) - return@withContext - } else { - onProgress(0.05f) + if (splitZapRequests.isNotEmpty()) { + onProgress(0.05f) + + val payables = + assembleAllInvoices( + requests = splitZapRequests, + totalAmountMilliSats = amountMilliSats, + message = message, + okHttpClient = okHttpClient, + onError = onError, + onProgress = { onProgress(it * 0.7f + 0.05f) }, + context = context, + totalWeight = totalWeight, + ) + + if (payables.isNotEmpty()) { + onProgress(0.75f) + + // Route through the user's selected default payment source. A CLINK debit takes + // precedence over NWC when it is the chosen default; NWC-only users are unaffected + // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) + }, context) + } + + is PaymentSource.Nwc -> { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + } + + null -> { + onPayViaIntent(payables.toImmutableList()) + } + } + } + } } - val payables = - assembleAllInvoices( - requests = splitZapRequests, + // --- BOLT12 lane -------------------------------------------------------------- + if (bolt12Recipients.isNotEmpty()) { + payViaBolt12( + recipients = bolt12Recipients, + note = note, totalAmountMilliSats = amountMilliSats, + totalWeight = totalWeight, message = message, - okHttpClient = okHttpClient, + zapType = zapType, onError = onError, - onProgress = { onProgress(it * 0.7f + 0.05f) }, + onProgress = { onProgress(it * 0.25f + 0.75f) }, context = context, ) - - if (payables.isEmpty()) { - onProgress(0.00f) - return@withContext - } else { - onProgress(0.75f) } - // Route through the user's selected default payment source. A CLINK debit takes - // precedence over NWC when it is the chosen default; NWC-only users are unaffected - // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. - when (val source = account.settings.defaultPaymentSource()) { - is PaymentSource.ClinkDebit -> { - payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) - }, context) - } - - is PaymentSource.Nwc -> { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) - // onProgress(1f) - } - - null -> { - onPayViaIntent(payables.toImmutableList()) - onProgress(0f) - } - } + onProgress(1f) } private fun calculateZapValue( @@ -256,9 +308,10 @@ class ZapPaymentHandler( zapType: LnZapEvent.ZapType, zapsToSend: List, totalAmountMilliSats: Long, - ): List { - val totalWeight = zapsToSend.sumOf { it.weight } - return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> + // Shared across the lightning + BOLT12 lanes so a mixed split stays proportional. + totalWeight: Double = zapsToSend.sumOf { it.weight }, + ): List = + mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> // makes sure the author receives the zap event val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet() @@ -291,7 +344,6 @@ class ZapPaymentHandler( ZapRequestReady(next, zapRequest) } - } suspend fun assembleAllInvoices( requests: List, @@ -301,9 +353,10 @@ class ZapPaymentHandler( onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, + // Shared across the lightning + BOLT12 lanes so a mixed split stays proportional. + totalWeight: Double = requests.sumOf { it.inputSetup.weight }, ): List { var progressAllPayments = 0.00f - val totalWeight = requests.sumOf { it.inputSetup.weight } return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady -> try { @@ -386,6 +439,47 @@ class ZapPaymentHandler( ) } + /** + * BOLT12 zap rail (NIP-XX). For each recipient that publishes a kind:10058 offer, + * signs a 9737 intent, pays the offer over NWC with the intent-bound `payer_note`, + * and (if the returned proof validates) publishes a 9736 zap — see + * [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic + * and settlement/errors surface later through the async NWC response. + */ + suspend fun payViaBolt12( + recipients: List, + note: Note, + totalAmountMilliSats: Long, + totalWeight: Double, + message: String, + zapType: LnZapEvent.ZapType, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ) { + val progress = PaymentProgress(recipients.size, onProgress) + + mapNotNullAsync(recipients) { recipient: Bolt12Recipient -> + account.sendBolt12Zap( + zappedEvent = note.event, + recipientPubKey = recipient.user.pubkeyHex, + offer = recipient.offer, + amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight), + message = message, + zapType = zapType, + onError = { msgRes, detail -> + val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes) + onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user) + }, + onProcessed = { progress.step() }, + ) + + progress.step() + + recipient + } + } + /** * Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two * half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a86c147f8d..2bcba65691 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1729,6 +1729,9 @@ Amount (sats) BOLT12 payment sent BOLT12 payment failed: %1$s + BOLT12 zap error + Paid over BOLT12, but the wallet returned no proof, so no zap receipt was published. + Paid over BOLT12, but the payment proof did not validate, so no zap receipt was published. Not Started Compressing diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt new file mode 100644 index 0000000000..84ce92ef67 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt @@ -0,0 +1,97 @@ +/* + * 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.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ProofFixture +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Proves the send-side assembly ([Bolt12ZapBuilder.buildProfileIntent] → + * [Bolt12ZapBuilder.payerNote] → [Bolt12ZapBuilder.buildZap]) — the exact path + * `Account.sendBolt12Zap` drives — produces a kind:9736 the validator accepts. Uses + * a self-consistent fixture proof bound to the built intent (not a wallet interop + * vector), so it exercises structure + binding, not the compressed-merkle gap. + */ +class Bolt12ZapBuilderTest { + private val validator = Bolt12ZapValidator() + private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val amount = 21_000L + + @Test + fun payerNoteBindsToTheIntentId() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val offer = Bolt12ProofFixture.buildOffer(KeyPair(), amount) + val intent = Bolt12ZapBuilder.buildProfileIntent(signer, recipient, amount, offer, comment = "nice") + + assertEquals(Bolt12ZapValidator.NIP_URI_PREFIX + intent.id, Bolt12ZapBuilder.payerNote(intent)) + } + + @Test + fun builderProducesAValidatorAcceptedAttributedZap() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 7).toByte() } + + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount) + val intent = Bolt12ZapBuilder.buildProfileIntent(signer, recipient, amount, offer, comment = "nice") + val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, Bolt12ZapBuilder.payerNote(intent)) + + val zap = Bolt12ZapBuilder.buildZap(signer, intent, proof, anonymous = false) + + val result = validator.validate(zap) + assertIs(result) + assertTrue(result.proofCryptoVerified) + assertEquals(recipient, result.recipient) + assertEquals(signer.pubKey, result.payer) + assertEquals(amount, result.amountMillisats) + } + + @Test + fun anonymousBuilderProducesAValidZapWithNoPayerTag() = + runTest { + // Anonymous zaps sign intent AND zap with one ephemeral key and carry no P tag. + val ephemeral = NostrSignerInternal(KeyPair()) + val nodeKey = KeyPair() + val preimage = ByteArray(32) { (it + 11).toByte() } + + val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount) + val intent = Bolt12ZapBuilder.buildProfileIntent(ephemeral, recipient, amount, offer) + val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, Bolt12ZapBuilder.payerNote(intent)) + + val zap = Bolt12ZapBuilder.buildZap(ephemeral, intent, proof, anonymous = true) + + val result = validator.validate(zap) + assertIs(result) + assertTrue(result.proofCryptoVerified) + assertNull(result.payer, "an anonymous BOLT12 zap carries no P tag") + } +} From eced3c4027c9dfa22bcf0550eff48a432a1b1aec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:50:03 +0000 Subject: [PATCH 16/23] fix(bolt12): honor NONZAP zaps as a receiptless BOLT12 payment A NONZAP (pay-without-receipt) zap must not publish a public 9736. sendBolt12Zap now settles the offer over NWC without binding an intent or emitting a receipt when the zap type is NONZAP, matching bolt11 NONZAP privacy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../java/com/vitorpamplona/amethyst/model/Account.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 84ec9df9ee..6a4b25e98a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1445,6 +1445,18 @@ class Account( onError: (Int, String?) -> Unit, onProcessed: () -> Unit, ) { + // NONZAP means "pay, but publish no receipt" — settle the offer without binding + // a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP. + if (zapType == LnZapEvent.ZapType.NONZAP) { + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + scope.launch { + if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage()) + onProcessed() + } + } + return + } + val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS // The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous // zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable. From 7d9597b91fc80e82813742cddab4595481dc1fde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 23:21:06 +0000 Subject: [PATCH 17/23] feat(bolt12): gate BOLT12 zaps on wallet pay support, fall back to lightning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 capability gating. NwcSignerState caches the default wallet's advertised NIP-47 methods; Account refetches them via nwc#2 get_info whenever the default wallet changes. A zap now prefers the BOLT12 pay rail only when the wallet advertises `pay` (Account.defaultWalletSupportsBolt12Pay) — otherwise the recipient falls back to lightning through the existing partition, so a wallet without pay support degrades gracefully instead of erroring. The profile "pay with wallet" action is gated on the same signal. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 23 +++++++++++---- .../vitorpamplona/amethyst/model/Account.kt | 29 +++++++++++++++++++ .../nip47WalletConnect/NwcSignerState.kt | 9 ++++++ .../amethyst/service/ZapPaymentHandler.kt | 8 +++-- .../ui/screen/loggedIn/AccountViewModel.kt | 3 ++ .../profile/header/Bolt12PayButton.kt | 2 +- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index a0320b675d..305aded535 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -155,13 +155,26 @@ recipient offers it. - `Bolt12ZapBuilderTest` proves the send-side assembly round-trips to a validator-accepted, crypto-verified zap (attributed and anonymous). +## Phase 3 as shipped (capability gate + lightning fallback) + +- `NwcSignerState.defaultWalletCapabilities` caches the default wallet's advertised + method names. `Account` refetches them via nwc#2 `get_info` whenever the default + wallet changes (init collector on `defaultWalletUri`); `get_info.methods` → + the set. Empty until fetched or when the wallet doesn't advertise, which reads as + "no BOLT12". +- `Account.defaultWalletSupportsBolt12Pay()` = `pay` ∈ capabilities. The zap path's + `canBolt12` now requires it, so a recipient with an offer but a wallet that can't + `pay` **falls back to lightning** via the existing partition instead of erroring. +- `AccountViewModel.canPayBolt12ViaNwc()` gates the profile "pay with wallet" action + on the same signal. + +Residual (acceptable): capabilities are empty for the first moment after launch +until `get_info` returns, so a very early zap can miss the BOLT12 rail and use +lightning; and a wallet that doesn't populate `get_info.methods` never gets the +BOLT12 rail even if it supports `pay`. Both fail safe toward lightning. + Known limitations (follow-ons, not blockers): -- **No capability gate yet (Phase 3).** "NWC wallet present" is the proxy for - "wallet supports `pay`". If the wallet lacks `pay`, a BOLT12-first recipient's zap - fails with the wallet's error rather than falling back to lightning. Reading - `get_info.methods` / the 13194 info event to gate (and to auto-fall-back) is the - next step. - **Compressed proofs aren't locally counted.** A zap we send with a compressed proof is published and valid, but our own `updateZapTotal` won't count it until the merkle-reconstruction lands (see the interop-vectors plan). Other clients with diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0670af15fb..b2b2dce3dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -293,7 +293,10 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request @@ -1425,6 +1428,14 @@ class Account( client.publish(event, setOf(relay)) } + /** + * True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a + * BOLT12 zap needs to obtain a payer proof. Empty capabilities (not yet fetched, or a + * wallet that doesn't advertise it) read as false, so the zap path falls back to + * lightning rather than attempting a `pay` the wallet can't honor. + */ + fun defaultWalletSupportsBolt12Pay(): Boolean = nip47SignerState.defaultWalletCapabilities.value.contains(NwcMethod.PAY) + /** * Sends a NIP-XX BOLT12 zap to [recipientPubKey] over the default NWC wallet. * @@ -5860,6 +5871,24 @@ class Account( } } + // Track which methods the default NWC wallet advertises (nwc#2 `get_info.methods`) + // so a zap prefers the BOLT12 `pay` rail only when the wallet supports it, and + // otherwise falls back to lightning. Refetched whenever the default wallet changes. + scope.launch(Dispatchers.IO) { + nip47SignerState.defaultWalletUri.collect { uri -> + nip47SignerState.defaultWalletCapabilities.value = emptySet() + if (uri != null) { + runCatching { + sendNwcRequestToWallet(uri, GetInfoMethod.create()) { response -> + if (response is GetInfoSuccessResponse) { + nip47SignerState.defaultWalletCapabilities.value = response.result?.methods?.toSet() ?: emptySet() + } + } + }.onFailure { Log.w("Account", "NWC get_info for capabilities failed", it) } + } + } + } + scope.launch { cache.live.newEventBundles.collect { newNotes -> logTime("Account ${userProfile().toBestDisplayName()} newEventBundle Update with ${newNotes.size} new notes") { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 8b06eee55b..d98a9cd063 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -100,6 +101,14 @@ class NwcSignerState( }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectResponseCache(nip47Signer.value)) + /** + * The NIP-47 method names the default wallet advertises (nwc#2 `get_info.methods`). + * Empty until fetched or when no wallet is set. [Account] refreshes it whenever the + * default wallet changes; the zap path reads it to decide whether the BOLT12 `pay` + * rail is available before preferring it over lightning. + */ + val defaultWalletCapabilities = MutableStateFlow>(emptySet()) + fun buildSigner(uri: Nip47WalletConnect.Nip47URINorm?) = uri?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 3881a65f21..953fb23913 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -157,11 +157,13 @@ class ZapPaymentHandler( } // BOLT12-first: a recipient who publishes a kind:10058 offer is zapped over - // BOLT12 when we hold an NWC wallet to obtain the payer proof (nwc#2 `pay`). - // Recipients without an offer (or when we have no NWC wallet) stay on lightning. + // BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for + // the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient + // stays on lightning, so an unsupported wallet degrades gracefully instead of erroring. val canBolt12 = account.settings.nwcWallets.value - .isNotEmpty() + .isNotEmpty() && + account.defaultWalletSupportsBolt12Pay() val bolt12Recipients = unverifiedZapsToSend.mapNotNull { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index dc5923a744..c24bc053b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1209,6 +1209,9 @@ class AccountViewModel( account.settings.nwcWallets.value .isNotEmpty() + /** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */ + fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay() + /** * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 * `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt index 4ac9bcc062..ec6119d01e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt @@ -138,7 +138,7 @@ fun Bolt12OffersDialog( // Whether we can settle in-app over the user's NIP-47 wallet (nwc#2 `pay`), or // must hand off to an external wallet via a bitcoin: intent. - val canPayInApp = remember { accountViewModel.hasNwcWallet() } + val canPayInApp = remember { accountViewModel.canPayBolt12ViaNwc() } // The offer the user chose to pay over NWC; drives the amount-entry dialog. var nwcPayOffer by remember { mutableStateOf(null) } From 57f95cf15491a4d8494a500e37d3cd174aa0b828 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 00:22:42 +0000 Subject: [PATCH 18/23] =?UTF-8?q?feat(cli):=20add=20`amy=20bolt12`=20?= =?UTF-8?q?=E2=80=94=20decode,=20verify,=20offers,=20and=20two-step=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a BOLT12 zap (NIP-XX) command group over a new shared commons Bolt12ZapActions (assembly-only, mirrors ZapActions): bolt12 decode LNO1|LNP1 decode an offer or payer proof bolt12 verify EVENT-ID validate a kind:9736 in the local store bolt12 offer get/set read/publish a kind:10058 offer list bolt12 intent … / zap … two-step out-of-band send (amy has no NWC rail): intent prints the payer_note; zap wraps the signed intent + settled proof into a validated kind:9736 and publishes Keeps cli a thin assembly layer — all logic is quartz's Bolt12ZapBuilder/ Validator/codecs via commons Bolt12ZapActions. Adds Bolt12ZapActionsTest; updates README + ROADMAP. Interop harness and NWC-fetched proofs remain TODO. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- cli/README.md | 18 ++ cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 12 + .../amethyst/cli/commands/Bolt12Commands.kt | 209 ++++++++++++++++++ .../cli/commands/Bolt12SendCommands.kt | 145 ++++++++++++ .../commons/actions/Bolt12ZapActions.kt | 125 +++++++++++ .../commons/actions/Bolt12ZapActionsTest.kt | 84 +++++++ 7 files changed, 594 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt diff --git a/cli/README.md b/cli/README.md index cd0c728594..00dd84a862 100644 --- a/cli/README.md +++ b/cli/README.md @@ -672,6 +672,24 @@ output then also reports `paid` + the preimage. | `amy zap user USER SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Profile zap: build the zap request and fetch a BOLT11 from USER's LN service. | | `amy zap event EVENT-ID SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Same, attributed to a specific event (must be in the local store). Zap splits are honored — one invoice per recipient. | +### BOLT12 zaps (NIP-XX) + +BOLT12 zaps (kinds 9736/9737, offers in kind:10058). amy has no NWC payment +rail, so sending is a **two-step, out-of-band** flow: `bolt12 intent` signs the +kind:9737 and prints its `payer_note`; you pay the offer elsewhere putting that +note in the invoice request's `invreq_payer_note`; then `bolt12 zap` wraps the +same signed intent and the settled `lnp` proof into a kind:9736 (validated +before publishing). + +| Command | What it does | +|---|---| +| `amy bolt12 decode LNO1\|LNP1` | Decode a BOLT12 offer or payer proof to its fields (offline). | +| `amy bolt12 verify EVENT-ID` | Validate a kind:9736 zap in the local store — reports `valid`, `crypto_verified`, recipient, amount, payment hash. | +| `amy bolt12 offer get USER [--timeout SECS]` | Fetch + show a user's kind:10058 BOLT12 offers. | +| `amy bolt12 offer set LNO1 [LNO1 …]` | Publish your own kind:10058 offer list. | +| `amy bolt12 intent [event] TARGET SATS --offer LNO1 [--comment X]` | Sign a kind:9737 intent for a user (or event); prints its `intent_id`, `payer_note`, and `intent_json`. | +| `amy bolt12 zap --intent JSON --proof LNP1` | Wrap a signed intent + settled payer proof into a kind:9736, validate, and publish. | + ### CLINK Offers | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 4fbe280968..e785b1e2e0 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -62,6 +62,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). | | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. | +| BOLT12 zaps (NIP-XX, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. | | NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. | | CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. | | Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 1c3354ac84..5880237780 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli import com.vitorpamplona.amethyst.cli.commands.AdminCommand import com.vitorpamplona.amethyst.cli.commands.AwaitCommands import com.vitorpamplona.amethyst.cli.commands.BlossomCommands +import com.vitorpamplona.amethyst.cli.commands.Bolt12Commands import com.vitorpamplona.amethyst.cli.commands.BunkerCommand import com.vitorpamplona.amethyst.cli.commands.BuzzCommands import com.vitorpamplona.amethyst.cli.commands.ConcordCommands @@ -300,6 +301,7 @@ private suspend fun dispatch(argv: Array): Int { "graperank" -> GrapeRankCommand.dispatch(dataDir, tail) "search" -> SearchCommand.dispatch(dataDir, tail) "zap" -> ZapCommand.dispatch(dataDir, tail) + "bolt12" -> Bolt12Commands.dispatch(dataDir, tail) "offer" -> OfferCommands.dispatch(dataDir, tail) "debit" -> DebitCommands.dispatch(dataDir, tail) "event" -> EventCommand.run(dataDir, tail) @@ -748,6 +750,16 @@ private fun printUsage() { | [--comment X] [--anon|--private] event (must be in local store) | [--timeout SECS] | + |BOLT12 zaps (NIP-XX): + | bolt12 decode LNO1|LNP1 decode a BOLT12 offer or payer proof + | bolt12 verify EVENT-ID validate a kind:9736 zap in the local store + | bolt12 offer get USER fetch + show a user's kind:10058 offers + | bolt12 offer set LNO1 [LNO1 …] publish your own kind:10058 offer list + | bolt12 intent [event] TARGET SATS sign a 9737 intent; prints its payer_note + | --offer LNO1 [--comment X] (pay the offer out-of-band with that note) + | bolt12 zap --intent JSON wrap a signed intent + settled proof into a + | --proof LNP1 kind:9736, validate, and publish + | |CLINK Offers: | offer info NOFFER decode a noffer1… pointer (local, no network) | offer discover USER find a user's published offers diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt new file mode 100644 index 0000000000..3623bc26cd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent + +/** + * `amy bolt12 …` — NIP-XX BOLT12 zaps from the command line, for headless interop + * testing of the quartz BOLT12 stack. + * + * Sub-verbs: + * * `bolt12 decode ` — decode an offer or payer proof to its fields. + * * `bolt12 verify ` — validate a kind:9736 zap in the local store. + * * `bolt12 offer get ` — fetch + show a user's kind:10058 offers. + * * `bolt12 offer set …` — publish your own kind:10058 offer list. + * * `bolt12 zap|intent …` — send-side assembly (see [Bolt12SendCommands]). + * + * amy has no NIP-47/NWC payment rail, so BOLT12 sending is a two-step, out-of-band + * flow: `bolt12 intent` signs the 9737 and prints its `payer_note`; you pay the offer + * elsewhere putting that note in the invoice request's `invreq_payer_note`; then + * `bolt12 zap --intent --proof ` wraps that same signed intent and the + * settled proof into a kind:9736. (The intent must be reused verbatim — re-deriving it + * would change its id, and the `payer_note` you paid with wouldn't match.) + */ +object Bolt12Commands { + val USAGE: String = + """ + |BOLT12 zaps (NIP-XX): + | bolt12 decode LNO1|LNP1 decode an offer or payer proof to fields + | bolt12 verify EVENT-ID validate a kind:9736 zap in the local store + | bolt12 offer get USER fetch + show a user's kind:10058 offers + | [--timeout SECS] + | bolt12 offer set LNO1 [LNO1 …] publish your own kind:10058 offer list + | bolt12 intent USER SATS --offer LNO1 [--comment X] + | bolt12 intent event EVENT-ID SATS --offer LNO1 [--comment X] + | sign a 9737 intent; prints its id + payer_note + | bolt12 zap --intent JSON --proof LNP1 + | wrap a signed intent + settled proof into a + | kind:9736, validate, and publish + """.trimMargin() + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "bolt12", + tail, + "bolt12 …", + help = USAGE, + routes = + mapOf( + "decode" to { rest -> decode(rest) }, + "verify" to { rest -> verify(dataDir, rest) }, + "offer" to { rest -> offerDispatch(dataDir, rest) }, + "intent" to { rest -> Bolt12SendCommands.intent(dataDir, rest) }, + "zap" to { rest -> Bolt12SendCommands.zap(dataDir, rest) }, + ), + ) + + private fun decode(rest: Array): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 decode ") + val raw = rest[0] + val canonical = Bolt12Bech32.canonicalize(raw) + when { + Bolt12Bech32.isOffer(canonical) -> { + val fields = Bolt12ZapActions.decodeOffer(raw) ?: return Output.error("decode_failed", "not a parseable BOLT12 offer") + Output.emit(mapOf("type" to "offer") + fields) + } + Bolt12Bech32.isPayerProof(canonical) -> { + val fields = Bolt12ZapActions.decodeProof(raw) ?: return Output.error("decode_failed", "not a parseable BOLT12 payer proof") + Output.emit(mapOf("type" to "payer_proof") + fields) + } + else -> return Output.error("bad_args", "not a BOLT12 offer (lno1…) or payer proof (lnp1…)") + } + return 0 + } + + private suspend fun verify( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 verify ") + val eventId = rest[0] + if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val zap = + ctx.store.query(Filter(ids = listOf(eventId), limit = 1)).firstOrNull() + ?: return Output.error("not_found", "no kind:9736 event $eventId in local store; sync or fetch first") + + when (val result = Bolt12ZapActions.validate(zap)) { + is Bolt12ZapValidation.Valid -> + Output.emit( + buildMap { + put("event_id", zap.id) + put("valid", true) + put("crypto_verified", result.proofCryptoVerified) + put("recipient", result.recipient) + result.payer?.let { put("payer", it) } + put("anonymous", result.payer == null) + put("amount_msat", result.amountMillisats) + put("payment_hash", result.paymentHashHex) + result.zappedEventId?.let { put("zapped_event_id", it) } + }, + ) + + is Bolt12ZapValidation.Invalid -> + Output.emit(mapOf("event_id" to zap.id, "valid" to false, "reason" to result.reason.name)) + } + return 0 + } + } + + private suspend fun offerDispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "bolt12 offer", + tail, + "bolt12 offer …", + help = USAGE, + routes = + mapOf( + "get" to { rest -> offerGet(dataDir, rest) }, + "set" to { rest -> offerSet(dataDir, rest) }, + ), + ) + + private suspend fun offerGet( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer get [--timeout SECS]") + val args = Args(rest.drop(1).toTypedArray()) + val timeoutMs = args.timeoutMs(8) + args.rejectUnknown() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val user = ctx.requireUserHex(rest[0]) + val relays = ctx.bootstrapRelays() + val filter = Filter(kinds = listOf(Bolt12OfferListEvent.KIND), authors = listOf(user), limit = 1) + val latest = + (ctx.store.query(filter) + ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs).mapNotNull { it.second as? Bolt12OfferListEvent }) + .filter { it.pubKey == user } + .maxByOrNull { it.createdAt } + + Output.emit(mapOf("user" to user, "offers" to (latest?.offers() ?: emptyList()))) + return 0 + } + } + + private suspend fun offerSet( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer set [ …]") + val canonical = + rest.map { raw -> + Bolt12ZapActions.canonicalOfferOrNull(raw) ?: return Output.error("bad_args", "not a valid BOLT12 offer: $raw") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val event = Bolt12ZapActions.buildOfferList(ctx.signer, canonical) + val relays: Set = ctx.outboxRelays() + val results = ctx.publish(event, relays) + Output.emit( + mapOf( + "event_id" to event.id, + "offers" to canonical, + "published_to" to results.keys.map { it.url }, + "accepted_by" to results.filterValues { it.accepted }.keys.map { it.url }, + ), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt new file mode 100644 index 0000000000..fa8acb2f58 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12SendCommands.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation + +/** + * The BOLT12 send-side sub-verbs of `amy bolt12` (split from [Bolt12Commands] to keep + * each file focused). Two steps because amy has no NWC rail to fetch the proof itself: + * [intent] signs the 9737 and prints the `payer_note`; the caller pays the offer + * out-of-band with that note; [zap] wraps the same intent + settled proof into a 9736. + */ +object Bolt12SendCommands { + suspend fun intent( + dataDir: DataDir, + rest: Array, + ): Int { + val eventMode = rest.firstOrNull() == "event" + val body = if (eventMode) rest.drop(1).toTypedArray() else rest + if (body.size < 2) { + return Output.error("bad_args", "bolt12 intent [event] --offer [--comment X]") + } + val target = body[0] + val sats = + body[1].toLongOrNull()?.takeIf { it > 0 } + ?: return Output.error("bad_args", "sats must be a positive integer (got '${body[1]}')") + val args = Args(body.drop(2).toTypedArray()) + val offerArg = args.flag("offer") ?: return Output.error("bad_args", "--offer is required") + val offer = Bolt12ZapActions.canonicalOfferOrNull(offerArg) ?: return Output.error("bad_args", "--offer is not a valid BOLT12 offer") + val comment = args.flag("comment") ?: "" + args.rejectUnknown() + val amountMsat = Bolt12ZapActions.satsToMillisats(sats) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val intent = + if (eventMode) { + if (target.length != 64) return Output.error("bad_args", "event-id must be 64-hex") + val zappedEvent = + ctx.store.query(Filter(ids = listOf(target), limit = 1)).firstOrNull() + ?: return Output.error("not_found", "event $target not in local store; sync or fetch first") + val recipient = zappedEvent.pubKey + Bolt12ZapActions.buildEventIntent(ctx.signer, recipient, amountMsat, offer, zappedEvent, comment) + } else { + val recipient = ctx.requireUserHex(target) + Bolt12ZapActions.buildProfileIntent(ctx.signer, recipient, amountMsat, offer, comment) + } + + Output.emit( + mapOf( + "intent_id" to intent.id, + "recipient" to intent.recipient(), + "amount_msat" to amountMsat, + "offer" to offer, + // Put this in the BOLT12 invoice request's invreq_payer_note when paying. + "payer_note" to Bolt12ZapActions.payerNote(intent), + // Feed this verbatim back to `bolt12 zap --intent` once you have the proof. + "intent_json" to intent.toJson(), + ), + ) + return 0 + } + } + + suspend fun zap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val intentJson = args.flag("intent") ?: return Output.error("bad_args", "bolt12 zap --intent --proof ") + val proofArg = args.flag("proof") ?: return Output.error("bad_args", "--proof is required") + args.rejectUnknown() + + val proof = Bolt12Bech32.canonicalize(proofArg) + if (!Bolt12Bech32.isPayerProof(proof)) return Output.error("bad_args", "--proof is not a BOLT12 payer proof (lnp1…)") + + val intent = + runCatching { Event.fromJson(intentJson) as? Bolt12ZapIntentEvent }.getOrNull() + ?: return Output.error("bad_args", "--intent is not a valid kind:9737 zap-intent event") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + // Sign the 9736 with our account key. The validator requires the zap and the + // embedded intent to share a pubkey, so an intent signed by another key is + // rejected below and nothing is published. + val zap = Bolt12ZapActions.buildZap(ctx.signer, intent, proof, anonymous = false) + + when (val result = Bolt12ZapActions.validate(zap)) { + is Bolt12ZapValidation.Invalid -> + return Output.error("invalid_zap", "assembled zap failed validation: ${result.reason.name}; nothing published") + + is Bolt12ZapValidation.Valid -> { + val recipientInbox: Set = + ctx + .relaysOf(result.recipient) + ?.readRelaysNorm() + ?.toSet() + .orEmpty() + val relays = ctx.outboxRelays() + recipientInbox + val results = ctx.publish(zap, relays) + Output.emit( + mapOf( + "event_id" to zap.id, + "recipient" to result.recipient, + "amount_msat" to result.amountMillisats, + "payment_hash" to result.paymentHashHex, + "crypto_verified" to result.proofCryptoVerified, + "published_to" to results.keys.map { it.url }, + "accepted_by" to results.filterValues { it.accepted }.keys.map { it.url }, + ), + ) + return 0 + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt new file mode 100644 index 0000000000..79b9227b8d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +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.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder +import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import com.vitorpamplona.quartz.utils.Hex + +/** + * NIP-XX BOLT12 zap building, decoding, and validation — the shared, UI-free + * surface a non-Android caller (amy CLI, interop harnesses) drives. + * + * Assembly-only: it re-exposes [Bolt12ZapBuilder] / [Bolt12ZapValidator] and the + * BOLT12 codecs. Obtaining a settled `lnp` payer proof (the wallet's job over + * NIP-47/nwc#2) lives outside these builders — a caller supplies the proof. + * Pattern matches [ZapActions]. + */ +object Bolt12ZapActions { + fun satsToMillisats(sats: Long): Long = sats * 1000L + + /** The canonical raw offer if [raw] is a well-formed BOLT12 offer (`lno1…`), else null. */ + fun canonicalOfferOrNull(raw: String): String? { + val canonical = Bolt12Bech32.canonicalize(raw) + return if (Bolt12Bech32.isOffer(canonical)) canonical else null + } + + /** Decode a BOLT12 offer (`lno1…`) to its interesting fields, or null when unparseable. */ + fun decodeOffer(raw: String): Map? { + val offer = Bolt12Offer.parse(raw) ?: return null + return buildMap { + put("canonical", Bolt12Bech32.canonicalize(raw)) + offer.amount()?.let { put("amount_msat", it) } + offer.currency()?.let { put("currency", it) } + offer.description()?.let { put("description", it) } + offer.issuerId()?.let { put("issuer_id", Hex.encode(it)) } + put("has_paths", offer.hasPaths()) + } + } + + /** Decode a BOLT12 payer proof (`lnp1…`) to its interesting fields, or null when unparseable. */ + fun decodeProof(raw: String): Map? { + val proof = Bolt12PayerProof.parse(raw) ?: return null + return buildMap { + put("has_all_required_fields", proof.hasAllRequiredFields()) + put("compressed", proof.isCompressed()) + proof.invreqPayerNote()?.let { put("invreq_payer_note", it) } + proof.invreqPayerId()?.let { put("invreq_payer_id", Hex.encode(it)) } + proof.invoiceAmount()?.let { put("invoice_amount_msat", it) } + proof.invoicePaymentHash()?.let { put("invoice_payment_hash", Hex.encode(it)) } + proof.invoiceNodeId()?.let { put("invoice_node_id", Hex.encode(it)) } + proof.offerIssuerId()?.let { put("offer_issuer_id", Hex.encode(it)) } + } + } + + /** The value a payer MUST put in the BOLT12 `invreq_payer_note` to bind a payment to [intent]. */ + fun payerNote(intent: Bolt12ZapIntentEvent): String = Bolt12ZapBuilder.payerNote(intent) + + /** Sign a kind:9737 zap intent targeting [recipientPubKey]'s profile. */ + suspend fun buildProfileIntent( + signer: NostrSigner, + recipientPubKey: HexKey, + amountMillisats: Long, + offer: String, + comment: String = "", + ): Bolt12ZapIntentEvent = Bolt12ZapBuilder.buildProfileIntent(signer, recipientPubKey, amountMillisats, offer, comment) + + /** Sign a kind:9737 zap intent targeting a specific [zappedEvent]. */ + suspend fun buildEventIntent( + signer: NostrSigner, + recipientPubKey: HexKey, + amountMillisats: Long, + offer: String, + zappedEvent: Event, + comment: String = "", + ): Bolt12ZapIntentEvent = Bolt12ZapBuilder.buildIntent(signer, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), comment) + + /** + * Wrap a signed [intent] and a settled [payerProof] into a signed kind:9736 zap. + * When [anonymous], [signer] MUST be the same ephemeral key that signed [intent]. + */ + suspend fun buildZap( + signer: NostrSigner, + intent: Bolt12ZapIntentEvent, + payerProof: String, + anonymous: Boolean = false, + ): Bolt12ZapEvent = Bolt12ZapBuilder.buildZap(signer, intent, payerProof, anonymous) + + /** Validate a kind:9736 BOLT12 zap (structure + intent match + payer-proof binding + crypto). */ + fun validate(zap: Bolt12ZapEvent): Bolt12ZapValidation = Bolt12ZapValidator().validate(zap) + + /** Sign a kind:10058 BOLT12 offer list publishing [offers] (canonical `lno1…` strings). */ + suspend fun buildOfferList( + signer: NostrSigner, + offers: List, + ): Bolt12OfferListEvent = Bolt12OfferListEvent.create(offers, signer) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt new file mode 100644 index 0000000000..8df8767bd5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Locks the shared BOLT12 assembly surface amy drives: offer canonicalization, + * offer decoding, and the kind:10058 offer-list round-trip. + */ +class Bolt12ZapActionsTest { + // A minimal, well-formed offer: amount(8) + description(10), TLV-ascending. + private fun sampleOffer(amountMsat: Long): String { + val records = + listOf( + TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMsat)), + TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "coffee".encodeToByteArray()), + ) + return Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, TlvStream(records).encode()) + } + + @Test + fun canonicalOfferAcceptsAValidOfferAndRejectsJunk() { + val offer = sampleOffer(21_000L) + assertEquals(offer.lowercase(), Bolt12ZapActions.canonicalOfferOrNull(offer)) + assertNull(Bolt12ZapActions.canonicalOfferOrNull("not-an-offer")) + assertNull(Bolt12ZapActions.canonicalOfferOrNull("lnbc10n1xxx")) // bolt11, not bolt12 + } + + @Test + fun decodeOfferReadsAmountAndDescription() { + val fields = Bolt12ZapActions.decodeOffer(sampleOffer(21_000L)) + assertTrue(fields != null) + assertEquals(21_000L, fields["amount_msat"]) + assertEquals("coffee", fields["description"]) + assertEquals(false, fields["has_paths"]) + } + + @Test + fun decodeOfferReturnsNullForAProof() { + assertNull(Bolt12ZapActions.decodeOffer("lnp1garbage")) + } + + @Test + fun offerListRoundTrips() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val offers = listOf(sampleOffer(21_000L).lowercase(), sampleOffer(5_000L).lowercase()) + val event = Bolt12ZapActions.buildOfferList(signer, offers) + + assertEquals(10058, event.kind) + assertEquals(offers, event.offers()) + assertEquals(signer.pubKey, event.pubKey) + } +} From 9bb6237d8cc77ec1e01b4d9e5edb71f4e30f49a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 00:51:38 +0000 Subject: [PATCH 19/23] docs(bolt12): note the nwc#2 ecosystem status (LND service exists, BOLT12 gap) benthecarman/nostr-wallet-connect-lnd implements NWC-321 pay/receive (confirming Phase 0), but is LND-backed so it rejects BOLT12 lno and returns no payer_proof. The blocker for real BOLT12-zap testing is a CLN/LDK-backed NWC-321 service. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index 8ce2db65f7..bb953291f4 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -3,6 +3,20 @@ Status: **scoping** — no code yet. Depends on NIP-2421 (this branch) and the unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47). +## Ecosystem status (2026-07-25) — the wallet gap for real testing + +A real NWC-321 (`pay`/`receive`) service exists — +[benthecarman/nostr-wallet-connect-lnd](https://github.com/benthecarman/nostr-wallet-connect-lnd) +— which confirms the protocol we built (Phase 0) is real and adopted. **But it is +LND-backed:** its `pay` "selects and pays a BOLT11 `lightning` instruction from a +BIP-321 URI" and "BOLT12 `lno` instructions are not supported by this LND backend." +So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-2421 +zap loop. The blocker is the **node backend**, not the protocol: a CLN- or +LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft +bolts#1346) would support `lno` + return `payer_proof`. Until one exists, the +self-consistent interop harness (`cli/tests/bolt12/`, TODO) is the only way to +exercise the full send → verify → count loop. + ## What nwc#2 adds Two generalized methods replace the bolt11-only `pay_invoice`: From 42e69fd424d34fa0a7292e2b778894a428f122b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:23:23 +0000 Subject: [PATCH 20/23] =?UTF-8?q?fix(bolt12):=20audit=20fixes=20=E2=80=94?= =?UTF-8?q?=20verify=20crash,=20decode=20hardening,=20send=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - amy bolt12 verify: the id-only query with a type crashed with ClassCastException when the id pointed at a non-9736 event. Constrain the filter to kind 9736 and cast defensively (query() as?), returning a clean not_found instead. - Bolt12ZapActions.decodeOffer/decodeProof: a parseable bech32 with an over-8-byte amount TLV threw in tu64 on field read instead of honoring the null contract; guarded the field extraction in runCatching. Adds a malformed-amount regression test. - Account.sendBolt12Zap: wrap the NWC response callback in try/catch/finally so a post-payment receipt-assembly failure (e.g. a remote signer error) steps progress and surfaces "paid, no receipt" instead of vanishing as an uncaught coroutine exception. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../vitorpamplona/amethyst/model/Account.kt | 44 ++++++++++------ .../amethyst/cli/commands/Bolt12Commands.kt | 8 ++- .../commons/actions/Bolt12ZapActions.kt | 52 ++++++++++++------- .../commons/actions/Bolt12ZapActionsTest.kt | 12 +++++ 4 files changed, 79 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 53b3d90d0a..bd86d5c32f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1505,27 +1505,39 @@ class Account( sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response -> scope.launch { - when (response) { - is PaySuccessResponse -> { - val proof = response.result?.payer_proof - if (proof.isNullOrBlank()) { - onError(R.string.bolt12_zap_paid_no_receipt, null) - } else { - val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous) - if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { - cache.justConsumeMyOwnEvent(zap) - client.publish(zap, computeRelayListToBroadcast(zap)) + // try/finally so a failure while assembling/publishing the receipt (e.g. a + // remote signer error) still steps progress and surfaces an error, instead + // of vanishing as an uncaught coroutine exception. The payment already + // settled at this point, so such a failure means "paid, no receipt". + try { + when (response) { + is PaySuccessResponse -> { + val proof = response.result?.payer_proof + if (proof.isNullOrBlank()) { + onError(R.string.bolt12_zap_paid_no_receipt, null) } else { - onError(R.string.bolt12_zap_invalid_receipt, null) + val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous) + if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { + cache.justConsumeMyOwnEvent(zap) + client.publish(zap, computeRelayListToBroadcast(zap)) + } else { + onError(R.string.bolt12_zap_invalid_receipt, null) + } } } + + is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) + + else -> onError(R.string.bolt12_zap_paid_no_receipt, null) } - - is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) - - else -> onError(R.string.bolt12_zap_paid_no_receipt, null) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e) + onError(R.string.bolt12_zap_paid_no_receipt, null) + } finally { + onProcessed() } - onProcessed() } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt index 3623bc26cd..13f0217f41 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Bolt12Commands.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 @@ -113,8 +114,13 @@ object Bolt12Commands { if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex") Context.open(dataDir).use { ctx -> ctx.prepare() + // Constrain by kind AND cast defensively: the store filters by id alone and + // returns whatever kind that id actually is, so querying + // directly would ClassCastException if the id points at a non-9736 event. val zap = - ctx.store.query(Filter(ids = listOf(eventId), limit = 1)).firstOrNull() + ctx.store + .query(Filter(kinds = listOf(Bolt12ZapEvent.KIND), ids = listOf(eventId), limit = 1)) + .firstOrNull() as? Bolt12ZapEvent ?: return Output.error("not_found", "no kind:9736 event $eventId in local store; sync or fetch first") when (val result = Bolt12ZapActions.validate(zap)) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt index 79b9227b8d..9e82437c53 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt @@ -53,32 +53,44 @@ object Bolt12ZapActions { return if (Bolt12Bech32.isOffer(canonical)) canonical else null } - /** Decode a BOLT12 offer (`lno1…`) to its interesting fields, or null when unparseable. */ + /** + * Decode a BOLT12 offer (`lno1…`) to its interesting fields, or null when unparseable. + * A field read can still throw on a well-encoded-but-malformed TLV (e.g. an amount + * value longer than 8 bytes), so the whole field extraction is guarded to honor the + * null contract rather than leak an exception to the caller. + */ fun decodeOffer(raw: String): Map? { val offer = Bolt12Offer.parse(raw) ?: return null - return buildMap { - put("canonical", Bolt12Bech32.canonicalize(raw)) - offer.amount()?.let { put("amount_msat", it) } - offer.currency()?.let { put("currency", it) } - offer.description()?.let { put("description", it) } - offer.issuerId()?.let { put("issuer_id", Hex.encode(it)) } - put("has_paths", offer.hasPaths()) - } + return runCatching { + buildMap { + put("canonical", Bolt12Bech32.canonicalize(raw)) + offer.amount()?.let { put("amount_msat", it) } + offer.currency()?.let { put("currency", it) } + offer.description()?.let { put("description", it) } + offer.issuerId()?.let { put("issuer_id", Hex.encode(it)) } + put("has_paths", offer.hasPaths()) + } + }.getOrNull() } - /** Decode a BOLT12 payer proof (`lnp1…`) to its interesting fields, or null when unparseable. */ + /** + * Decode a BOLT12 payer proof (`lnp1…`) to its interesting fields, or null when + * unparseable. Guarded like [decodeOffer] against a malformed-but-encoded field. + */ fun decodeProof(raw: String): Map? { val proof = Bolt12PayerProof.parse(raw) ?: return null - return buildMap { - put("has_all_required_fields", proof.hasAllRequiredFields()) - put("compressed", proof.isCompressed()) - proof.invreqPayerNote()?.let { put("invreq_payer_note", it) } - proof.invreqPayerId()?.let { put("invreq_payer_id", Hex.encode(it)) } - proof.invoiceAmount()?.let { put("invoice_amount_msat", it) } - proof.invoicePaymentHash()?.let { put("invoice_payment_hash", Hex.encode(it)) } - proof.invoiceNodeId()?.let { put("invoice_node_id", Hex.encode(it)) } - proof.offerIssuerId()?.let { put("offer_issuer_id", Hex.encode(it)) } - } + return runCatching { + buildMap { + put("has_all_required_fields", proof.hasAllRequiredFields()) + put("compressed", proof.isCompressed()) + proof.invreqPayerNote()?.let { put("invreq_payer_note", it) } + proof.invreqPayerId()?.let { put("invreq_payer_id", Hex.encode(it)) } + proof.invoiceAmount()?.let { put("invoice_amount_msat", it) } + proof.invoicePaymentHash()?.let { put("invoice_payment_hash", Hex.encode(it)) } + proof.invoiceNodeId()?.let { put("invoice_node_id", Hex.encode(it)) } + proof.offerIssuerId()?.let { put("offer_issuer_id", Hex.encode(it)) } + } + }.getOrNull() } /** The value a payer MUST put in the BOLT12 `invreq_payer_note` to bind a payment to [intent]. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt index 8df8767bd5..fdccafba05 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt @@ -70,6 +70,18 @@ class Bolt12ZapActionsTest { assertNull(Bolt12ZapActions.decodeOffer("lnp1garbage")) } + @Test + fun decodeOfferReturnsNullForAParseableButMalformedAmount() { + // The TLV stream parses (ascending type, valid length), but the amount value is + // 9 bytes — reading it throws in tu64. decodeOffer must honor its null contract. + val bad = + Bolt12Bech32.encode( + Bolt12Bech32.OFFER_HRP, + TlvStream(listOf(TlvRecord(Bolt12Offer.TYPE_AMOUNT, ByteArray(9) { 1 }))).encode(), + ) + assertNull(Bolt12ZapActions.decodeOffer(bad)) + } + @Test fun offerListRoundTrips() = runTest { From f62ecb7ad2466177d99f9525c615301c3f341e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:27:49 +0000 Subject: [PATCH 21/23] fix(nwc): kotlinx pay/receive parsers mishandle explicit JSON null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-backend defects in the kotlinx (native/iOS) NWC-321 parsers, found by the audit and empirically reproduced. Jackson (JVM/Android) writes null-valued keys, so a native peer parsing that output hit two bugs: - parsePay/parseReceive crashed on `metadata: null` — `?.jsonObject` doesn't short-circuit on JsonNull (a non-null element). Use `as? JsonObject`. - parsePaySuccess/parseReceiveSuccess (and parsePay's string fields) read an explicit JSON null as the literal string "null" via `?.jsonPrimitive?.content`. Use `contentOrNull`. Only affects the kotlinx path (Android/JVM use Jackson), but violates the KMP mapper-interchangeability contract. Adds Nip47KotlinSerializationNullTest hitting the kotlinx serializers directly so it's covered regardless of platform actual. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../Nip47RequestKSerializer.kt | 14 ++-- .../Nip47ResponseKSerializer.kt | 23 +++--- .../Nip47KotlinSerializationNullTest.kt | 75 +++++++++++++++++++ 3 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index a26a3fae6d..fae08b9f97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -64,6 +64,7 @@ import kotlinx.serialization.json.add import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray @@ -298,11 +299,14 @@ object Nip47RequestKSerializer : KSerializer { val params = json["params"]?.jsonObject return PayMethod( params?.let { + // contentOrNull / `as? JsonObject` treat an explicit JSON `null` as absent — + // Jackson (JVM/Android) writes null-valued keys, so a native/iOS peer parsing + // that output must not read `JsonNull` as the string "null" or crash on it. PayParams( - payment = it["payment"]?.jsonPrimitive?.content, + payment = it["payment"]?.jsonPrimitive?.contentOrNull, amount = it["amount"]?.jsonPrimitive?.longOrNull, - payer_note = it["payer_note"]?.jsonPrimitive?.content, - metadata = it["metadata"]?.jsonObject?.toAnyMap(), + payer_note = it["payer_note"]?.jsonPrimitive?.contentOrNull, + metadata = (it["metadata"] as? JsonObject)?.toAnyMap(), ) }, ) @@ -314,8 +318,8 @@ object Nip47RequestKSerializer : KSerializer { params?.let { ReceiveParams( amount = it["amount"]?.jsonPrimitive?.longOrNull, - description = it["description"]?.jsonPrimitive?.content, - metadata = it["metadata"]?.jsonObject?.toAnyMap(), + description = it["description"]?.jsonPrimitive?.contentOrNull, + metadata = (it["metadata"] as? JsonObject)?.toAnyMap(), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index ce77e90fa5..d938b5ecb6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -55,6 +55,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -431,17 +432,19 @@ object Nip47ResponseKSerializer : KSerializer { val result = json["result"]?.jsonObject return PaySuccessResponse( result?.let { + // contentOrNull, not content: an explicit JSON `null` (which Jackson writes + // for every null field) must read back as a real null, not the string "null". PaySuccessResponse.PayResult( - transaction_id = it["transaction_id"]?.jsonPrimitive?.content, - state = it["state"]?.jsonPrimitive?.content, - instruction_type = it["instruction_type"]?.jsonPrimitive?.content, + transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull, + state = it["state"]?.jsonPrimitive?.contentOrNull, + instruction_type = it["instruction_type"]?.jsonPrimitive?.contentOrNull, amount = it["amount"]?.jsonPrimitive?.longOrNull, fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, - payment_hash = it["payment_hash"]?.jsonPrimitive?.content, - preimage = it["preimage"]?.jsonPrimitive?.content, - payer_proof = it["payer_proof"]?.jsonPrimitive?.content, - txid = it["txid"]?.jsonPrimitive?.content, - failure_reason = it["failure_reason"]?.jsonPrimitive?.content, + payment_hash = it["payment_hash"]?.jsonPrimitive?.contentOrNull, + preimage = it["preimage"]?.jsonPrimitive?.contentOrNull, + payer_proof = it["payer_proof"]?.jsonPrimitive?.contentOrNull, + txid = it["txid"]?.jsonPrimitive?.contentOrNull, + failure_reason = it["failure_reason"]?.jsonPrimitive?.contentOrNull, created_at = it["created_at"]?.jsonPrimitive?.longOrNull, settled_at = it["settled_at"]?.jsonPrimitive?.longOrNull, ) @@ -454,8 +457,8 @@ object Nip47ResponseKSerializer : KSerializer { return ReceiveSuccessResponse( result?.let { ReceiveSuccessResponse.ReceiveResult( - bip321 = it["bip321"]?.jsonPrimitive?.content, - transaction_id = it["transaction_id"]?.jsonPrimitive?.content, + bip321 = it["bip321"]?.jsonPrimitive?.contentOrNull, + transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull, ) }, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt new file mode 100644 index 0000000000..60696efab3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt @@ -0,0 +1,75 @@ +/* + * 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.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47ResponseKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * Exercises the **kotlinx** (native/iOS) NWC serializers directly — not through + * `OptimizedJsonMapper`, whose JVM actual is Jackson — to cover the cross-backend + * asymmetry: Jackson (JVM/Android) writes explicit `null` for every null field, and a + * native peer parsing that output must read those as real nulls, not the string "null", + * and must not crash on a null `metadata` object. + */ +class Nip47KotlinSerializationNullTest { + @Test + fun payRequestWithExplicitNullMetadataParsesWithoutCrashing() { + val input = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"note","metadata":null}}""" + val req = Json.decodeFromString(Nip47RequestKSerializer, input) + assertIs(req) + assertEquals("bitcoin:?lno=lno1abc", req.params?.payment) + assertEquals("note", req.params?.payer_note) + assertNull(req.params?.metadata) + } + + @Test + fun receiveRequestWithExplicitNullMetadataParsesWithoutCrashing() { + val input = """{"method":"receive","params":{"amount":21000,"description":null,"metadata":null}}""" + val req = Json.decodeFromString(Nip47RequestKSerializer, input) + assertIs(req) + assertNull(req.params?.description) + assertNull(req.params?.metadata) + } + + @Test + fun paySuccessWithExplicitNullFieldsYieldsRealNullsNotTheStringNull() { + val input = + """{"result_type":"pay","result":{"state":"settled","preimage":"abc","transaction_id":null,"txid":null,"failure_reason":null,"payer_proof":null,"instruction_type":null}}""" + val resp = Json.decodeFromString(Nip47ResponseKSerializer, input) + assertIs(resp) + assertEquals("settled", resp.result?.state) + assertEquals("abc", resp.result?.preimage) + assertNull(resp.result?.transaction_id) + assertNull(resp.result?.txid) + assertNull(resp.result?.failure_reason) + assertNull(resp.result?.payer_proof) + assertNull(resp.result?.instruction_type) + } +} From 7535d791f33471ed454a2d4dc6b57ca07e85e5de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 02:35:16 +0000 Subject: [PATCH 22/23] feat(bolt12): verify compressed payer proofs via merkle reconstruction Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata` is always withheld and other invoice fields may be elided for privacy, with `proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying enough to rebuild the invoice signature's merkle root. The verifier previously reported these as unsupported (cryptoVerified = false), so a zap paid through a real wallet never counted locally. Implement the lightning/bolts#1346 reader: - Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest). Add emitMissingHashes as the writer dual, unify both on one tree builder. - Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the record's type bytes (not the full encoded TLV), and the payer proof signs under fieldname `proof_signature` (not `signature`). - Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field range predicate; the verifier reconstructs on every proof (type 0 is always the implied first omitted leaf) and drops the Unsupported result. - Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop), and rewire Bolt12ProofFixture onto it. Validated byte-for-byte against the draft's own conformance suite (bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are rejected, and the writer reproduces every vector's compression fields exactly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 18 +- .../commons/actions/Bolt12ZapActions.kt | 2 +- .../2026-07-23-bolt12-zap-interop-vectors.md | 89 +- .../nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt | 170 ++- .../bolt12/Bolt12PayerProof.kt | 79 +- .../bolt12/Bolt12ProofBuilder.kt | 155 +++ .../verify/Bolt12ProofResult.kt | 18 +- .../verify/Bolt12ProofVerifier.kt | 119 ++- .../verify/Bolt12ZapValidation.kt | 11 +- .../verify/Bolt12ZapValidator.kt | 4 +- .../builder/Bolt12ZapBuilderTest.kt | 4 +- .../verify/Bolt12PayerProofVectorTest.kt | 130 +++ .../verify/Bolt12ProofFixture.kt | 70 +- .../verify/Bolt12ZapValidatorTest.kt | 7 +- .../resources/bolt12/payer-proof-test.json | 969 ++++++++++++++++++ 15 files changed, 1662 insertions(+), 183 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt create mode 100644 quartz/src/commonTest/resources/bolt12/payer-proof-test.json diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index bb953291f4..37e0a30b8c 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -123,10 +123,12 @@ but is **not** wired into the pay path — we'd add the gate ourselves. A wallet may settle the offer and return no proof → payment succeeds but we can't publish a zap. Phase 1 is unaffected; Phase 2 must degrade gracefully ("paid, but no zap receipt available"). -3. **Our verifier can't check compressed proofs yet** (see - `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet proofs are - compressed, so a zap we send may show locally as unverified. Publishing is fine; - local counting waits on the merkle-reconstruction work. +3. ~~**Our verifier can't check compressed proofs yet.**~~ **Resolved.** The + compressed-proof merkle reconstruction shipped and is validated byte-for-byte + against the lightning/bolts#1346 conformance vectors (see + `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet + (selective-disclosure) proofs now reconstruct and verify, so a bound zap counts + locally as `cryptoVerified = true`. 4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement `pay` today. Gate hard on capability (Phase 3) and keep the intent fallback. @@ -191,7 +193,7 @@ BOLT12 rail even if it supports `pay`. Both fail safe toward lightning. Known limitations (follow-ons, not blockers): -- **Compressed proofs aren't locally counted.** A zap we send with a compressed - proof is published and valid, but our own `updateZapTotal` won't count it until - the merkle-reconstruction lands (see the interop-vectors plan). Other clients with - full BOLT12 support can count it. +- ~~**Compressed proofs aren't locally counted.**~~ **Resolved.** Compressed + (selective-disclosure) proofs now reconstruct and verify, so `updateZapTotal` + counts a bound zap locally. Validated against the lightning/bolts#1346 + conformance vectors — see `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt index 9e82437c53..bcf6cdce3c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt @@ -81,7 +81,7 @@ object Bolt12ZapActions { val proof = Bolt12PayerProof.parse(raw) ?: return null return runCatching { buildMap { - put("has_all_required_fields", proof.hasAllRequiredFields()) + put("has_all_required_fields", proof.hasAllCryptoFields()) put("compressed", proof.isCompressed()) proof.invreqPayerNote()?.let { put("invreq_payer_note", it) } proof.invreqPayerId()?.let { put("invreq_payer_id", Hex.encode(it)) } diff --git a/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md b/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md index 19374f4b8c..6f38edcf7c 100644 --- a/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md +++ b/quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md @@ -1,67 +1,44 @@ # BOLT12 zap proof verification — interop test vectors (follow-up) -Status: **blocked on upstream.** The NIP-XX BOLT12-zap layer (`quartz/…/nipXXBolt12Zaps/`) -verifies fully-disclosed payer proofs and reports compressed ones as -`Bolt12ProofResult.Unsupported` (surfaced as `cryptoVerified = false`). Two pieces -of work are gated on the BOLT12 payer-proof spec ([lightning/bolts#1346]) merging -with published test vectors. +Status: **done** (both work items shipped), with one standing upstream caveat. +`Bolt12ProofVerifier` now reconstructs and fully verifies **compressed** BOLT12 +payer proofs — the selective-disclosure case real wallets emit — so they count as +`cryptoVerified = true` (subject to the usual offer-binding rule). Verified +byte-for-byte against the draft's own conformance vectors. -## Why it's gated +## What shipped -Today the crypto path (`Bolt12ProofVerifier` + `Bolt12Merkle`) is validated only by -**self-consistent round-trips** (our own encoder ↔ our own verifier, see -`Bolt12ProofFixture` + `Bolt12MerkleTest` + `Bolt12ZapValidatorTest`). That proves -internal correctness, not agreement with CLN/LDK. Several constants are our best -reading of the still-draft spec and MUST be reconciled against real vectors before -we trust wallet-produced proofs: +- **Vector-driven interop test.** `bolt12/payer-proof-test.json` from + [lightning/bolts#1346] is vendored into `quartz/src/commonTest/resources/bolt12/` + and driven by `Bolt12PayerProofVectorTest`: all 5 `valid_vectors` verify, all 23 + `invalid_vectors` are rejected, and the writer (`Bolt12ProofBuilder`) reproduces + each valid vector's `proof_omitted_tlvs` / `proof_missing_hashes` / + `proof_leaf_hashes` exactly. The vectors disproved two of our earlier guesses, + now fixed: + - the **nonce leaf** hashes the record's *type* bytes, not the full encoded TLV + (`Bolt12Merkle.nonceLeafHash`); + - the **proof signature** field name is `proof_signature`, not `signature` + (`Bolt12ProofVerifier.PROOF_SIG_FIELD`). -- TLV type numbers (`Bolt12PayerProof` companion): 240/241, 1001–1005, 22, 80–91, - 160–176. -- Signature digest tags (`Bolt12ProofVerifier`): `"lightning" + messagename + fieldname` - — `INVOICE_MESSAGE`/`PROOF_MESSAGE`/`SIGNATURE_FIELD`. The proof-signature field - name especially is a guess. -- Merkle leaf/branch tag strings + odd-node promotion (`Bolt12Merkle`) — believed to - match LDK, not checked byte-for-byte. -- 33-byte compressed `point` → BIP-340 x-only handling / even-y convention for - `invoice_node_id` and `invreq_payer_id`. +- **Compressed-proof merkle reconstruction.** `Bolt12Merkle.reconstructRoot` + rebuilds the invoice root from the disclosed `LnLeaf` hashes + `proof_leaf_hashes` + (nonce leaves) + `proof_omitted_tlvs` markers + `proof_missing_hashes` (consumed + post-order DFS smallest-to-largest). `invreq_metadata` (type 0) is always the + implied first omitted leaf. The `isCompressed()` short-circuit is gone; every + proof now goes through reconstruction. -## Work item 1 — vector-driven interop test +## Standing caveat (upstream) -When `bolt12/payer-proof-test.json` exists in #1346: +#1346 is still an unmerged draft. The TLV type numbers, signature digest tag +strings, leaf/branch tags, and the invoice/proof field ranges track the current +PR head (`vincenzopalazzo/bolts@1be97b2`) and MUST be re-checked if the spec +changes before it merges. The vector test is the tripwire: refresh the resource +from the merged BOLT and it will flag any drift. -1. Vendor the vectors into `quartz/src/commonTest/resources/` (or inline the hex). -2. Add `Bolt12PayerProofVectorTest`: for each `valid` proof assert - `Bolt12ProofVerifier.verify(...) is Valid`; for each `invalid` proof assert the - specific rejection reason. -3. Fix any constant above that the vectors disprove. If a fix is needed, the - round-trip tests will still pass (they move with our encoder) — the vector test - is the real gate. +## Not covered here -## Work item 2 — compressed-proof merkle reconstruction - -Real wallet proofs omit non-required invoice TLVs (blinded paths, etc.), which still -contributed to the invoice signature's merkle root — so `Bolt12ProofVerifier.verify` -currently returns `Unsupported` for them. Implement the reconstruction in -`Bolt12Merkle`, rebuilding the invoice root from: - -- disclosed invoice TLVs → compute their `LnLeaf` hashes locally; -- `proof_leaf_hashes` (1004) → the `LnNonce` leaves for disclosed fields (can't be - computed locally — the nonce tag embeds the possibly-omitted first TLV); -- `proof_omitted_tlvs` (1002) → markers for where omitted fields sit in - TLV-ascending order; -- `proof_missing_hashes` (1003) → sibling subtree hashes for omitted branches, - consumed post-order DFS smallest-to-largest. - -Then verify the invoice signature against the reconstructed root and drop the -`isCompressed()` short-circuit. Gate acceptance behind Work item 1's vectors — a -reconstruction that only round-trips against our own encoder proves nothing about -real-wallet interop. - -## Not gated on this - -Runtime validation is fully offline (no network) and everything else in the feature -— events, accounting, display, the fully-disclosed crypto path — is done. This -document only covers making compressed real-wallet proofs count as -`cryptoVerified = true`. +Offer↔recipient-identity binding is still out of scope — a verified proof only +proves payment to the *embedded* offer, not that the offer belongs to the +p-tagged recipient (see `Bolt12ZapValidator.isInvoiceBoundToOffer`). [lightning/bolts#1346]: https://github.com/lightning/bolts/pull/1346 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt index 32550dec82..5432ca13cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12Merkle.kt @@ -32,19 +32,22 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * - 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. + * 1. `H("LnLeaf", tlv)` — over the record's full `type || length || value`. + * 2. `H("LnNonce" || first-tlv, type)` — over just the record's `type` field + * (its BigSize bytes), where `first-tlv` is the encoded bytes of the + * numerically-first signable record. (This is the subtle bit the BOLT + * spec's worked example pins down: the nonce leaf hashes the *type*, not + * the whole 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. + * [rootHash] computes the root over a **fully-disclosed** record set. + * [reconstructRoot] rebuilds it from a selectively-disclosed payer proof + * (lightning/bolts#1346), pulling omitted subtrees from `proof_missing_hashes`. + * Both are exercised byte-for-byte against the spec's `payer-proof-test.json` + * vectors. */ object Bolt12Merkle { private val LN_NONCE = "LnNonce".encodeToByteArray() @@ -82,7 +85,7 @@ object Bolt12Merkle { var nodes = ArrayList(signableRecords.size * 2) for (record in signableRecords) { nodes.add(taggedHashPrecomputed(LN_LEAF_TAG_HASH, record.encoded)) - nodes.add(taggedHashPrecomputed(nonceTagHash, record.encoded)) + nodes.add(taggedHashPrecomputed(nonceTagHash, BigSize.encode(record.type))) } while (nodes.size > 1) { @@ -102,6 +105,155 @@ object Bolt12Merkle { return nodes[0] } + /** + * The `H("LnLeaf", tlv)` leaf hash of a single record (over its full encoded + * `type || length || value`). + */ + fun leafHash(encodedRecord: ByteArray): ByteArray = taggedHashPrecomputed(LN_LEAF_TAG_HASH, encodedRecord) + + /** + * The `H("LnNonce" || first-tlv, type)` nonce leaf hash for a record of the + * given [type], where [firstTlvEncoded] is the encoded numerically-first + * signable record of the message. + */ + fun nonceLeafHash( + firstTlvEncoded: ByteArray, + type: Long, + ): ByteArray = taggedHash(LN_NONCE + firstTlvEncoded, BigSize.encode(type)) + + /** + * The per-field merkle node — `branch(H("LnLeaf", tlv), nonceLeafHash)` — the + * hash that sits directly above a single TLV's leaf pair. A compressed proof + * supplies [nonceLeafHash] (from `proof_leaf_hashes`) because it depends on + * `first-tlv`, which the proof may have omitted. + */ + fun fieldNode( + encodedRecord: ByteArray, + nonceLeafHash: ByteArray, + ): ByteArray = branch(leafHash(encodedRecord), nonceLeafHash) + + /** + * A node of the reconstruction/emission tree. A leaf carries a per-field node + * hash ([leafHash], null only for a reader's omitted position) and whether it + * was omitted; an inner node carries [left]/[right]. + */ + private class TreeNode private constructor( + val left: TreeNode?, + val right: TreeNode?, + val leafHash: ByteArray?, + val omitted: Boolean, + ) { + val isLeaf: Boolean get() = left == null + + companion object { + fun leaf( + hash: ByteArray?, + omitted: Boolean, + ) = TreeNode(null, null, hash, omitted) + + fun fork( + left: TreeNode, + right: TreeNode, + ) = TreeNode(left, right, null, false) + } + } + + /** Rebuilds the pair-adjacent / promote-odd tree shape [rootHash] flattens. */ + private fun buildTree(leaves: List): TreeNode { + var level = leaves + while (level.size > 1) { + val next = ArrayList((level.size + 1) / 2) + var i = 0 + while (i < level.size) { + if (i + 1 < level.size) { + next.add(TreeNode.fork(level[i], level[i + 1])) + i += 2 + } else { + next.add(level[i]) + i += 1 + } + } + level = next + } + return level[0] + } + + /** + * Reconstructs the merkle root of a selectively-disclosed BOLT12 message + * (lightning/bolts#1346). [leafNodeHashes] holds one entry per non-signature + * leaf of the **original** message in ascending-type order: the per-field node + * hash ([fieldNode]) for a disclosed field, or `null` for an omitted one. When + * exactly one child of an inner node is entirely omitted, its hash is pulled + * from [missingHashes] in the post-order depth-first (smallest-to-largest) + * order the writer emitted them. + * + * Returns `null` if the tree cannot be closed with exactly the supplied + * missing hashes (too few, too many, or an entirely-omitted root) — i.e. an + * unverifiable proof. + */ + fun reconstructRoot( + leafNodeHashes: List, + missingHashes: List, + ): ByteArray? { + if (leafNodeHashes.isEmpty()) return null + val tree = buildTree(leafNodeHashes.map { TreeNode.leaf(it, omitted = it == null) }) + var idx = 0 + var failed = false + + fun eval(node: TreeNode): ByteArray? { + if (failed) return null + if (node.isLeaf) return node.leafHash + val a = eval(node.left!!) + val b = eval(node.right!!) + return when { + failed -> null + a == null && b == null -> null + a != null && b != null -> branch(a, b) + else -> { + if (idx >= missingHashes.size) { + failed = true + null + } else { + branch(a ?: b!!, missingHashes[idx++]) + } + } + } + } + + val root = eval(tree) + return if (failed || root == null || idx != missingHashes.size) null else root + } + + /** + * The writer dual of [reconstructRoot]: given every non-signature leaf's + * per-field node hash ([leafNodeHashes]) and whether each was omitted + * ([leafOmitted]), emits the `proof_missing_hashes` — the hash of each subtree + * that is the lone entirely-omitted child of an inner node — in post-order + * depth-first (smallest-to-largest) order. Used to mint payer proofs (tests + + * interop harness), never on the hot verification path. + */ + fun emitMissingHashes( + leafNodeHashes: List, + leafOmitted: List, + ): List { + require(leafNodeHashes.size == leafOmitted.size) { "leaf hash / omitted-flag size mismatch" } + if (leafNodeHashes.isEmpty()) return emptyList() + val tree = buildTree(leafNodeHashes.indices.map { TreeNode.leaf(leafNodeHashes[it], leafOmitted[it]) }) + val missing = ArrayList() + + // Returns the subtree hash and whether it is entirely omitted. + fun emit(node: TreeNode): Pair { + if (node.isLeaf) return node.leafHash!! to node.omitted + val (a, aOmitted) = emit(node.left!!) + val (b, bOmitted) = emit(node.right!!) + if (aOmitted != bOmitted) missing.add(if (aOmitted) a else b) + return branch(a, b) to (aOmitted && bOmitted) + } + + emit(tree) + return missing + } + private fun branch( a: ByteArray, b: ByteArray, 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 index 479c12cd5e..bbcbadca72 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt @@ -24,9 +24,11 @@ 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`. + * plus the invoice's `signature`, and adds the payer's own `proof_signature`, the + * `proof_preimage`, and the merkle-reconstruction fields `proof_missing_hashes` / + * `proof_leaf_hashes` / `proof_omitted_tlvs` that let [Bolt12ProofVerifier] rebuild + * the invoice root even though `invreq_metadata` (and optionally other fields) are + * withheld for privacy. * * The type numbers below are the ones proposed in lightning/bolts#1346 and MUST * be reconciled against the final merged BOLT if they change. @@ -61,32 +63,59 @@ class Bolt12PayerProof( 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]). + * The `proof_omitted_tlvs` marker numbers (BigSize-decoded), empty when the + * field is absent. Returns null if the bytes don't decode as a BigSize list — + * a malformed proof the verifier must reject. */ - fun isCompressed(): Boolean { - if (tlv.has(TYPE_PROOF_OMITTED_TLVS)) return true - val missing = proofMissingHashes() - return missing != null && missing.isNotEmpty() + fun omittedTlvMarkers(): List? { + val bytes = proofOmittedTlvs() ?: return emptyList() + return try { + val reader = TlvReader(bytes) + buildList { while (reader.remaining() > 0) add(reader.readBigSize()) } + } catch (_: Exception) { + null + } } - /** 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 } + /** `proof_leaf_hashes` split into 32-byte hashes, or null if not a whole multiple of 32. */ + fun leafHashList(): List? = split32(proofLeafHashes()) + + /** `proof_missing_hashes` split into 32-byte hashes, or null if not a whole multiple of 32. */ + fun missingHashList(): List? = split32(proofMissingHashes()) + + /** + * The disclosed invoice (offer / invoice-request / invoice) records — the + * fields whose types fall in the invoice ranges 1..239 and + * 1_000_000_000..3_999_999_999 (lightning/bolts#1346), excluding the signature + * elements and the proof-specific fields (240..999_999_999). + */ + fun invoiceIncludedRecords(): List = tlv.records.filter { isInvoiceField(it.type) } + + /** + * True when the proof omits some of the original invoice's TLV fields (i.e. + * carries `proof_omitted_tlvs` markers). Every proof reconstructs the invoice + * root through the merkle machinery — `invreq_metadata` (type 0) is always + * omitted — but this flags the extra selective disclosure for display. + */ + fun isCompressed(): Boolean = omittedTlvMarkers()?.isNotEmpty() == true /** 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 = + /** + * True when every field the BOLT12 crypto verification requires is present and + * well-sized (lightning/bolts#1346 reader rules). `invreq_payer_note` is *not* + * required here — the NIP-XX zap binding checks it separately in the validator. + */ + fun hasAllCryptoFields(): Boolean = invreqPayerId() != null && - invreqPayerNote() != null && - invoicePaymentHash() != null && + invoicePaymentHash()?.size == 32 && invoiceNodeId() != null && invoiceSignature()?.size == 64 && proofSignature()?.size == 64 && - proofPreimage()?.size == 32 + proofPreimage()?.size == 32 && + tlv.has(TYPE_PROOF_MISSING_HASHES) && + tlv.has(TYPE_PROOF_LEAF_HASHES) companion object { // Offer / invoice-request fields copied into the proof. @@ -122,6 +151,20 @@ class Bolt12PayerProof( const val TYPE_PROOF_LEAF_HASHES = 1004L const val TYPE_PROOF_NOTE = 1005L + /** + * The invoice TLV type ranges that participate in the invoice merkle tree, + * per lightning/bolts#1346: 1..239 (offer/invreq/invoice) and + * 1_000_000_000..3_999_999_999 (high/unknown invoice fields). Excludes the + * signature range 240..1000 and the proof-specific fields 1001..999_999_999. + */ + fun isInvoiceField(type: Long): Boolean = type in 1L..239L || type in 1_000_000_000L..3_999_999_999L + + private fun split32(bytes: ByteArray?): List? { + if (bytes == null) return null + if (bytes.size % 32 != 0) return null + return (0 until bytes.size / 32).map { bytes.copyOfRange(it * 32, it * 32 + 32) } + } + fun parse(canonicalProof: String): Bolt12PayerProof? { val bytes = Bolt12Bech32.decodeToBytesOrNull(canonicalProof, Bolt12Bech32.PAYER_PROOF_HRP) ?: return null val tlv = TlvStream.readOrNull(bytes) ?: return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.kt new file mode 100644 index 0000000000..e4b5548703 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12ProofBuilder.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.bolt12 + +/** + * Mints a canonical BOLT12 `lnp1...` payer proof from a full invoice, per + * lightning/bolts#1346 — the writer dual of [Bolt12ProofVerifier]. Production never + * *creates* payer proofs (a paying wallet does); this exists for the self-consistent + * test fixtures and the interop harness, and to prove the reader against + * writer-produced streams. It reproduces the spec `payer-proof-test.json` vectors' + * `proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` byte-for-byte. + */ +object Bolt12ProofBuilder { + /** + * One non-signature invoice TLV. [include] flags whether it is disclosed in the + * proof; `invreq_metadata` (type 0) is always omitted regardless (it is the + * hashing nonce and must never be revealed). + */ + class InvoiceField( + val type: Long, + val value: ByteArray, + val include: Boolean, + ) + + /** + * @param invoiceFields ALL non-signature invoice TLVs in ascending type order, + * including `invreq_metadata` (type 0). + * @param preimage the `proof_preimage`; the caller ensures the invoice's + * `invoice_payment_hash` (type 168) is its SHA-256 (or deliberately corrupt). + * @param signInvoiceDigest signs the 32-byte invoice merkle digest (node key). + * @param signProofDigest signs the 32-byte proof merkle digest (payer key). + */ + fun build( + invoiceFields: List, + preimage: ByteArray, + proofNote: String? = null, + signInvoiceDigest: (ByteArray) -> ByteArray, + signProofDigest: (ByteArray) -> ByteArray, + ): String { + val firstTlv = TlvRecord(invoiceFields.first().type, invoiceFields.first().value).encoded + + // Invoice signature (240) over the full invoice merkle root. + val invoiceRecords = invoiceFields.map { TlvRecord(it.type, it.value) } + val invoiceRoot = Bolt12Merkle.rootHash(invoiceRecords) + val invoiceSig = signInvoiceDigest(Bolt12Merkle.signatureDigest("invoice", INVOICE_SIG_FIELD, invoiceRoot)) + + // Disclosed invoice fields (type 0 is always withheld). + val disclosed = invoiceFields.filter { it.include && it.type != 0L } + val leafHashes = disclosed.map { Bolt12Merkle.nonceLeafHash(firstTlv, it.type) } + val markers = renumberOmitted(invoiceFields) + + val leafNodeHashes = + invoiceFields.map { Bolt12Merkle.fieldNode(TlvRecord(it.type, it.value).encoded, Bolt12Merkle.nonceLeafHash(firstTlv, it.type)) } + val leafOmitted = invoiceFields.map { !(it.include && it.type != 0L) } + val missingHashes = Bolt12Merkle.emitMissingHashes(leafNodeHashes, leafOmitted) + + // Records the proof signature (241) commits to: everything but the 240..1000 + // signature elements — disclosed invoice fields plus the proof-specific fields. + val proofSignable = + buildList { + disclosed.forEach { add(TlvRecord(it.type, it.value)) } + add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_PREIMAGE, preimage)) + if (markers.isNotEmpty()) add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_OMITTED_TLVS, encodeMarkers(markers))) + add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_MISSING_HASHES, concat(missingHashes))) + add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_LEAF_HASHES, concat(leafHashes))) + if (proofNote != null) add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_NOTE, proofNote.encodeToByteArray())) + }.sortedBy { it.type } + val proofRoot = Bolt12Merkle.rootHash(proofSignable) + val proofSig = signProofDigest(Bolt12Merkle.signatureDigest("payer_proof", PROOF_SIG_FIELD, proofRoot)) + + val all = + ( + proofSignable + + TlvRecord(Bolt12PayerProof.TYPE_SIGNATURE, invoiceSig) + + TlvRecord(Bolt12PayerProof.TYPE_PROOF_SIGNATURE, proofSig) + ).sortedBy { it.type } + return Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, TlvStream(all).encode()) + } + + /** + * Minimal renumbering of omitted fields into `proof_omitted_tlvs` markers + * (lightning/bolts#1346): `invreq_metadata` (type 0) is implied and never + * emitted; every other omitted field takes the previous included type + 1, or + * the next value after the previous marker (starting at 1), with the + * 1_000_000_000 jump once the low range (≤239) is exhausted. + */ + private fun renumberOmitted(invoiceFields: List): List { + val markers = ArrayList() + var prevIncludedType: Long? = null + for (f in invoiceFields) { + if (f.include && f.type != 0L) { + prevIncludedType = f.type + continue + } + if (f.type == 0L) { + prevIncludedType = null + continue + } + markers.add( + when { + prevIncludedType != null -> prevIncludedType + 1 + markers.isEmpty() -> 1L + markers.last() == 239L -> 1_000_000_000L + else -> markers.last() + 1 + }, + ) + prevIncludedType = null + } + return markers + } + + private fun encodeMarkers(markers: List): ByteArray { + var size = 0 + for (m in markers) size += BigSize.encodedSize(m) + val out = ByteArray(size) + var offset = 0 + for (m in markers) { + val enc = BigSize.encode(m) + enc.copyInto(out, offset) + offset += enc.size + } + return out + } + + private fun concat(hashes: List): ByteArray { + val out = ByteArray(hashes.size * 32) + var offset = 0 + for (h in hashes) { + h.copyInto(out, offset) + offset += 32 + } + return out + } + + private const val INVOICE_SIG_FIELD = "signature" + private const val PROOF_SIG_FIELD = "proof_signature" +} 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 index 8002ce5bc5..73cbaa06fe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofResult.kt @@ -52,22 +52,18 @@ sealed interface Bolt12ProofResult { 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, + + /** + * The selective-disclosure fields (`proof_omitted_tlvs` / + * `proof_missing_hashes` / `proof_leaf_hashes`) are malformed or don't + * describe a closable merkle tree, so the invoice root can't be rebuilt. + */ + RECONSTRUCTION_FAILED, 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 index d73c35ebea..b06b8ac7d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofVerifier.kt @@ -23,59 +23,56 @@ 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.nipXXBolt12Zaps.bolt12.TlvRecord 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`. + * 2. The invoice `signature` (240) is valid over the **reconstructed** invoice + * merkle root, signed by `invoice_node_id`. The proof discloses only some of + * the invoice's TLV fields (`invreq_metadata` is always withheld); the omitted + * branches are rebuilt from `proof_missing_hashes` / `proof_leaf_hashes` / + * `proof_omitted_tlvs` via [Bolt12Merkle.reconstructRoot]. + * 3. The `proof_signature` (241) is valid over the proof's own (fully-disclosed) + * 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. + * The full reader path — reconstruction and both BIP-340 checks — is exercised + * byte-for-byte against the spec's `payer-proof-test.json` vectors + * ([Bolt12PayerProofVectorTest]). The BOLT signature message/field names below + * still track the unmerged draft and must be reconciled if it changes on merge. */ class Bolt12ProofVerifier { fun verify(proof: Bolt12PayerProof): Bolt12ProofResult { - if (!proof.hasAllRequiredFields()) { + if (!proof.hasAllCryptoFields()) { 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) + val preimage = proof.proofPreimage()!! + val paymentHash = proof.invoicePaymentHash()!! // 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) - } - + // 2. Invoice signature over the reconstructed invoice merkle root. + val invoiceRoot = + reconstructInvoiceRoot(proof) + ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.RECONSTRUCTION_FAILED) 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) + val invoiceDigest = Bolt12Merkle.signatureDigest(INVOICE_MESSAGE, INVOICE_SIG_FIELD, invoiceRoot) if (!Nip01Crypto.verify(invoiceSig, invoiceDigest, nodeId)) { return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.INVOICE_SIGNATURE_INVALID) } + // 3. Payer proof signature over the proof's own (disclosed) records. 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) + val proofDigest = Bolt12Merkle.signatureDigest(PROOF_MESSAGE, PROOF_SIG_FIELD, proofRoot) if (!Nip01Crypto.verify(proofSig, proofDigest, payerId)) { return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.PROOF_SIGNATURE_INVALID) } @@ -83,6 +80,71 @@ class Bolt12ProofVerifier { return Bolt12ProofResult.Valid(paymentHash = paymentHash, invoiceAmountMillisats = proof.invoiceAmount()) } + /** + * Rebuilds the invoice merkle root from a selectively-disclosed proof. Returns + * null when the proof's compression fields are malformed or don't describe a + * closable tree — an unverifiable proof the caller must not count. + */ + private fun reconstructInvoiceRoot(proof: Bolt12PayerProof): ByteArray? { + val markers = proof.omittedTlvMarkers() ?: return null + val leafHashes = proof.leafHashList() ?: return null + val missingHashes = proof.missingHashList() ?: return null + + val included = proof.invoiceIncludedRecords().sortedBy { it.type } + if (leafHashes.size != included.size) return null + if (!markersValid(markers, included)) return null + + // Ordered non-signature leaves of the original invoice: the implied + // `invreq_metadata` (type 0) plus every omitted marker plus every disclosed + // field, merged in ascending numeric order. Disclosed fields carry their + // per-field node hash; omitted positions are null (filled from missing hashes). + val positions = ArrayList>(included.size + markers.size + 1) + positions.add(0L to null) // type 0 (invreq_metadata) is always omitted + for (m in markers) positions.add(m to null) + for (r in included) positions.add(r.type to r) + positions.sortBy { it.first } + + var leafIdx = 0 + val leafNodeHashes = + positions.map { (_, record) -> + if (record == null) { + null + } else { + Bolt12Merkle.fieldNode(record.encoded, leafHashes[leafIdx++]) + } + } + + return Bolt12Merkle.reconstructRoot(leafNodeHashes, missingHashes) + } + + /** + * The lightning/bolts#1346 reader rules for `proof_omitted_tlvs`: strictly + * ascending, non-zero, within the invoice ranges, never the number of a + * disclosed field, and each a valid *minimal renumbering* successor — one more + * than a disclosed field, one more than the previous marker (or 0 for the + * first), or the 1_000_000_000 jump after 239. + */ + private fun markersValid( + markers: List, + included: List, + ): Boolean { + val includedTypes = included.mapTo(HashSet()) { it.type } + var prev = 0L + for ((i, m) in markers.withIndex()) { + if (i > 0 && m <= markers[i - 1]) return false // strict ascending, no duplicates + if (m == 0L) return false + if (!Bolt12PayerProof.isInvoiceField(m)) return false + if (m in includedTypes) return false + val validSuccessor = + (m - 1) in includedTypes || + m == prev + 1 || + (m == 1_000_000_000L && prev == 239L) + if (!validSuccessor) return false + prev = m + } + return true + } + /** * 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 @@ -100,6 +162,11 @@ class Bolt12ProofVerifier { // 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" + + /** The invoice's own signature field is named `signature`. */ + const val INVOICE_SIG_FIELD = "signature" + + /** The payer proof's signature field is named `proof_signature`. */ + const val PROOF_SIG_FIELD = "proof_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 index 757a65a03e..c0809e0d10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidation.kt @@ -36,10 +36,12 @@ sealed interface Bolt12ZapValidation { * @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. + * verified **and** the settled invoice is provably the embedded offer's (the + * offer publishes an `offer_issuer_id`, uses no blinded paths, and the + * invoice node key equals it). False when the signatures verify but the offer + * hides its destination (blinded paths / no issuer id), so paying *this* offer + * isn't proven — see [Bolt12ProofVerifier]. Callers decide whether to count or + * merely display the latter, and MUST label it as unverified. */ @Immutable data class Valid( @@ -94,6 +96,7 @@ sealed interface Bolt12ZapValidation { OFFER_PROOF_MISMATCH, PROOF_MISSING_REQUIRED_FIELDS, PROOF_PREIMAGE_MISMATCH, + PROOF_RECONSTRUCTION_FAILED, 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 index e05acb5d62..8a5164efec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidator.kt @@ -138,7 +138,6 @@ class Bolt12ZapValidator( val cryptoOk = when (cryptoResult) { is Bolt12ProofResult.Valid -> true - is Bolt12ProofResult.Unsupported -> false is Bolt12ProofResult.Invalid -> return invalid(mapProofReason(cryptoResult.reason)) } @@ -225,11 +224,10 @@ class Bolt12ZapValidator( when (reason) { Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS -> Reason.PROOF_MISSING_REQUIRED_FIELDS Bolt12ProofResult.Reason.PREIMAGE_MISMATCH -> Reason.PROOF_PREIMAGE_MISMATCH + Bolt12ProofResult.Reason.RECONSTRUCTION_FAILED -> Reason.PROOF_RECONSTRUCTION_FAILED 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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt index 84ce92ef67..1aa3d9862a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/builder/Bolt12ZapBuilderTest.kt @@ -36,8 +36,8 @@ import kotlin.test.assertTrue * Proves the send-side assembly ([Bolt12ZapBuilder.buildProfileIntent] → * [Bolt12ZapBuilder.payerNote] → [Bolt12ZapBuilder.buildZap]) — the exact path * `Account.sendBolt12Zap` drives — produces a kind:9736 the validator accepts. Uses - * a self-consistent fixture proof bound to the built intent (not a wallet interop - * vector), so it exercises structure + binding, not the compressed-merkle gap. + * a self-consistent fixture proof bound to the built intent; byte-exact wallet + * interop is covered separately by [Bolt12PayerProofVectorTest]. */ class Bolt12ZapBuilderTest { private val validator = Bolt12ZapValidator() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt new file mode 100644 index 0000000000..396ca38ef6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt @@ -0,0 +1,130 @@ +/* + * 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.TestResourceLoader +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12ProofBuilder +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Byte-exact interop against the lightning/bolts#1346 conformance suite + * (`bolt12/payer-proof-test.json`), the draft's own CLN/LDK-generated vectors. + * + * - Every `valid_vectors` entry must fully verify ([Bolt12ProofVerifier] returns + * [Bolt12ProofResult.Valid]) — this exercises the compressed-proof merkle + * reconstruction and both BIP-340 signature checks against real proofs. + * - Every `invalid_vectors` entry must be rejected (never [Bolt12ProofResult.Valid]). + * - The writer ([Bolt12ProofBuilder]) reproduces each valid vector's + * `proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` exactly. + */ +class Bolt12PayerProofVectorTest { + private val vectors by lazy { + Json.parseToJsonElement(TestResourceLoader().loadString("bolt12/payer-proof-test.json")).jsonObject + } + + private val verifier = Bolt12ProofVerifier() + + @Test + fun everyValidVectorVerifies() { + val valid = vectors["valid_vectors"]!!.jsonArray + assertTrue(valid.size >= 5, "expected the full valid vector set") + for (vector in valid) { + val obj = vector.jsonObject + val name = obj["name"]!!.jsonPrimitive.content + val bech32 = obj["result"]!!.jsonObject["bech32"]!!.jsonPrimitive.content + val proof = Bolt12PayerProof.parse(bech32) ?: fail("valid vector '$name' failed to parse") + val result = verifier.verify(proof) + assertIs(result, "valid vector '$name' did not verify: $result") + } + } + + @Test + fun everyInvalidVectorIsRejected() { + val invalid = vectors["invalid_vectors"]!!.jsonArray + assertTrue(invalid.size >= 20, "expected the full invalid vector set") + for (vector in invalid) { + val obj = vector.jsonObject + val reason = obj["reason"]?.jsonPrimitive?.content ?: "?" + val bech32 = obj["bech32"]!!.jsonPrimitive.content + val proof = Bolt12PayerProof.parse(bech32) + // Rejection is either an unparseable stream or any non-Valid crypto result. + val verified = proof?.let { verifier.verify(it) } + assertTrue( + verified !is Bolt12ProofResult.Valid, + "invalid vector '$reason' was accepted", + ) + } + } + + @Test + fun writerReproducesEveryValidVectorCompressionFields() { + val valid = vectors["valid_vectors"]!!.jsonArray + for (vector in valid) { + val obj = vector.jsonObject + val name = obj["name"]!!.jsonPrimitive.content + val working = obj["working"]!!.jsonObject + + val invoiceFields = + obj["input"]!! + .jsonObject["invoice_fields"]!! + .jsonArray + .map { it.jsonObject } + .filter { it["type"]!!.jsonPrimitive.content.toLong() !in 240L..1000L } + .map { + Bolt12ProofBuilder.InvoiceField( + type = it["type"]!!.jsonPrimitive.content.toLong(), + value = Hex.decode(it["hex"]!!.jsonPrimitive.content), + include = it["included"]!!.jsonPrimitive.content.toBoolean(), + ) + } + + // Deterministic compression fields don't depend on the signatures, so + // dummy signers suffice; we read the minted proof back and compare. + val minted = + Bolt12ProofBuilder.build( + invoiceFields = invoiceFields, + preimage = Hex.decode(obj["input"]!!.jsonObject["preimage"]!!.jsonPrimitive.content), + signInvoiceDigest = { ByteArray(64) }, + signProofDigest = { ByteArray(64) }, + ) + val proof = Bolt12PayerProof.parse(minted) ?: fail("writer output for '$name' failed to parse") + + val expectedMarkers = working["proof_omitted_tlvs"]!!.jsonArray.map { it.jsonPrimitive.content.toLong() } + assertEquals(expectedMarkers, proof.omittedTlvMarkers(), "proof_omitted_tlvs mismatch for '$name'") + + val expectedMissing = working["proof_missing_hashes"]!!.jsonArray.map { it.jsonPrimitive.content } + assertEquals(expectedMissing, proof.missingHashList()!!.map { Hex.encode(it) }, "proof_missing_hashes mismatch for '$name'") + + val expectedLeaves = working["proof_leaf_hashes"]!!.jsonArray.map { it.jsonPrimitive.content } + assertEquals(expectedLeaves, proof.leafHashList()!!.map { Hex.encode(it) }, "proof_leaf_hashes mismatch for '$name'") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt index ba41644604..e397c01877 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ProofFixture.kt @@ -23,19 +23,20 @@ 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.Bolt12ProofBuilder 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]. + * Builds matched BOLT12 offers and payer proofs for tests, minting spec-compliant + * (lightning/bolts#1346) proofs through [Bolt12ProofBuilder] and self-signing them + * with Quartz's own secp256k1 so the whole reconstruction + merkle + BIP-340 path + * is exercised. This is a self-consistent construction; byte-exact CLN/LDK interop + * is covered separately by [Bolt12PayerProofVectorTest]. */ object Bolt12ProofFixture { /** A 33-byte compressed point (even parity) wrapping an x-only key. */ @@ -73,48 +74,31 @@ object Bolt12ProofFixture { // the preimage check (SHA256(preimage) != invoice_payment_hash) is what rejects it. val paymentHash = sha256(preimage).also { if (corruptPaymentHash) it[0] = (it[0] + 1).toByte() } - // The invoice's signed records (types < 240), in ascending order. - val invoiceRecords = + // The full invoice's non-signature TLVs, ascending. invreq_metadata (type 0) + // is always present and always withheld; invreq_amount (82) is the field the + // `compressed` flag selectively omits. + val invoiceFields = 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), + Bolt12ProofBuilder.InvoiceField(TYPE_INVREQ_METADATA, ByteArray(16), include = false), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_OFFER_ISSUER_ID, nodePoint, include = true), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVREQ_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats), include = !compressed), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVREQ_PAYER_ID, payerPoint, include = true), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVREQ_PAYER_NOTE, payerNote.encodeToByteArray(), include = true), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVOICE_PAYMENT_HASH, paymentHash, include = true), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVOICE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats), include = true), + Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVOICE_NODE_ID, nodePoint, include = true), ) - val invoiceRoot = Bolt12Merkle.rootHash(invoiceRecords) + val invoiceSigningKey = if (breakInvoiceSignature) payerLightningKey else nodeKey - val invoiceSig = - Nip01Crypto.sign( - Bolt12Merkle.signatureDigest(Bolt12ProofVerifier.INVOICE_MESSAGE, Bolt12ProofVerifier.SIGNATURE_FIELD, invoiceRoot), - invoiceSigningKey.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()) + return Bolt12ProofBuilder.build( + invoiceFields = invoiceFields, + preimage = preimage, + signInvoiceDigest = { digest -> Nip01Crypto.sign(digest, invoiceSigningKey.privKey!!) }, + signProofDigest = { digest -> Nip01Crypto.sign(digest, proofSigningKey.privKey!!) }, + ) } + + private const val TYPE_INVREQ_METADATA = 0L } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt index e7e11a8663..0d1d20ab50 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12ZapValidatorTest.kt @@ -76,8 +76,11 @@ class Bolt12ZapValidatorTest { } @Test - fun acceptsButFlagsACompressedProofAsUnverified() = + fun verifiesACompressedProofThroughMerkleReconstruction() = runTest { + // The proof withholds `invreq_amount`, forcing the verifier to rebuild the + // invoice merkle root from `proof_missing_hashes` before checking the + // invoice signature. Bound to a directly-addressed offer, it is fully verified. val signer = NostrSignerInternal(KeyPair()) val nodeKey = KeyPair() val preimage = ByteArray(32) { (it + 1).toByte() } @@ -88,7 +91,7 @@ class Bolt12ZapValidatorTest { val result = validator.validate(signedZap(signer, intent, proof)) assertIs(result) - assertTrue(!result.proofCryptoVerified, "a compressed proof is bound but not yet crypto-verified") + assertTrue(result.proofCryptoVerified, "a compressed proof bound to the offer must verify") } @Test diff --git a/quartz/src/commonTest/resources/bolt12/payer-proof-test.json b/quartz/src/commonTest/resources/bolt12/payer-proof-test.json new file mode 100644 index 0000000000..6a7c89e4ef --- /dev/null +++ b/quartz/src/commonTest/resources/bolt12/payer-proof-test.json @@ -0,0 +1,969 @@ +{ + "payer_secret": "4242424242424242424242424242424242424242424242424242424242424242", + "keys": { + "offer_issuer_id": { + "secret": "4646464646464646464646464646464646464646464646464646464646464646", + "pubkey": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + "invreq_payer_id": { + "secret": "4242424242424242424242424242424242424242424242424242424242424242", + "pubkey": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + "first_node_id": { + "secret": "4343434343434343434343434343434343434343434343434343434343434343", + "pubkey": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007" + }, + "first_path_key": { + "secret": "4444444444444444444444444444444444444444444444444444444444444444", + "pubkey": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991" + }, + "blinded_node_id": { + "secret": "4545454545454545454545454545454545454545454545454545454545454545", + "pubkey": "02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145" + }, + "invoice_node_id": { + "secret": "4646464646464646464646464646464646464646464646464646464646464646", + "pubkey": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + } + }, + "valid_vectors": [ + { + "name": "full_disclosure", + "input": { + "invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazhq6zqqqqqqqqqqqqqqqqqqqzczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyp7aextn2n4d5mzx2phwfe70cejyqaaq78my4wndgna3ymwyc4vlf60kke258g33nhp23vldqpygemxp54ecl0vr0qfejm3xpm6atq4mlavkstcqszss", + "invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8ae0d08000000000000000000000000b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f040fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577feb2d05e010142", + "preimage": "0101010101010101010101010101010101010101010101010101010101010101", + "invoice_fields": [ + { + "type": 0, + "len": 16, + "hex": "00000000000000000000000000000000", + "included": false + }, + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 82, + "len": 2, + "hex": "03e8", + "included": true + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "included": true + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000", + "included": true + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000", + "included": true + }, + { + "type": 164, + "len": 4, + "hex": "67527988", + "included": true + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793", + "included": true + }, + { + "type": 170, + "len": 2, + "hex": "03e8", + "included": true + }, + { + "type": 174, + "len": 13, + "hex": "08000000000000000000000000", + "included": true + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 240, + "len": 64, + "hex": "fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577", + "included": false + }, + { + "type": 3000000001, + "len": 1, + "hex": "42", + "included": true + } + ] + }, + "working": { + "invoice_merkle_root": "cb9e0c81bb39fc244f9f523c748ab4de0e09f1a5fef74359c2e1f7cc7cdc7447", + "invoice_sighash": "538aab18f82285032db132cecf7275ba2cbb462ef1f4d151588f836d0ce47aae", + "invoice_signature": "fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577", + "proof_merkle_root": "d461a2dd3099e2c43ccef4c3c8d36f720948fe21712ea94dceccb4bddb0c9e5e", + "proof_leaf_hashes": [ + "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f461", + "8dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283c", + "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67", + "f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa", + "7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e", + "19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997", + "f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a", + "dc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb", + "c14cfffaa314261bcbb2ed4ca24d5717bb608d8a6cc9910790bc1d49af7858ab", + "7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + "abaab91b367e30fea7026daf9f2590bb7e9cc31db8221f4013c67289e38f22c8" + ], + "proof_omitted_tlvs": [], + "proof_missing_hashes": [ + "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1" + ] + }, + "result": { + "payer_sig": "4961333409f453b5518fcbc662936fcb46e6c1db8d963c44b67d5677f0ffce3ac5c42293d1bc1298b0d67320a772e20a06c069dfa7079df9207b675357735dd7", + "proof_fields": [ + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 82, + "len": 2, + "hex": "03e8" + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000" + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000" + }, + { + "type": 164, + "len": 4, + "hex": "67527988" + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793" + }, + { + "type": 170, + "len": 2, + "hex": "03e8" + }, + { + "type": 174, + "len": 13, + "hex": "08000000000000000000000000" + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 240, + "len": 64, + "hex": "fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577" + }, + { + "type": 241, + "len": 64, + "hex": "4961333409f453b5518fcbc662936fcb46e6c1db8d963c44b67d5677f0ffce3ac5c42293d1bc1298b0d67320a772e20a06c069dfa7079df9207b675357735dd7" + }, + { + "type": 1001, + "len": 32, + "hex": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "type": 1003, + "len": 32, + "hex": "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1" + }, + { + "type": 1004, + "len": 352, + "hex": "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffbc14cfffaa314261bcbb2ed4ca24d5717bb608d8a6cc9910790bc1d49af7858ab7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cbabaab91b367e30fea7026daf9f2590bb7e9cc31db8221f4013c67289e38f22c8" + }, + { + "type": 3000000001, + "len": 1, + "hex": "42" + } + ], + "bech32": "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73tsdpqqqqqqqqqqqqqqqqqqqpvppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqlwun9e4f6k6d3r9qmhyul8uvezqw7s0raj2hfk5f7cjdhzv2k05a8mtv42r5gcems4gk0ksqjyvanq62uu0hkphsyuedcnqaaw4s2al3gpykzve5p8698d233l9uvc5ndl95dekpmwxev0zyke74valsll8r43wyy2far0qjnzcdvueq5aewyzsxcp5alfc8nhujq7m82dthxhwhl5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86eqpdgshfxx3pxkqv2eemf0pj3puaeyyj6eu54zrydml08swdmcqksl6qlvl5qkprys2lkc3u7956myg8w2cw67fnhmxc2eqntnv2uxu7zz07mftarp3hz549698hhx7grl55sk5v832e6yyufv4xy990rcndecs5pf9q709h40tuctu08d3878cfx5y2qehur27rjg5v2z8w7suf3570pauel4f7qvjj588qlj4rhhc0jxr33tv7j3mfdueakdj6nah2efh6j3lfuynw9c2msa9f3annnacxncupwtkt00rawhwzwy36rs0c99nlj3ux08unhwd06kcmzcnljsqd2fpsd8eydh209cqp7yk55r3fnh97vh7qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psddczuduql35zc9xxllz35c947kz0hku8hc6w7ajkgfw87p3kp4l77pfnll4gc5ycduhvhdfj3y64chhdsgmznvexgs0y9ur4y677zc4dlf9dmmncuyxeg0dnt7a99kw5l2nhe4xdcskpx7u6r26dm9zkjuh2a2hydnvl3sl6nsymd0nujepwm7nnp3mwpzraqp83nj383c7gkgl6edqhspq9pq" + } + }, + { + "name": "minimal_disclosure", + "input": { + "invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs", + "invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "preimage": "0101010101010101010101010101010101010101010101010101010101010101", + "invoice_fields": [ + { + "type": 0, + "len": 16, + "hex": "00000000000000000000000000000000", + "included": false + }, + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": false + }, + { + "type": 82, + "len": 2, + "hex": "03e8", + "included": false + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "included": true + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000", + "included": false + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000", + "included": false + }, + { + "type": 164, + "len": 4, + "hex": "67527988", + "included": false + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793", + "included": true + }, + { + "type": 170, + "len": 2, + "hex": "03e8", + "included": false + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "included": false + } + ] + }, + "working": { + "invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + "invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5", + "invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "proof_merkle_root": "ccc704b73e8190c71bf2ac3661d631c8c5ac502b78641708c8e61c0a1c8ebb72", + "proof_leaf_hashes": [ + "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67", + "f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a", + "7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + ], + "proof_omitted_tlvs": [ + 1, + 2, + 89, + 90, + 91, + 169 + ], + "proof_missing_hashes": [ + "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41", + "b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02ab", + "cd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e316", + "9f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9", + "998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db" + ] + }, + "result": { + "payer_sig": "cebc25d40a2d927b5ebcab8400f542fbb7a8f462e8dd802bb13e050b9bf293b7457c19b46a476740f97c9d6ec141f23434c5d4fa253a1d2eb8896ebad99455cc", + "proof_fields": [ + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793" + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319" + }, + { + "type": 241, + "len": 64, + "hex": "cebc25d40a2d927b5ebcab8400f542fbb7a8f462e8dd802bb13e050b9bf293b7457c19b46a476740f97c9d6ec141f23434c5d4fa253a1d2eb8896ebad99455cc" + }, + { + "type": 1001, + "len": 32, + "hex": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "type": 1002, + "len": 6, + "hex": "0102595a5ba9" + }, + { + "type": 1003, + "len": 160, + "hex": "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db" + }, + { + "type": 1004, + "len": 96, + "hex": "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + } + ], + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + } + }, + { + "name": "with_note", + "input": { + "invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs", + "invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "preimage": "0101010101010101010101010101010101010101010101010101010101010101", + "note": "test note", + "invoice_fields": [ + { + "type": 0, + "len": 16, + "hex": "00000000000000000000000000000000", + "included": false + }, + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": false + }, + { + "type": 82, + "len": 2, + "hex": "03e8", + "included": false + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "included": true + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000", + "included": false + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000", + "included": false + }, + { + "type": 164, + "len": 4, + "hex": "67527988", + "included": false + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793", + "included": true + }, + { + "type": 170, + "len": 2, + "hex": "03e8", + "included": false + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "included": false + } + ] + }, + "working": { + "invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + "invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5", + "invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "proof_merkle_root": "327c38426d1d557f3669f19cdb440187e312679eadcfb61a9a8dcb0098639467", + "proof_leaf_hashes": [ + "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67", + "f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a", + "7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + ], + "proof_omitted_tlvs": [ + 1, + 2, + 89, + 90, + 91, + 169 + ], + "proof_missing_hashes": [ + "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41", + "b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02ab", + "cd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e316", + "9f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9", + "998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db" + ] + }, + "result": { + "payer_sig": "2a47f98770ae814119d682a7b19f7ce9e050a3bb49d9b0a031c46d9cb57a3075ddc23a742f6d33bc4ec8e1778327494bea76d2ee564625e99bfb95cc209a7722", + "proof_fields": [ + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793" + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319" + }, + { + "type": 241, + "len": 64, + "hex": "2a47f98770ae814119d682a7b19f7ce9e050a3bb49d9b0a031c46d9cb57a3075ddc23a742f6d33bc4ec8e1778327494bea76d2ee564625e99bfb95cc209a7722" + }, + { + "type": 1001, + "len": 32, + "hex": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "type": 1002, + "len": 6, + "hex": "0102595a5ba9" + }, + { + "type": 1003, + "len": 160, + "hex": "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db" + }, + { + "type": 1004, + "len": 96, + "hex": "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + }, + { + "type": 1005, + "len": 9, + "hex": "74657374206e6f7465" + } + ], + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qz53lesac2aq2pr8tg9fa3na7wnczs5wa5nkds5qcugmvuk4arqawacga8gtmdxw7yaj8pw7pjwj2tafmd9mjkgcj7nxlmjhxzpxnhyt7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9e07s8mgfw3jhxapqdehhgeg" + } + }, + { + "name": "left_subtree_omitted", + "input": { + "invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs", + "invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "preimage": "0101010101010101010101010101010101010101010101010101010101010101", + "invoice_fields": [ + { + "type": 0, + "len": 16, + "hex": "00000000000000000000000000000000", + "included": false + }, + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": false + }, + { + "type": 82, + "len": 2, + "hex": "03e8", + "included": false + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "included": true + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000", + "included": false + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000", + "included": false + }, + { + "type": 164, + "len": 4, + "hex": "67527988", + "included": false + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793", + "included": true + }, + { + "type": 170, + "len": 2, + "hex": "03e8", + "included": true + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "included": false + } + ] + }, + "working": { + "invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + "invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5", + "invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "proof_merkle_root": "1b0bab09a5fcccec19eb3aaafbbfa4075944fd1f65691ed715fbbc17a59b3651", + "proof_leaf_hashes": [ + "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67", + "f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a", + "dc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb", + "7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + ], + "proof_omitted_tlvs": [ + 1, + 2, + 89, + 90, + 91 + ], + "proof_missing_hashes": [ + "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41", + "b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02ab", + "cd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e316", + "9f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9" + ] + }, + "result": { + "payer_sig": "ed03ec58d5ac676539486bb6e8d6f389b6375b6693ffdf30a93919590d744fa393f945a5de3bb57d65c7f61def1cefa8aa038725b15bf0fa797dfd08ca97538a", + "proof_fields": [ + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793" + }, + { + "type": 170, + "len": 2, + "hex": "03e8" + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319" + }, + { + "type": 241, + "len": 64, + "hex": "ed03ec58d5ac676539486bb6e8d6f389b6375b6693ffdf30a93919590d744fa393f945a5de3bb57d65c7f61def1cefa8aa038725b15bf0fa797dfd08ca97538a" + }, + { + "type": 1001, + "len": 32, + "hex": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "type": 1002, + "len": 5, + "hex": "0102595a5b" + }, + { + "type": 1003, + "len": 128, + "hex": "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9" + }, + { + "type": 1004, + "len": 128, + "hex": "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + } + ], + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqpgejy3tgk64wdmf9yqft6llpqukq867u5layf72mq0cu6zd79zc2s0yuxgg9j7xdsrdqdztevckgp7sqlujsenwy6x9hp8lar3awxx03grks8mzc6kkxwefefp4md6xk7wymvd6mv6fllhes4yu3jkgdw3868ylegkjauwa404ju0asaauwwl292qwrjtv2m7ra8jl0apr9fw5u2l5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86s9qyp9jkjml5p7hq9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyl6qlvsredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq6ms9cmcplrg9s2vdl79rfsttavyl0dc0035aam9vsju0urrvrtlahay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc" + } + }, + { + "name": "empty_proof_omitted_tlvs_explicit", + "input": { + "invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs", + "invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "preimage": "0101010101010101010101010101010101010101010101010101010101010101", + "invoice_fields": [ + { + "type": 0, + "len": 16, + "hex": "00000000000000000000000000000000", + "included": false + }, + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 82, + "len": 2, + "hex": "03e8", + "included": true + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "included": true + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000", + "included": true + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000", + "included": true + }, + { + "type": 164, + "len": 4, + "hex": "67527988", + "included": true + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793", + "included": true + }, + { + "type": 170, + "len": 2, + "hex": "03e8", + "included": true + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382", + "included": true + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "included": false + } + ] + }, + "working": { + "invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e", + "invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5", + "invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319", + "proof_merkle_root": "a98cbee700b100ede32c19f9525264d51a105751fb27e790dad011bb38415b89", + "proof_leaf_hashes": [ + "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f461", + "8dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283c", + "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67", + "f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa", + "7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e", + "19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997", + "f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a", + "dc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb", + "7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + ], + "proof_omitted_tlvs": [], + "proof_missing_hashes": [ + "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1" + ] + }, + "result": { + "payer_sig": "dffe62d6446c182f2503e780fce09fc5cf310ef14a54f65dd1df124039b7a897f2940470ac0f9857f826b232289c0ac4e6927ca67d805167fb0add352f88add1", + "proof_fields": [ + { + "type": 22, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 82, + "len": 2, + "hex": "03e8" + }, + { + "type": 88, + "len": 33, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + { + "type": 160, + "len": 118, + "hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000" + }, + { + "type": 162, + "len": 28, + "hex": "00000001000000020003000000000000000400000000000000050000" + }, + { + "type": 164, + "len": 4, + "hex": "67527988" + }, + { + "type": 168, + "len": 32, + "hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793" + }, + { + "type": 170, + "len": 2, + "hex": "03e8" + }, + { + "type": 176, + "len": 33, + "hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382" + }, + { + "type": 240, + "len": 64, + "hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319" + }, + { + "type": 241, + "len": 64, + "hex": "dffe62d6446c182f2503e780fce09fc5cf310ef14a54f65dd1df124039b7a897f2940470ac0f9857f826b232289c0ac4e6927ca67d805167fb0add352f88add1" + }, + { + "type": 1001, + "len": 32, + "hex": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "type": 1002, + "len": 0, + "hex": "" + }, + { + "type": 1003, + "len": 32, + "hex": "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1" + }, + { + "type": 1004, + "len": 288, + "hex": "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb" + } + ], + "bech32": "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqpgejy3tgk64wdmf9yqft6llpqukq867u5layf72mq0cu6zd79zc2s0yuxgg9j7xdsrdqdztevckgp7sqlujsenwy6x9hp8lar3awxx03gr0luckkg3kpste9q0ncpl8qnlzu7vgw7999faja6803yspek75f0u55q3c2cruc2luzdv3j9zwq438xjf72vlvq29nlkzkax5hc3tw3l5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sql5p7kgqt2y96f35gf4srzkww6tcv5g08wfpykk099gserwlmeurnw7q9587s8m8aqysgeyzhaky083dxkezpmjkrkhjva7ekzkgy6umzhph8ssnlk62lgcvdc49fw3faaehjqla9y94rpu2kw3p8zt9f3pftc7ymwwy9q2fg8nedat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0a20sry54pec8u4gaa7ru3suv2m855w6t0x0dnvk5ld6k2d755060pym3wzku8f2v0vuulwp578qtjajmmclt4msn3ywsur7pfvlu50pnelyamnt74kxckylu5qr2jgvrf7frd6newqq03949qu2vae0n9lsrywr2qqzga25hrg2r95f3fu5hu773xvz2ugh3kf34lak2ncvrtwqhr0q8udqkpf3hlc5dxpd04snaahpa7xnhhv4jzt3lsvdsd0lkl5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt" + } + } + ], + "invalid_vectors": [ + { + "reason": "missing_invreq_payer_id", + "bech32": "lnp14qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "missing_invoice_payment_hash", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cukqssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz7pqq5vezg45td2hxa5jjqy4a0lsswtqra0w207jyl9ds8uwdpxlz3v9g8jwryyze0rxcpksx39ukvtyqlgq07fgvehzdrzmsnl73c7hrr8c5pn4uyh2q5tvj0d0te2uyqr6597ah4r6x96xasq4mz0s9pwdl9yahg47pndr2gan5p7tun4hvzs0jxs6vt486y5ap6t4c39ht4kv52hx06qlfyqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsrlgragrqzqjetfd6nlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s" + }, + { + "reason": "missing_invoice_node_id", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ylsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "missing_signature", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qh3gr8tcfw5pgkey767hj4cgq84gtam0285vt5dmqptkylq2zum72fmw3turx6x53m8gruhe8twc9qlydp5ch205ff6r5ht3ztwhtveg4wvl5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sxqyp9jkjm487s86aqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kml5p7cc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt" + }, + { + "reason": "missing_proof_preimage", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86sxqyp9jkjm487s86aqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kml5p7cc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt" + }, + { + "reason": "missing_proof_missing_hashes", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s" + }, + { + "reason": "missing_proof_leaf_hashes", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjmv" + }, + { + "reason": "missing_proof_signature", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84ccel5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sxqyp9jkjm487s86aqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kml5p7cc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt" + }, + { + "reason": "wrong_proof_preimage", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq06ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "proof_omitted_tlvs_not_ascending", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk2649dl6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "proof_omitted_tlvs_contains_zero", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2quqqzqjetfd6nlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s" + }, + { + "reason": "proof_omitted_tlvs_contains_signature_field", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2quqsyk26tw5lrlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s" + }, + { + "reason": "proof_omitted_tlvs_contains_proof_field", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2pyqsyk26tw5l6qlfl5p7hg9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyenz4hl2w8g0aem0dsmr2xl0366ve5qz7s0uegmndkqzrep0ya9klaq0kxpuk74a0np03uakylclpy6s3grxlsdtcwfz33ggam6r3xxneu8hn87qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psd8ay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc" + }, + { + "reason": "proof_omitted_tlvs_contains_high_field", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2pvqsyk26tw5lamnt9qq06qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "proof_omitted_tlvs_contains_included_tlv_field", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2quqsykzetfd6nlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s" + }, + { + "reason": "proof_omitted_tlvs_not_sequential", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyktyv4n06qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "proof_leaf_hashes_too_few", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mzq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxs" + }, + { + "reason": "proof_leaf_hashes_too_many", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8myq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9evqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + }, + { + "reason": "proof_missing_hashes_too_few", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qltszlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4j0aq0kxpuk74a0np03uakylclpy6s3grxlsdtcwfz33ggam6r3xxneu8hn87qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psd8ay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc" + }, + { + "reason": "proof_missing_hashes_too_many", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qltczlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjmvqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplgra3s09h40tuctu08d3878cfx5y2qehur27rjg5v2z8w7suf3570pauelsrywr2qqzga25hrg2r95f3fu5hu773xvz2ugh3kf34lak2ncvrflf9dmmncuyxeg0dnt7a99kw5l2nhe4xdcskpx7u6r26dm9zkjuk" + }, + { + "reason": "wrong_invoice_signature", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9nxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "wrong_proof_signature", + "bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qvl0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev" + }, + { + "reason": "contains_invreq_metadata", + "bech32": "lnp1qqq5ykppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43e2pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7fmqggzf0p2xyn9z5ls0ecwpw4ssujwdwz7y9lce43ge6mzjapy0w6fxwp0qsq2xv3y269k4tnw6ffqz27hlcg89sp7hh98lfz0jkcr78xsn03gkz5re8pjzpvh3nvqmgrgj7tx9jq05q8ly5xvm3x33dcfllgu0t33nu2qe67zt4q29kf8kh4u4wzqpa2zlwm63arzarwcq2a38czshxljjwm52lqek34ywe6ql97f6mkpg8ergdx96naz2wsa96ugjm46mx29tn8aq05jqqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpl5p75pspqfv45kafl5p7hg9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyenz4hl2w8g0aem0dsmr2xl0366ve5qz7s0uegmndkqzrep0ya9klaq0kxpuk74a0np03uakylclpy6s3grxlsdtcwfz33ggam6r3xxneu8hn87qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psd8ay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc" + } + ] +} From 9caf330879de70da71d0c448347b82db4dda2d29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 03:00:51 +0000 Subject: [PATCH 23/23] fix(bolt12): don't throw on an oversized tu64; cover proof_note path Audit of the compressed-proof work found one real defect and one coverage gap. Defect: a hostile BOLT12 proof/offer can carry a 9+ byte `invoice_amount` (or any tu64 field) that parses as a valid TLV. `TlvStream.tu64` then called the strict `Bolt12Values.tu64`, which throws `require(size <= 8)`. On the `amy bolt12 verify` path (`Bolt12ZapActions.validate`, no surrounding catch) that surfaced as an uncaught exception and abnormal exit instead of a clean `Invalid`; the Android ingest path was already contained by LocalCache's broad catch. Make the nullable stream accessor `TlvStream.tu64` return null for an over-8-byte value so every amount read (invoice_amount, invreq_amount, offer amount) degrades to a clean rejection. Regression-tested at the codec level. Coverage: the writer's `proof_note` (1005) branch and the `with_note` vector's note were never exercised. Add a `Bolt12PayerProof.proofNote()` reader and thread the vector's note through the writer round-trip so 1005 is asserted. The forged-proof, DoS, and reconstruction-accounting paths were reviewed and found sound (the reconstructed root is only ever a BIP-340 message; the NIP offer-binding gate still pins invoice_node_id to the offer's issuer). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/commons/actions/Bolt12ZapActions.kt | 6 +++--- .../amethyst/commons/actions/Bolt12ZapActionsTest.kt | 9 ++++++--- .../nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt | 3 +++ .../quartz/nipXXBolt12Zaps/bolt12/Tlv.kt | 9 +++++++-- .../quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt | 12 ++++++++++++ .../verify/Bolt12PayerProofVectorTest.kt | 5 +++++ 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt index bcf6cdce3c..3875931c94 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActions.kt @@ -55,9 +55,9 @@ object Bolt12ZapActions { /** * Decode a BOLT12 offer (`lno1…`) to its interesting fields, or null when unparseable. - * A field read can still throw on a well-encoded-but-malformed TLV (e.g. an amount - * value longer than 8 bytes), so the whole field extraction is guarded to honor the - * null contract rather than leak an exception to the caller. + * Individual field reads degrade to null on malformed values (e.g. an amount longer + * than 8 bytes), so a structurally-valid offer still decodes without its bad field; + * the extraction stays guarded as defense-in-depth against any future throwing read. */ fun decodeOffer(raw: String): Map? { val offer = Bolt12Offer.parse(raw) ?: return null diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt index fdccafba05..fabbf01868 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/Bolt12ZapActionsTest.kt @@ -71,15 +71,18 @@ class Bolt12ZapActionsTest { } @Test - fun decodeOfferReturnsNullForAParseableButMalformedAmount() { + fun decodeOfferOmitsAMalformedAmountWithoutThrowing() { // The TLV stream parses (ascending type, valid length), but the amount value is - // 9 bytes — reading it throws in tu64. decodeOffer must honor its null contract. + // 9 bytes — too long for a tu64. Reading it must not throw: the offer still + // decodes, just without an `amount_msat`. val bad = Bolt12Bech32.encode( Bolt12Bech32.OFFER_HRP, TlvStream(listOf(TlvRecord(Bolt12Offer.TYPE_AMOUNT, ByteArray(9) { 1 }))).encode(), ) - assertNull(Bolt12ZapActions.decodeOffer(bad)) + val fields = Bolt12ZapActions.decodeOffer(bad) + assertTrue(fields != null) + assertNull(fields["amount_msat"]) } @Test 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 index bbcbadca72..3cbf2f0fa2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Bolt12PayerProof.kt @@ -56,6 +56,9 @@ class Bolt12PayerProof( fun proofPreimage(): ByteArray? = tlv.value(TYPE_PROOF_PREIMAGE) + /** The optional free-text `proof_note` (1005) a challenge-response verifier may request. */ + fun proofNote(): String? = tlv.value(TYPE_PROOF_NOTE)?.decodeToString() + fun proofOmittedTlvs(): ByteArray? = tlv.value(TYPE_PROOF_OMITTED_TLVS) fun proofMissingHashes(): ByteArray? = tlv.value(TYPE_PROOF_MISSING_HASHES) 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 index 2a7285d679..7d166e4980 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/Tlv.kt @@ -147,8 +147,13 @@ class TlvStream( 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) } + /** + * The truncated-uint64 value of a record, or null if absent **or malformed**. + * A `tu64` is at most 8 bytes; a longer value is invalid encoding, so this + * returns null rather than throwing — untrusted proofs/offers reach this on the + * validation path and must degrade to a clean rejection, not an exception. + */ + fun tu64(type: Long): Long? = value(type)?.let { if (it.size > 8) null else Bolt12Values.tu64(it) } fun encode(): ByteArray { var size = 0 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 index c7d67ce438..5308744b5c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/bolt12/TlvTest.kt @@ -64,6 +64,18 @@ class TlvTest { } } + @Test + fun tu64FieldReturnsNullForAnOversizedValueInsteadOfThrowing() { + // A tu64 value must be at most 8 bytes. A hostile proof/offer can carry a 9-byte + // amount that parses as a TLV; reading it via the stream accessor must degrade to + // null (a clean rejection on the validation path), not throw. + val stream = TlvStream(listOf(TlvRecord(170, ByteArray(9) { 1 }))) + val decoded = TlvStream.read(stream.encode()) + assertNull(decoded.tu64(170)) + // The exact-8-byte boundary still decodes. + assertEquals(Long.MAX_VALUE, TlvStream(listOf(TlvRecord(170, Bolt12Values.tu64ToBytes(Long.MAX_VALUE)))).tu64(170)) + } + @Test fun tlvStreamRoundTrips() { val records = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt index 396ca38ef6..449d4cc12e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXBolt12Zaps/verify/Bolt12PayerProofVectorTest.kt @@ -108,10 +108,12 @@ class Bolt12PayerProofVectorTest { // Deterministic compression fields don't depend on the signatures, so // dummy signers suffice; we read the minted proof back and compare. + val note = obj["input"]!!.jsonObject["note"]?.jsonPrimitive?.content val minted = Bolt12ProofBuilder.build( invoiceFields = invoiceFields, preimage = Hex.decode(obj["input"]!!.jsonObject["preimage"]!!.jsonPrimitive.content), + proofNote = note, signInvoiceDigest = { ByteArray(64) }, signProofDigest = { ByteArray(64) }, ) @@ -125,6 +127,9 @@ class Bolt12PayerProofVectorTest { val expectedLeaves = working["proof_leaf_hashes"]!!.jsonArray.map { it.jsonPrimitive.content } assertEquals(expectedLeaves, proof.leafHashList()!!.map { Hex.encode(it) }, "proof_leaf_hashes mismatch for '$name'") + + // The optional proof_note (1005) must round-trip when the vector carries one. + assertEquals(note, proof.proofNote(), "proof_note mismatch for '$name'") } } }