mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
This commit is contained in:
@@ -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
|
||||
|
||||
+10
@@ -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
|
||||
}
|
||||
+3
@@ -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
|
||||
}
|
||||
+319
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
-11
@@ -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() {
|
||||
|
||||
+22
-7
@@ -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 ->
|
||||
|
||||
+198
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+51
-7
@@ -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()
|
||||
|
||||
+8
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+45
-18
@@ -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,
|
||||
|
||||
+23
-1
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+9
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -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()
|
||||
}
|
||||
|
||||
+21
-5
@@ -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() {
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+124
-35
@@ -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
|
||||
|
||||
+9
-3
@@ -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)
|
||||
|
||||
+23
-6
@@ -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) {
|
||||
|
||||
+153
@@ -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()
|
||||
|
||||
@@ -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 & 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 & 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 & 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"><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>
|
||||
|
||||
+115
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
state-pow-headless/
|
||||
Executable
+124
@@ -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
|
||||
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
+83
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
@@ -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)
|
||||
}
|
||||
+163
@@ -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)
|
||||
}
|
||||
}
|
||||
+416
@@ -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"
|
||||
}
|
||||
}
|
||||
+95
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+112
@@ -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")))
|
||||
}
|
||||
}
|
||||
+330
@@ -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,
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+96
@@ -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,
|
||||
|
||||
+31
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+83
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user