diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5293dc5a90..6f421381c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -888,13 +888,13 @@ class AppModules( } } - // Resume PoW mining jobs that were checkpointed before a process death. + // Resume PoW mining jobs that were checkpointed before a process death, + // for EVERY loaded account (the always-on service preloads non-active + // accounts, whose pending posts must not stay stranded on disk). // Idempotent (the queue dedupes by job id), so re-emissions are safe. applicationIOScope.launch { - sessionManager.accountContent.collectLatest { state -> - if (state is AccountState.LoggedIn) { - powJobRestorer.restore(state.account) - } + accountsCache.accounts.collect { loaded -> + loaded.values.forEach { powJobRestorer.restore(it) } } } 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 04372595b5..7e8d3072e7 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,7 @@ 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.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue @@ -831,7 +832,7 @@ class Account( work: suspend (isActive: () -> Boolean) -> Unit, ): Boolean { val queue = powQueue() ?: return false - queue.enqueueWork(kind, difficulty, work) + queue.enqueueWork(kind, difficulty, owner = signer.pubKey, work = work) return true } @@ -871,6 +872,68 @@ class Account( return true } + /** + * The one-liner for template send paths: when [template]'s kind should be + * mined (per settings and the optional composer [overrideDifficulty]), + * enqueue it and run [send] with the mined template once the nonce is + * found; otherwise run [send] with [template] right now. + */ + suspend fun sendMined( + template: EventTemplate, + replay: PoWReplay?, + overrideDifficulty: Int? = null, + send: suspend (EventTemplate) -> Unit, + ) { + val difficulty = powDifficultyFor(template.kind, overrideDifficulty) + if (difficulty == null || !mineTemplateInBackground(template, difficulty, replay, send)) { + send(template) + } + } + + /** + * Queues wrap mining for pre-signed [seals] (see NIP17Factory.createSeals): + * each seal gets its ephemeral-key envelope mined at [difficulty] on the + * worker pool, then the wraps broadcast. Checkpointed under + * [PersistedPoWJob.REPLAY_WRAPS] (the seals are already-signed ciphertext, + * safe to persist) unless an [existingRecord] from the restorer is passed. + * Returns false when no queue is wired. + */ + fun mineWrapsInBackground( + seals: List, + expirationDelta: Long?, + difficulty: Int, + existingRecord: PersistedPoWJob? = null, + ): Boolean { + val queue = powQueue() ?: return false + if (seals.isEmpty()) return true + + val record = + existingRecord + ?: PersistedPoWJob( + id = RandomInstance.randomChars(16), + accountPubkey = signer.pubKey, + kind = GiftWrapEvent.KIND, + difficulty = difficulty, + templateJson = "", + replayType = PersistedPoWJob.REPLAY_WRAPS, + extraEventsJson = seals.map { it.seal.toJson() }, + recipientPubkeys = seals.map { it.recipient }, + wrapExpirationDelta = expirationDelta, + createdAtSec = TimeUtils.now(), + ) + + queue.enqueueStaged( + kind = GiftWrapEvent.KIND, + difficulty = difficulty, + persistAs = record, + mine = { isActive -> + seals.map { NIP17Factory().wrapSeal(it, expirationDelta, powDifficulty = difficulty, powIsActive = isActive) } + }, + publish = { wraps -> broadcastPrivately(wraps) }, + ) + return true + } + private fun withFinalSignerTags(template: EventTemplate): EventTemplate { val currentSigner = signer if (currentSigner !is NostrSignerWithClientTag) return template @@ -913,19 +976,28 @@ class Account( 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( - note = note, - reaction = reaction, - by = userProfile(), - signer = miningSigner(powDifficulty, setOf(ReactionEvent.KIND), isActive), - onPublic = ::sendAutomatic, - onPrivate = ::broadcastPrivately, - ) + if (powDifficulty != null) { + val queue = powQueue() + if (queue != null) { + // toggle semantics while mining: a second tap on the same + // reaction un-likes by cancelling the pending job instead of + // publishing a duplicate (the mined event doesn't exist yet, + // so hasReacted can't dedupe). + val dedupeKey = "reaction:${note.idHex}:$reaction" + if (queue.cancelByKey(dedupeKey)) return + + queue.enqueueWork(ReactionEvent.KIND, powDifficulty, dedupeKey, owner = signer.pubKey) { isActive -> + ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = miningSigner(powDifficulty, setOf(ReactionEvent.KIND), isActive), + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) + } + return } - ) { - return } ReactionAction.reactTo( @@ -2732,12 +2804,15 @@ class Account( if (!isWriteable()) return val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND) - if (powDifficulty != null && - mineInBackground(GiftWrapEvent.KIND, powDifficulty) { isActive -> - broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer, wrapPowDifficulty = powDifficulty, wrapPowIsActive = isActive)) - } - ) { - return + if (powDifficulty != null) { + // Sign the inner event and every seal NOW, in the caller's + // interaction context — an external signer (Amber/bunker) cannot + // prompt from a background mining worker. Only the local-CPU + // ephemeral-key wrap mining goes to the queue, checkpointed so a + // process death mid-mine cannot lose the file announcement. + val senderMessage = signer.sign(template) + val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer) + if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return } broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer)) @@ -2745,12 +2820,11 @@ class Account( override suspend fun sendNip17PrivateMessage(template: EventTemplate) { val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND) - if (powDifficulty != null && - mineInBackground(GiftWrapEvent.KIND, powDifficulty) { isActive -> - broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer, wrapPowDifficulty = powDifficulty, wrapPowIsActive = isActive)) - } - ) { - return + if (powDifficulty != null) { + // See sendNip17EncryptedFile: sign inline, queue only wrap mining. + val senderMessage = signer.sign(template) + val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer) + if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return } broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer)) @@ -2762,17 +2836,23 @@ class Account( * to the recipient's DM relays. Used for private replies (the parent's * author and participants are already p-tagged) and for private posts * (the Notify list is the audience). Nothing reaches public relays. + * + * [powOverrideDifficulty] is the composer chip's per-post override: + * null follows the account's gift-wrap setting, 0 disables mining. */ - suspend fun sendPrivateNote(template: EventTemplate) { + suspend fun sendPrivateNote( + template: EventTemplate, + powOverrideDifficulty: Int? = null, + ) { 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 + val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND, powOverrideDifficulty) + if (powDifficulty != null) { + // See sendNip17EncryptedFile: sign inline, queue only wrap mining. + val senderNote = signer.sign(template) + val recipients = senderNote.taggedUserIds().plus(signer.pubKey).toSet() + val seals = NIP17Factory().createSeals(senderNote, recipients, signer) + if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return } broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer)) @@ -2785,8 +2865,10 @@ class Account( } } - suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { - val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) = broadcastPrivately(signedEvents.wraps) + + suspend fun broadcastPrivately(wraps: List) { + val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) } mine.forEach { giftWrap -> cache.justConsumeMyOwnEvent(giftWrap) @@ -2795,7 +2877,7 @@ class Account( val id = mine.firstOrNull()?.id val mineNote = if (id == null) null else cache.getNoteIfExists(id) - signedEvents.wraps.forEach { wrap -> + wraps.forEach { wrap -> // Creates an alias if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) { cache.getOrAddAliasNote(wrap.id, mineNote) 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 2b4580c93a..45310e9073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -23,6 +23,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.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -197,8 +198,12 @@ class AccountSyncedSettings( chats.pinnedChatrooms.tryEmit(newPinnedChatrooms) } - if (proofOfWork.difficulty.value != syncedSettingsInternal.proofOfWork.difficulty) { - proofOfWork.difficulty.tryEmit(syncedSettingsInternal.proofOfWork.difficulty) + // clamp like the local setter: a synced NIP-78 event from another + // client could carry an out-of-range value that would crash the miner + // (>256) or mine forever (41+). + val newDifficulty = syncedSettingsInternal.proofOfWork.difficulty.coerceIn(0, PoWPolicy.MAX_DIFFICULTY) + if (proofOfWork.difficulty.value != newDifficulty) { + proofOfWork.difficulty.tryEmit(newDifficulty) } val newPoWCategories = PoWCategory.fromIds(syncedSettingsInternal.proofOfWork.enabledCategories) @@ -313,13 +318,18 @@ 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)) + fun updateDifficulty(newDifficulty: Int): Boolean { + // compare the coerced value: reporting a change for an out-of-range + // input that clamps to the current value would republish identical + // settings to relays. + val coerced = newDifficulty.coerceIn(0, MAX_POW_DIFFICULTY) + return if (difficulty.value != coerced) { + difficulty.tryEmit(coerced) true } else { false } + } fun updateCategory( category: PoWCategory, @@ -336,8 +346,7 @@ class AccountPoWPreferences( } companion object { - // above ~40 bits a phone would mine for days; treat it as a config error. - const val MAX_POW_DIFFICULTY = 40 + const val MAX_POW_DIFFICULTY = PoWPolicy.MAX_DIFFICULTY } } 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 eb920115f4..9341cb5109 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 @@ -28,6 +28,7 @@ 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.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.utils.Log import java.util.UUID @@ -49,6 +50,16 @@ class PowJobRestorer( Log.d(TAG) { "Restoring ${records.size} pending PoW job(s) for ${account.signer.pubKey.take(8)}…" } records.forEach { record -> + if (record.difficulty <= 0) { + store.remove(record.id) + return@forEach + } + + if (record.replayType == PersistedPoWJob.REPLAY_WRAPS) { + restoreWraps(account, record) + return@forEach + } + val template = try { EventTemplate.fromJson(record.templateJson) @@ -58,11 +69,6 @@ class PowJobRestorer( return@forEach } - if (record.difficulty <= 0) { - store.remove(record.id) - return@forEach - } - queue.enqueue( template = template, pubKey = account.signer.pubKey, @@ -78,6 +84,46 @@ class PowJobRestorer( } } + /** + * Wrap jobs carry no template — the pre-signed seals live in + * [PersistedPoWJob.extraEventsJson], one recipient per seal in + * [PersistedPoWJob.recipientPubkeys]. Re-enqueueing goes through the same + * [Account.mineWrapsInBackground] path the send used, with the existing + * record so the checkpoint id (and restore idempotence) is preserved. + */ + private fun restoreWraps( + account: Account, + record: PersistedPoWJob, + ) { + if (record.extraEventsJson.size != record.recipientPubkeys.size) { + Log.w(TAG) { "Dropping malformed wrap PoW job ${record.id}: ${record.extraEventsJson.size} seal(s) vs ${record.recipientPubkeys.size} recipient(s)" } + store.remove(record.id) + return + } + + val seals = + record.extraEventsJson.zip(record.recipientPubkeys).mapNotNull { (sealJson, recipient) -> + try { + NIP17Factory.AddressedSeal(recipient = recipient, seal = Event.fromJson(sealJson)) + } catch (e: Exception) { + Log.w(TAG, "Dropping unreadable seal of wrap PoW job ${record.id}", e) + null + } + } + + if (seals.isEmpty()) { + store.remove(record.id) + return + } + + account.mineWrapsInBackground( + seals = seals, + expirationDelta = record.wrapExpirationDelta, + difficulty = record.difficulty, + existingRecord = record, + ) + } + private suspend fun replay( account: Account, record: PersistedPoWJob, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowKindLabel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowKindLabel.kt new file mode 100644 index 0000000000..1e91ba492e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowKindLabel.kt @@ -0,0 +1,51 @@ +/* + * 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 androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +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.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +/** + * The one user-facing label for "what is being mined": shared by the + * broadcast banner, the mining foreground notification, and failure toasts + * so a job is described the same way everywhere it appears. + */ +@StringRes +fun powKindLabelRes(kind: Int): Int = + when (kind) { + ReactionEvent.KIND -> R.string.reaction + RepostEvent.KIND, GenericRepostEvent.KIND -> R.string.boost + VoiceEvent.KIND -> R.string.voice_post + VoiceReplyEvent.KIND -> R.string.voice_reply + ReportEvent.KIND -> R.string.pow_kind_report + GiftWrapEvent.KIND -> R.string.private_message + ChannelMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND -> R.string.pow_kind_chat_message + else -> R.string.post + } 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 index a28e730463..cd023e303d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.pow +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -35,12 +36,8 @@ 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.pluralStringRes 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 @@ -75,6 +72,33 @@ class PowMiningForegroundService : Service() { private var sessionTotal = 0 private var lastQueueSize = 0 + // Built once per service instance: the intents never change, and + // buildNotification runs on every queue update. + private val tapIntent: PendingIntent by lazy { + 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, + ) + } + + private val cancelIntent: PendingIntent by lazy { + PendingIntent.getService( + this, + 1, + Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + override fun onCreate() { + super.onCreate() + running = true + } + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand( @@ -114,6 +138,7 @@ class PowMiningForegroundService : Service() { } override fun onDestroy() { + running = false scope.cancel() super.onDestroy() } @@ -158,14 +183,14 @@ class PowMiningForegroundService : Service() { } } - private fun buildNotification(jobs: ImmutableList): android.app.Notification { + private fun buildNotification(jobs: ImmutableList): 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()) + pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty) } ?: stringRes(this, R.string.pow_mining_title) val progressStyle: NotificationCompat.ProgressStyle = @@ -178,30 +203,12 @@ class PowMiningForegroundService : Service() { .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()) + pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size) } else { stringRes(this, R.string.pow_mining_title) }, @@ -223,6 +230,13 @@ class PowMiningForegroundService : Service() { private const val NOTIFICATION_ID = 0x504F57 // "POW" private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL" + // Best-effort de-dup for start(): the queue calls it on EVERY enqueue, + // and each call otherwise round-trips through system_server. A stale + // false only costs one redundant startForegroundService (which Android + // routes to the existing instance's onStartCommand anyway). + @Volatile + private var running = false + /** * Best-effort start: enqueue happens while the user is interacting * with the app, so the foreground-start allowance normally holds. A @@ -230,6 +244,7 @@ class PowMiningForegroundService : Service() { * proceeds unprotected and the service starts on the next enqueue. */ fun start(context: Context) { + if (running) return try { context.startForegroundService(Intent(context, PowMiningForegroundService::class.java)) } catch (e: Exception) { @@ -251,17 +266,5 @@ class PowMiningForegroundService : Service() { }, ) } - - 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/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 7bd48b1187..33353938aa 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.broadcast +import android.text.format.DateUtils import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.RepeatMode @@ -57,6 +58,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -68,6 +70,7 @@ 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.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event @@ -172,12 +175,15 @@ private fun MiningContent( label = "boltAlpha", ) - // 1 Hz clock driving the per-job elapsed labels + // 1 Hz clock driving the per-job elapsed labels; only ticks while some + // job actually shows an elapsed time (queued-only banners don't need it). var nowSec by remember { mutableLongStateOf(TimeUtils.now()) } - LaunchedEffect(Unit) { - while (true) { - delay(1_000) - nowSec = TimeUtils.now() + if (miningJobs.any { it.miningStartedAt != null }) { + LaunchedEffect(Unit) { + while (true) { + nowSec = TimeUtils.now() + delay(1_000) + } } } @@ -195,7 +201,7 @@ private fun MiningContent( ) Text( - text = stringRes(R.string.pow_mining_progress, miningJobs.size), + text = pluralStringResource(R.plurals.pow_mining_progress, miningJobs.size, miningJobs.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -213,12 +219,13 @@ 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, + pluralStringResource( + if (job.isMining) R.plurals.pow_mining_job else R.plurals.pow_queued_job, + job.difficulty, kindToName(job.kind), - job.difficulty.toString(), + job.difficulty, ) - val elapsed = job.miningStartedAt?.let { formatElapsed((nowSec - it).coerceAtLeast(0)) } + val elapsed = job.miningStartedAt?.let { DateUtils.formatElapsedTime((nowSec - it).coerceAtLeast(0)) } Text( text = if (elapsed != null) "$base • $elapsed" else base, @@ -229,16 +236,20 @@ private fun MiningContent( modifier = Modifier.weight(1f), ) - 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), - ) + // once the nonce is found the job is signing/broadcasting — + // there is nothing safe to abort anymore. + if (job.isCancellable) { + 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), + ) + } } } } @@ -253,13 +264,6 @@ 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( @@ -576,14 +580,7 @@ fun Event.toKindName(): String = } @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) - } +fun kindToName(kind: Int): String = stringRes(powKindLabelRes(kind)) @Preview @Composable 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 858ce330e0..3c564e5551 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -110,7 +111,7 @@ fun PowOverrideButton( text = { Text( if (defaultDifficulty != null && defaultDifficulty > 0) { - stringRes(R.string.pow_option_default_on, defaultDifficulty) + pluralStringResource(R.plurals.pow_option_default_on, defaultDifficulty, defaultDifficulty) } else { stringRes(R.string.pow_option_default_off) }, @@ -138,7 +139,7 @@ fun PowOverrideButton( DropdownMenuItem( text = { Text( - stringRes(R.string.pow_option_bits, preset), + pluralStringResource(R.plurals.pow_option_bits, preset, preset), fontWeight = if (isOverridden && effectiveDifficulty == preset) FontWeight.Bold else null, ) }, 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 faa0920d39..e6ed3bc28a 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 @@ -537,9 +537,15 @@ open class CommentPostViewModel : val draftToDelete = draftNote val anonymous = wantsAnonymousPost - val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, powOverride) + // captured before cancel() resets the chip + val chosenPow = powOverride cancel() + // Draft deletion lives INSIDE each publish continuation: when the post + // is mined first, the draft must survive until the mined event is + // actually signed and dispatched — a cancelled or process-killed + // mining job would otherwise have destroyed the only copy of the text. + // A reply within a NIP-29 group is group content: pin it to the group's // host relay (the relay the thread was seen on) instead of the author's // outbox, so it reaches the group and — for a private/closed group — is @@ -559,8 +565,10 @@ open class CommentPostViewModel : if (anonymous) { // The anonymous key signs without a client tag, so the template is - // mined as-is against the throwaway pubkey. + // mined as-is against the throwaway pubkey — and never checkpointed + // to disk, so the key and content can't outlive the process. val anonSigner = anonymousSigner() + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, chosenPow) val enqueued = powDifficulty != null && accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> @@ -569,37 +577,27 @@ open class CommentPostViewModel : 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) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } if (!enqueued) { accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonSigner) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } 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() - val enqueued = - powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.ToRelays(relays)) { mined -> - accountViewModel.account.signAndSendPrivatelyOrBroadcast(mined) { relays } - } - if (!enqueued) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays } + accountViewModel.account.sendMined(template, PoWReplay.ToRelays(relays), chosenPow) { readyTemplate -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { relays } + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } else { - val enqueued = - powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.Broadcast(extraNotesToBroadcast)) { mined -> - accountViewModel.account.signAndComputeBroadcast(mined, extraNotesToBroadcast) - } - if (!enqueued) { - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.sendMined(template, PoWReplay.Broadcast(extraNotesToBroadcast), chosenPow) { readyTemplate -> + accountViewModel.account.signAndComputeBroadcast(readyTemplate, extraNotesToBroadcast) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } - - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) - } } suspend fun sendDraftSync() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index bf63ace422..9d27b2b332 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -389,15 +389,26 @@ class AccountSessionManager( localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(hex) accountsCache.deleteAccountFiles(hex) - Amethyst.instance.scheduledPostStore.removeForAccount(hex) + purgePendingPosts(hex) loginWithDefaultAccount() } else { // delete without switching logins localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(hex) accountsCache.deleteAccountFiles(hex) - Amethyst.instance.scheduledPostStore.removeForAccount(hex) + purgePendingPosts(hex) } } } + + /** + * Drops everything a deleted account left in the publish pipelines: parked + * scheduled posts, checkpointed PoW mining jobs, and any of its jobs still + * queued or mining (a post must not publish after its account is gone). + */ + private suspend fun purgePendingPosts(hex: String) { + Amethyst.instance.scheduledPostStore.removeForAccount(hex) + Amethyst.instance.powPublishQueue.cancelForOwner(hex) + Amethyst.instance.powJobStore.removeForAccount(hex) + } } 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 4c6940dd7b..7e6493b85d 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 @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent +import com.vitorpamplona.amethyst.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao @@ -278,6 +279,19 @@ class AccountViewModel( // receivers can reach callManager + accountViewModel. com.vitorpamplona.amethyst.service.call.CallSessionBridge .set(callManager, this) + + // A mined post that fails to sign or broadcast would otherwise die + // silently — the composer already returned when it was enqueued. + viewModelScope.launch { + Amethyst.instance.powPublishQueue.failures.collect { failure -> + val kindLabel = stringRes(Amethyst.instance.appContext, powKindLabelRes(failure.kind)) + if (failure.willRetryOnRestart) { + toastManager.toast(R.string.pow_settings_title, R.string.pow_publish_failed_retry, kindLabel) + } else { + toastManager.toast(R.string.pow_settings_title, R.string.pow_publish_failed, kindLabel, failure.message.orEmpty()) + } + } + } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index f870b0595b..fe79dde482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -316,10 +317,14 @@ open class ChannelNewMessageViewModel : val draftToDelete = draftNote cancel() - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { - channelRelays.toList() - } - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + // Kinds 42/1311 are the PUBLIC_CHAT PoW category: route through the + // mining gate (a no-op when the category is off). Draft deletion runs + // inside the publish continuation so a cancelled mining job can't + // destroy the only copy of the text. + accountViewModel.account.sendMined(template, PoWReplay.ToRelays(channelRelays.toList())) { readyTemplate -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { + channelRelays.toList() + } accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } 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 3d37dfca79..7bc0a0f755 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 @@ -352,49 +352,37 @@ class LongFormPostViewModel : val template = createTemplate() ?: return val draftToDelete = draftNote - val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind) cancel() - val enqueued = - powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.Broadcast()) { 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()) - } - } - - accountViewModel.launchSigner { + // Draft deletion runs INSIDE the publish continuation: when the + // article is mined first, the draft must survive until the mined event + // is actually signed and dispatched — a cancelled or process-killed + // mining job would otherwise have destroyed the only copy of the text. + accountViewModel.account.sendMined(template, PoWReplay.Broadcast()) { readyTemplate -> + broadcastArticle(readyTemplate) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } /** - * 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. + * The publish step shared by the direct and post-mining paths. Tracked + * broadcasting is launched fire-and-forget on the account scope — the + * composer must not wait for relay acks before navigating away, and a + * mined job must not hold its queue entry while acks trickle in. Runs on + * the mining queue's scope when PoW is on, so it must not touch + * viewModelScope. */ 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) + accountViewModel.account.scope.launch { + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } } else { accountViewModel.account.signAndComputeBroadcast(template, emptyList()) } 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 75f473e8ef..b687561e41 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 @@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -175,6 +176,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import java.util.UUID enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -388,8 +390,11 @@ open class ShortNotePostViewModel : var powOverride by mutableStateOf(null) // Best guess of the kind createTemplate() will produce, for the PoW chip. + // A private note is gift-wrapped, so what gets mined (and what the + // settings gate on) is the kind-1059 wrap, not the inner kind-1. private fun anticipatedPowKind(): Int = when { + wantsPrivateNote -> GiftWrapEvent.KIND wantsPoll -> PollEvent.KIND wantsZapPoll -> ZapPollEvent.KIND voiceRecording != null -> VoiceEvent.KIND @@ -981,21 +986,20 @@ open class ShortNotePostViewModel : val scheduledFor = scheduledForSec val privately = wantsPrivateNote val threadTarget = groupThreadTarget - val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, powOverride) + // captured before cancel() resets the chip + val chosenPow = powOverride cancel() + // Draft deletion lives INSIDE each publish continuation: when the post + // is mined first, the draft must survive until the mined event is + // actually signed and dispatched — a cancelled or process-killed + // mining job would otherwise have destroyed the only copy of the text. + 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. - val enqueued = - powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.ToRelays(threadTarget.relays)) { mined -> - accountViewModel.account.signAndSendPrivatelyOrBroadcast(mined) { threadTarget.relays } - } - if (!enqueued) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays } - } - accountViewModel.launchSigner { + accountViewModel.account.sendMined(template, PoWReplay.ToRelays(threadTarget.relays), chosenPow) { readyTemplate -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { threadTarget.relays } accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } return @@ -1005,19 +1009,22 @@ open class ShortNotePostViewModel : // Gift-wrap to the p-tagged users instead of publishing. Private // wins over the anonymous and scheduled modes: a locked private // reply must never fall through to a public publish path (the UI - // hides those toggles while private mode is on). + // hides those toggles while private mode is on). The inner note and + // seals are signed inline; only wrap mining is queued — the content + // is committed (and checkpointed) by the time this returns. @Suppress("UNCHECKED_CAST") - accountViewModel.account.sendPrivateNote(template as EventTemplate) - accountViewModel.launchSigner { - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) - } + accountViewModel.account.sendPrivateNote(template as EventTemplate, chosenPow) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) return } if (scheduledFor != null && !anonymous) { // Re-stamp the template with created_at = scheduled time so the post, // when published later, shows up at its scheduled moment in feeds - // rather than as N minutes/hours old (= compose time). + // rather than as N minutes/hours old (= compose time). 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 rescheduledTemplate = EventTemplate( createdAt = scheduledFor, @@ -1026,23 +1033,12 @@ open class ShortNotePostViewModel : content = template.content, ) - // 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, - PoWReplay.Schedule(scheduledFor, extraNotesToBroadcast), - ) { mined -> - storeScheduledPost(mined, extraNotesToBroadcast, scheduledFor) - } - if (!enqueued) { - storeScheduledPost(rescheduledTemplate, extraNotesToBroadcast, scheduledFor) - } - - accountViewModel.launchSigner { + accountViewModel.account.sendMined( + rescheduledTemplate, + PoWReplay.Schedule(scheduledFor, extraNotesToBroadcast), + chosenPow, + ) { readyTemplate -> + storeScheduledPost(readyTemplate, extraNotesToBroadcast, scheduledFor) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } return @@ -1050,8 +1046,10 @@ open class ShortNotePostViewModel : if (anonymous) { // The anonymous key signs without a client tag, so the template is - // mined as-is against the throwaway pubkey. + // mined as-is against the throwaway pubkey — and never checkpointed + // to disk, so the key and content can't outlive the process. val anonSigner = anonymousSigner() + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, chosenPow) val enqueued = powDifficulty != null && accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> @@ -1060,38 +1058,17 @@ open class ShortNotePostViewModel : 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) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } if (!enqueued) { accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonSigner) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } - } else { - val enqueued = - powDifficulty != null && - accountViewModel.account.mineTemplateInBackground(template, powDifficulty, PoWReplay.Broadcast(extraNotesToBroadcast)) { 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) - } - } + return } - accountViewModel.launchSigner { + accountViewModel.account.sendMined(template, PoWReplay.Broadcast(extraNotesToBroadcast), chosenPow) { readyTemplate -> + broadcastPublicPost(readyTemplate, extraNotesToBroadcast) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } @@ -1110,10 +1087,7 @@ open class ShortNotePostViewModel : val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) Amethyst.instance.scheduledPostStore.add( ScheduledPost( - id = - java.util.UUID - .randomUUID() - .toString(), + id = UUID.randomUUID().toString(), accountPubkey = event.pubKey, signedEventJson = event.toJson(), relayUrls = relays.map { it.url }, @@ -1125,9 +1099,13 @@ 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. + * The publish step shared by the direct and post-mining paths: signs the + * template and dispatches the broadcast. Tracked broadcasting is launched + * fire-and-forget on the account scope — the composer must not wait for + * relay acks before navigating away, and a mined job must not hold its + * queue entry while acks trickle in (the event is signed and dispatched; + * only progress reporting remains). Runs on the mining queue's scope when + * PoW is on, so it must not touch viewModelScope. */ private suspend fun broadcastPublicPost( template: EventTemplate, @@ -1135,12 +1113,14 @@ open class ShortNotePostViewModel : ) { 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) + accountViewModel.account.scope.launch { + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } } else { accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 40c5b3919a..5917ab4994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.uploads.CompressorQuality @@ -251,8 +252,11 @@ class VoiceReplyViewModel : ViewModel() { // Check if replying to a voice event val voiceHint = note.toEventHint() if (voiceHint != null) { - // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies - accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, voiceHint)) + // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies, + // routed through the PoW gate (VOICE category; no-op when off) + accountViewModel.account.sendMined(VoiceReplyEvent.build(audioMeta, voiceHint), PoWReplay.Broadcast()) { + accountViewModel.account.signAndComputeBroadcast(it) + } } else { // Create TextNoteEvent (KIND 1) with audio IMeta for voice replies to regular notes val textHint = note.toEventHint() @@ -270,7 +274,9 @@ class VoiceReplyViewModel : ViewModel() { // Add audio as IMeta attachment add(audioMeta.toIMetaArray()) } - accountViewModel.account.signAndComputeBroadcast(template) + accountViewModel.account.sendMined(template, PoWReplay.Broadcast()) { + accountViewModel.account.signAndComputeBroadcast(it) + } } accountViewModel.account.settings.changeDefaultFileServer(server) 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 e903178551..3cfb90b687 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -40,6 +41,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -54,6 +56,7 @@ 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.pluralStringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -191,10 +194,11 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) { private fun PowTimeEstimate(difficulty: Int) { if (difficulty <= 0) return + val context = LocalContext.current val estimate by produceState(initialValue = null, difficulty) { val rate = PoWEstimator.hashesPerSecond() - value = formatEstimate(PoWEstimator.estimateSeconds(difficulty, rate)) + value = formatEstimate(context, PoWEstimator.estimateSeconds(difficulty, rate)) } estimate?.let { @@ -206,14 +210,23 @@ private fun PowTimeEstimate(difficulty: Int) { } } -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" +private fun formatEstimate( + context: Context, + seconds: Double, +): String { + fun quantity( + id: Int, + count: Long, + ) = pluralStringRes(context, id, count.toInt(), count.toInt()) + + return when { + seconds < 1.0 -> stringRes(context, R.string.pow_estimate_instant) + seconds < 90.0 -> quantity(R.plurals.pow_estimate_seconds, seconds.roundToLong()) + seconds < 90.0 * 60.0 -> quantity(R.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong()) + seconds < 48.0 * 3600.0 -> quantity(R.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong()) + else -> quantity(R.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong()) } +} @Composable private fun PowCategoryChecklist(accountViewModel: AccountViewModel) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d16ca2e59a..aba064cb81 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3764,19 +3764,53 @@ Other public content Polls, live statuses, classifieds and everything else public Mining proof of work - 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 - PoW off - Default (%1$d bits) + + Mining proof of work… (%1$d post in queue) + Mining proof of work… (%1$d posts in queue) + + + %1$s • mining at %2$d bit + %1$s • mining at %2$d bits + + + %1$s • waiting to mine at %2$d bit + %1$s • waiting to mine at %2$d bits + + + Default (%1$d bit) + Default (%1$d bits) + Default (off) Off for this post - %1$d bits + + %1$d bit + %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 ≈ %1$s per post on this device + <1 s + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + Your %1$s finished mining but could not be published: %2$s + Your %1$s finished mining but could not be published. It will be retried the next time the app starts. + Chat message + Report Use This Dismiss Correct 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 ae16171a9d..b94ead611d 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 @@ -24,6 +24,7 @@ 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.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip13Pow.pow @@ -69,11 +70,7 @@ object PostCommand { Context.open(dataDir).use { ctx -> ctx.prepare() val outbox = ctx.outboxRelays() - val extraNormalized = - extraRelays.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalizeOrNull(it) - } + val extraNormalized = extraRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } val targets = (outbox + extraNormalized).toSet() if (targets.isEmpty()) { return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") 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 index 910bc0060f..bc6a1e73bb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt @@ -34,6 +34,7 @@ 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.Hex import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -49,6 +50,9 @@ import kotlin.math.roundToLong * pass `--pubkey`, or omit it to mine for the active account. */ object PowCommands { + // Deliberately above PoWPolicy.MAX_DIFFICULTY (the app's UI ceiling, 40): + // amy is a power tool that may mine on beefy hardware or run deliberate + // long jobs. Still bounded well under the miner's hard 256-bit limit. private const val MAX_DIFFICULTY = 64 suspend fun dispatch( @@ -119,14 +123,19 @@ object PowCommands { return Output.error("bad_template", e.message) } + // normalized to lowercase: the pubkey is serialized verbatim into the + // id preimage, and NIP-01 ids/keys are lowercase hex — an uppercase + // pubkey would mine an id that never matches the signed event. 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" }) { + ( + 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})") + } + ).lowercase() + if (pubKey.length != 64 || !Hex.isHex(pubKey)) { return Output.error("bad_args", "--pubkey must be 64 hex characters") } 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 index c482c3a169..e12843c5fc 100644 --- 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 @@ -23,6 +23,8 @@ 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.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.math.pow import kotlin.time.Duration.Companion.milliseconds @@ -47,10 +49,16 @@ object PoWEstimator { private val BENCH_DURATION = 250.milliseconds private var cachedRate: Double? = null + private val benchLock = Mutex() suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double = cachedRate ?: withContext(dispatcher) { - cachedRate ?: benchmark().also { cachedRate = it } + // single-flight: concurrent first callers (e.g. the settings screen + // recomposing while the composer chip opens) share one ~250 ms + // benchmark instead of each burning a core. + benchLock.withLock { + cachedRate ?: benchmark().also { cachedRate = it } + } } fun estimateSeconds( 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 index bdb6bdd355..1409f4768b 100644 --- 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 @@ -36,17 +36,26 @@ data class PersistedPoWJob( val accountPubkey: String, val kind: Int, val difficulty: Int, + /** Unsigned template for the template replay types; empty for [REPLAY_WRAPS]. */ val templateJson: String, val replayType: String, val relayUrls: List = emptyList(), + /** Extra pre-signed events to broadcast; for [REPLAY_WRAPS], the signed seals. */ val extraEventsJson: List = emptyList(), val publishAtSec: Long? = null, + /** [REPLAY_WRAPS]: recipient of each seal in [extraEventsJson], same order. */ + val recipientPubkeys: List = emptyList(), + /** [REPLAY_WRAPS]: expiration delta to stamp on each wrap. */ + val wrapExpirationDelta: Long? = null, val createdAtSec: Long = 0, ) { companion object { const val REPLAY_BROADCAST = "broadcast" const val REPLAY_RELAYS = "relays" const val REPLAY_SCHEDULE = "schedule" + + /** Mine one gift wrap per pre-signed seal, then broadcast the wraps. */ + const val REPLAY_WRAPS = "wraps" } } 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 index a9c7a7c541..158ccb7adb 100644 --- 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 @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.service.pow +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -28,6 +33,7 @@ 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.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -73,7 +79,13 @@ enum class PoWCategory( * keystroke debounce. */ object PoWPolicy { - private const val DRAFT_WRAP_KIND = 31234 // quartz's DraftWrapEvent (NIP-37) + /** + * Practical UI ceiling for the difficulty setting: above ~40 bits a phone + * would mine for days; treat anything larger (including values arriving + * from a synced NIP-78 settings event) as a config error and clamp. + */ + const val MAX_DIFFICULTY = 40 + private const val LONG_FORM_DRAFT_KIND = 30024 /** NIP-51 sets and other settings-like addressable kinds. */ @@ -95,26 +107,26 @@ object PoWPolicy { private val NEVER_EXPLICIT = setOf( - 0, // metadata - 3, // contact list + MetadataEvent.KIND, + ContactListEvent.KIND, LnZapRequestEvent.KIND, // blocks the invoice fetch OtsEvent.KIND, // machine-generated companion events - DRAFT_WRAP_KIND, // re-signed on a 1s debounce while typing + DraftWrapEvent.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. + * The replaceable range covers relay lists, NIP-51 standard lists, NWC + * info and other settings sync; the ephemeral range 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 = + fun neverMine(kind: Kind): Boolean = kind in NEVER_EXPLICIT || - kind in 10000..19999 || - kind in 20000..29999 || + kind.isReplaceable() || + kind.isEphemeral() || kind in NEVER_ADDRESSABLE fun categoryOf(kind: Int): PoWCategory = @@ -133,7 +145,10 @@ object PoWPolicy { /** * Returns the difficulty to mine [kind] at, or null when the event should - * be published without proof of work. + * be published without proof of work. The difficulty is clamped to + * [MAX_DIFFICULTY] as defense in depth against out-of-range values synced + * from other clients — an unclamped 300 would crash the miner and 41+ + * would mine effectively forever. */ fun shouldMine( kind: Int, @@ -143,6 +158,6 @@ object PoWPolicy { if (difficulty <= 0) return null if (neverMine(kind)) return null if (categoryOf(kind) !in enabledCategories) return null - return difficulty + return difficulty.coerceAtMost(MAX_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 index 1d15bd3e2d..4f69808bea 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 @@ -39,8 +39,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -49,18 +52,41 @@ import kotlinx.coroutines.launch import kotlin.concurrent.Volatile import kotlin.coroutines.cancellation.CancellationException +enum class PoWJobPhase { + /** Waiting for a mining worker. Cancellable. */ + QUEUED, + + /** A worker is searching for the nonce. Cancellable. */ + MINING, + + /** Nonce found; signing and broadcasting. No longer cancellable. */ + PUBLISHING, +} + /** - * 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. + * Snapshot of one queued/mining/publishing 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 nonce search has been running. */ @Immutable data class PoWJobState( val id: String, val kind: Int, val difficulty: Int, - val isMining: Boolean, + val phase: PoWJobPhase = PoWJobPhase.QUEUED, val miningStartedAt: Long? = null, +) { + val isMining: Boolean get() = phase == PoWJobPhase.MINING + val isCancellable: Boolean get() = phase != PoWJobPhase.PUBLISHING +} + +/** Emitted when a job dies after mining or while publishing, so the UI can toast. */ +@Immutable +data class PoWJobFailure( + val kind: Int, + /** true when a checkpoint survived and the restorer will retry it on next login. */ + val willRetryOnRestart: Boolean, + val message: String?, ) /** @@ -70,14 +96,17 @@ data class PoWJobState( * 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. + * queues up instead of spawning unbounded CPU work. Jobs are cancellable + * while QUEUED or MINING; once PUBLISHING starts, cancel is a no-op. * * 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. + * [persistence]. The checkpoint is deleted only after the [onMined] + * continuation COMPLETES (publish included) or the user cancels — never at + * mining-complete — so a process death mid-sign or mid-broadcast is replayed + * by the restorer on the next login. A continuation that throws keeps its + * checkpoint (retry on restart) and reports through [failures]. Opaque + * [enqueueWork] jobs (reactions, reposts — 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 @@ -94,19 +123,36 @@ class PoWPublishQueue( val id: String, val kind: Int, val difficulty: Int, + val persisted: Boolean, + val dedupeKey: String?, + val owner: HexKey?, val work: suspend (isActive: () -> Boolean) -> Unit, ) { @Volatile var cancelled = false + + @Volatile + var publishing = false } private val queue = Channel(UNLIMITED) private val _jobs = MutableStateFlow>(persistentListOf()) - /** Queued + currently-mining jobs, in enqueue order. */ + /** Queued + mining + publishing jobs, in enqueue order. */ val jobs: StateFlow> = _jobs.asStateFlow() + private val _failures = MutableSharedFlow(extraBufferCapacity = 16) + + /** Post-mining failures (signer rejected, broadcast threw). */ + val failures: SharedFlow = _failures.asSharedFlow() + + // 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. This map + // and _jobs are only ever mutated together inside addJob/setPhase/removeEntry. + private val pending = MutableStateFlow>(persistentMapOf()) + init { repeat(maxConcurrent.coerceAtLeast(1)) { scope.launch(miningDispatcher) { @@ -120,12 +166,12 @@ 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. + * sign+broadcast path the caller would have used without PoW; the job + * (and its checkpoint) lives until that continuation finishes. * - * 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. + * When [persistAs] is given, the job is checkpointed under the record's + * id. 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 @@ -140,22 +186,55 @@ class PoWPublishQueue( persistAs: PersistedPoWJob? = null, refreshCreatedAtOnStart: Boolean = false, onMined: suspend (EventTemplate) -> Unit, - ) = addJob( - id = persistAs?.id ?: RandomInstance.randomChars(16), + ) = enqueueStaged( kind = template.kind, difficulty = difficulty, persistAs = persistAs, - ) { 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) } + owner = pubKey, + mine = { isActive -> + val toMine = + if (refreshCreatedAtOnStart) { + EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + } else { + template + } + PoWMiner.run(toMine, pubKey, difficulty, isActive) + }, + publish = onMined, + ) + + /** + * The staged primitive behind [enqueue]: [mine] runs on the capped worker + * pool (CPU only — it must not touch the user's signer or the network); + * [publish] runs detached on the queue's scope so a slow external signer + * or broadcast never holds a mining slot. The job entry and its optional + * checkpoint live until [publish] completes. + */ + fun enqueueStaged( + kind: Int, + difficulty: Int, + persistAs: PersistedPoWJob? = null, + dedupeKey: String? = null, + owner: HexKey? = null, + mine: suspend (isActive: () -> Boolean) -> R, + publish: suspend (R) -> Unit, + ) { + val id = persistAs?.id ?: RandomInstance.randomChars(16) + addJob( + id = id, + kind = kind, + difficulty = difficulty, + persistAs = persistAs, + dedupeKey = dedupeKey, + owner = owner ?: persistAs?.accountPubkey, + ) { isActive -> + val mined = mine(isActive) + // frees the mining worker: signing may wait on an external signer + // (Amber/bunker) and broadcasting is IO, neither belongs on the + // pool. The job entry + checkpoint survive until publish finishes. + setPhase(id, PoWJobPhase.PUBLISHING) + scope.launch { finishDetached(id, kind, persisted = persistAs != null) { publish(mined) } } + } } /** @@ -164,29 +243,41 @@ class PoWPublishQueue( * 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. + * + * A non-null [dedupeKey] makes the enqueue idempotent against pending + * jobs with the same key (see [cancelByKey] for toggle semantics). */ fun enqueueWork( kind: Int, difficulty: Int, + dedupeKey: String? = null, + owner: HexKey? = null, work: suspend (isActive: () -> Boolean) -> Unit, - ) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, work = work) + ) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, dedupeKey = dedupeKey, owner = owner, work = work) private fun addJob( id: String, kind: Int, difficulty: Int, persistAs: PersistedPoWJob?, + dedupeKey: String?, + owner: HexKey?, work: suspend (isActive: () -> Boolean) -> Unit, ) { - if (_jobs.value.any { it.id == id }) { + val current = pending.value + if (current.containsKey(id)) { Log.d(TAG) { "PoW job $id already queued; skipping duplicate enqueue" } return } + if (dedupeKey != null && current.values.any { it.dedupeKey == dedupeKey && !it.cancelled }) { + Log.d(TAG) { "PoW job with key $dedupeKey already queued; skipping duplicate enqueue" } + return + } - val job = MiningJob(id, kind, difficulty, work) + val job = MiningJob(id, kind, difficulty, persisted = persistAs != null, dedupeKey = dedupeKey, owner = owner, work = 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() } + _jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty)).toImmutableList() } 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)" @@ -195,58 +286,128 @@ class PoWPublishQueue( onQueueActive() } - /** Cancels a queued or mining job. No-op if the job already finished. */ + /** + * Cancels a queued or mining job. No-op if the job already finished or is + * already publishing (the event may be half-signed/half-broadcast — there + * is nothing safe to abort). + */ fun cancel(jobId: String) { val job = pending.value[jobId] ?: return + if (job.publishing) return job.cancelled = true - remove(jobId) + removeEntry(jobId, dropCheckpoint = true, persisted = job.persisted) Log.d(TAG) { "Cancelled PoW job $jobId" } } + /** + * Cancels the pending job carrying [dedupeKey], if any. Returns true when + * a job was cancelled — callers use this for toggle semantics (tapping + * "like" again while the first like is still mining un-likes it). + */ + fun cancelByKey(dedupeKey: String): Boolean { + val job = pending.value.values.firstOrNull { it.dedupeKey == dedupeKey && !it.publishing } ?: return false + cancel(job.id) + return true + } + /** 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. - private val pending = MutableStateFlow>(persistentMapOf()) + /** + * Cancels every queued/mining job enqueued for [owner]'s account — used at + * log-off so a deleted account's posts can't publish after the fact. + * Publishing jobs are left to finish (cancel is unsafe mid-broadcast). + */ + fun cancelForOwner(owner: HexKey) { + pending.value.values + .filter { it.owner == owner } + .forEach { cancel(it.id) } + } private suspend fun process(job: MiningJob) { if (job.cancelled) { - remove(job.id) + removeEntry(job.id, dropCheckpoint = true, persisted = job.persisted) return } - markMining(job.id) + setPhase(job.id, PoWJobPhase.MINING) val workerJob = currentCoroutineContext().job + var detached = false try { job.work { !job.cancelled && workerJob.isActive } - Log.d(TAG) { "Finished PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty}" } + detached = job.publishing + Log.d(TAG) { "Finished mining 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) + // mining-stage failures are deterministic (bad difficulty, broken + // template): drop the checkpoint too, or every restore re-crashes. + Log.w(TAG, "PoW job ${job.id} kind=${job.kind} failed while mining", e) + _failures.tryEmit(PoWJobFailure(job.kind, willRetryOnRestart = false, message = e.message)) } finally { - remove(job.id) + // detached template jobs remove themselves in finishDetached once + // the sign+broadcast continuation completes. + if (!detached) { + removeEntry(job.id, dropCheckpoint = true, persisted = job.persisted) + } } } - private fun markMining(jobId: String) { + /** + * Runs the post-mining continuation off the worker pool. Success drops the + * checkpoint; failure keeps it (the restorer replays it headlessly on the + * next login) and surfaces through [failures]. + */ + private suspend fun finishDetached( + jobId: String, + kind: Int, + persisted: Boolean, + onMined: suspend () -> Unit, + ) { + try { + onMined() + Log.d(TAG) { "Published PoW job $jobId kind=$kind" } + removeEntry(jobId, dropCheckpoint = true, persisted = persisted) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "PoW job $jobId kind=$kind failed after mining", e) + removeEntry(jobId, dropCheckpoint = false, persisted = persisted) + _failures.tryEmit(PoWJobFailure(kind, willRetryOnRestart = persisted, message = e.message)) + } + } + + private fun setPhase( + jobId: String, + phase: PoWJobPhase, + ) { + if (phase == PoWJobPhase.PUBLISHING) pending.value[jobId]?.publishing = true _jobs.update { list -> - list.map { if (it.id == jobId) it.copy(isMining = true, miningStartedAt = TimeUtils.now()) else it }.toImmutableList() + list + .map { + when { + it.id != jobId -> it + phase == PoWJobPhase.MINING -> it.copy(phase = phase, miningStartedAt = TimeUtils.now()) + else -> it.copy(phase = phase) + } + }.toImmutableList() } } - private fun remove(jobId: String) { + private fun removeEntry( + jobId: String, + dropCheckpoint: Boolean, + persisted: Boolean, + ) { pending.update { it.remove(jobId) } _jobs.update { list -> list.filter { it.id != jobId }.toImmutableList() } - persistence?.remove(jobId) + if (persisted && dropCheckpoint) persistence?.remove(jobId) } companion object { 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 4df54de822..d77c7c4ba8 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 @@ -28,7 +28,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -213,4 +215,116 @@ class PoWPublishQueueTest { withContext(Dispatchers.Default) { withTimeout(60_000) { queue.jobs.first { it.isEmpty() } } } scope.cancel() } + + @Test + fun failedPublishKeepsCheckpointAndReportsFailure() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val persistence = FakePersistence() + val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence) + + val failure = CompletableDeferred() + scope.launch { queue.failures.collect { failure.complete(it) } } + // give the collector a beat to subscribe (failures has no replay) + withContext(Dispatchers.Default) { delay(50) } + + queue.enqueueStaged( + kind = TextNoteEvent.KIND, + difficulty = 10, + persistAs = recordFor("job-d"), + mine = { "nonce" }, + publish = { throw IllegalStateException("signer rejected") }, + ) + + val reported = withContext(Dispatchers.Default) { withTimeout(10_000) { failure.await() } } + assertEquals(TextNoteEvent.KIND, reported.kind) + assertTrue(reported.willRetryOnRestart, "persisted job must be retried by the restorer") + + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + assertFalse("job-d" in persistence.removed, "checkpoint must survive a failed publish for restart retry") + scope.cancel() + } + + @Test + fun checkpointSurvivesUntilPublishCompletes() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val persistence = FakePersistence() + val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence) + + val publishing = CompletableDeferred() + val publishGate = CompletableDeferred() + + queue.enqueueStaged( + kind = TextNoteEvent.KIND, + difficulty = 10, + persistAs = recordFor("job-e"), + mine = { "nonce" }, + publish = { + publishing.complete(Unit) + publishGate.await() + }, + ) + + withContext(Dispatchers.Default) { withTimeout(10_000) { publishing.await() } } + assertFalse("job-e" in persistence.removed, "checkpoint must not be dropped at mining-complete") + + val job = queue.jobs.value.first() + assertEquals(PoWJobPhase.PUBLISHING, job.phase) + assertFalse(job.isCancellable) + + // cancel is a no-op mid-publish: the event may be half-broadcast + queue.cancel(job.id) + assertEquals(1, queue.jobs.value.size) + + publishGate.complete(Unit) + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + assertTrue("job-e" in persistence.removed, "checkpoint drops once publish completes") + scope.cancel() + } + + @Test + fun cancelByKeyTogglesAPendingJob() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + + val gate = CompletableDeferred() + var reactionRan = false + queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() } + queue.enqueueWork(kind = 7, difficulty = 10, dedupeKey = "reaction:abc:+") { reactionRan = true } + + // same key while pending → deduped + queue.enqueueWork(kind = 7, difficulty = 10, dedupeKey = "reaction:abc:+") { reactionRan = true } + assertEquals(2, queue.jobs.value.size) + + assertTrue(queue.cancelByKey("reaction:abc:+"), "first toggle cancels the pending like") + assertFalse(queue.cancelByKey("reaction:abc:+"), "nothing left to cancel") + + gate.complete(Unit) + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + assertFalse(reactionRan, "cancelled reaction must never publish") + scope.cancel() + } + + @Test + fun cancelForOwnerOnlyDropsThatAccountsJobs() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + + val gate = CompletableDeferred() + var otherRan = false + queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() } + queue.enqueueWork(kind = 1, difficulty = 10, owner = "account-a") {} + queue.enqueueWork(kind = 1, difficulty = 10, owner = "account-b") { otherRan = true } + + queue.cancelForOwner("account-a") + assertEquals(2, queue.jobs.value.size, "only account-a's job leaves the queue") + + gate.complete(Unit) + withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } } + assertTrue(otherRan, "account-b's job still runs") + 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 d4f0592364..280d38309f 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 @@ -83,6 +83,10 @@ class PoWMiner( desiredPoW: Int, isActive: () -> Boolean = { true }, ): EventTemplate { + // sha256 ids have 256 bits; anything outside would index past the + // hash (or never terminate) deep inside the hot loop. + require(desiredPoW in 1..256) { "desiredPoW must be in 1..256, was $desiredPoW" } + var nextSize = STARTING_NONCE_SIZE do { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt index a95a77f2d7..64e81ece7b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWRankEvaluator.kt @@ -106,7 +106,9 @@ class PoWRankEvaluator { minPoW: Int, emptyBytes: Int, ): Boolean { - for (index in 0 until emptyBytes) { + // emptyBytes is minPoW/8; clamp so an oversized target can never + // index past the 32-byte hash. + for (index in 0 until emptyBytes.coerceAtMost(id.size)) { if (id[index] != R8) return false } 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 19a3204209..ae9e90d60a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -47,6 +47,81 @@ class NIP17Factory { val wraps: List, ) + /** A seal encrypted to one recipient, waiting to be gift-wrapped. */ + data class AddressedSeal( + val recipient: HexKey, + val seal: Event, + ) + + data class SealsForWrapping( + val seals: List, + val expirationDelta: Long?, + ) + + /** + * Phase one of a split wrap build: signs one seal per recipient using + * [signer]. This is the only step that talks to the user's signer + * (external signers must prompt in the user's interaction context); + * the remaining wrap step ([wrapSeal]) is pure local CPU work that can + * run on a background mining worker. + */ + suspend fun createSeals( + event: Event, + to: Set, + signer: NostrSigner, + ): SealsForWrapping { + val innerExpDelta = + event.expiration()?.let { + if (it > event.createdAt) { + it - event.createdAt + } else { + null + } + } + + val bunkerLimiter = if (signer is NostrSignerRemote) Semaphore(BUNKER_PARALLELISM) else null + + val seals = + mapNotNullAsync(to.toList()) { next -> + val build: suspend () -> AddressedSeal = { + AddressedSeal( + recipient = next, + seal = + SealedRumorEvent.create( + event = event, + encryptTo = next, + expirationDelta = innerExpDelta, + signer = signer, + ), + ) + } + bunkerLimiter?.withPermit { build() } ?: build() + } + + return SealsForWrapping(seals, innerExpDelta) + } + + /** + * Phase two: wraps one pre-signed seal in its ephemeral-key envelope, + * optionally mining a NIP-13 proof of work into the wrap. Local-only — + * no user signer involved. + */ + fun wrapSeal( + addressed: AddressedSeal, + expirationDelta: Long?, + recipientRelayHint: NormalizedRelayUrl? = null, + powDifficulty: Int? = null, + powIsActive: () -> Boolean = { true }, + ): GiftWrapEvent = + GiftWrapEvent.create( + event = addressed.seal, + recipientPubKey = addressed.recipient, + expirationDelta = expirationDelta, + recipientRelayHint = recipientRelayHint, + powDifficulty = powDifficulty, + powIsActive = powIsActive, + ) + /** * Build one NIP-59 gift wrap per recipient. *