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
@@ -318,6 +318,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -385,6 +388,13 @@ object LocalCache : ILocalCache, ICacheProvider {
*/
val onchainZapResolver = OnchainZapResolver(this)
/**
* NIP-XX BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification
* is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)`
* validates inline and needs no async resolver.
*/
val bolt12ZapValidator = Bolt12ZapValidator()
/**
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
@@ -1181,6 +1191,17 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
is Bolt12ZapEvent -> {
// NIP-XX BOLT12 zaps target an event (e), an addressable event (a),
// or just the recipient profile (p) — same shape as onchain zaps.
buildList {
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
event.zappedAddress()?.let { coord ->
Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) }
}
}
}
is NutzapEvent -> {
// The zapped event is carried in the kind:9321's `e` tags
// (and optionally an `a` tag for addressables). Whichever
@@ -2380,6 +2401,41 @@ object LocalCache : ILocalCache, ICacheProvider {
return !alreadyLoaded
}
fun consume(
event: Bolt12ZapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
// Already processed — still route it into any live-activity channel it references.
if (note.event != null) {
attachZapToLiveActivityChannel(event, note, relay)
return false
}
if (!(wasVerified || justVerify(event))) return false
// NIP-XX validation is fully synchronous: zap-event structure, the embedded
// kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed
// validation drops the zap entirely — it never contributes to a zap total.
val validation = bolt12ZapValidator.validate(event)
if (validation !is Bolt12ZapValidation.Valid) {
Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" }
return false
}
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
repliesTo.forEach {
it.addBolt12Zap(note, validation.paymentHashHex, validation.amountMillisats, validation.proofCryptoVerified)
}
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
}
/**
* Consume a NIP-61 nutzap (kind 9321). Resolves the e-tagged target
* note(s), parses the proof amounts once, and attaches a `NutzapEntry`
@@ -2435,6 +2491,23 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
private fun attachZapToLiveActivityChannel(
event: Bolt12ZapEvent,
note: Note,
relay: NormalizedRelayUrl?,
) {
// Only surface zaps whose recipient is the live activity host.
val host = event.recipient() ?: return
event.tags
.asSequence()
.mapNotNull(ATag::parseAddress)
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex == host }
.distinct()
.forEach { address ->
getOrCreateLiveChannel(address).addNote(note, relay)
}
}
fun consume(
event: LnZapRequestEvent,
relay: NormalizedRelayUrl?,
@@ -4301,6 +4374,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is Bolt12ZapEvent -> {
consume(event, relay, wasVerified)
}
is NIP90StatusEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
@@ -102,6 +103,7 @@ class NotificationDispatcher(
PrivateDmEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
Bolt12ZapEvent.KIND,
ReactionEvent.KIND,
TextNoteEvent.KIND,
CommentEvent.KIND,
@@ -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,
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import com.vitorpamplona.amethyst.commons.model.Bolt12ZapEntry
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
private fun onBolt12ZapEntryClick(
entry: Bolt12ZapEntry,
nav: INav,
) {
entry.source.author?.let { nav.nav(routeFor(it)) }
}
/**
* Reactions-row gallery of the payers who BOLT12-zapped this note. Mirrors the
* onchain gallery but simpler: BOLT12 zaps are validated synchronously at consume
* time, so there is no async re-verification and no pending state every entry
* here is already counted. `Note.addBolt12Zap` invalidates `flowSet.zaps`, so this
* refreshes on arrival; memoizing on the `bolt12Zaps` map reference keeps a busy
* lightning thread from churning it.
*/
@Composable
internal fun WatchBolt12ZapsAndRenderGallery(
baseNote: Note,
nav: INav,
accountViewModel: AccountViewModel,
) {
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val bolt12ZapsMap = zapsState?.note?.bolt12Zaps
val entries =
remember(bolt12ZapsMap) {
bolt12ZapsMap?.values?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) {
RenderBolt12ZapGallery(entries, nav, accountViewModel)
}
}
@Composable
private fun RenderBolt12ZapGallery(
entries: ImmutableList<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,
) {
val user = entry.source.author
// The amount is validated (checked against the proof's invoice_amount), so it
// is safe to show for any sender. A not-yet-crypto-verified (compressed) proof
// still dims its avatar so the viewer can tell it apart from a fully-verified one.
val avatarAlpha = if (entry.cryptoVerified) 1f else 0.6f
val amountText =
remember(entry.amountMillisats) {
val sats = entry.amountMillisats / 1000
if (sats > 0L) showAmount(BigDecimal.valueOf(sats)) else ""
}
Box(
modifier = Size35Modifier.clickable { onBolt12ZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
) {
Box(modifier = Modifier.alpha(avatarAlpha)) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
user,
accountViewModel,
)
}
if (amountText.isNotEmpty()) {
CrossfadeToDisplayAmount(amountText)
}
}
}
@@ -126,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
@@ -351,6 +352,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
@@ -1091,6 +1093,10 @@ private fun RenderNoteRow(
RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is Bolt12ZapEvent -> {
RenderBolt12Zap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
@@ -582,6 +582,7 @@ private fun ReactionDetailGallery(
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBolt12ZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
if (relays.isNotEmpty()) {
@@ -1509,7 +1510,8 @@ fun ObserveZapIconState(
zapsState?.note?.zapPayments?.isNotEmpty() == true ||
zapsState?.note?.zaps?.isNotEmpty() == true ||
zapsState?.note?.nutzaps?.isNotEmpty() == true ||
zapsState?.note?.onchainZaps?.isNotEmpty() == true
zapsState?.note?.onchainZaps?.isNotEmpty() == true ||
zapsState?.note?.bolt12Zaps?.isNotEmpty() == true
val wasZapped =
if (hasZapData) {
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds)
@@ -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)
}
}
}
@@ -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 -> {
@@ -85,6 +85,7 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -163,6 +164,7 @@ class NotificationFeedFilter(
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
Bolt12ZapEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,
PollEvent.KIND,
@@ -264,7 +266,8 @@ class NotificationFeedFilter(
// on the user marks it as theirs.
val targetsZapReceipt =
event.hasScopeKind(LnZapEvent.KIND.toString()) ||
note.replyTo?.any { it.event is LnZapEvent } == true
event.hasScopeKind(Bolt12ZapEvent.KIND.toString()) ||
note.replyTo?.any { it.event is LnZapEvent || it.event is Bolt12ZapEvent } == true
if (targetsZapReceipt && event.isTaggedUser(authorHex)) {
return true
@@ -424,6 +427,9 @@ class NotificationFeedFilter(
} else {
noteEvent.pubKey
}
} else if (noteEvent is Bolt12ZapEvent) {
// The sender is the `P` payer tag; anonymous zaps sign with an ephemeral key.
noteEvent.payer() ?: noteEvent.pubKey
} else {
if (it is AddressableNote) {
it.address.pubKeyHex
@@ -434,7 +440,7 @@ class NotificationFeedFilter(
// Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags,
// so isNotInMutedThread on the wrapper event misses them.
if (noteEvent is ReactionEvent || noteEvent is LnZapEvent ||
if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent ||
noteEvent is RepostEvent || noteEvent is GenericRepostEvent
) {
val target = it.replyTo?.lastOrNull()
@@ -492,7 +498,7 @@ class NotificationFeedFilter(
// relevance check (tagsAnEventByUser is skipped below); it still scopes
// to genuine replies, so unrelated channel chatter never leaks through.
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) &&
(isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
@@ -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,
@@ -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
@@ -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) {
@@ -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,
@@ -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
@@ -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]
@@ -303,6 +303,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
@@ -539,6 +540,7 @@ object KindNames {
RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"),
RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"),
OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"),
Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"),
PutUserEvent.KIND to KindName("Group Put User", "29"),
RemoveUserEvent.KIND to KindName("Group Remove User", "29"),
EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"),