fix: NIP-13 compliance — refresh created_at at mining start, clean nonce tag

Findings from a full NIP-13 review:

- The spec recommends updating created_at while mining. Queue jobs now
  re-stamp the template to "now" when a worker picks them up (a post can
  wait behind other jobs, and a job restored after process death could be
  hours old); the restorer does the same. Scheduled posts are exempt —
  their future created_at is intentional. Anonymous posts re-stamp before
  mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
  the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
  no-commitment round trip; a queue test asserts the created_at
  re-stamp at mining start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
This commit is contained in:
Claude
2026-07-10 22:22:04 +00:00
parent ba98f8c232
commit 97fc801294
8 changed files with 114 additions and 6 deletions
@@ -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
}
@@ -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)
}
}
@@ -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<Event>(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) {
@@ -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<Event>(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) {
@@ -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 <T : Event> enqueue(
template: EventTemplate<T>,
pubKey: HexKey,
difficulty: Int,
persistAs: PersistedPoWJob? = null,
refreshCreatedAtOnStart: Boolean = false,
onMined: suspend (EventTemplate<T>) -> 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<T>(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) }
@@ -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<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 {
@@ -52,6 +52,6 @@ class PoWTag(
fun assemble(
nonce: String,
commitment: Int?,
) = arrayOfNotNull(TAG_NAME, nonce, commitment.toString())
) = arrayOfNotNull(TAG_NAME, nonce, commitment?.toString())
}
}
@@ -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())
}
}