mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+14
-1
@@ -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) }
|
||||
|
||||
+18
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user