feat: account for and display BOLT12 zaps everywhere lightning zaps are

Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a
NIP-57 lightning zap is counted or shown. Sending is intentionally left for
later. Modeled on the lightning-zap scheme (synchronous, the proof carries the
amount, counted the moment it validates) rather than the onchain scheme (async
chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is
a self-contained synchronous check, so no resolver/backend is needed.

Model (commons):
- Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash
  (the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into
  updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts;
  wired into clearChildLinks, moveAllReferencesTo, removeNote,
  hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family.

Ingestion (LocalCache):
- consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then
  addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and
  live-activity channel routing branches; dispatch case.

Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND
(notifications, replies/reactions to notes & addresses, profile received-zaps,
live-activity goal + messages, nest room + collectors, notification dispatcher,
shared NotificationKinds, app-functions).

Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState
(both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author
resolution, muted-thread + own-event gates), NotificationKinds own-event exception,
ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators
(RoomZapsState, LiveStreamTopZappers, NestViewModel).

UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled
BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the
reactions row (payer avatars + amounts, unverified/compressed proofs dimmed);
reaction-row counter gate; KindNames / KindDisplayName entries.

Note: validated BOLT12 zaps are counted immediately; a compressed proof whose
signatures aren't yet verifiable (pending lightning/bolts#1346 merkle
reconstruction) is stored with cryptoVerified=false and dimmed in the gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-23 20:08:46 +00:00
parent 2ec1744c92
commit 33fb3a54b2
28 changed files with 627 additions and 22 deletions
@@ -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,
)
@@ -169,6 +169,7 @@ open class Note(
removeLabel(note)
removeNutzap(note)
removeOnchainZapBySource(note)
removeBolt12ZapBySource(note)
}
var poll: PollResponsesCache? = null
@@ -249,6 +250,22 @@ open class Note(
var nutzaps = mapOf<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
@@ -376,7 +393,8 @@ open class Note(
zaps.isNotEmpty() ||
boosts.isNotEmpty() ||
onchainZaps.isNotEmpty() ||
nutzaps.isNotEmpty()
nutzaps.isNotEmpty() ||
bolt12Zaps.isNotEmpty()
fun countReactions(): Int {
var total = 0
@@ -408,7 +426,7 @@ open class Note(
fun clearChildLinks(): List<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 reportsChanged = reports.isNotEmpty()
val labelsChanged = labels.isNotEmpty()
@@ -424,7 +442,8 @@ open class Note(
zapPayments.keys +
zapPayments.values.filterNotNull() +
nutzaps.values.map { it.source } +
onchainZaps.values.map { it.source }
onchainZaps.values.map { it.source } +
bolt12Zaps.values.map { it.source }
replies = listOf()
reactions = mapOf()
@@ -435,6 +454,7 @@ open class Note(
onchainZaps = mapOf()
onchainZapResolved = false
nutzaps = mapOf()
bolt12Zaps = mapOf()
zapPayments = mapOf()
zapsAmount = BigDecimal(0)
relays = listOf()
@@ -713,6 +733,59 @@ open class Note(
}
}
private fun innerAddBolt12Zap(
paymentHashHex: String,
entry: Bolt12ZapEntry,
): Boolean =
syncLock.withLock {
val existing = bolt12Zaps[paymentHashHex]
if (existing != null) {
// Same settled payment (dedup by invoice_payment_hash) — a relay echo.
if (entry == existing) return@withLock false
// Prefer a fully crypto-verified entry; never let an unverified
// (compressed-proof) republish overwrite a verified one.
if (!entry.cryptoVerified && existing.cryptoVerified) return@withLock false
}
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, entry)
return@withLock true
}
private fun innerRemoveBolt12ZapBySource(source: Note): Boolean =
syncLock.withLock {
val newMap = bolt12Zaps.filterValues { it.source != source }
if (newMap.size == bolt12Zaps.size) return@withLock false
bolt12Zaps = newMap
return@withLock true
}
/**
* Register a NIP-XX BOLT12 zap targeting this note. [source] is the kind:9736
* event's own note — `source.author` is the payer shown in the reactions
* gallery and notifications. [amountMillisats] and [cryptoVerified] come from
* the synchronous [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator]
* verdict; the caller MUST only call this for a `Valid` result. Deduplicated by
* [paymentHashHex] (the proof's `invoice_payment_hash`).
*/
fun addBolt12Zap(
source: Note,
paymentHashHex: String,
amountMillisats: Long,
cryptoVerified: Boolean,
) {
if (innerAddBolt12Zap(paymentHashHex, Bolt12ZapEntry(source, amountMillisats, cryptoVerified))) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
/** Detach every BOLT12-zap entry contributed by [source] — used when the source note is pruned or deleted. */
fun removeBolt12ZapBySource(source: Note) {
if (innerRemoveBolt12ZapBySource(source)) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
private fun innerAddZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
@@ -906,6 +979,7 @@ open class Note(
// zap requests).
if (isNutzappedBy(user, afterTimeInSeconds)) return true
if (isOnchainZappedBy(user, afterTimeInSeconds)) return true
if (isBolt12ZappedBy(user, afterTimeInSeconds)) return true
val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps)
if (first) return true
@@ -933,6 +1007,15 @@ open class Note(
entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds
}
private fun isBolt12ZappedBy(
user: User,
afterTimeInSeconds: Long,
): Boolean =
bolt12Zaps.values.any { entry ->
val sourceEvent = entry.source.event ?: return@any false
entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds
}
/**
* Extra sats to add on top of [zapsAmount] for the reaction-row
* counter when the signed-in user has outgoing onchain zaps on
@@ -1019,6 +1102,13 @@ open class Note(
sumOfAmounts += BigDecimal(entry.claimedSats)
}
// NIP-XX BOLT12 zaps — validated synchronously at consume time (the `lnp`
// payer proof is a self-contained settlement proof), so every stored entry
// counts, converting its millisat amount to sats like the lightning path.
bolt12Zaps.values.forEach { entry ->
sumOfAmounts += BigDecimal(entry.amountMillisats / 1000)
}
zapsAmount = sumOfAmounts
}
@@ -1186,7 +1276,8 @@ open class Note(
fun hasZapped(loggedIn: User): Boolean =
zaps.any { it.key.author == loggedIn } ||
nutzaps.values.any { it.source.author == loggedIn }
nutzaps.values.any { it.source.author == loggedIn } ||
bolt12Zaps.values.any { it.source.author == loggedIn }
fun hasReacted(
loggedIn: User,
@@ -1251,6 +1342,10 @@ open class Note(
note.addOnchainZap(entry.source, txid, entry.claimedSats, entry.verifiedSats, entry.status)
entry.source.replyTo = entry.source.replyTo?.replace(this, note)
}
bolt12Zaps.forEach { (paymentHash, entry) ->
note.addBolt12Zap(entry.source, paymentHash, entry.amountMillisats, entry.cryptoVerified)
entry.source.replyTo = entry.source.replyTo?.replace(this, note)
}
zapPayments.forEach {
note.addZapPayment(it.key, it.value)
it.key.replyTo = it.key.replyTo?.replace(this, note)
@@ -1271,6 +1366,7 @@ open class Note(
zaps = emptyMap()
nutzaps = emptyMap()
onchainZaps = emptyMap()
bolt12Zaps = emptyMap()
zapPayments = emptyMap()
labels = emptyMap()
zapsAmount = BigDecimal(0)
@@ -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
}
@@ -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