Merge pull request #3685 from vitorpamplona/claude/nip-2421-pr-review-6znvdd

Add NIP-XX BOLT12 zap support with validation and UI integration
This commit is contained in:
Vitor Pamplona
2026-07-24 23:26:28 -04:00
committed by GitHub
103 changed files with 7867 additions and 87 deletions
+199
View File
@@ -0,0 +1,199 @@
# 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).
## 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`:
- **`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=<offer>`. 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=<offer>`, `amount`,
`payer_note="nostr:nipXX:<intent-id>"`.
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:<intent-id>`). 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.**~~ **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.
## 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:<intent-id>`) 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 (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:<intent-id>`, 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).
## Phase 3 as shipped (capability gate + lightning fallback)
- `Account.defaultWalletSupportsBolt12Pay()` reads the default wallet's cached
kind:13194 info event via `NwcSignerState.infoCache` (added on `main` for
encryption negotiation; it already refreshes on wallet change) and checks
`supportsMethod("pay")`. A missing/unfetched info event reads as false. (An earlier
cut fetched `get_info.methods` into its own state; unified onto the 13194 cache on
the `main` merge to avoid a redundant fetch — the info event is the canonical
capability advertisement.)
- 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):
- ~~**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`.
@@ -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<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestBolt12Offers = async { parseEventOrNull<Bolt12OfferListEvent>(latestBolt12OffersStr) }
val latestCashuWallet =
async {
parseEventOrNull<com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent>(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),
@@ -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.actions.ConcordSubscriptionPlanner
@@ -78,6 +79,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
@@ -294,6 +296,10 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
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
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -365,6 +371,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
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
@@ -780,6 +788,8 @@ class Account(
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
val bolt12OfferList = Bolt12OfferListState(signer, cache, scope, settings)
val feedDecryptionCaches =
FeedDecryptionCaches(
peopleListCache = peopleListDecryptionCache,
@@ -1433,6 +1443,106 @@ 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. Read from the wallet's cached kind:13194
* info event (its capability advertisement), which [NwcSignerState] already refreshes
* on wallet change. A missing/unfetched info event reads as false, so the zap path
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
*/
fun defaultWalletSupportsBolt12Pay(): Boolean {
val uri = nip47SignerState.defaultWalletUri.value ?: return false
return nip47SignerState.infoCache?.current(uri)?.supportsMethod(NwcMethod.PAY) == true
}
/**
* 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,
) {
// 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.
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 {
// 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 {
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)
}
} 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()
}
}
}
}
suspend fun createZapRequestFor(
user: User,
message: String = "",
@@ -5687,6 +5797,8 @@ class Account(
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
suspend fun saveBolt12Offers(offers: List<String>) = sendMyPublicAndPrivateOutbox(bolt12OfferList.saveOffers(offers))
fun markAsRead(
route: String,
timestampInSecs: Long,
@@ -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<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
var backupBolt12Offers: Bolt12OfferListEvent? = null,
var callTurnServers: List<CallTurnServer> = 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
@@ -400,6 +400,10 @@ 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
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -467,6 +471,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
@@ -1281,6 +1292,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
@@ -2664,6 +2686,43 @@ 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.
// 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
}
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`
@@ -2719,6 +2778,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?,
@@ -4157,6 +4233,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Bolt12OfferListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is ClassifiedsEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
@@ -4577,6 +4657,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is Bolt12ZapEvent -> {
consume(event, relay, wasVerified)
}
is NIP90StatusEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -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<NoteState> = 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<List<String>> =
getBolt12OfferListFlow()
.map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
suspend fun saveOffers(offers: List<String>): 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)
}
}
}
}
}
@@ -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,62 @@ 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 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() &&
account.defaultWalletSupportsBolt12Pay()
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 +215,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 +310,10 @@ class ZapPaymentHandler(
zapType: LnZapEvent.ZapType,
zapsToSend: List<MyZapSplitSetup>,
totalAmountMilliSats: Long,
): List<ZapRequestReady> {
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<ZapRequestReady> =
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
// makes sure the author receives the zap event
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
@@ -291,7 +346,6 @@ class ZapPaymentHandler(
ZapRequestReady(next, zapRequest)
}
}
suspend fun assembleAllInvoices(
requests: List<ZapRequestReady>,
@@ -301,9 +355,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<Payable> {
var progressAllPayments = 0.00f
val totalWeight = requests.sumOf { it.inputSetup.weight }
return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady ->
try {
@@ -386,6 +441,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<Bolt12Recipient>,
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.
@@ -63,6 +63,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
@@ -111,6 +112,7 @@ class NotificationDispatcher(
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
Bolt12ZapEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
@@ -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.
@@ -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 =
@@ -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,
@@ -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,
@@ -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
@@ -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)
}
}
}
}
@@ -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<List<String>>(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()
}
}
}
@@ -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.BlossomImportScreen
@@ -593,6 +594,7 @@ fun BuildNavigation(
}
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
composableFromEnd<Route.EditBolt12Offers> { Bolt12OffersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
@@ -486,6 +486,8 @@ sealed class Route {
@Serializable object EditPaymentTargets : Route()
@Serializable object EditBolt12Offers : Route()
@Serializable object UpdateReactionType : Route()
@Serializable data class Nip47NWCSetup(
@@ -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.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 com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
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<Bolt12ZapEntry>,
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<Bolt12ZapEntry>,
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,
) {
// 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
// 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)
}
}
}
@@ -128,6 +128,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
@@ -355,6 +356,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
@@ -1095,6 +1097,10 @@ private fun RenderNoteRow(
RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is Bolt12ZapEvent -> {
RenderBolt12Zap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
@@ -587,6 +587,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()) {
@@ -1514,7 +1515,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)
@@ -1554,7 +1556,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
@@ -543,6 +543,33 @@ fun payViaIntent(
}
}
/**
* Hands a reusable BOLT12 offer (`lno1…`, from a recipient's kind:10058) off to an
* 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,
context: Context,
onPaid: () -> Unit,
onError: (String) -> Unit,
) {
try {
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)
onPaid()
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(stringRes(context, R.string.no_wallet_found))
}
}
@Composable
fun PayButton(
isActive: Boolean,
@@ -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<Color>,
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)
}
}
}
@@ -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
@@ -1205,6 +1208,34 @@ class AccountViewModel(
)
}
/** True when the account has at least one NIP-47 wallet configured. */
fun hasNwcWallet(): Boolean =
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
* 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
@@ -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,
@@ -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,
@@ -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,
@@ -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) }
}
}
}
@@ -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 -> {
@@ -88,6 +88,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
@@ -166,6 +167,7 @@ class NotificationFeedFilter(
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
Bolt12ZapEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,
PollEvent.KIND,
@@ -267,7 +269,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
@@ -472,6 +475,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
@@ -482,7 +488,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()
@@ -545,7 +551,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 || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
@@ -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,
@@ -0,0 +1,294 @@
/*
* 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.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
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.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
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.ButtonBorder
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<Bolt12OfferListEvent>(note, accountViewModel)
val offers =
remember(event) {
event?.offers() ?: emptyList()
}
if (offers.isNotEmpty()) {
Bolt12PayButtonWithOffers(offers, accountViewModel)
}
}
}
}
@Composable
fun Bolt12PayButtonWithOffers(
offers: List<String>,
accountViewModel: AccountViewModel,
) {
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,
accountViewModel = accountViewModel,
onDismiss = { expanded = false },
)
}
}
@Composable
fun Bolt12OffersDialog(
offers: List<String>,
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.canPayBolt12ViaNwc() }
// The offer the user chose to pay over NWC; drives the amount-entry dialog.
var nwcPayOffer by remember { mutableStateOf<String?>(null) }
M3ActionDialog(
title = stringRes(R.string.bolt12_offers),
onDismiss = onDismiss,
) {
M3ActionSection {
offers.forEach { offer ->
Bolt12OfferRow(
offer = offer,
canPayInApp = canPayInApp,
onCopy = {
scope.launch {
clipboardManager.setText(offer)
Toast
.makeText(
context,
stringRes(context, R.string.copied_to_clipboard),
Toast.LENGTH_SHORT,
).show()
}
},
onPayViaIntent = {
payViaBolt12Intent(
offer = offer,
context = context,
onPaid = { onDismiss() },
onError = { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
)
},
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,
onPayViaIntent: () -> Unit,
onPayInApp: () -> 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,
)
}
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),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** 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))
}
}
}
}
}
}
@@ -42,6 +42,8 @@ fun ProfileActions(
PaymentButton(baseUser, accountViewModel, nav)
Bolt12PayButton(baseUser, accountViewModel)
val isMe by
remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } }
@@ -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<Event>.sumAmountsByUser(): List<ZapAmount> {
val results = mutableMapOf<User, BigDecimal>()
@@ -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) {
@@ -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
@@ -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),
@@ -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) {
+16
View File
@@ -1753,6 +1753,21 @@
<string name="no_payment_app_found_for_type">No app installed to handle %1$s payments. Please install a compatible wallet.</string>
<string name="error_dialog_payment_error">Unable to open payment</string>
<string name="bolt12_offers">BOLT12 Offers</string>
<string name="bolt12_offers_explainer">Publish reusable BOLT12 offers so others can zap you over Lightning without a separate invoice each time.</string>
<string name="bolt12_offers_section_explainer">Add one or more BOLT12 offers (lno1…). They are shared publicly so anyone can pay you.</string>
<string name="no_bolt12_offers_message">No BOLT12 offers set. Add one below ↓</string>
<string name="bolt12_offer">BOLT12 offer (lno1…)</string>
<string name="invalid_bolt12_offer">Not a valid BOLT12 offer</string>
<string name="delete_bolt12_offer">Delete BOLT12 offer</string>
<string name="bolt12_pay_with_wallet">Pay with connected wallet</string>
<string name="bolt12_payment_amount_sats">Amount (sats)</string>
<string name="bolt12_payment_sent">BOLT12 payment sent</string>
<string name="bolt12_payment_failed">BOLT12 payment failed: %1$s</string>
<string name="bolt12_zap_error">BOLT12 zap error</string>
<string name="bolt12_zap_paid_no_receipt">Paid over BOLT12, but the wallet returned no proof, so no zap receipt was published.</string>
<string name="bolt12_zap_invalid_receipt">Paid over BOLT12, but the payment proof did not validate, so no zap receipt was published.</string>
<string name="uploading_state_ready">Not Started</string>
<string name="uploading_state_compressing">Compressing</string>
<string name="uploading_state_uploading">Uploading</string>
@@ -2255,6 +2270,7 @@
<string name="video_player_search_keywords" translatable="false">video, player, playback, autoplay, mute</string>
<string name="audio_visualizer_search_keywords" translatable="false">audio, visualizer, spectrum, bars, waves, radial, aurora, animation</string>
<string name="payment_targets_search_keywords" translatable="false">zap split, split, recipients, forward zaps</string>
<string name="bolt12_offers_search_keywords" translatable="false">bolt12, bolt 12, offer, lno, lightning, zap, reusable invoice</string>
<string name="call_settings_search_keywords" translatable="false">webrtc, video call, voice call, calls</string>
<string name="translations_search_keywords" translatable="false">language, translate, locale</string>
<string name="ots_explorer_search_keywords" translatable="false">opentimestamps, timestamp, ots, proof</string>
@@ -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,
+18
View File
@@ -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 |
+1
View File
@@ -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. |
@@ -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<String>): 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
@@ -0,0 +1,215 @@
/*
* 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.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 <lno1…|lnp1…>` — decode an offer or payer proof to its 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.
* * `bolt12 offer set <lno1…>…` — 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 <json> --proof <lnp1>` 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<String>,
): Int =
route(
"bolt12",
tail,
"bolt12 <decode|verify|offer|intent|zap> …",
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<String>): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 decode <lno1…|lnp1…>")
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<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 verify <event-id>")
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()
// Constrain by kind AND cast defensively: the store filters by id alone and
// returns whatever kind that id actually is, so querying <Bolt12ZapEvent>
// directly would ClassCastException if the id points at a non-9736 event.
val zap =
ctx.store
.query<Event>(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)) {
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<String>,
): Int =
route(
"bolt12 offer",
tail,
"bolt12 offer <get|set> …",
help = USAGE,
routes =
mapOf(
"get" to { rest -> offerGet(dataDir, rest) },
"set" to { rest -> offerSet(dataDir, rest) },
),
)
private suspend fun offerGet(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer get <user> [--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<Bolt12OfferListEvent>(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<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer set <lno1…> [<lno1…> …]")
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<NormalizedRelayUrl> = 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
}
}
}
@@ -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<String>,
): 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] <user|event-id> <sats> --offer <lno1> [--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 <lno1…> 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<Event>(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<String>,
): Int {
val args = Args(rest)
val intentJson = args.flag("intent") ?: return Output.error("bad_args", "bolt12 zap --intent <json> --proof <lnp1>")
val proofArg = args.flag("proof") ?: return Output.error("bad_args", "--proof <lnp1…> 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<NormalizedRelayUrl> =
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
}
}
}
}
}
@@ -0,0 +1,137 @@
/*
* 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.
* 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<String, Any?>? {
val offer = Bolt12Offer.parse(raw) ?: return null
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. Guarded like [decodeOffer] against a malformed-but-encoded field.
*/
fun decodeProof(raw: String): Map<String, Any?>? {
val proof = Bolt12PayerProof.parse(raw) ?: return null
return runCatching {
buildMap {
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)) }
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]. */
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<String>,
): Bolt12OfferListEvent = Bolt12OfferListEvent.create(offers, signer)
}
@@ -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,
)
@@ -172,6 +172,7 @@ open class Note(
removeLabel(note)
removeNutzap(note)
removeOnchainZapBySource(note)
removeBolt12ZapBySource(note)
}
var poll: PollResponsesCache? = null
@@ -285,6 +286,22 @@ open class Note(
var nutzaps = mapOf<HexKey, NutzapEntry>()
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<String, Bolt12ZapEntry>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
@@ -412,7 +429,8 @@ open class Note(
zaps.isNotEmpty() ||
boosts.isNotEmpty() ||
onchainZaps.isNotEmpty() ||
nutzaps.isNotEmpty()
nutzaps.isNotEmpty() ||
bolt12Zaps.isNotEmpty()
fun countReactions(): Int {
var total = 0
@@ -472,7 +490,7 @@ open class Note(
fun clearChildLinks(): List<Note> {
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 editsChanged = edits.isNotEmpty()
val timestampsChanged = timestamps.isNotEmpty()
@@ -492,7 +510,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()
@@ -505,6 +524,7 @@ open class Note(
onchainZaps = mapOf()
onchainZapResolved = false
nutzaps = mapOf()
bolt12Zaps = mapOf()
zapPayments = mapOf()
zapsAmount = BigDecimal(0)
relays = listOf()
@@ -785,6 +805,74 @@ open class Note(
}
}
private fun innerAddBolt12Zap(
paymentHashHex: String,
entry: Bolt12ZapEntry,
): Boolean =
syncLock.withLock {
val existing = bolt12Zaps[paymentHashHex]
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 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)
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?,
@@ -978,6 +1066,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
@@ -1005,6 +1094,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
@@ -1091,6 +1189,18 @@ open class Note(
sumOfAmounts += BigDecimal(entry.claimedSats)
}
// 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 ->
if (entry.cryptoVerified) {
sumOfAmounts += BigDecimal(entry.amountMillisats).divide(BigDecimal(1000))
}
}
zapsAmount = sumOfAmounts
}
@@ -1258,7 +1368,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,
@@ -1323,6 +1434,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)
@@ -1343,6 +1458,7 @@ open class Note(
zaps = emptyMap()
nutzaps = emptyMap()
onchainZaps = emptyMap()
bolt12Zaps = emptyMap()
zapPayments = emptyMap()
labels = emptyMap()
zapsAmount = BigDecimal(0)
@@ -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
}
@@ -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<String> = bolt12OfferList()?.offers().orEmpty()
/** True when this user has published a kind:10019 with a P2PK pubkey. */
fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null
@@ -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
}
@@ -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,
@@ -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<String, List<RoomZap>> = apply(RoomZap.from(event), nowSec, windowSec)
fun apply(
event: Bolt12ZapEvent,
nowSec: Long,
windowSec: Long,
): Map<String, List<RoomZap>> = apply(RoomZap.from(event), nowSec, windowSec)
private fun apply(
incoming: RoomZap,
nowSec: Long,
windowSec: Long,
): Map<String, List<RoomZap>> {
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
@@ -0,0 +1,99 @@
/*
* 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 decodeOfferOmitsAMalformedAmountWithoutThrowing() {
// The TLV stream parses (ascending type, valid length), but the amount value is
// 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(),
)
val fields = Bolt12ZapActions.decodeOffer(bad)
assertTrue(fields != null)
assertNull(fields["amount_msat"])
}
@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)
}
}
@@ -0,0 +1,152 @@
/*
* 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 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))
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 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))
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 onlyCryptoVerifiedBolt12ZapsCountTowardTheTotal() {
val target = note("a".repeat(64))
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)
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")
}
}
@@ -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]
@@ -0,0 +1,44 @@
# BOLT12 zap proof verification — interop test vectors (follow-up)
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.
## What shipped
- **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`).
- **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.
## Standing caveat (upstream)
#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.
## Not covered here
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
@@ -303,6 +303,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.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
@@ -539,6 +541,8 @@ 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"),
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"),
@@ -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
@@ -60,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
@@ -85,6 +90,14 @@ object Nip47RequestKSerializer : KSerializer<Request> {
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 +151,21 @@ object Nip47RequestKSerializer : KSerializer<Request> {
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 +264,8 @@ object Nip47RequestKSerializer : KSerializer<Request> {
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 +295,36 @@ object Nip47RequestKSerializer : KSerializer<Request> {
)
}
private fun parsePay(json: JsonObject): PayMethod {
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?.contentOrNull,
amount = it["amount"]?.jsonPrimitive?.longOrNull,
payer_note = it["payer_note"]?.jsonPrimitive?.contentOrNull,
metadata = (it["metadata"] as? 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?.contentOrNull,
metadata = (it["metadata"] as? JsonObject)?.toAnyMap(),
)
},
)
}
private fun parsePayKeysend(json: JsonObject): PayKeysendMethod {
val params = json["params"]?.jsonObject
return PayKeysendMethod(
@@ -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
@@ -53,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
@@ -81,6 +84,14 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
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 +166,28 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
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 +275,14 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
parsePayInvoiceSuccess(jsonObject)
}
NwcMethod.PAY -> {
parsePaySuccess(jsonObject)
}
NwcMethod.RECEIVE -> {
parseReceiveSuccess(jsonObject)
}
NwcMethod.PAY_KEYSEND -> {
parsePayKeysendSuccess(jsonObject)
}
@@ -387,6 +428,42 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
)
}
private fun parsePaySuccess(json: JsonObject): PaySuccessResponse {
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?.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?.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,
)
},
)
}
private fun parseReceiveSuccess(json: JsonObject): ReceiveSuccessResponse {
val result = json["result"]?.jsonObject
return ReceiveSuccessResponse(
result?.let {
ReceiveSuccessResponse.ReceiveResult(
bip321 = it["bip321"]?.jsonPrimitive?.contentOrNull,
transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull,
)
},
)
}
private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse {
val result = json["result"]?.jsonObject
return PayKeysendSuccessResponse(
@@ -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,
}
@@ -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"
}
@@ -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:<intent-id>`).
class PayParams(
var payment: String? = null,
var amount: Long? = null,
var payer_note: String? = null,
var metadata: Map<String, Any?>? = 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<String, Any?>? = 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,
@@ -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,
@@ -0,0 +1,128 @@
/*
* 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()
}
// 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,
): 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 (!isBech32Char(canonical[i])) return false
}
return true
}
/** True when [canonical] is a syntactically well-formed canonical `lno1...` offer. */
fun isOffer(canonical: String) = hasHrp(canonical, OFFER_HRP)
/** True when [canonical] is a syntactically well-formed canonical `lnp1...` payer proof. */
fun isPayerProof(canonical: String) = hasHrp(canonical, PAYER_PROOF_HRP)
/**
* Decodes a BOLT12 bech32 string (offer or proof) into its raw TLV-stream
* bytes, first canonicalizing it. Optionally asserts the human-readable
* prefix. Throws [IllegalArgumentException] on malformed input or a prefix
* mismatch.
*/
fun decodeToBytes(
raw: String,
expectedHrp: String? = null,
): ByteArray {
val (hrp, bytes, _) = Bech32.decodeBytes(canonicalize(raw), noChecksum = true)
if (expectedHrp != null) {
require(hrp == expectedHrp) { "Expected BOLT12 prefix $expectedHrp but obtained $hrp" }
}
return bytes
}
fun decodeToBytesOrNull(
raw: String,
expectedHrp: String? = null,
): ByteArray? =
try {
decodeToBytes(raw, expectedHrp)
} catch (_: Exception) {
null
}
/**
* Encodes raw TLV-stream [bytes] as a canonical (no `+` continuation) BOLT12
* bech32 string with the given [hrp]. Primarily an interop/testing helper —
* production only ever decodes offers and proofs produced by wallets.
*/
fun encode(
hrp: String,
bytes: ByteArray,
): String = Bech32.encodeBytes(hrp, bytes, Bech32.Encoding.Beck32WithoutChecksum)
}
@@ -0,0 +1,289 @@
/*
* 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)` — 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.
*
* [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()
// 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 = 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
* already excluded the signature elements (types 240..1000). Records must be
* in ascending type order.
*/
fun rootHash(signableRecords: List<TlvRecord>): ByteArray {
require(signableRecords.isNotEmpty()) { "Cannot compute a merkle root over zero records" }
val firstTlv = signableRecords.first().encoded
val nonceTagHash = sha256(LN_NONCE + firstTlv)
var nodes = ArrayList<ByteArray>(signableRecords.size * 2)
for (record in signableRecords) {
nodes.add(taggedHashPrecomputed(LN_LEAF_TAG_HASH, record.encoded))
nodes.add(taggedHashPrecomputed(nonceTagHash, BigSize.encode(record.type)))
}
while (nodes.size > 1) {
val next = ArrayList<ByteArray>((nodes.size + 1) / 2)
var i = 0
while (i < nodes.size) {
if (i + 1 < nodes.size) {
next.add(branch(nodes[i], nodes[i + 1]))
i += 2
} else {
next.add(nodes[i])
i += 1
}
}
nodes = next
}
return nodes[0]
}
/**
* 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>): TreeNode {
var level = leaves
while (level.size > 1) {
val next = ArrayList<TreeNode>((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<ByteArray?>,
missingHashes: List<ByteArray>,
): 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<ByteArray>,
leafOmitted: List<Boolean>,
): List<ByteArray> {
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<ByteArray>()
// Returns the subtree hash and whether it is entirely omitted.
fun emit(node: TreeNode): Pair<ByteArray, Boolean> {
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,
): ByteArray =
if (compareUnsigned(a, b) <= 0) {
taggedHashPrecomputed(LN_BRANCH_TAG_HASH, a + b)
} else {
taggedHashPrecomputed(LN_BRANCH_TAG_HASH, b + a)
}
/**
* The 32-byte BIP-340 message digest a BOLT12 signature signs:
* `H("lightning" || messagename || fieldname, merkleRoot)`.
*/
fun signatureDigest(
messageName: String,
fieldName: String,
merkleRoot: ByteArray,
): ByteArray = taggedHash("lightning$messageName$fieldName".encodeToByteArray(), merkleRoot)
private fun compareUnsigned(
a: ByteArray,
b: ByteArray,
): Int {
val min = minOf(a.size, b.size)
for (i in 0 until min) {
val ai = a[i].toInt() and 0xff
val bi = b[i].toInt() and 0xff
if (ai != bi) return ai - bi
}
return a.size - b.size
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12
/**
* A parsed BOLT12 offer (`lno1...`). Exposes the fields NIP-XX cares about; the
* full TLV stream is retained for callers that need more.
*
* See https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#offer-fields
*/
class Bolt12Offer(
val tlv: TlvStream,
) {
/** `offer_issuer_id` (type 22): the 33-byte compressed node id that signs invoices for this offer, if present. */
fun issuerId(): ByteArray? = tlv.value(TYPE_ISSUER_ID)
/** `offer_amount` (type 8): the offer amount in the offer currency's minimal unit (msats when no currency). */
fun amount(): Long? = tlv.tu64(TYPE_AMOUNT)
/** `offer_currency` (type 6): ISO 4217 code; absent means bitcoin (msats). */
fun currency(): String? = tlv.value(TYPE_CURRENCY)?.decodeToString()
/** `offer_description` (type 10). */
fun description(): String? = tlv.value(TYPE_DESCRIPTION)?.decodeToString()
fun hasPaths(): Boolean = tlv.has(TYPE_PATHS)
companion object {
const val TYPE_CHAINS = 2L
const val TYPE_METADATA = 4L
const val TYPE_CURRENCY = 6L
const val TYPE_AMOUNT = 8L
const val TYPE_DESCRIPTION = 10L
const val TYPE_FEATURES = 12L
const val TYPE_ABSOLUTE_EXPIRY = 14L
const val TYPE_PATHS = 16L
const val TYPE_ISSUER = 18L
const val TYPE_QUANTITY_MAX = 20L
const val TYPE_ISSUER_ID = 22L
fun parse(canonicalOffer: String): Bolt12Offer? {
val bytes = Bolt12Bech32.decodeToBytesOrNull(canonicalOffer, Bolt12Bech32.OFFER_HRP) ?: return null
val tlv = TlvStream.readOrNull(bytes) ?: return null
return Bolt12Offer(tlv)
}
}
}
@@ -0,0 +1,177 @@
/*
* 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 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.
*/
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)
/** 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)
fun proofLeafHashes(): ByteArray? = tlv.value(TYPE_PROOF_LEAF_HASHES)
/**
* 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 omittedTlvMarkers(): List<Long>? {
val bytes = proofOmittedTlvs() ?: return emptyList()
return try {
val reader = TlvReader(bytes)
buildList { while (reader.remaining() > 0) add(reader.readBigSize()) }
} catch (_: Exception) {
null
}
}
/** `proof_leaf_hashes` split into 32-byte hashes, or null if not a whole multiple of 32. */
fun leafHashList(): List<ByteArray>? = split32(proofLeafHashes())
/** `proof_missing_hashes` split into 32-byte hashes, or null if not a whole multiple of 32. */
fun missingHashList(): List<ByteArray>? = 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<TlvRecord> = 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<TlvRecord> = tlv.records.filter { !it.isSignatureElement() }
/**
* 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 &&
invoicePaymentHash()?.size == 32 &&
invoiceNodeId() != null &&
invoiceSignature()?.size == 64 &&
proofSignature()?.size == 64 &&
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.
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
/**
* 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<ByteArray>? {
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
return Bolt12PayerProof(tlv)
}
}
}
@@ -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<InvoiceField>,
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<InvoiceField>): List<Long> {
val markers = ArrayList<Long>()
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<Long>): 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>): 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"
}
@@ -0,0 +1,230 @@
/*
* 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()
// 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).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()
}
}
private fun readUInt(n: Int): Long {
var value = 0L
repeat(n) {
value = (value shl 8) or readByte().toLong()
}
return value
}
}
/**
* A single TLV record: an unsigned [type], its [value] bytes, and a canonical
* `type || length || value` [encoded] form (used both to re-serialize a stream
* and as the leaf input for the BOLT12 signature merkle tree).
*/
class TlvRecord(
val type: Long,
val value: ByteArray,
) {
val encoded: ByteArray by lazy {
BigSize.encode(type) + BigSize.encode(value.size.toLong()) + value
}
/** BOLT12 signature TLV elements (types 240..1000 inclusive) are excluded from the merkle root. */
fun isSignatureElement() = type in SIGNATURE_TYPE_MIN..SIGNATURE_TYPE_MAX
companion object {
const val SIGNATURE_TYPE_MIN = 240L
const val SIGNATURE_TYPE_MAX = 1000L
}
}
/**
* A parsed BOLT12 TLV stream (an offer, invoice request, invoice, or payer
* proof). Records are kept in the order read; BOLT12 requires strictly
* ascending, unique types, which [read] enforces.
*/
class TlvStream(
val records: List<TlvRecord>,
) {
fun get(type: Long): TlvRecord? = records.firstOrNull { it.type == type }
fun value(type: Long): ByteArray? = get(type)?.value
fun has(type: Long): Boolean = get(type) != null
/**
* The truncated-uint64 value of a record, or null if absent **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
for (r in records) size += r.encoded.size
val out = ByteArray(size)
var offset = 0
for (r in records) {
r.encoded.copyInto(out, offset)
offset += r.encoded.size
}
return out
}
companion object {
fun read(bytes: ByteArray): TlvStream {
val reader = TlvReader(bytes)
val records = ArrayList<TlvRecord>()
var lastType = -1L
while (reader.remaining() > 0) {
val type = reader.readBigSize()
val length = reader.readBigSize()
require(length 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
records.add(TlvRecord(type, value))
}
return TlvStream(records)
}
fun readOrNull(bytes: ByteArray): TlvStream? =
try {
read(bytes)
} catch (_: Exception) {
null
}
}
}
/** Encoders/decoders for the BOLT12 fundamental TLV value types we use. */
object Bolt12Values {
/**
* Decodes a `tu64` (truncated uint64): a big-endian unsigned integer with
* leading zero bytes removed, so its encoded length is 0..8 bytes.
*/
fun tu64(bytes: ByteArray): Long {
require(bytes.size <= 8) { "tu64 must be at most 8 bytes, was ${bytes.size}" }
var value = 0L
for (b in bytes) {
value = (value shl 8) or (b.toLong() and 0xff)
}
return value
}
/** Minimal big-endian `tu64` encoding of [value] (leading zero bytes stripped). */
fun tu64ToBytes(value: Long): ByteArray {
require(value >= 0) { "tu64 cannot encode a negative value" }
if (value == 0L) return ByteArray(0)
val full =
byteArrayOf(
(value shr 56).toByte(),
(value shr 48).toByte(),
(value shr 40).toByte(),
(value shr 32).toByte(),
(value shr 24).toByte(),
(value shr 16).toByte(),
(value shr 8).toByte(),
value.toByte(),
)
var start = 0
while (start < full.size && full[start].toInt() == 0) start++
return full.copyOfRange(start, full.size)
}
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.builder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Assembles the two NIP-XX events in the order the payment flow requires:
*
* 1. [buildIntent] — sign a kind 9737 zap intent *before* paying.
* 2. Pay the offer, putting [payerNote] in the BOLT12 `invreq_payer_note`, and
* collect a settled `lnp` payer proof from the wallet.
* 3. [buildZap] — wrap the signed intent and the proof into a kind 9736 zap.
*
* This is the on-Nostr assembly only; requesting the BOLT12 invoice, paying it,
* and obtaining the payer proof are the wallet's job (no Amethyst payment rail
* exposes `lnp` proofs yet).
*/
object Bolt12ZapBuilder {
/** A fresh 128-bit `zap_id` as lowercase hex. */
fun randomZapId(): String = Hex.encode(RandomInstance.bytes(16))
/**
* The value the payer MUST place in the BOLT12 `invreq_payer_note` when paying
* the offer, binding the settled payment to [intent].
*/
fun payerNote(intent: Bolt12ZapIntentEvent): String = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
/** Sign a zap intent targeting a specific event. */
suspend fun buildIntent(
signer: NostrSigner,
recipientPubKey: HexKey,
amountInMillisats: Long,
offer: String,
zappedEvent: EventHintBundle<out Event>,
comment: String = "",
zapId: String = randomZapId(),
createdAt: Long = TimeUtils.now(),
): Bolt12ZapIntentEvent =
signer.sign(
Bolt12ZapIntentEvent.build(recipientPubKey, amountInMillisats, offer, zapId, zappedEvent, comment, createdAt),
)
/** Sign a zap intent targeting a recipient's profile (no event / address). */
suspend fun buildProfileIntent(
signer: NostrSigner,
recipientPubKey: HexKey,
amountInMillisats: Long,
offer: String,
comment: String = "",
zapId: String = randomZapId(),
createdAt: Long = TimeUtils.now(),
): Bolt12ZapIntentEvent =
signer.sign(
Bolt12ZapIntentEvent.buildProfileZap(recipientPubKey, amountInMillisats, offer, zapId, comment, createdAt),
)
/**
* Sign the final kind 9736 zap from a [signedIntent] and a settled [payerProof].
*
* @param anonymous when true, no `P` tag is added; the caller MUST pass an
* ephemeral [signer] (the same key that signed [signedIntent]). When false,
* the signer's pubkey is added as `P`, publicly attributing the zap.
*/
suspend fun buildZap(
signer: NostrSigner,
signedIntent: Bolt12ZapIntentEvent,
payerProof: String,
anonymous: Boolean = false,
createdAt: Long = TimeUtils.now(),
): Bolt12ZapEvent {
val payerPubKey = if (anonymous) null else signer.pubKey
return signer.sign(
Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey, createdAt),
)
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.intent
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.toETag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-XX: BOLT12 Zaps — the **zap intent** (kind 9737).
*
* The payer creates and signs this event *before* paying the BOLT12 offer. It
* binds the payer's Nostr key to the recipient, the target, the amount and the
* offer. The payer then references this event's id in the BOLT12
* `invreq_payer_note` (`nostr:nipXX:<zap-intent-event-id>`), and finally embeds
* the whole serialized intent inside the kind 9736 zap event's `description` tag
* — the same embedding pattern NIP-57 uses for the zap request.
*
* A zap intent is never counted on its own; only the kind 9736 zap that carries
* both this intent and a settled payer proof is.
*/
@Immutable
class Bolt12ZapIntentEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
/** The recipient pubkey (`p` tag). */
fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey)
/** The amount in millisatoshis (`amount` tag). */
fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse)
/** The canonical raw BOLT12 offer (`offer` tag). */
fun offer() = tags.firstNotNullOfOrNull(OfferTag::parse)
/** The random `zap_id`. */
fun zapId() = tags.firstNotNullOfOrNull(ZapIdTag::parse)
/** The event being zapped, if any (`e` tag). */
fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId)
/** The addressable event being zapped, if any (`a` tag). */
fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
/** The kind of the target event, if declared (`k` tag). */
fun zappedKind() = tags.firstNotNullOfOrNull(KindTag::parse)
/** True when neither `e` nor `a` is present — the intent targets the recipient's profile. */
fun isProfileZap() = zappedEvent() == null && zappedAddress() == null
companion object {
const val KIND = 9737
/** Build a zap intent that targets a specific event. */
fun build(
recipientPubKey: HexKey,
amountInMillisats: Long,
offer: String,
zapId: String,
zappedEvent: EventHintBundle<out Event>,
comment: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Bolt12ZapIntentEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) {
recipient(recipientPubKey)
amountInMillisats(amountInMillisats)
offer(offer)
zapId(zapId)
if (zappedEvent.event is AddressableEvent) {
zappedAddress(zappedEvent.toATag())
} else {
zappedEvent(zappedEvent.toETag())
}
zappedKind(zappedEvent.event.kind)
initializer()
}
/** Build a zap intent that targets a recipient's profile (no event / address). */
fun buildProfileZap(
recipientPubKey: HexKey,
amountInMillisats: Long,
offer: String,
zapId: String,
comment: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Bolt12ZapIntentEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) {
recipient(recipientPubKey)
amountInMillisats(amountInMillisats)
offer(offer)
zapId(zapId)
initializer()
}
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.intent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag
fun TagArrayBuilder<Bolt12ZapIntentEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
fun TagArrayBuilder<Bolt12ZapIntentEvent>.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats))
fun TagArrayBuilder<Bolt12ZapIntentEvent>.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer))
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zapId(zapId: String) = addUnique(ZapIdTag.assemble(zapId))
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedEvent(tag: ETag) = addUnique(tag.toTagArray())
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedAddress(tag: ATag) = addUnique(tag.toATagArray())
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind))
@@ -0,0 +1,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<Array<String>>,
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<String> = 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<String>): Array<Array<String>> =
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<String>,
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<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "")
fun create(
offers: List<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "")
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `amount` tag of a NIP-XX BOLT12 zap or zap intent: the payment amount in
* **millisatoshis** (not sats — this matches the BOLT12 `invoice_amount` field it
* is validated against, and NIP-57's `amount` tag).
*/
class AmountTag {
companion object {
const val TAG_NAME = "amount"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(amountInMillisats: Long) = arrayOf(TAG_NAME, amountInMillisats.toString())
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `description` tag of a BOLT12 zap event (kind 9736): the complete serialized
* kind 9737 zap intent event, as JSON. Uses the same embedding pattern as NIP-57,
* where the zap receipt carries the serialized zap request.
*
* This class only extracts the raw string; parsing it back into an event and
* checking its signature is the validator's job.
*/
class DescriptionTag {
companion object {
const val TAG_NAME = "description"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(serializedIntentEventJson: String) = arrayOf(TAG_NAME, serializedIntentEventJson)
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.utils.ensure
/**
* The `offer` tag: the **canonical raw BOLT12 offer** the payment was made to.
*
* The canonical form is the lowercase `lno1...` string with BOLT12 `+`
* continuation separators and whitespace removed (see [Bolt12Bech32.canonicalize]).
* Both the zap event (kind 9736) and the embedded zap intent (kind 9737) carry
* the exact same canonical value; the validator compares them byte-for-byte.
*/
class OfferTag {
companion object {
const val TAG_NAME = "offer"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isOffer(tag[1])
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(Bolt12Bech32.isOffer(tag[1])) { return null }
return tag[1]
}
fun assemble(canonicalOffer: String) = arrayOf(TAG_NAME, canonicalOffer)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The uppercase `P` tag: the **payer** pubkey of a publicly-attributed BOLT12 zap.
*
* When present it MUST equal the zap event `pubkey`. Anonymous zaps use an
* ephemeral event pubkey and MUST omit this tag (mirrors NIP-57's uppercase-`P`
* convention for the sender).
*/
class PayerTag {
companion object {
const val TAG_NAME = "P"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun assemble(payerPubKey: HexKey) = arrayOf(TAG_NAME, payerPubKey)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.utils.ensure
/**
* The `proof` tag: the bech32-encoded BOLT12 `lnp` **payer proof** for the settled
* payment. Only present on the zap event (kind 9736), never on the intent.
*
* The proof is decoded and cryptographically checked by the validator; this tag
* class only guards the surface syntax (`lnp1...`).
*/
class ProofTag {
companion object {
const val TAG_NAME = "proof"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isPayerProof(tag[1])
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(Bolt12Bech32.isPayerProof(tag[1])) { return null }
return tag[1]
}
fun assemble(payerProof: String) = arrayOf(TAG_NAME, payerProof)
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
* The `zap_id` tag of a zap intent (kind 9737): a random value with at least
* 128 bits of entropy, encoded as lowercase hex. It only needs to be present and
* well-formed; the anti-replay binding is enforced by the payer note referencing
* the intent's event id, not by this value itself.
*/
class ZapIdTag {
companion object {
const val TAG_NAME = "zap_id"
/** 128 bits of entropy = 16 bytes = 32 lowercase-hex characters. */
const val MIN_HEX_LENGTH = 32
fun isValid(zapId: String) = zapId.length >= MIN_HEX_LENGTH && Hex.isHex(zapId) && zapId == zapId.lowercase()
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isValid(tag[1])
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(isValid(tag[1])) { return null }
return tag[1]
}
fun assemble(zapId: String) = arrayOf(TAG_NAME, zapId)
}
}
@@ -0,0 +1,69 @@
/*
* 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
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,
}
}
@@ -0,0 +1,172 @@
/*
* 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.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 **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 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.hasAllCryptoFields()) {
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. 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 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, PROOF_SIG_FIELD, proofRoot)
if (!Nip01Crypto.verify(proofSig, proofDigest, payerId)) {
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.PROOF_SIGNATURE_INVALID)
}
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<Pair<Long, TlvRecord?>>(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<Long>,
included: List<TlvRecord>,
): 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
* 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"
/** 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"
}
}
@@ -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.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
* 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(
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_RECONSTRUCTION_FAILED,
PROOF_INVOICE_SIGNATURE_INVALID,
PROOF_SIGNATURE_INVALID,
PROOF_MALFORMED_KEY,
}
}
@@ -0,0 +1,245 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.verify
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.Hex
/**
* NIP-XX validator: runs the spec's validation steps over a [Bolt12ZapEvent] and
* returns a [Bolt12ZapValidation]. A client MUST validate an event with this (and
* deduplicate the results by [Bolt12ZapValidation.Valid.paymentHashHex]) before
* counting a BOLT12 zap.
*
* The steps mirror the spec:
* 1. kind 9736 and a valid event signature;
* 2. zap-event structure (exactly one `description`/`p`, positive `amount`,
* canonical `offer`, `lnp` `proof`, at most one `e`/`a` and never both,
* `P` == `pubkey` when present);
* 3. the embedded kind 9737 intent parses, is signed by the same pubkey, and is
* itself well-formed;
* 4. the zap and intent match on `content`, `p`, `amount`, `offer`, `e`, `a`, `k`;
* 5. the raw BOLT12 offer parses;
* 6. the `lnp` payer proof decodes and its crypto checks pass (see
* [Bolt12ProofVerifier]);
* 7. the proof binds to this zap: `invreq_payer_note == nostr:nipXX:<intent-id>`,
* `invoice_amount == amount`, and the proof matches the offer.
*/
class Bolt12ZapValidator(
private val proofVerifier: Bolt12ProofVerifier = Bolt12ProofVerifier(),
) {
/**
* @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) --------------------------------------------
// 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)
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)
// Embedded intent — parse + structure (signature verified later).
val intent = event.zapIntent ?: return invalid(Reason.MISSING_OR_INVALID_INTENT)
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)
// 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)
// Parse the raw offer + payer proof.
val offerParsed = Bolt12Offer.parse(offer) ?: return invalid(Reason.UNPARSEABLE_OFFER)
val proof = Bolt12PayerProof.parse(proofStr) ?: return invalid(Reason.UNPARSEABLE_PROOF)
// 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)
val invoiceAmount = proof.invoiceAmount() ?: return invalid(Reason.MISSING_INVOICE_AMOUNT)
if (invoiceAmount != amount) return invalid(Reason.PROOF_AMOUNT_MISMATCH)
offerBindingHardFailure(offerParsed, proof)?.let { return invalid(it) }
// --- 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 cryptoOk =
when (cryptoResult) {
is Bolt12ProofResult.Valid -> true
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(
recipient = recipient,
payer = payer,
amountMillisats = amount,
paymentHashHex = Hex.encode(paymentHash),
zappedEventId = event.zappedEvent(),
zappedAddress = event.zappedAddress(),
zappedKind = event.zappedKind(),
proofCryptoVerified = cryptoVerified,
)
}
/** Returns the relevant reason when the `e`/`a` target cardinality is illegal, or null when fine. */
private fun checkTargetCardinality(tags: Array<Array<String>>): Reason? {
val eCount = tags.count(ETag::isTagged)
val aCount = tags.count(ATag::isTagged)
if (eCount > 1) return Reason.MULTIPLE_EVENT_TARGETS
if (aCount > 1) return Reason.MULTIPLE_ADDRESS_TARGETS
if (eCount >= 1 && aCount >= 1) return Reason.BOTH_EVENT_AND_ADDRESS_TARGET
return null
}
/**
* 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 offerBindingHardFailure(
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
}
/**
* 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
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
}
private fun invalid(reason: Reason) = Bolt12ZapValidation.Invalid(reason)
companion object {
/**
* The NIP binds the Lightning payment to the signed intent through the
* BOLT12 `invreq_payer_note`, which MUST equal this prefix followed by the
* intent event id. The `nipXX` segment tracks the final NIP number.
*/
const val NIP_URI_PREFIX = "nostr:nipXX:"
}
}
private typealias Reason = Bolt12ZapValidation.Reason
@@ -0,0 +1,170 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.zap
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-XX: BOLT12 Zaps — the **zap event** (kind 9736).
*
* A public, self-verifying proof that a BOLT12 payment was made to the author of
* a profile, event, or addressable event. It carries:
* - the serialized kind 9737 zap intent in its `description` tag,
* - the recipient / amount / offer copied from that intent, and
* - the settled BOLT12 `lnp` payer proof in its `proof` tag.
*
* This is the only event counted as a BOLT12 zap. Counting clients MUST run it
* through the validator (structure + intent match + payer-proof binding) and
* deduplicate by the proof's `invoice_payment_hash` before adding its amount.
*/
@Immutable
class Bolt12ZapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider,
SearchableEvent {
// The public zap comment; it mirrors the embedded intent's content.
override fun indexableContent() = content
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
/** The raw serialized zap intent JSON from the `description` tag. */
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
/** The parsed & typed embedded zap intent, or null if it isn't a valid kind 9737 event. */
val zapIntent: Bolt12ZapIntentEvent? by lazy { containedIntent() }
private fun containedIntent(): Bolt12ZapIntentEvent? =
try {
description()?.ifBlank { null }?.let { Event.fromJson(it) } as? Bolt12ZapIntentEvent
} catch (e: Exception) {
Log.w("Bolt12ZapEvent", "Failed to parse embedded zap intent in event $id", e)
null
}
/** The recipient pubkey (`p` tag). */
fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey)
/** The claimed amount in millisatoshis (`amount` tag). Verified against the payer proof. */
fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse)
/** The canonical raw BOLT12 offer (`offer` tag). */
fun offer() = tags.firstNotNullOfOrNull(OfferTag::parse)
/** The bech32 `lnp` payer proof (`proof` tag). */
fun payerProof() = tags.firstNotNullOfOrNull(ProofTag::parse)
/** The payer pubkey (`P` tag), present only for publicly-attributed zaps. */
fun payer() = tags.firstNotNullOfOrNull(PayerTag::parse)
/** The event being zapped, if any (`e` tag). */
fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId)
/** The addressable event being zapped, if any (`a` tag). */
fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
/** The kind of the target event, if declared (`k` tag). */
fun zappedKind() = tags.firstNotNullOfOrNull(KindTag::parse)
/** True when neither `e` nor `a` is present — the zap targets the recipient's profile. */
fun isProfileZap() = zappedEvent() == null && zappedAddress() == null
/** True when the zap is anonymous: it carries no `P` tag. */
fun isAnonymous() = payer() == null
companion object {
const val KIND = 9736
/**
* Assemble a kind 9736 zap event from a **signed** zap intent and a settled
* payer proof. The recipient, amount, offer, and target (`e`/`a`/`k`) tags
* and the content are copied from the intent so they match, as the NIP
* requires.
*
* @param payerPubKey when non-null, added as the uppercase `P` tag (a
* publicly-attributed zap). It MUST be the same key that will sign this
* event. Anonymous zaps pass null and sign with an ephemeral key.
*/
fun build(
signedIntent: Bolt12ZapIntentEvent,
payerProof: String,
payerPubKey: HexKey? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<Bolt12ZapEvent>.() -> Unit = {},
): EventTemplate<Bolt12ZapEvent> {
val recipient = requireNotNull(signedIntent.recipient()) { "zap intent is missing its p tag" }
val amount = requireNotNull(signedIntent.amount()) { "zap intent is missing its amount tag" }
val offer = requireNotNull(signedIntent.offer()) { "zap intent is missing its offer tag" }
return eventTemplate(KIND, signedIntent.content, createdAt) {
description(signedIntent.toJson())
recipient(recipient)
amountInMillisats(amount)
offer(offer)
proof(payerProof)
payerPubKey?.let { payer(it) }
// Copy the target tags verbatim so the zap and intent match exactly.
signedIntent.tags.firstOrNull { ETag.isTagged(it) }?.let { add(it) }
signedIntent.tags.firstOrNull { ATag.isTagged(it) }?.let { add(it) }
signedIntent.tags.firstOrNull { KindTag.match(it) }?.let { add(it) }
initializer()
}
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.zap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag
fun TagArrayBuilder<Bolt12ZapEvent>.description(serializedIntentEventJson: String) = addUnique(DescriptionTag.assemble(serializedIntentEventJson))
fun TagArrayBuilder<Bolt12ZapEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
fun TagArrayBuilder<Bolt12ZapEvent>.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats))
fun TagArrayBuilder<Bolt12ZapEvent>.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer))
fun TagArrayBuilder<Bolt12ZapEvent>.proof(payerProof: String) = addUnique(ProofTag.assemble(payerProof))
fun TagArrayBuilder<Bolt12ZapEvent>.payer(payerPubKey: HexKey) = addUnique(PayerTag.assemble(payerPubKey))
fun TagArrayBuilder<Bolt12ZapEvent>.zappedEvent(tag: ETag) = addUnique(tag.toTagArray())
fun TagArrayBuilder<Bolt12ZapEvent>.zappedAddress(tag: ATag) = addUnique(tag.toATagArray())
fun TagArrayBuilder<Bolt12ZapEvent>.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind))
@@ -395,6 +395,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.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
@@ -731,6 +734,9 @@ 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)
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)
@@ -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<PayMethod>(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<ReceiveMethod>(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<PaySuccessResponse>(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)
}
}
@@ -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"))
}
@@ -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<Request>(json)
assertIs<PayMethod>(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<Request>(json)
assertIs<PayMethod>(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<Request>(json)
assertIs<ReceiveMethod>(request)
assertEquals(21_000L, request.params?.amount)
assertEquals("tip jar", request.params?.description)
}
// --- PayKeysend ---
@Test
@@ -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<Response>(json)
assertIs<PaySuccessResponse>(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<Response>(json)
assertIs<PaySuccessResponse>(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<Response>(json)
assertIs<NwcErrorResponse>(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<Response>(json)
assertIs<ReceiveSuccessResponse>(response)
assertEquals("bitcoin:?lightning=lnbc1&lno=lno1", response.result?.bip321)
assertEquals("tx1", response.result?.transaction_id)
}
// --- PayKeysend Success ---
@Test
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Bolt12ZapEventTest {
private val signer = NostrSignerInternal(KeyPair())
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4, 5, 6))
private val proof = Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, byteArrayOf(7, 8, 9, 10))
private val zapId = "ab".repeat(16)
private fun Array<Array<String>>.tag(name: String) = firstOrNull { it.isNotEmpty() && it[0] == name }
@Test
fun profileZapIntentCarriesTheRequiredTags() {
val template = Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "excellent note")
assertEquals(Bolt12ZapIntentEvent.KIND, template.kind)
assertEquals("excellent note", template.content)
assertEquals(listOf("p", recipient), template.tags.tag("p")?.toList())
assertEquals(listOf("amount", "21000"), template.tags.tag("amount")?.toList())
assertEquals(listOf("offer", offer), template.tags.tag("offer")?.toList())
assertEquals(listOf("zap_id", zapId), template.tags.tag("zap_id")?.toList())
assertNull(template.tags.tag("e"))
assertNull(template.tags.tag("a"))
}
@Test
fun eventTargetedIntentCarriesEAndKTags() {
val zapped =
Event("b".repeat(64), "c".repeat(64), 1_700_000_000L, 1, emptyArray(), "hi", "d".repeat(128))
val template = Bolt12ZapIntentEvent.build(recipient, 21_000L, offer, zapId, EventHintBundle(zapped))
val eTag = template.tags.tag("e")
assertTrue(eTag != null && eTag[1] == "b".repeat(64))
assertEquals(listOf("k", "1"), template.tags.tag("k")?.toList())
assertNull(template.tags.tag("a"))
}
@Test
fun zapEventEmbedsIntentAndCopiesFields() =
runTest {
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "nice"))
val template = Bolt12ZapEvent.build(intent, proof, payerPubKey = signer.pubKey)
assertEquals(Bolt12ZapEvent.KIND, template.kind)
assertEquals("nice", template.content)
assertEquals(listOf("description", intent.toJson()), template.tags.tag("description")?.toList())
assertEquals(listOf("p", recipient), template.tags.tag("p")?.toList())
assertEquals(listOf("amount", "21000"), template.tags.tag("amount")?.toList())
assertEquals(listOf("offer", offer), template.tags.tag("offer")?.toList())
assertEquals(listOf("proof", proof), template.tags.tag("proof")?.toList())
assertEquals(listOf("P", signer.pubKey), template.tags.tag("P")?.toList())
}
@Test
fun anonymousZapOmitsThePayerTag() =
runTest {
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 1_000L, offer, zapId))
val template = Bolt12ZapEvent.build(intent, proof, payerPubKey = null)
assertNull(template.tags.tag("P"))
}
@Test
fun parsedBackAccessorsAndFactoryTypesAreCorrect() =
runTest {
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "nice"))
val zap = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = signer.pubKey))
assertEquals(recipient, zap.recipient())
assertEquals(21_000L, zap.amount())
assertEquals(offer, zap.offer())
assertEquals(proof, zap.payerProof())
assertEquals(signer.pubKey, zap.payer())
assertTrue(zap.isProfileZap())
assertEquals(intent.id, zap.zapIntent?.id)
// The factory (used by fromJson / relay ingestion) resolves the right types.
assertTrue(Event.fromJson(zap.toJson()) is Bolt12ZapEvent)
assertTrue(Event.fromJson(intent.toJson()) is Bolt12ZapIntentEvent)
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class Bolt12Bech32Test {
@Test
fun canonicalizeStripsContinuationsAndWhitespaceAndLowercases() {
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("LNO1ABC+ DEF"))
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("lno1abc+\n def"))
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("lno1abc + def"))
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize(" lno1abcdef "))
}
@Test
fun roundTripsArbitraryBytesWithoutChecksumOrLengthCap() {
// 200 bytes — far beyond the BIP-173 90-char cap that plain bech32 enforces.
val bytes = ByteArray(200) { (it * 7 + 3).toByte() }
val encoded = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, bytes)
assertTrue(encoded.startsWith("lno1"))
assertContentEquals(bytes, Bolt12Bech32.decodeToBytes(encoded, Bolt12Bech32.OFFER_HRP))
}
@Test
fun recognizesOfferAndProofPrefixes() {
val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4))
val proof = Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, byteArrayOf(1, 2, 3, 4))
assertTrue(Bolt12Bech32.isOffer(offer))
assertFalse(Bolt12Bech32.isPayerProof(offer))
assertTrue(Bolt12Bech32.isPayerProof(proof))
assertFalse(Bolt12Bech32.isOffer(proof))
// Non-BOLT12 or malformed strings are rejected.
assertFalse(Bolt12Bech32.isOffer("not an offer"))
assertFalse(Bolt12Bech32.isOffer("lno1"))
assertFalse(Bolt12Bech32.isPayerProof("lnbc1abc"))
}
@Test
fun decodingWithMismatchedPrefixFails() {
val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(9, 9, 9))
assertFailsWith<IllegalArgumentException> {
Bolt12Bech32.decodeToBytes(offer, Bolt12Bech32.PAYER_PROOF_HRP)
}
assertEquals(null, Bolt12Bech32.decodeToBytesOrNull("garbage", Bolt12Bech32.OFFER_HRP))
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class Bolt12MerkleTest {
@Test
fun taggedHashMatchesTheDefinition() {
val tag = "LnLeaf".encodeToByteArray()
val msg = byteArrayOf(1, 2, 3, 4)
val tagHash = sha256(tag)
val expected = sha256(tagHash + tagHash + msg)
assertContentEquals(expected, Bolt12Merkle.taggedHash(tag, msg))
}
@Test
fun rootHashIsDeterministicAndDependsOnEveryRecord() {
val records =
listOf(
TlvRecord(22, ByteArray(33) { 2 }),
TlvRecord(170, Bolt12Values.tu64ToBytes(21_000)),
TlvRecord(176, ByteArray(33) { 3 }),
)
val root = Bolt12Merkle.rootHash(records)
assertEquals(32, root.size)
assertContentEquals(root, Bolt12Merkle.rootHash(records))
val altered =
records.toMutableList().also {
it[1] = TlvRecord(170, Bolt12Values.tu64ToBytes(21_001))
}
assertFalse(root.contentEquals(Bolt12Merkle.rootHash(altered)))
}
/**
* End-to-end check of the tagged-hash → merkle-root → signature-digest → BIP-340
* pipeline: a signature made over the digest of a record set verifies, and any
* tampering with the records (which changes the root) makes it fail. This
* validates the composition; byte-exact interop with CLN/LDK proofs additionally
* needs the lightning/bolts#1346 test vectors.
*/
@Test
fun signatureOverTheMerkleRootVerifiesAndTamperingBreaksIt() {
val key = KeyPair()
val records =
listOf(
TlvRecord(88, ByteArray(33) { 2 }),
TlvRecord(168, ByteArray(32) { it.toByte() }),
TlvRecord(176, ByteArray(33) { 3 }),
)
val root = Bolt12Merkle.rootHash(records)
val digest = Bolt12Merkle.signatureDigest("invoice", "signature", root)
val sig = Nip01Crypto.sign(digest, key.privKey!!)
assertTrue(Nip01Crypto.verify(sig, digest, key.pubKey))
val tamperedRoot = Bolt12Merkle.rootHash(records.dropLast(1))
val tamperedDigest = Bolt12Merkle.signatureDigest("invoice", "signature", tamperedRoot)
assertFalse(Nip01Crypto.verify(sig, tamperedDigest, key.pubKey))
}
}
@@ -0,0 +1,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.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 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 =
listOf(
TlvRecord(8, Bolt12Values.tu64ToBytes(21_000)),
TlvRecord(22, ByteArray(33) { it.toByte() }),
TlvRecord(1001, ByteArray(32) { (it + 1).toByte() }),
)
val stream = TlvStream(records)
val decoded = TlvStream.read(stream.encode())
assertEquals(records.map { it.type }, decoded.records.map { it.type })
assertEquals(21_000L, decoded.tu64(8))
assertContentEquals(records[1].value, decoded.value(22))
assertNull(decoded.get(99))
}
@Test
fun tlvStreamRejectsNonAscendingTypes() {
val outOfOrder = TlvRecord(22, byteArrayOf(1)).encoded + TlvRecord(8, byteArrayOf(2)).encoded
assertFailsWith<IllegalArgumentException> { TlvStream.read(outOfOrder) }
}
@Test
fun bigSizeRejectsNonMinimalEncodings() {
// 5 encoded in the 3-byte (0xfd) form instead of a single byte.
assertFailsWith<IllegalArgumentException> { TlvReader(byteArrayOf(0xfd.toByte(), 0x00, 0x05)).readBigSize() }
// 0x100 encoded in the 5-byte (0xfe) form instead of 3.
assertFailsWith<IllegalArgumentException> {
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<IllegalArgumentException> { TlvStream.read(hostile) }
}
@Test
fun signatureElementRangeIsRecognized() {
assertEquals(false, TlvRecord(176, byteArrayOf()).isSignatureElement())
assertEquals(true, TlvRecord(240, byteArrayOf()).isSignatureElement())
assertEquals(true, TlvRecord(241, byteArrayOf()).isSignatureElement())
assertEquals(true, TlvRecord(1000, byteArrayOf()).isSignatureElement())
assertEquals(false, TlvRecord(1001, byteArrayOf()).isSignatureElement())
}
}
@@ -0,0 +1,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; byte-exact wallet
* interop is covered separately by [Bolt12PayerProofVectorTest].
*/
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<Bolt12ZapValidation.Valid>(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<Bolt12ZapValidation.Valid>(result)
assertTrue(result.proofCryptoVerified)
assertNull(result.payer, "an anonymous BOLT12 zap carries no P tag")
}
}
@@ -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" })
}
}
@@ -0,0 +1,135 @@
/*
* 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<Bolt12ProofResult.Valid>(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 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) },
)
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'")
// The optional proof_note (1005) must round-trip when the vector carries one.
assertEquals(note, proof.proofNote(), "proof_note mismatch for '$name'")
}
}
}
@@ -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.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.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, 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. */
private fun point(xOnly: ByteArray) = byteArrayOf(0x02) + xOnly
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 =
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())
}
fun buildProof(
nodeKey: KeyPair,
payerLightningKey: KeyPair,
preimage: ByteArray,
amountMillisats: Long,
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)
// 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 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(
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 invoiceSigningKey = if (breakInvoiceSignature) payerLightningKey else nodeKey
val proofSigningKey = if (breakProofSignature) nodeKey else payerLightningKey
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
}
@@ -0,0 +1,263 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipXXBolt12Zaps.verify
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class Bolt12ZapValidatorTest {
private val validator = Bolt12ZapValidator()
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val amount = 21_000L
private val zapId = "ab".repeat(16)
private suspend fun signedIntent(
signer: NostrSigner,
offer: String,
) = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, amount, offer, zapId, comment = "nice"))
private suspend fun signedZap(
signer: NostrSigner,
intent: Bolt12ZapIntentEvent,
proof: String,
attributed: Boolean = true,
) = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = if (attributed) signer.pubKey else null))
@Test
fun acceptsAWellFormedFullyVerifiedZap() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val nodeKey = KeyPair()
val payerLnKey = KeyPair()
val preimage = ByteArray(32) { (it + 7).toByte() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, payerLnKey, preimage, amount, note)
val zap = signedZap(signer, intent, proof)
val result = validator.validate(zap)
assertIs<Bolt12ZapValidation.Valid>(result)
assertTrue(result.proofCryptoVerified)
assertEquals(recipient, result.recipient)
assertEquals(signer.pubKey, result.payer)
assertEquals(amount, result.amountMillisats)
assertEquals(Hex.encode(sha256(preimage)), result.paymentHashHex)
assertTrue(result.isProfileZap)
}
@Test
fun 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() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, compressed = true)
val result = validator.validate(signedZap(signer, intent, proof))
assertIs<Bolt12ZapValidation.Valid>(result)
assertTrue(result.proofCryptoVerified, "a compressed proof bound to the offer must verify")
}
@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)
}
@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 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<Bolt12ZapValidation.Valid>(result)
assertTrue(!result.proofCryptoVerified, "an offer with no issuer id cannot be crypto-verified")
}
@Test
fun rejectsWhenTheProofDoesNotMatchTheOffer() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val offerNodeKey = KeyPair()
val proofNodeKey = KeyPair() // a different node than the offer's issuer
val preimage = ByteArray(32) { (it + 10).toByte() }
val offer = Bolt12ProofFixture.buildOffer(offerNodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(proofNodeKey, KeyPair(), preimage, amount, note)
val result = validator.validate(signedZap(signer, intent, proof))
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.OFFER_PROOF_MISMATCH), result)
}
@Test
fun skipsTheEventSignatureCheckWhenTheCallerAlreadyVerifiedIt() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val nodeKey = KeyPair()
val preimage = ByteArray(32) { (it + 11).toByte() }
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
val intent = signedIntent(signer, offer)
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
val validZap = signedZap(signer, intent, proof)
// Same event, but its own signature is corrupted (id still matches content).
val tamperedSig =
Bolt12ZapEvent(validZap.id, validZap.pubKey, validZap.createdAt, validZap.tags, validZap.content, "0".repeat(128))
// Default: the bad event signature is caught.
assertEquals(
Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.BAD_EVENT_SIGNATURE),
validator.validate(tamperedSig),
)
// Ingest path: the pipeline already verified the event, so skipping is safe and it validates.
assertIs<Bolt12ZapValidation.Valid>(validator.validate(tamperedSig, verifyEventSignature = false))
}
}
@@ -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"
}
]
}
@@ -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>(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)

Some files were not shown because too many files have changed in this diff Show More