fix(onchain-zaps): harden against spoofing, fix re-verify lifecycle, audit cleanup

Addresses the 15 findings from the high-effort code review on top of the
optimistic-attach fix. Notable behavior changes:

- Per-source removal: `Note.removeOnchainZapForSource(txid, pubkey)` only
  drops an entry whose source matches, preventing a spoofed kind:8333 with
  the same txid but a bystander recipient from erasing a legitimate
  CONFIRMED entry. Rejected (txid, sender) pairs are recorded so a fresh
  event id from the same attacker no longer re-flickers into the gallery.

- Sender-only optimistic attach: only the user's own outgoing zap (relay ==
  null path) gets the optimistic UNVERIFIED entry. Incoming zaps render
  only after on-chain verification, so an attacker-controlled `amount` tag
  can't briefly mislead viewers. `claimedSats` is clamped >= 0.

- Reverification across every screen: the chain-tip poller moves from the
  thread screen into `LocalCache.onchainTipHeightFlow` (lazy, shared,
  WhileSubscribed). The onchain-zap gallery itself drives reverification
  whenever it composes with non-CONFIRMED entries — covers home feed,
  notifications, profile, channel and single-note views. The gallery
  observes the tip flow and the note's zap state, so new arrivals while
  the gallery is on screen are picked up too.

- Verifier fan-out + parallelism: re-arrivals skip the verifier launch
  when every target note already holds a CONFIRMED entry for the txid.
  `reverifyOnchainZapsForNote` now runs verifier calls in parallel,
  capped by a 4-permit semaphore.

- Monotonic upgrade based on explicit `OnchainZapStatus.level` instead of
  `ordinal`, with a unit test locking the order. Same-level entries with a
  larger `verifiedSats` are accepted so a stale indexer estimate isn't
  permanent.

- Cancellation propagation: `catch (Throwable)` rethrows
  `CancellationException` in `verifyAndUpgradeOnchainZap` so screen-scoped
  callers tear down cleanly.

- Memory visibility: `Note.onchainZaps` is `@Volatile` since the
  reverification driver reads it on Main while the IO scope writes.
