feat(cli): amy sno, and a conformance harness against the reference

`amy sno parse|work|verify` — validate a DECK-0003 payload against §1.9,
price the proof of work an avatar owes for it, and check whether a
kind:11333 avatar has paid. Local, accountless, thin over quartz: the
verbs exist so the reader can be diffed against the implementations the
spec points at, without a device or a relay.

cli/tests/sno/ is that diff. The reference's own fixtures drive it —
_rejections() out of decks/sno-reference.py and the golden vectors out of
cyberspace-cli — so the cases are theirs, not ours. 26 §1.9 verdicts
agree on both the answer and the rule number; all 8 avatar vectors agree.

The four places the two differ are asserted as divergences rather than
skipped, because a reader that quietly stopped diverging would be as
interesting as one that started:

- we read the v:2-with-literal-triples cohort, the arbiter refuses it (§5
  permits the generosity; three of seven live objects need it);
- we refuse a vertex past 64 units, the arbiter accepts it (§8's own open
  question, and whole*120 overflows an Int);
- the Python reference still calls 33331 the avatar kind, a week behind
  §8.10's move to 11333, so it reads a conformant avatar as
  not-an-avatar and would price a standalone object as one;
- empty content is §8.10's default avatar to us and not-an-avatar to it.
  Same outcome — the client draws its default — different word.

SnoAvatarEvent gains payment(), which reports required/committed/zeros
and why, mirroring verify_avatar_work's shape so the two are directly
comparable; isPaid() is now that result's ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
This commit is contained in:
Vitor Pamplona
2026-09-22 02:41:01 +00:00
co-authored by Claude Opus 5
parent fdb6630ea2
commit 288ed01105
10 changed files with 691 additions and 11 deletions
+1
View File
@@ -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 <parse\|work\|verify>` | 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. |
+1
View File
@@ -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 | 🆕 | |
@@ -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<String>): 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 <parse|work|verify> 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)
@@ -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<String>): Int =
route(
"sno",
tail,
"sno <parse|work|verify>",
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<String>): 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<String>): 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<String>): 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<String, Any?> =
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
}
}
+11
View File
@@ -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`.
+1
View File
@@ -0,0 +1 @@
state-sno-conformance/
+106
View File
@@ -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]]()
+284
View File
@@ -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
@@ -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
}
@@ -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"),
}
}