fix(ots): never persist the non-terminal Verifying sentinel on the note

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
This commit is contained in:
Claude
2026-07-24 22:42:51 +00:00
parent a65d2b9342
commit fef1d15f12
@@ -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