Merge pull request #3039 from vitorpamplona/claude/fix-zaps-display-tHV2a

NIP-BC onchain zaps: add verification state machine & reverify driver
This commit is contained in:
Vitor Pamplona
2026-05-23 17:04:22 -04:00
committed by GitHub
6 changed files with 829 additions and 114 deletions
@@ -26,6 +26,7 @@ import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -41,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
@@ -250,8 +252,6 @@ 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
@@ -308,6 +308,15 @@ object LocalCache : ILocalCache, ICacheProvider {
@Volatile
var onchainBackend: OnchainBackend? = null
/**
* NIP-BC on-chain zap verification coordinator. Owns the chain-tip poller flow,
* the in-flight de-duplication of verifier calls across consume/reverify paths,
* and the parallelism cap. `consume(OnchainZapEvent)` delegates the async
* verification side here; the gallery's reverification driver also calls in here
* directly.
*/
val onchainZapResolver = OnchainZapResolver(this)
/**
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
@@ -1848,52 +1857,57 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val alreadyLoaded = note.event != null
if (!(wasVerified || justVerify(event))) return false
// `relay == null` means this event was generated locally by the signed-in user
// and routed through `justConsumeMyOwnEvent` — see `Account.sendOnchainZap` →
// `LocalCache.justConsumeMyOwnEvent`. Only these get the optimistic gallery
// attachment; for incoming zaps from others, the sender-claimed `amount` tag
// is untrusted and could mislead the viewer until the chain verifier responds.
val isOwnEvent = relay == null
var repliesTo: List<Note>? = null
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
if (!alreadyLoaded) {
if (!(wasVerified || justVerify(event))) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
refreshNewNoteObservers(note)
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
// 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)
val author = getOrCreateUser(event.pubKey)
val resolvedRepliesTo = computeReplyTo(event)
repliesTo = resolvedRepliesTo
note.loadEvent(event, author, resolvedRepliesTo)
Amethyst.instance.applicationIOScope.launch {
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = true)
}
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = false)
}
}
is VerifiedOnchainZap.Rejected -> {
Log.d("OnchainZap") {
"rejected ${result.txid}: ${result.reason}"
}
if (isOwnEvent) {
// Optimistic attachment for the sender's own zap: surface it on the
// thread immediately, before the chain backend has indexed the tx.
// Crucial because `OnchainZapSender.send` consumes the kind:8333
// milliseconds after broadcasting the tx — the verifier would
// otherwise return TX_NOT_FOUND and the entry would never appear
// until a later re-verification pass.
val txid = event.txid()
if (txid != null) {
// Clamp claimedSats to a non-negative value. `amount` tag parses
// via toLongOrNull() with no sign check, so a malicious sender
// could otherwise put "-1" in the gallery as a negative-sats badge.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
resolvedRepliesTo.forEach {
it.addOnchainZap(note, txid, claimedSats, verifiedSats = 0L, OnchainZapStatus.UNVERIFIED)
}
}
} catch (t: Throwable) {
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
refreshNewNoteObservers(note)
}
return true
// Async chain verification is delegated to OnchainZapResolver, which owns the
// in-flight gates (so two relay echoes don't double-fetch) and the chain-tip
// polling flow used by the gallery driver. Reusing the repliesTo already
// computed above avoids the second computeReplyTo pass on the new-event path.
onchainZapResolver.launchVerification(event, note, repliesTo ?: computeReplyTo(event))
return !alreadyLoaded
}
private fun attachZapToLiveActivityChannel(
@@ -0,0 +1,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nipBCOnchainZaps
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.ConcurrentHashMap
/**
* Coordinates NIP-BC on-chain zap verification on top of the chain backend.
*
* `LocalCache.consume(OnchainZapEvent)` owns the event dispatch and optimistic
* gallery attachment; this class owns the asynchronous chain-verification side:
*
* - Launching a verifier coroutine when a new event arrives (with per-event
* in-flight de-duplication so simultaneous relay echoes don't double-fetch).
* - Re-running verification for visible notes when the chain tip advances or
* when a screen first comes into view.
* - Owning the shared chain-tip poller flow that UI subscribes to.
*
* Stateless from the caller's perspective — the per-event resolution state lives
* on the source [Note] itself (`onchainZapResolved`), so it travels with the Note
* across cache eviction and doesn't accumulate here.
*/
class OnchainZapResolver(
private val cache: LocalCache,
) {
/**
* Caps the parallelism of [reverifyOnchainZapsForNote] so a thread with many
* pending entries doesn't blast public Esplora endpoints with a burst of
* simultaneous requests.
*/
private val reverifySemaphore = Semaphore(permits = 8)
/**
* In-flight set of event ids currently being verified. Prevents two `consume()`
* calls (or a `consume` plus a reverify) from issuing parallel Esplora fetches
* for the same event. `ConcurrentHashMap.newKeySet` gives lock-free atomic `add`
* returning `true` only for the inserting caller.
*/
private val verifyingEventIds: MutableSet<HexKey> = ConcurrentHashMap.newKeySet()
/**
* In-flight set of note id strings currently being reverified. Lets the on-chain
* zap gallery driver be called from many visible composables without the same
* note's reverify pass running concurrently. The per-event gate above is the
* backstop; this one short-circuits earlier and avoids creating async coroutines
* that would just no-op.
*/
private val reverifyingNoteIds: MutableSet<HexKey> = ConcurrentHashMap.newKeySet()
/**
* Shared poller for the current bitcoin chain tip height. Each gallery that
* holds non-CONFIRMED on-chain zaps subscribes to this flow and re-verifies its
* entries whenever the tip advances. Lazy + `WhileSubscribed` so the HTTP call
* only fires when at least one UI surface needs it.
*
* Lazy initialization is required because [Amethyst.instance] may not exist
* when [OnchainZapResolver] is first constructed. Falls back to a constant
* null-emitting [StateFlow] if the application scope isn't available yet
* (e.g. unit tests, ContentProvider invocations), so the lazy field doesn't
* permanently fail with `UninitializedPropertyAccessException`.
*/
val onchainTipHeightFlow: StateFlow<Long?> by lazy {
val scope =
runCatching { Amethyst.instance.applicationIOScope }.getOrNull()
?: return@lazy MutableStateFlow<Long?>(null).asStateFlow()
flow {
while (true) {
val tip =
cache.onchainBackend?.let { backend ->
// Explicit try/catch instead of `runCatching` because the latter
// would swallow CancellationException too — when the upstream
// scope cancels, we must let it propagate so the flow tears down
// promptly instead of looping through one more delay().
try {
backend.tipHeight()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
null
}
}
emit(tip)
delay(TIP_POLL_INTERVAL_MS)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
initialValue = null,
)
}
/**
* Launches an asynchronous chain verification for [event] unless one is already
* in flight or [source] has already reached a terminal verdict. Returns
* immediately. Apply-to-Note updates happen on the application IO scope.
*
* [repliesTo] is the pre-computed list of notes the event references so consume()
* doesn't pay the cost of resolving it twice.
*/
fun launchVerification(
event: OnchainZapEvent,
source: Note,
repliesTo: List<Note>,
) {
val backend = cache.onchainBackend ?: return
if (source.onchainZapResolved) return
if (!verifyingEventIds.add(event.id)) return
val verifier = OnchainZapVerifier(backend)
Amethyst.instance.applicationIOScope.launch {
try {
verifyAndUpgradeOnchainZap(event, source, repliesTo, verifier)
} finally {
verifyingEventIds.remove(event.id)
}
}
}
/**
* Re-run on-chain zap verification for every non-CONFIRMED entry attached to
* [note]. Safe to call from a screen-visibility hook or a chain-tip change
* observer; each verifier call is bounded by the chain backend's cache TTLs.
*
* - Per-note in-flight gate ([reverifyingNoteIds]) — if another caller is
* already reverifying this note (e.g. multiple visible galleries fired in
* the same tip tick) we skip the dup.
* - Per-event in-flight gate ([verifyingEventIds]) — coordinates with
* [launchVerification] so the same event isn't verified twice concurrently.
* - [supervisorScope] — a single verifier failure won't cancel sibling
* verifiers for other entries on the same note.
* - Bounded parallelism via [reverifySemaphore] so a thread with many pending
* entries doesn't blast Esplora.
*/
suspend fun reverifyOnchainZapsForNote(note: Note) {
val backend = cache.onchainBackend ?: return
if (!reverifyingNoteIds.add(note.idHex)) return
try {
val pendingEntries =
note.onchainZaps.values.filter { it.status != OnchainZapStatus.CONFIRMED }
if (pendingEntries.isEmpty()) return
val verifier = OnchainZapVerifier(backend)
supervisorScope {
pendingEntries
.mapNotNull { entry ->
val sourceEvent = entry.source.event as? OnchainZapEvent ?: return@mapNotNull null
val source = entry.source
if (source.onchainZapResolved) return@mapNotNull null
if (!verifyingEventIds.add(sourceEvent.id)) return@mapNotNull null
async {
try {
reverifySemaphore.withPermit {
val repliesTo = cache.computeReplyTo(sourceEvent)
verifyAndUpgradeOnchainZap(sourceEvent, source, repliesTo, verifier)
}
} finally {
verifyingEventIds.remove(sourceEvent.id)
}
}
}.awaitAll()
}
} finally {
reverifyingNoteIds.remove(note.idHex)
}
}
/**
* Run the chain verifier for a single on-chain zap event and apply the result to
* every target note. Designed to be safe to call repeatedly — the [Note] entries
* upgrade monotonically (UNVERIFIED → PENDING → CONFIRMED) and won't move
* backwards, and source-scoped removal prevents one event's rejection from
* erasing another sender's legitimate entry that happens to share a txid.
*
* Callers must wrap the call site with the [verifyingEventIds] gate; this
* function does not itself protect against duplicate concurrent runs.
*/
private suspend fun verifyAndUpgradeOnchainZap(
event: OnchainZapEvent,
source: Note,
repliesTo: List<Note>,
verifier: OnchainZapVerifier,
) {
// Clamp claimedSats to non-negative — `amount` tag parses via toLongOrNull()
// with no sign check, so a malicious sender could otherwise put "-1" in the
// gallery as a negative-sats badge.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(source, result.txid, claimedSats, result.verifiedSats, OnchainZapStatus.CONFIRMED)
}
// Terminal — the chain has confirmed. Future relay echoes of this
// event id skip the verifier entirely via the `onchainZapResolved`
// gate in `launchVerification`.
source.onchainZapResolved = true
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(source, result.txid, claimedSats, result.verifiedSats, OnchainZapStatus.PENDING)
}
// Not terminal — the tx is in the mempool. A future tip-poll or
// reverify call can upgrade it to CONFIRMED.
}
is VerifiedOnchainZap.Rejected -> {
if (result.reason == VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) {
// Transient — the tx may not have propagated to the backend's
// indexer yet. Leave the entry as UNVERIFIED so a later
// reverifyOnchainZapsForNote() call (e.g. on chain tip change)
// can promote it.
Log.d("OnchainZap") { "tx not yet indexed for ${event.id} (${result.txid}); will retry" }
} else if (result.txid.isNotEmpty()) {
// Hard rejection — e.g. ZERO_VERIFIED_AMOUNT means this sender's
// tx did not pay the recipient. Drop only entries whose source
// matches THIS event AND that aren't CONFIRMED (the per-target
// CONFIRMED check inside `removeOnchainZapForSource` prevents a
// transient backend hiccup from wiping a previously-verified
// entry on a sibling target).
Log.d("OnchainZap") { "rejected ${result.txid}: ${result.reason}" }
repliesTo.forEach { it.removeOnchainZapForSource(result.txid, event.pubKey) }
// Terminal — don't re-verify on future relay echoes of this
// event id. (Different event ids with the same txid still go
// through their own verifier pass.)
source.onchainZapResolved = true
} else {
// MISSING_TXID etc. — log so we have observability when a
// malformed event reaches the backend. Mark terminal so we
// don't re-verify the same broken event on every echo.
Log.d("OnchainZap") { "rejected ${event.id}: ${result.reason} (no txid)" }
source.onchainZapResolved = true
}
}
}
} catch (t: Throwable) {
// Never swallow cancellation — it must propagate so screen-scoped callers
// (the gallery's reverification driver) can tear down cleanly.
if (t is CancellationException) throw t
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
}
companion object {
/**
* Polling interval for the shared on-chain chain-tip flow. Aligned with
* `CachingOnchainBackend.tipHeightTtlSeconds` (60s) — polling faster would
* just hit the cache and do no useful work.
*/
private const val TIP_POLL_INTERVAL_MS = 60_000L
}
}
@@ -29,13 +29,17 @@ 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.LaunchedEffect
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 androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -65,23 +69,70 @@ internal fun WatchOnchainZapsAndRenderGallery(
) {
// Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap
// invalidates flowSet.zaps, so this composable refreshes when on-chain zaps
// arrive or upgrade pending → confirmed. The flow also fires for lightning
// zap arrivals on the same note, so memoize the list snapshot.
// arrive or upgrade pending → confirmed. The flow ALSO fires for lightning
// zap arrivals on the same note — memoize on the onchainZaps map reference
// (a fresh immutable map per onchain mutation, stable across lightning-only
// updates) so a busy lightning thread doesn't churn this gallery's state.
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val onchainZapsMap = zapsState?.note?.onchainZaps
val entries =
remember(zapsState) {
zapsState
?.note
?.onchainZaps
?.values
?.toImmutableList() ?: persistentListOf()
remember(onchainZapsMap) {
onchainZapsMap?.values?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) {
// Drive re-verification of any non-CONFIRMED entries while this gallery
// is on screen — covers home feed, profile, notifications, channels,
// single-note view, threads. Per-note in-flight gating inside the cache
// dedupes the work when multiple gallery instances for the same note
// (lazy-list off/on screen flicker, split feed) all fire together.
DriveOnchainZapReverification(baseNote, entries)
RenderOnchainZapGallery(entries, nav, accountViewModel)
}
}
/**
* Drives on-chain zap re-verification for [note] while the gallery is composed.
*
* Three triggers fire reverify:
* 1. First view of this note (independent of tip availability — covers the cold-
* start case where the chain backend isn't wired yet, or `tipHeight()` is slow).
* 2. A new pending entry arrives (`entries.size` changes).
* 3. The chain tip advances (the shared StateFlow emits a new value).
*
* The cache's per-note + per-event gates dedupe concurrent calls; this composable
* doesn't need its own throttling.
*/
@Composable
private fun DriveOnchainZapReverification(
note: Note,
entries: ImmutableList<OnchainZapEntry>,
) {
val pendingCount = remember(entries) { entries.count { it.status != OnchainZapStatus.CONFIRMED } }
if (pendingCount == 0) return
val resolver = LocalCache.onchainZapResolver
// First-view kick — unconditional, doesn't wait for the tip flow. Keyed on
// (idHex, pendingCount) so a brand-new pending entry arriving while the
// gallery is still on screen also kicks an immediate reverify instead of
// waiting up to a full tip-poll interval.
LaunchedEffect(note.idHex, pendingCount) {
resolver.reverifyOnchainZapsForNote(note)
}
// Tip-change kick — subscribes to the shared poller (lazy, WhileSubscribed
// so only one HTTP poller runs across the whole UI no matter how many
// galleries are visible). Skips the first emission (null) to avoid
// duplicating the first-view kick above.
val tip by resolver.onchainTipHeightFlow.collectAsStateWithLifecycle()
LaunchedEffect(note.idHex, tip) {
if (tip != null) {
resolver.reverifyOnchainZapsForNote(note)
}
}
}
@Composable
private fun RenderOnchainZapGallery(
entries: ImmutableList<OnchainZapEntry>,
@@ -122,18 +173,30 @@ private fun OnchainZapEntryRow(
accountViewModel: AccountViewModel,
) {
val user = entry.source.author
val amountText =
remember(entry.verifiedSats) {
showAmount(BigDecimal.valueOf(entry.verifiedSats))
val isConfirmed = entry.status == OnchainZapStatus.CONFIRMED
val avatarAlpha = if (isConfirmed) 1f else 0.6f
// Anti-spoof: `claimedSats` comes from the kind:8333 `amount` tag, which is
// attacker-controlled for incoming zaps. Only show the claimed amount for the
// signed-in user's own outgoing zap (where the user knows what they sent) —
// otherwise show the on-chain verified amount, or nothing while still unverified.
val displaySats =
when {
isConfirmed || entry.status == OnchainZapStatus.PENDING -> entry.verifiedSats
user != null && accountViewModel.isLoggedUser(user.pubkeyHex) -> entry.claimedSats
else -> 0L
}
val amountText =
remember(displaySats) {
if (displaySats > 0L) showAmount(BigDecimal.valueOf(displaySats)) else ""
}
val avatarAlpha = if (entry.confirmed) 1f else 0.6f
Box(
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
) {
// Only the avatar dims for pending entries. The amount overlay and clock
// badge stay at full opacity so they remain readable.
// Only the avatar dims for unverified/pending entries. The amount overlay
// and clock badge stay at full opacity so they remain readable.
Box(modifier = Modifier.alpha(avatarAlpha)) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
user,
@@ -141,9 +204,11 @@ private fun OnchainZapEntryRow(
)
}
CrossfadeToDisplayAmount(amountText)
if (amountText.isNotEmpty()) {
CrossfadeToDisplayAmount(amountText)
}
if (!entry.confirmed) {
if (!isConfirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon
// that WatchUserMetadataAndFollowsAndRenderUserProfilePicture
// paints at TopEnd for followed users.
@@ -157,16 +157,33 @@ 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 entry with the source
* OnchainZapEvent note (so `source.author` identifies the sender), the verified
* satoshis paid to the recipient (NOT the sender-claimed amount), and a confirmed
* flag. Confirmed and pending entries live here together; `updateZapTotal` only
* counts confirmed amounts.
* NIP-BC onchain zaps targeting this note.
* Key: Bitcoin txid (lowercase 64-char hex). Value: entry with the source
* OnchainZapEvent note (so `source.author` identifies the sender), the sender-claimed
* amount, the on-chain verified amount, and a verification status.
* Unverified, pending, and confirmed entries live here together; `updateZapTotal`
* only counts CONFIRMED amounts.
*
* `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope`
* (inside the @Synchronized inner methods) and reads happen on the Compose Main
* thread (gallery recomposition + the reverification driver's `any { … }` check).
*/
@Volatile
var onchainZaps = mapOf<String, OnchainZapEntry>()
private set
/**
* True when the NIP-BC chain verifier has reached a terminal verdict for THIS
* note's OnchainZapEvent — i.e. on-chain Confirmed, or hard-rejected for a reason
* other than `TX_NOT_FOUND`. `LocalCache.consume()` uses this to skip re-launching
* the verifier on relay echoes once the chain has spoken definitively.
*
* Stays `false` for transient states (`UNVERIFIED`, `PENDING`, `TX_NOT_FOUND`) so
* the gallery's reverify driver can still upgrade them as the chain advances.
*/
@Volatile
var onchainZapResolved: Boolean = false
var zapPayments = mapOf<Note, Note?>()
private set
@@ -330,6 +347,7 @@ open class Note(
reports = mapOf()
zaps = mapOf()
onchainZaps = mapOf()
onchainZapResolved = false
zapPayments = mapOf()
zapsAmount = BigDecimal.ZERO
relays = listOf()
@@ -436,36 +454,87 @@ open class Note(
): Boolean {
val existing = onchainZaps[txid]
if (existing != null) {
// Same-state duplicate: keep the first source and amount we got.
if (existing.confirmed == entry.confirmed) return false
// Downgrade confirmed → pending: never accept. States differ here,
// so existing.confirmed alone is sufficient to identify the downgrade.
if (existing.confirmed) return false
// Else: existing pending + incoming confirmed — fall through to upgrade.
// Exact structural duplicate (same source Note + same fields) — typical
// relay echo of the same event. Skip the rewrite to avoid spurious
// flowSet invalidation.
if (entry == existing) return false
// Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
// so the upgrade contract survives future enum reordering or insertions.
if (entry.status.level < existing.status.level) return false
// Same level: accept only when verifiedSats grows OR the source differs
// (legitimate alternate signer republishing a split-zap receipt). A strictly
// smaller verifiedSats is a backend downgrade and we ignore it.
if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return false
}
onchainZaps = onchainZaps + Pair(txid, entry)
return true
}
@Synchronized
private fun innerRemoveOnchainZapForSource(
txid: String,
sourceAuthorPubKey: HexKey,
): Boolean {
val existing = onchainZaps[txid] ?: return false
// Anti-spoof: only remove the entry if its source matches the rejecting event.
// Otherwise a malicious third party could erase a legitimate CONFIRMED entry
// by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
// Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
// CONFIRMED replacement should change it; a transient backend hiccup must not
// wipe a previously-CONFIRMED entry just because some other target on the same
// event is still UNVERIFIED.
if (existing.status == OnchainZapStatus.CONFIRMED) return false
if (existing.source.author?.pubkeyHex == null) return false
if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false
onchainZaps = onchainZaps - txid
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. `source` is the OnchainZapEvent's own
* Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own
* note — `source.author` is the sender shown in the reactions gallery.
*
* Call with [OnchainZapStatus.UNVERIFIED] (and `verifiedSats = 0`) at consumption time
* to attach the zap optimistically so the user immediately sees their own outgoing zap
* is processing. Call again with [OnchainZapStatus.PENDING] or [OnchainZapStatus.CONFIRMED]
* (and the verified output sum) once the chain backend confirms it.
*
* `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(
source: Note,
txid: String,
claimedSats: Long,
verifiedSats: Long,
confirmed: Boolean,
status: OnchainZapStatus,
) {
val inserted = innerAddOnchainZap(txid, OnchainZapEntry(source, verifiedSats, confirmed))
val inserted = innerAddOnchainZap(txid, OnchainZapEntry(source, claimedSats, verifiedSats, status))
if (inserted) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
/**
* Remove a previously-attached onchain zap entry whose source matches [sourceAuthorPubKey].
* Used when verification produced a hard rejection (e.g. the transaction paid zero to the
* recipient — a spoof attempt). The source-scoped match prevents a malicious third party
* from erasing a legitimate CONFIRMED entry by publishing a spoofed kind:8333 with the
* same txid but a different recipient pubkey. Per-target CONFIRMED check also prevents
* a transient backend reject from erasing an already-confirmed entry.
*/
fun removeOnchainZapForSource(
txid: String,
sourceAuthorPubKey: HexKey,
) {
val removed = innerRemoveOnchainZapForSource(txid, sourceAuthorPubKey)
if (removed) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
@Synchronized
private fun innerAddZapPayment(
zapPaymentRequest: Note,
@@ -679,9 +748,9 @@ open class Note(
}
// NIP-BC onchain zaps — verified amounts only, confirmed only.
// Pending/unconfirmed entries are tracked but excluded from the total per spec.
// Unverified/pending entries are tracked but excluded from the total per spec.
onchainZaps.values.forEach { entry ->
if (entry.confirmed) {
if (entry.status == OnchainZapStatus.CONFIRMED) {
sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats)
}
}
@@ -23,22 +23,53 @@ package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
/**
* Per-(note, txid) verified NIP-BC onchain zap state.
* NIP-BC onchain zap verification state.
*
* The chain backend may not have indexed the transaction at the moment we first
* see the zap event — especially for the sender's own outgoing zaps, where we
* consume the kind:8333 event milliseconds after broadcasting the transaction.
* Tracking the verification status as a state machine lets us attach the zap
* to the thread optimistically and upgrade it as the chain catches up.
*
* [level] establishes the monotonic upgrade order independently of declaration
* order. The `Note.addOnchainZap` upgrade guard compares [level] (not
* `ordinal`), so future contributors can safely reorder or insert states.
*/
enum class OnchainZapStatus(
val level: Int,
) {
/** Not yet checked against the chain (or the chain didn't have the tx yet). */
UNVERIFIED(0),
/** Verified against the chain, 0 confirmations (in mempool). */
PENDING(1),
/** Verified against the chain, ≥1 confirmation. */
CONFIRMED(2),
}
/**
* Per-(note, txid) NIP-BC onchain zap state.
*
* @property source The OnchainZapEvent note that contributed this entry. `source.author` is the
* sender shown in the reactions gallery. When pending → confirmed upgrades
* happen, the upgrading event's note replaces the existing `source`.
* sender shown in the reactions gallery. When an entry is upgraded
* (UNVERIFIED → PENDING/CONFIRMED, PENDING → CONFIRMED), the upgrading
* event's note replaces the existing `source`.
* @property claimedSats Sender-claimed amount from the kind:8333 event's `amount` tag. This
* value is UNTRUSTED — only display it for the signed-in user's own
* outgoing zaps (where the user knows what they sent). Never render
* it for incoming zaps from other senders, as a spoofed amount tag
* would mislead the viewer.
* @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").
* address on chain. Zero while [status] is [OnchainZapStatus.UNVERIFIED].
* NEVER the sender-claimed `amount` tag.
* @property status See [OnchainZapStatus]. Only [OnchainZapStatus.CONFIRMED] entries are added
* to the note's aggregate zap total.
*/
@Stable
data class OnchainZapEntry(
val source: Note,
val claimedSats: Long,
val verifiedSats: Long,
val confirmed: Boolean,
val status: OnchainZapStatus,
)
@@ -20,30 +20,68 @@
*/
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import java.math.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class NoteOnchainZapTest {
private fun freshNote(idHex: String = "a".repeat(64)) = Note(idHex)
private fun freshNote(idHex: String = "f".repeat(64)) = Note(idHex)
private fun sourceNote(idHex: String) = Note(idHex)
// Creates a sender-event Note whose `author.pubkeyHex` is set to [pubKey]. The Note's
// own `idHex` is derived from the pubkey so it stays distinct from the target note.
// `removeOnchainZapForSource` keys off `source.author?.pubkeyHex`, so the author
// must be wired even in unit tests.
private fun sourceNote(pubKey: HexKey): Note {
val src = Note(pubKey)
src.author = User(pubKey, Note(pubKey + "n65"), Note(pubKey + "dm"))
return src
}
@Test
fun enumLevelOrderingIsMonotonic() {
// Lock the documented status ordering. The upgrade guard in `innerAddOnchainZap`
// depends on this — if someone reorders the enum without updating levels, the
// guard silently breaks. This test fails before that happens.
assertTrue(OnchainZapStatus.UNVERIFIED.level < OnchainZapStatus.PENDING.level)
assertTrue(OnchainZapStatus.PENDING.level < OnchainZapStatus.CONFIRMED.level)
}
@Test
fun unverifiedEntryIsStoredAndDoesNotAffectTotal() {
val target = freshNote()
val src = sourceNote("a".repeat(64))
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
val entry = target.onchainZaps["tx1"]
assertEquals(1, target.onchainZaps.size)
assertSame(src, entry?.source)
assertEquals(1000L, entry?.claimedSats)
assertEquals(0L, entry?.verifiedSats)
assertEquals(OnchainZapStatus.UNVERIFIED, entry?.status)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@Test
fun pendingEntryIsStoredWithSourceAndDoesNotAffectTotal() {
val target = freshNote()
val src = sourceNote("b".repeat(64))
target.addOnchainZap(source = src, txid = "tx1", verifiedSats = 1000L, confirmed = false)
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.PENDING)
val entry = target.onchainZaps["tx1"]
assertEquals(1, target.onchainZaps.size)
assertSame(src, entry?.source)
assertEquals(1000L, entry?.verifiedSats)
assertEquals(false, entry?.confirmed)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@@ -52,77 +90,246 @@ class NoteOnchainZapTest {
val target = freshNote()
val src = sourceNote("c".repeat(64))
target.addOnchainZap(source = src, txid = "tx1", verifiedSats = 5000L, confirmed = true)
target.addOnchainZap(src, "tx1", claimedSats = 5000L, verifiedSats = 5000L, status = OnchainZapStatus.CONFIRMED)
assertEquals(true, target.onchainZaps["tx1"]?.confirmed)
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount)
}
@Test
fun pendingThenConfirmedReplacesSourceAndUpdatesTotal() {
fun unverifiedThenPendingUpgradesSourceAmountAndStatus() {
val target = freshNote()
val firstSrc = sourceNote("d".repeat(64))
val secondSrc = sourceNote("e".repeat(64))
target.addOnchainZap(firstSrc, "tx1", 2500L, confirmed = false)
target.addOnchainZap(secondSrc, "tx1", 2500L, confirmed = true)
target.addOnchainZap(firstSrc, "tx1", claimedSats = 2500L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 2500L, verifiedSats = 2400L, status = OnchainZapStatus.PENDING)
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(true, entry?.confirmed)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(2400L, entry?.verifiedSats)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@Test
fun pendingThenConfirmedUpgradesSourceAndUpdatesTotal() {
val target = freshNote()
val firstSrc = sourceNote("d".repeat(64))
val secondSrc = sourceNote("e".repeat(64))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 2500L, verifiedSats = 2500L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 2500L, verifiedSats = 2500L, status = OnchainZapStatus.CONFIRMED)
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(OnchainZapStatus.CONFIRMED, entry?.status)
assertEquals(BigDecimal.valueOf(2500L), target.zapsAmount)
}
@Test
fun confirmedThenPendingIsIgnored() {
val target = freshNote()
val firstSrc = sourceNote("f".repeat(64))
val secondSrc = sourceNote("0".repeat(64))
val firstSrc = sourceNote("a1".repeat(32))
val secondSrc = sourceNote("b2".repeat(32))
target.addOnchainZap(firstSrc, "tx1", 7500L, confirmed = true)
target.addOnchainZap(secondSrc, "tx1", 7500L, confirmed = false)
target.addOnchainZap(firstSrc, "tx1", claimedSats = 7500L, verifiedSats = 7500L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 7500L, verifiedSats = 7500L, status = OnchainZapStatus.PENDING)
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(true, entry?.confirmed)
assertEquals(OnchainZapStatus.CONFIRMED, entry?.status)
assertEquals(BigDecimal.valueOf(7500L), target.zapsAmount)
}
@Test
fun sameStateDuplicateIsIgnored() {
fun pendingThenUnverifiedIsIgnored() {
val target = freshNote()
val firstSrc = sourceNote("1".repeat(64))
val secondSrc = sourceNote("2".repeat(64))
val firstSrc = sourceNote("c3".repeat(32))
val secondSrc = sourceNote("d4".repeat(32))
target.addOnchainZap(firstSrc, "tx1", 100L, confirmed = true)
// Mismatched verifiedSats so we can detect a regression that silently
// overwrites the first entry with the second.
target.addOnchainZap(secondSrc, "tx1", 999L, confirmed = true)
target.addOnchainZap(firstSrc, "tx1", claimedSats = 100L, verifiedSats = 100L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 100L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(100L, entry?.verifiedSats)
}
@Test
fun sameStatusHigherVerifiedSatsReplacesEntry() {
// The indexer can revise its view of the recipient outputs upward across calls
// (delayed mempool propagation, cached partial response). A larger verifiedSats
// for the same status MUST replace the existing entry so the gallery doesn't
// freeze on a stale low estimate.
val target = freshNote()
val firstSrc = sourceNote("e5".repeat(32))
val secondSrc = sourceNote("f6".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 5000L, verifiedSats = 1000L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 5000L, verifiedSats = 2000L, status = OnchainZapStatus.PENDING)
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(2000L, entry?.verifiedSats)
}
@Test
fun sameStatusLowerVerifiedSatsIsIgnored() {
val target = freshNote()
val firstSrc = sourceNote("a7".repeat(32))
val secondSrc = sourceNote("b8".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 999L, verifiedSats = 999L, status = OnchainZapStatus.CONFIRMED)
// Strictly-lower verifiedSats: keep the original entry; don't downgrade.
target.addOnchainZap(secondSrc, "tx1", claimedSats = 999L, verifiedSats = 500L, status = OnchainZapStatus.CONFIRMED)
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(999L, entry?.verifiedSats)
assertEquals(BigDecimal.valueOf(999L), target.zapsAmount)
}
@Test
fun sameStatusEqualVerifiedSatsDifferentSourceReplaces() {
// Multi-signer / split-zap-rebroadcast scenario: a second legitimate kind:8333
// with the same txid and identical verifiedSats arrives from a different signer.
// The new entry should win so attribution isn't permanently locked to whichever
// relay delivered first.
val target = freshNote()
val firstSrc = sourceNote("c9".repeat(32))
val secondSrc = sourceNote("d0".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(BigDecimal.valueOf(1000L), target.zapsAmount)
}
@Test
fun structuralDuplicateIsIgnored() {
// Exact structural duplicate: same source Note reference + same fields. This
// is the typical relay-echo case — N relays deliver the same event id, so
// `getOrCreateNote` returns the same Note instance for each. Skip the rewrite
// to avoid spurious flowSet invalidations.
val target = freshNote()
val src = sourceNote("e1".repeat(32))
target.addOnchainZap(src, "tx1", claimedSats = 100L, verifiedSats = 100L, status = OnchainZapStatus.CONFIRMED)
val firstEntry = target.onchainZaps["tx1"]
target.addOnchainZap(src, "tx1", claimedSats = 100L, verifiedSats = 100L, status = OnchainZapStatus.CONFIRMED)
val secondEntry = target.onchainZaps["tx1"]
assertSame(firstEntry, secondEntry)
assertEquals(BigDecimal.valueOf(100L), target.zapsAmount)
}
@Test
fun removeOnchainZapForMatchingSourceDropsEntryAndAdjustsTotal() {
// CONFIRMED entries are guarded against removal (see
// `confirmedEntryIsNotRemovableOnTransientReject`), so use a PENDING entry
// here to exercise the matching-source removal path.
val target = freshNote()
val srcKey = "c9".repeat(32)
val src = sourceNote(srcKey)
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.PENDING)
// PENDING entries don't contribute to total per spec.
assertEquals(BigDecimal.ZERO, target.zapsAmount)
assertNotNull(target.onchainZaps["tx1"])
target.removeOnchainZapForSource("tx1", srcKey)
assertNull(target.onchainZaps["tx1"])
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@Test
fun removeOnchainZapForMismatchedSourceKeepsLegitimateEntry() {
// Regression test for the txid-collision attack: a spoofed kind:8333 with the
// same txid but a different sender pubkey must not erase a legitimate entry.
// `removeOnchainZapForSource` requires the existing entry's source.author
// to match the rejecting sender.
val target = freshNote()
val legitSrcKey = "1a".repeat(32)
val attackerKey = "2b".repeat(32)
val legitSrc = sourceNote(legitSrcKey)
target.addOnchainZap(legitSrc, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.PENDING)
target.removeOnchainZapForSource("tx1", attackerKey)
assertNotEquals(null, target.onchainZaps["tx1"])
}
@Test
fun confirmedEntryIsNotRemovableOnTransientReject() {
// Once chain-verified, a CONFIRMED entry must not be erased by a later
// verifier reject (e.g. transient ZERO_VERIFIED_AMOUNT from a corrupted
// Esplora response, or a multi-target event where a sibling target hadn't
// confirmed yet). Only an explicit fresh CONFIRMED replacement should
// change a confirmed entry.
val target = freshNote()
val srcKey = "f1".repeat(32)
val src = sourceNote(srcKey)
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED)
assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
target.removeOnchainZapForSource("tx1", srcKey)
assertNotNull(target.onchainZaps["tx1"])
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
}
@Test
fun removeOnchainZapForUnknownTxidIsNoOp() {
val target = freshNote()
val srcKey = "3c".repeat(32)
val src = sourceNote(srcKey)
target.addOnchainZap(src, "tx1", claimedSats = 100L, verifiedSats = 100L, status = OnchainZapStatus.PENDING)
target.removeOnchainZapForSource("tx-never-added", srcKey)
// The known entry survives; the unknown txid op is a no-op.
assertEquals(1, target.onchainZaps.size)
assertNotNull(target.onchainZaps["tx1"])
}
@Test
fun onchainZapResolvedFlagDefaultsFalseAndIsMutable() {
// The resolved flag is the dedup gate `LocalCache.consume()` uses to skip
// re-launching the verifier for terminally-resolved events. New notes start
// unresolved; the verifier flips the flag on Confirmed / hard-Rejected.
val src = sourceNote("9d".repeat(32))
assertFalse(src.onchainZapResolved)
src.onchainZapResolved = true
assertTrue(src.onchainZapResolved)
}
@Test
fun hasZapsBoostsOrReactionsReturnsTrueWhenOnlyOnchainZapPresent() {
val target = freshNote()
val src = sourceNote("9".repeat(64))
val src = sourceNote("4d".repeat(32))
assertFalse(target.hasZapsBoostsOrReactions())
target.addOnchainZap(source = src, txid = "tx1", verifiedSats = 6416L, confirmed = true)
target.addOnchainZap(src, "tx1", claimedSats = 6416L, verifiedSats = 6416L, status = OnchainZapStatus.CONFIRMED)
assertTrue(target.hasZapsBoostsOrReactions())
}
@Test
fun hasZapsBoostsOrReactionsReturnsTrueWhenOnlyPendingOnchainZapPresent() {
fun hasZapsBoostsOrReactionsReturnsTrueWhenOnlyUnverifiedOnchainZapPresent() {
val target = freshNote()
val src = sourceNote("8".repeat(64))
val src = sourceNote("5e".repeat(32))
target.addOnchainZap(source = src, txid = "tx1", verifiedSats = 1000L, confirmed = false)
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
assertTrue(target.hasZapsBoostsOrReactions())
}
@@ -130,12 +337,46 @@ class NoteOnchainZapTest {
@Test
fun updateZapTotalSumsConfirmedAcrossMultipleTxids() {
val target = freshNote()
target.addOnchainZap(sourceNote("3".repeat(64)), "tx1", 1000L, confirmed = true)
target.addOnchainZap(sourceNote("4".repeat(64)), "tx2", 2000L, confirmed = true)
target.addOnchainZap(sourceNote("5".repeat(64)), "tx3", 9999L, confirmed = false)
target.addOnchainZap(sourceNote("60".repeat(32)), "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(sourceNote("71".repeat(32)), "tx2", claimedSats = 2000L, verifiedSats = 2000L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(sourceNote("82".repeat(32)), "tx3", claimedSats = 9999L, verifiedSats = 9999L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(sourceNote("93".repeat(32)), "tx4", claimedSats = 1234L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
assertEquals(BigDecimal.valueOf(3000L), target.zapsAmount)
assertEquals(3, target.onchainZaps.size)
assertEquals(4, target.onchainZaps.size)
assertTrue(target.onchainZaps.containsKey("tx3"))
assertTrue(target.onchainZaps.containsKey("tx4"))
}
@Test
fun removeAllChildNotesClearsOnchainZapResolvedFlag() {
// `removeAllChildNotes()` runs on delete-event handling and during cache
// pressure; the resolved flag must travel with the cleared state so a
// re-arrival of the same event gets re-verified instead of being silently
// skipped against stale state.
val src = sourceNote("ee".repeat(32))
src.onchainZapResolved = true
src.removeAllChildNotes()
assertFalse(src.onchainZapResolved)
}
@Test
fun upgradeProducesNewEntryInstance() {
// Sanity check: when an upgrade is accepted, the new immutable map entry is a
// fresh OnchainZapEntry instance (not a mutation of the existing one). Compose
// skipping/recomposition correctness depends on this.
val target = freshNote()
val firstSrc = sourceNote("11".repeat(32))
val secondSrc = sourceNote("22".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.PENDING)
val before = target.onchainZaps["tx1"]!!
target.addOnchainZap(secondSrc, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
val after = target.onchainZaps["tx1"]!!
assertNotSame(before, after)
assertEquals(OnchainZapStatus.CONFIRMED, after.status)
}
}