From a65d2b93422ddd97167dfcc19b4f4b418d2681dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:18:27 +0000 Subject: [PATCH 1/2] refactor(ots): anchor OTS attestations on the Note lifecycle, drop the verification cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-03 OpenTimestamps attestations (kind 1040) were stored loosely in the main note cache and found via a full-cache scan, with their blockchain verdicts held in a separate, id-keyed VerificationStateCache LRU. Nothing tied either to the lifecycle of the note being timestamped, so a deleted/pruned note leaked its attestations, and consume(OtsEvent) invalidated the attestation's own (observer-less) flow instead of the target's — so a live-arriving proof never pinged the target's UI. Mirror the recent edits→Note migration: - Note gains a `timestamps` child collection (like `edits`/`reactions`), wired into clearChildLinks/removeNote, so an attestation survives exactly as long as its target and is collected when the target is pruned or deleted. - consume(OtsEvent) anchors the proof on its target via the `e` tag and invalidates the target's `ots` flow; unlinkAndRemove detaches it symmetrically. - Each attestation memoizes its own verdict in `Note.otsVerification`, so the result shares the note's lifecycle. This replaces VerificationStateCache (deleted) and the full-cache scan: the OTS pill now folds `note.timestamps` via the new Note.earliestOtsVerifiedTime / cacheVerifyOts helpers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj --- .../com/vitorpamplona/amethyst/AppModules.kt | 9 +- .../amethyst/model/LocalCache.kt | 61 +++------- .../IncomingOtsEventVerifier.kt | 13 ++- .../nip03Timestamp/NoteOtsVerification.kt | 105 ++++++++++++++++++ .../vitorpamplona/amethyst/ui/note/Loaders.kt | 8 +- .../amethyst/commons/model/Note.kt | 42 +++++++ .../nip03Timestamp/VerificationStateCache.kt | 66 ----------- 7 files changed, 173 insertions(+), 131 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index dee33b33c8..2af6467ef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -137,7 +137,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDe import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore -import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -577,12 +576,6 @@ class AppModules( ) } - // Application-wide ots verification cache - val otsVerifCache by lazy { - Log.d("AppModules", "OtsCache Init") - VerificationStateCache(otsResolverBuilder) - } - val torEvaluatorFlow = TorRelayState( okHttpClients, @@ -747,7 +740,7 @@ class AppModules( // Tries to verify new OTS events when they arrive. val otsEventVerifier = IncomingOtsEventVerifier( - otsVerifCache = { otsVerifCache }, + otsResolverBuilder = otsResolverBuilder, cache = cache, scope = applicationIOScope, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e6775f3860..20e7c76fac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -197,14 +197,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.VerificationState -import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex @@ -1451,12 +1448,17 @@ object LocalCache : ILocalCache, ICacheProvider { val author = getOrCreateUser(event.pubKey) // Already processed this event. - if (version.event?.id == event.id) return false + if (version.event != null) return false if (wasVerified || justVerify(event)) { - if (version.event == null) { - version.loadEvent(event, author, emptyList()) - version.flowSet?.ots?.invalidateData() + version.loadEvent(event, author, emptyList()) + + // Anchor the attestation to the note it timestamps (like an edit to its message), so it + // survives exactly as long as that note and is dropped when the note is deleted or pruned. + // addTimestamp invalidates the target's `ots` flow so the OTS pill re-derives — the old + // code invalidated the attestation's OWN (observer-less) flow, so the target never updated. + event.digestEventId()?.let { targetId -> + getOrCreateNote(targetId).addTimestamp(version) } refreshNewNoteObservers(version) @@ -3201,45 +3203,6 @@ object LocalCache : ILocalCache, ICacheProvider { fun getPeopleListNotesFor(user: User): List = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) - suspend fun findEarliestOtsForNote( - note: Note, - otsVerifCacheBuilder: () -> VerificationStateCache, - ): Long? { - checkNotInMainThread() - - var minTime: Long? = null - val time = TimeUtils.now() - - val candidates = - notes.mapNotNull { _, item -> - val noteEvent = item.event - if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) { - val cachedTime = (otsVerifCacheBuilder().justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime - if (cachedTime != null) { - if (minTime == null || cachedTime < (minTime ?: Long.MAX_VALUE)) { - minTime = cachedTime - } - null - } else { - // tries to verify again - noteEvent - } - } else { - null - } - } - - candidates.forEach { noteEvent -> - (otsVerifCacheBuilder().cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime -> - if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) { - minTime = stampedTime - } - } - } - - return minTime - } - fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() @@ -3561,6 +3524,12 @@ object LocalCache : ILocalCache, ICacheProvider { // and drop it there, or a deleted edit would keep overlaying its message. editedTargetIdOf(noteEvent)?.let { getNoteIfExists(it)?.removeEdit(note) } + // OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with + // no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there. + if (noteEvent is OtsEvent) { + noteEvent.digestEventId()?.let { getNoteIfExists(it)?.removeTimestamp(note) } + } + if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt index f082fa1086..ab553c660f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model.nip03Timestamp import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn class IncomingOtsEventVerifier( - private val otsVerifCache: () -> VerificationStateCache, + private val otsResolverBuilder: OtsResolverBuilder, private val cache: LocalCache, private val scope: CoroutineScope, ) { @@ -49,11 +49,12 @@ class IncomingOtsEventVerifier( null, ) + // Verifies each newly-arrived attestation once and memoizes the verdict on the note itself + // (Note.otsVerification), so the OTS pill can render off a cached result without re-hitting + // the blockchain. The verdict is evicted with the note — no separate cache to keep in sync. suspend fun consume(note: Note) { - note.event?.let { event -> - if (event is OtsEvent) { - otsVerifCache().cacheVerify(event) - } + if (note.event is OtsEvent) { + note.cacheVerifyOts(otsResolverBuilder.build()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt new file mode 100644 index 0000000000..58ed3dd167 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt @@ -0,0 +1,105 @@ +/* + * 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.nip03Timestamp + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import com.vitorpamplona.quartz.nip03Timestamp.VerificationState +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.utils.TimeUtils + +/* + * OTS (NIP-03) attestation resolution off a note's own [Note.timestamps] list. Every kind-1040 + * attestation targeting a note is anchored there as a hard-referenced child (like a reaction), so + * finding a note's proofs is an in-memory fold — no LocalCache scan. Each attestation memoizes its + * blockchain verdict in [Note.otsVerification], which shares the attestation note's lifecycle: it is + * evicted when the target note is (a NIP-09 delete or a cache prune), so there is no separate, + * id-keyed verification cache to keep in sync. This is the read side that replaces the old + * VerificationStateCache + full-cache scan. + */ + +/** The memoized OTS verdict for this attestation note, or null when it has never been verified. */ +fun Note.justOtsVerification(): VerificationState? = otsVerification + +/** + * Returns the OTS verdict for this attestation note, verifying against the blockchain only when no + * usable verdict exists yet (or a stale [VerificationState.NetworkError] is due for a retry). The + * result is stored on the note so later reads are free. Must run off the main thread — verification + * hits the network. No-op verdict ([VerificationState.Error]) when the note is not an OTS event. + */ +suspend fun Note.cacheVerifyOts(resolver: OtsResolver): VerificationState { + val event = event as? OtsEvent ?: return VerificationState.Error("Not an OTS event") + return when (val current = otsVerification) { + is VerificationState.Verifying -> current + is VerificationState.Verified -> current + is VerificationState.NetworkError -> + if (current.time < TimeUtils.fiveMinutesAgo()) verifyOts(event, resolver) else current + is VerificationState.Error -> current + else -> verifyOts(event, resolver) + } +} + +private suspend fun Note.verifyOts( + event: OtsEvent, + resolver: OtsResolver, +): VerificationState { + otsVerification = VerificationState.Verifying + return event.verifyState(resolver).also { otsVerification = it } +} + +/** + * The earliest blockchain-verified time (unix seconds) among this note's non-expired OTS + * attestations, or null when none verify. Reads already-cached verdicts first — so a proof that was + * verified on arrival shows without waiting on the network — then verifies any still-unresolved + * proofs. Must run off the main thread. + */ +suspend fun Note.earliestOtsVerifiedTime(resolver: OtsResolver): Long? { + val now = TimeUtils.now() + var minTime: Long? = null + + fun consider(time: Long) { + if (minTime.let { it == null || time < it }) minTime = time + } + + val live = + timestamps.filter { + val e = it.event + e is OtsEvent && !e.isExpirationBefore(now) + } + + val unresolved = + live.filter { proof -> + val verified = (proof.justOtsVerification() as? VerificationState.Verified)?.verifiedTime + if (verified != null) { + consider(verified) + false + } else { + true + } + } + + unresolved.forEach { proof -> + (proof.cacheVerifyOts(resolver) as? VerificationState.Verified)?.verifiedTime?.let(::consider) + } + + return minTime +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 365c13a9b6..5e64c5b521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -37,8 +37,8 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.nip03Timestamp.earliestOtsVerifiedTime import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -119,12 +119,10 @@ fun LoadOts( val noteStatus by observeNoteOts(note, accountViewModel) LaunchedEffect(key1 = noteStatus) { + val target = noteStatus?.note ?: note val newOts = withContext(Dispatchers.IO) { - LocalCache.findEarliestOtsForNote( - note = noteStatus?.note ?: note, - otsVerifCacheBuilder = { Amethyst.instance.otsVerifCache }, - ) + target.earliestOtsVerifiedTime(Amethyst.instance.otsResolverBuilder.build()) } earliestDate = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 4a97a79451..24c18957f1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider +import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -164,6 +165,7 @@ open class Note( removeBoost(note) removeReaction(note) removeEdit(note) + removeTimestamp(note) removeZap(note) removeZapPayment(note) removeReport(note) @@ -178,6 +180,18 @@ open class Note( fun pollState(): PollResponsesCache = poll ?: PollResponsesCache().also { poll = it } + /** + * When this note holds a NIP-03 OpenTimestamps event (kind 1040), its memoized blockchain + * verification verdict. Kept on the note itself — not in a separate id-keyed cache — so the + * (expensive, network-backed) result shares the note's lifecycle: the attestation is anchored on + * its target's [timestamps] and both are evicted together. `null` means never verified yet. + * + * `@Volatile`: written by the OTS verifier on `applicationIOScope` and read from the Compose + * read path when folding an earliest-attested time. + */ + @Volatile + var otsVerification: VerificationState? = null + // These fields are updated every time an event related to this note is received. var replies = listOf() private set @@ -199,6 +213,16 @@ open class Note( var edits = listOf() private set + /** + * NIP-03 OpenTimestamps attestations (kind 1040) proving this note existed by a given time, + * held here — like [reactions] and [edits] — so an attestation survives exactly as long as the + * note it timestamps and is dropped the moment that note leaves the cache (including on a NIP-09 + * delete). Anchoring them here also replaces the old full-cache scan: the OTS pill folds this + * list directly. Each attestation memoizes its own blockchain verdict in [Note.otsVerification]. + */ + var timestamps = listOf() + private set + var reports = mapOf>() private set @@ -424,6 +448,20 @@ open class Note( } } + fun addTimestamp(note: Note) { + if (note !in timestamps) { + timestamps = timestamps + note + flowSet?.ots?.invalidateData() + } + } + + fun removeTimestamp(note: Note) { + if (note in timestamps) { + timestamps = timestamps - note + flowSet?.ots?.invalidateData() + } + } + fun removeBoost(note: Note) { if (note in boosts) { boosts = boosts - note @@ -437,6 +475,7 @@ open class Note( val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() val boostsChanged = boosts.isNotEmpty() val editsChanged = edits.isNotEmpty() + val timestampsChanged = timestamps.isNotEmpty() val reportsChanged = reports.isNotEmpty() val labelsChanged = labels.isNotEmpty() @@ -445,6 +484,7 @@ open class Note( reactions.values.flatten() + boosts + edits + + timestamps + reports.values.flatten() + labels.values.flatten() + zaps.keys + @@ -458,6 +498,7 @@ open class Note( reactions = mapOf() boosts = listOf() edits = listOf() + timestamps = listOf() reports = mapOf() labels = mapOf() zaps = mapOf() @@ -472,6 +513,7 @@ open class Note( if (reactionsChanged) flowSet?.reactions?.invalidateData() if (boostsChanged) flowSet?.boosts?.invalidateData() if (editsChanged) flowSet?.edits?.invalidateData() + if (timestampsChanged) flowSet?.ots?.invalidateData() if (reportsChanged) flowSet?.reports?.invalidateData() if (labelsChanged) flowSet?.labels?.invalidateData() if (zapsChanged) flowSet?.zaps?.invalidateData() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt deleted file mode 100644 index 05d99b69e3..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.quartz.nip03Timestamp - -import androidx.collection.LruCache -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.utils.TimeUtils - -class VerificationStateCache( - val otsResolverBuilder: OtsResolverBuilder, -) { - private val cache = LruCache(200) - - suspend fun verify(event: OtsEvent): VerificationState { - cache.put(event.id, VerificationState.Verifying) - return event.verifyState(otsResolverBuilder.build()).also { cache.put(event.id, it) } - } - - fun justCache(event: OtsEvent): VerificationState? = cache[event.id] - - suspend fun cacheVerify(event: OtsEvent): VerificationState = - when (val verif = cache[event.id]) { - is VerificationState.Verifying -> { - verif - } - - is VerificationState.Verified -> { - verif - } - - is VerificationState.NetworkError -> { - // try again in 5 mins - if (verif.time < TimeUtils.fiveMinutesAgo()) { - verify(event) - } else { - verif - } - } - - is VerificationState.Error -> { - verif - } - - else -> { - verify(event) - } - } -} From fef1d15f12737f630101c747cfd4591ed6978796 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:42:51 +0000 Subject: [PATCH 2/2] fix(ots): never persist the non-terminal Verifying sentinel on the note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyOts wrote otsVerification = Verifying before the suspending blockchain verifyState() call and only overwrote it with the real verdict afterwards. That verification runs inside LoadOts's cancellable LaunchedEffect, so a row scrolling off-screen cancels the coroutine at the network suspension point — the verdict write never happens and the note is left stuck at Verifying. cacheVerifyOts treats Verifying as terminal, so earliestOtsVerifiedTime then returns null and the confirmed-timestamp pill silently vanishes for the note's lifetime. The old VerificationStateCache had the same write but its LRU eventually evicted the stuck entry; anchoring the verdict on the long-lived note removed that recovery. Store only terminal verdicts (Verified / Error / NetworkError). A cancelled or in-flight verification leaves the field null and simply retries on the next read; the cost is at worst two concurrent first-time verifications, already benign. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj --- .../nip03Timestamp/NoteOtsVerification.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt index 58ed3dd167..cb7e3218d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/NoteOtsVerification.kt @@ -49,22 +49,28 @@ fun Note.justOtsVerification(): VerificationState? = otsVerification suspend fun Note.cacheVerifyOts(resolver: OtsResolver): VerificationState { val event = event as? OtsEvent ?: return VerificationState.Error("Not an OTS event") return when (val current = otsVerification) { - is VerificationState.Verifying -> current is VerificationState.Verified -> current + is VerificationState.Error -> current is VerificationState.NetworkError -> if (current.time < TimeUtils.fiveMinutesAgo()) verifyOts(event, resolver) else current - is VerificationState.Error -> current + // null, or a leftover non-terminal state from an interrupted run: (re)verify. else -> verifyOts(event, resolver) } } +/** + * Verifies the attestation and stores ONLY the terminal verdict. We deliberately never persist a + * `Verifying` sentinel: this runs inside a cancellable `LoadOts` LaunchedEffect, so if the coroutine + * were cancelled at the network suspension point after writing `Verifying` but before the verdict, + * that sentinel would stick on the (long-lived) note forever — there is no LRU eviction to recover + * it, unlike the old VerificationStateCache — and the OTS pill would silently vanish. Leaving the + * field untouched until a real verdict lands means a cancelled run simply retries on the next read; + * the cost is at worst two concurrent first-time verifications, which is harmless. + */ private suspend fun Note.verifyOts( event: OtsEvent, resolver: OtsResolver, -): VerificationState { - otsVerification = VerificationState.Verifying - return event.verifyState(resolver).also { otsVerification = it } -} +): VerificationState = event.verifyState(resolver).also { otsVerification = it } /** * The earliest blockchain-verified time (unix seconds) among this note's non-expired OTS