diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 86c45117a9..04372595b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -858,7 +858,16 @@ class Account( val queue = powQueue() ?: return false val finalTemplate = withFinalSignerTags(template) val record = replay?.toRecord(RandomInstance.randomChars(16), signer.pubKey, finalTemplate, difficulty) - queue.enqueue(finalTemplate, signer.pubKey, difficulty, record, onMined) + queue.enqueue( + template = finalTemplate, + pubKey = signer.pubKey, + difficulty = difficulty, + persistAs = record, + // NIP-13 recommends refreshing created_at while mining; scheduled + // posts keep their intentional future timestamp. + refreshCreatedAtOnStart = replay !is PoWReplay.Schedule, + onMined = onMined, + ) return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index db691d4920..eb920115f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -63,7 +63,16 @@ class PowJobRestorer( return@forEach } - queue.enqueue(template, account.signer.pubKey, record.difficulty, persistAs = record) { mined -> + queue.enqueue( + template = template, + pubKey = account.signer.pubKey, + difficulty = record.difficulty, + persistAs = record, + // a restored job may be hours old; publish with a fresh + // created_at (NIP-13 recommendation) — except scheduled posts, + // whose future created_at is the point. + refreshCreatedAtOnStart = record.replayType != PersistedPoWJob.REPLAY_SCHEDULE, + ) { mined -> replay(account, record, mined) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index c315d8ada4..faa0920d39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -564,7 +564,10 @@ open class CommentPostViewModel : val enqueued = powDifficulty != null && accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> - val mined = PoWMiner.run(template, anonSigner.pubKey, powDifficulty, isActive) + // fresh created_at at mining start (NIP-13 recommendation): + // the job may have waited in the queue behind other posts. + val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) } if (!enqueued) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 412576e981..75f473e8ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1055,7 +1055,10 @@ open class ShortNotePostViewModel : val enqueued = powDifficulty != null && accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> - val mined = PoWMiner.run(template, anonSigner.pubKey, powDifficulty, isActive) + // fresh created_at at mining start (NIP-13 recommendation): + // the job may have waited in the queue behind other posts. + val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) } if (!enqueued) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index 74d14d77f2..1d15bd3e2d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -126,12 +126,19 @@ class PoWPublishQueue( * id) until it finishes or is cancelled, so it can be restored after * process death. Re-enqueueing an id already in the queue is a no-op — * that makes restore-on-login idempotent. + * + * [refreshCreatedAtOnStart] re-stamps the template's created_at to "now" + * when a worker picks the job up — NIP-13 recommends updating created_at + * while mining, and a job that waited in the queue (or was restored after + * a process death) would otherwise publish visibly in the past. Must stay + * false for scheduled posts, whose future created_at is intentional. */ fun enqueue( template: EventTemplate, pubKey: HexKey, difficulty: Int, persistAs: PersistedPoWJob? = null, + refreshCreatedAtOnStart: Boolean = false, onMined: suspend (EventTemplate) -> Unit, ) = addJob( id = persistAs?.id ?: RandomInstance.randomChars(16), @@ -139,7 +146,13 @@ class PoWPublishQueue( difficulty = difficulty, persistAs = persistAs, ) { isActive -> - val mined = PoWMiner.run(template, pubKey, difficulty, isActive) + val toMine = + if (refreshCreatedAtOnStart) { + EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + } else { + template + } + val mined = PoWMiner.run(toMine, pubKey, difficulty, isActive) // frees the mining worker: signing may wait on an external signer // (Amber/bunker) and broadcasting is IO, neither belongs on the pool. scope.launch { onMined(mined) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt index 80f9455f7d..4df54de822 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt @@ -175,6 +175,24 @@ class PoWPublishQueueTest { scope.cancel() } + @Test + fun refreshCreatedAtReStampsAtMiningStart() = + runTest { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val queue = PoWPublishQueue(scope, maxConcurrent = 1) + val mined = CompletableDeferred>() + + // template stamped in 2023; refresh must bring it to "now" + queue.enqueue(template, pubKey, difficulty = 10, refreshCreatedAtOnStart = true) { mined.complete(it) } + + val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } } + assertTrue( + result.createdAt > template.createdAt, + "created_at must be re-stamped at mining start (was ${result.createdAt})", + ) + scope.cancel() + } + @Test fun duplicateJobIdsAreEnqueuedOnce() = runTest { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt index 8654d18745..db08fbd088 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/tags/PoWTag.kt @@ -52,6 +52,6 @@ class PoWTag( fun assemble( nonce: String, commitment: Int?, - ) = arrayOfNotNull(TAG_NAME, nonce, commitment.toString()) + ) = arrayOfNotNull(TAG_NAME, nonce, commitment?.toString()) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt new file mode 100644 index 0000000000..d9bf3ad904 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWTagTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip13Pow + +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PoWTagTest { + @Test + fun assembleMatchesNip13Example() { + // NIP-13's example: ["nonce", "776797", "20"] + assertContentEquals(arrayOf("nonce", "776797", "20"), PoWTag.assemble("776797", 20)) + } + + @Test + fun assembleWithoutCommitmentOmitsTheThirdEntry() { + // a null commitment must not serialize as the literal string "null" + assertContentEquals(arrayOf("nonce", "776797"), PoWTag.assemble("776797", null)) + } + + @Test + fun parseRoundTrips() { + val parsed = PoWTag.parse(arrayOf("nonce", "776797", "20"))!! + assertEquals("776797", parsed.nonce) + assertEquals(20, parsed.commitment) + assertContentEquals(arrayOf("nonce", "776797", "20"), parsed.toTagArray()) + + val noCommitment = PoWTag.parse(arrayOf("nonce", "776797"))!! + assertNull(noCommitment.commitment) + assertContentEquals(arrayOf("nonce", "776797"), noCommitment.toTagArray()) + } +}