From 8988c98308eb8812a6a0201032eb544265474d92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:24:45 +0000 Subject: [PATCH 01/15] feat: add NIP-13 proof-of-work publishing with a fire-and-forget mining queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements user-facing PoW publishing (#3317) on top of the existing quartz miner: - quartz: PoWMiner.run gains a cooperative isActive cancellation hook (checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines kind-scoped templates pre-signature so it composes with every signer; GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key wrap (never the seal/rumor); NostrSignerWithClientTag exposes prepareTags so mining runs over the final tag set. - commons: PoWPublishQueue (FIFO, capped worker pool on Dispatchers.Default, per-job cancel, in-memory only — unmined posts are lost on process death, logged) and PoWPolicy (kind-group categories with a hardcoded NEVER list: auth, zap requests, NWC and bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS). - amethyst: per-account synced settings (difficulty Off/16/20/24/28 or custom, per-category checklist) in Compose Settings; Post enqueues the template and returns immediately; reactions, reposts, reports, private notes, DMs and long-form route through the same shouldMine gate at their existing choke points; per-post PoW override chip in the composer options row; "Mining proof of work… (N in queue)" phase with per-job cancel in the broadcast banner. Scheduled posts and anonymous posts mine against the correct key (scheduled posts skip mining in v1); the client tag is applied to the template before mining so signing never invalidates the nonce. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- .../com/vitorpamplona/amethyst/AppModules.kt | 11 + .../vitorpamplona/amethyst/model/Account.kt | 239 ++++++++++++++++-- .../amethyst/model/AccountSettings.kt | 20 ++ .../amethyst/model/AccountSyncedSettings.kt | 56 ++++ .../model/AccountSyncedSettingsInternal.kt | 10 + .../model/accountsCache/AccountCacheState.kt | 3 + .../amethyst/ui/broadcast/BroadcastBanner.kt | 120 ++++++++- .../ui/broadcast/DisplayBroadcastProgress.kt | 29 ++- .../ui/note/creators/pow/PowOverrideButton.kt | 118 +++++++++ .../nip22Comments/CommentPostViewModel.kt | 48 +++- .../nip22Comments/GenericCommentPostScreen.kt | 8 + .../ui/screen/loggedIn/AccountViewModel.kt | 32 ++- .../nip23LongForm/LongFormPostViewModel.kt | 49 +++- .../loggedIn/home/ShortNotePostScreen.kt | 8 + .../loggedIn/home/ShortNotePostViewModel.kt | 107 ++++++-- .../settings/ComposeSettingsScreen.kt | 120 +++++++++ amethyst/src/main/res/values/strings.xml | 40 ++- .../amethyst/commons/service/pow/PoWPolicy.kt | 148 +++++++++++ .../commons/service/pow/PoWPublishQueue.kt | 192 ++++++++++++++ .../commons/service/pow/PoWPolicyTest.kt | 112 ++++++++ .../service/pow/PoWPublishQueueTest.kt | 118 +++++++++ .../quartz/nip13Pow/miner/PoWMiner.kt | 14 +- .../quartz/nip13Pow/signer/PoWNostrSigner.kt | 96 +++++++ .../quartz/nip17Dm/NIP17Factory.kt | 23 +- .../nip59Giftwrap/wraps/GiftWrapEvent.kt | 32 ++- .../clientTag/NostrSignerWithClientTag.kt | 8 + .../nip13Pow/PoWMinerCancellationTest.kt | 83 ++++++ 27 files changed, 1764 insertions(+), 80 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8e4b80441d..ee2146c925 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -596,6 +597,15 @@ class AppModules( applicationIOScope, ) + // fire-and-forget NIP-13 mining: posts queue here and publish when mined. + // Capped worker pool so a burst of sends never spawns unbounded miners. + val powPublishQueue by lazy { + PoWPublishQueue( + scope = applicationIOScope, + maxConcurrent = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 2), + ) + } + // keeps all accounts live val accountsCache = AccountCacheState( @@ -609,6 +619,7 @@ class AppModules( cache = cache, client = client, rootFilesDir = { appContext.filesDir }, + powQueue = { powPublishQueue }, ) val sessionManager = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b8f6eb22d3..9b1aecdcca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -52,6 +52,9 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -184,6 +187,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf +import com.vitorpamplona.quartz.nip13Pow.signer.PoWNostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -202,6 +206,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -227,6 +232,7 @@ import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkLi import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -266,6 +272,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -325,6 +332,7 @@ class Account( val mlsGroupStateStore: MlsGroupStateStore? = null, val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, + val powQueue: () -> PoWPublishQueue? = { null }, ) : IAccount { private var userProfileCache: User? = null @@ -661,6 +669,21 @@ class Account( return false } + suspend fun updatePowDifficulty(difficulty: Int) { + if (settings.updatePowDifficulty(difficulty)) { + sendNewAppSpecificData() + } + } + + suspend fun updatePowCategory( + category: PoWCategory, + enabled: Boolean, + ) { + if (settings.updatePowCategory(category, enabled)) { + sendNewAppSpecificData() + } + } + suspend fun updateFilterSpam(filterSpam: Boolean): Boolean { if (settings.updateFilterSpam(filterSpam)) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { @@ -762,17 +785,134 @@ class Account( private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData()) + // --- + // NIP-13 proof-of-work publishing + // --- + + /** + * Difficulty to mine [kind] at per this account's NIP-13 settings, or null + * when the kind publishes immediately: master difficulty off, category + * disabled, or one of [PoWPolicy]'s hard-excluded kinds (auth, zap + * requests, NWC/bunker RPC, drafts, lists…). + */ + fun powDifficultyFor(kind: Int): Int? = + PoWPolicy.shouldMine( + kind = kind, + difficulty = settings.syncedSettings.proofOfWork.difficulty.value, + enabledCategories = settings.syncedSettings.proofOfWork.enabledCategories.value, + ) + + /** + * [powDifficultyFor] with a per-post override from the composer chip: + * null defers to the account settings, 0 disables mining for this post, + * a positive value forces that difficulty (hard-excluded kinds still win). + */ + fun powDifficultyFor( + kind: Int, + overrideDifficulty: Int?, + ): Int? = + when { + overrideDifficulty == null -> powDifficultyFor(kind) + overrideDifficulty <= 0 -> null + PoWPolicy.neverMine(kind) -> null + else -> overrideDifficulty + } + + /** + * Enqueues [work] into the fire-and-forget mining queue. Returns false when + * no queue is wired (headless/test accounts): callers must then run their + * direct, un-mined send path instead. + */ + fun mineInBackground( + kind: Int, + difficulty: Int, + work: suspend (isActive: () -> Boolean) -> Unit, + ): Boolean { + val queue = powQueue() ?: return false + queue.enqueueWork(kind, difficulty, work) + return true + } + + /** + * Enqueues [template] to be mined at [difficulty] and then handed to + * [onMined], which should run the exact sign+send path the caller would + * have used without PoW. Returns false when no queue is wired. + * + * The template is normalized to the final tag shape the signer will submit + * (client tag included) before mining — a tag appended after mining would + * invalidate the nonce. + */ + fun mineTemplateInBackground( + template: EventTemplate, + difficulty: Int, + onMined: suspend (EventTemplate) -> Unit, + ): Boolean { + val queue = powQueue() ?: return false + queue.enqueue(withFinalSignerTags(template), signer.pubKey, difficulty, onMined) + return true + } + + private fun withFinalSignerTags(template: EventTemplate): EventTemplate { + val currentSigner = signer + if (currentSigner !is NostrSignerWithClientTag) return template + + val finalTags = currentSigner.prepareTags(template.tags) + if (finalTags === template.tags) return template + + return EventTemplate(template.createdAt, template.kind, finalTags, template.content) + } + + /** + * A signer that mines [kindsToMine] at [difficulty] right before signing. + * When the account signer stamps a client tag, the miner is layered inside + * it so mining runs over the final tag set. + */ + private fun miningSigner( + difficulty: Int, + kindsToMine: Set, + isActive: () -> Boolean, + ): NostrSigner { + val currentSigner = signer + return if (currentSigner is NostrSignerWithClientTag) { + NostrSignerWithClientTag( + inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive), + clientTag = currentSigner.clientTag, + disabled = currentSigner.disabled, + ) + } else { + PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive) + } + } + suspend fun reactTo( note: Note, reaction: String, - ) = ReactionAction.reactTo( - note = note, - reaction = reaction, - by = userProfile(), - signer = signer, - onPublic = ::sendAutomatic, - onPrivate = ::broadcastPrivately, - ) + ) { + val powDifficulty = powDifficultyFor(ReactionEvent.KIND) + if (powDifficulty != null && + mineInBackground(ReactionEvent.KIND, powDifficulty) { isActive -> + ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = miningSigner(powDifficulty, setOf(ReactionEvent.KIND), isActive), + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) + } + ) { + return + } + + ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = signer, + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) + } /** * Creates a reaction event without sending it. @@ -1030,16 +1170,41 @@ class Account( // A kind-1984 e-tagging the rumor would leak the private id onto // public relays. Report the author instead (p-tag only). note.author?.let { report(it, type, content) } - } else { - sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + return } + + val powDifficulty = powDifficultyFor(ReportEvent.KIND) + if (powDifficulty != null && + mineInBackground(ReportEvent.KIND, powDifficulty) { isActive -> + sendMyPublicAndPrivateOutbox( + ReportAction.report(note, type, content, userProfile(), miningSigner(powDifficulty, setOf(ReportEvent.KIND), isActive)), + ) + } + ) { + return + } + + sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) } suspend fun report( user: User, type: ReportType, content: String = "", - ) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer)) + ) { + val powDifficulty = powDifficultyFor(ReportEvent.KIND) + if (powDifficulty != null && + mineInBackground(ReportEvent.KIND, powDifficulty) { isActive -> + sendMyPublicAndPrivateOutbox( + ReportAction.report(user, type, content, userProfile(), miningSigner(powDifficulty, setOf(ReportEvent.KIND), isActive)), + ) + } + ) { + return + } + + sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer)) + } suspend fun delete(note: Note) = delete(listOf(note)) @@ -1115,7 +1280,23 @@ class Account( ) = blossomServers.createBlossomDeleteAuth(hash, alt) suspend fun boost(note: Note) { - RepostAction.repost(note, signer)?.let { event -> + val powDifficulty = powDifficultyFor(RepostEvent.KIND) + if (powDifficulty != null && + mineInBackground(RepostEvent.KIND, powDifficulty) { isActive -> + repostNow(note, miningSigner(powDifficulty, setOf(RepostEvent.KIND, GenericRepostEvent.KIND), isActive)) + } + ) { + return + } + + repostNow(note, signer) + } + + private suspend fun repostNow( + note: Note, + repostSigner: NostrSigner, + ) { + RepostAction.repost(note, repostSigner)?.let { event -> client.publish(event, computeMyReactionToNote(note, event)) cache.justConsumeMyOwnEvent(event) } @@ -2526,13 +2707,29 @@ class Account( override suspend fun sendNip17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return - val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer) - broadcastPrivately(wraps) + val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND) + if (powDifficulty != null && + mineInBackground(GiftWrapEvent.KIND, powDifficulty) { isActive -> + broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer, wrapPowDifficulty = powDifficulty, wrapPowIsActive = isActive)) + } + ) { + return + } + + broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer)) } override suspend fun sendNip17PrivateMessage(template: EventTemplate) { - val events = NIP17Factory().createMessageNIP17(template, signer) - broadcastPrivately(events) + val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND) + if (powDifficulty != null && + mineInBackground(GiftWrapEvent.KIND, powDifficulty) { isActive -> + broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer, wrapPowDifficulty = powDifficulty, wrapPowIsActive = isActive)) + } + ) { + return + } + + broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer)) } /** @@ -2544,6 +2741,16 @@ class Account( */ suspend fun sendPrivateNote(template: EventTemplate) { if (!isWriteable()) return + + val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND) + if (powDifficulty != null && + mineInBackground(GiftWrapEvent.KIND, powDifficulty) { isActive -> + broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer, wrapPowDifficulty = powDifficulty, wrapPowIsActive = isActive)) + } + ) { + return + } + broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 002cd04e70..4bffb78a01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -578,6 +579,25 @@ class AccountSettings( false } + fun updatePowDifficulty(difficulty: Int): Boolean = + if (syncedSettings.proofOfWork.updateDifficulty(difficulty)) { + saveAccountSettings() + true + } else { + false + } + + fun updatePowCategory( + category: PoWCategory, + enabled: Boolean, + ): Boolean = + if (syncedSettings.proofOfWork.updateCategory(category, enabled)) { + saveAccountSettings() + true + } else { + false + } + // --- // list names // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index acb4dfd8ff..2b4580c93a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -72,6 +73,11 @@ class AccountSyncedSettings( AccountChatPreferences( MutableStateFlow(internalSettings.chats.toChatroomKeys()), ) + val proofOfWork = + AccountPoWPreferences( + MutableStateFlow(internalSettings.proofOfWork.difficulty), + MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -104,6 +110,14 @@ class AccountSyncedSettings( videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name), chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }), + proofOfWork = + AccountPoWPreferencesInternal( + proofOfWork.difficulty.value, + // sorted so the serialized form is deterministic + proofOfWork.enabledCategories.value + .map { it.id } + .sorted(), + ), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -182,6 +196,15 @@ class AccountSyncedSettings( if (chats.pinnedChatrooms.value != newPinnedChatrooms) { chats.pinnedChatrooms.tryEmit(newPinnedChatrooms) } + + if (proofOfWork.difficulty.value != syncedSettingsInternal.proofOfWork.difficulty) { + proofOfWork.difficulty.tryEmit(syncedSettingsInternal.proofOfWork.difficulty) + } + + val newPoWCategories = PoWCategory.fromIds(syncedSettingsInternal.proofOfWork.enabledCategories) + if (proofOfWork.enabledCategories.value != newPoWCategories) { + proofOfWork.enabledCategories.tryEmit(newPoWCategories) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -285,6 +308,39 @@ class AccountChatPreferences( val pinnedChatrooms: MutableStateFlow>, ) +@Stable +class AccountPoWPreferences( + val difficulty: MutableStateFlow = MutableStateFlow(0), + val enabledCategories: MutableStateFlow> = MutableStateFlow(PoWCategory.DEFAULT_ENABLED), +) { + fun updateDifficulty(newDifficulty: Int): Boolean = + if (difficulty.value != newDifficulty) { + difficulty.tryEmit(newDifficulty.coerceIn(0, MAX_POW_DIFFICULTY)) + true + } else { + false + } + + fun updateCategory( + category: PoWCategory, + enabled: Boolean, + ): Boolean { + val current = enabledCategories.value + val updated = if (enabled) current + category else current - category + return if (updated != current) { + enabledCategories.tryEmit(updated) + true + } else { + false + } + } + + companion object { + // above ~40 bits a phone would mine for days; treat it as a config error. + const val MAX_POW_DIFFICULTY = 40 + } +} + internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 800f4528d1..3de1f9b27c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.serialization.Serializable import java.util.Locale @@ -157,6 +158,7 @@ class AccountSyncedSettingsInternal( val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(), val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(), val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(), + val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(), ) @Serializable @@ -206,6 +208,14 @@ class AccountMediaPreferencesInternal( var audioVisualizer: String = "CLASSIC", ) +@Serializable +class AccountPoWPreferencesInternal( + // NIP-13 target difficulty in leading zero bits; 0 = don't mine anything. + val difficulty: Int = 0, + // PoWCategory ids the user wants mined when difficulty > 0. + val enabledCategories: List = PoWCategory.DEFAULT_ENABLED.map { it.id }, +) + @Serializable class AccountChatPreferencesInternal( // Rooms pinned to the top of the chat list. Each room is its member diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index d307f3a07b..32a6183eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -61,6 +62,7 @@ class AccountCacheState( val cache: LocalCache, val client: INostrClient, val rootFilesDir: () -> File = { File("") }, + val powQueue: () -> PoWPublishQueue? = { null }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -244,6 +246,7 @@ class AccountCacheState( mlsGroupStateStore = mlsStore, marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, + powQueue = powQueue, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 2fab6dfd26..8e10f8dfe4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event @@ -77,17 +78,22 @@ import java.util.UUID /** * Banner showing active broadcast progress. * Displayed above bottom navigation when events are being sent to relays. + * + * [miningJobs] is the NIP-13 pre-send phase: posts waiting for (or in the + * middle of) proof-of-work mining, before their per-relay send states exist. */ @Composable fun BroadcastBanner( broadcasts: ImmutableList, + miningJobs: ImmutableList = persistentListOf(), + onCancelJob: (String) -> Unit = {}, onTap: () -> Unit = {}, onRetryAll: () -> Unit = {}, onDismiss: () -> Unit = {}, modifier: Modifier = Modifier, ) { AnimatedVisibility( - visible = broadcasts.isNotEmpty(), + visible = broadcasts.isNotEmpty() || miningJobs.isNotEmpty(), enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), modifier = modifier, @@ -108,19 +114,29 @@ fun BroadcastBanner( .padding(horizontal = 16.dp, vertical = 8.dp) .animateContentSize(), ) { - val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS } + if (miningJobs.isNotEmpty()) { + MiningContent(miningJobs, onCancelJob) - if (isAllFinished) { - if (broadcasts.size == 1) { - CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss) - } else { - MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss) + if (broadcasts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) } - } else { - if (broadcasts.size == 1) { - SingleBroadcastContent(broadcasts.first()) + } + + if (broadcasts.isNotEmpty()) { + val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS } + + if (isAllFinished) { + if (broadcasts.size == 1) { + CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss) + } else { + MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss) + } } else { - MultipleBroadcastsContent(broadcasts) + if (broadcasts.size == 1) { + SingleBroadcastContent(broadcasts.first()) + } else { + MultipleBroadcastsContent(broadcasts) + } } } } @@ -128,6 +144,78 @@ fun BroadcastBanner( } } +@Composable +private fun MiningContent( + miningJobs: ImmutableList, + onCancelJob: (String) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = stringRes(R.string.pow_mining_title), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + + Text( + text = stringRes(R.string.pow_mining_progress, miningJobs.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + miningJobs.forEach { job -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(Modifier.width(26.dp)) + + Text( + text = + stringRes( + if (job.isMining) R.string.pow_mining_job else R.string.pow_queued_job, + kindToName(job.kind), + job.difficulty.toString(), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = "×", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .clickable(onClick = { onCancelJob(job.id) }) + .padding(start = 2.dp), + ) + } + } + + Spacer(Modifier.height(4.dp)) + + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } +} + @Composable private fun SingleBroadcastContent(broadcast: BroadcastEvent) { Row( @@ -443,6 +531,16 @@ fun Event.toKindName(): String = else -> stringRes(R.string.post) } +@Composable +fun kindToName(kind: Int): String = + when (kind) { + ReactionEvent.KIND -> stringRes(R.string.reaction) + RepostEvent.KIND, GenericRepostEvent.KIND -> stringRes(R.string.boost) + VoiceEvent.KIND -> stringRes(R.string.voice_post) + VoiceReplyEvent.KIND -> stringRes(R.string.voice_reply) + else -> stringRes(R.string.post) + } + @Preview @Composable fun BroadcastBannerSingleEventPreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt index 2a6bb6c98d..2715778e18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -37,11 +37,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay /** @@ -50,29 +53,38 @@ import kotlinx.coroutines.delay * - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s) * - BroadcastDetailsSheet: Shows detailed relay status on tap * - * Hidden when the "Tracked broadcasts" UI setting is off. + * The relay-progress part is hidden when the "Tracked broadcasts" UI setting + * is off, but the NIP-13 mining phase always shows — the user needs to see + * (and be able to cancel) posts still burning CPU in the queue. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { val useTrackedBroadcasts by accountViewModel.settings.uiSettingsFlow.useTrackedBroadcasts .collectAsStateWithLifecycle() - if (useTrackedBroadcasts != BooleanType.ALWAYS) return + val trackingEnabled = useTrackedBroadcasts == BooleanType.ALWAYS - val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() + val miningJobs by Amethyst.instance.powPublishQueue.jobs + .collectAsStateWithLifecycle() + val trackedBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() + val activeBroadcasts = if (trackingEnabled) trackedBroadcasts else persistentListOf() // State for details sheet var seeDetails by remember { mutableStateOf(false) } - if (activeBroadcasts.isEmpty() && !seeDetails) return + if (activeBroadcasts.isEmpty() && miningJobs.isEmpty() && !seeDetails) return if (!seeDetails) { - DisplaySnack(activeBroadcasts, { seeDetails = true }, accountViewModel) + DisplaySnack( + activeBroadcasts, + miningJobs, + { if (activeBroadcasts.isNotEmpty()) seeDetails = true }, + accountViewModel, + ) LaunchedEffect(activeBroadcasts) { // this effect gets restarted every time the active broadcast changes - val allComplete = activeBroadcasts.all { it.isComplete } - if (allComplete) { + if (activeBroadcasts.isNotEmpty() && activeBroadcasts.all { it.isComplete }) { // All relays responded — dismiss quickly delay(3_000) accountViewModel.broadcastTracker.clear() @@ -111,12 +123,15 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { @Composable fun DisplaySnack( activeBroadcasts: ImmutableList, + miningJobs: ImmutableList, onTap: () -> Unit, accountViewModel: AccountViewModel, ) { Box(modifier = Modifier.fillMaxSize()) { BroadcastBanner( broadcasts = activeBroadcasts, + miningJobs = miningJobs, + onCancelJob = { Amethyst.instance.powPublishQueue.cancel(it) }, onTap = onTap, onRetryAll = { activeBroadcasts.forEach { b -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt new file mode 100644 index 0000000000..9d3018e7ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt @@ -0,0 +1,118 @@ +/* + * 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.ui.note.creators.pow + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font14SP + +val POW_PRESETS = listOf(16, 20, 24, 28) + +/** + * Composer chip showing the NIP-13 difficulty this post will be mined at. + * Tapping opens a menu to raise/lower/disable mining for this post only — + * the account setting is untouched. + * + * [effectiveDifficulty] is what will actually be used at send time (override + * or account default); null/0 means the post publishes without PoW. + * [defaultDifficulty] is what the account settings alone would produce, shown + * in the "default" menu entry. [onSelect] receives null to follow the account + * default, 0 to disable for this post, or a positive difficulty. + */ +@Composable +fun PowOverrideButton( + effectiveDifficulty: Int?, + defaultDifficulty: Int?, + isOverridden: Boolean, + onSelect: (Int?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Box { + TextButton(onClick = { expanded = true }) { + Text( + text = + if (effectiveDifficulty != null && effectiveDifficulty > 0) { + stringRes(R.string.pow_chip_active, effectiveDifficulty) + } else { + stringRes(R.string.pow_chip_off) + }, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + color = + if (isOverridden || (effectiveDifficulty != null && effectiveDifficulty > 0)) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { + Text( + if (defaultDifficulty != null && defaultDifficulty > 0) { + stringRes(R.string.pow_option_default_on, defaultDifficulty) + } else { + stringRes(R.string.pow_option_default_off) + }, + ) + }, + onClick = { + onSelect(null) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.pow_option_off)) }, + onClick = { + onSelect(0) + expanded = false + }, + ) + POW_PRESETS.forEach { preset -> + DropdownMenuItem( + text = { Text(stringRes(R.string.pow_option_bits, preset)) }, + onClick = { + onSelect(preset) + expanded = false + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index f680c7183e..9d7a00d1d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -235,6 +236,20 @@ open class CommentPostViewModel : var wantsAnonymousPost by mutableStateOf(false) + // NIP-13 per-post override from the composer chip: null = follow account + // settings, 0 = don't mine this post, >0 = mine at that difficulty. + var powOverride by mutableStateOf(null) + + fun effectivePowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(CommentEvent.KIND, powOverride) + } + + fun defaultPowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(CommentEvent.KIND) + } + // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed // by the same throwaway key, instead of leaking the real account's pubkey into the @@ -521,6 +536,7 @@ open class CommentPostViewModel : val draftToDelete = draftNote val anonymous = wantsAnonymousPost + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, powOverride) cancel() // A reply within a NIP-29 group is group content: pin it to the group's @@ -541,15 +557,40 @@ open class CommentPostViewModel : } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) + // The anonymous key signs without a client tag, so the template is + // mined as-is against the throwaway pubkey. + val anonSigner = anonymousSigner() + val enqueued = + powDifficulty != null && + accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> + val mined = PoWMiner.run(template, anonSigner.pubKey, powDifficulty, isActive) + accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) + } + if (!enqueued) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonSigner) + } } else if (replyGroupId != null) { // Group content: route to the resolved host. If it couldn't be resolved, publish to the // parent's relays (possibly empty) rather than broadcasting to the outbox — better to // under-deliver a group reply than to leak group participation to unrelated relays. val relays = groupHostRelays ?: replyingTo?.relays.orEmpty() - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays } + val enqueued = + powDifficulty != null && + accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(mined) { relays } + } + if (!enqueued) { + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays } + } } else { - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + val enqueued = + powDifficulty != null && + accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.signAndComputeBroadcast(mined, extraNotesToBroadcast) + } + if (!enqueued) { + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + } } accountViewModel.viewModelScope.launch(Dispatchers.IO) { @@ -824,6 +865,7 @@ open class CommentPostViewModel : wantsSecretEmoji = false wantsAnonymousPost = false anonymousSignerCache = null + powOverride = null forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 8a4c67b999..8d4497e18d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest @@ -505,5 +506,12 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + PowOverrideButton( + effectiveDifficulty = postViewModel.effectivePowDifficulty(), + defaultDifficulty = postViewModel.defaultPowDifficulty(), + isOverridden = postViewModel.powOverride != null, + onSelect = { postViewModel.powOverride = it }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9eb163a241..4c6940dd7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -143,6 +144,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -535,7 +537,8 @@ class AccountViewModel( account.deletePrivately(privateRumors, note) } } else { - if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { + val minesReactions = account.powDifficultyFor(ReactionEvent.KIND) != null + if (!minesReactions && settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( @@ -547,7 +550,9 @@ class AccountViewModel( account.consumeReactionEvent(event) } } else { - // Fire-and-forget (original behavior) + // Fire-and-forget (original behavior). When PoW mining is + // on for reactions this path also routes through the + // mining queue, which has its own progress banner. account.reactTo(note, reaction) } } @@ -1203,11 +1208,17 @@ class AccountViewModel( } fun boost(note: Note) = - launchTrackedOrDirect( - createTracked = { account.createBoostEvent(note) }, - consumeTracked = account::consumeBoostEvent, - direct = { account.boost(note) }, - ) + if (account.powDifficultyFor(RepostEvent.KIND) != null) { + // Reposts are mined: route through the queue (which has its own + // progress banner) instead of the inline tracked path. + launchSigner { account.boost(note) } + } else { + launchTrackedOrDirect( + createTracked = { account.createBoostEvent(note) }, + consumeTracked = account::consumeBoostEvent, + direct = { account.boost(note) }, + ) + } fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) } @@ -1597,6 +1608,13 @@ class AccountViewModel( fun updateAddClientTag(add: Boolean) = launchSigner { account.updateAddClientTag(add) } + fun updatePowDifficulty(difficulty: Int) = launchSigner { account.updatePowDifficulty(difficulty) } + + fun updatePowCategory( + category: PoWCategory, + enabled: Boolean, + ) = launchSigner { account.updatePowCategory(category, enabled) } + fun updateFilterSpam(filterSpam: Boolean) = launchSigner { if (account.updateFilterSpam(filterSpam)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index e7ef300063..1d0eae657e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -351,20 +351,28 @@ class LongFormPostViewModel : val template = createTemplate() ?: return val draftToDelete = draftNote + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind) cancel() - if (accountViewModel.settings.useTrackedBroadcasts()) { - val (event, relays, extras) = accountViewModel.account.createPostEvent(template, emptyList()) - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.broadcastTracker.trackBroadcast( - event = event, - relays = relays, - client = accountViewModel.account.client, - ) - accountViewModel.account.consumePostEvent(event, relays, extras) + val enqueued = + powDifficulty != null && + accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + broadcastArticle(mined) + } + if (!enqueued) { + if (accountViewModel.settings.useTrackedBroadcasts()) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, emptyList()) + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } + } else { + accountViewModel.account.signAndComputeBroadcast(template, emptyList()) } - } else { - accountViewModel.account.signAndComputeBroadcast(template, emptyList()) } accountViewModel.launchSigner { @@ -372,6 +380,25 @@ class LongFormPostViewModel : } } + /** + * The post-mining continuation: same tracked/untracked split as the direct + * path, but running on the mining queue's scope — the composer's + * viewModelScope may already be gone by the time the nonce is found. + */ + private suspend fun broadcastArticle(template: EventTemplate) { + if (accountViewModel.settings.useTrackedBroadcasts()) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, emptyList()) + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } else { + accountViewModel.account.signAndComputeBroadcast(template, emptyList()) + } + } + suspend fun sendDraftSync() { val text = message.text.toString() if ((text.isBlank() || text.trim() == appliedSignature) && title.text.isBlank()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index c0b698c19b..bf79d262e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField +import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker @@ -868,6 +869,13 @@ private fun BottomRowActions( postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + PowOverrideButton( + effectiveDifficulty = postViewModel.effectivePowDifficulty(), + defaultDifficulty = postViewModel.defaultPowDifficulty(), + isOverridden = postViewModel.powOverride != null, + onSelect = { postViewModel.powOverride = it }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index f623c7f489..31ff06f0c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -116,6 +116,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds @@ -381,6 +382,29 @@ open class ShortNotePostViewModel : // Null = post immediately on Send (existing behavior). var scheduledForSec by mutableStateOf(null) + // NIP-13 per-post override from the composer chip: null = follow account + // settings, 0 = don't mine this post, >0 = mine at that difficulty. + var powOverride by mutableStateOf(null) + + // Best guess of the kind createTemplate() will produce, for the PoW chip. + private fun anticipatedPowKind(): Int = + when { + wantsPoll -> PollEvent.KIND + wantsZapPoll -> ZapPollEvent.KIND + voiceRecording != null -> VoiceEvent.KIND + else -> TextNoteEvent.KIND + } + + fun effectivePowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(anticipatedPowKind(), powOverride) + } + + fun defaultPowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(anticipatedPowKind()) + } + // AI Writing Help for testing private val useMockAi = false @@ -956,12 +980,20 @@ open class ShortNotePostViewModel : val scheduledFor = scheduledForSec val privately = wantsPrivateNote val threadTarget = groupThreadTarget + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, powOverride) cancel() if (threadTarget != null) { // NIP-29 group thread: publish only to the group's host relay, never the account's // outbox — bypass the private/scheduled/anonymous paths entirely. - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays } + val enqueued = + powDifficulty != null && + accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(mined) { threadTarget.relays } + } + if (!enqueued) { + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays } + } accountViewModel.launchSigner { accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } @@ -1014,23 +1046,43 @@ open class ShortNotePostViewModel : } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) - } else if (accountViewModel.settings.useTrackedBroadcasts()) { - // Tracked broadcasting with progress feedback (non-blocking) - val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) - - // Launch broadcast in background - don't wait for completion - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.broadcastTracker.trackBroadcast( - event = event, - relays = relays, - client = accountViewModel.account.client, - ) - accountViewModel.account.consumePostEvent(event, relays, extras) + // The anonymous key signs without a client tag, so the template is + // mined as-is against the throwaway pubkey. + val anonSigner = anonymousSigner() + val enqueued = + powDifficulty != null && + accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> + val mined = PoWMiner.run(template, anonSigner.pubKey, powDifficulty, isActive) + accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) + } + if (!enqueued) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonSigner) } } else { - // Fire-and-forget (original behavior) - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + val enqueued = + powDifficulty != null && + accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + broadcastPublicPost(mined, extraNotesToBroadcast) + } + if (!enqueued) { + if (accountViewModel.settings.useTrackedBroadcasts()) { + // Tracked broadcasting with progress feedback (non-blocking) + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + + // Launch broadcast in background - don't wait for completion + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } + } else { + // Fire-and-forget (original behavior) + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + } + } } accountViewModel.launchSigner { @@ -1038,6 +1090,28 @@ open class ShortNotePostViewModel : } } + /** + * The post-mining continuation: same tracked/untracked split as the direct + * path, but running on the mining queue's scope — the composer's + * viewModelScope may already be gone by the time the nonce is found. + */ + private suspend fun broadcastPublicPost( + template: EventTemplate, + extraNotesToBroadcast: List, + ) { + if (accountViewModel.settings.useTrackedBroadcasts()) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } else { + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + } + } + suspend fun sendDraftSync() { val text = message.text.toString() if (text.isBlank() || text.trim() == appliedSignature) { @@ -1451,6 +1525,7 @@ open class ShortNotePostViewModel : wantsAnonymousPost = false anonymousSignerCache = null scheduledForSec = null + powOverride = null wantsPrivateNote = false privateNoteLocked = false wantsToAddNotifyUser = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index 33bd6417e0..575426e678 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -29,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,10 +44,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.model.AccountPoWPreferences import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.creators.pow.POW_PRESETS import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -115,9 +121,123 @@ fun ComposeSettingsContent( SettingsDivider() SignatureTile(sharedPrefs.composeSignature) } + + SettingsSection(R.string.pow_settings_title) { + PowDifficultyTile(accountViewModel) + SettingsDivider() + PowCategoryChecklist(accountViewModel) + } } } +@Composable +private fun PowDifficultyTile(accountViewModel: AccountViewModel) { + val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork + .difficulty + .collectAsStateWithLifecycle() + + SettingsBlockTile( + icon = MaterialSymbols.Bolt, + title = stringRes(R.string.pow_difficulty_title), + description = stringRes(R.string.pow_difficulty_explainer), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = difficulty <= 0, + onClick = { accountViewModel.updatePowDifficulty(0) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = POW_PRESETS.size + 1), + ) { + Text(stringRes(R.string.pow_difficulty_off)) + } + POW_PRESETS.forEachIndexed { index, preset -> + SegmentedButton( + selected = difficulty == preset, + onClick = { accountViewModel.updatePowDifficulty(preset) }, + shape = SegmentedButtonDefaults.itemShape(index = index + 1, count = POW_PRESETS.size + 1), + ) { + Text(preset.toString()) + } + } + } + } + + SettingsSubControlRow( + title = stringRes(R.string.pow_custom_difficulty_title), + description = stringRes(R.string.pow_custom_difficulty_explainer), + ) { + SettingsStepper( + value = difficulty, + min = 0, + max = AccountPoWPreferences.MAX_POW_DIFFICULTY, + unsetLabel = stringRes(R.string.pow_difficulty_off), + onValueChange = accountViewModel::updatePowDifficulty, + ) + } +} + +@Composable +private fun PowCategoryChecklist(accountViewModel: AccountViewModel) { + val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork + .difficulty + .collectAsStateWithLifecycle() + val enabledCategories by accountViewModel.account.settings.syncedSettings.proofOfWork + .enabledCategories + .collectAsStateWithLifecycle() + + val miningOn = difficulty > 0 + + SettingsControlRow( + icon = MaterialSymbols.Checklist, + title = stringRes(R.string.pow_categories_title), + description = stringRes(R.string.pow_categories_explainer), + ) {} + + PoWCategory.entries.forEach { category -> + val checked = category in enabledCategories + SettingsSubControlRow( + title = stringRes(category.titleRes()), + description = stringRes(category.descriptionRes()), + enabled = miningOn, + ) { + Switch( + checked = checked, + enabled = miningOn, + onCheckedChange = { accountViewModel.updatePowCategory(category, it) }, + ) + } + } +} + +@StringRes +private fun PoWCategory.titleRes(): Int = + when (this) { + PoWCategory.SHORT_NOTES -> R.string.pow_category_short_notes + PoWCategory.COMMENTS -> R.string.pow_category_comments + PoWCategory.REPORTS -> R.string.pow_category_reports + PoWCategory.LONG_FORM -> R.string.pow_category_long_form + PoWCategory.VOICE -> R.string.pow_category_voice + PoWCategory.REPOSTS -> R.string.pow_category_reposts + PoWCategory.REACTIONS -> R.string.pow_category_reactions + PoWCategory.PUBLIC_CHAT -> R.string.pow_category_public_chat + PoWCategory.GIFT_WRAPS -> R.string.pow_category_gift_wraps + PoWCategory.OTHER_PUBLIC -> R.string.pow_category_other_public + } + +@StringRes +private fun PoWCategory.descriptionRes(): Int = + when (this) { + PoWCategory.SHORT_NOTES -> R.string.pow_category_short_notes_explainer + PoWCategory.COMMENTS -> R.string.pow_category_comments_explainer + PoWCategory.REPORTS -> R.string.pow_category_reports_explainer + PoWCategory.LONG_FORM -> R.string.pow_category_long_form_explainer + PoWCategory.VOICE -> R.string.pow_category_voice_explainer + PoWCategory.REPOSTS -> R.string.pow_category_reposts_explainer + PoWCategory.REACTIONS -> R.string.pow_category_reactions_explainer + PoWCategory.PUBLIC_CHAT -> R.string.pow_category_public_chat_explainer + PoWCategory.GIFT_WRAPS -> R.string.pow_category_gift_wraps_explainer + PoWCategory.OTHER_PUBLIC -> R.string.pow_category_other_public_explainer + } + @Composable private fun SignatureTile(flow: MutableStateFlow) { val value by flow.collectAsState() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f275053e00..2e786d2591 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1782,7 +1782,7 @@ opentimestamps, timestamp, ots, proof namecoin, dns, identity, name calendar, events, reminders, rsvp - draft, posting, editor, auto-save, signature + draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13 emoji, reactions, like navigation, tabs, nav bar tabs, feeds, threads, conversations @@ -3735,6 +3735,44 @@ Signature Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable. Your signature + Proof of Work + Difficulty + Mines a NIP-13 proof of work into your posts before publishing so relays and readers can weigh them against spam. Higher values take exponentially longer to mine. Posts publish in the background once mined. + Off + Custom difficulty + Fine-tune the target in leading zero bits. + What to mine + Time-critical events (relay auth, zap requests, wallet and signer messages, drafts, lists) are never mined. + Short notes & replies + The primary public spam surface + Comments + Replies to articles, files and other content + Reports + Relays weigh reports by their cost + Long-form & highlights + Articles and highlights; infrequent, cost is negligible + Voice messages + Public voice posts and replies + Reposts + High volume for active users + Reactions + Highest-volume kind; mining every like costs battery + Public & live chat + Mining delays hurt conversation flow + Private message wraps + Lets DM relays filter inbox spam; only the outer wrap is mined, never your message + Other public content + Polls, live statuses, classifieds and everything else public + Mining proof of work + Mining proof of work… (%1$d in queue) + %1$s • mining at %2$s bits + %1$s • waiting to mine at %2$s bits + PoW %1$d + PoW off + Default (%1$d bits) + Default (off) + Off for this post + %1$d bits Use This Dismiss Correct diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt new file mode 100644 index 0000000000..a9c7a7c541 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt @@ -0,0 +1,148 @@ +/* + * 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.commons.service.pow + +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +/** + * User-facing kind groups for the NIP-13 proof-of-work settings checklist. + * Users toggle categories, never raw kind numbers. + */ +enum class PoWCategory( + val id: String, + val defaultEnabled: Boolean, +) { + SHORT_NOTES("short_notes", true), + COMMENTS("comments", true), + REPORTS("reports", true), + LONG_FORM("long_form", true), + VOICE("voice", true), + REPOSTS("reposts", false), + REACTIONS("reactions", false), + PUBLIC_CHAT("public_chat", false), + GIFT_WRAPS("gift_wraps", false), + OTHER_PUBLIC("other_public", false), + ; + + companion object { + val DEFAULT_ENABLED = entries.filter { it.defaultEnabled }.toSet() + + fun fromIds(ids: Collection): Set = entries.filter { it.id in ids }.toSet() + } +} + +/** + * Decides which events get a NIP-13 proof of work mined into them before signing. + * + * The NEVER rules are hardcoded on purpose (not user preferences) so no caller + * can accidentally mine a relay AUTH challenge, a zap request that blocks an + * invoice fetch, an NWC/bunker RPC, or the drafts that are re-signed on every + * keystroke debounce. + */ +object PoWPolicy { + private const val DRAFT_WRAP_KIND = 31234 // quartz's DraftWrapEvent (NIP-37) + private const val LONG_FORM_DRAFT_KIND = 30024 + + /** NIP-51 sets and other settings-like addressable kinds. */ + private val NEVER_ADDRESSABLE = + setOf( + 30000, // follow sets + 30001, // deprecated generic lists + 30002, // relay sets + 30003, // bookmark sets + 30004, // curation sets (articles) + 30005, // curation sets (videos) + 30007, // kind mute sets + 30015, // interest sets + 30030, // emoji sets + 30063, // release artifact sets + LONG_FORM_DRAFT_KIND, + AppSpecificDataEvent.KIND, + ) + + private val NEVER_EXPLICIT = + setOf( + 0, // metadata + 3, // contact list + LnZapRequestEvent.KIND, // blocks the invoice fetch + OtsEvent.KIND, // machine-generated companion events + DRAFT_WRAP_KIND, // re-signed on a 1s debounce while typing + ) + + /** + * Kinds that must never be mined regardless of user settings. + * + * The replaceable range (10000..19999) covers relay lists, NIP-51 standard + * lists, NWC info and other settings sync; the ephemeral range + * (20000..29999) covers relay AUTH (22242), NWC RPC (23194..23196), NIP-46 + * bunker messages (24133), Blossom auth (24242) and HTTP auth (27235) — + * all time-critical request/response events where mining only adds latency. + */ + fun neverMine(kind: Int): Boolean = + kind in NEVER_EXPLICIT || + kind in 10000..19999 || + kind in 20000..29999 || + kind in NEVER_ADDRESSABLE + + fun categoryOf(kind: Int): PoWCategory = + when (kind) { + TextNoteEvent.KIND -> PoWCategory.SHORT_NOTES + CommentEvent.KIND -> PoWCategory.COMMENTS + ReportEvent.KIND -> PoWCategory.REPORTS + LongTextNoteEvent.KIND, HighlightEvent.KIND -> PoWCategory.LONG_FORM + VoiceEvent.KIND, VoiceReplyEvent.KIND -> PoWCategory.VOICE + RepostEvent.KIND, GenericRepostEvent.KIND -> PoWCategory.REPOSTS + ReactionEvent.KIND -> PoWCategory.REACTIONS + ChannelMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND -> PoWCategory.PUBLIC_CHAT + GiftWrapEvent.KIND -> PoWCategory.GIFT_WRAPS + else -> PoWCategory.OTHER_PUBLIC + } + + /** + * Returns the difficulty to mine [kind] at, or null when the event should + * be published without proof of work. + */ + fun shouldMine( + kind: Int, + difficulty: Int, + enabledCategories: Set, + ): Int? { + if (difficulty <= 0) return null + if (neverMine(kind)) return null + if (categoryOf(kind) !in enabledCategories) return null + return difficulty + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt new file mode 100644 index 0000000000..d04abaffed --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -0,0 +1,192 @@ +/* + * 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.commons.service.pow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile +import kotlin.coroutines.cancellation.CancellationException + +/** + * Snapshot of one queued/mining publish job, for display in the broadcast banner. + */ +@Immutable +data class PoWJobState( + val id: String, + val kind: Int, + val difficulty: Int, + val isMining: Boolean, +) + +/** + * Fire-and-forget NIP-13 mining queue: the Post button enqueues the finished, + * unsigned template here and returns immediately; mining runs on a small + * worker pool and, once the nonce is found, the job hands the mined template + * to the normal sign+broadcast continuation captured at enqueue time. + * + * FIFO with at most [maxConcurrent] concurrent miners so a burst of posts + * queues up instead of spawning unbounded CPU work. Each job is cancellable + * while queued or mining. + * + * The queue is in-memory only: if the process dies, still-unmined posts are + * lost (v1 trade-off; every enqueue/finish is logged for post-mortems). + */ +class PoWPublishQueue( + private val scope: CoroutineScope, + maxConcurrent: Int = 1, + miningDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { + private class MiningJob( + val id: String, + val kind: Int, + val difficulty: Int, + val work: suspend (isActive: () -> Boolean) -> Unit, + ) { + @Volatile + var cancelled = false + } + + private val queue = Channel(UNLIMITED) + + private val _jobs = MutableStateFlow>(persistentListOf()) + + /** Queued + currently-mining jobs, in enqueue order. */ + val jobs: StateFlow> = _jobs.asStateFlow() + + init { + repeat(maxConcurrent.coerceAtLeast(1)) { + scope.launch(miningDispatcher) { + for (job in queue) { + process(job) + } + } + } + } + + /** + * Mines [template] at [difficulty] and hands the mined template to + * [onMined] on the queue's scope. [onMined] should run the exact + * sign+broadcast path the caller would have used without PoW. + */ + fun enqueue( + template: EventTemplate, + pubKey: HexKey, + difficulty: Int, + onMined: suspend (EventTemplate) -> Unit, + ) = enqueueWork(template.kind, difficulty) { isActive -> + val mined = PoWMiner.run(template, pubKey, difficulty, isActive) + // frees the mining worker: signing may wait on an external signer + // (Amber/bunker) and broadcasting is IO, neither belongs on the pool. + scope.launch { onMined(mined) } + } + + /** + * Enqueues arbitrary mining work — used by flows where the mining happens + * inside a larger build step (e.g. gift wraps, where each recipient's + * ephemeral-key wrap is mined right before its local signature). + */ + fun enqueueWork( + kind: Int, + difficulty: Int, + work: suspend (isActive: () -> Boolean) -> Unit, + ) { + val job = MiningJob(RandomInstance.randomChars(16), kind, difficulty, work) + pending.update { it.put(job.id, job) } + _jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty, isMining = false)).toImmutableList() } + Log.d(TAG) { "Enqueued PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty} (in-memory queue, lost on process death)" } + queue.trySend(job) + } + + /** Cancels a queued or mining job. No-op if the job already finished. */ + fun cancel(jobId: String) { + val job = pending.value[jobId] ?: return + job.cancelled = true + remove(jobId) + Log.d(TAG) { "Cancelled PoW job $jobId" } + } + + // Jobs the workers haven't finished yet, so cancel() can reach the flag of + // a job that is still sitting in the channel. StateFlow.update gives us + // atomic CAS updates across the UI thread and the mining workers. + private val pending = MutableStateFlow>(persistentMapOf()) + + private suspend fun process(job: MiningJob) { + if (job.cancelled) { + remove(job.id) + return + } + + markMining(job.id) + + val workerJob = currentCoroutineContext().job + + try { + job.work { !job.cancelled && workerJob.isActive } + Log.d(TAG) { "Finished PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty}" } + } catch (e: CancellationException) { + // the worker itself was cancelled (scope teardown): propagate. + if (!currentCoroutineContext().isActive) throw e + Log.d(TAG) { "PoW job ${job.id} cancelled while mining" } + } catch (e: Exception) { + Log.w(TAG, "PoW job ${job.id} kind=${job.kind} failed", e) + } finally { + remove(job.id) + } + } + + private fun markMining(jobId: String) { + _jobs.update { list -> + list.map { if (it.id == jobId) it.copy(isMining = true) else it }.toImmutableList() + } + } + + private fun remove(jobId: String) { + pending.update { it.remove(jobId) } + _jobs.update { list -> list.filter { it.id != jobId }.toImmutableList() } + } + + companion object { + private const val TAG = "PoWPublishQueue" + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt new file mode 100644 index 0000000000..d1822331fa --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt @@ -0,0 +1,112 @@ +/* + * 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.commons.service.pow + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PoWPolicyTest { + val allCategories = PoWCategory.entries.toSet() + + @Test + fun difficultyOffMinesNothing() { + assertNull(PoWPolicy.shouldMine(1, 0, allCategories)) + assertNull(PoWPolicy.shouldMine(1, -5, allCategories)) + } + + @Test + fun defaultCategoriesMinePrimarySpamSurfaces() { + val defaults = PoWCategory.DEFAULT_ENABLED + // ON by default + assertEquals(20, PoWPolicy.shouldMine(1, 20, defaults)) + assertEquals(20, PoWPolicy.shouldMine(1111, 20, defaults)) + assertEquals(20, PoWPolicy.shouldMine(1984, 20, defaults)) + assertEquals(20, PoWPolicy.shouldMine(30023, 20, defaults)) + assertEquals(20, PoWPolicy.shouldMine(9802, 20, defaults)) + assertEquals(20, PoWPolicy.shouldMine(1222, 20, defaults)) + assertEquals(20, PoWPolicy.shouldMine(1244, 20, defaults)) + // OFF by default (opt-in toggles) + assertNull(PoWPolicy.shouldMine(6, 20, defaults)) + assertNull(PoWPolicy.shouldMine(16, 20, defaults)) + assertNull(PoWPolicy.shouldMine(7, 20, defaults)) + assertNull(PoWPolicy.shouldMine(42, 20, defaults)) + assertNull(PoWPolicy.shouldMine(1311, 20, defaults)) + assertNull(PoWPolicy.shouldMine(1059, 20, defaults)) + assertNull(PoWPolicy.shouldMine(1068, 20, defaults)) + } + + @Test + fun optInCategoriesMineWhenEnabled() { + assertEquals(16, PoWPolicy.shouldMine(7, 16, allCategories)) + assertEquals(16, PoWPolicy.shouldMine(6, 16, allCategories)) + assertEquals(16, PoWPolicy.shouldMine(1059, 16, allCategories)) + assertEquals(16, PoWPolicy.shouldMine(42, 16, allCategories)) + // long tail routes through OTHER_PUBLIC + assertEquals(16, PoWPolicy.shouldMine(1068, 16, allCategories)) + assertEquals(16, PoWPolicy.shouldMine(30315, 16, allCategories)) + } + + @Test + fun neverListWinsOverEverySetting() { + val neverKinds = + listOf( + 0, // metadata + 3, // contact list + 9734, // zap request + 1040, // OTS attestation + 31234, // NIP-37 draft wrap + 30024, // long-form draft + 22242, // relay auth + 13194, // NWC info + 23194, // NWC request + 23195, // NWC response + 23196, // NWC notification + 24133, // NIP-46 bunker + 27235, // HTTP auth + 24242, // Blossom auth + 10002, // relay list + 10000, // mute list + 30000, // follow sets + 30078, // app-specific data + ) + + neverKinds.forEach { kind -> + assertNull(PoWPolicy.shouldMine(kind, 28, allCategories), "kind $kind must never be mined") + assertTrue(PoWPolicy.neverMine(kind), "kind $kind must be in the NEVER list") + } + } + + @Test + fun minedContentIsNotInTheNeverList() { + listOf(1, 1111, 1984, 30023, 9802, 1222, 1244, 6, 16, 7, 42, 1311, 1059, 1068).forEach { kind -> + assertTrue(!PoWPolicy.neverMine(kind), "kind $kind must be minable") + } + } + + @Test + fun categoryIdsRoundTrip() { + val ids = PoWCategory.entries.map { it.id } + assertEquals(PoWCategory.entries.toSet(), PoWCategory.fromIds(ids)) + assertEquals(emptySet(), PoWCategory.fromIds(listOf("bogus"))) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt new file mode 100644 index 0000000000..40141ce6a6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt @@ -0,0 +1,118 @@ +/* + * 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.commons.service.pow + +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PoWPublishQueueTest { + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val template = + EventTemplate( + 1683596206, + TextNoteEvent.KIND, + emptyArray(), + "A note to mine", + ) + + @Test + fun minesTemplateThenRunsContinuation() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + val mined = CompletableDeferred>() + + queue.enqueue(template, pubKey, difficulty = 10) { mined.complete(it) } + + val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } } + + val powTag = result.tags.firstNotNullOfOrNull { PoWTag.parse(it) } + assertNotNull(powTag, "mined template must carry a nonce tag") + assertEquals(10, powTag.commitment) + + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + scope.cancel() + } + + @Test + fun cancellingAQueuedJobSkipsItsWork() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + + val gate = CompletableDeferred() + var secondRan = false + + // occupies the single worker until the gate opens + queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() } + queue.enqueueWork(kind = 1, difficulty = 10) { secondRan = true } + + val jobs = queue.jobs.value + assertEquals(2, jobs.size) + assertTrue(jobs.map { it.kind }.all { it == 1 }) + + queue.cancel(jobs[1].id) + assertEquals(1, queue.jobs.value.size, "cancelled job leaves the visible queue immediately") + + gate.complete(Unit) + + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + assertFalse(secondRan, "cancelled job must never run") + scope.cancel() + } + + @Test + fun jobsRunInFifoOrder() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + + val order = mutableListOf() + val done = CompletableDeferred() + + repeat(3) { index -> + queue.enqueueWork(kind = 1, difficulty = 10) { + order.add(index) + if (index == 2) done.complete(Unit) + } + } + + withContext(Dispatchers.Default) { withTimeout(10_000) { done.await() } } + assertEquals(listOf(0, 1, 2), order) + scope.cancel() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt index 782e68e6d6..d4f0592364 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt @@ -26,10 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.coroutines.cancellation.CancellationException class PoWMiner( val buffer: MiningBuffer, val desiredPoW: Int, + val isActive: () -> Boolean = { true }, ) { val emptyBytesForDesiredPoW = desiredPoW / 8 @@ -38,6 +40,12 @@ class PoWMiner( fun run() = runDigit(buffer.nonceStarts) private fun runDigit(index: Int): Boolean { + // checks once every VALID_BYTES.size^2 hashes: cheap enough to not slow + // mining down, frequent enough for cancellation to feel immediate. + if (index + 2 <= buffer.nonceEnds && !isActive()) { + throw CancellationException("PoW mining was cancelled") + } + for (testByte in VALID_BYTES) { // replaces the background base by the nonce integers buffer.bytes[index] = testByte @@ -65,11 +73,15 @@ class PoWMiner( /** * The miner creates a stringified json template and changes the nonce directly in the UTF-8 ByteArray representation * to avoid having to recompute the json objects and stringify it. + * + * [isActive] is polled while mining; returning false aborts the search with a + * [CancellationException] so callers can cancel long-running jobs cooperatively. */ fun run( template: EventTemplate, pubKey: HexKey, desiredPoW: Int, + isActive: () -> Boolean = { true }, ): EventTemplate { var nextSize = STARTING_NONCE_SIZE @@ -90,7 +102,7 @@ class PoWMiner( val buffer = MiningBuffer(bytes, startIndex, startIndex + nextSize) - if (PoWMiner(buffer, desiredPoW).run()) { + if (PoWMiner(buffer, desiredPoW, isActive).run()) { return EventTemplate( template.createdAt, template.kind, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt new file mode 100644 index 0000000000..b114259885 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt @@ -0,0 +1,96 @@ +/* + * 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.nip13Pow.signer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +/** + * A [NostrSigner] decorator that mines a NIP-13 proof of work into the unsigned + * template before delegating to the wrapped signer. Because mining happens on the + * pre-signature template it composes with every signer kind (local key, NIP-55 + * external app, NIP-46 bunker). + * + * Only events whose kind is in [kindsToMine] are mined; anything else (e.g. the + * seal and rumor of a gift-wrapped flow) passes through untouched. Templates that + * already carry a nonce tag are not mined again. + */ +class PoWNostrSigner( + val signer: NostrSigner, + val desiredPoW: Int, + val kindsToMine: Set, + val isActive: () -> Boolean = { true }, +) : NostrSigner(signer.pubKey) { + override fun isWriteable(): Boolean = signer.isWriteable() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = + if (kind in kindsToMine && tags.none { PoWTag.hasTagWithContent(it) }) { + val mined = + PoWMiner.run( + template = EventTemplate(createdAt, kind, tags, content), + pubKey = pubKey, + desiredPoW = desiredPoW, + isActive = isActive, + ) + signer.sign(mined.createdAt, mined.kind, mined.tags, mined.content) + } else { + signer.sign(createdAt, kind, tags, content) + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = signer.nip04Encrypt(plaintext, toPublicKey) + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = signer.nip04Decrypt(ciphertext, fromPublicKey) + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = signer.nip44Encrypt(plaintext, toPublicKey) + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = signer.nip44Decrypt(ciphertext, fromPublicKey) + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = signer.decryptZapEvent(event) + + override suspend fun deriveKey(nonce: HexKey): HexKey = signer.deriveKey(nonce) + + override suspend fun signPsbt(psbtHex: String): String = signer.signPsbt(psbtHex) + + override fun hasForegroundSupport(): Boolean = signer.hasForegroundSupport() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index ceb97f6678..19a3204209 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -75,6 +75,8 @@ class NIP17Factory { to: Set, signer: NostrSigner, recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, + wrapPowDifficulty: Int? = null, + wrapPowIsActive: () -> Boolean = { true }, ): List { val innerExpDelta = event.expiration()?.let { @@ -102,6 +104,8 @@ class NIP17Factory { recipientPubKey = next, expirationDelta = innerExpDelta, recipientRelayHint = recipientRelayHints(next), + powDifficulty = wrapPowDifficulty, + powIsActive = wrapPowIsActive, ) } bunkerLimiter?.withPermit { build() } ?: build() @@ -123,9 +127,11 @@ class NIP17Factory { template: EventTemplate, signer: NostrSigner, recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, + wrapPowDifficulty: Int? = null, + wrapPowIsActive: () -> Boolean = { true }, ): Result { val senderMessage = signer.sign(template) - val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints, wrapPowDifficulty, wrapPowIsActive) return Result( msg = senderMessage, wraps = wraps, @@ -142,9 +148,18 @@ class NIP17Factory { suspend fun createNoteNIP17( template: EventTemplate, signer: NostrSigner, + wrapPowDifficulty: Int? = null, + wrapPowIsActive: () -> Boolean = { true }, ): Result { val senderNote = signer.sign(template) - val wraps = createWraps(senderNote, senderNote.taggedUserIds().plus(signer.pubKey).toSet(), signer) + val wraps = + createWraps( + senderNote, + senderNote.taggedUserIds().plus(signer.pubKey).toSet(), + signer, + wrapPowDifficulty = wrapPowDifficulty, + wrapPowIsActive = wrapPowIsActive, + ) return Result( msg = senderNote, wraps = wraps, @@ -155,9 +170,11 @@ class NIP17Factory { template: EventTemplate, signer: NostrSigner, recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, + wrapPowDifficulty: Int? = null, + wrapPowIsActive: () -> Boolean = { true }, ): Result { val senderMessage = signer.sign(template) - val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints, wrapPowDifficulty, wrapPowIsActive) return Result( msg = senderMessage, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 881e344cd2..017a4e7f2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent @@ -106,6 +108,11 @@ open class GiftWrapEvent( * the wrap without a separate kind:10050 lookup. Pass it via * [recipientRelayHint] — `null` (the default) preserves the * historical 2-element `["p", pubkey]` shape. + * + * [powDifficulty] mines a NIP-13 proof of work into the wrap itself + * (the ephemeral-key envelope, never the inner seal or rumor) so DM + * relays can PoW-filter inbox spam. [powIsActive] is the cooperative + * cancellation hook forwarded to the miner. */ fun create( event: Event, @@ -113,6 +120,8 @@ open class GiftWrapEvent( expirationDelta: Long? = null, createdAt: Long = TimeUtils.randomWithTwoDays(), recipientRelayHint: NormalizedRelayUrl? = null, + powDifficulty: Int? = null, + powIsActive: () -> Boolean = { true }, ): GiftWrapEvent { val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key @@ -128,11 +137,26 @@ open class GiftWrapEvent( PTag.assemble(recipientPubKey, recipientRelayHint), ) + val template = + EventTemplate( + createdAt = createdAt, + kind = KIND, + tags = tags, + content = signer.nip44Encrypt(event.toJson(), recipientPubKey), + ) + + val readyToSign = + if (powDifficulty != null && powDifficulty > 0) { + PoWMiner.run(template, signer.pubKey, powDifficulty, powIsActive) + } else { + template + } + return signer.sign( - createdAt = createdAt, - kind = KIND, - tags = tags, - content = signer.nip44Encrypt(event.toJson(), recipientPubKey), + createdAt = readyToSign.createdAt, + kind = readyToSign.kind, + tags = readyToSign.tags, + content = readyToSign.content, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt index 2da2479daa..b39c69843b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -102,6 +102,14 @@ class NostrSignerWithClientTag( override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() + /** + * The exact tag set [sign] will forward to the inner signer. Callers that + * transform the template before signing (e.g. NIP-13 mining, which commits + * the tags into the hashed id) must mine over this final shape, otherwise + * the client tag appended at sign time would invalidate the nonce. + */ + fun prepareTags(tags: Array>): Array> = if (disabled()) tags else appendClientTag(tags) + private fun appendClientTag(tags: Array>): Array> { // Don't add if a client tag already exists if (tags.any { it.size >= 2 && it[0] == ClientTag.TAG_NAME }) return tags diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt new file mode 100644 index 0000000000..d12aaf5a76 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt @@ -0,0 +1,83 @@ +/* + * 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.nip13Pow + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PoWMinerCancellationTest { + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val baseTemplate = + EventTemplate( + 1683596206, + TextNoteEvent.KIND, + emptyArray(), + "A note to mine", + ) + + @Test + fun cancellationAbortsAnImpossibleSearch() { + var polls = 0 + // 256 bits of PoW never completes; only the isActive check can end the run. + assertFailsWith { + PoWMiner.run(baseTemplate, pubKey, 256) { + polls++ < 3 + } + } + assertTrue(polls in 4..10, "expected the miner to stop right after isActive flipped, polled $polls times") + } + + @Test + fun activeMinerStillFindsPoW() { + val desiredPoW = 12 + val mined = PoWMiner.run(baseTemplate, pubKey, desiredPoW) { true } + + val powTag = mined.tags.firstNotNullOfOrNull { PoWTag.parse(it) } + assertNotNull(powTag, "mined template must carry a nonce tag") + + val id = + sha256( + EventHasherSerializer.fastMakeJsonForId( + pubKey = pubKey, + createdAt = mined.createdAt, + kind = mined.kind, + tags = mined.tags, + content = mined.content, + ), + ) + + assertTrue( + PoWRankEvaluator.atLeastPowRank(id, desiredPoW, desiredPoW / 8), + "mined id must reach the desired PoW", + ) + } +} From ac5223b956e1b78a583eff4814b199cbfff0044a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:31:41 +0000 Subject: [PATCH 02/15] feat: mine scheduled posts through the PoW queue The scheduled branch re-stamps the template with created_at = the future publish time and the worker publishes the stored signed JSON verbatim, so a nonce mined at compose time commits to that future created_at and remains valid when the post goes out. The sign+store step moves into the mining continuation (storeScheduledPost), keeping the composer fire-and-forget for scheduled posts too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- .../loggedIn/home/ShortNotePostViewModel.kt | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 31ff06f0c9..f3e235b2e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1024,21 +1024,19 @@ open class ShortNotePostViewModel : tags = template.tags, content = template.content, ) - val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast) - Amethyst.instance.scheduledPostStore.add( - ScheduledPost( - id = - java.util.UUID - .randomUUID() - .toString(), - accountPubkey = event.pubKey, - signedEventJson = event.toJson(), - relayUrls = relays.map { it.url }, - extraEventsJson = extras.map { it.toJson() }, - publishAtSec = scheduledFor, - createdAtSec = System.currentTimeMillis() / 1000, - ), - ) + + // Mining commits the future created_at into the hashed id, and the + // worker publishes the stored signed JSON verbatim, so the nonce is + // still valid at publish time. + val enqueued = + powDifficulty != null && + accountViewModel.account.mineTemplateInBackground(rescheduledTemplate, powDifficulty) { mined -> + storeScheduledPost(mined, extraNotesToBroadcast, scheduledFor) + } + if (!enqueued) { + storeScheduledPost(rescheduledTemplate, extraNotesToBroadcast, scheduledFor) + } + accountViewModel.launchSigner { accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } @@ -1090,6 +1088,34 @@ open class ShortNotePostViewModel : } } + /** + * Signs the (possibly mined) re-stamped template and parks it in the + * scheduled-post store for the worker to publish at its created_at time. + * Runs on the mining queue's scope when PoW is on, so it must not touch + * viewModelScope. + */ + private suspend fun storeScheduledPost( + template: EventTemplate, + extraNotesToBroadcast: List, + publishAtSec: Long, + ) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + Amethyst.instance.scheduledPostStore.add( + ScheduledPost( + id = + java.util.UUID + .randomUUID() + .toString(), + accountPubkey = event.pubKey, + signedEventJson = event.toJson(), + relayUrls = relays.map { it.url }, + extraEventsJson = extras.map { it.toJson() }, + publishAtSec = publishAtSec, + createdAtSec = System.currentTimeMillis() / 1000, + ), + ) + } + /** * The post-mining continuation: same tracked/untracked split as the direct * path, but running on the mining queue's scope — the composer's From 35647d0a5cad19c03a427ccbffbe34bb63083b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:08:51 +0000 Subject: [PATCH 03/15] feat: persist the PoW queue and shield it with a shortService foreground service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backgrounding the app no longer pauses or loses mining: - commons: PoWPublishQueue checkpoints template jobs through a PoWJobPersistence hook (saved on enqueue, removed on finish/cancel, deduped by id so restore is idempotent) and fires onQueueActive on every enqueue. PoWReplay flattens the live continuation into a PersistedPoWJob record (broadcast / to-relays / schedule) that can be replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift wraps) and anonymous posts stay in-memory on purpose — a throwaway key and its content must never touch disk. - amethyst: PowJobStore (atomic-rename JSON file, single-lane writer, 3-day staleness purge) and PowJobRestorer, which re-enqueues an account's checkpointed jobs on login and finishes them via the replay descriptor. Composers now pass the matching PoWReplay for the public, group-thread and scheduled paths. - PowMiningForegroundService: a shortService-type FGS started on every enqueue (~3 min guaranteed budget, no special permission) that keeps the process out of the cached-apps freezer while mining. Stops itself when the queue drains; on onTimeout it exits cleanly since the jobs are checkpointed. The notification is a live progress card (NotificationCompat.ProgressStyle): one track segment per post filling as jobs finish, indeterminate for a single post, per-kind text, a cancel-all action, and Live Updates rendering on Android 16+. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- amethyst/src/main/AndroidManifest.xml | 9 + .../com/vitorpamplona/amethyst/AppModules.kt | 25 ++ .../vitorpamplona/amethyst/model/Account.kt | 12 +- .../amethyst/service/pow/PowJobRestorer.kt | 125 ++++++++ .../amethyst/service/pow/PowJobStore.kt | 153 ++++++++++ .../service/pow/PowMiningForegroundService.kt | 267 ++++++++++++++++++ .../nip22Comments/CommentPostViewModel.kt | 5 +- .../nip23LongForm/LongFormPostViewModel.kt | 3 +- .../loggedIn/home/ShortNotePostViewModel.kt | 11 +- amethyst/src/main/res/values/strings.xml | 5 +- .../commons/service/pow/PoWJobPersistence.kt | 64 +++++ .../commons/service/pow/PoWPublishQueue.kt | 62 +++- .../amethyst/commons/service/pow/PoWReplay.kt | 95 +++++++ .../service/pow/PoWPublishQueueTest.kt | 80 ++++++ 14 files changed, 900 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWReplay.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 72bdeefe46..e8035be81f 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -346,6 +346,15 @@ android:stopWithTask="true" android:exported="false" /> + + + + if (state is AccountState.LoggedIn) { + powJobRestorer.restore(state.account) + } + } + } + // Evict the BlossomServerResolver URL cache whenever either local-cache // toggle flips or the probe transitions up/down so stale entries don't // outlive the underlying decision. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9b1aecdcca..3c79ccb9e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -293,6 +294,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope @@ -838,6 +840,11 @@ class Account( * [onMined], which should run the exact sign+send path the caller would * have used without PoW. Returns false when no queue is wired. * + * When [replay] is given the job is checkpointed to disk so it survives + * process death: on the next login the restorer re-mines the persisted + * template and finishes it with the (headless) replay path instead of + * [onMined]. Pass null for content that must not touch disk. + * * The template is normalized to the final tag shape the signer will submit * (client tag included) before mining — a tag appended after mining would * invalidate the nonce. @@ -845,10 +852,13 @@ class Account( fun mineTemplateInBackground( template: EventTemplate, difficulty: Int, + replay: PoWReplay? = null, onMined: suspend (EventTemplate) -> Unit, ): Boolean { val queue = powQueue() ?: return false - queue.enqueue(withFinalSignerTags(template), signer.pubKey, difficulty, onMined) + val finalTemplate = withFinalSignerTags(template) + val record = replay?.toRecord(RandomInstance.randomChars(16), signer.pubKey, finalTemplate, difficulty) + queue.enqueue(finalTemplate, signer.pubKey, difficulty, record, onMined) return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt new file mode 100644 index 0000000000..db691d4920 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -0,0 +1,125 @@ +/* + * 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.service.pow + +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.utils.Log +import java.util.UUID + +/** + * Re-enqueues the mining jobs checkpointed by [PowJobStore] when an account + * logs in, replacing the lost in-memory continuation with the headless replay + * described by each record. Restore is idempotent: the queue dedupes by job + * id, so a login flow that emits twice cannot double-mine. + */ +class PowJobRestorer( + private val queue: PoWPublishQueue, + private val store: PowJobStore, + private val scheduledPostStore: ScheduledPostStore, +) { + suspend fun restore(account: Account) { + val records = store.listFor(account.signer.pubKey) + if (records.isEmpty()) return + + Log.d(TAG) { "Restoring ${records.size} pending PoW job(s) for ${account.signer.pubKey.take(8)}…" } + + records.forEach { record -> + val template = + try { + EventTemplate.fromJson(record.templateJson) + } catch (e: Exception) { + Log.w(TAG, "Dropping unreadable PoW job ${record.id}", e) + store.remove(record.id) + return@forEach + } + + if (record.difficulty <= 0) { + store.remove(record.id) + return@forEach + } + + queue.enqueue(template, account.signer.pubKey, record.difficulty, persistAs = record) { mined -> + replay(account, record, mined) + } + } + } + + private suspend fun replay( + account: Account, + record: PersistedPoWJob, + mined: EventTemplate, + ) { + val extras = + record.extraEventsJson.mapNotNull { + try { + Event.fromJson(it) + } catch (e: Exception) { + Log.w(TAG, "Dropping unreadable extra event of PoW job ${record.id}", e) + null + } + } + + when (record.replayType) { + PersistedPoWJob.REPLAY_BROADCAST -> { + account.signAndComputeBroadcast(mined, extras) + } + + PersistedPoWJob.REPLAY_RELAYS -> { + val relays = record.relayUrls.map { NormalizedRelayUrl(it) } + account.signAndSendPrivatelyOrBroadcast(mined) { relays } + } + + PersistedPoWJob.REPLAY_SCHEDULE -> { + val publishAtSec = record.publishAtSec + if (publishAtSec == null) { + Log.w(TAG) { "Scheduled PoW job ${record.id} has no publish time; broadcasting instead" } + account.signAndComputeBroadcast(mined, extras) + return + } + val (event, relays, extraList) = account.createPostEvent(mined, extras) + scheduledPostStore.add( + ScheduledPost( + id = UUID.randomUUID().toString(), + accountPubkey = event.pubKey, + signedEventJson = event.toJson(), + relayUrls = relays.map { it.url }, + extraEventsJson = extraList.map { it.toJson() }, + publishAtSec = publishAtSec, + createdAtSec = System.currentTimeMillis() / 1000, + ), + ) + } + + else -> Log.w(TAG) { "Unknown replay type '${record.replayType}' for PoW job ${record.id}; dropping" } + } + } + + companion object { + private const val TAG = "PowJobRestorer" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt new file mode 100644 index 0000000000..e20ee660d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt @@ -0,0 +1,153 @@ +/* + * 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.service.pow + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * On-disk checkpoint of pending PoW mining jobs so posts survive process + * death: the queue upserts on enqueue and removes on finish/cancel, and + * [PowJobRestorer] re-enqueues whatever is left when an account logs in. + * + * [save]/[remove] are the queue-facing fire-and-forget hooks; they serialize + * onto a single-lane dispatcher so writes land in call order. + */ +class PowJobStore( + private val storageFile: File, + scope: CoroutineScope, +) : PoWJobPersistence { + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + // one lane: launch order == execution order, so a save followed by its + // remove can never be applied backwards. + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + private val writeLane = Dispatchers.IO.limitedParallelism(1) + private val writeScope = CoroutineScope(scope.coroutineContext + writeLane) + + private val mutex = Mutex() + private var loaded = false + private var jobs: MutableList = mutableListOf() + + override fun save(job: PersistedPoWJob) { + writeScope.launch { + mutex.withLock { + ensureLoaded() + jobs.removeAll { it.id == job.id } + jobs.add(job) + persist() + } + } + } + + override fun remove(jobId: String) { + writeScope.launch { + mutex.withLock { + ensureLoaded() + if (jobs.removeAll { it.id == jobId }) persist() + } + } + } + + suspend fun listFor(accountPubkey: String): List = + withContext(writeLane) { + mutex.withLock { + ensureLoaded() + jobs.filter { it.accountPubkey == accountPubkey } + } + } + + /** Drops every record owned by [accountPubkey] (account deletion). */ + fun removeForAccount(accountPubkey: String) { + writeScope.launch { + mutex.withLock { + ensureLoaded() + if (jobs.removeAll { it.accountPubkey == accountPubkey }) persist() + } + } + } + + private fun ensureLoaded() { + if (loaded) return + jobs = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile).jobs.toMutableList() + } else { + mutableListOf() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load pending PoW jobs from $storageFile", e) + mutableListOf() + } + loaded = true + val cutoff = System.currentTimeMillis() / 1000 - MAX_AGE_SEC + if (jobs.removeAll { it.createdAtSec in 1 until cutoff }) persist() + } + + private fun persist() { + storageFile.parentFile?.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, PowJobsFile(version = 1, jobs = jobs.toList())) + if (!tmp.renameTo(storageFile)) { + if (!storageFile.delete() || !tmp.renameTo(storageFile)) { + Log.e(TAG) { "Failed to rename $tmp to $storageFile" } + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to persist pending PoW jobs to $storageFile", e) + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" } + } + } + } + + companion object { + private const val TAG = "PowJobStore" + const val FILE_NAME = "pending_pow_jobs.json" + + // a job this stale is a post the user has long forgotten; publishing + // it a week later would be more surprising than dropping it. + private const val MAX_AGE_SEC = 3L * 24 * 3600 + } +} + +data class PowJobsFile( + val version: Int = 1, + val jobs: List = emptyList(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt new file mode 100644 index 0000000000..a28e730463 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -0,0 +1,267 @@ +/* + * 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.service.pow + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Short-lived foreground service that shields the PoW mining queue from the + * cached-apps freezer: while it runs the process stays schedulable, so posts + * finish mining even after the user backgrounds the app. + * + * Uses the Android 14+ `shortService` type — no special permission, but a + * hard ~3 minute budget. On [onTimeout] the service exits cleanly; every + * persistable job is already checkpointed by [PowJobStore], so anything still + * unmined resumes on the next app launch. Started on every enqueue (the app + * is necessarily in the foreground then), stops itself when the queue drains. + * + * The notification is a live progress card ([NotificationCompat.ProgressStyle]): + * one track segment per post, filling as jobs complete, indeterminate while a + * single post mines, with a cancel-all action. On Android 16+ it renders as a + * Live Updates chip; older versions fall back to a standard progress bar. + */ +class PowMiningForegroundService : Service() { + private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private var watchJob: Job? = null + + // Session totals so the progress track can show "done / enqueued since the + // service started" — the queue itself only knows what is still pending. + private var sessionTotal = 0 + private var lastQueueSize = 0 + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + // Android's contract: every onStartCommand after startForegroundService + // must call startForeground promptly, even on the stop path. + runCatching { startForegroundCompat(currentJobs()) } + .onFailure { + Log.w(TAG, "startForeground failed; mining continues without the service", it) + stopSelf() + return START_NOT_STICKY + } + + if (intent?.action == ACTION_CANCEL_ALL) { + Amethyst.instance.powPublishQueue.cancelAll() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + + watchQueue() + return START_NOT_STICKY + } + + /** + * The shortService budget (~3 min) is exhausted. Exit before the system + * ANRs us: persisted jobs are checkpointed and resume on next launch; + * in-memory jobs keep mining opportunistically until the process freezes. + */ + override fun onTimeout(startId: Int) { + Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun currentJobs(): ImmutableList = Amethyst.instance.powPublishQueue.jobs.value + + private fun watchQueue() { + if (watchJob != null) return + watchJob = + scope.launch { + Amethyst.instance.powPublishQueue.jobs.collect { jobs -> + if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize + lastQueueSize = jobs.size + + if (jobs.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } else { + updateNotification(jobs) + } + } + } + } + + private fun startForegroundCompat(jobs: ImmutableList) { + ensureChannel(this) + val notification = buildNotification(jobs) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun updateNotification(jobs: ImmutableList) { + val manager = NotificationManagerCompat.from(this) + if (!manager.areNotificationsEnabled()) return + try { + manager.notify(NOTIFICATION_ID, buildNotification(jobs)) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running. + } + } + + private fun buildNotification(jobs: ImmutableList): android.app.Notification { + val done = (sessionTotal - jobs.size).coerceAtLeast(0) + val total = (done + jobs.size).coerceAtLeast(1) + + val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull() + val text = + current?.let { + stringRes(this, R.string.pow_mining_job, kindLabel(this, it.kind), it.difficulty.toString()) + } ?: stringRes(this, R.string.pow_mining_title) + + val progressStyle: NotificationCompat.ProgressStyle = + if (total <= 1) { + NotificationCompat.ProgressStyle().setProgressIndeterminate(true) + } else { + NotificationCompat + .ProgressStyle() + .setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) }) + .setProgress(done) + } + + val tapIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val cancelIntent = + PendingIntent.getService( + this, + 1, + Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle( + if (jobs.size > 1) { + stringRes(this, R.string.pow_mining_progress, jobs.size.toString()) + } else { + stringRes(this, R.string.pow_mining_title) + }, + ).setContentText(text) + .setStyle(progressStyle) + .setContentIntent(tapIntent) + .addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } + + companion object { + private const val TAG = "PowMiningFgs" + private const val CHANNEL_ID = "pow_mining" + private const val NOTIFICATION_ID = 0x504F57 // "POW" + private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL" + + /** + * Best-effort start: enqueue happens while the user is interacting + * with the app, so the foreground-start allowance normally holds. A + * restore during a cold background launch may be denied — mining then + * proceeds unprotected and the service starts on the next enqueue. + */ + fun start(context: Context) { + try { + context.startForegroundService(Intent(context, PowMiningForegroundService::class.java)) + } catch (e: Exception) { + Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e) + } + } + + private fun ensureChannel(context: Context) { + val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + stringRes(context, R.string.pow_notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = stringRes(context, R.string.pow_notification_channel_description) + setShowBadge(false) + }, + ) + } + + private fun kindLabel( + context: Context, + kind: Int, + ): String = + when (kind) { + ReactionEvent.KIND -> stringRes(context, R.string.reaction) + RepostEvent.KIND, GenericRepostEvent.KIND -> stringRes(context, R.string.boost) + VoiceEvent.KIND -> stringRes(context, R.string.voice_post) + VoiceReplyEvent.KIND -> stringRes(context, R.string.voice_reply) + else -> stringRes(context, R.string.post) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 9d7a00d1d6..c315d8ada4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -576,7 +577,7 @@ open class CommentPostViewModel : val relays = groupHostRelays ?: replyingTo?.relays.orEmpty() val enqueued = powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.ToRelays(relays)) { mined -> accountViewModel.account.signAndSendPrivatelyOrBroadcast(mined) { relays } } if (!enqueued) { @@ -585,7 +586,7 @@ open class CommentPostViewModel : } else { val enqueued = powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.Broadcast(extraNotesToBroadcast)) { mined -> accountViewModel.account.signAndComputeBroadcast(mined, extraNotesToBroadcast) } if (!enqueued) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 1d0eae657e..3d37dfca79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -356,7 +357,7 @@ class LongFormPostViewModel : val enqueued = powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.Broadcast()) { mined -> broadcastArticle(mined) } if (!enqueued) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index f3e235b2e9..412576e981 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -988,7 +989,7 @@ open class ShortNotePostViewModel : // outbox — bypass the private/scheduled/anonymous paths entirely. val enqueued = powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.ToRelays(threadTarget.relays)) { mined -> accountViewModel.account.signAndSendPrivatelyOrBroadcast(mined) { threadTarget.relays } } if (!enqueued) { @@ -1030,7 +1031,11 @@ open class ShortNotePostViewModel : // still valid at publish time. val enqueued = powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(rescheduledTemplate, powDifficulty) { mined -> + accountViewModel.account.mineTemplateInBackground( + rescheduledTemplate, + powDifficulty, + PoWReplay.Schedule(scheduledFor, extraNotesToBroadcast), + ) { mined -> storeScheduledPost(mined, extraNotesToBroadcast, scheduledFor) } if (!enqueued) { @@ -1059,7 +1064,7 @@ open class ShortNotePostViewModel : } else { val enqueued = powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty) { mined -> + accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.Broadcast(extraNotesToBroadcast)) { mined -> broadcastPublicPost(mined, extraNotesToBroadcast) } if (!enqueued) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2e786d2591..779f988a77 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3764,7 +3764,7 @@ Other public content Polls, live statuses, classifieds and everything else public Mining proof of work - Mining proof of work… (%1$d in queue) + Mining proof of work… (%1$s in queue) %1$s • mining at %2$s bits %1$s • waiting to mine at %2$s bits PoW %1$d @@ -3773,6 +3773,9 @@ Default (off) Off for this post %1$d bits + Proof of work mining + Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app. + Cancel Use This Dismiss Correct diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt new file mode 100644 index 0000000000..bdb6bdd355 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt @@ -0,0 +1,64 @@ +/* + * 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.commons.service.pow + +/** + * Durable record of a template mining job so a post survives process death: + * everything needed to re-mine and re-send with no lambda captured — the + * unsigned template plus a flat replay descriptor the platform layer turns + * back into the right sign+broadcast call. + * + * Replay types are interpreted by the platform restorer (see the Android + * `PowJobRestorer`): [REPLAY_BROADCAST] signs and broadcasts to the computed + * outbox relays, [REPLAY_RELAYS] publishes to [relayUrls], [REPLAY_SCHEDULE] + * signs and parks the event in the scheduled-post store for [publishAtSec]. + */ +data class PersistedPoWJob( + val id: String, + val accountPubkey: String, + val kind: Int, + val difficulty: Int, + val templateJson: String, + val replayType: String, + val relayUrls: List = emptyList(), + val extraEventsJson: List = emptyList(), + val publishAtSec: Long? = null, + val createdAtSec: Long = 0, +) { + companion object { + const val REPLAY_BROADCAST = "broadcast" + const val REPLAY_RELAYS = "relays" + const val REPLAY_SCHEDULE = "schedule" + } +} + +/** + * Where the queue checkpoints its persistable jobs. Implementations must be + * safe to call from any thread and should apply writes in call order; both + * methods are fire-and-forget from the queue's perspective. + */ +interface PoWJobPersistence { + /** Upserts [job] (re-saving the same id on restore is expected). */ + fun save(job: PersistedPoWJob) + + /** Drops [jobId] once the job finished, failed, or was cancelled. */ + fun remove(jobId: String) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index d04abaffed..62389b46d0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -69,13 +69,22 @@ data class PoWJobState( * queues up instead of spawning unbounded CPU work. Each job is cancellable * while queued or mining. * - * The queue is in-memory only: if the process dies, still-unmined posts are - * lost (v1 trade-off; every enqueue/finish is logged for post-mortems). + * Template jobs enqueued with a [PersistedPoWJob] record are checkpointed to + * [persistence] and removed when they finish or are cancelled, so the platform + * layer can re-enqueue them after process death. Opaque [enqueueWork] jobs + * (reactions, reposts, gift wraps — and anonymous posts, deliberately, so a + * throwaway key and its content never touch disk) stay in-memory only. + * + * [onQueueActive] fires on every enqueue; the Android layer uses it to start + * the mining foreground service so backgrounding the app doesn't freeze the + * workers mid-nonce. */ class PoWPublishQueue( private val scope: CoroutineScope, maxConcurrent: Int = 1, miningDispatcher: CoroutineDispatcher = Dispatchers.Default, + private val persistence: PoWJobPersistence? = null, + private val onQueueActive: () -> Unit = {}, ) { private class MiningJob( val id: String, @@ -108,13 +117,24 @@ class PoWPublishQueue( * Mines [template] at [difficulty] and hands the mined template to * [onMined] on the queue's scope. [onMined] should run the exact * sign+broadcast path the caller would have used without PoW. + * + * When [persistAs] is given, the job is checkpointed (under the record's + * id) until it finishes or is cancelled, so it can be restored after + * process death. Re-enqueueing an id already in the queue is a no-op — + * that makes restore-on-login idempotent. */ fun enqueue( template: EventTemplate, pubKey: HexKey, difficulty: Int, + persistAs: PersistedPoWJob? = null, onMined: suspend (EventTemplate) -> Unit, - ) = enqueueWork(template.kind, difficulty) { isActive -> + ) = addJob( + id = persistAs?.id ?: RandomInstance.randomChars(16), + kind = template.kind, + difficulty = difficulty, + persistAs = persistAs, + ) { isActive -> val mined = PoWMiner.run(template, pubKey, difficulty, isActive) // frees the mining worker: signing may wait on an external signer // (Amber/bunker) and broadcasting is IO, neither belongs on the pool. @@ -122,20 +142,40 @@ class PoWPublishQueue( } /** - * Enqueues arbitrary mining work — used by flows where the mining happens - * inside a larger build step (e.g. gift wraps, where each recipient's - * ephemeral-key wrap is mined right before its local signature). + * Enqueues arbitrary in-memory mining work — used by flows where the + * mining happens inside a larger build step (e.g. gift wraps, where each + * recipient's ephemeral-key wrap is mined right before its local + * signature) and by anonymous posts, whose throwaway key must not be + * written to disk. Lost on process death. */ fun enqueueWork( kind: Int, difficulty: Int, work: suspend (isActive: () -> Boolean) -> Unit, + ) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, work = work) + + private fun addJob( + id: String, + kind: Int, + difficulty: Int, + persistAs: PersistedPoWJob?, + work: suspend (isActive: () -> Boolean) -> Unit, ) { - val job = MiningJob(RandomInstance.randomChars(16), kind, difficulty, work) + if (_jobs.value.any { it.id == id }) { + Log.d(TAG) { "PoW job $id already queued; skipping duplicate enqueue" } + return + } + + val job = MiningJob(id, kind, difficulty, work) + persistAs?.let { persistence?.save(it) } pending.update { it.put(job.id, job) } _jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty, isMining = false)).toImmutableList() } - Log.d(TAG) { "Enqueued PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty} (in-memory queue, lost on process death)" } + Log.d(TAG) { + val durability = if (persistAs != null) "persisted" else "in-memory only, lost on process death" + "Enqueued PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty} ($durability)" + } queue.trySend(job) + onQueueActive() } /** Cancels a queued or mining job. No-op if the job already finished. */ @@ -146,6 +186,11 @@ class PoWPublishQueue( Log.d(TAG) { "Cancelled PoW job $jobId" } } + /** Cancels everything still queued or mining. */ + fun cancelAll() { + pending.value.keys.forEach { cancel(it) } + } + // Jobs the workers haven't finished yet, so cancel() can reach the flag of // a job that is still sitting in the channel. StateFlow.update gives us // atomic CAS updates across the UI thread and the mining workers. @@ -184,6 +229,7 @@ class PoWPublishQueue( private fun remove(jobId: String) { pending.update { it.remove(jobId) } _jobs.update { list -> list.filter { it.id != jobId }.toImmutableList() } + persistence?.remove(jobId) } companion object { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWReplay.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWReplay.kt new file mode 100644 index 0000000000..48bff6faca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWReplay.kt @@ -0,0 +1,95 @@ +/* + * 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.commons.service.pow + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * How to finish publishing a mined template if the original in-memory + * continuation is gone (process death). Callers pass one of these alongside + * their richer live continuation; it is flattened into a [PersistedPoWJob] + * the platform restorer can replay headlessly. + */ +sealed class PoWReplay { + /** Sign and broadcast to the account's computed outbox relays. */ + class Broadcast( + val extras: List = emptyList(), + ) : PoWReplay() + + /** Sign and publish to exactly [relays] (e.g. a NIP-29 group host). */ + class ToRelays( + val relays: List, + ) : PoWReplay() + + /** Sign and park in the scheduled-post store for [publishAtSec]. */ + class Schedule( + val publishAtSec: Long, + val extras: List = emptyList(), + ) : PoWReplay() + + fun toRecord( + id: String, + accountPubkey: String, + template: EventTemplate<*>, + difficulty: Int, + ): PersistedPoWJob = + when (this) { + is Broadcast -> + PersistedPoWJob( + id = id, + accountPubkey = accountPubkey, + kind = template.kind, + difficulty = difficulty, + templateJson = template.toJson(), + replayType = PersistedPoWJob.REPLAY_BROADCAST, + extraEventsJson = extras.map { it.toJson() }, + createdAtSec = TimeUtils.now(), + ) + + is ToRelays -> + PersistedPoWJob( + id = id, + accountPubkey = accountPubkey, + kind = template.kind, + difficulty = difficulty, + templateJson = template.toJson(), + replayType = PersistedPoWJob.REPLAY_RELAYS, + relayUrls = relays.map { it.url }, + createdAtSec = TimeUtils.now(), + ) + + is Schedule -> + PersistedPoWJob( + id = id, + accountPubkey = accountPubkey, + kind = template.kind, + difficulty = difficulty, + templateJson = template.toJson(), + replayType = PersistedPoWJob.REPLAY_SCHEDULE, + extraEventsJson = extras.map { it.toJson() }, + publishAtSec = publishAtSec, + createdAtSec = TimeUtils.now(), + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt index 40141ce6a6..80f9455f7d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt @@ -115,4 +115,84 @@ class PoWPublishQueueTest { assertEquals(listOf(0, 1, 2), order) scope.cancel() } + + private class FakePersistence : PoWJobPersistence { + val saved = mutableListOf() + val removed = mutableListOf() + + override fun save(job: PersistedPoWJob) { + saved.add(job.id) + } + + override fun remove(jobId: String) { + removed.add(jobId) + } + } + + private fun recordFor(id: String) = + PersistedPoWJob( + id = id, + accountPubkey = pubKey, + kind = TextNoteEvent.KIND, + difficulty = 10, + templateJson = template.toJson(), + replayType = PersistedPoWJob.REPLAY_BROADCAST, + ) + + @Test + fun persistedJobIsSavedThenRemovedOnCompletion() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val persistence = FakePersistence() + val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence) + val mined = CompletableDeferred() + + queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-a")) { mined.complete(Unit) } + assertEquals(listOf("job-a"), persistence.saved) + + withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } } + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + assertEquals(listOf("job-a"), persistence.removed) + scope.cancel() + } + + @Test + fun cancellingAPersistedJobRemovesItsCheckpoint() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val persistence = FakePersistence() + val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence) + + val gate = CompletableDeferred() + queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() } + queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-b")) {} + + queue.cancel("job-b") + assertTrue("job-b" in persistence.removed, "cancel must drop the checkpoint") + + gate.complete(Unit) + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + scope.cancel() + } + + @Test + fun duplicateJobIdsAreEnqueuedOnce() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val persistence = FakePersistence() + val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence) + + val gate = CompletableDeferred() + queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() } + + queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-c")) {} + queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-c")) {} + + assertEquals(2, queue.jobs.value.size, "restore-style re-enqueue of the same id must not duplicate") + assertEquals(listOf("job-c"), persistence.saved) + + gate.complete(Unit) + withContext(Dispatchers.Default) { withTimeout(60_000) { queue.jobs.first { it.isEmpty() } } } + scope.cancel() + } } From 6ef1caef77d06221719769bb21e33163396ae73d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:22:27 +0000 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20polish=20the=20PoW=20surfaces=20?= =?UTF-8?q?=E2=80=94=20icon=20chip,=20animated=20banner,=20time=20estimate?= =?UTF-8?q?,=20note=20pill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Composer chip: replaced the text button with a bolt icon carrying the difficulty as a small badge (primary-tinted when active, dimmed when off), matching the layered-icon language of the rest of the options row; the current choice is bolded in the override menu. - Mining banner: pulsing bolt while the nonce search runs, per-job elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"), and proper close IconButtons instead of a text "×". PoWJobState now carries miningStartedAt for the elapsed display. - Settings: a live "≈ 45s per post on this device" estimate under the difficulty picker, from a one-shot cached sha256 benchmark (PoWEstimator in commons) and the 2^d expected-attempts mean. - Received notes: DisplayPoW's plain "PoW-24" text is now a compact secondaryContainer pill (bolt + bits) used by NoteCompose, thread view and chat messages. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- .../amethyst/ui/broadcast/BroadcastBanner.kt | 76 ++++++++++--- .../ui/note/creators/pow/PowOverrideButton.kt | 104 ++++++++++++++---- .../amethyst/ui/note/elements/DisplayPoW.kt | 53 +++++++-- .../settings/ComposeSettingsScreen.kt | 40 +++++++ amethyst/src/main/res/values/strings.xml | 1 + .../commons/service/pow/PoWEstimator.kt | 75 +++++++++++++ .../commons/service/pow/PoWPublishQueue.kt | 6 +- 7 files changed, 305 insertions(+), 50 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 8e10f8dfe4..7bd48b1187 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.ui.broadcast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -45,7 +49,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -73,6 +81,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay import java.util.UUID /** @@ -149,6 +158,29 @@ private fun MiningContent( miningJobs: ImmutableList, onCancelJob: (String) -> Unit, ) { + // one shared pulse for the bolt — mining has no measurable progress, so + // the animation is what says "the app is working right now". + val pulse = rememberInfiniteTransition(label = "miningPulse") + val boltAlpha by pulse.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(700), + repeatMode = RepeatMode.Reverse, + ), + label = "boltAlpha", + ) + + // 1 Hz clock driving the per-job elapsed labels + var nowSec by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(Unit) { + while (true) { + delay(1_000) + nowSec = TimeUtils.now() + } + } + Column(modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -158,7 +190,7 @@ private fun MiningContent( Icon( symbol = MaterialSymbols.Bolt, contentDescription = stringRes(R.string.pow_mining_title), - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.primary.copy(alpha = boltAlpha), modifier = Modifier.size(18.dp), ) @@ -180,13 +212,16 @@ private fun MiningContent( ) { Spacer(Modifier.width(26.dp)) + val base = + stringRes( + if (job.isMining) R.string.pow_mining_job else R.string.pow_queued_job, + kindToName(job.kind), + job.difficulty.toString(), + ) + val elapsed = job.miningStartedAt?.let { formatElapsed((nowSec - it).coerceAtLeast(0)) } + Text( - text = - stringRes( - if (job.isMining) R.string.pow_mining_job else R.string.pow_queued_job, - kindToName(job.kind), - job.difficulty.toString(), - ), + text = if (elapsed != null) "$base • $elapsed" else base, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -194,15 +229,17 @@ private fun MiningContent( modifier = Modifier.weight(1f), ) - Text( - text = "×", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .clickable(onClick = { onCancelJob(job.id) }) - .padding(start = 2.dp), - ) + IconButton( + onClick = { onCancelJob(job.id) }, + modifier = Modifier.size(22.dp), + ) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.pow_notification_cancel_all), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } } } @@ -216,6 +253,13 @@ private fun MiningContent( } } +private fun formatElapsed(seconds: Long): String = + if (seconds < 60) { + "${seconds}s" + } else { + "${seconds / 60}m ${seconds % 60}s" + } + @Composable private fun SingleBroadcastContent(broadcast: BroadcastEvent) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt index 9d3018e7ab..858ce330e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt @@ -21,27 +21,39 @@ package com.vitorpamplona.amethyst.ui.note.creators.pow import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow val POW_PRESETS = listOf(16, 20, 24, 28) /** - * Composer chip showing the NIP-13 difficulty this post will be mined at. - * Tapping opens a menu to raise/lower/disable mining for this post only — - * the account setting is untouched. + * Composer options-row button showing the NIP-13 difficulty this post will be + * mined at: a bolt with the difficulty as a small badge when mining is on, + * a dimmed bolt when off. Tapping opens a menu to raise/lower/disable mining + * for this post only — the account setting is untouched. * * [effectiveDifficulty] is what will actually be used at send time (override * or account default); null/0 means the post publishes without PoW. @@ -57,25 +69,37 @@ fun PowOverrideButton( onSelect: (Int?) -> Unit, ) { var expanded by remember { mutableStateOf(false) } + val isActive = effectiveDifficulty != null && effectiveDifficulty > 0 Box { - TextButton(onClick = { expanded = true }) { - Text( - text = - if (effectiveDifficulty != null && effectiveDifficulty > 0) { - stringRes(R.string.pow_chip_active, effectiveDifficulty) - } else { - stringRes(R.string.pow_chip_off) - }, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - color = - if (isOverridden || (effectiveDifficulty != null && effectiveDifficulty > 0)) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, - ) + IconButton(onClick = { expanded = true }) { + Box( + Modifier + .height(20.dp) + .width(23.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = stringRes(R.string.pow_settings_title), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + }, + ) + if (isActive) { + Text( + text = effectiveDifficulty.toString(), + fontSize = 9.sp, + lineHeight = 9.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopEnd), + ) + } + } } DropdownMenu( @@ -90,6 +114,7 @@ fun PowOverrideButton( } else { stringRes(R.string.pow_option_default_off) }, + fontWeight = if (!isOverridden) FontWeight.Bold else null, ) }, onClick = { @@ -98,7 +123,12 @@ fun PowOverrideButton( }, ) DropdownMenuItem( - text = { Text(stringRes(R.string.pow_option_off)) }, + text = { + Text( + stringRes(R.string.pow_option_off), + fontWeight = if (isOverridden && !isActive) FontWeight.Bold else null, + ) + }, onClick = { onSelect(0) expanded = false @@ -106,7 +136,12 @@ fun PowOverrideButton( ) POW_PRESETS.forEach { preset -> DropdownMenuItem( - text = { Text(stringRes(R.string.pow_option_bits, preset)) }, + text = { + Text( + stringRes(R.string.pow_option_bits, preset), + fontWeight = if (isOverridden && effectiveDifficulty == preset) FontWeight.Bold else null, + ) + }, onClick = { onSelect(preset) expanded = false @@ -116,3 +151,24 @@ fun PowOverrideButton( } } } + +@Preview +@Composable +private fun PowOverrideButtonPreview() { + ThemeComparisonRow { + Row { + PowOverrideButton( + effectiveDifficulty = 24, + defaultDifficulty = 20, + isOverridden = true, + onSelect = {}, + ) + PowOverrideButton( + effectiveDifficulty = null, + defaultDifficulty = null, + isOverridden = false, + onSelect = {}, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 7599d47f54..aabe123276 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -20,14 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.ui.theme.Font14SP +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink @Composable @Preview @@ -37,13 +50,35 @@ fun DisplayPoWPreview() { ) } +/** + * Compact pill showing the proof of work a received note carries: a bolt plus + * the difficulty in leading zero bits. Sits inline in note headers, so it + * stays at text height. + */ @Composable fun DisplayPoW(pow: Int) { - Text( - "PoW-$pow", - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = stringRes(R.string.pow_settings_title), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(12.dp), + ) + Text( + text = pow.toString(), + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontSize = 12.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index 575426e678..e903178551 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -37,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,6 +47,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.model.AccountPoWPreferences import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.UiSettingsFlow @@ -56,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.math.roundToLong @Composable fun ComposeSettingsScreen( @@ -159,6 +163,8 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) { } } } + + PowTimeEstimate(difficulty) } SettingsSubControlRow( @@ -175,6 +181,40 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) { } } +/** + * "≈ 45 s per post on this device" — turns the abstract bit count into a cost + * the user can feel. The hash rate is benchmarked once (~250 ms on a worker + * thread) and cached; the figure is a statistical mean, so any single post can + * be luckier or unluckier. + */ +@Composable +private fun PowTimeEstimate(difficulty: Int) { + if (difficulty <= 0) return + + val estimate by + produceState(initialValue = null, difficulty) { + val rate = PoWEstimator.hashesPerSecond() + value = formatEstimate(PoWEstimator.estimateSeconds(difficulty, rate)) + } + + estimate?.let { + Text( + text = stringRes(R.string.pow_difficulty_estimate, it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +private fun formatEstimate(seconds: Double): String = + when { + seconds < 1.0 -> "<1s" + seconds < 90.0 -> "${seconds.roundToLong()}s" + seconds < 90.0 * 60.0 -> "${(seconds / 60.0).roundToLong()}m" + seconds < 48.0 * 3600.0 -> "${(seconds / 3600.0).roundToLong()}h" + else -> "${(seconds / 86400.0).roundToLong()}d" + } + @Composable private fun PowCategoryChecklist(accountViewModel: AccountViewModel) { val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 779f988a77..d16ca2e59a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3776,6 +3776,7 @@ Proof of work mining Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app. Cancel + ≈ %1$s per post on this device Use This Dismiss Correct diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt new file mode 100644 index 0000000000..c482c3a169 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt @@ -0,0 +1,75 @@ +/* + * 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.commons.service.pow + +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.pow +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit +import kotlin.time.TimeSource + +/** + * Rough on-device estimate of how long mining a given NIP-13 difficulty takes, + * so the settings picker can say "≈ 45 s per post" instead of leaving the user + * to guess what "24 bits" means for their phone. + * + * The benchmark hashes a short-note-sized payload for ~a quarter second on the + * first call and caches the rate. Expected attempts for `d` leading zero bits + * are `2^d` (geometric mean), so the estimate is `2^d / rate` — right on + * average, but any individual post can be much luckier or unluckier. + */ +object PoWEstimator { + // representative serialized-event size for a short note; the miner hashes + // the full JSON on every attempt, so payload size sets the rate. + private const val PAYLOAD_BYTES = 300 + private const val BATCH = 2_000 + private val BENCH_DURATION = 250.milliseconds + + private var cachedRate: Double? = null + + suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double = + cachedRate ?: withContext(dispatcher) { + cachedRate ?: benchmark().also { cachedRate = it } + } + + fun estimateSeconds( + difficulty: Int, + hashesPerSecond: Double, + ): Double = 2.0.pow(difficulty) / hashesPerSecond.coerceAtLeast(1.0) + + private fun benchmark(): Double { + val payload = ByteArray(PAYLOAD_BYTES) { (it % 251).toByte() } + + // warm up JIT/caches so the measured window reflects steady state + repeat(3 * BATCH) { sha256(payload) } + + val mark = TimeSource.Monotonic.markNow() + var count = 0L + while (mark.elapsedNow() < BENCH_DURATION) { + repeat(BATCH) { sha256(payload) } + count += BATCH + } + return count / mark.elapsedNow().toDouble(DurationUnit.SECONDS) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index 62389b46d0..74d14d77f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentListOf @@ -50,6 +51,8 @@ import kotlin.coroutines.cancellation.CancellationException /** * Snapshot of one queued/mining publish job, for display in the broadcast banner. + * [miningStartedAt] (epoch seconds) is set when a worker picks the job up, so + * the UI can show how long the current nonce search has been running. */ @Immutable data class PoWJobState( @@ -57,6 +60,7 @@ data class PoWJobState( val kind: Int, val difficulty: Int, val isMining: Boolean, + val miningStartedAt: Long? = null, ) /** @@ -222,7 +226,7 @@ class PoWPublishQueue( private fun markMining(jobId: String) { _jobs.update { list -> - list.map { if (it.id == jobId) it.copy(isMining = true) else it }.toImmutableList() + list.map { if (it.id == jobId) it.copy(isMining = true, miningStartedAt = TimeUtils.now()) else it }.toImmutableList() } } From ba98f8c2326d64438546c82ef16aac74923b2ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:43:00 +0000 Subject: [PATCH 05/15] fix: never mine the inner kind-7 of gift-wrapped reactions Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the inner reaction only travels as ciphertext, so relays can never PoW-filter it and mining it was pure battery waste. Account.reactTo now detects private targets up front and routes them straight to the plain signer, skipping the mining queue. DM/private-note/file wraps were already correct (only the kind-1059 envelope is mined, never the seal or rumor). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- .../main/java/com/vitorpamplona/amethyst/model/Account.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3c79ccb9e4..86c45117a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -898,7 +898,12 @@ class Account( note: Note, reaction: String, ) { - val powDifficulty = powDifficultyFor(ReactionEvent.KIND) + // Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the + // inner kind-7 only ever travels as ciphertext, so mining it is pure + // waste — those targets skip the queue and sign with the plain signer. + val isPrivateTarget = note.event is NIP17Group || note.isPrivateRumor() + + val powDifficulty = if (isPrivateTarget) null else powDifficultyFor(ReactionEvent.KIND) if (powDifficulty != null && mineInBackground(ReactionEvent.KIND, powDifficulty) { isActive -> ReactionAction.reactTo( From 97fc801294f6b411ff193e25d90c9cd3d69cb1ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:22:04 +0000 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20NIP-13=20compliance=20=E2=80=94=20?= =?UTF-8?q?refresh=20created=5Fat=20at=20mining=20start,=20clean=20nonce?= =?UTF-8?q?=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a full NIP-13 review: - The spec recommends updating created_at while mining. Queue jobs now re-stamp the template to "now" when a worker picks them up (a post can wait behind other jobs, and a job restored after process death could be hours old); the restorer does the same. Scheduled posts are exempt — their future created_at is intentional. Anonymous posts re-stamp before mining against the throwaway key. - PoWTag.assemble(nonce, null) serialized the literal string "null" as the third tag entry; a missing commitment now omits the entry entirely. - New tests: PoWTagTest pins the NIP-13 example tag shape and the no-commitment round trip; a queue test asserts the created_at re-stamp at mining start. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- .../vitorpamplona/amethyst/model/Account.kt | 11 +++- .../amethyst/service/pow/PowJobRestorer.kt | 11 +++- .../nip22Comments/CommentPostViewModel.kt | 5 +- .../loggedIn/home/ShortNotePostViewModel.kt | 5 +- .../commons/service/pow/PoWPublishQueue.kt | 15 +++++- .../service/pow/PoWPublishQueueTest.kt | 18 +++++++ .../quartz/nip13Pow/tags/PoWTag.kt | 2 +- .../quartz/nip13Pow/PoWTagTest.kt | 53 +++++++++++++++++++ 8 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 86c45117a9..04372595b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -858,7 +858,16 @@ class Account( val queue = powQueue() ?: return false val finalTemplate = withFinalSignerTags(template) val record = replay?.toRecord(RandomInstance.randomChars(16), signer.pubKey, finalTemplate, difficulty) - queue.enqueue(finalTemplate, signer.pubKey, difficulty, record, onMined) + queue.enqueue( + template = finalTemplate, + pubKey = signer.pubKey, + difficulty = difficulty, + persistAs = record, + // NIP-13 recommends refreshing created_at while mining; scheduled + // posts keep their intentional future timestamp. + refreshCreatedAtOnStart = replay !is PoWReplay.Schedule, + onMined = onMined, + ) return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index db691d4920..eb920115f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -63,7 +63,16 @@ class PowJobRestorer( return@forEach } - queue.enqueue(template, account.signer.pubKey, record.difficulty, persistAs = record) { mined -> + queue.enqueue( + template = template, + pubKey = account.signer.pubKey, + difficulty = record.difficulty, + persistAs = record, + // a restored job may be hours old; publish with a fresh + // created_at (NIP-13 recommendation) — except scheduled posts, + // whose future created_at is the point. + refreshCreatedAtOnStart = record.replayType != PersistedPoWJob.REPLAY_SCHEDULE, + ) { mined -> replay(account, record, mined) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index c315d8ada4..faa0920d39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -564,7 +564,10 @@ open class CommentPostViewModel : val enqueued = powDifficulty != null && accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> - val mined = PoWMiner.run(template, anonSigner.pubKey, powDifficulty, isActive) + // fresh created_at at mining start (NIP-13 recommendation): + // the job may have waited in the queue behind other posts. + val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) } if (!enqueued) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 412576e981..75f473e8ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1055,7 +1055,10 @@ open class ShortNotePostViewModel : val enqueued = powDifficulty != null && accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> - val mined = PoWMiner.run(template, anonSigner.pubKey, powDifficulty, isActive) + // fresh created_at at mining start (NIP-13 recommendation): + // the job may have waited in the queue behind other posts. + val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) } if (!enqueued) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index 74d14d77f2..1d15bd3e2d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -126,12 +126,19 @@ class PoWPublishQueue( * id) until it finishes or is cancelled, so it can be restored after * process death. Re-enqueueing an id already in the queue is a no-op — * that makes restore-on-login idempotent. + * + * [refreshCreatedAtOnStart] re-stamps the template's created_at to "now" + * when a worker picks the job up — NIP-13 recommends updating created_at + * while mining, and a job that waited in the queue (or was restored after + * a process death) would otherwise publish visibly in the past. Must stay + * false for scheduled posts, whose future created_at is intentional. */ fun enqueue( template: EventTemplate, pubKey: HexKey, difficulty: Int, persistAs: PersistedPoWJob? = null, + refreshCreatedAtOnStart: Boolean = false, onMined: suspend (EventTemplate) -> Unit, ) = addJob( id = persistAs?.id ?: RandomInstance.randomChars(16), @@ -139,7 +146,13 @@ class PoWPublishQueue( difficulty = difficulty, persistAs = persistAs, ) { isActive -> - val mined = PoWMiner.run(template, pubKey, difficulty, isActive) + val toMine = + if (refreshCreatedAtOnStart) { + EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + } else { + template + } + val mined = PoWMiner.run(toMine, pubKey, difficulty, isActive) // frees the mining worker: signing may wait on an external signer // (Amber/bunker) and broadcasting is IO, neither belongs on the pool. scope.launch { onMined(mined) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt index 80f9455f7d..4df54de822 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt @@ -175,6 +175,24 @@ class PoWPublishQueueTest { scope.cancel() } + @Test + fun refreshCreatedAtReStampsAtMiningStart() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + val mined = CompletableDeferred>() + + // template stamped in 2023; refresh must bring it to "now" + queue.enqueue(template, pubKey, difficulty = 10, refreshCreatedAtOnStart = true) { mined.complete(it) } + + val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } } + assertTrue( + result.createdAt > template.createdAt, + "created_at must be re-stamped at mining start (was ${result.createdAt})", + ) + scope.cancel() + } + @Test fun duplicateJobIdsAreEnqueuedOnce() = runTest { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt index 8654d18745..db08fbd088 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt @@ -52,6 +52,6 @@ class PoWTag( fun assemble( nonce: String, commitment: Int?, - ) = arrayOfNotNull(TAG_NAME, nonce, commitment.toString()) + ) = arrayOfNotNull(TAG_NAME, nonce, commitment?.toString()) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt new file mode 100644 index 0000000000..d9bf3ad904 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt @@ -0,0 +1,53 @@ +/* + * 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.nip13Pow + +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PoWTagTest { + @Test + fun assembleMatchesNip13Example() { + // NIP-13's example: ["nonce", "776797", "20"] + assertContentEquals(arrayOf("nonce", "776797", "20"), PoWTag.assemble("776797", 20)) + } + + @Test + fun assembleWithoutCommitmentOmitsTheThirdEntry() { + // a null commitment must not serialize as the literal string "null" + assertContentEquals(arrayOf("nonce", "776797"), PoWTag.assemble("776797", null)) + } + + @Test + fun parseRoundTrips() { + val parsed = PoWTag.parse(arrayOf("nonce", "776797", "20"))!! + assertEquals("776797", parsed.nonce) + assertEquals(20, parsed.commitment) + assertContentEquals(arrayOf("nonce", "776797", "20"), parsed.toTagArray()) + + val noCommitment = PoWTag.parse(arrayOf("nonce", "776797"))!! + assertNull(noCommitment.commitment) + assertContentEquals(arrayOf("nonce", "776797"), noCommitment.toTagArray()) + } +} From 906744d32a61effafb67d32fefe8c9d3b02050e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:34:13 +0000 Subject: [PATCH 07/15] fix: treat DM rooms whose newest message is my own as read (#1286, #1287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a reply — from this device or from another one via the self-addressed gift wrap — now counts as having read the conversation: - Account.broadcastPrivately / sendNip04PrivateMessage advance the room's local read marker to the sent message, so the Messages tab dot and the room's new-items bubble clear immediately on send. - unreadPrivateChatRoute and the room/channel/marmot rows in ChatroomHeaderCompose additionally ignore newest messages authored by the logged-in user, covering own messages that arrive from other devices before any local marker exists. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RqN8cfqNdo1MAvLN4C9krm --- .../vitorpamplona/amethyst/model/Account.kt | 17 ++++ .../ui/screen/loggedIn/AccountViewModel.kt | 25 +++-- .../chats/rooms/ChatroomHeaderCompose.kt | 9 +- .../loggedIn/UnreadPrivateChatRouteTest.kt | 91 +++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/UnreadPrivateChatRouteTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b8f6eb22d3..4a843d91ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -187,6 +187,7 @@ import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -2521,6 +2522,8 @@ class Account( cache.justConsumeMyOwnEvent(newEvent) client.publish(newEvent, outboxRelays.flow.value + destinationRelays) + + markDmRoomAsRead(newEvent) } override suspend fun sendNip17EncryptedFile(template: EventTemplate) { @@ -2573,6 +2576,20 @@ class Account( val relayList = computeRelayListToBroadcast(wrap) client.publish(wrap, relayList) } + + markDmRoomAsRead(signedEvents.msg) + } + + /** + * Sending a message into a DM room means the user has caught up with it: advance the + * room's local read marker to the sent message so the unread indicators clear without + * requiring the conversation to be reopened (#1286, #1287). No-op for private events + * that don't belong to a room (private notes, reactions, deletions). + */ + private fun markDmRoomAsRead(event: Event) { + if (event is ChatroomKeyable) { + markAsRead("Room/${event.chatroomKey(signer.pubKey).hashCode()}", event.createdAt) + } } // --- Marmot Group Messaging --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9eb163a241..3ead5b6143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1902,12 +1902,7 @@ class AccountViewModel( } } - private fun unreadPrivateChatRoute(chat: Note): Pair? { - val noteEvent = chat.event ?: return null - val room = (noteEvent as? ChatroomKeyable)?.chatroomKey(account.signer.pubKey) ?: return null - if (account.isAllHidden(room.users)) return null - return privateChatRoute(room) to noteEvent.createdAt - } + private fun unreadPrivateChatRoute(chat: Note): Pair? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden) private fun markHiddenChatroomsAsRead() { account.chatroomList.rooms.forEach { roomKey, chatroom -> @@ -2592,6 +2587,24 @@ class AccountViewModel( val nip19: Nip19Parser.ParseReturn, ) +/** + * Read-marker route + timestamp for the newest message of a private chat room, or null when + * the room cannot be unread: no event, not a chat message, every participant hidden, or the + * newest message authored by the logged-in user — replying (from this device, or from another + * one via the self-addressed gift wrap) counts as having read the conversation (#1286, #1287). + */ +internal fun unreadPrivateChatRoute( + newestMessage: Event?, + loggedInUser: HexKey, + isAllHidden: (Set) -> Boolean, +): Pair? { + val noteEvent = newestMessage ?: return null + val room = (noteEvent as? ChatroomKeyable)?.chatroomKey(loggedInUser) ?: return null + if (isAllHidden(room.users)) return null + if (noteEvent.pubKey == loggedInUser) return null + return "Room/${room.hashCode()}" to noteEvent.createdAt +} + var mockedCache: AccountViewModel? = null @SuppressLint("ViewModelConstructorInComposable") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index e54a7e3732..c09ccd0585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -265,7 +265,7 @@ private fun ChannelRoomCompose( channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.public_chat, modifier) }, channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", - hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, + hasNewMessages = !accountViewModel.isLoggedUser(lastMessage.author) && (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -301,7 +301,7 @@ private fun ChannelRoomCompose( channelTitle = { modifier -> ChannelTitleWithLabelInfo(channel.toBestDisplayName(), R.string.ephemeral_relay_chat, modifier) }, channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", - hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, + hasNewMessages = !accountViewModel.isLoggedUser(lastMessage.author) && (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -341,7 +341,7 @@ private fun MarmotGroupRoomCompose( channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) }, channelLastTime = lastMessage.createdAt(), channelLastContent = lastContent, - hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, + hasNewMessages = !accountViewModel.isLoggedUser(author) && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -572,8 +572,9 @@ private fun UserRoomCompose( } } + // A message I authored (sent here or from another device) counts as read (#1286, #1287). val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() - if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + if (!accountViewModel.isLoggedUser(lastMessage.author) && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { Spacer(modifier = Height4dpModifier) NewItemsBubble() } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/UnreadPrivateChatRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/UnreadPrivateChatRouteTest.kt new file mode 100644 index 0000000000..5b72bea7a2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/UnreadPrivateChatRouteTest.kt @@ -0,0 +1,91 @@ +/* + * 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.ui.screen.loggedIn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The unread predicate behind the Messages tab dot: a room whose newest message was + * authored by the logged-in user counts as read (#1286, #1287). + */ +class UnreadPrivateChatRouteTest { + private val me: HexKey = "a".repeat(64) + private val peer: HexKey = "b".repeat(64) + + private val roomWithPeer = "Room/${ChatroomKey(persistentSetOf(peer)).hashCode()}" + + private fun message( + from: HexKey, + to: HexKey, + createdAt: Long, + ) = ChatMessageEvent( + id = "0".repeat(64), + pubKey = from, + createdAt = createdAt, + tags = arrayOf(arrayOf("p", to)), + content = "hello", + sig = "", + ) + + @Test + fun newestMessageFromPeerReturnsTheRoomRoute() { + val route = unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { false }) + + assertEquals(roomWithPeer to 100L, route) + } + + @Test + fun newestMessageAuthoredByMeCountsAsRead() { + assertNull(unreadPrivateChatRoute(message(from = me, to = peer, createdAt = 100), me, isAllHidden = { false })) + } + + @Test + fun hiddenRoomsAreNeverUnread() { + assertNull(unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { true })) + } + + @Test + fun missingEventIsNotUnread() { + assertNull(unreadPrivateChatRoute(null, me, isAllHidden = { false })) + } + + @Test + fun nonChatEventsAreNotUnread() { + val reaction = + ReactionEvent( + id = "0".repeat(64), + pubKey = peer, + createdAt = 100, + tags = arrayOf(arrayOf("p", me)), + content = "+", + sig = "", + ) + + assertNull(unreadPrivateChatRoute(reaction, me, isAllHidden = { false })) + } +} From 81dc4449bd735f58a1180bcee4c1b473304388dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:47:05 +0000 Subject: [PATCH 08/15] =?UTF-8?q?feat(cli):=20NIP-13=20for=20amy=20?= =?UTF-8?q?=E2=80=94=20post=20--pow=20and=20the=20pow=20verb=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `amy notes post TEXT --pow BITS [--pow-timeout SECS]` mines the note pre-signature via quartz's PoWMiner (blocking — the CLI process is the job), exits 124 on timeout with nothing published, and adds additive --json keys: pow, pow_target, pow_millis. - `amy pow check EVENT-JSON|-` reports actual_bits, committed_target, has_commitment and effective_pow (capped at the commitment per NIP-13's anti-lucky-spam rule) plus id+sig validity. - `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE|-` mines an unsigned template for any pubkey — NIP-13's delegated PoW: ids don't commit to signatures, so a headless box can mine for a phone and hand the template back for signing. - `amy pow bench` prints the machine's hash rate and expected seconds at 16/20/24/28 bits (commons PoWEstimator). - cli/tests/pow/pow-headless.sh: relay-free harness covering bench, mine (commitment shape + 124 timeout), and the delegated round trip (mine → sign via `amy event` → pow check ≥ target). 6/6 passing. No logic added to cli/ — thin assembly over quartz nip13Pow + commons PoWEstimator per the CLI architecture rules. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- cli/README.md | 5 +- cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 11 + .../amethyst/cli/commands/PostCommand.kt | 53 ++++- .../amethyst/cli/commands/PowCommands.kt | 196 ++++++++++++++++++ cli/tests/pow/.gitignore | 1 + cli/tests/pow/pow-headless.sh | 124 +++++++++++ 7 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt create mode 100644 cli/tests/pow/.gitignore create mode 100755 cli/tests/pow/pow-headless.sh diff --git a/cli/README.md b/cli/README.md index 7b9628ac97..0ded3d65d8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -215,6 +215,9 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. | | `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | | `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | +| `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). | +| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. | +| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. | | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | | `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | @@ -381,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | Command | What it does | |---|---| -| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. | +| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). | | `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 56f2bb0514..d0fc574d7c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -51,6 +51,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Marmot message send / list | ✅ | `commons/marmot/` | | `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` | | NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. | +| NIP-13 proof of work (`amy notes post --pow N`, `amy pow check/mine/bench`) | ✅ | `PostCommand` + `PowCommands` — mines pre-signature via quartz `PoWMiner`; `pow mine --pubkey` covers delegated PoW; `pow check` applies the commitment cap. | | NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. | | NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. | | NIP-09 event deletion | 🆕 | Builder exists in quartz. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 786f06dfbe..9fd05e2a63 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands import com.vitorpamplona.amethyst.cli.commands.PodcastCommands +import com.vitorpamplona.amethyst.cli.commands.PowCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands @@ -262,6 +263,7 @@ private suspend fun dispatch(argv: Array): Int { "dm" -> DmCommands.dispatch(dataDir, tail) "profile" -> ProfileCommands.dispatch(dataDir, tail) "notes" -> NotesCommands.dispatch(dataDir, tail) + "pow" -> PowCommands.dispatch(dataDir, tail) "nsite" -> NsiteCommands.dispatch(dataDir, tail) "napplet" -> NappletCommands.dispatch(dataDir, tail) "store" -> StoreCommands.dispatch(dataDir, tail) @@ -428,6 +430,13 @@ private fun printUsage() { | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] | verify [EVENT-JSON] check an event's id hash + signature | (reads stdin when the arg is omitted or `-`) + | pow check EVENT-JSON|- NIP-13: leading-zero bits, committed target, + | effective PoW (capped at the commitment) + | pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON|- + | mine an UNSIGNED template (delegated PoW: + | ids don't commit to sigs, so amy can mine + | for any pubkey); exit 124 on timeout + | pow bench hash rate + expected seconds at 16/20/24/28 bits | key generate mint a fresh keypair (nsec + npub + hex) | key public NSEC|HEX derive the public key from a secret key | key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1… @@ -493,6 +502,8 @@ private fun printUsage() { | |Notes (NIP-10 kind:1): | notes post TEXT [--relay URL] publish a kind:1 short text note + | [--pow BITS [--pow-timeout SECS]] mine a NIP-13 proof of work first + | (exit 124 on timeout, nothing published) | (--relay accepts comma-separated extras) | notes feed [--author USER] fetch kind:1 notes | [--following] (default: own; --author: one user; diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt index 3a46c37bf4..ae16171a9d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -25,21 +25,31 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.pow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException /** - * `amy post [--relay URL …]` — publish a NIP-10 kind:1 short text note - * to the user's outbox relays. + * `amy post [--relay URL …] [--pow BITS [--pow-timeout SECS]]` — + * publish a NIP-10 kind:1 short text note to the user's outbox relays, + * optionally mining a NIP-13 proof of work into it first. Mining blocks the + * invocation (the CLI process IS the job); `--pow-timeout` aborts with exit + * 124 and publishes nothing. * * Threading is intentionally out of scope here — `amy post` only handles new * top-level notes. Replies/quotes need richer event-hint plumbing and will get * their own verb when needed. */ object PostCommand { + private const val MAX_DIFFICULTY = 64 + suspend fun run( dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …]") + if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …] [--pow BITS [--pow-timeout SECS]]") val text = rest[0] if (text.isBlank()) return Output.error("bad_args", "post text must not be blank") @@ -50,6 +60,12 @@ object PostCommand { ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() + val powTarget = args.flags["pow"]?.toIntOrNull() + if (args.flags.containsKey("pow") && (powTarget == null || powTarget < 1 || powTarget > MAX_DIFFICULTY)) { + return Output.error("bad_args", "--pow must be between 1 and $MAX_DIFFICULTY leading zero bits") + } + val powTimeoutSec = args.flags["pow-timeout"]?.toLongOrNull() + Context.open(dataDir).use { ctx -> ctx.prepare() val outbox = ctx.outboxRelays() @@ -63,7 +79,33 @@ object PostCommand { return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") } - val signed = ctx.signer.sign(TextNoteEvent.build(text)) + val template = TextNoteEvent.build(text) + + var powMillis: Long? = null + val readyToSign = + if (powTarget != null) { + System.err.println("mining $powTarget bits…") + val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } + val startedAt = System.nanoTime() + val mined = + try { + withContext(Dispatchers.Default) { + PoWMiner.run(template, ctx.signer.pubKey, powTarget) { + deadlineNanos == null || System.nanoTime() < deadlineNanos + } + } + } catch (e: CancellationException) { + Output.error("pow_timeout", "did not reach $powTarget bits within ${powTimeoutSec}s; nothing was published") + return 124 + } + powMillis = (System.nanoTime() - startedAt) / 1_000_000 + System.err.println("mined in ${powMillis}ms") + mined + } else { + template + } + + val signed = ctx.signer.sign(readyToSign) val ack = ctx.publish(signed, targets) Output.emit( @@ -72,6 +114,9 @@ object PostCommand { "kind" to signed.kind, "created_at" to signed.createdAt, "content" to signed.content, + "pow" to if (powTarget != null) signed.pow() else null, + "pow_target" to powTarget, + "pow_millis" to powMillis, "published_to" to ack.filterValues { it }.keys.map { it.url }, "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt new file mode 100644 index 0000000000..910bc0060f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt @@ -0,0 +1,196 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip13Pow.commitedPoW +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.pow +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.roundToLong + +/** + * `amy pow ` — NIP-13 proof-of-work primitives. + * + * `check` and `bench` are stateless (no account, no network). `mine` works on + * an UNSIGNED template: because the NIP-01 id does not commit to the + * signature, amy can mine on behalf of any pubkey (NIP-13's delegated PoW) — + * pass `--pubkey`, or omit it to mine for the active account. + */ +object PowCommands { + private const val MAX_DIFFICULTY = 64 + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "pow", + tail, + "pow …", + mapOf( + "check" to { rest -> check(rest) }, + "mine" to { rest -> mine(dataDir, rest) }, + "bench" to { rest -> bench() }, + ), + ) + + /** + * `amy pow check ` — difficulty of a SIGNED event, with the + * NIP-13 commitment rule applied: `effective_pow` is capped at the committed + * target and `valid` covers id+signature (a forged id can claim any PoW). + */ + private fun check(rest: Array): Int { + val json = readPayload(rest) ?: return Output.error("bad_args", "pow check (- reads stdin)") + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_event", e.message) + } + + val commitment = event.tags.commitedPoW() + Output.emit( + mapOf( + "event_id" to event.id, + "valid" to event.verify(), + "actual_bits" to PoWRankEvaluator.calculatePowRankOf(event.id), + "committed_target" to commitment, + "has_commitment" to (commitment != null), + "effective_pow" to event.pow(), + ), + ) + return 0 + } + + /** + * `amy pow mine --target N [--pubkey HEX] [--timeout SECS] ` + * — mines an unsigned template and prints it back with the nonce tag, ready + * to be signed by whoever owns the pubkey. + */ + private suspend fun mine( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] " + + val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage) + if (target < 1 || target > MAX_DIFFICULTY) { + return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY") + } + + val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage) + val template = + try { + EventTemplate.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_template", e.message) + } + + val pubKey = + args.flags["pubkey"] + ?: try { + Context.open(dataDir).use { it.signer.pubKey } + } catch (e: Exception) { + return Output.error("bad_args", "no account available; pass --pubkey (${e.message})") + } + if (pubKey.length != 64 || pubKey.any { it !in "0123456789abcdefABCDEF" }) { + return Output.error("bad_args", "--pubkey must be 64 hex characters") + } + + val timeoutSec = args.flags["timeout"]?.toLongOrNull() + val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } + + System.err.println("mining $target bits for ${pubKey.take(8)}…") + val startedAt = System.nanoTime() + + val mined = + try { + withContext(Dispatchers.Default) { + PoWMiner.run(template, pubKey, target) { + deadlineNanos == null || System.nanoTime() < deadlineNanos + } + } + } catch (e: CancellationException) { + Output.error("pow_timeout", "did not reach $target bits within ${timeoutSec}s") + return 124 + } + + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + val id = + sha256( + EventHasherSerializer.fastMakeJsonForId( + pubKey = pubKey, + createdAt = mined.createdAt, + kind = mined.kind, + tags = mined.tags, + content = mined.content, + ), + ).toHexKey() + + Output.emit( + mapOf( + "id" to id, + "pubkey" to pubKey, + "pow" to PoWRankEvaluator.calculatePowRankOf(id), + "pow_target" to target, + "pow_millis" to elapsedMs, + "template_json" to mined.toJson(), + ), + ) + return 0 + } + + /** `amy pow bench` — hash rate + expected mining time per common target. */ + private suspend fun bench(): Int { + val rate = PoWEstimator.hashesPerSecond() + Output.emit( + mapOf( + "hashes_per_second" to rate.roundToLong(), + "expected_seconds" to + listOf(16, 20, 24, 28).associate { bits -> + bits.toString() to PoWEstimator.estimateSeconds(bits, rate) + }, + ), + ) + return 0 + } + + private fun readPayload(rest: Array): String? { + val arg = rest.firstOrNull() ?: return null + val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg + return payload.trim().ifEmpty { null } + } +} diff --git a/cli/tests/pow/.gitignore b/cli/tests/pow/.gitignore new file mode 100644 index 0000000000..3bf212ad88 --- /dev/null +++ b/cli/tests/pow/.gitignore @@ -0,0 +1 @@ +state-pow-headless/ diff --git a/cli/tests/pow/pow-headless.sh b/cli/tests/pow/pow-headless.sh new file mode 100755 index 0000000000..f4d2e629a7 --- /dev/null +++ b/cli/tests/pow/pow-headless.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# pow-headless.sh — verifies amy's NIP-13 primitives without a relay. +# +# One throwaway amy identity in an isolated $HOME. We assert that: +# +# 1. `amy pow bench` reports a positive hash rate and estimates. +# 2. `amy pow mine --target 10 --pubkey HEX