diff --git a/cli/README.md b/cli/README.md index 8556c5cf3d..d031074438 100644 --- a/cli/README.md +++ b/cli/README.md @@ -229,6 +229,7 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. | | `amy nip N` / `amy nip list` | Look up a NIP — the `nostr-protocol/nips` repo first, then a Nostr wiki/long-form fallback. `list` fetches the index. | | `amy kind N` / `amy kind NAME` | Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's `KindNames` registry. | +| `amy sno ` | Simple Nostr Objects (DECK-0003): validate a payload against §1.9, price the proof of work an avatar owes for it (§8.10), or check whether a kind:11333 avatar event has paid. Local and accountless; every rule lives in quartz. | | `amy namecoin resolve IDENT [--server HOST:PORT[:tcp][,…]] [--timeout SECS]` | Resolve a Namecoin identifier (`.bit`, `d/`, `id/`, `alice@example.bit`) to a Nostr pubkey + relays via the Namecoin blockchain. Stateless: talks directly to one or more ElectrumX servers over TLS (`:tcp` for plaintext), no account needed. Reuses the same NIP-05-Namecoin parser, server set, and pinned trust store as the Android and Desktop apps. | | `amy namecoin servers` | Print the default ElectrumX server list (host, port, TLS flag). | | `amy relay info URL` | Fetch and print a relay's NIP-11 information document. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 74645af784..09b80c2072 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -76,6 +76,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | | Live activities / chess (NIP-53 / NIP-64) | 🆕 | | +| Simple Nostr Objects (DECK-0003) | ✅ read-only | `SnoCommands` — `sno parse` (§1.9 verdict + the rule a refusal broke), `sno work` (the §8.10 proof of work an avatar owes), `sno verify` (whether a kind:11333 avatar has paid). Thin over quartz `cyberspace/deck0003Sno/`. Conformance harness at `cli/tests/sno/` diffs all three against the cyberspace project's own `sno-reference.py` and `cyberspace-cli`. Publishing/authoring 🆕. | | Blossom blobs (NIP-B7) | ✅ | `BlossomCommands` — upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. | | NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `sync`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Reads project the local store; `cashu sync` (or `--sync`) is what fills it, paging every relay to exhaustion so a cap can't truncate the proof set. Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). | | NIP-47 Wallet Connect | 🆕 | | 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 3eec899f21..5a82324fea 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand import com.vitorpamplona.amethyst.cli.commands.ServeCommand +import com.vitorpamplona.amethyst.cli.commands.SnoCommands import com.vitorpamplona.amethyst.cli.commands.StatusCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands import com.vitorpamplona.amethyst.cli.commands.StreamCommands @@ -242,6 +243,7 @@ private suspend fun dispatch(argv: Array): Int { "filter" -> return FilterCommand.run(tail) "nip" -> return NipCommand.run(tail) "kind" -> return KindCommand.run(tail) + "sno" -> return SnoCommands.dispatch(tail) "namecoin" -> return NamecoinCommand.dispatch(tail) } @@ -512,6 +514,7 @@ private fun printUsage() { | nip N show a NIP (repo first, then a Nostr wiki/long-form fallback) | nip list fetch the NIP index (README) from the repo | kind N|NAME look up an event kind's label + NIP (number, or search by name) + | sno Simple Nostr Objects (DECK-0003): validate, price, verify an avatar | namecoin resolve IDENT resolve a Namecoin identifier (.bit, d/, id/, alice@x.bit) | [--server URL[,URL]] to a Nostr pubkey + relays via the Namecoin blockchain | [--timeout SECS] (no account, talks to ElectrumX over TLS) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt new file mode 100644 index 0000000000..5e3e29d67f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt @@ -0,0 +1,181 @@ +/* + * 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.Output +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarWork +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.math.abs + +/** + * `amy sno …` — read Simple Nostr Objects (DECK-0003), local and accountless. + * + * Thin assembly only: every rule lives in quartz's `cyberspace/deck0003Sno/`. + * These verbs exist so the Kotlin reader can be diffed against the deck's own + * `sno-reference.py` and against cyberspace-cli without a device or a relay — + * see `cli/tests/sno/`. + */ +object SnoCommands { + val USAGE: String = + """ + |amy sno — Simple Nostr Objects, DECK-0003 (local, accountless) + | + | sno parse [PAYLOAD|-] validate a payload against §1.9 + | sno work [PAYLOAD|-] the proof of work an avatar owes for it (§8.10) + | sno verify [EVENT|-] whether a kind 11333 avatar event has paid + | + |Each reads its argument as JSON, or from stdin when it is omitted or `-`. + """.trimMargin() + + suspend fun dispatch(tail: Array): Int = + route( + "sno", + tail, + "sno ", + mapOf( + "parse" to { rest -> parse(rest) }, + "work" to { rest -> work(rest) }, + "verify" to { rest -> verify(rest) }, + ), + USAGE, + ) + + /** + * Validate a payload. `valid` is the answer; on a refusal `rule` names the + * numbered rule of §1.9 that failed, which is what makes a verdict + * comparable with `sno-reference.py`'s. + */ + private fun parse(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + return when (val result = SnoParser.parse(json)) { + is SnoResult.Invalid -> { + Output.emit(mapOf("valid" to false, "rule" to result.rule, "reason" to result.reason)) + 0 + } + is SnoResult.Valid -> { + Output.emit(mapOf("valid" to true) + facts(result.payload)) + 0 + } + } + } + + private fun work(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + val payload = + SnoParser.parse(json).payloadOrNull() + ?: return Output.error("bad_payload", "not a valid SNO payload") + + Output.emit( + mapOf( + "required" to SnoAvatarWork.required(payload), + "reach_ticks" to reachTicks(payload), + "unit" to payload.unit, + "detail" to maxOf(SnoAvatarWork.DETAIL_FREE, payload.vertexCount + payload.faceCount), + ), + ) + return 0 + } + + /** + * Whether an avatar event may be drawn. The keys mirror + * `verify_avatar_work` in the reference implementations so the two can be + * diffed directly. + */ + private fun verify(rest: Array): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + args.rejectUnknown() + + val event = + try { + Event.fromJson(json) + } catch (_: Exception) { + return Output.error("bad_event", "not a nostr event") + } + + val avatar = + event as? SnoAvatarEvent + ?: return Output + .emit( + mapOf("ok" to false, "required" to 0, "committed" to null, "zeros" to 0, "reason" to "not-an-avatar"), + ).let { 0 } + + val payment = avatar.payment() + Output.emit( + mapOf( + "ok" to payment.ok, + "required" to payment.required, + "committed" to payment.committed, + "zeros" to payment.zeros, + "reason" to payment.reason.code, + ), + ) + return 0 + } + + /** The facts a conformance diff cares about, and nothing else. */ + private fun facts(payload: SnoPayload): Map = + mapOf( + "version" to payload.version, + "name" to payload.name, + "unit" to payload.unit, + "extent" to payload.extent, + "mode" to payload.mode.code, + "vertices" to payload.vertexCount, + "faces" to payload.faceCount, + "has_face_colors" to (payload.faceColors != null), + "palette" to + when (payload.paletteRef) { + SnoPaletteRef.BuiltIn -> "built-in" + is SnoPaletteRef.Inline -> "inline" + is SnoPaletteRef.Event -> "event" + }, + "up" to payload.up, + "spin" to payload.spin, + "reach_ticks" to reachTicks(payload), + ) + + /** + * The farthest any vertex lies from the build origin, in ticks — the + * integer half of §8.10's reach, before the scale exponent is applied. + * Reported as an integer so a diff never argues about float formatting. + */ + private fun reachTicks(payload: SnoPayload): Int { + var farthest = 0 + for (tick in payload.positions) { + val magnitude = abs(tick) + if (magnitude > farthest) farthest = magnitude + } + return farthest + } +} diff --git a/cli/tests/README.md b/cli/tests/README.md index caa8f88122..c91ed90712 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -38,6 +38,9 @@ cli/tests/ │ └── README.md # operator brief + per-test matrix ├── pow/ # NIP-13 primitives (bench/mine/check) — no relay │ └── pow-headless.sh +├── sno/ # DECK-0003 conformance vs the cyberspace project's +│ ├── sno-conformance.sh # own reference implementations — no relay +│ └── refdriver.py ├── relaygroup/ # NIP-29 round-trip vs embedded `amy serve` (geode) │ └── relaygroup-headless.sh └── sync/ # NIP-77 deletion propagation vs `amy serve` @@ -58,6 +61,14 @@ Suite notes: (the same fixtures quartz's `ClinkInteropTest` uses) to the right fields, plus the argument-error paths. The round-trip verbs (`offer request`, `debit pay/budget`) need a live CLINK service and aren't covered here. +- **`sno/sno-conformance.sh`** is relay-free and diffs `amy sno` against the + cyberspace project's own references: the deck's `sno-reference.py` for §1.9 + verdicts (the rejection table drives the run, so the fixtures are theirs and + not ours) and cyberspace-cli's `avatar.py` for the §8.10 work ladder and its + golden vectors. The two places our reader knowingly differs are asserted as + divergences rather than skipped, so neither can drift quietly. Reference + checkouts are cloned into `state/` unless `CYBERSPACE_DIR` / + `CYBERSPACE_CLI_DIR` already point at them. - **`pow/pow-headless.sh`** is also relay-free: `pow bench` sanity, `pow mine` hitting its target (and exiting 124 on an impossible one), and mined-nonce round-trips through `pow check`. diff --git a/cli/tests/sno/.gitignore b/cli/tests/sno/.gitignore new file mode 100644 index 0000000000..268b1592e6 --- /dev/null +++ b/cli/tests/sno/.gitignore @@ -0,0 +1 @@ +state-sno-conformance/ diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py new file mode 100755 index 0000000000..2981d0c627 --- /dev/null +++ b/cli/tests/sno/refdriver.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Adapter over the cyberspace project's own reference implementations. + +Speaks one JSON object per line on stdout so `sno-conformance.sh` can diff it +against `amy sno … --json` with jq. Nothing here reimplements anything: it +imports `decks/sno-reference.py` from the cyberspace spec repo (the §1.9 +arbiter) and `cyberspace_core/avatar.py` from cyberspace-cli (the §8.10 work +and payment reference), and just re-emits what they answer. + + refdriver.py cases the deck's own rejection table, + Appendix A + refdriver.py vectors cyberspace-cli's avatar-work golden vectors + refdriver.py verdict < payload {"valid":…, "rule":…} + refdriver.py work < payload {"required":…} + refdriver.py verify < event {"ok":…, "required":…, "committed":…, "zeros":…, "reason":…} + +Paths come from $CYBERSPACE_DIR and $CYBERSPACE_CLI_DIR. +""" +import importlib.util +import json +import os +import signal +import sys + +# Used in pipelines (`| head`), where dying on SIGPIPE is the right answer +# rather than a traceback. +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + +def _load(path, name): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None: + raise SystemExit(f"cannot load {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def _sno(): + root = os.environ.get("CYBERSPACE_DIR") + if not root: + raise SystemExit("CYBERSPACE_DIR is not set") + return _load(os.path.join(root, "decks", "sno-reference.py"), "sno_reference") + + +def _avatar(): + root = os.environ.get("CYBERSPACE_CLI_DIR") + if not root: + raise SystemExit("CYBERSPACE_CLI_DIR is not set") + sys.path.insert(0, os.path.join(root, "src")) + return _load(os.path.join(root, "src", "cyberspace_core", "avatar.py"), "ref_avatar") + + +def emit(obj): + print(json.dumps(obj, separators=(",", ":"), sort_keys=True)) + + +def cmd_cases(): + sno = _sno() + emit({"name": "appendix A", "rule": None, "payload": dict(sno.APPENDIX_A)}) + for label, payload in sno._rejections(): + emit({"name": label, "rule": label.replace("rule ", ""), "payload": payload}) + + +def cmd_vectors(): + root = os.environ.get("CYBERSPACE_CLI_DIR") + with open(os.path.join(root, "tests", "fixtures", "avatar_work.json")) as fh: + for case in json.load(fh): + emit({"name": case["name"], "required": case["required"], "payload": case["payload"]}) + + +def cmd_verdict(): + sno = _sno() + payload = json.load(sys.stdin) + try: + sno.validate(dict(payload)) + emit({"valid": True, "rule": None}) + except sno.SnoError as err: + text = str(err) + rule = text.split(":")[0].replace("rule ", "").strip() if text.startswith("rule ") else "?" + emit({"valid": False, "rule": rule}) + + +def cmd_work(): + avatar = _avatar() + emit({"required": avatar.avatar_work(json.load(sys.stdin))}) + + +def cmd_verify(): + avatar = _avatar() + result = avatar.verify_avatar_work(json.load(sys.stdin)) + emit({k: result[k] for k in ("ok", "required", "committed", "zeros", "reason")}) + + +COMMANDS = { + "cases": cmd_cases, + "vectors": cmd_vectors, + "verdict": cmd_verdict, + "work": cmd_work, + "verify": cmd_verify, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + raise SystemExit(f"usage: refdriver.py <{'|'.join(COMMANDS)}>") + COMMANDS[sys.argv[1]]() diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh new file mode 100755 index 0000000000..a9b4fa6a0a --- /dev/null +++ b/cli/tests/sno/sno-conformance.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# +# sno-conformance.sh — diffs amy's DECK-0003 reader against the cyberspace +# project's own reference implementations. No relay, no account, no device. +# +# Three comparisons, each driven by the reference's own fixtures rather than +# by anything we wrote: +# +# 1. §1.9 verdicts — the deck's rejection table (`_rejections()` in +# decks/sno-reference.py) plus Appendix A, through +# `amy sno parse` and through the reference. The +# valid/invalid answer AND the rule number must agree. +# 2. Avatar work — the golden vectors in cyberspace-cli's +# tests/fixtures/avatar_work.json, which §8.10 says +# both reference implementations are pinned to, +# through `amy sno work` and `avatar_work()`. +# 3. Avatar payment — synthesised events through `amy sno verify` and +# `verify_avatar_work()`. +# 4. Reader divergence — the two places we knowingly differ from the §1.9 +# arbiter, pinned so neither can drift quietly. +# +# Divergences we already know about are asserted as divergences, not ignored: +# a reader that silently stopped diverging would be just as interesting as one +# that started. +# +# Usage: ./sno-conformance.sh [--no-build] +# +# Reference checkouts are cloned into state/ unless CYBERSPACE_DIR and +# CYBERSPACE_CLI_DIR already point at them. +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-sno-conformance" +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" +DRIVER="$SCRIPT_DIR/refdriver.py" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-build) NO_BUILD=1 ;; + -h|--help) sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +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; } +command -v python3 >/dev/null || { fail_msg "python3 is required"; exit 1; } + +# ---- the reference checkouts ------------------------------------------------ + +clone_ref() { + local url="$1" dest="$2" + if [[ -d "$dest/.git" ]]; then info "reusing $dest"; return 0; fi + step "cloning $url" + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet --depth 1 "$url" "$dest" >>"$LOG_FILE" 2>&1 +} + +: "${CYBERSPACE_DIR:=$STATE_DIR/cyberspace}" +: "${CYBERSPACE_CLI_DIR:=$STATE_DIR/cyberspace-cli}" +export CYBERSPACE_DIR CYBERSPACE_CLI_DIR + +if [[ ! -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]]; then + clone_ref https://github.com/arkin0x/cyberspace "$CYBERSPACE_DIR" || true +fi +if [[ ! -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]]; then + clone_ref https://github.com/arkin0x/cyberspace-cli "$CYBERSPACE_CLI_DIR" || true +fi + +HAVE_SNO_REF=0; [[ -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]] && HAVE_SNO_REF=1 +HAVE_AVATAR_REF=0; [[ -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]] && HAVE_AVATAR_REF=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 not built at $AMY_BIN"; exit 1; } + +amy() { "$AMY_BIN" --json "$@" 2>>"$LOG_FILE"; } +ref() { python3 "$DRIVER" "$@" 2>>"$LOG_FILE"; } + +# ---- 1. §1.9 verdicts ------------------------------------------------------- + +banner "1. §1.9 verdicts — the deck's own rejection table" + +if [[ $HAVE_SNO_REF -eq 0 ]]; then + skip_msg "sno-reference.py unavailable" + record_result "sno-verdicts" skip "no cyberspace checkout" +else + AGREE=0; DISAGREE=0; DIVERGENCES="" + while IFS= read -r line; do + NAME="$(jq -r .name <<<"$line")" + PAYLOAD="$(jq -c .payload <<<"$line")" + + OURS="$(printf '%s' "$PAYLOAD" | amy sno parse -)" + THEIRS="$(printf '%s' "$PAYLOAD" | ref verdict)" + + OUR_VALID="$(jq -r '.valid' <<<"$OURS")" + OUR_RULE="$(jq -r '.rule // "null"' <<<"$OURS")" + THEIR_VALID="$(jq -r '.valid' <<<"$THEIRS")" + THEIR_RULE="$(jq -r '.rule // "null"' <<<"$THEIRS")" + + if [[ "$OUR_VALID" == "$THEIR_VALID" && "$OUR_RULE" == "$THEIR_RULE" ]]; then + AGREE=$((AGREE+1)) + else + DISAGREE=$((DISAGREE+1)) + DIVERGENCES+=" $NAME: amy(valid=$OUR_VALID rule=$OUR_RULE) ref(valid=$THEIR_VALID rule=$THEIR_RULE)"$'\n' + fi + done < <(ref cases) + + info "$AGREE agreed, $DISAGREE diverged" + [[ -n "$DIVERGENCES" ]] && printf '%s' "$DIVERGENCES" >&2 + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "sno-verdicts" pass "$AGREE cases agree" + else + record_result "sno-verdicts" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +# ---- 2. avatar work --------------------------------------------------------- + +banner "2. avatar work — cyberspace-cli's golden vectors" + +if [[ $HAVE_AVATAR_REF -eq 0 ]]; then + skip_msg "cyberspace-cli unavailable" + record_result "sno-avatar-work" skip "no cyberspace-cli checkout" +else + AGREE=0; DISAGREE=0 + while IFS= read -r line; do + NAME="$(jq -r .name <<<"$line")" + EXPECTED="$(jq -r .required <<<"$line")" + PAYLOAD="$(jq -c .payload <<<"$line")" + THEIRS="$(printf '%s' "$PAYLOAD" | ref work | jq -r .required)" + + # The fixtures carry only the fields the formula reads, and some index + # faces past the vertex list because it never looks at them — so dress + # each one as a payload §1.9 accepts, keeping the counts and the reach. + FULL="$(jq -c --argjson p "$PAYLOAD" -n ' + ($p.vertices | length) as $nv + | {v:2, name:"vector", unit:($p.unit // 0), + mode:(if (($p.faces // []) | length) > 0 then "solid" else "points" end), + vertices:$p.vertices, + colors:[range($nv) | 225], + faces:[range(($p.faces // []) | length) | [(. % $nv), ((.+1) % $nv), ((.+2) % $nv)]]} + + (if $p.ticks then {ticks:$p.ticks} else {} end)')" + + OURS="$(printf '%s' "$FULL" | amy sno work | jq -r .required)" + + if [[ "$OURS" == "$THEIRS" && "$OURS" == "$EXPECTED" ]]; then + AGREE=$((AGREE+1)) + info "$NAME: $OURS bits" + else + DISAGREE=$((DISAGREE+1)) + fail_msg "$NAME: amy=$OURS ref=$THEIRS fixture=$EXPECTED" + fi + done < <(ref vectors) + + if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then + record_result "sno-avatar-work" pass "$AGREE golden vectors agree" + else + record_result "sno-avatar-work" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged" + fi +fi + +# ---- 3. avatar payment, and the divergences we expect ----------------------- + +banner "3. avatar payment — and the divergences we know about" + +SHAPE='{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}' + +# id with exactly N leading zero bits, padded to 64 hex chars +id_with_bits() { + python3 - "$1" <<'PY' +import sys +bits = int(sys.argv[1]) +head = "0" * (bits // 4) + {0: "f", 1: "4", 2: "2", 3: "1"}[bits % 4] +print(head + "f" * (64 - len(head))) +PY +} + +avatar_event() { # kind, id-bits, committed|none, content + local kind="$1" bits="$2" committed="$3" content="$4" + local tags="[]" + [[ "$committed" != "none" ]] && tags="[[\"nonce\",\"1\",\"$committed\"]]" + jq -cn --arg id "$(id_with_bits "$bits")" --argjson kind "$kind" \ + --argjson tags "$tags" --arg content "$content" \ + '{id:$id, pubkey:"11111111111111111111111111111111111111111111111111111111111111111", kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}' \ + 2>/dev/null || jq -cn --arg id "$(id_with_bits "$bits")" --argjson kind "$kind" \ + --argjson tags "$tags" --arg content "$content" \ + '{id:$id, pubkey:("11"*32), kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}' +} + +check_verify() { # label, event, expected-amy-reason, expected-ref-reason + local label="$1" event="$2" want_ours="$3" want_theirs="$4" + local ours theirs + ours="$(printf '%s' "$event" | amy sno verify - | jq -r .reason)" + if [[ $HAVE_AVATAR_REF -eq 1 ]]; then + theirs="$(printf '%s' "$event" | ref verify | jq -r .reason)" + else + theirs="$want_theirs" + fi + if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then + record_result "$label" pass "amy=$ours ref=$theirs" + else + record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)" + fi +} + +# A paid avatar. The reference wants kind 33331 — it predates the move to +# 11333 that §8.10 documents — so it refuses a conformant one outright. +check_verify "verify-paid" "$(avatar_event 11333 16 16 "$SHAPE")" ok not-an-avatar +check_verify "verify-no-nonce" "$(avatar_event 11333 32 none "$SHAPE")" no-nonce not-an-avatar +check_verify "verify-under-committed" "$(avatar_event 11333 32 8 "$SHAPE")" under-committed not-an-avatar + +# The trap: committed 30, owes 16, id carries 20. min(20,30)=20 clears 16, so +# a reader comparing the wrong number calls this paid. Both of us refuse it. +check_verify "verify-short-of-commitment" "$(avatar_event 11333 20 30 "$SHAPE")" unpaid not-an-avatar + +# Empty content is §8.10's default avatar and owes no work; the reference +# calls it not-an-avatar because empty is not JSON. Same outcome — the client +# draws its default — different word for it. +check_verify "verify-default-avatar" "$(avatar_event 11333 0 none "")" default-avatar not-an-avatar + +# The reference's own kind. We refuse it as an avatar because DECK-0003 gave +# 33331 to standalone objects; it accepts it and prices it. +check_verify "verify-legacy-kind" "$(avatar_event 33331 16 16 "$SHAPE")" not-an-avatar ok + +# ---- 4. the two places our reader knowingly differs ------------------------ + +banner "4. reader divergence — pinned, not ignored" + +# These are not in the deck's rejection table, so section 1 never sees them. +# Both are deliberate and documented; a change in either direction is news. + +check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, note + local label="$1" payload="$2" want_ours="$3" want_theirs="$4" note="$5" + local ours theirs + ours="$(printf '%s' "$payload" | amy sno parse - | jq -r .valid)" + if [[ $HAVE_SNO_REF -eq 1 ]]; then + theirs="$(printf '%s' "$payload" | ref verdict | jq -r .valid)" + else + theirs="$want_theirs" + fi + if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then + record_result "$label" pass "amy=$ours ref=$theirs — $note" + else + record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)" + fi +} + +# D1. A v:2 payload whose colours are still literal triples. Version 2 shipped +# as two changes that did not land together, so these exist and are right in +# every other respect; §5 permits a reader to be generous and sno-core is. +# Three of the seven objects on the network are this shape. +TRIPLES='{"v":2,"name":"legacy","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]],"faces":[[0,1,2]]}' +check_verdict "divergence-v2-triples" "$TRIPLES" true false "we read the legacy colour cohort; the arbiter refuses it" + +# D5. A vertex past 64 units. §1.8 puts that bound on publishers only and lets +# a reader repair instead; §8's third open question admits leaving it off +# readers is unsafe. Neither reference bounds it. We draw strangers' events in +# a feed, and whole*120 overflows an Int long before the text's limit. +FAR='{"v":2,"name":"far","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[9999,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}' +check_verdict "divergence-position-bound" "$FAR" false true "we refuse an unbounded vertex; the arbiter accepts it" + +print_summary + +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt index 8e8c6ad188..9fd8b31ef8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -63,12 +63,13 @@ class SnoAvatarEvent( fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) /** - * Whether this avatar has paid for the room it takes up (§8.10). + * Whether this avatar has paid for the room it takes up, and why not when + * it has not (§8.10). * - * Both conditions are required: the committed target must cover the work the - * payload owes, **and** the id must actually carry that many leading zero - * bits. Committing the target before mining is what stops a lucky id being - * claimed against a lower bar than it was mined for (NIP-13). + * Both conditions are required: the committed target must cover the work + * the payload owes, **and** the id must actually carry that many leading + * zero bits. Committing the target before mining is what stops a lucky id + * being claimed against a lower bar than it was mined for (NIP-13). * * Note that `Event.pow()` cannot stand in for this. It is * `PoWRankEvaluator.compute(id, commitedPoW)`, which returns @@ -77,14 +78,33 @@ class SnoAvatarEvent( * committed 30 and an id carrying 20 gives 20, which clears 16 while * failing the second condition outright. */ - fun isPaid(): Boolean { - if (isDefaultAvatar()) return true - val payload = sno().payloadOrNull() ?: return false - val committed = tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } ?: return false - if (committed < SnoAvatarWork.required(payload)) return false - return PoWRankEvaluator.calculatePowRankOf(id) >= committed + fun payment(): SnoAvatarPayment { + val zeros = PoWRankEvaluator.calculatePowRankOf(id) + if (isDefaultAvatar()) { + return SnoAvatarPayment(true, 0, null, zeros, SnoAvatarPayment.Reason.DEFAULT_AVATAR) + } + + val payload = + sno().payloadOrNull() + ?: return SnoAvatarPayment(false, 0, null, zeros, SnoAvatarPayment.Reason.NOT_AN_AVATAR) + + val required = SnoAvatarWork.required(payload) + val committed = + tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } + ?: return SnoAvatarPayment(false, required, null, zeros, SnoAvatarPayment.Reason.NO_NONCE) + + if (committed < required) { + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNDER_COMMITTED) + } + if (zeros < committed) { + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNPAID) + } + return SnoAvatarPayment(true, required, committed, zeros, SnoAvatarPayment.Reason.OK) } + /** Whether a client may draw this avatar. See [payment] for why not. */ + fun isPaid(): Boolean = payment().ok + companion object { const val KIND = 11333 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt new file mode 100644 index 0000000000..46572a9494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt @@ -0,0 +1,72 @@ +/* + * 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.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable + +/** + * Why an avatar may or may not be drawn (`CYBERSPACE_V2.md` §8.10). + * + * The fields and the [reason] vocabulary deliberately mirror + * `verify_avatar_work` in the reference implementations, so the two can be + * diffed directly rather than compared by eye. + */ +@Immutable +data class SnoAvatarPayment( + /** Whether a client may draw this avatar. */ + val ok: Boolean, + /** The leading zero bits this shape owes, or 0 when the shape is unreadable. */ + val required: Int, + /** The target the publisher committed to in its `nonce` tag, if it wrote one. */ + val committed: Int?, + /** The leading zero bits the event id actually carries. */ + val zeros: Int, + val reason: Reason, +) { + enum class Reason( + val code: String, + ) { + OK("ok"), + + /** + * Empty content, which §8.10 defines as the default avatar: it owes no + * work and there is nothing to draw but the client's own default. + * + * The reference implementations answer `not-an-avatar` here, because + * their check begins by parsing the content and empty is not JSON. The + * two agree on what happens — the default gets drawn — and differ only + * in what they call it. + */ + DEFAULT_AVATAR("default-avatar"), + + /** Not kind 11333, or content that cannot be read as a payload. */ + NOT_AN_AVATAR("not-an-avatar"), + + /** No `nonce` tag, so nothing was committed to before mining (NIP-13). */ + NO_NONCE("no-nonce"), + + /** The committed target does not cover the work the shape owes. */ + UNDER_COMMITTED("under-committed"), + + /** The id does not carry the zeros its own commitment promised. */ + UNPAID("unpaid"), + } +}