Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779

This commit is contained in:
Claude
2026-07-11 01:51:00 +00:00
58 changed files with 4564 additions and 154 deletions
+9
View File
@@ -346,6 +346,15 @@
android:stopWithTask="true"
android:exported="false" />
<!-- Keeps the NIP-13 mining queue schedulable after the user leaves the
app: shortService gives a ~3 min guaranteed window with no special
permission. Jobs are persisted, so a timeout only defers them. -->
<service
android:name=".service.pow.PowMiningForegroundService"
android:foregroundServiceType="shortService"
android:stopWithTask="false"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -72,6 +73,9 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.amethyst.service.pow.PowJobRestorer
import com.vitorpamplona.amethyst.service.pow.PowJobStore
import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
@@ -596,6 +600,27 @@ class AppModules(
applicationIOScope,
)
// fire-and-forget NIP-13 mining: posts queue here and publish when mined.
// Capped worker pool so a burst of sends never spawns unbounded miners.
// Template jobs checkpoint to disk (restored on login) and every enqueue
// raises the shortService shield so backgrounding doesn't freeze a miner.
val powJobStore by lazy {
PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope)
}
val powPublishQueue by lazy {
PoWPublishQueue(
scope = applicationIOScope,
maxConcurrent = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 2),
persistence = powJobStore,
onQueueActive = { PowMiningForegroundService.start(appContext) },
)
}
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
}
// keeps all accounts live
val accountsCache =
AccountCacheState(
@@ -609,6 +634,7 @@ class AppModules(
cache = cache,
client = client,
rootFilesDir = { appContext.filesDir },
powQueue = { powPublishQueue },
)
val sessionManager =
@@ -862,6 +888,16 @@ class AppModules(
}
}
// 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 {
accountsCache.accounts.collect { loaded ->
loaded.values.forEach { powJobRestorer.restore(it) }
}
}
// Evict the BlossomServerResolver URL cache whenever either local-cache
// toggle flips or the probe transitions up/down so stale entries don't
// outlive the underlying decision.
@@ -57,6 +57,11 @@ 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
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
@@ -198,9 +203,12 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.signer.PoWNostrSigner
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -216,6 +224,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
@@ -241,6 +250,7 @@ import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkLi
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -259,6 +269,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
@@ -280,6 +291,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
@@ -343,6 +355,7 @@ class Account(
val mlsGroupStateStore: MlsGroupStateStore? = null,
val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null,
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
val powQueue: () -> PoWPublishQueue? = { null },
) : IAccount {
private var userProfileCache: User? = null
@@ -711,6 +724,21 @@ class Account(
return false
}
suspend fun updatePowDifficulty(difficulty: Int) {
if (settings.updatePowDifficulty(difficulty)) {
sendNewAppSpecificData()
}
}
suspend fun updatePowCategory(
category: PoWCategory,
enabled: Boolean,
) {
if (settings.updatePowCategory(category, enabled)) {
sendNewAppSpecificData()
}
}
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
if (settings.updateFilterSpam(filterSpam)) {
if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
@@ -812,17 +840,233 @@ class Account(
private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData())
// ---
// NIP-13 proof-of-work publishing
// ---
/**
* Difficulty to mine [kind] at per this account's NIP-13 settings, or null
* when the kind publishes immediately: master difficulty off, category
* disabled, or one of [PoWPolicy]'s hard-excluded kinds (auth, zap
* requests, NWC/bunker RPC, drafts, lists…).
*/
fun powDifficultyFor(kind: Int): Int? =
PoWPolicy.shouldMine(
kind = kind,
difficulty = settings.syncedSettings.proofOfWork.difficulty.value,
enabledCategories = settings.syncedSettings.proofOfWork.enabledCategories.value,
)
/**
* [powDifficultyFor] with a per-post override from the composer chip:
* null defers to the account settings, 0 disables mining for this post,
* a positive value forces that difficulty (hard-excluded kinds still win).
*/
fun powDifficultyFor(
kind: Int,
overrideDifficulty: Int?,
): Int? =
when {
overrideDifficulty == null -> powDifficultyFor(kind)
overrideDifficulty <= 0 -> null
PoWPolicy.neverMine(kind) -> null
else -> overrideDifficulty
}
/**
* Enqueues [work] into the fire-and-forget mining queue. Returns false when
* no queue is wired (headless/test accounts): callers must then run their
* direct, un-mined send path instead.
*/
fun mineInBackground(
kind: Int,
difficulty: Int,
work: suspend (isActive: () -> Boolean) -> Unit,
): Boolean {
val queue = powQueue() ?: return false
queue.enqueueWork(kind, difficulty, owner = signer.pubKey, work = work)
return true
}
/**
* Enqueues [template] to be mined at [difficulty] and then handed to
* [onMined], which should run the exact sign+send path the caller would
* have used without PoW. Returns false when no queue is wired.
*
* When [replay] is given the job is checkpointed to disk so it survives
* process death: on the next login the restorer re-mines the persisted
* template and finishes it with the (headless) replay path instead of
* [onMined]. Pass null for content that must not touch disk.
*
* The template is normalized to the final tag shape the signer will submit
* (client tag included) before mining — a tag appended after mining would
* invalidate the nonce.
*/
fun <T : Event> mineTemplateInBackground(
template: EventTemplate<T>,
difficulty: Int,
replay: PoWReplay? = null,
onMined: suspend (EventTemplate<T>) -> Unit,
): Boolean {
val queue = powQueue() ?: return false
val finalTemplate = withFinalSignerTags(template)
val record = replay?.toRecord(RandomInstance.randomChars(16), signer.pubKey, finalTemplate, difficulty)
queue.enqueue(
template = finalTemplate,
pubKey = signer.pubKey,
difficulty = difficulty,
persistAs = record,
// NIP-13 recommends refreshing created_at while mining; scheduled
// posts keep their intentional future timestamp.
refreshCreatedAtOnStart = replay !is PoWReplay.Schedule,
onMined = onMined,
)
return true
}
/**
* 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 <T : Event> sendMined(
template: EventTemplate<T>,
replay: PoWReplay?,
overrideDifficulty: Int? = null,
send: suspend (EventTemplate<T>) -> 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<NIP17Factory.AddressedSeal>,
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 ->
// the wrap's ephemeral key is generated inside the wrap build;
// the conversion hook hands its pubkey back so the nonce can
// commit to it.
val mineWrap: GiftWrapTemplateConversion = { template, ephemeralPubKey ->
PoWMiner.run(template, ephemeralPubKey, difficulty, isActive)
}
seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) }
},
publish = { wraps -> broadcastPrivately(wraps) },
)
return true
}
private fun <T : Event> withFinalSignerTags(template: EventTemplate<T>): EventTemplate<T> {
val currentSigner = signer
if (currentSigner !is NostrSignerWithClientTag) return template
val finalTags = currentSigner.prepareTags(template.tags)
if (finalTags === template.tags) return template
return EventTemplate(template.createdAt, template.kind, finalTags, template.content)
}
/**
* A signer that mines [kindsToMine] at [difficulty] right before signing.
* When the account signer stamps a client tag, the miner is layered inside
* it so mining runs over the final tag set.
*/
private fun miningSigner(
difficulty: Int,
kindsToMine: Set<Int>,
isActive: () -> Boolean,
): NostrSigner {
val currentSigner = signer
return if (currentSigner is NostrSignerWithClientTag) {
NostrSignerWithClientTag(
inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive),
clientTag = currentSigner.clientTag,
disabled = currentSigner.disabled,
)
} else {
PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive)
}
}
suspend fun reactTo(
note: Note,
reaction: String,
) = ReactionAction.reactTo(
note = note,
reaction = reaction,
by = userProfile(),
signer = signer,
onPublic = ::sendAutomatic,
onPrivate = ::broadcastPrivately,
)
) {
// Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the
// inner kind-7 only ever travels as ciphertext, so mining it is pure
// waste — those targets skip the queue and sign with the plain signer.
val isPrivateTarget = note.event is NIP17Group || note.isPrivateRumor()
val powDifficulty = if (isPrivateTarget) null else powDifficultyFor(ReactionEvent.KIND)
if (powDifficulty != null) {
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
}
}
ReactionAction.reactTo(
note = note,
reaction = reaction,
by = userProfile(),
signer = signer,
onPublic = ::sendAutomatic,
onPrivate = ::broadcastPrivately,
)
}
/**
* Creates a reaction event without sending it.
@@ -1080,16 +1324,41 @@ class Account(
// A kind-1984 e-tagging the rumor would leak the private id onto
// public relays. Report the author instead (p-tag only).
note.author?.let { report(it, type, content) }
} else {
sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
return
}
val powDifficulty = powDifficultyFor(ReportEvent.KIND)
if (powDifficulty != null &&
mineInBackground(ReportEvent.KIND, powDifficulty) { isActive ->
sendMyPublicAndPrivateOutbox(
ReportAction.report(note, type, content, userProfile(), miningSigner(powDifficulty, setOf(ReportEvent.KIND), isActive)),
)
}
) {
return
}
sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
}
suspend fun report(
user: User,
type: ReportType,
content: String = "",
) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer))
) {
val powDifficulty = powDifficultyFor(ReportEvent.KIND)
if (powDifficulty != null &&
mineInBackground(ReportEvent.KIND, powDifficulty) { isActive ->
sendMyPublicAndPrivateOutbox(
ReportAction.report(user, type, content, userProfile(), miningSigner(powDifficulty, setOf(ReportEvent.KIND), isActive)),
)
}
) {
return
}
sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer))
}
suspend fun delete(note: Note) = delete(listOf(note))
@@ -1165,7 +1434,23 @@ class Account(
) = blossomServers.createBlossomDeleteAuth(hash, alt)
suspend fun boost(note: Note) {
RepostAction.repost(note, signer)?.let { event ->
val powDifficulty = powDifficultyFor(RepostEvent.KIND)
if (powDifficulty != null &&
mineInBackground(RepostEvent.KIND, powDifficulty) { isActive ->
repostNow(note, miningSigner(powDifficulty, setOf(RepostEvent.KIND, GenericRepostEvent.KIND), isActive))
}
) {
return
}
repostNow(note, signer)
}
private suspend fun repostNow(
note: Note,
repostSigner: NostrSigner,
) {
RepostAction.repost(note, repostSigner)?.let { event ->
client.publish(event, computeMyReactionToNote(note, event))
cache.justConsumeMyOwnEvent(event)
}
@@ -2921,18 +3206,38 @@ class Account(
// broadcastPrivately) instead of waiting for the newEventBundles
// batcher; the later batched re-delivery is deduped by the chatroom.
cache.getNoteIfExists(newEvent.id)?.let { newNotesPreProcessor.consume(it) }
markDmRoomAsRead(newEvent)
}
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
broadcastPrivately(wraps)
val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND)
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))
}
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
val events = NIP17Factory().createMessageNIP17(template, signer)
broadcastPrivately(events)
val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND)
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))
}
/**
@@ -2941,9 +3246,25 @@ 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<TextNoteEvent>) {
suspend fun sendPrivateNote(
template: EventTemplate<TextNoteEvent>,
powOverrideDifficulty: Int? = null,
) {
if (!isWriteable()) 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))
}
@@ -2954,8 +3275,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<GiftWrapEvent>) {
val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) }
mine.forEach { giftWrap ->
cache.justConsumeMyOwnEvent(giftWrap)
@@ -2964,7 +3287,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)
@@ -2981,6 +3304,28 @@ class Account(
// batcher re-delivers this note later; the processor's replay path and
// the chatroom add are both idempotent.
mineNote?.let { newNotesPreProcessor.consume(it) }
markDmRoomAsRead(signedEvents.msg)
}
/**
* Sending a message into a DM room means the user has caught up with what the room
* showed when they replied: advance the local read marker to the newest known message —
* not just the sent one, whose local clock may lag behind a skew-ahead peer's — so the
* unread indicators clear without requiring the conversation to be reopened
* (#1286, #1287). No-op for private events that don't belong to a room (private notes,
* reactions, deletions).
*/
private fun markDmRoomAsRead(event: Event) {
if (event is ChatroomKeyable) {
val room = event.chatroomKey(signer.pubKey)
val newestInRoom =
chatroomList.rooms
.get(room)
?.newestMessage
?.createdAt() ?: 0L
markAsRead(privateChatLastReadRoute(room), maxOf(event.createdAt, newestInRoom))
}
}
// --- Marmot Group Messaging ---
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -589,6 +590,25 @@ class AccountSettings(
false
}
fun updatePowDifficulty(difficulty: Int): Boolean =
if (syncedSettings.proofOfWork.updateDifficulty(difficulty)) {
saveAccountSettings()
true
} else {
false
}
fun updatePowCategory(
category: PoWCategory,
enabled: Boolean,
): Boolean =
if (syncedSettings.proofOfWork.updateCategory(category, enabled)) {
saveAccountSettings()
true
} else {
false
}
// ---
// list names
// ---
@@ -22,6 +22,8 @@ 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
@@ -72,6 +74,11 @@ class AccountSyncedSettings(
AccountChatPreferences(
MutableStateFlow(internalSettings.chats.toChatroomKeys()),
)
val proofOfWork =
AccountPoWPreferences(
MutableStateFlow(internalSettings.proofOfWork.difficulty),
MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)),
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
@@ -104,6 +111,14 @@ class AccountSyncedSettings(
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
proofOfWork =
AccountPoWPreferencesInternal(
proofOfWork.difficulty.value,
// sorted so the serialized form is deterministic
proofOfWork.enabledCategories.value
.map { it.id }
.sorted(),
),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -182,6 +197,19 @@ class AccountSyncedSettings(
if (chats.pinnedChatrooms.value != newPinnedChatrooms) {
chats.pinnedChatrooms.tryEmit(newPinnedChatrooms)
}
// 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)
if (proofOfWork.enabledCategories.value != newPoWCategories) {
proofOfWork.enabledCategories.tryEmit(newPoWCategories)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -285,6 +313,43 @@ class AccountChatPreferences(
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
)
@Stable
class AccountPoWPreferences(
val difficulty: MutableStateFlow<Int> = MutableStateFlow(0),
val enabledCategories: MutableStateFlow<Set<PoWCategory>> = MutableStateFlow(PoWCategory.DEFAULT_ENABLED),
) {
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,
enabled: Boolean,
): Boolean {
val current = enabledCategories.value
val updated = if (enabled) current + category else current - category
return if (updated != current) {
enabledCategories.tryEmit(updated)
true
} else {
false
}
}
companion object {
const val MAX_POW_DIFFICULTY = PoWPolicy.MAX_DIFFICULTY
}
}
internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set<ChatroomKey> = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) }
@Stable
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
import java.util.Locale
@@ -157,6 +158,7 @@ class AccountSyncedSettingsInternal(
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(),
)
@Serializable
@@ -206,6 +208,14 @@ class AccountMediaPreferencesInternal(
var audioVisualizer: String = "CLASSIC",
)
@Serializable
class AccountPoWPreferencesInternal(
// NIP-13 target difficulty in leading zero bits; 0 = don't mine anything.
val difficulty: Int = 0,
// PoWCategory ids the user wants mined when difficulty > 0.
val enabledCategories: List<String> = PoWCategory.DEFAULT_ENABLED.map { it.id },
)
@Serializable
class AccountChatPreferencesInternal(
// Rooms pinned to the top of the chat list. Each room is its member
@@ -0,0 +1,63 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
/**
* Route key under which a private chat room's last-read time is stored in AccountSettings.
* Every marker writer (send paths, ingestion, room view, hidden-room sweep) and reader
* (Messages-tab dot, room-row bubble) must build the key through this function: a format
* drift between a writer and a reader silently splits read state (#1286).
*/
fun privateChatLastReadRoute(room: ChatroomKey) = "Room/${room.hashCode()}"
/**
* True when [message] marks [room] as read up to its timestamp: the logged-in user authored
* it, so sending it from this device, or from another one arriving via the self-addressed
* gift wrap means they had caught up with the conversation (#1286, #1287). Notes-to-self
* rooms are exempt: there the user's own messages ARE the content still to be seen.
*/
fun chatMessageMarksRoomAsRead(
message: Event,
room: ChatroomKey,
loggedInUser: HexKey,
): Boolean = message.pubKey == loggedInUser && room.users.singleOrNull() != loggedInUser
/**
* Read-marker route + timestamp for the newest message of a private chat room, or null when
* the room cannot be unread: no chat event, a newest message that counts as read (see
* [chatMessageMarksRoomAsRead]), or every participant hidden.
*/
fun unreadPrivateChatRoute(
newestMessage: Event?,
loggedInUser: HexKey,
isAllHidden: (Set<HexKey>) -> Boolean,
): Pair<String, Long>? {
if (newestMessage !is ChatroomKeyable) return null
val room = newestMessage.chatroomKey(loggedInUser)
if (chatMessageMarksRoomAsRead(newestMessage, room, loggedInUser)) return null
if (isAllHidden(room.users)) return null
return privateChatLastReadRoute(room) to newestMessage.createdAt
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.accountsCache
import android.content.ContentResolver
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
@@ -61,6 +62,7 @@ class AccountCacheState(
val cache: LocalCache,
val client: INostrClient,
val rootFilesDir: () -> File = { File("") },
val powQueue: () -> PoWPublishQueue? = { null },
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
@@ -244,6 +246,7 @@ class AccountCacheState(
mlsGroupStateStore = mlsStore,
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
powQueue = powQueue,
).also { newAccount ->
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import kotlin.math.roundToLong
/**
* "45 seconds" / "10 minutes" / "3 hours" the one human-readable rendering
* of a PoW duration estimate, shared by the settings picker, the composer
* difficulty menu, the mining banner, and the mining notification.
*
* Estimates come from [com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator]
* and are the statistical mean of a memoryless search any single post can be
* much luckier or unluckier, so always present these as approximations.
*/
fun formatApproxDuration(
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())
}
}
/**
* "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean,
* "any moment now" once past it a memoryless search has no shrinking
* remainder, so past the mean the only honest claim is "soon".
*/
fun formatTimeLeft(
context: Context,
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
stringRes(context, R.string.pow_time_left, formatApproxDuration(context, remaining))
} else {
stringRes(context, R.string.pow_time_left_soon)
}
}
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.pow
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.utils.Log
import java.util.UUID
/**
* Re-enqueues the mining jobs checkpointed by [PowJobStore] when an account
* logs in, replacing the lost in-memory continuation with the headless replay
* described by each record. Restore is idempotent: the queue dedupes by job
* id, so a login flow that emits twice cannot double-mine.
*/
class PowJobRestorer(
private val queue: PoWPublishQueue,
private val store: PowJobStore,
private val scheduledPostStore: ScheduledPostStore,
) {
suspend fun restore(account: Account) {
val records = store.listFor(account.signer.pubKey)
if (records.isEmpty()) return
Log.d(TAG) { "Restoring ${records.size} pending PoW job(s) for ${account.signer.pubKey.take(8)}" }
records.forEach { record ->
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)
} catch (e: Exception) {
Log.w(TAG, "Dropping unreadable PoW job ${record.id}", e)
store.remove(record.id)
return@forEach
}
queue.enqueue(
template = template,
pubKey = account.signer.pubKey,
difficulty = record.difficulty,
persistAs = record,
// a restored job may be hours old; publish with a fresh
// created_at (NIP-13 recommendation) — except scheduled posts,
// whose future created_at is the point.
refreshCreatedAtOnStart = record.replayType != PersistedPoWJob.REPLAY_SCHEDULE,
) { mined ->
replay(account, record, mined)
}
}
}
/**
* 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,
mined: EventTemplate<Event>,
) {
val extras =
record.extraEventsJson.mapNotNull {
try {
Event.fromJson(it)
} catch (e: Exception) {
Log.w(TAG, "Dropping unreadable extra event of PoW job ${record.id}", e)
null
}
}
when (record.replayType) {
PersistedPoWJob.REPLAY_BROADCAST -> {
account.signAndComputeBroadcast(mined, extras)
}
PersistedPoWJob.REPLAY_RELAYS -> {
val relays = record.relayUrls.map { NormalizedRelayUrl(it) }
account.signAndSendPrivatelyOrBroadcast(mined) { relays }
}
PersistedPoWJob.REPLAY_SCHEDULE -> {
val publishAtSec = record.publishAtSec
if (publishAtSec == null) {
Log.w(TAG) { "Scheduled PoW job ${record.id} has no publish time; broadcasting instead" }
account.signAndComputeBroadcast(mined, extras)
return
}
val (event, relays, extraList) = account.createPostEvent(mined, extras)
scheduledPostStore.add(
ScheduledPost(
id = UUID.randomUUID().toString(),
accountPubkey = event.pubKey,
signedEventJson = event.toJson(),
relayUrls = relays.map { it.url },
extraEventsJson = extraList.map { it.toJson() },
publishAtSec = publishAtSec,
createdAtSec = System.currentTimeMillis() / 1000,
),
)
}
else -> Log.w(TAG) { "Unknown replay type '${record.replayType}' for PoW job ${record.id}; dropping" }
}
}
companion object {
private const val TAG = "PowJobRestorer"
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.pow
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
/**
* On-disk checkpoint of pending PoW mining jobs so posts survive process
* death: the queue upserts on enqueue and removes on finish/cancel, and
* [PowJobRestorer] re-enqueues whatever is left when an account logs in.
*
* [save]/[remove] are the queue-facing fire-and-forget hooks; they serialize
* onto a single-lane dispatcher so writes land in call order.
*/
class PowJobStore(
private val storageFile: File,
scope: CoroutineScope,
) : PoWJobPersistence {
private val mapper =
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
// one lane: launch order == execution order, so a save followed by its
// remove can never be applied backwards.
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val writeLane = Dispatchers.IO.limitedParallelism(1)
private val writeScope = CoroutineScope(scope.coroutineContext + writeLane)
private val mutex = Mutex()
private var loaded = false
private var jobs: MutableList<PersistedPoWJob> = mutableListOf()
override fun save(job: PersistedPoWJob) {
writeScope.launch {
mutex.withLock {
ensureLoaded()
jobs.removeAll { it.id == job.id }
jobs.add(job)
persist()
}
}
}
override fun remove(jobId: String) {
writeScope.launch {
mutex.withLock {
ensureLoaded()
if (jobs.removeAll { it.id == jobId }) persist()
}
}
}
suspend fun listFor(accountPubkey: String): List<PersistedPoWJob> =
withContext(writeLane) {
mutex.withLock {
ensureLoaded()
jobs.filter { it.accountPubkey == accountPubkey }
}
}
/** Drops every record owned by [accountPubkey] (account deletion). */
fun removeForAccount(accountPubkey: String) {
writeScope.launch {
mutex.withLock {
ensureLoaded()
if (jobs.removeAll { it.accountPubkey == accountPubkey }) persist()
}
}
}
private fun ensureLoaded() {
if (loaded) return
jobs =
try {
if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<PowJobsFile>(storageFile).jobs.toMutableList()
} else {
mutableListOf()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load pending PoW jobs from $storageFile", e)
mutableListOf()
}
loaded = true
val cutoff = System.currentTimeMillis() / 1000 - MAX_AGE_SEC
if (jobs.removeAll { it.createdAtSec in 1 until cutoff }) persist()
}
private fun persist() {
storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try {
mapper.writeValue(tmp, PowJobsFile(version = 1, jobs = jobs.toList()))
if (!tmp.renameTo(storageFile)) {
if (!storageFile.delete() || !tmp.renameTo(storageFile)) {
Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp" }
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to persist pending PoW jobs to $storageFile", e)
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" }
}
}
}
companion object {
private const val TAG = "PowJobStore"
const val FILE_NAME = "pending_pow_jobs.json"
// a job this stale is a post the user has long forgotten; publishing
// it a week later would be more surprising than dropping it.
private const val MAX_AGE_SEC = 3L * 24 * 3600
}
}
data class PowJobsFile(
val version: Int = 1,
val jobs: List<PersistedPoWJob> = emptyList(),
)
@@ -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
}
@@ -0,0 +1,319 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.pow
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
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.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Short-lived foreground service that shields the PoW mining queue from the
* cached-apps freezer: while it runs the process stays schedulable, so posts
* finish mining even after the user backgrounds the app.
*
* Uses the Android 14+ `shortService` type no special permission, but a
* hard ~3 minute budget. On [onTimeout] the service exits cleanly; every
* persistable job is already checkpointed by [PowJobStore], so anything still
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
* The notification is a live progress card ([NotificationCompat.ProgressStyle]):
* one track segment per post, filling as jobs complete, indeterminate while a
* single post mines, with a cancel-all action. On Android 16+ it renders as a
* Live Updates chip; older versions fall back to a standard progress bar.
*/
class PowMiningForegroundService : Service() {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var watchJob: Job? = null
// Session totals so the progress track can show "done / enqueued since the
// service started" — the queue itself only knows what is still pending.
private var sessionTotal = 0
private var lastQueueSize = 0
// Benchmarked once per service run (~250 ms, cached by the estimator);
// read from the notification builder to compute expected durations.
@Volatile
private var hashRate: Double? = null
// 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(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// Android's contract: every onStartCommand after startForegroundService
// must call startForeground promptly, even on the stop path.
runCatching { startForegroundCompat(currentJobs()) }
.onFailure {
Log.w(TAG, "startForeground failed; mining continues without the service", it)
stopSelf()
return START_NOT_STICKY
}
if (intent?.action == ACTION_CANCEL_ALL) {
Amethyst.instance.powPublishQueue.cancelAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
watchQueue()
return START_NOT_STICKY
}
/**
* The shortService budget (~3 min) is exhausted. Exit before the system
* ANRs us: persisted jobs are checkpointed and resume on next launch;
* in-memory jobs keep mining opportunistically until the process freezes.
*/
override fun onTimeout(startId: Int) {
Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
running = false
scope.cancel()
super.onDestroy()
}
private fun currentJobs(): ImmutableList<PoWJobState> = Amethyst.instance.powPublishQueue.jobs.value
private fun watchQueue() {
if (watchJob != null) return
watchJob =
scope.launch {
Amethyst.instance.powPublishQueue.jobs.collect { jobs ->
if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize
lastQueueSize = jobs.size
if (jobs.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
updateNotification(jobs)
}
}
}
// the estimated-time-left figure and progress fraction only move with
// the clock, not with queue events: benchmark the hash rate once,
// then refresh the card periodically while something is mining.
scope.launch {
hashRate = PoWEstimator.hashesPerSecond()
while (true) {
val jobs = currentJobs()
if (jobs.any { it.isMining }) updateNotification(jobs)
delay(PROGRESS_REFRESH_MS)
}
}
}
private fun startForegroundCompat(jobs: ImmutableList<PoWJobState>) {
ensureChannel(this)
val notification = buildNotification(jobs)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateNotification(jobs: ImmutableList<PoWJobState>) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(NOTIFICATION_ID, buildNotification(jobs))
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
private fun buildNotification(jobs: ImmutableList<PoWJobState>): Notification {
val done = (sessionTotal - jobs.size).coerceAtLeast(0)
val total = (done + jobs.size).coerceAtLeast(1)
val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull()
// expected duration for the job being mined right now, so the card can
// say "≈ 10 minutes left" and fill its bar toward a predictable end.
val rate = hashRate
val startedAt = current?.miningStartedAt
val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null
val elapsedSec = startedAt?.let { (TimeUtils.now() - it).coerceAtLeast(0) }
val base =
current?.let {
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty)
} ?: stringRes(this, R.string.pow_mining_title)
val text =
if (expectedSec != null && elapsedSec != null) {
"$base${formatTimeLeft(this, expectedSec, elapsedSec)}"
} else {
base
}
val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null
val progressStyle: NotificationCompat.ProgressStyle =
if (total <= 1) {
// single post: fill toward the estimated duration; past the
// mean the search is memoryless, so sweep instead of lying.
if (fraction != null && fraction < 1.0) {
NotificationCompat
.ProgressStyle()
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
.setProgress((fraction * 100).toInt())
} else {
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
}
} else {
NotificationCompat
.ProgressStyle()
.setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) })
.setProgress(done)
}
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(
if (jobs.size > 1) {
pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size)
} else {
stringRes(this, R.string.pow_mining_title)
},
).setContentText(text)
.setStyle(progressStyle)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
companion object {
private const val TAG = "PowMiningFgs"
private const val CHANNEL_ID = "pow_mining"
private const val NOTIFICATION_ID = 0x504F57 // "POW"
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
// clock-driven refresh cadence for the time-left text and bar; the
// shortService budget (~3 min) caps this at a handful of updates.
private const val PROGRESS_REFRESH_MS = 30_000L
// 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
* restore during a cold background launch may be denied mining then
* proceeds unprotected and the service starts on the next enqueue.
*/
fun start(context: Context) {
if (running) return
try {
context.startForegroundService(Intent(context, PowMiningForegroundService::class.java))
} catch (e: Exception) {
Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e)
}
}
private fun ensureChannel(context: Context) {
val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.pow_notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = stringRes(context, R.string.pow_notification_channel_description)
setShowBadge(false)
},
)
}
}
}
@@ -20,9 +20,14 @@
*/
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
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -45,10 +50,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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
@@ -59,6 +71,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.service.pow.formatTimeLeft
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
@@ -72,22 +88,28 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import java.util.UUID
/**
* Banner showing active broadcast progress.
* Displayed above bottom navigation when events are being sent to relays.
*
* [miningJobs] is the NIP-13 pre-send phase: posts waiting for (or in the
* middle of) proof-of-work mining, before their per-relay send states exist.
*/
@Composable
fun BroadcastBanner(
broadcasts: ImmutableList<BroadcastEvent>,
miningJobs: ImmutableList<PoWJobState> = persistentListOf(),
onCancelJob: (String) -> Unit = {},
onTap: () -> Unit = {},
onRetryAll: () -> Unit = {},
onDismiss: () -> Unit = {},
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = broadcasts.isNotEmpty(),
visible = broadcasts.isNotEmpty() || miningJobs.isNotEmpty(),
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
modifier = modifier,
@@ -108,19 +130,29 @@ fun BroadcastBanner(
.padding(horizontal = 16.dp, vertical = 8.dp)
.animateContentSize(),
) {
val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS }
if (miningJobs.isNotEmpty()) {
MiningContent(miningJobs, onCancelJob)
if (isAllFinished) {
if (broadcasts.size == 1) {
CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss)
} else {
MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss)
if (broadcasts.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
}
} else {
if (broadcasts.size == 1) {
SingleBroadcastContent(broadcasts.first())
}
if (broadcasts.isNotEmpty()) {
val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS }
if (isAllFinished) {
if (broadcasts.size == 1) {
CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss)
} else {
MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss)
}
} else {
MultipleBroadcastsContent(broadcasts)
if (broadcasts.size == 1) {
SingleBroadcastContent(broadcasts.first())
} else {
MultipleBroadcastsContent(broadcasts)
}
}
}
}
@@ -128,6 +160,164 @@ fun BroadcastBanner(
}
}
@Composable
private fun MiningContent(
miningJobs: ImmutableList<PoWJobState>,
onCancelJob: (String) -> Unit,
) {
// one shared pulse for the gear — mining has no measurable progress, so
// the animation is what says "the app is working right now".
val pulse = rememberInfiniteTransition(label = "miningPulse")
val gearAlpha by pulse.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(700),
repeatMode = RepeatMode.Reverse,
),
label = "gearAlpha",
)
// 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()) }
val anyMiningStarted = miningJobs.any { it.miningStartedAt != null }
if (anyMiningStarted) {
LaunchedEffect(Unit) {
while (true) {
nowSec = TimeUtils.now()
delay(1_000)
}
}
}
// Benchmarked once and cached (~250 ms on a worker): turns each job's
// difficulty into an expected duration so the bar has a predictable end.
val context = LocalContext.current
val hashRate by
produceState<Double?>(initialValue = null) {
value = PoWEstimator.hashesPerSecond()
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
symbol = MaterialSymbols.Manufacturing,
contentDescription = stringRes(R.string.pow_mining_title),
tint = MaterialTheme.colorScheme.primary.copy(alpha = gearAlpha),
modifier = Modifier.size(18.dp),
)
Text(
text = pluralStringResource(R.plurals.pow_mining_progress, miningJobs.size, miningJobs.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
miningJobs.forEach { job ->
val elapsedSec = job.miningStartedAt?.let { (nowSec - it).coerceAtLeast(0) }
val expectedSec = hashRate?.let { PoWEstimator.estimateSeconds(job.difficulty, it) }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Spacer(Modifier.width(26.dp))
val base =
pluralStringResource(
if (job.isMining) R.plurals.pow_mining_job else R.plurals.pow_queued_job,
job.difficulty,
kindToName(job.kind),
job.difficulty,
)
val suffix =
buildList {
elapsedSec?.let { add(DateUtils.formatElapsedTime(it)) }
if (elapsedSec != null && expectedSec != null) {
add(formatTimeLeft(context, expectedSec, elapsedSec))
}
}.joinToString("")
Text(
text = if (suffix.isEmpty()) base else "$base$suffix",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
// 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),
)
}
}
}
// Predictable end: the bar fills over the estimated duration for
// this difficulty. The search is memoryless, so once the mean is
// passed there is no honest remainder to show — fall back to the
// indeterminate sweep instead of a bar stuck at 100%.
if (elapsedSec != null) {
val fraction = expectedSec?.let { (elapsedSec / it).toFloat() }
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(Modifier.width(26.dp))
if (fraction != null && fraction < 1f) {
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
} else {
LinearProgressIndicator(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
Spacer(Modifier.height(4.dp))
}
}
// nothing mining yet (all jobs waiting for a worker): keep the shared
// activity sweep so the banner still reads as "working".
if (!anyMiningStarted) {
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@Composable
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
Row(
@@ -443,6 +633,9 @@ fun Event.toKindName(): String =
else -> stringRes(R.string.post)
}
@Composable
fun kindToName(kind: Int): String = stringRes(powKindLabelRes(kind))
@Preview
@Composable
fun BroadcastBannerSingleEventPreview() {
@@ -37,11 +37,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
/**
@@ -50,29 +53,38 @@ import kotlinx.coroutines.delay
* - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s)
* - BroadcastDetailsSheet: Shows detailed relay status on tap
*
* Hidden when the "Tracked broadcasts" UI setting is off.
* The relay-progress part is hidden when the "Tracked broadcasts" UI setting
* is off, but the NIP-13 mining phase always shows the user needs to see
* (and be able to cancel) posts still burning CPU in the queue.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
val useTrackedBroadcasts by accountViewModel.settings.uiSettingsFlow.useTrackedBroadcasts
.collectAsStateWithLifecycle()
if (useTrackedBroadcasts != BooleanType.ALWAYS) return
val trackingEnabled = useTrackedBroadcasts == BooleanType.ALWAYS
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
val miningJobs by Amethyst.instance.powPublishQueue.jobs
.collectAsStateWithLifecycle()
val trackedBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
val activeBroadcasts = if (trackingEnabled) trackedBroadcasts else persistentListOf()
// State for details sheet
var seeDetails by remember { mutableStateOf(false) }
if (activeBroadcasts.isEmpty() && !seeDetails) return
if (activeBroadcasts.isEmpty() && miningJobs.isEmpty() && !seeDetails) return
if (!seeDetails) {
DisplaySnack(activeBroadcasts, { seeDetails = true }, accountViewModel)
DisplaySnack(
activeBroadcasts,
miningJobs,
{ if (activeBroadcasts.isNotEmpty()) seeDetails = true },
accountViewModel,
)
LaunchedEffect(activeBroadcasts) {
// this effect gets restarted every time the active broadcast changes
val allComplete = activeBroadcasts.all { it.isComplete }
if (allComplete) {
if (activeBroadcasts.isNotEmpty() && activeBroadcasts.all { it.isComplete }) {
// All relays responded — dismiss quickly
delay(3_000)
accountViewModel.broadcastTracker.clear()
@@ -111,12 +123,15 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
@Composable
fun DisplaySnack(
activeBroadcasts: ImmutableList<BroadcastEvent>,
miningJobs: ImmutableList<PoWJobState>,
onTap: () -> Unit,
accountViewModel: AccountViewModel,
) {
Box(modifier = Modifier.fillMaxSize()) {
BroadcastBanner(
broadcasts = activeBroadcasts,
miningJobs = miningJobs,
onCancelJob = { Amethyst.instance.powPublishQueue.cancel(it) },
onTap = onTap,
onRetryAll = {
activeBroadcasts.forEach { b ->
@@ -0,0 +1,198 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.pow
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.service.pow.formatApproxDuration
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
val POW_PRESETS = listOf(16, 20, 24, 28)
/**
* Composer options-row button showing the NIP-13 difficulty this post will be
* mined at: a manufacturing gear with the difficulty as a small badge when
* mining is on, a dimmed gear when off. Tapping opens a menu to
* raise/lower/disable mining for this post only the account setting is
* untouched.
*
* [effectiveDifficulty] is what will actually be used at send time (override
* or account default); null/0 means the post publishes without PoW.
* [defaultDifficulty] is what the account settings alone would produce, shown
* in the "default" menu entry. [onSelect] receives null to follow the account
* default, 0 to disable for this post, or a positive difficulty.
*/
@Composable
fun PowOverrideButton(
effectiveDifficulty: Int?,
defaultDifficulty: Int?,
isOverridden: Boolean,
onSelect: (Int?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val isActive = effectiveDifficulty != null && effectiveDifficulty > 0
Box {
IconButton(onClick = { expanded = true }) {
Box(
Modifier
.height(20.dp)
.width(23.dp),
) {
Icon(
symbol = MaterialSymbols.Manufacturing,
contentDescription = stringRes(R.string.pow_settings_title),
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
tint =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
},
)
if (isActive) {
Text(
text = effectiveDifficulty.toString(),
fontSize = 9.sp,
lineHeight = 9.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd),
)
}
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
// benchmarked on first open (~250 ms, cached after): each option
// shows what it would cost on THIS device, e.g. "24 bits · ≈ 45 seconds".
val context = LocalContext.current
val hashRate by
produceState<Double?>(initialValue = null) {
value = PoWEstimator.hashesPerSecond()
}
fun eta(difficulty: Int): String? = hashRate?.let { formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, it)) }
fun withEta(
label: String,
difficulty: Int,
): String = eta(difficulty)?.let { "$label · $it" } ?: label
DropdownMenuItem(
text = {
Text(
if (defaultDifficulty != null && defaultDifficulty > 0) {
withEta(
pluralStringResource(R.plurals.pow_option_default_on, defaultDifficulty, defaultDifficulty),
defaultDifficulty,
)
} else {
stringRes(R.string.pow_option_default_off)
},
fontWeight = if (!isOverridden) FontWeight.Bold else null,
)
},
onClick = {
onSelect(null)
expanded = false
},
)
DropdownMenuItem(
text = {
Text(
stringRes(R.string.pow_option_off),
fontWeight = if (isOverridden && !isActive) FontWeight.Bold else null,
)
},
onClick = {
onSelect(0)
expanded = false
},
)
POW_PRESETS.forEach { preset ->
DropdownMenuItem(
text = {
Text(
withEta(pluralStringResource(R.plurals.pow_option_bits, preset, preset), preset),
fontWeight = if (isOverridden && effectiveDifficulty == preset) FontWeight.Bold else null,
)
},
onClick = {
onSelect(preset)
expanded = false
},
)
}
}
}
}
@Preview
@Composable
private fun PowOverrideButtonPreview() {
ThemeComparisonRow {
Row {
PowOverrideButton(
effectiveDifficulty = 24,
defaultDifficulty = 20,
isOverridden = true,
onSelect = {},
)
PowOverrideButton(
effectiveDifficulty = null,
defaultDifficulty = null,
isOverridden = false,
onSelect = {},
)
}
}
}
@@ -20,14 +20,27 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
@Composable
@Preview
@@ -37,13 +50,35 @@ fun DisplayPoWPreview() {
)
}
/**
* Compact pill showing the proof of work a received note carries: a bolt plus
* the difficulty in leading zero bits. Sits inline in note headers, so it
* stays at text height.
*/
@Composable
fun DisplayPoW(pow: Int) {
Text(
"PoW-$pow",
color = MaterialTheme.colorScheme.lessImportantLink,
fontSize = Font14SP,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 6.dp, vertical = 1.dp),
) {
Icon(
symbol = MaterialSymbols.Manufacturing,
contentDescription = stringRes(R.string.pow_settings_title),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(12.dp),
)
Text(
text = pow.toString(),
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontSize = 12.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
@@ -87,6 +88,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -235,6 +237,20 @@ open class CommentPostViewModel :
var wantsAnonymousPost by mutableStateOf(false)
// NIP-13 per-post override from the composer chip: null = follow account
// settings, 0 = don't mine this post, >0 = mine at that difficulty.
var powOverride by mutableStateOf<Int?>(null)
fun effectivePowDifficulty(): Int? {
if (!::accountViewModel.isInitialized) return null
return accountViewModel.account.powDifficultyFor(CommentEvent.KIND, powOverride)
}
fun defaultPowDifficulty(): Int? {
if (!::accountViewModel.isInitialized) return null
return accountViewModel.account.powDifficultyFor(CommentEvent.KIND)
}
// A single ephemeral signer reused for the whole compose session so that media
// uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
// by the same throwaway key, instead of leaking the real account's pubkey into the
@@ -521,8 +537,15 @@ open class CommentPostViewModel :
val draftToDelete = draftNote
val anonymous = wantsAnonymousPost
// 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
@@ -541,19 +564,39 @@ open class CommentPostViewModel :
}
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
// The anonymous key signs without a client tag, so the template is
// mined as-is against the throwaway pubkey — 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 ->
// fresh created_at at mining start (NIP-13 recommendation):
// the job may have waited in the queue behind other posts.
val fresh = EventTemplate<Event>(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()
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays }
accountViewModel.account.sendMined(template, PoWReplay.ToRelays(relays), chosenPow) { readyTemplate ->
accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { relays }
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
} else {
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
}
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
accountViewModel.account.sendMined(template, PoWReplay.Broadcast(extraNotesToBroadcast), chosenPow) { readyTemplate ->
accountViewModel.account.signAndComputeBroadcast(readyTemplate, extraNotesToBroadcast)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
}
@@ -824,6 +867,7 @@ open class CommentPostViewModel :
wantsSecretEmoji = false
wantsAnonymousPost = false
anonymousSignerCache = null
powOverride = null
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.clearText()
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
@@ -505,5 +506,12 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
PowOverrideButton(
effectiveDifficulty = postViewModel.effectivePowDifficulty(),
defaultDifficulty = postViewModel.defaultPowDifficulty(),
isOverridden = postViewModel.powOverride != null,
onSelect = { postViewModel.powOverride = it },
)
}
}
@@ -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)
}
}
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -69,6 +70,8 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
@@ -78,6 +81,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
@@ -144,6 +148,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -279,6 +284,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())
}
}
}
}
/**
@@ -546,7 +564,8 @@ class AccountViewModel(
account.deletePrivately(privateRumors, note)
}
} else {
if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) {
val minesReactions = account.powDifficultyFor(ReactionEvent.KIND) != null
if (!minesReactions && settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) {
// Tracked broadcasting with progress feedback
account.createReactionEvent(note, reaction)?.let { (event, relays) ->
broadcastTracker.trackBroadcast(
@@ -558,7 +577,9 @@ class AccountViewModel(
account.consumeReactionEvent(event)
}
} else {
// Fire-and-forget (original behavior)
// Fire-and-forget (original behavior). When PoW mining is
// on for reactions this path also routes through the
// mining queue, which has its own progress banner.
account.reactTo(note, reaction)
}
}
@@ -1246,11 +1267,17 @@ class AccountViewModel(
}
fun boost(note: Note) =
launchTrackedOrDirect(
createTracked = { account.createBoostEvent(note) },
consumeTracked = account::consumeBoostEvent,
direct = { account.boost(note) },
)
if (account.powDifficultyFor(RepostEvent.KIND) != null) {
// Reposts are mined: route through the queue (which has its own
// progress banner) instead of the inline tracked path.
launchSigner { account.boost(note) }
} else {
launchTrackedOrDirect(
createTracked = { account.createBoostEvent(note) },
consumeTracked = account::consumeBoostEvent,
direct = { account.boost(note) },
)
}
fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) }
@@ -1640,6 +1667,13 @@ class AccountViewModel(
fun updateAddClientTag(add: Boolean) = launchSigner { account.updateAddClientTag(add) }
fun updatePowDifficulty(difficulty: Int) = launchSigner { account.updatePowDifficulty(difficulty) }
fun updatePowCategory(
category: PoWCategory,
enabled: Boolean,
) = launchSigner { account.updatePowCategory(category, enabled) }
fun updateFilterSpam(filterSpam: Boolean) =
launchSigner {
if (account.updateFilterSpam(filterSpam)) {
@@ -1927,7 +1961,7 @@ class AccountViewModel(
}
noteEvent is ChatroomKeyable -> {
account.markAsRead("Room/${noteEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt)
account.markAsRead(privateChatLastReadRoute(noteEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt)
}
noteEvent is DraftWrapEvent -> {
@@ -1935,7 +1969,7 @@ class AccountViewModel(
if (innerEvent is IsInPublicChatChannel) {
account.markAsRead("Channel/${innerEvent.channelId()}", noteEvent.createdAt)
} else if (innerEvent is ChatroomKeyable) {
account.markAsRead("Room/${innerEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt)
account.markAsRead(privateChatLastReadRoute(innerEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt)
}
}
}
@@ -1945,25 +1979,18 @@ class AccountViewModel(
}
}
private fun unreadPrivateChatRoute(chat: Note): Pair<String, Long>? {
val noteEvent = chat.event ?: return null
val room = (noteEvent as? ChatroomKeyable)?.chatroomKey(account.signer.pubKey) ?: return null
if (account.isAllHidden(room.users)) return null
return privateChatRoute(room) to noteEvent.createdAt
}
private fun unreadPrivateChatRoute(chat: Note): Pair<String, Long>? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden)
private fun markHiddenChatroomsAsRead() {
account.chatroomList.rooms.forEach { roomKey, chatroom ->
if (account.isAllHidden(roomKey.users)) {
chatroom.newestMessage?.createdAt()?.let {
account.markAsRead(privateChatRoute(roomKey), it)
account.markAsRead(privateChatLastReadRoute(roomKey), it)
}
}
}
}
private fun privateChatRoute(room: ChatroomKey) = "Room/${room.hashCode()}"
class Factory(
val account: Account,
val settings: UiSettingsState,
@@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
@@ -96,7 +98,10 @@ class EventProcessor(
is CallRenegotiateEvent,
-> callManager?.onSignalingEvent(event)
is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
is ChatroomKeyable -> {
chatHandler.add(event, eventNote, publicNote)
markOwnChatMessageAsRead(event)
}
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
@@ -110,6 +115,23 @@ class EventProcessor(
}
}
/**
* A chat message authored by this account sent from this device through any code
* path, or arriving via the self-addressed gift wrap from another device means the
* user was caught up with the room when they sent it, so advance the room's read
* marker here at the single ingestion choke point rather than at each send site
* (#1286, #1287). markAsRead is monotonic, so out-of-order history sync cannot move
* the marker backwards. Unsent drafts never reach this branch (DraftEventHandler
* indexes their rumors directly into the chatroom).
*/
private fun <T> markOwnChatMessageAsRead(event: T) where T : Event, T : ChatroomKeyable {
val me = account.signer.pubKey
val room = event.chatroomKey(me)
if (chatMessageMarksRoomAsRead(event, room, me)) {
account.markAsRead(privateChatLastReadRoute(room), event.createdAt)
}
}
suspend fun delete(note: Note) {
note.event?.let { event ->
try {
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
@@ -268,7 +269,7 @@ fun ChatroomViewUI(
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
routeForLastRead = privateChatLastReadRoute(room),
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
@@ -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)
}
}
@@ -62,7 +62,9 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription
@@ -144,7 +146,7 @@ fun ChatroomComposeChannelOrUser(
val baseNoteEvent = baseNote.event
if (baseNoteEvent is DraftWrapEvent) {
ObserveDraftEvent(baseNote, accountViewModel) { innerNote ->
ChatroomEntry(innerNote, accountViewModel, nav)
ChatroomEntry(innerNote, accountViewModel, nav, isDraft = true)
}
} else {
ChatroomEntry(baseNote, accountViewModel, nav)
@@ -156,6 +158,7 @@ private fun ChatroomEntry(
lastMessage: Note,
accountViewModel: AccountViewModel,
nav: INav,
isDraft: Boolean = false,
) {
if (lastMessage is RelayGroupServerRoomNote) {
RelayGroupServerRoomCompose(lastMessage, accountViewModel, nav)
@@ -220,7 +223,7 @@ private fun ChatroomEntry(
is ChatroomKeyable -> {
val room = baseNoteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex)
UserRoomCompose(room, lastMessage, accountViewModel, nav)
UserRoomCompose(room, lastMessage, isDraft, accountViewModel, nav)
}
is EphemeralChatEvent -> {
@@ -585,6 +588,7 @@ private fun ChannelTitleWithLabelInfo(
private fun UserRoomCompose(
room: ChatroomKey,
lastMessage: Note,
isDraft: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -636,8 +640,15 @@ private fun UserRoomCompose(
}
}
val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle()
if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) {
// A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
val newestEvent = lastMessage.event
val countsAsRead =
!isDraft &&
newestEvent != null &&
chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey)
val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle()
if (!countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) {
Spacer(modifier = Height4dpModifier)
NewItemsBubble()
}
@@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
@@ -353,9 +354,28 @@ class LongFormPostViewModel :
val draftToDelete = draftNote
cancel()
// 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 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<out Event>) {
if (accountViewModel.settings.useTrackedBroadcasts()) {
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, emptyList())
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.scope.launch {
accountViewModel.broadcastTracker.trackBroadcast(
event = event,
relays = relays,
@@ -366,10 +386,6 @@ class LongFormPostViewModel :
} else {
accountViewModel.account.signAndComputeBroadcast(template, emptyList())
}
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
@@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker
@@ -833,6 +834,13 @@ private fun BottomRowActions(
}
}
PowOverrideButton(
effectiveDifficulty = postViewModel.effectivePowDifficulty(),
defaultDifficulty = postViewModel.defaultPowDifficulty(),
isOverridden = postViewModel.powOverride != null,
onSelect = { postViewModel.powOverride = it },
)
// A group thread's title is required, so the field is always shown for it — no toggle.
if (postViewModel.groupThreadTarget == null) {
AddSubjectButton(postViewModel.wantsSubject) {
@@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
@@ -116,6 +117,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip10Notes.tags.markedETags
import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
@@ -137,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
@@ -173,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,
@@ -381,6 +385,32 @@ open class ShortNotePostViewModel :
// Null = post immediately on Send (existing behavior).
var scheduledForSec by mutableStateOf<Long?>(null)
// NIP-13 per-post override from the composer chip: null = follow account
// settings, 0 = don't mine this post, >0 = mine at that difficulty.
var powOverride by mutableStateOf<Int?>(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
else -> TextNoteEvent.KIND
}
fun effectivePowDifficulty(): Int? {
if (!::accountViewModel.isInitialized) return null
return accountViewModel.account.powDifficultyFor(anticipatedPowKind(), powOverride)
}
fun defaultPowDifficulty(): Int? {
if (!::accountViewModel.isInitialized) return null
return accountViewModel.account.powDifficultyFor(anticipatedPowKind())
}
// AI Writing Help for testing
private val useMockAi = false
@@ -956,13 +986,20 @@ open class ShortNotePostViewModel :
val scheduledFor = scheduledForSec
val privately = wantsPrivateNote
val threadTarget = groupThreadTarget
// 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.
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
@@ -972,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<TextNoteEvent>)
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
accountViewModel.account.sendPrivateNote(template as EventTemplate<TextNoteEvent>, 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<Event>(
createdAt = scheduledFor,
@@ -992,35 +1032,88 @@ open class ShortNotePostViewModel :
tags = template.tags,
content = template.content,
)
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
Amethyst.instance.scheduledPostStore.add(
ScheduledPost(
id =
java.util.UUID
.randomUUID()
.toString(),
accountPubkey = event.pubKey,
signedEventJson = event.toJson(),
relayUrls = relays.map { it.url },
extraEventsJson = extras.map { it.toJson() },
publishAtSec = scheduledFor,
createdAtSec = System.currentTimeMillis() / 1000,
),
)
accountViewModel.launchSigner {
accountViewModel.account.sendMined(
rescheduledTemplate,
PoWReplay.Schedule(scheduledFor, extraNotesToBroadcast),
chosenPow,
) { readyTemplate ->
storeScheduledPost(readyTemplate, extraNotesToBroadcast, scheduledFor)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
return
}
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
// Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
// The anonymous key signs without a client tag, so the template is
// 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 ->
// fresh created_at at mining start (NIP-13 recommendation):
// the job may have waited in the queue behind other posts.
val fresh = EventTemplate<Event>(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)
}
return
}
// Launch broadcast in background - don't wait for completion
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.sendMined(template, PoWReplay.Broadcast(extraNotesToBroadcast), chosenPow) { readyTemplate ->
broadcastPublicPost(readyTemplate, extraNotesToBroadcast)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
/**
* Signs the (possibly mined) re-stamped template and parks it in the
* scheduled-post store for the worker to publish at its created_at time.
* Runs on the mining queue's scope when PoW is on, so it must not touch
* viewModelScope.
*/
private suspend fun storeScheduledPost(
template: EventTemplate<out Event>,
extraNotesToBroadcast: List<Event>,
publishAtSec: Long,
) {
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
Amethyst.instance.scheduledPostStore.add(
ScheduledPost(
id = UUID.randomUUID().toString(),
accountPubkey = event.pubKey,
signedEventJson = event.toJson(),
relayUrls = relays.map { it.url },
extraEventsJson = extras.map { it.toJson() },
publishAtSec = publishAtSec,
createdAtSec = System.currentTimeMillis() / 1000,
),
)
}
/**
* The 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<out Event>,
extraNotesToBroadcast: List<Event>,
) {
if (accountViewModel.settings.useTrackedBroadcasts()) {
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
accountViewModel.account.scope.launch {
accountViewModel.broadcastTracker.trackBroadcast(
event = event,
relays = relays,
@@ -1029,13 +1122,8 @@ open class ShortNotePostViewModel :
accountViewModel.account.consumePostEvent(event, relays, extras)
}
} else {
// Fire-and-forget (original behavior)
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
}
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
@@ -1451,6 +1539,7 @@ open class ShortNotePostViewModel :
wantsAnonymousPost = false
anonymousSignerCache = null
scheduledForSec = null
powOverride = null
wantsPrivateNote = false
privateNoteLocked = false
wantsToAddNotifyUser = false
@@ -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<BaseVoiceEvent>()
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<TextNoteEvent>()
@@ -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)
@@ -829,9 +829,9 @@ private fun RelayHeader(
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier.padding(horizontal = 30.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
FlowRow(
modifier = Modifier.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
) {
OutlinedButton(
shape = ButtonBorder,
@@ -843,7 +843,22 @@ private fun RelayHeader(
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.see_relay_feed))
Text(text = stringRes(R.string.see_relay_feed), maxLines = 1)
}
if (supportsNip29(relayInfo.supported_nips)) {
OutlinedButton(
onClick = { nav.nav(Route.RelayGroupServer(relay.url)) },
shape = ButtonBorder,
) {
Icon(
MaterialSymbols.Groups,
contentDescription = stringRes(R.string.relay_groups_button),
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.relay_groups_button), maxLines = 1)
}
}
if (supportsNip43(relayInfo.supported_nips)) {
@@ -857,7 +872,7 @@ private fun RelayHeader(
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.relay_members))
Text(text = stringRes(R.string.relay_members), maxLines = 1)
}
}
@@ -877,6 +892,8 @@ private fun RelayHeader(
}
}
fun supportsNip29(supportedNips: List<String>?): Boolean = supportedNips?.any { it == "29" } == true
fun supportsNip43(supportedNips: List<String>?): Boolean = supportedNips?.any { it == "43" } == true
@Composable
@@ -939,7 +956,7 @@ fun LimitationsCard(lim: Nip11RelayInformation.RelayInformationLimitation) {
val minPoW = lim.min_pow_difficulty
if (minPoW != null && minPoW > 0) {
InfoRow(MaterialSymbols.Bolt, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW))
InfoRow(MaterialSymbols.Manufacturing, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW))
} else {
lim.min_prefix?.let {
if (it > 0) {
@@ -27,24 +27,35 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.model.AccountPoWPreferences
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.pow.formatApproxDuration
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.creators.pow.POW_PRESETS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -115,9 +126,151 @@ fun ComposeSettingsContent(
SettingsDivider()
SignatureTile(sharedPrefs.composeSignature)
}
SettingsSection(R.string.pow_settings_title) {
PowDifficultyTile(accountViewModel)
SettingsDivider()
PowCategoryChecklist(accountViewModel)
}
}
}
@Composable
private fun PowDifficultyTile(accountViewModel: AccountViewModel) {
val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork
.difficulty
.collectAsStateWithLifecycle()
SettingsBlockTile(
icon = MaterialSymbols.Manufacturing,
title = stringRes(R.string.pow_difficulty_title),
description = stringRes(R.string.pow_difficulty_explainer),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
selected = difficulty <= 0,
onClick = { accountViewModel.updatePowDifficulty(0) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = POW_PRESETS.size + 1),
) {
Text(stringRes(R.string.pow_difficulty_off))
}
POW_PRESETS.forEachIndexed { index, preset ->
SegmentedButton(
selected = difficulty == preset,
onClick = { accountViewModel.updatePowDifficulty(preset) },
shape = SegmentedButtonDefaults.itemShape(index = index + 1, count = POW_PRESETS.size + 1),
) {
Text(preset.toString())
}
}
}
PowTimeEstimate(difficulty)
}
SettingsSubControlRow(
title = stringRes(R.string.pow_custom_difficulty_title),
description = stringRes(R.string.pow_custom_difficulty_explainer),
) {
SettingsStepper(
value = difficulty,
min = 0,
max = AccountPoWPreferences.MAX_POW_DIFFICULTY,
unsetLabel = stringRes(R.string.pow_difficulty_off),
onValueChange = accountViewModel::updatePowDifficulty,
)
}
}
/**
* "≈ 45 s per post on this device" turns the abstract bit count into a cost
* the user can feel. The hash rate is benchmarked once (~250 ms on a worker
* thread) and cached; the figure is a statistical mean, so any single post can
* be luckier or unluckier.
*/
@Composable
private fun PowTimeEstimate(difficulty: Int) {
if (difficulty <= 0) return
val context = LocalContext.current
val estimate by
produceState<String?>(initialValue = null, difficulty) {
val rate = PoWEstimator.hashesPerSecond()
value = formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, rate))
}
estimate?.let {
Text(
text = stringRes(R.string.pow_difficulty_estimate, it),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun PowCategoryChecklist(accountViewModel: AccountViewModel) {
val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork
.difficulty
.collectAsStateWithLifecycle()
val enabledCategories by accountViewModel.account.settings.syncedSettings.proofOfWork
.enabledCategories
.collectAsStateWithLifecycle()
val miningOn = difficulty > 0
SettingsControlRow(
icon = MaterialSymbols.Checklist,
title = stringRes(R.string.pow_categories_title),
description = stringRes(R.string.pow_categories_explainer),
) {}
PoWCategory.entries.forEach { category ->
val checked = category in enabledCategories
SettingsSubControlRow(
title = stringRes(category.titleRes()),
description = stringRes(category.descriptionRes()),
enabled = miningOn,
) {
Switch(
checked = checked,
enabled = miningOn,
onCheckedChange = { accountViewModel.updatePowCategory(category, it) },
)
}
}
}
@StringRes
private fun PoWCategory.titleRes(): Int =
when (this) {
PoWCategory.SHORT_NOTES -> R.string.pow_category_short_notes
PoWCategory.COMMENTS -> R.string.pow_category_comments
PoWCategory.REPORTS -> R.string.pow_category_reports
PoWCategory.LONG_FORM -> R.string.pow_category_long_form
PoWCategory.VOICE -> R.string.pow_category_voice
PoWCategory.REPOSTS -> R.string.pow_category_reposts
PoWCategory.REACTIONS -> R.string.pow_category_reactions
PoWCategory.PUBLIC_CHAT -> R.string.pow_category_public_chat
PoWCategory.GIFT_WRAPS -> R.string.pow_category_gift_wraps
PoWCategory.OTHER_PUBLIC -> R.string.pow_category_other_public
}
@StringRes
private fun PoWCategory.descriptionRes(): Int =
when (this) {
PoWCategory.SHORT_NOTES -> R.string.pow_category_short_notes_explainer
PoWCategory.COMMENTS -> R.string.pow_category_comments_explainer
PoWCategory.REPORTS -> R.string.pow_category_reports_explainer
PoWCategory.LONG_FORM -> R.string.pow_category_long_form_explainer
PoWCategory.VOICE -> R.string.pow_category_voice_explainer
PoWCategory.REPOSTS -> R.string.pow_category_reposts_explainer
PoWCategory.REACTIONS -> R.string.pow_category_reactions_explainer
PoWCategory.PUBLIC_CHAT -> R.string.pow_category_public_chat_explainer
PoWCategory.GIFT_WRAPS -> R.string.pow_category_gift_wraps_explainer
PoWCategory.OTHER_PUBLIC -> R.string.pow_category_other_public_explainer
}
@Composable
private fun SignatureTile(flow: MutableStateFlow<String>) {
val value by flow.collectAsState()
+80 -1
View File
@@ -1855,7 +1855,7 @@
<string name="ots_explorer_search_keywords" translatable="false">opentimestamps, timestamp, ots, proof</string>
<string name="namecoin_search_keywords" translatable="false">namecoin, dns, identity, name</string>
<string name="calendar_reminder_search_keywords" translatable="false">calendar, events, reminders, rsvp</string>
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save, signature</string>
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13</string>
<string name="reactions_settings_search_keywords" translatable="false">emoji, reactions, like</string>
<string name="bottom_bar_search_keywords" translatable="false">navigation, tabs, nav bar</string>
<string name="home_tabs_search_keywords" translatable="false">tabs, feeds, threads, conversations</string>
@@ -2009,6 +2009,7 @@
<string name="relay_group_view_grouped_desc">Collapse each relay\'s groups into a single row, placed at its newest message.</string>
<string name="relay_group_view_mode_title">NIP-29 group display</string>
<string name="relay_group_server_label">Relay groups</string>
<string name="relay_groups_button">Groups</string>
<string name="messages_settings">Messages</string>
<string name="messages_settings_search_keywords" translatable="false">messages, chats, groups, nip-29, relay, inline, dm</string>
<string name="relay_group_create_title">Create a group</string>
@@ -3808,6 +3809,84 @@
<string name="compose_signature_setting_title">Signature</string>
<string name="compose_signature_setting_description">Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable.</string>
<string name="compose_signature_setting_hint">Your signature</string>
<string name="pow_settings_title">Proof of Work</string>
<string name="pow_difficulty_title">Difficulty</string>
<string name="pow_difficulty_explainer">Mines a NIP-13 proof of work into your posts before publishing so relays and readers can weigh them against spam. Higher values take exponentially longer to mine. Posts publish in the background once mined.</string>
<string name="pow_difficulty_off">Off</string>
<string name="pow_custom_difficulty_title">Custom difficulty</string>
<string name="pow_custom_difficulty_explainer">Fine-tune the target in leading zero bits.</string>
<string name="pow_categories_title">What to mine</string>
<string name="pow_categories_explainer">Time-critical events (relay auth, zap requests, wallet and signer messages, drafts, lists) are never mined.</string>
<string name="pow_category_short_notes">Short notes &amp; replies</string>
<string name="pow_category_short_notes_explainer">The primary public spam surface</string>
<string name="pow_category_comments">Comments</string>
<string name="pow_category_comments_explainer">Replies to articles, files and other content</string>
<string name="pow_category_reports">Reports</string>
<string name="pow_category_reports_explainer">Relays weigh reports by their cost</string>
<string name="pow_category_long_form">Long-form &amp; highlights</string>
<string name="pow_category_long_form_explainer">Articles and highlights; infrequent, cost is negligible</string>
<string name="pow_category_voice">Voice messages</string>
<string name="pow_category_voice_explainer">Public voice posts and replies</string>
<string name="pow_category_reposts">Reposts</string>
<string name="pow_category_reposts_explainer">High volume for active users</string>
<string name="pow_category_reactions">Reactions</string>
<string name="pow_category_reactions_explainer">Highest-volume kind; mining every like costs battery</string>
<string name="pow_category_public_chat">Public &amp; live chat</string>
<string name="pow_category_public_chat_explainer">Mining delays hurt conversation flow</string>
<string name="pow_category_gift_wraps">Private message wraps</string>
<string name="pow_category_gift_wraps_explainer">Lets DM relays filter inbox spam; only the outer wrap is mined, never your message</string>
<string name="pow_category_other_public">Other public content</string>
<string name="pow_category_other_public_explainer">Polls, live statuses, classifieds and everything else public</string>
<string name="pow_mining_title">Mining proof of work</string>
<plurals name="pow_mining_progress">
<item quantity="one">Mining proof of work… (%1$d post in queue)</item>
<item quantity="other">Mining proof of work… (%1$d posts in queue)</item>
</plurals>
<plurals name="pow_mining_job">
<item quantity="one">%1$s • mining at %2$d bit</item>
<item quantity="other">%1$s • mining at %2$d bits</item>
</plurals>
<plurals name="pow_queued_job">
<item quantity="one">%1$s • waiting to mine at %2$d bit</item>
<item quantity="other">%1$s • waiting to mine at %2$d bits</item>
</plurals>
<plurals name="pow_option_default_on">
<item quantity="one">Default (%1$d bit)</item>
<item quantity="other">Default (%1$d bits)</item>
</plurals>
<string name="pow_option_default_off">Default (off)</string>
<string name="pow_option_off">Off for this post</string>
<plurals name="pow_option_bits">
<item quantity="one">%1$d bit</item>
<item quantity="other">%1$d bits</item>
</plurals>
<string name="pow_notification_channel_name">Proof of work mining</string>
<string name="pow_notification_channel_description">Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app.</string>
<string name="pow_notification_cancel_all">Cancel</string>
<string name="pow_difficulty_estimate">≈ %1$s per post on this device</string>
<string name="pow_estimate_instant">&lt;1 s</string>
<plurals name="pow_estimate_seconds">
<item quantity="one">%1$d second</item>
<item quantity="other">%1$d seconds</item>
</plurals>
<plurals name="pow_estimate_minutes">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<plurals name="pow_estimate_hours">
<item quantity="one">%1$d hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<plurals name="pow_estimate_days">
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
<string name="pow_time_left">≈ %1$s left</string>
<string name="pow_time_left_soon">any moment now</string>
<string name="pow_publish_failed">Your %1$s finished mining but could not be published: %2$s</string>
<string name="pow_publish_failed_retry">Your %1$s finished mining but could not be published. It will be retried the next time the app starts.</string>
<string name="pow_kind_chat_message">Chat message</string>
<string name="pow_kind_report">Report</string>
<string name="ai_writing_use_this">Use This</string>
<string name="ai_writing_dismiss">Dismiss</string>
<string name="ai_tone_correct">Correct</string>
@@ -0,0 +1,115 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The unread predicate behind the Messages tab dot and the room-row bubble: a room whose
* newest message was authored by the logged-in user counts as read (#1286, #1287), except
* notes-to-self rooms, where the user's own messages are the content still to be seen.
*/
class PrivateChatroomReadStateTest {
private val me: HexKey = "a".repeat(64)
private val peer: HexKey = "b".repeat(64)
private val roomWithPeer = ChatroomKey(persistentSetOf(peer))
private val selfRoom = ChatroomKey(persistentSetOf(me))
private fun message(
from: HexKey,
to: HexKey,
createdAt: Long,
) = ChatMessageEvent(
id = "0".repeat(64),
pubKey = from,
createdAt = createdAt,
tags = arrayOf(arrayOf("p", to)),
content = "hello",
sig = "",
)
@Test
fun newestMessageFromPeerReturnsTheRoomRoute() {
val route = unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { false })
assertEquals(privateChatLastReadRoute(roomWithPeer) to 100L, route)
}
@Test
fun newestMessageAuthoredByMeCountsAsRead() {
assertNull(unreadPrivateChatRoute(message(from = me, to = peer, createdAt = 100), me, isAllHidden = { false }))
}
@Test
fun notesToSelfRoomsCanStillBeUnread() {
val route = unreadPrivateChatRoute(message(from = me, to = me, createdAt = 100), me, isAllHidden = { false })
assertEquals(privateChatLastReadRoute(selfRoom) to 100L, route)
}
@Test
fun hiddenRoomsAreNeverUnread() {
assertNull(unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { true }))
}
@Test
fun missingEventIsNotUnread() {
assertNull(unreadPrivateChatRoute(null, me, isAllHidden = { false }))
}
@Test
fun nonChatEventsAreNotUnread() {
val reaction =
ReactionEvent(
id = "0".repeat(64),
pubKey = peer,
createdAt = 100,
tags = arrayOf(arrayOf("p", me)),
content = "+",
sig = "",
)
assertNull(unreadPrivateChatRoute(reaction, me, isAllHidden = { false }))
}
@Test
fun myMessageMarksAPeerRoomAsRead() {
assertEquals(true, chatMessageMarksRoomAsRead(message(from = me, to = peer, createdAt = 100), roomWithPeer, me))
}
@Test
fun aPeerMessageNeverMarksTheRoomAsRead() {
assertEquals(false, chatMessageMarksRoomAsRead(message(from = peer, to = me, createdAt = 100), roomWithPeer, me))
}
@Test
fun myMessageDoesNotMarkTheSelfRoomAsRead() {
assertEquals(false, chatMessageMarksRoomAsRead(message(from = me, to = me, createdAt = 100), selfRoom, me))
}
}
+4 -1
View File
@@ -215,6 +215,9 @@ Army-knife verbs that operate purely on their arguments. They never touch
| `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. |
| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. |
| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. |
| `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). |
| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. |
| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. |
| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. |
| `amy key public NSEC\|HEX` | Derive the public key from a secret key. |
| `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. |
@@ -381,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| Command | What it does |
|---|---|
| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. |
| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). |
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
+1
View File
@@ -51,6 +51,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Marmot message send / list | ✅ | `commons/marmot/` |
| `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` |
| NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. |
| NIP-13 proof of work (`amy notes post --pow N`, `amy pow check/mine/bench`) | ✅ | `PostCommand` + `PowCommands` — mines pre-signature via quartz `PoWMiner`; `pow mine --pubkey` covers delegated PoW; `pow check` applies the commitment cap. |
| NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. |
| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.cli.commands.OfferCommands
import com.vitorpamplona.amethyst.cli.commands.OutboxCommand
import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands
import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
import com.vitorpamplona.amethyst.cli.commands.PowCommands
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
@@ -263,6 +264,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"dm" -> DmCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
"notes" -> NotesCommands.dispatch(dataDir, tail)
"pow" -> PowCommands.dispatch(dataDir, tail)
"nsite" -> NsiteCommands.dispatch(dataDir, tail)
"napplet" -> NappletCommands.dispatch(dataDir, tail)
"store" -> StoreCommands.dispatch(dataDir, tail)
@@ -430,6 +432,13 @@ private fun printUsage() {
| encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL]]
| verify [EVENT-JSON] check an event's id hash + signature
| (reads stdin when the arg is omitted or `-`)
| pow check EVENT-JSON|- NIP-13: leading-zero bits, committed target,
| effective PoW (capped at the commitment)
| pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON|-
| mine an UNSIGNED template (delegated PoW:
| ids don't commit to sigs, so amy can mine
| for any pubkey); exit 124 on timeout
| pow bench hash rate + expected seconds at 16/20/24/28 bits
| key generate mint a fresh keypair (nsec + npub + hex)
| key public NSEC|HEX derive the public key from a secret key
| key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1
@@ -495,6 +504,8 @@ private fun printUsage() {
|
|Notes (NIP-10 kind:1):
| notes post TEXT [--relay URL] publish a kind:1 short text note
| [--pow BITS [--pow-timeout SECS]] mine a NIP-13 proof of work first
| (exit 124 on timeout, nothing published)
| (--relay accepts comma-separated extras)
| notes feed [--author USER] fetch kind:1 notes
| [--following] (default: own; --author: one user;
@@ -24,22 +24,33 @@ 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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* `amy post <text> [--relay URL ]` publish a NIP-10 kind:1 short text note
* to the user's outbox relays.
* `amy post <text> [--relay URL ] [--pow BITS [--pow-timeout SECS]]`
* publish a NIP-10 kind:1 short text note to the user's outbox relays,
* optionally mining a NIP-13 proof of work into it first. Mining blocks the
* invocation (the CLI process IS the job); `--pow-timeout` aborts with exit
* 124 and publishes nothing.
*
* Threading is intentionally out of scope here `amy post` only handles new
* top-level notes. Replies/quotes need richer event-hint plumbing and will get
* their own verb when needed.
*/
object PostCommand {
private const val MAX_DIFFICULTY = 64
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--relay URL …]")
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--relay URL …] [--pow BITS [--pow-timeout SECS]]")
val text = rest[0]
if (text.isBlank()) return Output.error("bad_args", "post text must not be blank")
@@ -50,20 +61,48 @@ object PostCommand {
?.map { it.trim() }
?.filter { it.isNotEmpty() } ?: emptyList()
val powTarget = args.flags["pow"]?.toIntOrNull()
if (args.flags.containsKey("pow") && (powTarget == null || powTarget < 1 || powTarget > MAX_DIFFICULTY)) {
return Output.error("bad_args", "--pow must be between 1 and $MAX_DIFFICULTY leading zero bits")
}
val powTimeoutSec = args.flags["pow-timeout"]?.toLongOrNull()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val outbox = ctx.outboxRelays()
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`")
}
val signed = ctx.signer.sign(TextNoteEvent.build(text))
val template = TextNoteEvent.build(text)
var powMillis: Long? = null
val readyToSign =
if (powTarget != null) {
System.err.println("mining $powTarget bits…")
val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L }
val startedAt = System.nanoTime()
val mined =
try {
withContext(Dispatchers.Default) {
PoWMiner.run(template, ctx.signer.pubKey, powTarget) {
deadlineNanos == null || System.nanoTime() < deadlineNanos
}
}
} catch (e: CancellationException) {
Output.error("pow_timeout", "did not reach $powTarget bits within ${powTimeoutSec}s; nothing was published")
return 124
}
powMillis = (System.nanoTime() - startedAt) / 1_000_000
System.err.println("mined in ${powMillis}ms")
mined
} else {
template
}
val signed = ctx.signer.sign(readyToSign)
val ack = ctx.publish(signed, targets)
Output.emit(
@@ -72,6 +111,9 @@ object PostCommand {
"kind" to signed.kind,
"created_at" to signed.createdAt,
"content" to signed.content,
"pow" to if (powTarget != null) signed.pow() else null,
"pow_target" to powTarget,
"pow_millis" to powMillis,
"published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
@@ -0,0 +1,205 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip13Pow.commitedPoW
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import com.vitorpamplona.quartz.nip13Pow.pow
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToLong
/**
* `amy pow <check|mine|bench>` NIP-13 proof-of-work primitives.
*
* `check` and `bench` are stateless (no account, no network). `mine` works on
* an UNSIGNED template: because the NIP-01 id does not commit to the
* signature, amy can mine on behalf of any pubkey (NIP-13's delegated PoW)
* pass `--pubkey`, or omit it to mine for the active account.
*/
object PowCommands {
// 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(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"pow",
tail,
"pow <check|mine|bench> …",
mapOf(
"check" to { rest -> check(rest) },
"mine" to { rest -> mine(dataDir, rest) },
"bench" to { rest -> bench() },
),
)
/**
* `amy pow check <event-json | ->` difficulty of a SIGNED event, with the
* NIP-13 commitment rule applied: `effective_pow` is capped at the committed
* target and `valid` covers id+signature (a forged id can claim any PoW).
*/
private fun check(rest: Array<String>): Int {
val json = readPayload(rest) ?: return Output.error("bad_args", "pow check <event-json | -> (- reads stdin)")
val event =
try {
Event.fromJson(json)
} catch (e: Exception) {
return Output.error("bad_event", e.message)
}
val commitment = event.tags.commitedPoW()
Output.emit(
mapOf(
"event_id" to event.id,
"valid" to event.verify(),
"actual_bits" to PoWRankEvaluator.calculatePowRankOf(event.id),
"committed_target" to commitment,
"has_commitment" to (commitment != null),
"effective_pow" to event.pow(),
),
)
return 0
}
/**
* `amy pow mine --target N [--pubkey HEX] [--timeout SECS] <template-json | ->`
* mines an unsigned template and prints it back with the nonce tag, ready
* to be signed by whoever owns the pubkey.
*/
private suspend fun mine(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] <template-json | ->"
val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage)
if (target < 1 || target > MAX_DIFFICULTY) {
return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY")
}
val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage)
val template =
try {
EventTemplate.fromJson(json)
} catch (e: Exception) {
return Output.error("bad_template", e.message)
}
// 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})")
}
).lowercase()
if (pubKey.length != 64 || !Hex.isHex(pubKey)) {
return Output.error("bad_args", "--pubkey must be 64 hex characters")
}
val timeoutSec = args.flags["timeout"]?.toLongOrNull()
val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L }
System.err.println("mining $target bits for ${pubKey.take(8)}")
val startedAt = System.nanoTime()
val mined =
try {
withContext(Dispatchers.Default) {
PoWMiner.run(template, pubKey, target) {
deadlineNanos == null || System.nanoTime() < deadlineNanos
}
}
} catch (e: CancellationException) {
Output.error("pow_timeout", "did not reach $target bits within ${timeoutSec}s")
return 124
}
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
val id =
sha256(
EventHasherSerializer.fastMakeJsonForId(
pubKey = pubKey,
createdAt = mined.createdAt,
kind = mined.kind,
tags = mined.tags,
content = mined.content,
),
).toHexKey()
Output.emit(
mapOf(
"id" to id,
"pubkey" to pubKey,
"pow" to PoWRankEvaluator.calculatePowRankOf(id),
"pow_target" to target,
"pow_millis" to elapsedMs,
"template_json" to mined.toJson(),
),
)
return 0
}
/** `amy pow bench` — hash rate + expected mining time per common target. */
private suspend fun bench(): Int {
val rate = PoWEstimator.hashesPerSecond()
Output.emit(
mapOf(
"hashes_per_second" to rate.roundToLong(),
"expected_seconds" to
listOf(16, 20, 24, 28).associate { bits ->
bits.toString() to PoWEstimator.estimateSeconds(bits, rate)
},
),
)
return 0
}
private fun readPayload(rest: Array<String>): String? {
val arg = rest.firstOrNull() ?: return null
val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg
return payload.trim().ifEmpty { null }
}
}
+1
View File
@@ -0,0 +1 @@
state-pow-headless/
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# pow-headless.sh — verifies amy's NIP-13 primitives without a relay.
#
# One throwaway amy identity in an isolated $HOME. We assert that:
#
# 1. `amy pow bench` reports a positive hash rate and estimates.
# 2. `amy pow mine --target 10 --pubkey HEX <template>` returns a
# template whose recomputed id has >= 10 leading zero bits and
# whose nonce tag commits to "10".
# 3. `amy pow mine` with a 0-second timeout on an impossible target
# exits 124 (the await-timeout contract).
# 4. `amy event --kind 1` piped through `amy pow check -` reports
# valid=true and has_commitment=false for an unmined event.
# 5. Mining via `amy event --tags <mined nonce tag>` … a signed event
# carrying the mined nonce round-trips through `pow check` with
# effective_pow >= 10 and has_commitment=true.
#
# Usage: ./pow-headless.sh [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-pow-headless"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,19p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$SCRIPT_DIR/../lib.sh"
command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; }
if [[ $NO_BUILD -eq 0 ]]; then
step "building amy (installDist)"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "gradle :cli:installDist failed"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
export HOME="$STATE_DIR"
amy() { "$AMY_BIN" --account t --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"; }
banner "amy NIP-13 primitives"
step "init throwaway identity"
amy init >>"$LOG_FILE" || { record_result "init" fail; exit 1; }
PUBKEY="$(amy whoami | jq -r .hex)"
[[ "$PUBKEY" =~ ^[0-9a-f]{64}$ ]] || { record_result "init" fail "no pubkey"; exit 1; }
record_result "init" pass
step "pow bench"
BENCH="$(amy pow bench)"
RATE="$(jq -r .hashes_per_second <<<"$BENCH")"
EST20="$(jq -r '.expected_seconds["20"]' <<<"$BENCH")"
if [[ "$RATE" -gt 0 ]] && jq -e '.expected_seconds["16"] < .expected_seconds["28"]' <<<"$BENCH" >/dev/null; then
record_result "pow-bench" pass "rate=$RATE h/s, 20 bits ≈ ${EST20}s"
else
record_result "pow-bench" fail "$BENCH"
fi
step "pow mine at 10 bits"
TEMPLATE='{"created_at":1683596206,"kind":1,"tags":[],"content":"pow harness"}'
MINE="$(amy pow mine --target 10 --pubkey "$PUBKEY" "$TEMPLATE")"
POW="$(jq -r .pow <<<"$MINE")"
NONCE_TARGET="$(jq -r '.template_json | fromjson | .tags[] | select(.[0]=="nonce") | .[2]' <<<"$MINE")"
if [[ "$POW" -ge 10 && "$NONCE_TARGET" == "10" ]]; then
record_result "pow-mine" pass "pow=$POW committed=$NONCE_TARGET"
else
record_result "pow-mine" fail "$MINE"
fi
step "pow mine timeout exits 124"
amy pow mine --target 60 --timeout 1 --pubkey "$PUBKEY" "$TEMPLATE" >>"$LOG_FILE"
RC=$?
if [[ $RC -eq 124 ]]; then
record_result "pow-mine-timeout" pass
else
record_result "pow-mine-timeout" fail "exit=$RC"
fi
step "pow check on an unmined signed event"
UNMINED="$(amy event --kind 1 --content "no pow here" | jq -c .event)"
CHECK1="$(amy pow check "$UNMINED")"
if jq -e '.valid == true and .has_commitment == false' <<<"$CHECK1" >/dev/null; then
record_result "pow-check-unmined" pass
else
record_result "pow-check-unmined" fail "$CHECK1"
fi
step "mined template signs into a valid PoW event"
NONCE_TAGS="$(jq -c '.template_json | fromjson | .tags' <<<"$MINE")"
CREATED_AT="$(jq -r '.template_json | fromjson | .created_at' <<<"$MINE")"
SIGNED="$(amy event --kind 1 --content "pow harness" --tags "$NONCE_TAGS" --created-at "$CREATED_AT" | jq -c .event)"
CHECK2="$(amy pow check "$SIGNED")"
if jq -e '.valid == true and .has_commitment == true and .effective_pow >= 10' <<<"$CHECK2" >/dev/null; then
record_result "pow-check-mined" pass "effective_pow=$(jq -r .effective_pow <<<"$CHECK2")"
else
record_result "pow-check-mined" fail "$CHECK2"
fi
print_summary
@@ -152,6 +152,7 @@ object MaterialSymbols {
val Lock = MaterialSymbol("\uE899")
val LockOpen = MaterialSymbol("\uE898")
val Mail = MaterialSymbol("\uE159")
val Manufacturing = MaterialSymbol("\uE726")
val MenuBook = MaterialSymbol("\uEA19")
val Mic = MaterialSymbol("\uE31D")
val MicOff = MaterialSymbol("\uE02B")
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.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
import kotlin.time.DurationUnit
import kotlin.time.TimeSource
/**
* Rough on-device estimate of how long mining a given NIP-13 difficulty takes,
* so the settings picker can say "≈ 45 s per post" instead of leaving the user
* to guess what "24 bits" means for their phone.
*
* The benchmark hashes a short-note-sized payload for ~a quarter second on the
* first call and caches the rate. Expected attempts for `d` leading zero bits
* are `2^d` (geometric mean), so the estimate is `2^d / rate` right on
* average, but any individual post can be much luckier or unluckier.
*/
object PoWEstimator {
// representative serialized-event size for a short note; the miner hashes
// the full JSON on every attempt, so payload size sets the rate.
private const val PAYLOAD_BYTES = 300
private const val BATCH = 2_000
private val BENCH_DURATION = 250.milliseconds
private var cachedRate: Double? = null
private val benchLock = Mutex()
suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double =
cachedRate ?: withContext(dispatcher) {
// 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(
difficulty: Int,
hashesPerSecond: Double,
): Double = 2.0.pow(difficulty) / hashesPerSecond.coerceAtLeast(1.0)
private fun benchmark(): Double {
val payload = ByteArray(PAYLOAD_BYTES) { (it % 251).toByte() }
// warm up JIT/caches so the measured window reflects steady state
repeat(3 * BATCH) { sha256(payload) }
val mark = TimeSource.Monotonic.markNow()
var count = 0L
while (mark.elapsedNow() < BENCH_DURATION) {
repeat(BATCH) { sha256(payload) }
count += BATCH
}
return count / mark.elapsedNow().toDouble(DurationUnit.SECONDS)
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.pow
/**
* Durable record of a template mining job so a post survives process death:
* everything needed to re-mine and re-send with no lambda captured the
* unsigned template plus a flat replay descriptor the platform layer turns
* back into the right sign+broadcast call.
*
* Replay types are interpreted by the platform restorer (see the Android
* `PowJobRestorer`): [REPLAY_BROADCAST] signs and broadcasts to the computed
* outbox relays, [REPLAY_RELAYS] publishes to [relayUrls], [REPLAY_SCHEDULE]
* signs and parks the event in the scheduled-post store for [publishAtSec].
*/
data class PersistedPoWJob(
val id: String,
val accountPubkey: String,
val kind: Int,
val difficulty: Int,
/** Unsigned template for the template replay types; empty for [REPLAY_WRAPS]. */
val templateJson: String,
val replayType: String,
val relayUrls: List<String> = emptyList(),
/** Extra pre-signed events to broadcast; for [REPLAY_WRAPS], the signed seals. */
val extraEventsJson: List<String> = emptyList(),
val publishAtSec: Long? = null,
/** [REPLAY_WRAPS]: recipient of each seal in [extraEventsJson], same order. */
val recipientPubkeys: List<String> = 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"
}
}
/**
* Where the queue checkpoints its persistable jobs. Implementations must be
* safe to call from any thread and should apply writes in call order; both
* methods are fire-and-forget from the queue's perspective.
*/
interface PoWJobPersistence {
/** Upserts [job] (re-saving the same id on restore is expected). */
fun save(job: PersistedPoWJob)
/** Drops [jobId] once the job finished, failed, or was cancelled. */
fun remove(jobId: String)
}
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.pow
import com.vitorpamplona.quartz.nip01Core.core.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
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
/**
* User-facing kind groups for the NIP-13 proof-of-work settings checklist.
* Users toggle categories, never raw kind numbers.
*/
enum class PoWCategory(
val id: String,
val defaultEnabled: Boolean,
) {
SHORT_NOTES("short_notes", true),
COMMENTS("comments", true),
REPORTS("reports", true),
LONG_FORM("long_form", true),
VOICE("voice", true),
REPOSTS("reposts", false),
REACTIONS("reactions", false),
PUBLIC_CHAT("public_chat", false),
GIFT_WRAPS("gift_wraps", false),
OTHER_PUBLIC("other_public", false),
;
companion object {
val DEFAULT_ENABLED = entries.filter { it.defaultEnabled }.toSet()
fun fromIds(ids: Collection<String>): Set<PoWCategory> = entries.filter { it.id in ids }.toSet()
}
}
/**
* Decides which events get a NIP-13 proof of work mined into them before signing.
*
* The NEVER rules are hardcoded on purpose (not user preferences) so no caller
* can accidentally mine a relay AUTH challenge, a zap request that blocks an
* invoice fetch, an NWC/bunker RPC, or the drafts that are re-signed on every
* keystroke debounce.
*/
object PoWPolicy {
/**
* 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. */
private val NEVER_ADDRESSABLE =
setOf(
30000, // follow sets
30001, // deprecated generic lists
30002, // relay sets
30003, // bookmark sets
30004, // curation sets (articles)
30005, // curation sets (videos)
30007, // kind mute sets
30015, // interest sets
30030, // emoji sets
30063, // release artifact sets
LONG_FORM_DRAFT_KIND,
AppSpecificDataEvent.KIND,
)
private val NEVER_EXPLICIT =
setOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
LnZapRequestEvent.KIND, // blocks the invoice fetch
OtsEvent.KIND, // machine-generated companion events
DraftWrapEvent.KIND, // re-signed on a 1s debounce while typing
)
/**
* Kinds that must never be mined regardless of user settings.
*
* 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: Kind): Boolean =
kind in NEVER_EXPLICIT ||
kind.isReplaceable() ||
kind.isEphemeral() ||
kind in NEVER_ADDRESSABLE
fun categoryOf(kind: Int): PoWCategory =
when (kind) {
TextNoteEvent.KIND -> PoWCategory.SHORT_NOTES
CommentEvent.KIND -> PoWCategory.COMMENTS
ReportEvent.KIND -> PoWCategory.REPORTS
LongTextNoteEvent.KIND, HighlightEvent.KIND -> PoWCategory.LONG_FORM
VoiceEvent.KIND, VoiceReplyEvent.KIND -> PoWCategory.VOICE
RepostEvent.KIND, GenericRepostEvent.KIND -> PoWCategory.REPOSTS
ReactionEvent.KIND -> PoWCategory.REACTIONS
ChannelMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND -> PoWCategory.PUBLIC_CHAT
GiftWrapEvent.KIND -> PoWCategory.GIFT_WRAPS
else -> PoWCategory.OTHER_PUBLIC
}
/**
* Returns the difficulty to mine [kind] at, or null when the event should
* be published without proof of work. 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,
difficulty: Int,
enabledCategories: Set<PoWCategory>,
): Int? {
if (difficulty <= 0) return null
if (neverMine(kind)) return null
if (categoryOf(kind) !in enabledCategories) return null
return difficulty.coerceAtMost(MAX_DIFFICULTY)
}
}
@@ -0,0 +1,416 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.pow
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.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
import kotlinx.coroutines.job
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/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 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?,
)
/**
* Fire-and-forget NIP-13 mining queue: the Post button enqueues the finished,
* unsigned template here and returns immediately; mining runs on a small
* worker pool and, once the nonce is found, the job hands the mined template
* to the normal sign+broadcast continuation captured at enqueue time.
*
* FIFO with at most [maxConcurrent] concurrent miners so a burst of posts
* queues up instead of spawning unbounded CPU work. 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]. 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
* workers mid-nonce.
*/
class PoWPublishQueue(
private val scope: CoroutineScope,
maxConcurrent: Int = 1,
miningDispatcher: CoroutineDispatcher = Dispatchers.Default,
private val persistence: PoWJobPersistence? = null,
private val onQueueActive: () -> Unit = {},
) {
private class MiningJob(
val id: String,
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<MiningJob>(UNLIMITED)
private val _jobs = MutableStateFlow<ImmutableList<PoWJobState>>(persistentListOf())
/** Queued + mining + publishing jobs, in enqueue order. */
val jobs: StateFlow<ImmutableList<PoWJobState>> = _jobs.asStateFlow()
private val _failures = MutableSharedFlow<PoWJobFailure>(extraBufferCapacity = 16)
/** Post-mining failures (signer rejected, broadcast threw). */
val failures: SharedFlow<PoWJobFailure> = _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<PersistentMap<String, MiningJob>>(persistentMapOf())
init {
repeat(maxConcurrent.coerceAtLeast(1)) {
scope.launch(miningDispatcher) {
for (job in queue) {
process(job)
}
}
}
}
/**
* Mines [template] at [difficulty] and hands the mined template to
* [onMined] on the queue's scope. [onMined] should run the exact
* sign+broadcast path the caller would have used without PoW; the job
* (and its checkpoint) lives until that continuation finishes.
*
* 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
* while mining, and a job that waited in the queue (or was restored after
* a process death) would otherwise publish visibly in the past. Must stay
* false for scheduled posts, whose future created_at is intentional.
*/
fun <T : Event> enqueue(
template: EventTemplate<T>,
pubKey: HexKey,
difficulty: Int,
persistAs: PersistedPoWJob? = null,
refreshCreatedAtOnStart: Boolean = false,
onMined: suspend (EventTemplate<T>) -> Unit,
) = enqueueStaged(
kind = template.kind,
difficulty = difficulty,
persistAs = persistAs,
owner = pubKey,
mine = { isActive ->
val toMine =
if (refreshCreatedAtOnStart) {
EventTemplate<T>(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 <R> 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) } }
}
}
/**
* Enqueues arbitrary in-memory mining work used by flows where the
* mining happens inside a larger build step (e.g. gift wraps, where each
* recipient's ephemeral-key wrap is mined right before its local
* signature) and by anonymous posts, whose throwaway key must not be
* written to disk. Lost on process death.
*
* 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, 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,
) {
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, 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)).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)"
}
queue.trySend(job)
onQueueActive()
}
/**
* 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
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) }
}
/**
* 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) {
removeEntry(job.id, dropCheckpoint = true, persisted = job.persisted)
return
}
setPhase(job.id, PoWJobPhase.MINING)
val workerJob = currentCoroutineContext().job
var detached = false
try {
job.work { !job.cancelled && workerJob.isActive }
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) {
// 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 {
// detached template jobs remove themselves in finishDetached once
// the sign+broadcast continuation completes.
if (!detached) {
removeEntry(job.id, dropCheckpoint = true, persisted = job.persisted)
}
}
}
/**
* 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 {
when {
it.id != jobId -> it
phase == PoWJobPhase.MINING -> it.copy(phase = phase, miningStartedAt = TimeUtils.now())
else -> it.copy(phase = phase)
}
}.toImmutableList()
}
}
private fun removeEntry(
jobId: String,
dropCheckpoint: Boolean,
persisted: Boolean,
) {
pending.update { it.remove(jobId) }
_jobs.update { list -> list.filter { it.id != jobId }.toImmutableList() }
if (persisted && dropCheckpoint) persistence?.remove(jobId)
}
companion object {
private const val TAG = "PoWPublishQueue"
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.pow
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* How to finish publishing a mined template if the original in-memory
* continuation is gone (process death). Callers pass one of these alongside
* their richer live continuation; it is flattened into a [PersistedPoWJob]
* the platform restorer can replay headlessly.
*/
sealed class PoWReplay {
/** Sign and broadcast to the account's computed outbox relays. */
class Broadcast(
val extras: List<Event> = emptyList(),
) : PoWReplay()
/** Sign and publish to exactly [relays] (e.g. a NIP-29 group host). */
class ToRelays(
val relays: List<NormalizedRelayUrl>,
) : PoWReplay()
/** Sign and park in the scheduled-post store for [publishAtSec]. */
class Schedule(
val publishAtSec: Long,
val extras: List<Event> = emptyList(),
) : PoWReplay()
fun toRecord(
id: String,
accountPubkey: String,
template: EventTemplate<*>,
difficulty: Int,
): PersistedPoWJob =
when (this) {
is Broadcast ->
PersistedPoWJob(
id = id,
accountPubkey = accountPubkey,
kind = template.kind,
difficulty = difficulty,
templateJson = template.toJson(),
replayType = PersistedPoWJob.REPLAY_BROADCAST,
extraEventsJson = extras.map { it.toJson() },
createdAtSec = TimeUtils.now(),
)
is ToRelays ->
PersistedPoWJob(
id = id,
accountPubkey = accountPubkey,
kind = template.kind,
difficulty = difficulty,
templateJson = template.toJson(),
replayType = PersistedPoWJob.REPLAY_RELAYS,
relayUrls = relays.map { it.url },
createdAtSec = TimeUtils.now(),
)
is Schedule ->
PersistedPoWJob(
id = id,
accountPubkey = accountPubkey,
kind = template.kind,
difficulty = difficulty,
templateJson = template.toJson(),
replayType = PersistedPoWJob.REPLAY_SCHEDULE,
extraEventsJson = extras.map { it.toJson() },
publishAtSec = publishAtSec,
createdAtSec = TimeUtils.now(),
)
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.pow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PoWPolicyTest {
val allCategories = PoWCategory.entries.toSet()
@Test
fun difficultyOffMinesNothing() {
assertNull(PoWPolicy.shouldMine(1, 0, allCategories))
assertNull(PoWPolicy.shouldMine(1, -5, allCategories))
}
@Test
fun defaultCategoriesMinePrimarySpamSurfaces() {
val defaults = PoWCategory.DEFAULT_ENABLED
// ON by default
assertEquals(20, PoWPolicy.shouldMine(1, 20, defaults))
assertEquals(20, PoWPolicy.shouldMine(1111, 20, defaults))
assertEquals(20, PoWPolicy.shouldMine(1984, 20, defaults))
assertEquals(20, PoWPolicy.shouldMine(30023, 20, defaults))
assertEquals(20, PoWPolicy.shouldMine(9802, 20, defaults))
assertEquals(20, PoWPolicy.shouldMine(1222, 20, defaults))
assertEquals(20, PoWPolicy.shouldMine(1244, 20, defaults))
// OFF by default (opt-in toggles)
assertNull(PoWPolicy.shouldMine(6, 20, defaults))
assertNull(PoWPolicy.shouldMine(16, 20, defaults))
assertNull(PoWPolicy.shouldMine(7, 20, defaults))
assertNull(PoWPolicy.shouldMine(42, 20, defaults))
assertNull(PoWPolicy.shouldMine(1311, 20, defaults))
assertNull(PoWPolicy.shouldMine(1059, 20, defaults))
assertNull(PoWPolicy.shouldMine(1068, 20, defaults))
}
@Test
fun optInCategoriesMineWhenEnabled() {
assertEquals(16, PoWPolicy.shouldMine(7, 16, allCategories))
assertEquals(16, PoWPolicy.shouldMine(6, 16, allCategories))
assertEquals(16, PoWPolicy.shouldMine(1059, 16, allCategories))
assertEquals(16, PoWPolicy.shouldMine(42, 16, allCategories))
// long tail routes through OTHER_PUBLIC
assertEquals(16, PoWPolicy.shouldMine(1068, 16, allCategories))
assertEquals(16, PoWPolicy.shouldMine(30315, 16, allCategories))
}
@Test
fun neverListWinsOverEverySetting() {
val neverKinds =
listOf(
0, // metadata
3, // contact list
9734, // zap request
1040, // OTS attestation
31234, // NIP-37 draft wrap
30024, // long-form draft
22242, // relay auth
13194, // NWC info
23194, // NWC request
23195, // NWC response
23196, // NWC notification
24133, // NIP-46 bunker
27235, // HTTP auth
24242, // Blossom auth
10002, // relay list
10000, // mute list
30000, // follow sets
30078, // app-specific data
)
neverKinds.forEach { kind ->
assertNull(PoWPolicy.shouldMine(kind, 28, allCategories), "kind $kind must never be mined")
assertTrue(PoWPolicy.neverMine(kind), "kind $kind must be in the NEVER list")
}
}
@Test
fun minedContentIsNotInTheNeverList() {
listOf(1, 1111, 1984, 30023, 9802, 1222, 1244, 6, 16, 7, 42, 1311, 1059, 1068).forEach { kind ->
assertTrue(!PoWPolicy.neverMine(kind), "kind $kind must be minable")
}
}
@Test
fun categoryIdsRoundTrip() {
val ids = PoWCategory.entries.map { it.id }
assertEquals(PoWCategory.entries.toSet(), PoWCategory.fromIds(ids))
assertEquals(emptySet(), PoWCategory.fromIds(listOf("bogus")))
}
}
@@ -0,0 +1,330 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.pow
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class PoWPublishQueueTest {
val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val template =
EventTemplate<TextNoteEvent>(
1683596206,
TextNoteEvent.KIND,
emptyArray(),
"A note to mine",
)
@Test
fun minesTemplateThenRunsContinuation() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
val mined = CompletableDeferred<EventTemplate<TextNoteEvent>>()
queue.enqueue(template, pubKey, difficulty = 10) { mined.complete(it) }
val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } }
val powTag = result.tags.firstNotNullOfOrNull { PoWTag.parse(it) }
assertNotNull(powTag, "mined template must carry a nonce tag")
assertEquals(10, powTag.commitment)
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
scope.cancel()
}
@Test
fun cancellingAQueuedJobSkipsItsWork() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
val gate = CompletableDeferred<Unit>()
var secondRan = false
// occupies the single worker until the gate opens
queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() }
queue.enqueueWork(kind = 1, difficulty = 10) { secondRan = true }
val jobs = queue.jobs.value
assertEquals(2, jobs.size)
assertTrue(jobs.map { it.kind }.all { it == 1 })
queue.cancel(jobs[1].id)
assertEquals(1, queue.jobs.value.size, "cancelled job leaves the visible queue immediately")
gate.complete(Unit)
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
assertFalse(secondRan, "cancelled job must never run")
scope.cancel()
}
@Test
fun jobsRunInFifoOrder() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
val order = mutableListOf<Int>()
val done = CompletableDeferred<Unit>()
repeat(3) { index ->
queue.enqueueWork(kind = 1, difficulty = 10) {
order.add(index)
if (index == 2) done.complete(Unit)
}
}
withContext(Dispatchers.Default) { withTimeout(10_000) { done.await() } }
assertEquals(listOf(0, 1, 2), order)
scope.cancel()
}
private class FakePersistence : PoWJobPersistence {
val saved = mutableListOf<String>()
val removed = mutableListOf<String>()
override fun save(job: PersistedPoWJob) {
saved.add(job.id)
}
override fun remove(jobId: String) {
removed.add(jobId)
}
}
private fun recordFor(id: String) =
PersistedPoWJob(
id = id,
accountPubkey = pubKey,
kind = TextNoteEvent.KIND,
difficulty = 10,
templateJson = template.toJson(),
replayType = PersistedPoWJob.REPLAY_BROADCAST,
)
@Test
fun persistedJobIsSavedThenRemovedOnCompletion() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val persistence = FakePersistence()
val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence)
val mined = CompletableDeferred<Unit>()
queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-a")) { mined.complete(Unit) }
assertEquals(listOf("job-a"), persistence.saved)
withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } }
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
assertEquals(listOf("job-a"), persistence.removed)
scope.cancel()
}
@Test
fun cancellingAPersistedJobRemovesItsCheckpoint() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val persistence = FakePersistence()
val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence)
val gate = CompletableDeferred<Unit>()
queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() }
queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-b")) {}
queue.cancel("job-b")
assertTrue("job-b" in persistence.removed, "cancel must drop the checkpoint")
gate.complete(Unit)
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
scope.cancel()
}
@Test
fun refreshCreatedAtReStampsAtMiningStart() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
val mined = CompletableDeferred<EventTemplate<TextNoteEvent>>()
// template stamped in 2023; refresh must bring it to "now"
queue.enqueue(template, pubKey, difficulty = 10, refreshCreatedAtOnStart = true) { mined.complete(it) }
val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } }
assertTrue(
result.createdAt > template.createdAt,
"created_at must be re-stamped at mining start (was ${result.createdAt})",
)
scope.cancel()
}
@Test
fun duplicateJobIdsAreEnqueuedOnce() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val persistence = FakePersistence()
val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence)
val gate = CompletableDeferred<Unit>()
queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() }
queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-c")) {}
queue.enqueue(template, pubKey, difficulty = 10, persistAs = recordFor("job-c")) {}
assertEquals(2, queue.jobs.value.size, "restore-style re-enqueue of the same id must not duplicate")
assertEquals(listOf("job-c"), persistence.saved)
gate.complete(Unit)
withContext(Dispatchers.Default) { withTimeout(60_000) { queue.jobs.first { it.isEmpty() } } }
scope.cancel()
}
@Test
fun failedPublishKeepsCheckpointAndReportsFailure() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val persistence = FakePersistence()
val queue = PoWPublishQueue(scope, maxConcurrent = 1, persistence = persistence)
val failure = CompletableDeferred<PoWJobFailure>()
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<Unit>()
val publishGate = CompletableDeferred<Unit>()
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<Unit>()
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<Unit>()
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()
}
}
@@ -26,10 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.coroutines.cancellation.CancellationException
class PoWMiner(
val buffer: MiningBuffer,
val desiredPoW: Int,
val isActive: () -> Boolean = { true },
) {
val emptyBytesForDesiredPoW = desiredPoW / 8
@@ -38,6 +40,12 @@ class PoWMiner(
fun run() = runDigit(buffer.nonceStarts)
private fun runDigit(index: Int): Boolean {
// checks once every VALID_BYTES.size^2 hashes: cheap enough to not slow
// mining down, frequent enough for cancellation to feel immediate.
if (index + 2 <= buffer.nonceEnds && !isActive()) {
throw CancellationException("PoW mining was cancelled")
}
for (testByte in VALID_BYTES) {
// replaces the background base by the nonce integers
buffer.bytes[index] = testByte
@@ -65,12 +73,20 @@ class PoWMiner(
/**
* The miner creates a stringified json template and changes the nonce directly in the UTF-8 ByteArray representation
* to avoid having to recompute the json objects and stringify it.
*
* [isActive] is polled while mining; returning false aborts the search with a
* [CancellationException] so callers can cancel long-running jobs cooperatively.
*/
fun <T : Event> run(
template: EventTemplate<T>,
pubKey: HexKey,
desiredPoW: Int,
isActive: () -> Boolean = { true },
): EventTemplate<T> {
// 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 {
@@ -90,7 +106,7 @@ class PoWMiner(
val buffer = MiningBuffer(bytes, startIndex, startIndex + nextSize)
if (PoWMiner(buffer, desiredPoW).run()) {
if (PoWMiner(buffer, desiredPoW, isActive).run()) {
return EventTemplate(
template.createdAt,
template.kind,
@@ -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
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip13Pow.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
/**
* A [NostrSigner] decorator that mines a NIP-13 proof of work into the unsigned
* template before delegating to the wrapped signer. Because mining happens on the
* pre-signature template it composes with every signer kind (local key, NIP-55
* external app, NIP-46 bunker).
*
* Only events whose kind is in [kindsToMine] are mined; anything else (e.g. the
* seal and rumor of a gift-wrapped flow) passes through untouched. Templates that
* already carry a nonce tag are not mined again.
*/
class PoWNostrSigner(
val signer: NostrSigner,
val desiredPoW: Int,
val kindsToMine: Set<Int>,
val isActive: () -> Boolean = { true },
) : NostrSigner(signer.pubKey) {
override fun isWriteable(): Boolean = signer.isWriteable()
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T =
if (kind in kindsToMine && tags.none { PoWTag.hasTagWithContent(it) }) {
val mined =
PoWMiner.run(
template = EventTemplate<T>(createdAt, kind, tags, content),
pubKey = pubKey,
desiredPoW = desiredPoW,
isActive = isActive,
)
signer.sign(mined.createdAt, mined.kind, mined.tags, mined.content)
} else {
signer.sign(createdAt, kind, tags, content)
}
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: HexKey,
): String = signer.nip04Encrypt(plaintext, toPublicKey)
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String = signer.nip04Decrypt(ciphertext, fromPublicKey)
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: HexKey,
): String = signer.nip44Encrypt(plaintext, toPublicKey)
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String = signer.nip44Decrypt(ciphertext, fromPublicKey)
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = signer.decryptZapEvent(event)
override suspend fun deriveKey(nonce: HexKey): HexKey = signer.deriveKey(nonce)
override suspend fun signPsbt(psbtHex: String): String = signer.signPsbt(psbtHex)
override fun hasForegroundSupport(): Boolean = signer.hasForegroundSupport()
}
@@ -52,6 +52,6 @@ class PoWTag(
fun assemble(
nonce: String,
commitment: Int?,
) = arrayOfNotNull(TAG_NAME, nonce, commitment.toString())
) = arrayOfNotNull(TAG_NAME, nonce, commitment?.toString())
}
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -47,6 +48,80 @@ class NIP17Factory {
val wraps: List<GiftWrapEvent>,
)
/** 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<AddressedSeal>,
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<HexKey>,
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.
* Local-only no user signer involved. [templateConversion] is the
* pre-sign hook on the wrap template (e.g. NIP-13 mining); see
* [GiftWrapTemplateConversion].
*/
fun wrapSeal(
addressed: AddressedSeal,
expirationDelta: Long?,
recipientRelayHint: NormalizedRelayUrl? = null,
templateConversion: GiftWrapTemplateConversion = { template, _ -> template },
): GiftWrapEvent =
GiftWrapEvent.create(
event = addressed.seal,
recipientPubKey = addressed.recipient,
expirationDelta = expirationDelta,
recipientRelayHint = recipientRelayHint,
templateConversion = templateConversion,
)
/**
* Build one NIP-59 gift wrap per recipient.
*
@@ -75,6 +150,7 @@ class NIP17Factory {
to: Set<HexKey>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
wrapTemplateConversion: GiftWrapTemplateConversion = { template, _ -> template },
): List<GiftWrapEvent> {
val innerExpDelta =
event.expiration()?.let {
@@ -102,6 +178,7 @@ class NIP17Factory {
recipientPubKey = next,
expirationDelta = innerExpDelta,
recipientRelayHint = recipientRelayHints(next),
templateConversion = wrapTemplateConversion,
)
}
bunkerLimiter?.withPermit { build() } ?: build()
@@ -123,9 +200,10 @@ class NIP17Factory {
template: EventTemplate<ChatMessageEvent>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
wrapTemplateConversion: GiftWrapTemplateConversion = { template2, _ -> template2 },
): Result {
val senderMessage = signer.sign(template)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints, wrapTemplateConversion)
return Result(
msg = senderMessage,
wraps = wraps,
@@ -142,9 +220,16 @@ class NIP17Factory {
suspend fun createNoteNIP17(
template: EventTemplate<TextNoteEvent>,
signer: NostrSigner,
wrapTemplateConversion: GiftWrapTemplateConversion = { template2, _ -> template2 },
): Result {
val senderNote = signer.sign(template)
val wraps = createWraps(senderNote, senderNote.taggedUserIds().plus(signer.pubKey).toSet(), signer)
val wraps =
createWraps(
senderNote,
senderNote.taggedUserIds().plus(signer.pubKey).toSet(),
signer,
wrapTemplateConversion = wrapTemplateConversion,
)
return Result(
msg = senderNote,
wraps = wraps,
@@ -155,9 +240,10 @@ class NIP17Factory {
template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
signer: NostrSigner,
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
wrapTemplateConversion: GiftWrapTemplateConversion = { template2, _ -> template2 },
): Result {
val senderMessage = signer.sign(template)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints, wrapTemplateConversion)
return Result(
msg = senderMessage,
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -36,6 +37,15 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.concurrent.Volatile
/**
* Caller hook to adjust the finished wrap template right before the ephemeral
* key signs it e.g. mining a NIP-13 proof of work into it. Receives the
* ephemeral key's pubkey because the NIP-01 id (what a nonce commits to)
* includes it, and the key never leaves [GiftWrapEvent.create]. Must return a
* template of the same kind; the default is the identity.
*/
typealias GiftWrapTemplateConversion = (template: EventTemplate<GiftWrapEvent>, ephemeralPubKey: HexKey) -> EventTemplate<GiftWrapEvent>
@Immutable
open class GiftWrapEvent(
id: HexKey,
@@ -110,6 +120,12 @@ open class GiftWrapEvent(
* the wrap without a separate kind:10050 lookup. Pass it via
* [recipientRelayHint] `null` (the default) preserves the
* historical 2-element `["p", pubkey]` shape.
*
* [templateConversion] runs on the finished template right before the
* ephemeral key signs it. This is how a caller mines a NIP-13 proof
* of work into the wrap itself (the ephemeral-key envelope, never the
* inner seal or rumor) so DM relays can PoW-filter inbox spam
* without this NIP-59 code knowing anything about mining.
*/
fun create(
event: Event,
@@ -117,6 +133,7 @@ open class GiftWrapEvent(
expirationDelta: Long? = null,
createdAt: Long = TimeUtils.randomWithTwoDays(),
recipientRelayHint: NormalizedRelayUrl? = null,
templateConversion: GiftWrapTemplateConversion = { template, _ -> template },
): GiftWrapEvent {
val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key
@@ -132,11 +149,21 @@ open class GiftWrapEvent(
PTag.assemble(recipientPubKey, recipientRelayHint),
)
val template =
EventTemplate<GiftWrapEvent>(
createdAt = createdAt,
kind = KIND,
tags = tags,
content = signer.nip44Encrypt(event.toJson(), recipientPubKey),
)
val readyToSign = templateConversion(template, signer.pubKey)
return signer.sign(
createdAt = createdAt,
kind = KIND,
tags = tags,
content = signer.nip44Encrypt(event.toJson(), recipientPubKey),
createdAt = readyToSign.createdAt,
kind = readyToSign.kind,
tags = readyToSign.tags,
content = readyToSign.content,
)
}
}
@@ -102,6 +102,14 @@ class NostrSignerWithClientTag(
override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport()
/**
* The exact tag set [sign] will forward to the inner signer. Callers that
* transform the template before signing (e.g. NIP-13 mining, which commits
* the tags into the hashed id) must mine over this final shape, otherwise
* the client tag appended at sign time would invalidate the nonce.
*/
fun prepareTags(tags: Array<Array<String>>): Array<Array<String>> = if (disabled()) tags else appendClientTag(tags)
private fun appendClientTag(tags: Array<Array<String>>): Array<Array<String>> {
// Don't add if a client tag already exists
if (tags.any { it.size >= 2 && it[0] == ClientTag.TAG_NAME }) return tags
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip13Pow
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class PoWMinerCancellationTest {
val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val baseTemplate =
EventTemplate<TextNoteEvent>(
1683596206,
TextNoteEvent.KIND,
emptyArray(),
"A note to mine",
)
@Test
fun cancellationAbortsAnImpossibleSearch() {
var polls = 0
// 256 bits of PoW never completes; only the isActive check can end the run.
assertFailsWith<CancellationException> {
PoWMiner.run(baseTemplate, pubKey, 256) {
polls++ < 3
}
}
assertTrue(polls in 4..10, "expected the miner to stop right after isActive flipped, polled $polls times")
}
@Test
fun activeMinerStillFindsPoW() {
val desiredPoW = 12
val mined = PoWMiner.run(baseTemplate, pubKey, desiredPoW) { true }
val powTag = mined.tags.firstNotNullOfOrNull { PoWTag.parse(it) }
assertNotNull(powTag, "mined template must carry a nonce tag")
val id =
sha256(
EventHasherSerializer.fastMakeJsonForId(
pubKey = pubKey,
createdAt = mined.createdAt,
kind = mined.kind,
tags = mined.tags,
content = mined.content,
),
)
assertTrue(
PoWRankEvaluator.atLeastPowRank(id, desiredPoW, desiredPoW / 8),
"mined id must reach the desired PoW",
)
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip13Pow
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
class PoWTagTest {
@Test
fun assembleMatchesNip13Example() {
// NIP-13's example: ["nonce", "776797", "20"]
assertContentEquals(arrayOf("nonce", "776797", "20"), PoWTag.assemble("776797", 20))
}
@Test
fun assembleWithoutCommitmentOmitsTheThirdEntry() {
// a null commitment must not serialize as the literal string "null"
assertContentEquals(arrayOf("nonce", "776797"), PoWTag.assemble("776797", null))
}
@Test
fun parseRoundTrips() {
val parsed = PoWTag.parse(arrayOf("nonce", "776797", "20"))!!
assertEquals("776797", parsed.nonce)
assertEquals(20, parsed.commitment)
assertContentEquals(arrayOf("nonce", "776797", "20"), parsed.toTagArray())
val noCommitment = PoWTag.parse(arrayOf("nonce", "776797"))!!
assertNull(noCommitment.commitment)
assertContentEquals(arrayOf("nonce", "776797"), noCommitment.toTagArray())
}
}