Adds local cache to wait for OTS confirmations

Presents OTS pending and confirmations on the screen
Adds Menu item to OTS any post.
This commit is contained in:
Vitor Pamplona 2024-02-26 18:51:23 -05:00
parent bf67ff64b4
commit 8ebad524ff
12 changed files with 321 additions and 34 deletions

View File

@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
@ -113,6 +114,7 @@ private object PrefKeys {
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val PENDING_ATTESTATIONS = "pending_attestations"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
@ -339,6 +341,11 @@ object LocalPreferences {
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(account.pendingAttestations),
)
}
.apply()
}
@ -556,6 +563,26 @@ object LocalPreferences {
null
}
val pendingAttestations =
try {
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
println("Decoding Attestation List: " + it)
if (it != null) {
Event.mapper.readValue<Map<HexKey, String>>(it)
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
e,
)
null
}
val languagePreferences =
try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
@ -649,6 +676,7 @@ object LocalPreferences {
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
hasDonatedInVersion = hasDonatedInVersion,
pendingAttestations = pendingAttestations ?: emptyMap(),
)
// Loads from DB

View File

@ -76,6 +76,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP24Factory
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price
@ -185,6 +186,7 @@ class Account(
var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var hasDonatedInVersion: Set<String> = setOf<String>(),
var pendingAttestations: Map<HexKey, String> = mapOf<HexKey, String>(),
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
) {
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
@ -893,6 +895,38 @@ class Account(
}
}
suspend fun updateAttestations() {
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
pendingAttestations.toMap().forEach { pair ->
val newAttestation = OtsEvent.upgrade(pair.value, pair.key)
if (pair.value != newAttestation) {
OtsEvent.create(pair.key, newAttestation, signer) {
LocalCache.justConsume(it, null)
Client.send(it)
pendingAttestations = pendingAttestations - pair.key
}
}
}
}
fun hasPendingAttestations(note: Note): Boolean {
val id = note.event?.id() ?: note.idHex
return pendingAttestations.get(id) != null
}
fun timestamp(note: Note) {
if (!isWriteable()) return
val id = note.event?.id() ?: note.idHex
pendingAttestations = pendingAttestations + Pair(id, OtsEvent.stamp(id))
saveable.invalidateData()
}
fun follow(user: User) {
if (!isWriteable()) return

View File

@ -86,6 +86,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
@ -716,6 +717,28 @@ object LocalCache {
}
}
fun consume(
event: OtsEvent,
relay: Relay?,
) {
val version = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (version.event?.id() == event.id()) return
if (version.event == null) {
// makes sure the OTS has a valid certificate
if (event.cacheVerify() == null) return // no valid OTS
version.loadEvent(event, author, emptyList())
version.liveSet?.innerOts?.invalidateData()
}
refreshObservers(version)
}
fun consume(
event: BadgeDefinitionEvent,
relay: Relay?,
@ -1626,6 +1649,25 @@ object LocalCache {
.toImmutableList()
}
suspend fun findEarliestOtsForNote(note: Note): Long? {
checkNotInMainThread()
val validOts =
notes
.mapNotNull {
val noteEvent = it.value.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpired())) {
noteEvent.verifiedTime
} else {
null
}
}
if (validOts.isEmpty()) return null
return validOts.minBy { it }
}
fun cleanObservers() {
notes.forEach { it.value.clearLive() }
@ -1960,6 +2002,7 @@ object LocalCache {
is MetadataEvent -> consume(event)
is MuteListEvent -> consume(event, relay)
is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay)

View File

@ -947,6 +947,7 @@ class NoteLiveSet(u: Note) {
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
@ -1011,6 +1012,7 @@ class NoteLiveSet(u: Note) {
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
innerOts.destroy()
}
}

View File

@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
@ -134,6 +135,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),

View File

