mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+51
@@ -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)
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
+19
-6
@@ -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,
|
||||
|
||||
+27
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user