mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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(
|
||||
|
||||
+295
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-10
@@ -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,
|
||||
)
|
||||
|
||||
+272
-31
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user