From 6c7e4fd0dcdc6c38fb561c101c10b5707ef21cc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:41:23 +0000 Subject: [PATCH] feat(pow): keep created_at current while mining NIP-13: "It is recommended to update the created_at as well during this process." We only did half of it -- PoWPublishQueue and the anonymous post paths re-stamped the timestamp when a worker picked the job up, but the mining run itself was frozen. At 28 bits that is about a minute and at 30 several, so a post could still land in the feed minutes in the past. PoWMiner.run/mine take an optional refreshCreatedAt clock. A search pass now ends on either nonce-space exhaustion or a one-second budget (matching created_at's resolution), and each new pass re-stamps from the clock and rebuilds the payload. The returned template carries the timestamp its nonce actually commits to. Restamps are clamped with maxOf(previous, now) so a wall clock stepping back cannot drag a post into the past, and a pass that stopped on the clock does not widen the nonce -- its space is untouched, it just gets searched under the next timestamp. Left frozen wherever created_at is meaningful: scheduled posts (the queue reuses the existing predicate, renamed refreshCreatedAtOnStart -> refreshCreatedAt now that it covers the whole run), NIP-59 gift wraps with deliberately randomized timestamps, and amy pow, which mines a template the caller supplied. Replaceable and ephemeral kinds never reach the miner. Trailing-lambda call sites became explicit isActive = { ... } so the new optional parameter cannot capture them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT --- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/service/pow/PowJobRestorer.kt | 2 +- .../nip22Comments/CommentPostViewModel.kt | 2 +- .../send/ChannelNewMessageViewModel.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 2 +- .../amethyst/cli/commands/GeochatCommands.kt | 2 +- .../amethyst/cli/commands/PostCommand.kt | 12 +- .../amethyst/cli/commands/PowCommands.kt | 4 +- .../commons/service/pow/PoWPublishQueue.kt | 64 +++---- .../service/pow/PoWPublishQueueTest.kt | 2 +- .../quartz/nip13Pow/miner/PoWMiner.kt | 90 ++++++++-- .../quartz/nip13Pow/signer/PoWNostrSigner.kt | 7 + .../nip13Pow/PoWMinerCancellationTest.kt | 6 +- .../quartz/nip13Pow/PoWMinerCreatedAtTest.kt | 164 ++++++++++++++++++ .../quartz/nip13Pow/PoWMinerParallelTest.kt | 4 +- 15 files changed, 310 insertions(+), 59 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCreatedAtTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 458d7c7b64..ee50121463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1168,7 +1168,7 @@ class Account( persistAs = record, // NIP-13 recommends refreshing created_at while mining; scheduled // posts keep their intentional future timestamp. - refreshCreatedAtOnStart = replay !is PoWReplay.Schedule, + refreshCreatedAt = replay !is PoWReplay.Schedule, onMined = onMined, ) return true @@ -1272,12 +1272,12 @@ class Account( val workers = powMinerWorkers() return if (currentSigner is NostrSignerWithClientTag) { NostrSignerWithClientTag( - inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive, workers), + inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive, workers, TimeUtils::now), clientTag = currentSigner.clientTag, disabled = currentSigner.disabled, ) } else { - PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive, workers) + PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive, workers, TimeUtils::now) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index 1d714ce29e..e28ec90c8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -77,7 +77,7 @@ class PowJobRestorer( // 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, + refreshCreatedAt = record.replayType != PersistedPoWJob.REPLAY_SCHEDULE, ) { mined -> replay(account, record, mined) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 822ea06026..93e11a2eca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -620,7 +620,7 @@ open class CommentPostViewModel : // fresh created_at at mining start (NIP-13 recommendation): // the job may have waited in the queue behind other posts. val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) - val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive) + val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive, TimeUtils::now) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a8573a9032..97cedbaf59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -448,7 +448,7 @@ open class ChannelNewMessageViewModel : val deadline = System.nanoTime() + 2_000_000_000L val threads = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) runCatching { - PoWMiner.mine(template, pubKeyHex, 8, threads) { System.nanoTime() < deadline } + PoWMiner.mine(template, pubKeyHex, 8, threads, isActive = { System.nanoTime() < deadline }) }.getOrDefault(template) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 6f31c72231..22cc60b11c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1105,7 +1105,7 @@ open class ShortNotePostViewModel : // fresh created_at at mining start (NIP-13 recommendation): // the job may have waited in the queue behind other posts. val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) - val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive) + val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive, TimeUtils::now) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt index 22c74904ce..af5e9f1670 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt @@ -204,7 +204,7 @@ object GeochatCommands { val deadline = System.nanoTime() + args.longFlag("pow-timeout", DEFAULT_POW_TIMEOUT_SECS) * 1_000_000_000L template = withContext(Dispatchers.Default) { - PoWMiner.mine(template, keyPair.pubKey.toHexKey(), powBits, defaultThreads()) { System.nanoTime() < deadline } + PoWMiner.mine(template, keyPair.pubKey.toHexKey(), powBits, defaultThreads(), isActive = { System.nanoTime() < deadline }) } } val event = signer.sign(template) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt index d6222f0cf7..7b835e7824 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip13Pow.pow +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException @@ -89,9 +90,14 @@ object PostCommand { val mined = try { withContext(Dispatchers.Default) { - PoWMiner.mine(template, ctx.signer.pubKey, powTarget, threads) { - deadlineNanos == null || System.nanoTime() < deadlineNanos - } + PoWMiner.mine( + template, + ctx.signer.pubKey, + powTarget, + threads, + isActive = { deadlineNanos == null || System.nanoTime() < deadlineNanos }, + refreshCreatedAt = TimeUtils::now, + ) } } catch (e: CancellationException) { Output.error("timeout", "pow: did not reach $powTarget bits within ${powTimeoutSec}s; nothing was published") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt index 3f96b28a79..d6b5c0a475 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt @@ -168,9 +168,9 @@ object PowCommands { val mined = try { withContext(Dispatchers.Default) { - PoWMiner.mine(template, pubKey, target, threads) { + PoWMiner.mine(template, pubKey, target, threads, isActive = { deadlineNanos == null || System.nanoTime() < deadlineNanos - } + }) } } catch (e: CancellationException) { Output.error("timeout", "pow: did not reach $target bits within ${timeoutSec}s") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index cce1d27f43..49f42f3e58 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -191,45 +191,51 @@ class PoWPublishQueue( * 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. + * [refreshCreatedAt] keeps the template's created_at current — re-stamped to + * "now" when a worker picks the job up, and again roughly once a second for + * as long as the miner runs. NIP-13 recommends updating created_at while + * mining, and a job that waited in the queue (or was restored after a + * process death, or simply mined for two minutes) would otherwise publish + * visibly in the past. Must stay false for scheduled posts, whose future + * created_at is intentional. */ fun enqueue( template: EventTemplate, pubKey: HexKey, difficulty: Int, persistAs: PersistedPoWJob? = null, - refreshCreatedAtOnStart: Boolean = false, + refreshCreatedAt: Boolean = false, onMined: suspend (EventTemplate) -> Unit, - ) = enqueueStaged( - kind = template.kind, - difficulty = difficulty, - persistAs = persistAs, - owner = pubKey, - mine = { isActive -> - val toMine = - if (refreshCreatedAtOnStart) { - EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + ) { + val clock = if (refreshCreatedAt) TimeUtils::now else null + + enqueueStaged( + kind = template.kind, + difficulty = difficulty, + persistAs = persistAs, + owner = pubKey, + mine = { isActive -> + val toMine = + if (clock != null) { + EventTemplate(clock(), template.kind, template.tags, template.content) + } else { + template + } + PoWMiner.mine(toMine, pubKey, difficulty, minerThreads, isActive, clock) + }, + publish = onMined, + // send-now fallback: the same template, minus the nonce the miner would + // have added. created_at follows the mined path — refreshed to "now" for + // ordinary posts, left intact for scheduled ones. + sendWithoutPow = { + if (clock != null) { + EventTemplate(clock(), template.kind, template.tags, template.content) } else { template } - PoWMiner.mine(toMine, pubKey, difficulty, minerThreads, isActive) - }, - publish = onMined, - // send-now fallback: the same template, minus the nonce the miner would - // have added. created_at follows the mined path — refreshed to "now" for - // ordinary posts, left intact for scheduled ones. - sendWithoutPow = { - if (refreshCreatedAtOnStart) { - EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) - } else { - template - } - }, - ) + }, + ) + } /** * The staged primitive behind [enqueue]: [mine] runs on the capped worker diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt index 74535d9bf1..78fa2dc8cf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt @@ -244,7 +244,7 @@ class PoWPublishQueueTest { val mined = CompletableDeferred>() // template stamped in 2023; refresh must bring it to "now" - queue.enqueue(template, pubKey, difficulty = 10, refreshCreatedAtOnStart = true) { mined.complete(it) } + queue.enqueue(template, pubKey, difficulty = 10, refreshCreatedAt = true) { mined.complete(it) } val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } } assertTrue( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt index cb47baebf2..41096e59b3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt @@ -31,6 +31,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource class PoWMiner( val buffer: MiningBuffer, @@ -39,6 +41,9 @@ class PoWMiner( // first nonce byte the search is allowed to change; bytes between // nonceStarts and this index stay fixed (a parallel worker's prefix). val searchFrom: Int = buffer.nonceStarts, + // ends the current pass without cancelling it, so the caller can restamp + // created_at and search a brand-new space. Polled alongside [isActive]. + val isPassOver: () -> Boolean = { false }, ) { val emptyBytesForDesiredPoW = desiredPoW / 8 @@ -46,15 +51,31 @@ class PoWMiner( // 32-byte array per hash, keeping the hot loop allocation-free. private val hashOut = ByteArray(32) + /** + * True when the last [run] stopped because [isPassOver] flipped rather than + * because the nonce space ran out — the space is still unexplored, so the + * caller should retry it under a new created_at instead of widening it. + */ + var passedOver = false + private set + fun reachedDesiredPoW(byteArray: ByteArray) = PoWRankEvaluator.atLeastPowRank(sha256Into(hashOut, byteArray, byteArray.size), desiredPoW, emptyBytesForDesiredPoW) - fun run() = runDigit(searchFrom) + fun run(): Boolean { + passedOver = false + return runDigit(searchFrom) + } 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") + if (index + 2 <= buffer.nonceEnds) { + if (!isActive()) throw CancellationException("PoW mining was cancelled") + + if (isPassOver()) { + passedOver = true + return false + } } for (testByte in VALID_BYTES) { @@ -63,6 +84,9 @@ class PoWMiner( if (index + 1 < buffer.nonceEnds) { if (runDigit(index + 1)) return true + // unwind the whole recursion rather than stepping to the next + // byte: the pass is over, not just this branch. + if (passedOver) return false } else { if (reachedDesiredPoW(buffer.bytes)) return true } @@ -73,6 +97,14 @@ class PoWMiner( companion object { private const val STARTING_NONCE_SIZE = 5 + /** + * How long one pass searches before it stops to restamp created_at. + * created_at has one-second resolution, so a shorter pass would rebuild + * the payload for the same timestamp and a longer one would let the + * post go stale. Only applies when a clock is supplied. + */ + private val PASS_BUDGET = 1.seconds + // make sure these chars are not escaped by the JSON stringifier private val VALID_CHARS: List = ('0'..'9') + ('a'..'z') + ('A'..'Z') + "-()[]{}$@!*=;:?,".toCharArray().toList() @@ -91,13 +123,23 @@ class PoWMiner( * * [isActive] is polled while mining; returning false aborts the search with a * [CancellationException] so callers can cancel long-running jobs cooperatively. + * + * [refreshCreatedAt] opts into NIP-13's *"it is recommended to update the + * `created_at` as well during this process"*: the search restamps the + * template from that clock roughly once a second, so a post that mines for + * minutes does not publish minutes in the past. The returned template + * carries the timestamp the nonce actually commits to. Leave it null + * wherever created_at is meaningful — scheduled posts, NIP-59 wraps with + * deliberately randomized timestamps — and the search stays frozen exactly + * as before. */ fun run( template: EventTemplate, pubKey: HexKey, desiredPoW: Int, isActive: () -> Boolean = { true }, - ): EventTemplate = search(template, pubKey, desiredPoW, isActive, "") + refreshCreatedAt: (() -> Long)? = null, + ): EventTemplate = search(template, pubKey, desiredPoW, isActive, "", refreshCreatedAt) /** * [noncePrefix] is kept verbatim at the front of the nonce while only the @@ -110,21 +152,27 @@ class PoWMiner( desiredPoW: Int, isActive: () -> Boolean, noncePrefix: String, + refreshCreatedAt: (() -> Long)?, ): EventTemplate { // sha256 ids have 256 bits; anything outside would index past the // hash (or never terminate) deep inside the hot loop. require(desiredPoW in 1..256) { "desiredPoW must be in 1..256, was $desiredPoW" } var nextSize = STARTING_NONCE_SIZE + var createdAt = template.createdAt do { + // never backwards: a wall clock that steps back (or a template + // deliberately stamped ahead) must not drag the post into the past. + if (refreshCreatedAt != null) createdAt = maxOf(createdAt, refreshCreatedAt()) + val initialNonce = noncePrefix + randomBase(nextSize) val bytes = EventHasherSerializer .fastMakeJsonForId( pubKey = pubKey, - createdAt = template.createdAt, + createdAt = createdAt, kind = template.kind, tags = template.tags + PoWTag.assemble(initialNonce, desiredPoW), content = template.content, @@ -134,17 +182,32 @@ class PoWMiner( val buffer = MiningBuffer(bytes, startIndex, startIndex + initialNonce.length) - if (PoWMiner(buffer, desiredPoW, isActive, startIndex + noncePrefix.length).run()) { + val passStart = TimeSource.Monotonic.markNow() + val isPassOver: () -> Boolean = + if (refreshCreatedAt != null) { + { passStart.elapsedNow() >= PASS_BUDGET } + } else { + { false } + } + + val miner = PoWMiner(buffer, desiredPoW, isActive, startIndex + noncePrefix.length, isPassOver) + + if (miner.run()) { return EventTemplate( - template.createdAt, + createdAt, template.kind, template.tags + PoWTag.assemble(buffer.nonce(), desiredPoW), template.content, ) - } else { + } else if (!miner.passedOver) { + // only an exhausted space needs a wider nonce; a pass that + // stopped on the clock still has all of its own left to try + // under the next timestamp. nextSize += STARTING_NONCE_SIZE } - } while (nextSize < 50) + // with a clock the search never runs out of space — every restamp + // opens a fresh one — so only isActive ends it. + } while (refreshCreatedAt != null || nextSize < 50) throw RuntimeException("Could not find PoW") } @@ -183,6 +246,10 @@ class PoWMiner( * * Throws the same [CancellationException] as [run] when [isActive] flips * false before a nonce is found. + * + * [refreshCreatedAt] behaves as in [run]. Workers restamp independently, + * so the winner's template carries its own timestamp — which is the one + * its nonce commits to. */ suspend fun mine( template: EventTemplate, @@ -190,9 +257,10 @@ class PoWMiner( desiredPoW: Int, workers: Int = 1, isActive: () -> Boolean = { true }, + refreshCreatedAt: (() -> Long)? = null, ): EventTemplate { require(workers >= 1) { "workers must be >= 1, was $workers" } - if (workers == 1) return run(template, pubKey, desiredPoW, isActive) + if (workers == 1) return run(template, pubKey, desiredPoW, isActive, refreshCreatedAt) return coroutineScope { val winner = CompletableDeferred>() @@ -203,7 +271,7 @@ class PoWMiner( winner.complete( search(template, pubKey, desiredPoW, { isActive() && !winner.isCompleted - }, workerPrefix(worker, workers)), + }, workerPrefix(worker, workers), refreshCreatedAt), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt index 0aeb529e1e..de180cdbb6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt @@ -41,6 +41,11 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent * * [workers] parallel searches race over disjoint nonce slices (see * [PoWMiner.mine]); 1 keeps the historical single-threaded behavior. + * + * [refreshCreatedAt] keeps the timestamp current while mining, as NIP-13 + * recommends; null (the default) mines against the createdAt the caller passed + * to [sign]. Only the mined kinds are affected — pass-through kinds always keep + * their original timestamp. */ class PoWNostrSigner( val signer: NostrSigner, @@ -48,6 +53,7 @@ class PoWNostrSigner( val kindsToMine: Set, val isActive: () -> Boolean = { true }, val workers: Int = 1, + val refreshCreatedAt: (() -> Long)? = null, ) : NostrSigner(signer.pubKey) { override fun isWriteable(): Boolean = signer.isWriteable() @@ -65,6 +71,7 @@ class PoWNostrSigner( desiredPoW = desiredPoW, workers = workers, isActive = isActive, + refreshCreatedAt = refreshCreatedAt, ) signer.sign(mined.createdAt, mined.kind, mined.tags, mined.content) } else { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt index d12aaf5a76..3cf94e62f5 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCancellationTest.kt @@ -49,9 +49,9 @@ class PoWMinerCancellationTest { var polls = 0 // 256 bits of PoW never completes; only the isActive check can end the run. assertFailsWith { - PoWMiner.run(baseTemplate, pubKey, 256) { + PoWMiner.run(baseTemplate, pubKey, 256, isActive = { polls++ < 3 - } + }) } assertTrue(polls in 4..10, "expected the miner to stop right after isActive flipped, polled $polls times") } @@ -59,7 +59,7 @@ class PoWMinerCancellationTest { @Test fun activeMinerStillFindsPoW() { val desiredPoW = 12 - val mined = PoWMiner.run(baseTemplate, pubKey, desiredPoW) { true } + val mined = PoWMiner.run(baseTemplate, pubKey, desiredPoW, isActive = { true }) val powTag = mined.tags.firstNotNullOfOrNull { PoWTag.parse(it) } assertNotNull(powTag, "mined template must carry a nonce tag") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCreatedAtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCreatedAtTest.kt new file mode 100644 index 0000000000..7e867d6c16 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerCreatedAtTest.kt @@ -0,0 +1,164 @@ +/* + * 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.MiningBuffer +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.miner.indexOf +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.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * NIP-13: "It is recommended to update the `created_at` as well during this + * process." The mined nonce commits to a specific created_at, so the timestamp + * the miner returns must be the one it actually hashed. + */ +class PoWMinerCreatedAtTest { + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val originalCreatedAt = 1683596206L + + val baseTemplate = + EventTemplate( + originalCreatedAt, + TextNoteEvent.KIND, + emptyArray(), + "A note to mine", + ) + + private fun assertCommitsTo( + mined: EventTemplate, + desiredPoW: Int, + ) { + 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), + "the returned created_at must be the one the nonce commits to", + ) + } + + @Test + fun withoutAClockCreatedAtStaysFrozen() { + val mined = PoWMiner.run(baseTemplate, pubKey, 12) + + assertEquals(originalCreatedAt, mined.createdAt) + assertCommitsTo(mined, 12) + } + + @Test + fun theRefreshedTimestampIsTheOneMined() { + // the restamp happens at the top of the first pass too, so a single-pass + // search already returns (and commits to) the clock's timestamp. + val laterCreatedAt = originalCreatedAt + 3600 + val mined = PoWMiner.run(baseTemplate, pubKey, 12, refreshCreatedAt = { laterCreatedAt }) + + assertEquals(laterCreatedAt, mined.createdAt) + assertCommitsTo(mined, 12) + } + + @Test + fun createdAtNeverMovesBackwards() { + // a wall clock that steps back, or a template deliberately stamped ahead + // of now, must not drag the post into the past. + val mined = PoWMiner.run(baseTemplate, pubKey, 12, refreshCreatedAt = { originalCreatedAt - 3600 }) + + assertEquals(originalCreatedAt, mined.createdAt) + assertCommitsTo(mined, 12) + } + + @Test + fun aLongSearchRestampsOncePerPass() { + // 256 bits never completes, so every pass ends on the one-second budget + // rather than on a win — which is exactly the case NIP-13 is about. + val handedOut = mutableListOf() + var tick = originalCreatedAt + + assertFailsWith { + PoWMiner.run( + baseTemplate, + pubKey, + 256, + isActive = { handedOut.size < 3 }, + refreshCreatedAt = { + tick += 1 + tick.also { handedOut.add(it) } + }, + ) + } + + assertTrue(handedOut.size >= 2, "a multi-second search must restamp more than once, got ${handedOut.size}") + assertEquals(handedOut.sorted(), handedOut, "restamps must be non-decreasing") + } + + @Test + fun aPassThatRunsOutOfTimeUnwindsWithoutExhaustingTheSpace() { + val buffer = buildBuffer() + // 256 bits is unreachable: without the pass hook this enumerates the + // whole nonce space and returns false only after millions of hashes. + val miner = PoWMiner(buffer, 256, isPassOver = { true }) + + assertFalse(miner.run(), "an expired pass has found nothing") + assertTrue(miner.passedOver, "the caller must be able to tell expiry from exhaustion") + } + + @Test + fun aPassThatIsNeverOverBehavesExactlyAsBefore() { + val buffer = buildBuffer() + val miner = PoWMiner(buffer, 8, isPassOver = { false }) + + assertTrue(miner.run(), "8 bits is reachable well inside one nonce space") + assertFalse(miner.passedOver) + } + + private fun buildBuffer(): MiningBuffer { + val nonce = "abcde" + val bytes = + EventHasherSerializer.fastMakeJsonForId( + pubKey = pubKey, + createdAt = originalCreatedAt, + kind = baseTemplate.kind, + tags = baseTemplate.tags + PoWTag.assemble(nonce, 8), + content = baseTemplate.content, + ) + val start = bytes.indexOf(nonce.encodeToByteArray()) + return MiningBuffer(bytes, start, start + nonce.length) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt index d877a481ec..6ca113f497 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt @@ -79,9 +79,9 @@ class PoWMinerParallelTest { val start = TimeSource.Monotonic.markNow() // 256 bits never completes; only the isActive deadline can end the run. assertFailsWith { - PoWMiner.mine(baseTemplate, pubKey, 256, workers = 4) { + PoWMiner.mine(baseTemplate, pubKey, 256, workers = 4, isActive = { start.elapsedNow() < 150.milliseconds - } + }) } assertTrue( start.elapsedNow() < 5_000.milliseconds,