mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 5
parent
fdb6630ea2
commit
288ed01105
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
state-sno-conformance/
|
||||
Executable
+106
@@ -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]]()
|
||||
Executable
+284
@@ -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
|
||||
+31
-11
@@ -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
|
||||
}
|
||||
|
||||
+72
@@ -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"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user