Merge pull request #3702 from vitorpamplona/claude/ots-notes-lifecycle-ok3mz3

Anchor OTS attestations to target notes, replace verification cache
This commit is contained in:
Vitor Pamplona
2026-07-24 18:44:29 -04:00
committed by GitHub
7 changed files with 179 additions and 131 deletions
@@ -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,
)
@@ -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<AddressableNote> = 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 ->
@@ -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())
}
}
}
@@ -0,0 +1,111 @@
/*
* 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.Verified -> current
is VerificationState.Error -> current
is VerificationState.NetworkError ->
if (current.time < TimeUtils.fiveMinutesAgo()) verifyOts(event, resolver) else 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 = 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
}
@@ -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 =
@@ -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<Note>()
private set
@@ -199,6 +213,16 @@ open class Note(
var edits = listOf<Note>()
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<Note>()
private set
var reports = mapOf<User, List<Note>>()
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()
@@ -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<HexKey, VerificationState>(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)
}
}
}