@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
@ -177,6 +178,7 @@ import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier
import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.nip05
@ -2544,9 +2546,43 @@ fun SecondUserInfoRow(
Spacer(StdHorzSpacer)
DisplayPoW(pow)
}
DisplayOts(note, accountViewModel)
}
}
@Composable
fun DisplayOts(
note: Note,
accountViewModel: AccountViewModel,
) {
LoadOts(
note,
accountViewModel,
whenConfirmed = {
val context = LocalContext.current
val timeStr by remember(note) { mutableStateOf(timeAgo(it, context = context)) }
Text(
stringResource(id = R.string.existed_since, timeStr),
color = MaterialTheme.colorScheme.lessImportantLink,
fontSize = Font14SP,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
},
whenPending = {
Text(
stringResource(id = R.string.timestamp_pending_short),
color = MaterialTheme.colorScheme.lessImportantLink,
fontSize = Font14SP,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
},
)
}
@Composable
private fun ShowForkInformation(
noteEvent: BaseTextNoteEvent,
@ -2638,6 +2674,37 @@ fun LoadStatuses(
content(statuses)
}
@Composable
fun LoadOts(
note: Note,
accountViewModel: AccountViewModel,
whenConfirmed: @Composable (Long) -> Unit,
whenPending: @Composable () -> Unit,
) {
var earliestDate: GenericLoadable<Long> by remember { mutableStateOf(GenericLoadable.Loading()) }
val noteStatus by note.live().innerOts.observeAsState()
LaunchedEffect(key1 = noteStatus) {
accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts ->
if (newOts == null) {
earliestDate = GenericLoadable.Empty()
} else {
earliestDate = GenericLoadable.Loaded(newOts)
}
}
}
(earliestDate as? GenericLoadable.Loaded)?.let {
whenConfirmed(it.loaded)
} ?: run {
val account = accountViewModel.account.saveable.observeAsState()
if (account.value?.account?.hasPendingAttestations(note) == true) {
whenPending()
}
}
}
@Composable
fun LoadCityName(
geohash: GeoHash,

View File

@ -342,11 +342,9 @@ private fun RenderMainPopup(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast),
) {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
VerticalDivider(primaryLight)
NoteQuickActionItem(

View File

@ -554,13 +554,28 @@ fun NoteDropDownMenu(
DropdownMenuItem(
text = { Text(stringResource(R.string.broadcast)) },
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
onDismiss()
}
accountViewModel.broadcast(note)
onDismiss()
},
)
Divider()
if (accountViewModel.account.hasPendingAttestations(note)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.timestamp_pending)) },
onClick = {
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringResource(R.string.timestamp_it)) },
onClick = {
accountViewModel.timestamp(note)
onDismiss()
},
)
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_from_private_bookmarks)) },

View File

@ -528,7 +528,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
fun broadcast(note: Note) {
account.broadcast(note)
viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) }
}
fun timestamp(note: Note) {
viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) }
}
var lastTimeItTriedToUpdateAttestations: Long = 0
fun upgradeAttestations() {
// only tries to upgrade every hour
val now = TimeUtils.now()
if (now - lastTimeItTriedToUpdateAttestations > TimeUtils.ONE_HOUR) {
lastTimeItTriedToUpdateAttestations = now
viewModelScope.launch(Dispatchers.IO) { account.updateAttestations() }
}
}
fun delete(note: Note) {
@ -889,6 +904,13 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) }
}
fun findOtsEventsForNote(
note: Note,
onResult: (Long?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findEarliestOtsForNote(note)) }
}
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
return LocalCache.checkGetOrCreateChannel(key)
}
@ -1100,6 +1122,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
"Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}",
)
invalidateInsertData(newNotes)
upgradeAttestations()
}
}
}

View File

@ -24,6 +24,9 @@
<string name="copy_user_pubkey">Copy Author ID</string>
<string name="copy_note_id">Copy Note ID</string>
<string name="broadcast">Broadcast</string>
<string name="timestamp_it">Timestamp it</string>
<string name="timestamp_pending">Timestamp: Pending Confirmations</string>
<string name="timestamp_pending_short">OTS: Pending</string>
<string name="request_deletion">Request Deletion</string>
<string name="block_report">Block / Report</string>
<string name="block_hide_user"><![CDATA[Block & Hide User]]></string>
@ -782,4 +785,5 @@
<string name="git_repository">Git Repository: %1$s</string>
<string name="git_web_address">Web:</string>
<string name="git_clone_address">Clone:</string>
<string name="existed_since">OTS: %1$s</string>
</resources>

View File

