diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 8398deba54..ef482f8a89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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? = 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipBCOnchainZaps/OnchainZapResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipBCOnchainZaps/OnchainZapResolver.kt new file mode 100644 index 0000000000..b22c441921 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipBCOnchainZaps/OnchainZapResolver.kt @@ -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 = 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 = 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 by lazy { + val scope = + runCatching { Amethyst.instance.applicationIOScope }.getOrNull() + ?: return@lazy MutableStateFlow(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, + ) { + 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, + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt index e799e06b2e..5ae2b6c56e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt @@ -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, +) { + 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, @@ -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. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index a6a7a6d656..d250ae7d2a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -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() 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() 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) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt index 5f0d8e3a87..dded73ec19 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt @@ -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, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt index b70afc7dca..59064d5512 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt @@ -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) } }