This commit is contained in:
Claude
2026-05-21 21:34:36 +00:00
parent dd203a5537
commit 73f1e6ae9c
6 changed files with 352 additions and 187 deletions
@@ -263,15 +263,25 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -286,6 +296,13 @@ interface ILocalCache {
}
}
/**
* Polling interval for the shared onchain chain-tip flow. Aligned with
* `CachingOnchainBackend.tipHeightTtlSeconds` (60s) — polling faster would
* just hit the cache and do no useful work.
*/
private const val ONCHAIN_TIP_POLL_INTERVAL_MS = 60_000L
object LocalCache : ILocalCache, ICacheProvider {
val antiSpam = AntiSpamFilter()
@@ -1851,6 +1868,13 @@ object LocalCache : ILocalCache, ICacheProvider {
val note = getOrCreateNote(event.id)
val alreadyLoaded = note.event != null
// `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
if (!alreadyLoaded) {
if (!(wasVerified || justVerify(event))) return false
@@ -1862,30 +1886,49 @@ object LocalCache : ILocalCache, ICacheProvider {
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
// Optimistic attachment: surface the zap on the thread immediately, even
// before the chain confirms it. Crucial for the sender's own outgoing zap,
// where consumption happens milliseconds after broadcast and the chain
// backend usually hasn't indexed the tx yet.
val txid = event.txid()
val claimedSats = event.claimedAmountInSats() ?: 0L
if (txid != null) {
repliesTo.forEach {
it.addOnchainZap(note, txid, claimedSats, verifiedSats = 0L, OnchainZapStatus.UNVERIFIED)
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)
repliesTo.forEach {
if (!it.wasOnchainZapRejectedForSource(txid, event.pubKey)) {
it.addOnchainZap(note, txid, claimedSats, verifiedSats = 0L, OnchainZapStatus.UNVERIFIED)
}
}
}
}
refreshNewNoteObservers(note)
} else {
// A later arrival of the same event. The note is already cached, but the
// verifier may not have produced a CONFIRMED result yet. Fall through to
// re-run verification — `verifyAndUpgradeOnchainZap` is a no-op once an
// entry on every target note has reached CONFIRMED.
}
// Verification needs a chain backend. Without one (e.g. before Account
// wires its EsploraBackend) the event stays cached so subscriptions
// and profile zap views see it, but it can't contribute to Note totals.
// Verification needs a chain backend. Without one (e.g. before AppModules
// wires its EsploraBackend) the event stays cached so subscriptions and
// profile zap views see it, but it can't contribute to Note totals.
val backend = onchainBackend ?: return !alreadyLoaded
// Re-arrival path: on a later relay echo of the same event, skip the verifier
// launch entirely if every target note already holds a CONFIRMED entry for this
// txid. Avoids the relay-echo fan-out where the same kind:8333 is delivered N
// times and spawns N redundant verifier coroutines (each potentially hitting
// Esplora for any uncached lookup).
val txid = event.txid()
if (alreadyLoaded && txid != null) {
val repliesTo = computeReplyTo(event)
val allConfirmed =
repliesTo.isNotEmpty() &&
repliesTo.all { it.onchainZaps[txid]?.status == OnchainZapStatus.CONFIRMED }
if (allConfirmed) return false
}
val verifier = OnchainZapVerifier(backend)
val repliesTo = computeReplyTo(event)
@@ -1899,7 +1942,9 @@ object LocalCache : ILocalCache, ICacheProvider {
/**
* Run the chain verifier for a single onchain 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.
* 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.
*/
private suspend fun verifyAndUpgradeOnchainZap(
event: OnchainZapEvent,
@@ -1907,8 +1952,8 @@ object LocalCache : ILocalCache, ICacheProvider {
repliesTo: List<Note>,
verifier: OnchainZapVerifier,
) {
val txid = event.txid() ?: return
val claimedSats = event.claimedAmountInSats() ?: 0L
// Clamp claimedSats to non-negative; see consume() for rationale.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
@@ -1930,16 +1975,21 @@ object LocalCache : ILocalCache, ICacheProvider {
// 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 {
// Permanent — e.g. ZERO_VERIFIED_AMOUNT means the tx did not
// actually pay the recipient. Drop the optimistic entry so
// spoof attempts disappear from the gallery.
} 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 (Note.removeOnchainZapForSource enforces
// the match), so a spoofer can't erase a legitimate CONFIRMED
// entry that happens to share the same txid.
Log.d("OnchainZap") { "rejected ${result.txid}: ${result.reason}" }
repliesTo.forEach { it.removeOnchainZap(result.txid) }
repliesTo.forEach { it.removeOnchainZapForSource(result.txid, event.pubKey) }
}
}
}
} 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)
}
}
@@ -1948,6 +1998,10 @@ object LocalCache : ILocalCache, ICacheProvider {
* Re-run onchain-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.
*
* Runs the per-entry verifier calls in parallel (capped by [reverifySemaphore])
* so a thread root with many pending entries finishes within typical screen
* dwell time instead of N × RTT sequentially.
*/
suspend fun reverifyOnchainZapsForNote(note: Note) {
val backend = onchainBackend ?: return
@@ -1956,13 +2010,49 @@ object LocalCache : ILocalCache, ICacheProvider {
if (pendingEntries.isEmpty()) return
val verifier = OnchainZapVerifier(backend)
pendingEntries.forEach { entry ->
val sourceEvent = entry.source.event as? OnchainZapEvent ?: return@forEach
val repliesTo = computeReplyTo(sourceEvent)
verifyAndUpgradeOnchainZap(sourceEvent, entry.source, repliesTo, verifier)
coroutineScope {
pendingEntries
.mapNotNull { entry ->
val sourceEvent = entry.source.event as? OnchainZapEvent ?: return@mapNotNull null
async {
reverifySemaphore.withPermit {
val repliesTo = computeReplyTo(sourceEvent)
verifyAndUpgradeOnchainZap(sourceEvent, entry.source, repliesTo, verifier)
}
}
}.awaitAll()
}
}
/**
* 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 = 4)
/**
* Shared poller for the current bitcoin chain tip height. Each gallery that
* holds non-CONFIRMED onchain 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 the `LocalCache` singleton is class-loaded.
*/
val onchainTipHeightFlow: StateFlow<Long?> by lazy {
flow {
while (true) {
emit(onchainBackend?.let { runCatching { it.tipHeight() }.getOrNull() })
delay(ONCHAIN_TIP_POLL_INTERVAL_MS)
}
}.stateIn(
Amethyst.instance.applicationIOScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
initialValue = null,
)
}
private fun attachZapToLiveActivityChannel(
event: LnZapEvent,
note: Note,
@@ -29,14 +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
@@ -79,10 +82,38 @@ internal fun WatchOnchainZapsAndRenderGallery(
}
if (entries.isNotEmpty()) {
// Drive periodic re-verification of any non-CONFIRMED entries while this
// gallery is on screen — covers home feed, profile, notifications, channels,
// single-note view, threads. Keyed on baseNote.idHex so the subscription is
// stable across recompositions and pauses cleanly when the gallery scrolls
// off-screen (WhileSubscribed on the shared tip flow).
DriveOnchainZapReverification(baseNote, entries)
RenderOnchainZapGallery(entries, nav, accountViewModel)
}
}
/**
* Subscribes to the shared chain-tip flow and re-runs onchain-zap verification for
* [note] whenever the tip advances, while [entries] still contains non-CONFIRMED
* items. First-view kick happens via the StateFlow's initial null → first-tip
* transition.
*/
@Composable
private fun DriveOnchainZapReverification(
note: Note,
entries: ImmutableList<OnchainZapEntry>,
) {
val hasPending = remember(entries) { entries.any { it.status != OnchainZapStatus.CONFIRMED } }
if (!hasPending) return
val tip by LocalCache.onchainTipHeightFlow.collectAsStateWithLifecycle()
LaunchedEffect(note.idHex, hasPending, tip) {
if (tip != null) {
LocalCache.reverifyOnchainZapsForNote(note)
}
}
}
@Composable
private fun RenderOnchainZapGallery(
entries: ImmutableList<OnchainZapEntry>,
@@ -123,14 +154,24 @@ private fun OnchainZapEntryRow(
accountViewModel: AccountViewModel,
) {
val user = entry.source.author
val displaySats = entry.displaySats
val amountText =
remember(displaySats) {
showAmount(BigDecimal.valueOf(displaySats))
}
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 ""
}
Box(
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
@@ -144,7 +185,9 @@ private fun OnchainZapEntryRow(
)
}
CrossfadeToDisplayAmount(amountText)
if (amountText.isNotEmpty()) {
CrossfadeToDisplayAmount(amountText)
}
if (!isConfirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon
@@ -22,11 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
@@ -37,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
@Composable
fun ThreadScreen(
@@ -60,7 +56,6 @@ fun ThreadScreen(
if (it != null) {
// this will force loading every post from this thread.
EventFinderFilterAssemblerSubscription(it, accountViewModel)
ReverifyOnchainZapsWhileVisible(it, accountViewModel)
}
}
@@ -77,46 +72,3 @@ fun ThreadScreen(
ThreadFeedView(noteId, feedViewModel, accountViewModel, nav)
}
}
/**
* Drives re-verification of NIP-BC onchain zaps while the thread is on screen.
*
* The chain backend often hasn't indexed a transaction at the moment its kind:8333
* receipt is first consumed (especially for the user's own outgoing zap, broadcast
* milliseconds earlier). We attach those entries optimistically as UNVERIFIED in
* `LocalCache.consume(OnchainZapEvent)`; this composable re-runs the verifier on
* view and then again whenever the chain tip advances, upgrading entries to
* PENDING/CONFIRMED as the chain catches up.
*
* The polling interval is aligned with [com.vitorpamplona.quartz.nipBCOnchainZaps
* .chain.CachingOnchainBackend]'s tip-height TTL — anything shorter would just hit
* the cache and do no useful work.
*/
@Composable
private fun ReverifyOnchainZapsWhileVisible(
note: Note,
accountViewModel: AccountViewModel,
) {
LaunchedEffect(note) {
val cache = accountViewModel.account.cache
val backend = cache.onchainBackend ?: return@LaunchedEffect
// First pass on view: covers entries attached optimistically just before navigation.
cache.reverifyOnchainZapsForNote(note)
var lastTip: Long? = null
while (true) {
val pending = note.onchainZaps.values.any { it.status != OnchainZapStatus.CONFIRMED }
if (!pending) break
val tip = runCatching { backend.tipHeight() }.getOrNull()
if (tip != null && tip != lastTip) {
lastTip = tip
cache.reverifyOnchainZapsForNote(note)
}
delay(TIP_POLL_INTERVAL_MS)
}
}
}
private const val TIP_POLL_INTERVAL_MS = 60_000L
@@ -163,10 +163,26 @@ open class Note(
* 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
/**
* Anti-resurrection blocklist for hard-rejected NIP-BC onchain zaps.
* Key: txid. Value: set of source author pubkeys whose attempts for that txid have
* been verified-and-rejected on this note. Used by `LocalCache.consume(OnchainZapEvent)`
* to skip the optimistic UI attachment for known-bad (txid, sender) pairs that would
* otherwise re-flicker into the gallery on every fresh event id.
*/
@Volatile
var rejectedOnchainZapTxidsBySource = mapOf<String, Set<HexKey>>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
@@ -436,29 +452,48 @@ open class Note(
): Boolean {
val existing = onchainZaps[txid]
if (existing != null) {
// Same-state duplicate: keep the first source/amount/status we got.
if (existing.status == entry.status) return false
// Reject downgrades. The status enum is naturally ordered
// UNVERIFIED < PENDING < CONFIRMED — accept only forward transitions.
if (entry.status.ordinal <= existing.status.ordinal) 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 the on-chain verified amount has grown
// (e.g. the indexer revised its view of the tx's recipient outputs).
// Otherwise treat as a duplicate and keep the first source we got.
if (entry.status.level == existing.status.level && entry.verifiedSats <= existing.verifiedSats) return false
}
onchainZaps = onchainZaps + Pair(txid, entry)
return true
}
@Synchronized
private fun innerRemoveOnchainZap(txid: String): Boolean {
if (!onchainZaps.containsKey(txid)) return false
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.
if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false
onchainZaps = onchainZaps - txid
return true
}
@Synchronized
private fun innerRecordOnchainZapRejection(
txid: String,
sourceAuthorPubKey: HexKey,
) {
val current = rejectedOnchainZapTxidsBySource[txid].orEmpty()
if (sourceAuthorPubKey in current) return
rejectedOnchainZapTxidsBySource = rejectedOnchainZapTxidsBySource + Pair(txid, current + sourceAuthorPubKey)
}
/**
* 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 outgoing zap
* 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.
*
@@ -480,17 +515,37 @@ open class Note(
}
/**
* Remove a previously-attached onchain zap entry. Used when verification produced a
* hard rejection (e.g. the transaction paid zero to the recipient — a spoof attempt).
* 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.
*/
fun removeOnchainZap(txid: String) {
val removed = innerRemoveOnchainZap(txid)
fun removeOnchainZapForSource(
txid: String,
sourceAuthorPubKey: HexKey?,
) {
val removed = innerRemoveOnchainZapForSource(txid, sourceAuthorPubKey)
if (sourceAuthorPubKey != null) {
innerRecordOnchainZapRejection(txid, sourceAuthorPubKey)
}
if (removed) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
}
}
/**
* True if a previous verification rejected an onchain zap for this exact (txid, source)
* pair on this note. `LocalCache.consume(OnchainZapEvent)` uses this to skip the
* optimistic gallery attachment for known-bad senders, preventing attach-then-remove
* flicker when an attacker re-publishes the same spoof under a fresh event id.
*/
fun wasOnchainZapRejectedForSource(
txid: String,
sourceAuthorPubKey: HexKey,
): Boolean = sourceAuthorPubKey in rejectedOnchainZapTxidsBySource[txid].orEmpty()
@Synchronized
private fun innerAddZapPayment(
zapPaymentRequest: Note,
@@ -30,16 +30,22 @@ import androidx.compose.runtime.Stable
* 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 {
enum class OnchainZapStatus(
val level: Int,
) {
/** Not yet checked against the chain (or the chain didn't have the tx yet). */
UNVERIFIED,
UNVERIFIED(0),
/** Verified against the chain, 0 confirmations (in mempool). */
PENDING,
PENDING(1),
/** Verified against the chain, ≥1 confirmation. */
CONFIRMED,
CONFIRMED(2),
}
/**
@@ -49,9 +55,11 @@ enum class OnchainZapStatus {
* 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. Shown to
* the user while the zap is UNVERIFIED so they have feedback that
* a zap is processing.
* @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. Zero while [status] is [OnchainZapStatus.UNVERIFIED].
* NEVER the sender-claimed `amount` tag.
@@ -64,16 +72,4 @@ data class OnchainZapEntry(
val claimedSats: Long,
val verifiedSats: Long,
val status: OnchainZapStatus,
) {
/**
* Amount to display to the user. Once verified we always prefer the on-chain truth; while
* unverified we fall back to the sender-claimed amount so the user has some feedback.
*/
val displaySats: Long
get() =
if (status == OnchainZapStatus.UNVERIFIED) {
claimedSats
} else {
verifiedSats
}
}
)
@@ -20,31 +20,44 @@
*/
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.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.
// `wasOnchainZapRejectedForSource` and `removeOnchainZapForSource` both key 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 unverifiedEntryIsStoredWithClaimedAmountAndDoesNotAffectTotal() {
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(
source = src,
txid = "tx1",
claimedSats = 1000L,
verifiedSats = 0L,
status = OnchainZapStatus.UNVERIFIED,
)
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
val entry = target.onchainZaps["tx1"]
assertEquals(1, target.onchainZaps.size)
@@ -52,7 +65,6 @@ class NoteOnchainZapTest {
assertEquals(1000L, entry?.claimedSats)
assertEquals(0L, entry?.verifiedSats)
assertEquals(OnchainZapStatus.UNVERIFIED, entry?.status)
assertEquals(1000L, entry?.displaySats)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@@ -61,20 +73,13 @@ class NoteOnchainZapTest {
val target = freshNote()
val src = sourceNote("b".repeat(64))
target.addOnchainZap(
source = src,
txid = "tx1",
claimedSats = 1000L,
verifiedSats = 1000L,
status = OnchainZapStatus.PENDING,
)
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(OnchainZapStatus.PENDING, entry?.status)
assertEquals(1000L, entry?.displaySats)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@@ -83,36 +88,30 @@ class NoteOnchainZapTest {
val target = freshNote()
val src = sourceNote("c".repeat(64))
target.addOnchainZap(
source = src,
txid = "tx1",
claimedSats = 5000L,
verifiedSats = 5000L,
status = OnchainZapStatus.CONFIRMED,
)
target.addOnchainZap(src, "tx1", claimedSats = 5000L, verifiedSats = 5000L, status = OnchainZapStatus.CONFIRMED)
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount)
}
@Test
fun unverifiedThenPendingReplacesSourceAndAmountsAndKeepsTotalAtZero() {
fun unverifiedThenPendingUpgradesSourceAmountAndStatus() {
val target = freshNote()
val firstSrc = sourceNote("d".repeat(64))
val secondSrc = sourceNote("e".repeat(64))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 2500L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 2500L, verifiedSats = 2500L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 2500L, verifiedSats = 2400L, status = OnchainZapStatus.PENDING)
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(2500L, entry?.verifiedSats)
assertEquals(2400L, entry?.verifiedSats)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@Test
fun pendingThenConfirmedReplacesSourceAndUpdatesTotal() {
fun pendingThenConfirmedUpgradesSourceAndUpdatesTotal() {
val target = freshNote()
val firstSrc = sourceNote("d".repeat(64))
val secondSrc = sourceNote("e".repeat(64))
@@ -129,8 +128,8 @@ class NoteOnchainZapTest {
@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", claimedSats = 7500L, verifiedSats = 7500L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 7500L, verifiedSats = 7500L, status = OnchainZapStatus.PENDING)
@@ -144,8 +143,8 @@ class NoteOnchainZapTest {
@Test
fun pendingThenUnverifiedIsIgnored() {
val target = freshNote()
val firstSrc = sourceNote("7".repeat(64))
val secondSrc = sourceNote("6".repeat(64))
val firstSrc = sourceNote("c3".repeat(32))
val secondSrc = sourceNote("d4".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 100L, verifiedSats = 100L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(secondSrc, "tx1", claimedSats = 100L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
@@ -157,62 +156,98 @@ class NoteOnchainZapTest {
}
@Test
fun sameStateDuplicateIsIgnored() {
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("1".repeat(64))
val secondSrc = sourceNote("2".repeat(64))
val firstSrc = sourceNote("e5".repeat(32))
val secondSrc = sourceNote("f6".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 100L, verifiedSats = 100L, status = OnchainZapStatus.CONFIRMED)
// Mismatched verifiedSats so we can detect a regression that silently
// overwrites the first entry with the second.
target.addOnchainZap(secondSrc, "tx1", claimedSats = 999L, verifiedSats = 999L, status = OnchainZapStatus.CONFIRMED)
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(firstSrc, entry?.source)
assertEquals(100L, entry?.verifiedSats)
assertEquals(BigDecimal.valueOf(100L), target.zapsAmount)
assertSame(secondSrc, entry?.source)
assertEquals(2000L, entry?.verifiedSats)
}
@Test
fun removeOnchainZapDropsEntryAndAdjustsTotal() {
fun sameStatusLowerOrEqualVerifiedSatsIsIgnored() {
val target = freshNote()
val src = sourceNote("7".repeat(64))
val firstSrc = sourceNote("a7".repeat(32))
val secondSrc = sourceNote("b8".repeat(32))
target.addOnchainZap(firstSrc, "tx1", claimedSats = 999L, verifiedSats = 999L, status = OnchainZapStatus.CONFIRMED)
// 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 removeOnchainZapForMatchingSourceDropsEntryAndAdjustsTotal() {
val target = freshNote()
val srcKey = "c9".repeat(32)
val src = sourceNote(srcKey)
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED)
assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
target.removeOnchainZap("tx1")
target.removeOnchainZapForSource("tx1", srcKey)
assertNull(target.onchainZaps["tx1"])
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@Test
fun removeOnchainZapForUnknownTxidIsNoOp() {
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 CONFIRMED
// entry. `removeOnchainZapForSource` requires the existing entry's source.author
// to match the rejecting sender.
val target = freshNote()
val src = sourceNote("8".repeat(64))
val legitSrcKey = "1a".repeat(32)
val attackerKey = "2b".repeat(32)
val legitSrc = sourceNote(legitSrcKey)
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED)
target.removeOnchainZap("tx-never-added")
target.addOnchainZap(legitSrc, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED)
assertEquals(1, target.onchainZaps.size)
// Verifier rejects the attacker's spoof of "tx1" — must not touch Alice's entry.
target.removeOnchainZapForSource("tx1", attackerKey)
assertNotEquals(null, target.onchainZaps["tx1"])
assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
assertTrue(target.wasOnchainZapRejectedForSource("tx1", attackerKey))
assertFalse(target.wasOnchainZapRejectedForSource("tx1", legitSrcKey))
}
@Test
fun removeOnchainZapForUnknownTxidStillRecordsRejection() {
// Even when there's nothing to remove (the optimistic attach was skipped),
// the rejection blocklist must be updated so future fresh-event-id retries
// by the same attacker don't re-flicker into the gallery.
val target = freshNote()
val srcKey = "3c".repeat(32)
target.removeOnchainZapForSource("tx-never-added", srcKey)
assertEquals(0, target.onchainZaps.size)
assertTrue(target.wasOnchainZapRejectedForSource("tx-never-added", srcKey))
}
@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",
claimedSats = 6416L,
verifiedSats = 6416L,
status = OnchainZapStatus.CONFIRMED,
)
target.addOnchainZap(src, "tx1", claimedSats = 6416L, verifiedSats = 6416L, status = OnchainZapStatus.CONFIRMED)
assertTrue(target.hasZapsBoostsOrReactions())
}
@@ -220,15 +255,9 @@ class NoteOnchainZapTest {
@Test
fun hasZapsBoostsOrReactionsReturnsTrueWhenOnlyUnverifiedOnchainZapPresent() {
val target = freshNote()
val src = sourceNote("8".repeat(64))
val src = sourceNote("5e".repeat(32))
target.addOnchainZap(
source = src,
txid = "tx1",
claimedSats = 1000L,
verifiedSats = 0L,
status = OnchainZapStatus.UNVERIFIED,
)
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
assertTrue(target.hasZapsBoostsOrReactions())
}
@@ -236,10 +265,10 @@ class NoteOnchainZapTest {
@Test
fun updateZapTotalSumsConfirmedAcrossMultipleTxids() {
val target = freshNote()
target.addOnchainZap(sourceNote("3".repeat(64)), "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(sourceNote("4".repeat(64)), "tx2", claimedSats = 2000L, verifiedSats = 2000L, status = OnchainZapStatus.CONFIRMED)
target.addOnchainZap(sourceNote("5".repeat(64)), "tx3", claimedSats = 9999L, verifiedSats = 9999L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(sourceNote("6".repeat(64)), "tx4", claimedSats = 1234L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
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(4, target.onchainZaps.size)