mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
feat(cli): NIP-13 for amy — post --pow and the pow verb group
- `amy notes post TEXT --pow BITS [--pow-timeout SECS]` mines the note pre-signature via quartz's PoWMiner (blocking — the CLI process is the job), exits 124 on timeout with nothing published, and adds additive --json keys: pow, pow_target, pow_millis. - `amy pow check EVENT-JSON|-` reports actual_bits, committed_target, has_commitment and effective_pow (capped at the commitment per NIP-13's anti-lucky-spam rule) plus id+sig validity. - `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE|-` mines an unsigned template for any pubkey — NIP-13's delegated PoW: ids don't commit to signatures, so a headless box can mine for a phone and hand the template back for signing. - `amy pow bench` prints the machine's hash rate and expected seconds at 16/20/24/28 bits (commons PoWEstimator). - cli/tests/pow/pow-headless.sh: relay-free harness covering bench, mine (commitment shape + 124 timeout), and the delegated round trip (mine → sign via `amy event` → pow check ≥ target). 6/6 passing. No logic added to cli/ — thin assembly over quartz nip13Pow + commons PoWEstimator per the CLI architecture rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
This commit is contained in:
+4
-1
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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<String>): 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;
|
||||
|
||||
@@ -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 <text> [--relay URL …]` — publish a NIP-10 kind:1 short text note
|
||||
* to the user's outbox relays.
|
||||
* `amy post <text> [--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<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--relay URL …]")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "post <text> [--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 },
|
||||
),
|
||||
|
||||
@@ -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 <check|mine|bench>` — 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<String>,
|
||||
): Int =
|
||||
route(
|
||||
"pow",
|
||||
tail,
|
||||
"pow <check|mine|bench> …",
|
||||
mapOf(
|
||||
"check" to { rest -> check(rest) },
|
||||
"mine" to { rest -> mine(dataDir, rest) },
|
||||
"bench" to { rest -> bench() },
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* `amy pow check <event-json | ->` — 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<String>): Int {
|
||||
val json = readPayload(rest) ?: return Output.error("bad_args", "pow check <event-json | -> (- 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] <template-json | ->`
|
||||
* — 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<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] <template-json | ->"
|
||||
|
||||
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>): String? {
|
||||
val arg = rest.firstOrNull() ?: return null
|
||||
val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg
|
||||
return payload.trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
state-pow-headless/
|
||||
Executable
+124
@@ -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 <template>` returns a
|
||||
# template whose recomputed id has >= 10 leading zero bits and
|
||||
# whose nonce tag commits to "10".
|
||||
# 3. `amy pow mine` with a 0-second timeout on an impossible target
|
||||
# exits 124 (the await-timeout contract).
|
||||
# 4. `amy event --kind 1` piped through `amy pow check -` reports
|
||||
# valid=true and has_commitment=false for an unmined event.
|
||||
# 5. Mining via `amy event --tags <mined nonce tag>` … a signed event
|
||||
# carrying the mined nonce round-trips through `pow check` with
|
||||
# effective_pow >= 10 and has_commitment=true.
|
||||
#
|
||||
# Usage: ./pow-headless.sh [--no-build]
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-pow-headless"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
|
||||
RUN_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
|
||||
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
||||
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
NO_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
-h|--help)
|
||||
sed -n '3,19p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
|
||||
exit 0 ;;
|
||||
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
rm -rf "$STATE_DIR"
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
: >"$LOG_FILE"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$SCRIPT_DIR/../lib.sh"
|
||||
|
||||
command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; }
|
||||
|
||||
if [[ $NO_BUILD -eq 0 ]]; then
|
||||
step "building amy (installDist)"
|
||||
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|
||||
|| { fail_msg "gradle :cli:installDist failed"; exit 1; }
|
||||
fi
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
|
||||
|
||||
export HOME="$STATE_DIR"
|
||||
amy() { "$AMY_BIN" --account t --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"; }
|
||||
|
||||
banner "amy NIP-13 primitives"
|
||||
|
||||
step "init throwaway identity"
|
||||
amy init >>"$LOG_FILE" || { record_result "init" fail; exit 1; }
|
||||
PUBKEY="$(amy whoami | jq -r .hex)"
|
||||
[[ "$PUBKEY" =~ ^[0-9a-f]{64}$ ]] || { record_result "init" fail "no pubkey"; exit 1; }
|
||||
record_result "init" pass
|
||||
|
||||
step "pow bench"
|
||||
BENCH="$(amy pow bench)"
|
||||
RATE="$(jq -r .hashes_per_second <<<"$BENCH")"
|
||||
EST20="$(jq -r '.expected_seconds["20"]' <<<"$BENCH")"
|
||||
if [[ "$RATE" -gt 0 ]] && jq -e '.expected_seconds["16"] < .expected_seconds["28"]' <<<"$BENCH" >/dev/null; then
|
||||
record_result "pow-bench" pass "rate=$RATE h/s, 20 bits ≈ ${EST20}s"
|
||||
else
|
||||
record_result "pow-bench" fail "$BENCH"
|
||||
fi
|
||||
|
||||
step "pow mine at 10 bits"
|
||||
TEMPLATE='{"created_at":1683596206,"kind":1,"tags":[],"content":"pow harness"}'
|
||||
MINE="$(amy pow mine --target 10 --pubkey "$PUBKEY" "$TEMPLATE")"
|
||||
POW="$(jq -r .pow <<<"$MINE")"
|
||||
NONCE_TARGET="$(jq -r '.template_json | fromjson | .tags[] | select(.[0]=="nonce") | .[2]' <<<"$MINE")"
|
||||
if [[ "$POW" -ge 10 && "$NONCE_TARGET" == "10" ]]; then
|
||||
record_result "pow-mine" pass "pow=$POW committed=$NONCE_TARGET"
|
||||
else
|
||||
record_result "pow-mine" fail "$MINE"
|
||||
fi
|
||||
|
||||
step "pow mine timeout exits 124"
|
||||
amy pow mine --target 60 --timeout 1 --pubkey "$PUBKEY" "$TEMPLATE" >>"$LOG_FILE"
|
||||
RC=$?
|
||||
if [[ $RC -eq 124 ]]; then
|
||||
record_result "pow-mine-timeout" pass
|
||||
else
|
||||
record_result "pow-mine-timeout" fail "exit=$RC"
|
||||
fi
|
||||
|
||||
step "pow check on an unmined signed event"
|
||||
UNMINED="$(amy event --kind 1 --content "no pow here" | jq -c .event)"
|
||||
CHECK1="$(amy pow check "$UNMINED")"
|
||||
if jq -e '.valid == true and .has_commitment == false' <<<"$CHECK1" >/dev/null; then
|
||||
record_result "pow-check-unmined" pass
|
||||
else
|
||||
record_result "pow-check-unmined" fail "$CHECK1"
|
||||
fi
|
||||
|
||||
step "mined template signs into a valid PoW event"
|
||||
NONCE_TAGS="$(jq -c '.template_json | fromjson | .tags' <<<"$MINE")"
|
||||
CREATED_AT="$(jq -r '.template_json | fromjson | .created_at' <<<"$MINE")"
|
||||
SIGNED="$(amy event --kind 1 --content "pow harness" --tags "$NONCE_TAGS" --created-at "$CREATED_AT" | jq -c .event)"
|
||||
CHECK2="$(amy pow check "$SIGNED")"
|
||||
if jq -e '.valid == true and .has_commitment == true and .effective_pow >= 10' <<<"$CHECK2" >/dev/null; then
|
||||
record_result "pow-check-mined" pass "effective_pow=$(jq -r .effective_pow <<<"$CHECK2")"
|
||||
else
|
||||
record_result "pow-check-mined" fail "$CHECK2"
|
||||
fi
|
||||
|
||||
print_summary
|
||||
Reference in New Issue
Block a user