@ -26,8 +26,8 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.Assert
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
@ -60,9 +60,33 @@ class OtsTest {
val ots = Event.Companion.fromJson(otsPendingEvent) as OtsEvent
println(ots.info())
assertEquals(null, ots.verify())
val eventId =
ots.digestEvent() ?: run {
fail("Should not be null")
return
}
val upgraded = OtsEvent.upgrade(ots.content, eventId)
val signer = NostrSignerInternal(KeyPair())
var newOts: OtsEvent? = null
val countDownLatch = CountDownLatch(1)
OtsEvent.create(eventId, upgraded, signer) {
newOts = it
countDownLatch.countDown()
}
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(newOts!!.toJson())
println(newOts!!.info())
assertEquals(1708879025L, newOts!!.verify())
}
@Ignore("Need to figure out a way to wait for Bitcoin confirmations to test thiss")
@Test
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
@ -70,7 +94,7 @@ class OtsTest {
val countDownLatch = CountDownLatch(1)
OtsEvent.create(otsEvent2Digest, signer) {
OtsEvent.create(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest), signer) {
ots = it
countDownLatch.countDown()
}
@ -80,6 +104,6 @@ class OtsTest {
println(ots!!.toJson())
println(ots!!.info())
assertEquals(1706322179L, ots!!.verify())
assertEquals(null, ots!!.verify())
}
}

View File

@ -45,6 +45,9 @@ class OtsEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient
var verifiedTime: Long? = null
fun digestEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun digest() = digestEvent()?.hexToByteArray()
@ -53,25 +56,19 @@ class OtsEvent(
return Base64.getDecoder().decode(content)
}
fun verify(): Long? {
try {
val digest = digest() ?: return null
val detachedOts = DetachedTimestampFile.deserialize(otsByteArray())
val result = otsInstance.verify(detachedOts, digest)
if (result == null || result.isEmpty()) {
return null
} else {
return result.get(VerifyResult.Chains.BITCOIN)?.timestamp
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("OpenTimeStamps", "Failed to verify", e)
return null
fun cacheVerify(): Long? {
return if (verifiedTime != null) {
verifiedTime
} else {
verifiedTime = verify()
verifiedTime
}
}
fun verify(): Long? {
return digestEvent()?.let { OtsEvent.verify(otsByteArray(), it) }
}
fun info(): String {
val detachedOts = DetachedTimestampFile.deserialize(otsByteArray())
return otsInstance.info(detachedOts)
@ -83,27 +80,77 @@ class OtsEvent(
var otsInstance = OpenTimestamps(BlockstreamExplorer(), CalendarPureJavaBuilder())
fun stamp(eventId: HexKey): ByteArray {
fun stamp(eventId: HexKey): String {
val hash = Hash(eventId.hexToByteArray(), OpSHA256._TAG)
val file = DetachedTimestampFile.from(hash)
val timestamp = otsInstance.stamp(file)
val detachedToSerialize = DetachedTimestampFile(hash.getOp(), timestamp)
return detachedToSerialize.serialize()
return Base64.getEncoder().encodeToString(detachedToSerialize.serialize())
}
fun upgrade(
otsFile: String,
eventId: HexKey,
): String {
val detachedOts = DetachedTimestampFile.deserialize(Base64.getDecoder().decode(otsFile))
return if (otsInstance.upgrade(detachedOts)) {
// if the change is now verifiable.
if (verify(detachedOts, eventId) != null) {
Base64.getEncoder().encodeToString(detachedOts.serialize())
} else {
otsFile
}
} else {
otsFile
}
}
fun verify(
otsFile: String,
eventId: HexKey,
): Long? {
return verify(Base64.getDecoder().decode(otsFile), eventId)
}
fun verify(
otsFile: ByteArray,
eventId: HexKey,
): Long? {
return verify(DetachedTimestampFile.deserialize(otsFile), eventId)
}
fun verify(
detachedOts: DetachedTimestampFile,
eventId: HexKey,
): Long? {
try {
val result = otsInstance.verify(detachedOts, eventId.hexToByteArray())
if (result == null || result.isEmpty()) {
return null
} else {
return result.get(VerifyResult.Chains.BITCOIN)?.timestamp
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("OpenTimeStamps", "Failed to verify", e)
return null
}
}
fun create(
eventId: HexKey,
otsFileBase64: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (OtsEvent) -> Unit,
) {
val otsFile = stamp(eventId)
val tags =
arrayOf(
arrayOf("e", eventId),
arrayOf("alt", ALT),
)
signer.sign(createdAt, KIND, tags, Base64.getEncoder().encodeToString(otsFile), onReady)
signer.sign(createdAt, KIND, tags, otsFileBase64, onReady)
}
}
}