mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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<Event>(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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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<Event>(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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
+35
-29
@@ -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 <T : Event> enqueue(
|
||||
template: EventTemplate<T>,
|
||||
pubKey: HexKey,
|
||||
difficulty: Int,
|
||||
persistAs: PersistedPoWJob? = null,
|
||||
refreshCreatedAtOnStart: Boolean = false,
|
||||
refreshCreatedAt: 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)
|
||||
) {
|
||||
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<T>(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<T>(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<T>(TimeUtils.now(), template.kind, template.tags, template.content)
|
||||
} else {
|
||||
template
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged primitive behind [enqueue]: [mine] runs on the capped worker
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ class PoWPublishQueueTest {
|
||||
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) }
|
||||
queue.enqueue(template, pubKey, difficulty = 10, refreshCreatedAt = true) { mined.complete(it) }
|
||||
|
||||
val result = withContext(Dispatchers.Default) { withTimeout(60_000) { mined.await() } }
|
||||
assertTrue(
|
||||
|
||||
@@ -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<Char> =
|
||||
('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 <T : Event> run(
|
||||
template: EventTemplate<T>,
|
||||
pubKey: HexKey,
|
||||
desiredPoW: Int,
|
||||
isActive: () -> Boolean = { true },
|
||||
): EventTemplate<T> = search(template, pubKey, desiredPoW, isActive, "")
|
||||
refreshCreatedAt: (() -> Long)? = null,
|
||||
): EventTemplate<T> = 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<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
|
||||
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 <T : Event> mine(
|
||||
template: EventTemplate<T>,
|
||||
@@ -190,9 +257,10 @@ class PoWMiner(
|
||||
desiredPoW: Int,
|
||||
workers: Int = 1,
|
||||
isActive: () -> Boolean = { true },
|
||||
refreshCreatedAt: (() -> Long)? = null,
|
||||
): EventTemplate<T> {
|
||||
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<EventTemplate<T>>()
|
||||
@@ -203,7 +271,7 @@ class PoWMiner(
|
||||
winner.complete(
|
||||
search(template, pubKey, desiredPoW, {
|
||||
isActive() && !winner.isCompleted
|
||||
}, workerPrefix(worker, workers)),
|
||||
}, workerPrefix(worker, workers), refreshCreatedAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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<Int>,
|
||||
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 {
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@ class PoWMinerCancellationTest {
|
||||
var polls = 0
|
||||
// 256 bits of PoW never completes; only the isActive check can end the run.
|
||||
assertFailsWith<CancellationException> {
|
||||
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")
|
||||
|
||||
+164
@@ -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<TextNoteEvent>(
|
||||
originalCreatedAt,
|
||||
TextNoteEvent.KIND,
|
||||
emptyArray(),
|
||||
"A note to mine",
|
||||
)
|
||||
|
||||
private fun assertCommitsTo(
|
||||
mined: EventTemplate<TextNoteEvent>,
|
||||
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<Long>()
|
||||
var tick = originalCreatedAt
|
||||
|
||||
assertFailsWith<CancellationException> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -79,9 +79,9 @@ class PoWMinerParallelTest {
|
||||
val start = TimeSource.Monotonic.markNow()
|
||||
// 256 bits never completes; only the isActive deadline can end the run.
|
||||
assertFailsWith<CancellationException> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user