mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
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:
@@ -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
|
||||
|
||||
-48
@@ -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,
|
||||
|
||||
+16
-20
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+101
-72
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user