diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ec87e1d13..e606ac8d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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>(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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1cc7b8423..ee83c10a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = mapOf(), var hasDonatedInVersion: Set = setOf(), + var pendingAttestations: Map = mapOf(), val scope: CoroutineScope = Amethyst.instance.applicationIOScope, ) { var transientHiddenUsers: ImmutableSet = 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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e23032590..e21342a4f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 7602992b7..73526c322 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 6d5f14e21..e29566892 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -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), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 935ec7838..b9bc45ef3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 3f4d2d513..d2bbb7f59 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index a645395c9..b49ef2786 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -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)) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5181cbc74..1f53559a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f83505b19..b186de86e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,9 @@ Copy Author ID Copy Note ID Broadcast + Timestamp it + Timestamp: Pending Confirmations + OTS: Pending Request Deletion Block / Report @@ -782,4 +785,5 @@ Git Repository: %1$s Web: Clone: + OTS: %1$s diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/OtsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/OtsTest.kt index 3a57bb2c0..838c279ca 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/OtsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/OtsTest.kt @@ -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()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt index 60dfe74f2..f4f895322 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/OtsEvent.kt @@ -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) } } }