diff --git a/cli/README.md b/cli/README.md index 7b9628ac97..0ded3d65d8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -215,6 +215,9 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. | | `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | | `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | +| `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). | +| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. | +| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. | | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | | `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | @@ -381,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | Command | What it does | |---|---| -| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. | +| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). | | `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 56f2bb0514..d0fc574d7c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -51,6 +51,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Marmot message send / list | ✅ | `commons/marmot/` | | `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` | | NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. | +| NIP-13 proof of work (`amy notes post --pow N`, `amy pow check/mine/bench`) | ✅ | `PostCommand` + `PowCommands` — mines pre-signature via quartz `PoWMiner`; `pow mine --pubkey` covers delegated PoW; `pow check` applies the commitment cap. | | NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. | | NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. | | NIP-09 event deletion | 🆕 | Builder exists in quartz. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 786f06dfbe..9fd05e2a63 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands import com.vitorpamplona.amethyst.cli.commands.PodcastCommands +import com.vitorpamplona.amethyst.cli.commands.PowCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands @@ -262,6 +263,7 @@ private suspend fun dispatch(argv: Array): Int { "dm" -> DmCommands.dispatch(dataDir, tail) "profile" -> ProfileCommands.dispatch(dataDir, tail) "notes" -> NotesCommands.dispatch(dataDir, tail) + "pow" -> PowCommands.dispatch(dataDir, tail) "nsite" -> NsiteCommands.dispatch(dataDir, tail) "napplet" -> NappletCommands.dispatch(dataDir, tail) "store" -> StoreCommands.dispatch(dataDir, tail) @@ -428,6 +430,13 @@ private fun printUsage() { | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] | verify [EVENT-JSON] check an event's id hash + signature | (reads stdin when the arg is omitted or `-`) + | pow check EVENT-JSON|- NIP-13: leading-zero bits, committed target, + | effective PoW (capped at the commitment) + | pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON|- + | mine an UNSIGNED template (delegated PoW: + | ids don't commit to sigs, so amy can mine + | for any pubkey); exit 124 on timeout + | pow bench hash rate + expected seconds at 16/20/24/28 bits | key generate mint a fresh keypair (nsec + npub + hex) | key public NSEC|HEX derive the public key from a secret key | key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1… @@ -493,6 +502,8 @@ private fun printUsage() { | |Notes (NIP-10 kind:1): | notes post TEXT [--relay URL] publish a kind:1 short text note + | [--pow BITS [--pow-timeout SECS]] mine a NIP-13 proof of work first + | (exit 124 on timeout, nothing published) | (--relay accepts comma-separated extras) | notes feed [--author USER] fetch kind:1 notes | [--following] (default: own; --author: one user; 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 3a46c37bf4..ae16171a9d 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 @@ -25,21 +25,31 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException /** - * `amy post [--relay URL …]` — publish a NIP-10 kind:1 short text note - * to the user's outbox relays. + * `amy post [--relay URL …] [--pow BITS [--pow-timeout SECS]]` — + * publish a NIP-10 kind:1 short text note to the user's outbox relays, + * optionally mining a NIP-13 proof of work into it first. Mining blocks the + * invocation (the CLI process IS the job); `--pow-timeout` aborts with exit + * 124 and publishes nothing. * * Threading is intentionally out of scope here — `amy post` only handles new * top-level notes. Replies/quotes need richer event-hint plumbing and will get * their own verb when needed. */ object PostCommand { + private const val MAX_DIFFICULTY = 64 + suspend fun run( dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …]") + if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …] [--pow BITS [--pow-timeout SECS]]") val text = rest[0] if (text.isBlank()) return Output.error("bad_args", "post text must not be blank") @@ -50,6 +60,12 @@ object PostCommand { ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() + val powTarget = args.flags["pow"]?.toIntOrNull() + if (args.flags.containsKey("pow") && (powTarget == null || powTarget < 1 || powTarget > MAX_DIFFICULTY)) { + return Output.error("bad_args", "--pow must be between 1 and $MAX_DIFFICULTY leading zero bits") + } + val powTimeoutSec = args.flags["pow-timeout"]?.toLongOrNull() + Context.open(dataDir).use { ctx -> ctx.prepare() val outbox = ctx.outboxRelays() @@ -63,7 +79,33 @@ object PostCommand { return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") } - val signed = ctx.signer.sign(TextNoteEvent.build(text)) + val template = TextNoteEvent.build(text) + + var powMillis: Long? = null + val readyToSign = + if (powTarget != null) { + System.err.println("mining $powTarget bits…") + val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } + val startedAt = System.nanoTime() + val mined = + try { + withContext(Dispatchers.Default) { + PoWMiner.run(template, ctx.signer.pubKey, powTarget) { + deadlineNanos == null || System.nanoTime() < deadlineNanos + } + } + } catch (e: CancellationException) { + Output.error("pow_timeout", "did not reach $powTarget bits within ${powTimeoutSec}s; nothing was published") + return 124 + } + powMillis = (System.nanoTime() - startedAt) / 1_000_000 + System.err.println("mined in ${powMillis}ms") + mined + } else { + template + } + + val signed = ctx.signer.sign(readyToSign) val ack = ctx.publish(signed, targets) Output.emit( @@ -72,6 +114,9 @@ object PostCommand { "kind" to signed.kind, "created_at" to signed.createdAt, "content" to signed.content, + "pow" to if (powTarget != null) signed.pow() else null, + "pow_target" to powTarget, + "pow_millis" to powMillis, "published_to" to ack.filterValues { it }.keys.map { it.url }, "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, ), 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 new file mode 100644 index 0000000000..910bc0060f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt @@ -0,0 +1,196 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip13Pow.commitedPoW +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.pow +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.roundToLong + +/** + * `amy pow ` — NIP-13 proof-of-work primitives. + * + * `check` and `bench` are stateless (no account, no network). `mine` works on + * an UNSIGNED template: because the NIP-01 id does not commit to the + * signature, amy can mine on behalf of any pubkey (NIP-13's delegated PoW) — + * pass `--pubkey`, or omit it to mine for the active account. + */ +object PowCommands { + private const val MAX_DIFFICULTY = 64 + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "pow", + tail, + "pow …", + mapOf( + "check" to { rest -> check(rest) }, + "mine" to { rest -> mine(dataDir, rest) }, + "bench" to { rest -> bench() }, + ), + ) + + /** + * `amy pow check ` — difficulty of a SIGNED event, with the + * NIP-13 commitment rule applied: `effective_pow` is capped at the committed + * target and `valid` covers id+signature (a forged id can claim any PoW). + */ + private fun check(rest: Array): Int { + val json = readPayload(rest) ?: return Output.error("bad_args", "pow check (- reads stdin)") + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_event", e.message) + } + + val commitment = event.tags.commitedPoW() + Output.emit( + mapOf( + "event_id" to event.id, + "valid" to event.verify(), + "actual_bits" to PoWRankEvaluator.calculatePowRankOf(event.id), + "committed_target" to commitment, + "has_commitment" to (commitment != null), + "effective_pow" to event.pow(), + ), + ) + return 0 + } + + /** + * `amy pow mine --target N [--pubkey HEX] [--timeout SECS] ` + * — mines an unsigned template and prints it back with the nonce tag, ready + * to be signed by whoever owns the pubkey. + */ + private suspend fun mine( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] " + + val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage) + if (target < 1 || target > MAX_DIFFICULTY) { + return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY") + } + + val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage) + val template = + try { + EventTemplate.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_template", e.message) + } + + val pubKey = + args.flags["pubkey"] + ?: try { + Context.open(dataDir).use { it.signer.pubKey } + } catch (e: Exception) { + return Output.error("bad_args", "no account available; pass --pubkey (${e.message})") + } + if (pubKey.length != 64 || pubKey.any { it !in "0123456789abcdefABCDEF" }) { + return Output.error("bad_args", "--pubkey must be 64 hex characters") + } + + val timeoutSec = args.flags["timeout"]?.toLongOrNull() + val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } + + System.err.println("mining $target bits for ${pubKey.take(8)}…") + val startedAt = System.nanoTime() + + val mined = + try { + withContext(Dispatchers.Default) { + PoWMiner.run(template, pubKey, target) { + deadlineNanos == null || System.nanoTime() < deadlineNanos + } + } + } catch (e: CancellationException) { + Output.error("pow_timeout", "did not reach $target bits within ${timeoutSec}s") + return 124 + } + + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + val id = + sha256( + EventHasherSerializer.fastMakeJsonForId( + pubKey = pubKey, + createdAt = mined.createdAt, + kind = mined.kind, + tags = mined.tags, + content = mined.content, + ), + ).toHexKey() + + Output.emit( + mapOf( + "id" to id, + "pubkey" to pubKey, + "pow" to PoWRankEvaluator.calculatePowRankOf(id), + "pow_target" to target, + "pow_millis" to elapsedMs, + "template_json" to mined.toJson(), + ), + ) + return 0 + } + + /** `amy pow bench` — hash rate + expected mining time per common target. */ + private suspend fun bench(): Int { + val rate = PoWEstimator.hashesPerSecond() + Output.emit( + mapOf( + "hashes_per_second" to rate.roundToLong(), + "expected_seconds" to + listOf(16, 20, 24, 28).associate { bits -> + bits.toString() to PoWEstimator.estimateSeconds(bits, rate) + }, + ), + ) + return 0 + } + + private fun readPayload(rest: Array): String? { + val arg = rest.firstOrNull() ?: return null + val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg + return payload.trim().ifEmpty { null } + } +} diff --git a/cli/tests/pow/.gitignore b/cli/tests/pow/.gitignore new file mode 100644 index 0000000000..3bf212ad88 --- /dev/null +++ b/cli/tests/pow/.gitignore @@ -0,0 +1 @@ +state-pow-headless/ diff --git a/cli/tests/pow/pow-headless.sh b/cli/tests/pow/pow-headless.sh new file mode 100755 index 0000000000..f4d2e629a7 --- /dev/null +++ b/cli/tests/pow/pow-headless.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# pow-headless.sh — verifies amy's NIP-13 primitives without a relay. +# +# One throwaway amy identity in an isolated $HOME. We assert that: +# +# 1. `amy pow bench` reports a positive hash rate and estimates. +# 2. `amy pow mine --target 10 --pubkey HEX