feat(amethyst): NIP-BC onchain zap receive + display

Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md).
Plumbs the Quartz receive foundation from Phase A.1 into the Android app so
incoming kind 8333 zaps show up in note totals and the wallet screen.

Subscription / fetch path (kind 8333 rides existing zap filters, no new
assemblers per the plan):
- FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in
  RepliesAndReactionsKinds (#e on notes)
- FilterUserProfileZapReceived: OnchainZapEvent.KIND in
  UserProfileZapReceiverKinds (#p on profiles)
- UserProfileZapsViewModel: kinds = LnZap + Onchain inline
- FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds
- NotificationFeedFilter / NotificationDispatcher: kind 8333 in
  NOTIFICATION_KINDS
- FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333
  alongside 9735

Display path (Note.zapsAmount fold-in — no UI changes needed downstream):
- commons/model/Note.kt: new `onchainZaps: Map<String, OnchainZapAmount>`
  (keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats,
  confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain
  sats into `zapsAmount`. Pending entries are tracked but excluded from
  the aggregate total per the NIP-BC spec.
- commons/model/OnchainZapAmount.kt: new data class for per-tx state.
- LocalCache: new `onchainBackend: OnchainBackend?` slot; new
  `consume(OnchainZapEvent)` handler that runs the same sig-verify and
  computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async
  verification on `applicationIOScope`. Self-zaps are rejected
  synchronously per the spec. Verification failures, mempool-only txs,
  and confirmed txs all route to `Note.addOnchainZap` with the correct
  flag. Without a backend wired, events are still cached (so subscriptions
  see them) but skip the total contribution.
- LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that
  enumerates `e`/`a` targets — profile-only zaps return an empty list and
  flow through profile zap queries instead.

Wallet UI:
- Wallet screen now renders a "Bitcoin" Card above the existing NIP-47
  NWC wallet list. Single card (one onchain wallet per Nostr identity,
  derived from the pubkey). Shows the bc1p taproot address + Copy button.
  Balance, recent incoming zaps, and tap-to-detail come in later phases.

Settings + wiring:
- AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow<String>`,
  default mempool.space, plumbed for a future settings UI.
- AppModules: builds a single shared `EsploraBackend` at app init using
  the role-based money-flavored OkHttp client (Tor-aware), installs it on
  `LocalCache.onchainBackend` so verification can run.

Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1.

No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still
pending.
This commit is contained in:
Claude
2026-05-14 04:54:40 +00:00
parent 79f1d43581
commit 031159b3f6
15 changed files with 347 additions and 14 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DEFAULT_ESPLORA_ENDPOINT
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
@@ -99,6 +100,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -330,6 +332,19 @@ class AppModules(
// Caches all events in Memory
val cache: LocalCache = LocalCache
// NIP-BC onchain zap verification backend. Wired up once at app init so
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
// that pay the recipient's derived Taproot address. Endpoint is currently
// a fixed default; per-account override (AccountSettings.onchainEsploraEndpoint)
// is plumbed for a future Settings UI.
init {
cache.onchainBackend =
EsploraBackend(
baseUrl = { DEFAULT_ESPLORA_ENDPOINT },
client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT),
)
}
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
@@ -141,6 +141,9 @@ sealed class TopFilter(
) : TopFilter("InterestSet/${address.toValue()}")
}
/** Default Esplora-compatible Bitcoin chain backend for NIP-BC onchain zaps. */
const val DEFAULT_ESPLORA_ENDPOINT = "https://mempool.space/api"
@Stable
class AccountSettings(
val keyPair: KeyPair,
@@ -176,6 +179,8 @@ class AccountSettings(
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
// NIP-BC onchain zap configuration.
val onchainEsploraEndpoint: MutableStateFlow<String> = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -240,6 +240,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -286,6 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
val paymentTracker = NwcPaymentTracker()
/**
* Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps
* against the actual on-chain transaction. `null` disables verification (incoming
* onchain zap events still get cached, but no `Note.zapsAmount` contribution).
* Set during account init.
*/
@Volatile
var onchainBackend: OnchainBackend? = null
val relayHints = HintIndexer()
val deletionIndex = DeletionIndex()
@@ -904,6 +917,18 @@ object LocalCache : ILocalCache, ICacheProvider {
(event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList())
}
is OnchainZapEvent -> {
// NIP-BC zaps can target an event id (e), an addressable event (a),
// or just the recipient profile (p). Profile-only zaps have no
// Note target and are surfaced through profile zap queries.
buildList {
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
event.zappedAddress()?.let { coord ->
Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) }
}
}
}
is LnZapRequestEvent -> {
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
@@ -1712,6 +1737,60 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: OnchainZapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
if (!(wasVerified || justVerify(event))) return false
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
refreshNewNoteObservers(note)
// Verification needs a chain backend. Without one (e.g. before Account
// wires its EsploraBackend) the event is still cached so subscriptions
// and profile zap views see it, but it can't contribute to Note totals.
val backend = onchainBackend ?: return true
val verifier = OnchainZapVerifier(backend)
Amethyst.instance.applicationIOScope.launch {
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true)
}
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = false)
}
}
is VerifiedOnchainZap.Rejected -> {
Log.d("OnchainZap") {
"rejected ${result.txid}: ${result.reason}"
}
}
}
} catch (t: Throwable) {
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
}
return true
}
private fun attachZapToLiveActivityChannel(
event: LnZapEvent,
note: Note,
@@ -3142,6 +3221,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is OnchainZapEvent -> {
consume(event, relay, wasVerified)
}
is NIP90StatusEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
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.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -96,6 +97,7 @@ class NotificationDispatcher(
// Direct-arrival
PrivateDmEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
ReactionEvent.KIND,
TextNoteEvent.KIND,
CommentEvent.KIND,
@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
val SummaryKinds =
listOf(
@@ -62,6 +63,7 @@ val SummaryKinds =
RepostEvent.KIND,
GenericRepostEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
)
val NotificationsPerKeyKinds =
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
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.utils.mapOfSet
val RepliesAndReactionsKinds =
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
CommentEvent.KIND,
@@ -26,6 +26,7 @@ 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.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
/**
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
@@ -55,7 +56,7 @@ fun filterGoalForLiveActivities(
relay = relay,
filter =
Filter(
kinds = listOf(LnZapEvent.KIND),
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND),
tags = mapOf("e" to listOf(goalId)),
limit = 200,
since = since?.get(relay)?.time,
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
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
fun filterMessagesToLiveActivities(
channel: LiveActivitiesChannel,
@@ -44,6 +45,7 @@ fun filterMessagesToLiveActivities(
LiveActivitiesRaidEvent.KIND,
LiveActivitiesClipEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
),
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
limit = 200,
@@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -121,6 +122,7 @@ class NotificationFeedFilter(
ReactionEvent.KIND,
RepostEvent.KIND,
LnZapEvent.KIND,
OnchainZapEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,
PollEvent.KIND,
@@ -26,8 +26,9 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND)
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND)
fun filterUserProfileZapsReceived(
user: User,
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
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.utils.BigDecimal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -52,7 +53,7 @@ class UserProfileZapsViewModel(
) : ViewModel() {
val zapsToUser =
Filter(
kinds = listOf(LnZapEvent.KIND),
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND),
tags = mapOf("p" to listOf(user.pubkeyHex)),
)
@@ -0,0 +1,123 @@
/*
* 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.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress
import kotlinx.coroutines.launch
/**
* "Bitcoin" section card on the wallet screen, shown above the lightning NWC
* wallet list. Every account has exactly one Taproot address (derived from
* its Nostr pubkey via NIP-BC / BIP-341), so this is always a single card
* not a list.
*
* Phase C scope: derive + display + copy address. Balance, recent zaps, send,
* and tap-to-detail UI come in later phases.
*/
@Composable
fun OnchainSection(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val pubKey = accountViewModel.account.signer.pubKey
// Cache the derived address — bech32m + tap-tweak is cheap but pubkey doesn't
// change for the lifetime of the screen.
val address =
remember(pubKey) {
runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull()
}
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Bitcoin",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (address == null) {
Text(
text = "Address derivation unavailable for this account.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = "Your Taproot address",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
CopyAddressRow(address)
}
}
}
}
@Composable
private fun CopyAddressRow(address: String) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {
scope.launch { clipboard.setText(address) }
}) {
Text("Copy address")
}
}
}
@@ -124,18 +124,24 @@ fun WalletScreen(
}
},
) { padding ->
if (!hasWallet) {
NoWalletSetup(
modifier = Modifier.padding(padding),
nav = nav,
)
} else {
MultiWalletHomeContent(
walletViewModel = walletViewModel,
modifier = Modifier.padding(padding),
listState = listState,
nav = nav,
Column(modifier = Modifier.padding(padding)) {
OnchainSection(
accountViewModel = accountViewModel,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (!hasWallet) {
NoWalletSetup(
modifier = Modifier,
nav = nav,
)
} else {
MultiWalletHomeContent(
walletViewModel = walletViewModel,
modifier = Modifier,
listState = listState,
nav = nav,
)
}
}
}
}
@@ -156,6 +156,15 @@ open class Note(
var zapsAmount: BigDecimal = BigDecimal.ZERO
/**
* NIP-BC verified onchain zaps targeting this note.
* Key: Bitcoin txid (lowercase 64-char hex). Value: verified satoshis paid to the recipient
* (NOT the sender-claimed amount). Confirmed and pending entries live here together;
* `updateZapTotal` only counts confirmed amounts.
*/
var onchainZaps = mapOf<String, OnchainZapAmount>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
@@ -318,6 +327,7 @@ open class Note(
boosts = listOf()
reports = mapOf()
zaps = mapOf()
onchainZaps = mapOf()
zapPayments = mapOf()
zapsAmount = BigDecimal.ZERO
relays = listOf()
@@ -417,6 +427,36 @@ open class Note(
}
}
@Synchronized
private fun innerAddOnchainZap(
txid: String,
amount: OnchainZapAmount,
): Boolean {
val existing = onchainZaps[txid]
// Allow upgrading pending → confirmed but never reduce already-stored confirmations.
if (existing != null && existing.confirmed && !amount.confirmed) return false
if (existing == amount) return false
onchainZaps = onchainZaps + Pair(txid, amount)
return true
}
/**
* Register a NIP-BC verified onchain zap targeting this note. `verifiedSats` MUST come
* from on-chain verification (sum of outputs paying the recipient's derived Taproot
* address), not the sender-claimed `amount` tag.
*/
fun addOnchainZap(
txid: String,
verifiedSats: Long,
confirmed: Boolean,
) {
val inserted = innerAddOnchainZap(txid, OnchainZapAmount(verifiedSats, confirmed))
if (inserted) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
@Synchronized
private fun innerAddZapPayment(
zapPaymentRequest: Note,
@@ -629,6 +669,14 @@ open class Note(
}
}
// NIP-BC onchain zaps — verified amounts only, confirmed only.
// Pending/unconfirmed entries are tracked but excluded from the total per spec.
onchainZaps.values.forEach { entry ->
if (entry.confirmed) {
sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats)
}
}
zapsAmount = sumOfAmounts
}
@@ -0,0 +1,40 @@
/*
* 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.Immutable
/**
* Per-(note, txid) verified NIP-BC onchain zap state.
*
* @property verifiedSats Satoshis verified to have paid the recipient's derived Taproot
* address on chain. NEVER the sender-claimed `amount` tag.
* @property confirmed True when the transaction has at least one confirmation. Unconfirmed
* zaps are tracked but excluded from aggregate totals per the NIP-BC
* spec ("Unconfirmed transactions MAY be displayed as pending...
* SHOULD either exclude them from aggregate totals or clearly label
* them as pending").
*/
@Immutable
data class OnchainZapAmount(
val verifiedSats: Long,
val confirmed: Boolean,
)