From b41160f1cb5e96da89d84cc552428e4ed7e8b009 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:13:05 +0000 Subject: [PATCH 01/26] feat(cli): add nak-style stateless primitives (decode, encode, verify, key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the first batch of nak parity commands to amy — the army-knife primitives that operate purely on their arguments, with no account or network. They dispatch before account resolution (like `use`), so they run with zero `~/.amy/` state: - `amy decode ENTITY` NIP-19/21 entity -> JSON - `amy encode …` raw parts -> NIP-19 entity - `amy verify [JSON]` id-hash + signature check (reads stdin) - `amy key generate|public` mint a keypair / derive a pubkey All four are thin wrappers over quartz (Nip19Parser, the NIP-19 entities, Event.verifyId/verifySignature, KeyPair) per the cli thin-assembly rule. README + ROADMAP updated with a nak-parity matrix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 17 +++ cli/ROADMAP.md | 26 ++++ .../com/vitorpamplona/amethyst/cli/Main.kt | 32 +++++ .../amethyst/cli/commands/DecodeCommand.kt | 87 +++++++++++++ .../amethyst/cli/commands/EncodeCommand.kt | 114 ++++++++++++++++++ .../amethyst/cli/commands/KeyCommands.kt | 80 ++++++++++++ .../amethyst/cli/commands/VerifyCommand.kt | 75 ++++++++++++ 7 files changed, 431 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt diff --git a/cli/README.md b/cli/README.md index d5cf211887..df8b6c4228 100644 --- a/cli/README.md +++ b/cli/README.md @@ -197,6 +197,23 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 ## Commands +### Primitives (stateless — no account or network) + +Army-knife verbs that operate purely on their arguments. They never touch +`~/.amy/`, so they run with zero state — handy for scripting and piping +(`amy decode … | jq`, `… | amy verify`). + +| Command | What it does | +|---|---| +| `amy decode ENTITY` | Decode a NIP-19/21 entity (`npub`/`nsec`/`note`/`nevent`/`nprofile`/`naddr`/`nrelay`/`nembed`) to JSON. Accepts an optional `nostr:` prefix. | +| `amy encode npub HEX` / `nsec HEX` / `note ID` | Encode a single 32-byte hex value into the matching NIP-19 entity. | +| `amy encode nevent ID [--author HEX] [--kind N] [--relay URL[,URL…]]` | Encode an event pointer with optional author/kind/relay hints. | +| `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. | +| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | +| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | +| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | +| `amy key public NSEC\|HEX` | Derive the public key from a secret key. | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 4dd633c015..39971a418c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -70,6 +70,32 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Notifications feed | 🆕 | | | Search (NIP-50) | 🆕 | | +### `nak` parity — army-knife primitives + +Tracking [`fiatjaf/nak`](https://github.com/fiatjaf/nak)'s command surface. amy +adapts the verbiage where its own conventions differ (`req` → one-shot `fetch` +vs streaming `subscribe`). Stateless verbs run with no account or network. + +| nak command | amy verb | Status | Notes | +|---|---|---|---| +| `decode` | `amy decode` | ✅ | NIP-19/21 → JSON. Quartz `Nip19Parser`. | +| `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | +| `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | +| `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. | +| `event` | `amy event` | 🆕 | build/sign an arbitrary event, optional `--publish`. | +| `publish` | `amy publish` | 🆕 | broadcast a pre-made event JSON. | +| `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. | +| `req` (stream) | `amy subscribe` | 🆕 | filter → live stream to stdout. | +| `count` | `amy count` | 🆕 | NIP-45. | +| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | 🆕 | raw NIP-44 / NIP-04. | +| `gift` | `amy gift wrap\|unwrap` | 🆕 | NIP-59 primitive (gift-wrap path already used by `dm`). | +| `relay` (NIP-11) | `amy relay info` | 🆕 | amy's `relay` is config today; add an `info` verb. | +| `outbox` | `amy outbox` | 🆕 | NIP-65 relay discovery for a user. | +| `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | +| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups. | +| `sync` | `amy relay sync` | 🆕 | NIP-77 Negentropy. | +| `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | + --- ## Order of operations 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 5ddad30bd8..1c28fedc2e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -122,6 +122,24 @@ private suspend fun dispatch(argv: Array): Int { .run(tail) } + // Stateless local primitives (nak-style army-knife verbs). They operate + // purely on their arguments — no identity, no relays, no `~/.amy/` — so + // they dispatch before account resolution and work with zero state. + when (head) { + "decode" -> + return com.vitorpamplona.amethyst.cli.commands.DecodeCommand + .run(tail) + "encode" -> + return com.vitorpamplona.amethyst.cli.commands.EncodeCommand + .run(tail) + "verify" -> + return com.vitorpamplona.amethyst.cli.commands.VerifyCommand + .run(tail) + "key" -> + return com.vitorpamplona.amethyst.cli.commands.KeyCommands + .dispatch(tail) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) @@ -333,6 +351,20 @@ private fun printUsage() { | then ${'$'}AMY_PASSPHRASE, then a TTY prompt. `plaintext` writes the | private key directly into identity.json (still 0600) — dev only. | + |Primitives (stateless — no account or network needed): + | decode ENTITY decode a NIP-19/21 entity (npub|nsec|note|nevent| + | nprofile|naddr|nrelay|nembed) to JSON + | encode npub HEX encode raw parts into a NIP-19 entity: + | encode nsec HEX nevent/nprofile/naddr accept --relay URL[,URL…]; + | encode note ID nevent accepts --author HEX --kind N; + | encode nevent ID [...] naddr needs --kind N --pubkey HEX --identifier D + | encode nprofile HEX [...] + | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + | verify [EVENT-JSON] check an event's id hash + signature + | (reads stdin when the arg is omitted or `-`) + | key generate mint a fresh keypair (nsec + npub + hex) + | key public NSEC|HEX derive the public key from a secret key + | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt new file mode 100644 index 0000000000..b6a1585d1e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt @@ -0,0 +1,87 @@ +/* + * 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.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec + +/** + * `amy decode ` — turn a NIP-19 / NIP-21 entity into its structured + * parts (nak's `decode`). Local, no network, no account. Accepts an optional + * `nostr:` prefix. + * + * Thin assembly only: all parsing lives in quartz's [Nip19Parser]; this file + * just maps the parsed entity onto amy's result-map output contract. + */ +object DecodeCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val input = args.positional(0, "entity").trim() + + val entity = + Nip19Parser.uriToRoute(input)?.entity + ?: return Output.error("bad_args", "not a recognized NIP-19 entity (npub, nsec, note, nevent, nprofile, naddr, nrelay, nembed)") + + val result: Map = + when (entity) { + is NPub -> mapOf("type" to "npub", "pubkey" to entity.hex) + is NSec -> mapOf("type" to "nsec", "private_key" to entity.hex, "pubkey" to entity.toPubKeyHex()) + is NNote -> mapOf("type" to "note", "id" to entity.hex) + is NProfile -> + mapOf( + "type" to "nprofile", + "pubkey" to entity.hex, + "relays" to entity.relay.map { it.url }, + ) + is NEvent -> + mapOf( + "type" to "nevent", + "id" to entity.hex, + "author" to entity.author, + "kind" to entity.kind, + "relays" to entity.relay.map { it.url }, + ) + is NAddress -> + mapOf( + "type" to "naddr", + "kind" to entity.kind, + "pubkey" to entity.author, + "identifier" to entity.dTag, + "relays" to entity.relay.map { it.url }, + ) + is NRelay -> mapOf("type" to "nrelay", "relays" to entity.relay) + is NEmbed -> mapOf("type" to "nembed", "event" to Output.mapper.readTree(entity.event.toJson())) + else -> return Output.error("bad_args", "unsupported entity type") + } + + Output.emit(result) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt new file mode 100644 index 0000000000..fc5322586d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt @@ -0,0 +1,114 @@ +/* + * 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.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.toNsec + +/** + * `amy encode …` — build a NIP-19 entity from raw parts (nak's + * `encode`). Local, no network, no account. + * + * encode npub + * encode nsec + * encode note + * encode nevent [--author HEX] [--kind N] [--relay URL[,URL…]] + * encode nprofile [--relay URL[,URL…]] + * encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + * + * Thin assembly only: every encoder lives in quartz's NIP-19 entities; this + * file parses flags and calls them. + */ +object EncodeCommand { + fun run(rest: Array): Int { + if (rest.isEmpty()) return Output.error("bad_args", "encode …") + val type = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + + return when (type) { + "npub" -> emit("npub", NPub.create(hex32(args.positional(0, "pubkey-hex")))) + "nsec" -> emit("nsec", hex32(args.positional(0, "private-key-hex")).hexToByteArray().toNsec()) + "note" -> emit("note", NNote.create(hex32(args.positional(0, "event-id-hex")))) + "nevent" -> + emit( + "nevent", + NEvent.create( + idHex = hex32(args.positional(0, "event-id-hex")), + author = args.flag("author"), + kind = args.flag("kind")?.toIntOrNull(), + relays = relays(args), + ), + ) + "nprofile" -> + emit( + "nprofile", + NProfile.create( + authorPubKeyHex = hex32(args.positional(0, "pubkey-hex")), + relays = relays(args), + ), + ) + "naddr" -> + emit( + "naddr", + NAddress.create( + kind = args.requireFlag("kind").toIntOrNull() ?: return Output.error("bad_args", "--kind must be an integer"), + pubKeyHex = hex32(args.requireFlag("pubkey")), + dTag = args.flag("identifier", "") ?: "", + relays = relays(args), + ), + ) + else -> Output.error("bad_args", "encode $type (expected npub|nsec|note|nevent|nprofile|naddr)") + } + } + + /** Validate a 64-char hex key/id; bare-hex only — bech32 inputs go through `decode` first. */ + private fun hex32(value: String): String { + val v = value.trim().lowercase() + require(v.length == 64 && v.all { it in "0123456789abcdef" }) { + "expected 64-char hex, got '$value'" + } + return v + } + + private fun relays(args: Args): List = + args + .flag("relay") + ?.split(',') + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + .orEmpty() + + private fun emit( + key: String, + value: String, + ): Int { + Output.emit(mapOf(key to value)) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt new file mode 100644 index 0000000000..40cd57936d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -0,0 +1,80 @@ +/* + * 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.Identity +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +/** + * `amy key …` — standalone key utilities (nak's `key`). Local, no network, + * no account; these never touch `~/.amy/` — they operate purely on the keys + * passed in. Use `amy init` / `amy login` to persist an identity. + * + * key generate mint a fresh keypair (prints nsec + npub + hex) + * key public derive the public key from a secret key + * + * Thin assembly only: key generation + bech32 derivation live in quartz + * (reused here via [Identity], which wraps Quartz's `KeyPair`). + */ +object KeyCommands { + fun dispatch(rest: Array): Int { + if (rest.isEmpty()) return Output.error("bad_args", "key ") + val tail = rest.drop(1).toTypedArray() + return when (rest[0]) { + "generate" -> generate() + "public" -> public(tail) + else -> Output.error("bad_args", "key ${rest[0]} (expected generate|public)") + } + } + + private fun generate(): Int { + val id = Identity.create() + Output.emit( + mapOf( + "nsec" to id.nsec, + "npub" to id.npub, + "private_key" to id.privKeyHex, + "pubkey" to id.pubKeyHex, + ), + ) + return 0 + } + + private fun public(rest: Array): Int { + val args = Args(rest) + val input = args.positional(0, "secret-key").trim() + val id = + try { + when { + input.startsWith("nsec") -> Identity.fromNsec(input) + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> + Identity.fromPrivateKey(input.hexToByteArray()) + else -> return Output.error("bad_args", "expected an nsec or a 64-char hex private key") + } + } catch (e: Exception) { + return Output.error("bad_args", "could not parse secret key: ${e.message}") + } + Output.emit(mapOf("npub" to id.npub, "pubkey" to id.pubKeyHex)) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt new file mode 100644 index 0000000000..0733a00e71 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt @@ -0,0 +1,75 @@ +/* + * 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.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verifyId +import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature + +/** + * `amy verify [EVENT-JSON]` — check a Nostr event's id hash and signature + * (nak's `verify`). Local, no network, no account. The event JSON is read + * from the positional argument, or from stdin when the argument is omitted + * or `-`. + * + * Reports `id_ok` (computed id matches the `id` field) and `signature_ok` + * separately so a caller can tell a tampered id apart from a bad sig. + * `valid` is the conjunction. Exit code stays 0 — the result is data, not + * a runtime failure (parse errors are runtime/bad_args). + */ +object VerifyCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val arg = args.positionalOrNull(0) + val json = + if (arg == null || arg == "-") { + System.`in` + .readBytes() + .decodeToString() + .trim() + } else { + arg.trim() + } + if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin") + + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse event JSON: ${e.message}") + } + + val idOk = event.verifyId() + val sigOk = event.verifySignature() + + Output.emit( + mapOf( + "valid" to (idOk && sigOk), + "id" to event.id, + "id_ok" to idOk, + "signature_ok" to sigOk, + ), + ) + return 0 + } +} From b539609dfecbbf38135cf7ad05a8fe9f610c4e88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:30:47 +0000 Subject: [PATCH 02/26] feat(cli): add nak-style event + publish primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of nak parity: - `amy event --kind N [--content …] [--tags JSON] [--created-at TS]` builds and signs an arbitrary event with the active account. Prints the signed event by default; `--publish` / `--relay` broadcasts it. - `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed event (verified before broadcast; reads stdin when no arg). Both reuse quartz EventTemplate/NostrSigner.sign and the existing Context.publish path. New RawEventSupport holds the shared arg/stdin + relay-target helpers for the raw-event verbs. Verified offline via an event -> verify round-trip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 8 ++ cli/ROADMAP.md | 4 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 16 +++ .../amethyst/cli/commands/Commands.kt | 10 ++ .../amethyst/cli/commands/EventCommand.kt | 102 ++++++++++++++++++ .../amethyst/cli/commands/PublishCommand.kt | 79 ++++++++++++++ .../amethyst/cli/commands/RawEventSupport.kt | 68 ++++++++++++ 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt diff --git a/cli/README.md b/cli/README.md index df8b6c4228..7bfb2d9a28 100644 --- a/cli/README.md +++ b/cli/README.md @@ -214,6 +214,14 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | +### Raw events + +| Command | What it does | +|---|---| +| `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS]` | Build + sign an arbitrary event with the active account. Prints the signed event. `--tags` is a JSON array-of-arrays, e.g. `'[["t","nostr"],["e",""]]'`. | +| `amy event … --publish` / `--relay URL[,URL…]` | As above, then broadcast (to the outbox, or to the given relays). | +| `amy publish [EVENT-JSON] [--relay URL[,URL…]]` | Broadcast a pre-made signed event (verified first). Reads stdin when the argument is omitted or `-`. | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 39971a418c..da49fa1fc8 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -82,8 +82,8 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | | `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | | `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. | -| `event` | `amy event` | 🆕 | build/sign an arbitrary event, optional `--publish`. | -| `publish` | `amy publish` | 🆕 | broadcast a pre-made event JSON. | +| `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. | +| `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | | `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. | | `req` (stream) | `amy subscribe` | 🆕 | filter → live stream to stdout. | | `count` | `amy count` | 🆕 | NIP-45. | 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 1c28fedc2e..fc675a43f0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -216,6 +216,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.debit(dataDir, tail) } + "event" -> { + Commands.event(dataDir, tail) + } + + "publish" -> { + Commands.publish(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -398,6 +406,14 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Raw events (build / sign / broadcast): + | event --kind N [--content TEXT] build + sign an arbitrary event with the active + | [--tags JSON] [--created-at TS] account. Prints the signed event; add --publish + | [--publish] [--relay URL[,URL…]] (or --relay) to broadcast. --tags takes a JSON + | array-of-arrays, e.g. '[["t","nostr"]]'. + | publish [EVENT-JSON] [--relay URL[,URL…]] broadcast a pre-made signed event (verified + | first; reads stdin when the arg is omitted/`-`) + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 0859b4dc8f..6335416358 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -135,4 +135,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = DebitCommands.dispatch(dataDir, tail) + + suspend fun event( + dataDir: DataDir, + tail: Array, + ): Int = EventCommand.run(dataDir, tail) + + suspend fun publish( + dataDir: DataDir, + tail: Array, + ): Int = PublishCommand.run(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt new file mode 100644 index 0000000000..845f444364 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt @@ -0,0 +1,102 @@ +/* + * 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.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS] + * [--publish] [--relay URL[,URL…]]` — build and sign an arbitrary + * Nostr event (nak's `event`). + * + * Signs with the active account. By default it only prints the signed event + * (no relay traffic). Pass `--publish` to broadcast to the account's outbox, + * or `--relay` to broadcast to a specific set (implies publish). + * + * `--tags` takes a JSON array-of-arrays, e.g. + * --tags '[["p",""],["t","nostr"],["e","","","root"]]' + * + * Thin assembly only: event construction + signing live in quartz + * (`EventTemplate` / `NostrSigner.sign`); this file parses flags. + */ +object EventCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val kind = + args.flag("kind")?.toIntOrNull() + ?: return Output.error("bad_args", "event requires --kind N") + val content = args.flag("content", "") ?: "" + val createdAt = args.flag("created-at")?.toLongOrNull() ?: TimeUtils.now() + + val tags: Array> = + try { + args + .flag("tags") + ?.let { Output.mapper.readValue>>(it) } + ?.map { it.toTypedArray() } + ?.toTypedArray() + ?: emptyArray() + } catch (e: Exception) { + return Output.error("bad_args", "--tags must be a JSON array of string arrays: ${e.message}") + } + + // Publish when explicitly asked (--publish) or when a relay set is given. + val wantPublish = args.bool("publish") || args.flag("relay") != null + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val signed: Event = ctx.signer.sign(createdAt, kind, tags, content) + val eventNode = Output.mapper.readTree(signed.toJson()) + + if (!wantPublish) { + Output.emit(mapOf("event" to eventNode, "published" to false)) + return 0 + } + + val targets = RawEventSupport.publishTargets(ctx, args) + if (targets.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event" to eventNode, + "published" to true, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt new file mode 100644 index 0000000000..b668b1fdcf --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify + +/** + * `amy publish [EVENT-JSON] [--relay URL[,URL…]]` — broadcast a pre-made, + * already-signed event (nak's `publish`). The event JSON comes from the + * positional argument or from stdin when omitted or `-`. + * + * The event is verified before broadcast — a bad id/signature is rejected + * rather than published. Targets default to the account's outbox when + * `--relay` is not given. + */ +object PublishCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin") + + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse event JSON: ${e.message}") + } + if (!event.verify()) { + return Output.error("invalid_event", "event id/signature does not verify — refusing to publish") + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val targets = RawEventSupport.publishTargets(ctx, args) + if (targets.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + val ack = ctx.publish(event, targets) + Output.emit( + mapOf( + "event_id" to event.id, + "kind" to event.kind, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt new file mode 100644 index 0000000000..7401d5f165 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * Shared helpers for the nak-style raw-event verbs (`event`, `publish`, + * `fetch`, `subscribe`, `count`). Kept tiny — parsing/normalisation only, + * no protocol logic. + */ +object RawEventSupport { + /** + * Read a blob from the first positional argument, or from stdin when the + * argument is omitted or `-`. Used by verbs that take event/filter JSON. + */ + fun readArgOrStdin(args: Args): String { + val arg = args.positionalOrNull(0) + return if (arg == null || arg == "-") { + System.`in` + .readBytes() + .decodeToString() + .trim() + } else { + arg.trim() + } + } + + /** Parse a `--relay a,b,c` flag into normalized relay URLs (silently drops un-normalizable entries). */ + fun relayFlag(args: Args): Set = + args + .flag("relay") + ?.split(',') + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + ?.toSet() + .orEmpty() + + /** + * Resolve where to publish: the explicit `--relay` set when given, + * otherwise the account's NIP-65 outbox. Empty only when neither is + * available (caller turns that into a `no_relays` error). + */ + suspend fun publishTargets( + ctx: Context, + args: Args, + ): Set = relayFlag(args).ifEmpty { ctx.outboxRelays() } +} From afc3c83baafc59ed7b2c71a11817356270732a14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:34:24 +0000 Subject: [PATCH 03/26] feat(cli): add nak-style fetch + subscribe query primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third batch of nak parity — the fetch-vs-subscribe split of nak's `req`: - `amy fetch [filter] [--timeout SECS]` — one-shot query: open a subscription, collect until every relay sends EOSE (or timeout), dedupe by id, sort newest-first, cap at --limit (default 100), exit. - `amy subscribe [filter] [--timeout SECS]` — live stream: print each matching event as it arrives (NDJSON under --json), until timeout or interrupt. Shared filter flags (--kind/--author/--id/--tag/--since/--until/--limit /--search) are assembled by RawEventSupport.buildFilter; --author/--id accept npub/nevent/note/naddr or hex (local decode). fetch reuses Context.drain; subscribe uses the client subscription directly and verifies each event before printing. Verified against relay.damus.io (kind:0 by author, kind:1 stream). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 9 ++ cli/ROADMAP.md | 4 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 17 ++++ .../amethyst/cli/commands/Commands.kt | 10 +++ .../amethyst/cli/commands/FetchCommand.kt | 80 +++++++++++++++++ .../amethyst/cli/commands/RawEventSupport.kt | 68 +++++++++++++++ .../amethyst/cli/commands/SubscribeCommand.kt | 86 +++++++++++++++++++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt diff --git a/cli/README.md b/cli/README.md index 7bfb2d9a28..a93bf1997f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -222,6 +222,15 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy event … --publish` / `--relay URL[,URL…]` | As above, then broadcast (to the outbox, or to the given relays). | | `amy publish [EVENT-JSON] [--relay URL[,URL…]]` | Broadcast a pre-made signed event (verified first). Reads stdin when the argument is omitted or `-`. | +### Queries + +Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[,U]` (npub/nprofile/hex), `--id ID[,ID]` (note/nevent/naddr/hex), `--tag e=ID,p=PK,t=hashtag`, `--since TS`, `--until TS`, `--limit N`, `--search TEXT`, `--relay URL[,URL…]`. Relays default to your outbox, then the bootstrap set. + +| Command | What it does | +|---|---| +| `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. | +| `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index da49fa1fc8..3d6f4c93ee 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -84,8 +84,8 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. | | `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. | | `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | -| `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. | -| `req` (stream) | `amy subscribe` | 🆕 | filter → live stream to stdout. | +| `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. | +| `req` (stream) | `amy subscribe` | ✅ | filter → live NDJSON stream to stdout. | | `count` | `amy count` | 🆕 | NIP-45. | | `encrypt` / `decrypt` | `amy encrypt\|decrypt` | 🆕 | raw NIP-44 / NIP-04. | | `gift` | `amy gift wrap\|unwrap` | 🆕 | NIP-59 primitive (gift-wrap path already used by `dm`). | 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 fc675a43f0..f7d7bc6eda 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -224,6 +224,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.publish(dataDir, tail) } + "fetch" -> { + Commands.fetch(dataDir, tail) + } + + "subscribe" -> { + Commands.subscribe(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -414,6 +422,15 @@ private fun printUsage() { | publish [EVENT-JSON] [--relay URL[,URL…]] broadcast a pre-made signed event (verified | first; reads stdin when the arg is omitted/`-`) | + |Queries (filter flags shared by fetch/subscribe): + | fetch [--kind K[,K]] [--author U[,U]] one-shot query: collect until EOSE, print, exit. + | [--id ID[,ID]] [--tag e=ID,p=PK,…] --author/--id accept npub/nevent/note/hex. + | [--since TS] [--until TS] [--limit N] default --limit 100, --timeout 8s. + | [--search TEXT] [--relay URL[,URL…]] + | [--timeout SECS] + | subscribe [] live stream: print each event as it arrives + | [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt. + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 6335416358..2b14554b10 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -145,4 +145,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = PublishCommand.run(dataDir, tail) + + suspend fun fetch( + dataDir: DataDir, + tail: Array, + ): Int = FetchCommand.run(dataDir, tail) + + suspend fun subscribe( + dataDir: DataDir, + tail: Array, + ): Int = SubscribeCommand.run(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt new file mode 100644 index 0000000000..11ce9cb2e8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy fetch [--kind …] [--author …] [--id …] [--tag …] [--since/--until TS] + * [--limit N] [--search TEXT] [--relay URL[,URL…]] [--timeout SECS]` + * + * One-shot query: open a subscription, collect everything until every relay + * sends EOSE (or the timeout fires), then print and exit. This is the bounded + * half of nak's `req`; the live-streaming half is `amy subscribe`. + * + * Results are deduplicated by id, sorted newest-first, capped at `--limit` + * (default 100), and emitted as full event JSON under an `events` array. + * Relays default to the account's outbox, then the bootstrap union. + */ +object FetchCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.flag("limit")?.toIntOrNull() ?: 100 + if (limit <= 0) return Output.error("bad_args", "--limit must be > 0") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + val filter = RawEventSupport.buildFilter(args) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + val events = + received + .asSequence() + .map { it.second } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + .take(limit) + .map { Output.mapper.readTree(it.toJson()) } + .toList() + + Output.emit( + mapOf( + "queried_relays" to relays.map { it.url }, + "count" to events.size, + "events" to events, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt index 7401d5f165..9a81788869 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull /** * Shared helpers for the nak-style raw-event verbs (`event`, `publish`, @@ -65,4 +68,69 @@ object RawEventSupport { ctx: Context, args: Args, ): Set = relayFlag(args).ifEmpty { ctx.outboxRelays() } + + /** + * Where to read from for `fetch` / `subscribe` / `count`: the explicit + * `--relay` set, else the account's outbox, else the bootstrap union. + */ + suspend fun queryTargets( + ctx: Context, + args: Args, + ): Set = relayFlag(args).ifEmpty { ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } } + + /** + * Assemble a NIP-01 [Filter] from the common query flags shared by + * `fetch` / `subscribe` / `count`: + * + * --kind 1,7 comma-separated kind ints + * --author a,b comma-separated npub / nprofile / 64-hex (local decode only) + * --id x,y comma-separated note / nevent / naddr / 64-hex + * --tag e=,p=,t=nostr generic single-letter tag filters + * --since / --until unix seconds + * --limit N + * --search TEXT NIP-50 + * + * Author/id decoding is local (no NIP-05 round-trip) — pass hex or a + * bech32 entity. Unparseable entries are dropped. + */ + fun buildFilter(args: Args): Filter { + val kinds = + args + .flag("kind") + ?.split(',') + ?.mapNotNull { it.trim().toIntOrNull() } + ?.takeIf { it.isNotEmpty() } + val authors = + args + .flag("author") + ?.split(',') + ?.mapNotNull { decodePublicKeyAsHexOrNull(it.trim()) } + ?.takeIf { it.isNotEmpty() } + val ids = + args + .flag("id") + ?.split(',') + ?.mapNotNull { decodeEventIdAsHexOrNull(it.trim()) } + ?.takeIf { it.isNotEmpty() } + val tags = + args + .flag("tag") + ?.split(',') + ?.mapNotNull { pair -> + val idx = pair.indexOf('=') + if (idx <= 0) null else pair.take(idx).trim() to pair.substring(idx + 1).trim() + }?.groupBy({ it.first }, { it.second }) + ?.takeIf { it.isNotEmpty() } + + return Filter( + ids = ids, + authors = authors, + kinds = kinds, + tags = tags, + since = args.flag("since")?.toLongOrNull(), + until = args.flag("until")?.toLongOrNull(), + limit = args.flag("limit")?.toIntOrNull(), + search = args.flag("search"), + ) + } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt new file mode 100644 index 0000000000..6c4754d087 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withTimeoutOrNull + +/** + * `amy subscribe [] [--relay URL[,URL…]] + * [--timeout SECS]` + * + * The live-streaming half of nak's `req`: open a subscription and print each + * matching event as it arrives — one object per line (NDJSON under `--json`). + * Unlike `fetch` it does not stop at EOSE; it streams until `--timeout` SECS + * elapse, or until the process is interrupted when no timeout is given. + * + * Each event is signature-verified before printing; bad ones are dropped. + */ +object SubscribeCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + val filter = RawEventSupport.buildFilter(args) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.verify()) Output.emit(Output.mapper.readTree(event.toJson())) + } + } + + ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { awaitCancellation() } else awaitCancellation() + } finally { + ctx.client.unsubscribe(subId) + } + return 0 + } finally { + ctx.close() + } + } +} From 0ec3fc69e8cd57b5dd6a454fa7f513a226573110 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:38:01 +0000 Subject: [PATCH 04/26] feat(cli): add nak-style count, encrypt/decrypt, gift primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth batch of nak parity: - `amy count [filter]` — NIP-45 COUNT, per-relay match counts (reuses quartz INostrClient.count). Reports per-relay + max as `total`. - `amy encrypt --to USER [TEXT]` / `amy decrypt --from USER [CIPHER]` — raw NIP-44 (default) or NIP-04 (--nip04) with the active account key. - `amy gift wrap --to USER [EVENT]` / `amy gift unwrap [WRAP]` — NIP-59 seal+wrap and unwrap+unseal (reuses SealedRumorEvent/GiftWrapEvent). Text/ciphertext/JSON all read from arg or stdin. Verified with two local accounts: NIP-44 + NIP-04 round-trips, gift wrap(1059)->unwrap recovering the inner note + author, and count=60 against relay.damus.io. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 10 ++ cli/ROADMAP.md | 6 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 27 ++++ .../amethyst/cli/commands/Commands.kt | 20 +++ .../amethyst/cli/commands/CountCommand.kt | 78 ++++++++++ .../amethyst/cli/commands/CryptoCommands.kt | 88 ++++++++++++ .../amethyst/cli/commands/GiftCommands.kt | 135 ++++++++++++++++++ 7 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt diff --git a/cli/README.md b/cli/README.md index a93bf1997f..2b24b2ea5d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -230,6 +230,16 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ |---|---| | `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. | | `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | +| `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. | + +### Encryption + +| Command | What it does | +|---|---| +| `amy encrypt --to USER [TEXT] [--nip04]` | NIP-44 (default) or NIP-04 encrypt with the active account's key. Reads stdin when TEXT is omitted or `-`. USER accepts npub/nprofile/hex/NIP-05. | +| `amy decrypt --from USER [CIPHERTEXT] [--nip04]` | Inverse of `encrypt`. | +| `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | +| `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | ### Identity diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 3d6f4c93ee..9acbcb0a51 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -86,9 +86,9 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | | `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. | | `req` (stream) | `amy subscribe` | ✅ | filter → live NDJSON stream to stdout. | -| `count` | `amy count` | 🆕 | NIP-45. | -| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | 🆕 | raw NIP-44 / NIP-04. | -| `gift` | `amy gift wrap\|unwrap` | 🆕 | NIP-59 primitive (gift-wrap path already used by `dm`). | +| `count` | `amy count` | ✅ | NIP-45, per-relay counts. | +| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | ✅ | raw NIP-44 (default) / NIP-04. | +| `gift` | `amy gift wrap\|unwrap` | ✅ | NIP-59 seal+wrap / unwrap+unseal. | | `relay` (NIP-11) | `amy relay info` | 🆕 | amy's `relay` is config today; add an `info` verb. | | `outbox` | `amy outbox` | 🆕 | NIP-65 relay discovery for a user. | | `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | 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 f7d7bc6eda..fc2fccebfa 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -232,6 +232,22 @@ private suspend fun dispatch(argv: Array): Int { Commands.subscribe(dataDir, tail) } + "count" -> { + Commands.count(dataDir, tail) + } + + "encrypt" -> { + Commands.encrypt(dataDir, tail) + } + + "decrypt" -> { + Commands.decrypt(dataDir, tail) + } + + "gift" -> { + Commands.gift(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -430,6 +446,17 @@ private fun printUsage() { | [--timeout SECS] | subscribe [] live stream: print each event as it arrives | [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt. + | count [] NIP-45 COUNT: per-relay match counts, no + | [--relay URL[,URL…]] [--timeout SECS] event download. + | + |Encryption (active account's key): + | encrypt --to USER [TEXT] [--nip04] NIP-44 (default) or NIP-04 encrypt. Reads + | stdin when TEXT is omitted or `-`. + | decrypt --from USER [CIPHERTEXT] [--nip04] inverse of encrypt. + | gift wrap --to USER [EVENT-JSON] NIP-59: seal + wrap a signed inner event for + | [--relay URL[,URL…]] USER (add --relay to broadcast the wrap). + | gift unwrap [GIFTWRAP-JSON] decrypt + unseal a kind:1059 wrap addressed + | to the active account. | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 2b14554b10..7d7f42020a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -155,4 +155,24 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = SubscribeCommand.run(dataDir, tail) + + suspend fun count( + dataDir: DataDir, + tail: Array, + ): Int = CountCommand.run(dataDir, tail) + + suspend fun encrypt( + dataDir: DataDir, + tail: Array, + ): Int = EncryptCommand.run(dataDir, tail) + + suspend fun decrypt( + dataDir: DataDir, + tail: Array, + ): Int = DecryptCommand.run(dataDir, tail) + + suspend fun gift( + dataDir: DataDir, + tail: Array, + ): Int = GiftCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt new file mode 100644 index 0000000000..10b5c60ee1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count + +/** + * `amy count [] [--relay URL[,URL…]] [--timeout SECS]` + * + * NIP-45 COUNT: ask each relay how many events match the filter, without + * downloading them (nak's `count`). Reports each relay's reply plus a + * per-relay max as `total` (counts can't be deduplicated across relays, so + * summing would over-count overlapping stores — the max is the safer single + * number). + * + * Thin assembly only: the COUNT round-trip lives in quartz + * (`INostrClient.count`); this file builds the filter and shapes output. + */ +object CountCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000 + val filter = RawEventSupport.buildFilter(args) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val results = ctx.client.count(relays.associateWith { listOf(filter) }, timeoutMs) + + Output.emit( + mapOf( + "queried_relays" to relays.map { it.url }, + "responded" to results.size, + "total" to (results.values.maxOfOrNull { it.count } ?: 0), + "per_relay" to + results.map { (relay, res) -> + mapOf( + "relay" to relay.url, + "count" to res.count, + "approximate" to res.approximate, + ) + }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt new file mode 100644 index 0000000000..1cc0aaa9b8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy encrypt --to USER [TEXT] [--nip04]` and + * `amy decrypt --from USER [CIPHERTEXT] [--nip04]` — raw NIP-44 (default) or + * NIP-04 message encryption with the active account's key (nak's + * `encrypt`/`decrypt`). + * + * The plaintext/ciphertext is taken from the positional argument or stdin + * (when omitted or `-`). USER accepts npub/nprofile/hex/NIP-05. + * + * Thin assembly only: the crypto lives in quartz (`NostrSigner.nip44*` / + * `nip04*`); this file resolves the peer and shuttles strings. + */ +object EncryptCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val to = args.flag("to") ?: return Output.error("bad_args", "encrypt requires --to USER") + val nip04 = args.bool("nip04") + val text = RawEventSupport.readArgOrStdin(args) + if (text.isEmpty()) return Output.error("bad_args", "no plaintext on the argument or stdin") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val peer = ctx.requireUserHex(to) + val ciphertext = + if (nip04) ctx.signer.nip04Encrypt(text, peer) else ctx.signer.nip44Encrypt(text, peer) + Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "ciphertext" to ciphertext)) + return 0 + } finally { + ctx.close() + } + } +} + +object DecryptCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val from = args.flag("from") ?: return Output.error("bad_args", "decrypt requires --from USER") + val nip04 = args.bool("nip04") + val ciphertext = RawEventSupport.readArgOrStdin(args) + if (ciphertext.isEmpty()) return Output.error("bad_args", "no ciphertext on the argument or stdin") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val peer = ctx.requireUserHex(from) + val plaintext = + if (nip04) ctx.signer.nip04Decrypt(ciphertext, peer) else ctx.signer.nip44Decrypt(ciphertext, peer) + Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "plaintext" to plaintext)) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt new file mode 100644 index 0000000000..4622010fb9 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * `amy gift wrap --to USER [EVENT-JSON]` and `amy gift unwrap [GIFTWRAP-JSON]` + * — the NIP-59 gift-wrap primitive (nak's `gift`). + * + * wrap seals a signed inner event for USER and wraps it in a kind:1059 + * gift wrap (random ephemeral sender). Prints the wrap; pass + * `--relay URL[,URL…]` to also broadcast it. + * unwrap decrypts a kind:1059 gift wrap with the active account's key, + * unseals it, and prints the inner event. + * + * Inner/wrap JSON comes from the positional argument or stdin (`-`). + * + * Thin assembly only: seal/wrap/unwrap all live in quartz + * (`SealedRumorEvent`, `GiftWrapEvent`). + */ +object GiftCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "gift ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "wrap" -> wrap(dataDir, rest) + "unwrap" -> unwrap(dataDir, rest) + else -> Output.error("bad_args", "gift ${tail[0]} (expected wrap|unwrap)") + } + } + + private suspend fun wrap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val to = args.flag("to") ?: return Output.error("bad_args", "gift wrap requires --to USER") + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no inner event JSON on the argument or stdin") + val inner = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse inner event JSON: ${e.message}") + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val peer = ctx.requireUserHex(to) + val seal = SealedRumorEvent.create(event = inner, encryptTo = peer, signer = ctx.signer) + val giftWrap = GiftWrapEvent.create(event = seal, recipientPubKey = peer) + val wrapNode = Output.mapper.readTree(giftWrap.toJson()) + + val targets = RawEventSupport.relayFlag(args) + if (targets.isEmpty()) { + Output.emit(mapOf("event" to wrapNode, "published" to false)) + return 0 + } + val ack = ctx.publish(giftWrap, targets) + Output.emit( + mapOf( + "event" to wrapNode, + "published" to true, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun unwrap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no gift wrap JSON on the argument or stdin") + val giftWrap = + try { + Event.fromJson(json) as? GiftWrapEvent + ?: return Output.error("bad_args", "not a kind:1059 gift wrap event") + } catch (e: Exception) { + return Output.error("bad_args", "could not parse gift wrap JSON: ${e.message}") + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val unwrapped = + try { + giftWrap.unwrapThrowing(ctx.signer) + } catch (e: Exception) { + return Output.error("decrypt_failed", "could not unwrap (is this gift addressed to the active account?): ${e.message}") + } + // The wrap holds a seal (kind:13); unseal it to recover the rumor. + val inner = if (unwrapped is SealedRumorEvent) unwrapped.unsealThrowing(ctx.signer) else unwrapped + Output.emit(mapOf("event" to Output.mapper.readTree(inner.toJson()))) + return 0 + } finally { + ctx.close() + } + } +} From 62d0b167407a7c576c659ddc2924ef31dff7042a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:42:28 +0000 Subject: [PATCH 05/26] feat(cli): add nak-style filter, outbox, relay info primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth batch of nak parity: - `amy filter [filter flags]` — stateless: assemble + print a NIP-01 filter JSON from the same flags fetch/subscribe use (no query sent). - `amy outbox USER` — show a user's NIP-65 read/write relays (outbox model), cache-first with a relay-drain fallback / --refresh. - `amy relay info URL` — stateless NIP-11 info-document fetch over HTTP (Accept: application/nostr+json), parsed by quartz Nip11RelayInformation. filter + relay info dispatch before account resolution (no ~/.amy needed). Verified against relay.damus.io (NIP-11 doc) and fiatjaf's kind:10002 (outbox read/write sets). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 3 + cli/ROADMAP.md | 7 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 20 +++++ .../amethyst/cli/commands/Commands.kt | 5 ++ .../amethyst/cli/commands/FilterCommand.kt | 42 +++++++++ .../amethyst/cli/commands/OutboxCommand.kt | 86 +++++++++++++++++++ .../amethyst/cli/commands/RelayCommands.kt | 50 ++++++++++- 7 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt diff --git a/cli/README.md b/cli/README.md index 2b24b2ea5d..0937ea7dc0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -213,6 +213,8 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | +| `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. | +| `amy relay info URL` | Fetch and print a relay's NIP-11 information document. | ### Raw events @@ -231,6 +233,7 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. | | `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | | `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. | +| `amy outbox USER [--refresh] [--timeout SECS]` | Show USER's NIP-65 read/write relays (outbox model). Cache-first; `--refresh` forces a relay drain. | ### Encryption diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 9acbcb0a51..a18f7413fe 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -89,10 +89,11 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `count` | `amy count` | ✅ | NIP-45, per-relay counts. | | `encrypt` / `decrypt` | `amy encrypt\|decrypt` | ✅ | raw NIP-44 (default) / NIP-04. | | `gift` | `amy gift wrap\|unwrap` | ✅ | NIP-59 seal+wrap / unwrap+unseal. | -| `relay` (NIP-11) | `amy relay info` | 🆕 | amy's `relay` is config today; add an `info` verb. | -| `outbox` | `amy outbox` | 🆕 | NIP-65 relay discovery for a user. | +| `relay` (NIP-11) | `amy relay info` | ✅ | stateless NIP-11 doc fetch. | +| `outbox` | `amy outbox` | ✅ | NIP-65 read/write relays, cache-first. | +| `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | | `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | -| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups. | +| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry). | | `sync` | `amy relay sync` | 🆕 | NIP-77 Negentropy. | | `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | 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 fc2fccebfa..308c2cf6e0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -138,6 +138,17 @@ private suspend fun dispatch(argv: Array): Int { "key" -> return com.vitorpamplona.amethyst.cli.commands.KeyCommands .dispatch(tail) + "filter" -> + return com.vitorpamplona.amethyst.cli.commands.FilterCommand + .run(tail) + } + + // `relay info URL` is a stateless NIP-11 fetch — no account needed. The + // rest of `relay …` (add/list/publish-lists) operates on the account and + // falls through to the normal path below. + if (head == "relay" && tail.firstOrNull() == "info") { + return com.vitorpamplona.amethyst.cli.commands.RelayCommands + .info(tail.drop(1).toTypedArray()) } val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) @@ -248,6 +259,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.gift(dataDir, tail) } + "outbox" -> { + Commands.outbox(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -396,6 +411,8 @@ private fun printUsage() { | (reads stdin when the arg is omitted or `-`) | key generate mint a fresh keypair (nsec + npub + hex) | key public NSEC|HEX derive the public key from a secret key + | filter [--kind …] [--author …] assemble + print a NIP-01 filter JSON from the + | [--id …] [--tag …] … same flags fetch/subscribe use (no query sent) | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) @@ -407,6 +424,9 @@ private fun printUsage() { | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) | relay list print configured relays | relay publish-lists publish kind:10002 + kind:10050 + | relay info URL fetch + print a relay's NIP-11 info document + | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) + | [--timeout SECS] (USER: npub|nprofile|hex|name@domain) | |Profile (NIP-01 kind:0): | profile show [USER] [--timeout SECS] fetch latest kind:0 metadata diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 7d7f42020a..c65d29190d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -175,4 +175,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = GiftCommands.dispatch(dataDir, tail) + + suspend fun outbox( + dataDir: DataDir, + tail: Array, + ): Int = OutboxCommand.run(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt new file mode 100644 index 0000000000..2f4af8fd37 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt @@ -0,0 +1,42 @@ +/* + * 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 + +/** + * `amy filter [--kind …] [--author …] [--id …] [--tag …] [--since/--until TS] + * [--limit N] [--search TEXT]` — assemble a NIP-01 filter JSON + * from flags and print it (nak's `filter`). Local, no network, no account — + * handy for piping into another tool or eyeballing what `fetch`/`subscribe` + * would send. + * + * Same flag grammar as `fetch`/`subscribe`; `--author`/`--id` accept + * npub/nevent/note/naddr or hex (local decode only). + */ +object FilterCommand { + fun run(rest: Array): Int { + val filter = RawEventSupport.buildFilter(Args(rest)) + Output.emit(Output.mapper.readTree(filter.toJson())) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt new file mode 100644 index 0000000000..0755280b3e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * `amy outbox USER [--refresh] [--timeout SECS]` — show a user's NIP-65 + * (kind:10002) read/write relays — the outbox-model relay hints (nak's + * `outbox`). USER accepts npub/nprofile/hex/NIP-05. + * + * Cache-first: reads the local store, falling back to a relay drain on a + * miss (or always with `--refresh`). + */ +object OutboxCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val user = args.positional(0, "user") + val refresh = args.bool("refresh") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val pubkey = ctx.requireUserHex(user) + + var event = if (refresh) null else ctx.relaysOf(pubkey) + if (event == null) { + ctx.drain( + ctx.bootstrapRelays().associateWith { + listOf( + com.vitorpamplona.quartz.nip01Core.relay.filters + .Filter(authors = listOf(pubkey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1), + ) + }, + timeoutMs, + ) + event = ctx.relaysOf(pubkey) + } + + if (event == null) { + Output.emit(mapOf("pubkey" to pubkey, "found" to false)) + return 0 + } + + Output.emit( + mapOf( + "pubkey" to pubkey, + "found" to true, + "created_at" to event.createdAt, + "read" to (event.readRelaysNorm()?.map { it.url } ?: emptyList()), + "write" to (event.writeRelaysNorm()?.map { it.url } ?: emptyList()), + "all" to event.relaysNorm().map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index aade5d8298..c381ccd641 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -27,10 +27,14 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import okhttp3.OkHttpClient +import okhttp3.Request /** * `amy relay ` — manage this account's relay sets. @@ -52,17 +56,61 @@ object RelayCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "relay …") + if (tail.isEmpty()) return Output.error("bad_args", "relay …") val sub = tail[0] val rest = tail.drop(1).toTypedArray() return when (sub) { "add" -> add(dataDir, Args(rest)) "list" -> list(dataDir) "publish-lists" -> publishLists(dataDir) + "info" -> info(rest) else -> Output.error("bad_args", "relay $sub") } } + /** + * `relay info URL` — fetch a relay's NIP-11 information document over + * HTTP (`Accept: application/nostr+json`) and print it. Local/stateless: + * no account, no websocket. Parsing lives in quartz + * ([Nip11RelayInformation.fromJson]); this only does the GET. + */ + fun info(rest: Array): Int { + val args = Args(rest) + val raw = args.positional(0, "relay-url") + val normalized = + raw.normalizeRelayUrlOrNull() + ?: return Output.error("bad_args", "invalid relay url: $raw") + val httpUrl = normalized.toHttp() + + val request = + Request + .Builder() + .url(httpUrl) + .header("Accept", Nip11RelayInformation.CONTENT_TYPE) + .get() + .build() + + return try { + OkHttpClient().newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return Output.error("http_error", "relay returned HTTP ${response.code} for $httpUrl") + } + val body = response.body.string() + val info = Nip11RelayInformation.fromJson(body) + Output.emit( + mapOf( + "relay" to normalized.url, + "url" to httpUrl, + "info" to Output.mapper.readTree(info.toJson()), + ), + ) + 0 + } + } catch (e: Exception) { + Output.error("fetch_failed", "could not fetch NIP-11 from $httpUrl: ${e.message}") + } + } + private suspend fun add( dataDir: DataDir, args: Args, From 8e1059a4abed83000af2bef4733cde70bba96194 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:46:27 +0000 Subject: [PATCH 06/26] feat(cli): add nak-style blossom blob commands (upload/download/list/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth batch of nak parity — Blossom (NIP-B7 / BUD-01/02/04): - `amy blossom upload --server URL FILE [--mime-type M]` — authed upload, prints the blob URL + sha256. - `amy blossom download URL|HASH [--out FILE]` — public download (accepts a full URL or a HASH + --server). - `amy blossom list --server URL [USER]` — list a user's blobs (authed). - `amy blossom delete HASH --server URL` — delete a blob you own. Reuses commons BlossomClient + BlossomAuth and quartz BlossomAuthorizationEvent / sha256; list/delete use OkHttp directly with the quartz-built kind:24242 auth header. Verified a full upload->download sha256 round-trip against blossom.primal.net and an authed list. This completes the Tier-1 nak-parity surface (decode/encode/verify/key/ event/publish/fetch/subscribe/count/encrypt/decrypt/gift/filter/relay info/outbox/blossom); only the kind/nip reference lookups remain. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 9 + cli/ROADMAP.md | 11 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 12 ++ .../amethyst/cli/commands/BlossomCommands.kt | 202 ++++++++++++++++++ .../amethyst/cli/commands/Commands.kt | 5 + 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt diff --git a/cli/README.md b/cli/README.md index 0937ea7dc0..3070ff0c95 100644 --- a/cli/README.md +++ b/cli/README.md @@ -244,6 +244,15 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | | `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | +### Blossom blobs (NIP-B7) + +| Command | What it does | +|---|---| +| `amy blossom upload --server URL FILE [--mime-type M]` | Upload a file (BUD-01, authed). Prints the blob URL + sha256. | +| `amy blossom download URL [--out FILE]` | Download a blob (public). Accepts a full URL, or a `HASH` plus `--server URL`. | +| `amy blossom list --server URL [USER]` | List a user's blobs (BUD-04). USER defaults to the active account. | +| `amy blossom delete HASH --server URL` | Delete a blob you own (BUD-02). | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index a18f7413fe..a90ce9d1f1 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -92,11 +92,16 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `relay` (NIP-11) | `amy relay info` | ✅ | stateless NIP-11 doc fetch. | | `outbox` | `amy outbox` | ✅ | NIP-65 read/write relays, cache-first. | | `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | -| `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | -| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry). | -| `sync` | `amy relay sync` | 🆕 | NIP-77 Negentropy. | +| `blossom` | `amy blossom` | ✅ | upload/download/list/delete (reuses commons `BlossomClient`). | +| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry — only remaining Tier-1 gap). | +| `sync` | `amy relay sync` | 🆕 (tier 2) | NIP-77 Negentropy. | | `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | +**Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, +`publish`, `fetch`, `subscribe`, `count`, `encrypt`, `decrypt`, `gift`, +`filter`, `relay info`, `outbox`, `blossom`. Remaining: `kind` / `nip` +(reference lookups, pending a kind registry). + --- ## Order of operations 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 308c2cf6e0..101e7097b8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -263,6 +263,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.outbox(dataDir, tail) } + "blossom" -> { + Commands.blossom(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -478,6 +482,14 @@ private fun printUsage() { | gift unwrap [GIFTWRAP-JSON] decrypt + unseal a kind:1059 wrap addressed | to the active account. | + |Blossom blobs (NIP-B7 / BUD-01/02/04): + | blossom upload --server URL FILE upload a file (authed); prints the blob URL. + | [--mime-type M] + | blossom download URL [--out FILE] download a blob (public). Accepts a full URL, + | blossom download HASH --server URL or a HASH plus --server. + | blossom list --server URL [USER] list a user's blobs (defaults to self) + | blossom delete HASH --server URL delete a blob you own + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt new file mode 100644 index 0000000000..a99a8b66df --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.utils.sha256.sha256 +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.nio.file.Files + +/** + * `amy blossom ` — Blossom blob storage + * (nak's `blossom`). Uploads/lists/deletes are authed with the active + * account (BUD-01/02/04 kind:24242 events); downloads are public. + * + * upload --server URL FILE [--mime-type M] + * download URL [--out FILE] (or: download HASH --server URL) + * list --server URL [USER] (USER defaults to the active account) + * delete HASH --server URL + * + * Thin assembly only: HTTP + auth live in commons `BlossomClient` / + * `BlossomAuth` and quartz `BlossomAuthorizationEvent`; this file wires + * flags and shapes output. List/delete use OkHttp directly (no client + * method exists) with the quartz-built auth header. + */ +object BlossomCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "blossom ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "upload" -> upload(dataDir, rest) + "download" -> download(dataDir, rest) + "list" -> list(dataDir, rest) + "delete" -> delete(dataDir, rest) + else -> Output.error("bad_args", "blossom ${tail[0]} (expected upload|download|list|delete)") + } + } + + private suspend fun upload( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom upload requires --server URL") + val path = args.positional(0, "file") + val file = File(path) + if (!file.isFile) return Output.error("bad_args", "no such file: $path") + + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mime = args.flag("mime-type") ?: runCatching { Files.probeContentType(file.toPath()) }.getOrNull() ?: "application/octet-stream" + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val auth = BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer) + val result = BlossomClient().upload(file, mime, server, auth) + Output.emit( + mapOf( + "url" to result.url, + "sha256" to (result.sha256 ?: hash), + "size" to (result.size ?: file.length()), + "type" to (result.type ?: mime), + "server" to server, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun download( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val target = args.positional(0, "url-or-hash") + val server = args.flag("server") + val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target + + val ctx = Context.open(dataDir) + try { + val bytes = + BlossomClient().download(url) + ?: return Output.error("not_found", "server returned no blob for $url") + val out = args.flag("out") + if (out != null) { + File(out).writeBytes(bytes) + } + Output.emit( + mapOf( + "url" to url, + "size" to bytes.size, + "sha256" to sha256(bytes).toHexKey(), + "saved_to" to out, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom list requires --server URL") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val pubkey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val auth = BlossomAuthorizationEvent.createListAuth(ctx.signer, "List blobs").toAuthorizationHeader() + val listUrl = server.removeSuffix("/") + "/list/" + pubkey + + val request = + Request + .Builder() + .url(listUrl) + .header("Authorization", auth) + .get() + .build() + OkHttpClient().newCall(request).execute().use { response -> + if (!response.isSuccessful) return Output.error("http_error", "server returned HTTP ${response.code} for $listUrl") + val node = Output.mapper.readTree(response.body.string()) + Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to node.size(), "blobs" to node)) + } + return 0 + } finally { + ctx.close() + } + } + + private suspend fun delete( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom delete requires --server URL") + val hash = args.positional(0, "sha256") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val auth = BlossomAuthorizationEvent.createDeleteAuth(hash, "Delete blob", ctx.signer).toAuthorizationHeader() + val blobUrl = BlossomServerUrl.blob(server, hash) + val request = + Request + .Builder() + .url(blobUrl) + .header("Authorization", auth) + .delete() + .build() + OkHttpClient().newCall(request).execute().use { response -> + Output.emit( + mapOf( + "sha256" to hash, + "server" to server, + "deleted" to response.isSuccessful, + "status" to response.code, + ), + ) + return if (response.isSuccessful) 0 else 1 + } + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index c65d29190d..b16d1cf916 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -180,4 +180,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = OutboxCommand.run(dataDir, tail) + + suspend fun blossom( + dataDir: DataDir, + tail: Array, + ): Int = BlossomCommands.dispatch(dataDir, tail) } From fb35c85de4cbbd3e5a75734de04fd01cd21404dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:09:18 +0000 Subject: [PATCH 07/26] feat(cli): add nak-style sync (NIP-77 Negentropy) primitive `amy sync --relay URL [filter] [--down] [--up]` reconciles the local event store with a relay using NIP-77 Negentropy: - negotiate the symmetric difference under the filter (drives quartz NegentropySession over a raw WebSocket, mirroring geode's interop driver), - --down (default) downloads the ids the relay has and we lack via Context.drain, - --up uploads the events we have and the relay lacks via Context.publish. Filter flags match fetch/subscribe; an empty filter reconciles the whole store. Verified against relay.damus.io: cold store -> need=60, downloaded=60; immediate re-sync -> need=0 (idempotent). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 1 + cli/ROADMAP.md | 6 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 5 + .../amethyst/cli/commands/SyncCommand.kt | 209 ++++++++++++++++++ 4 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt diff --git a/cli/README.md b/cli/README.md index 3070ff0c95..47cacd7383 100644 --- a/cli/README.md +++ b/cli/README.md @@ -234,6 +234,7 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | | `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. | | `amy outbox USER [--refresh] [--timeout SECS]` | Show USER's NIP-65 read/write relays (outbox model). Cache-first; `--refresh` forces a relay drain. | +| `amy sync --relay URL [filter flags] [--down] [--up]` | NIP-77 Negentropy reconcile between the local store and a relay. `--down` (default) pulls events we lack; `--up` pushes events the relay lacks; both for bidirectional. | ### Encryption diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index a90ce9d1f1..aafe3e17a3 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -94,8 +94,10 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | | `blossom` | `amy blossom` | ✅ | upload/download/list/delete (reuses commons `BlossomClient`). | | `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry — only remaining Tier-1 gap). | -| `sync` | `amy relay sync` | 🆕 (tier 2) | NIP-77 Negentropy. | -| `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | +| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | +| `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | +| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | +| `bunker` / `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, `publish`, `fetch`, `subscribe`, `count`, `encrypt`, `decrypt`, `gift`, 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 07ec3a089d..903d023c5b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand +import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand import com.vitorpamplona.amethyst.cli.commands.VerifyCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand @@ -207,6 +208,7 @@ private suspend fun dispatch(argv: Array): Int { "gift" -> GiftCommands.dispatch(dataDir, tail) "outbox" -> OutboxCommand.run(dataDir, tail) "blossom" -> BlossomCommands.dispatch(dataDir, tail) + "sync" -> SyncCommand.run(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -391,6 +393,9 @@ private fun printUsage() { | [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt. | count [] NIP-45 COUNT: per-relay match counts, no | [--relay URL[,URL…]] [--timeout SECS] event download. + | sync --relay URL [] NIP-77 Negentropy reconcile with the local + | [--down] [--up] [--timeout SECS] store (--down default; --up to push ours; + | both for bidirectional). | |Encryption (active account's key): | encrypt --to USER [TEXT] [--nip04] NIP-44 (default) or NIP-04 encrypt. Reads diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt new file mode 100644 index 0000000000..51d7d471d8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** + * `amy sync --relay URL [filter flags] [--down] [--up] [--timeout SECS]` + * + * NIP-77 Negentropy set-reconciliation between the local event store and a + * relay (nak's `sync`, adapted to amy's local-store model). First it + * negotiates the symmetric difference under [filter], then closes the loop: + * + * --down (default) download events the relay has and we lack (REQ by id) + * --up upload events we have and the relay lacks (EVENT) + * + * Pass both for a full bidirectional sync. The filter flags are the same as + * `fetch`/`subscribe`; an empty filter reconciles the whole store. + * + * Thin assembly only: the negentropy protocol lives in quartz + * (`NegentropySession`); this file drives the WebSocket round-trips and + * reuses `Context.drain` / `Context.publish` for the NIP-01 follow-up. + */ +object SyncCommand { + private const val ID_CHUNK = 500 + + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val relayUrl = + args.flag("relay") + ?: return Output.error("bad_args", "sync requires --relay URL") + val relay = + RelayUrlNormalizer.normalizeOrNull(relayUrl) + ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 30L) * 1000 + // Default direction is download; --up adds upload. + val up = args.bool("up") + val down = args.bool("down") || !up + val filter = RawEventSupport.buildFilter(args) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val localEvents = ctx.store.query(filter) + val localById = localEvents.associateBy { it.id } + + val diff = + negotiate(relay.toHttp(), filter, localEvents, timeoutMs) + ?: return Output.error("timeout", "no negentropy response from ${relay.url} within ${timeoutMs}ms") + if (diff.error != null) { + return Output.error("sync_error", diff.error) + } + + // needIds = relay has, we lack; haveIds = we have, relay lacks. + var downloaded = 0 + if (down && diff.needIds.isNotEmpty()) { + diff.needIds.chunked(ID_CHUNK).forEach { chunk -> + val got = ctx.drain(mapOf(relay to listOf(Filter(ids = chunk))), timeoutMs) + downloaded += got.size + } + } + + var uploaded = 0 + if (up && diff.haveIds.isNotEmpty()) { + diff.haveIds.forEach { id -> + val ev = localById[id] ?: return@forEach + val ack = ctx.publish(ev, setOf(relay)) + if (ack.values.any { it }) uploaded++ + } + } + + Output.emit( + mapOf( + "relay" to relay.url, + "local_events" to localEvents.size, + "rounds" to diff.rounds, + "need" to diff.needIds.size, + "have" to diff.haveIds.size, + "downloaded" to downloaded, + "uploaded" to uploaded, + ), + ) + return 0 + } + } + + private data class Diff( + val haveIds: List, + val needIds: List, + val rounds: Int, + val error: String?, + ) + + /** + * Drive one NIP-77 reconciliation over a raw WebSocket and return the + * symmetric difference. Mirrors geode's interop sync driver: the protocol + * state machine is [NegentropySession]; this only shuttles frames. + */ + private suspend fun negotiate( + httpUrl: String, + filter: Filter, + localEvents: List, + timeoutMs: Long, + subId: String = "amy-sync", + maxRounds: Int = 64, + ): Diff? { + val incoming = Channel(UNLIMITED) + val client = OkHttpClient.Builder().build() + val ws = + client.newWebSocket( + Request.Builder().url(httpUrl).build(), + object : WebSocketListener() { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + incoming.trySend(text) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + incoming.close() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + incoming.close(t) + } + }, + ) + + return try { + withTimeoutOrNull(timeoutMs) { + val session = NegentropySession(subId, filter, localEvents, frameSizeLimit = 0) + ws.send(OptimizedJsonMapper.toJson(session.open())) + + val have = mutableSetOf() + val need = mutableSetOf() + var rounds = 0 + while (rounds < maxRounds) { + val raw = incoming.receive() + rounds++ + when (val msg = OptimizedJsonMapper.fromJsonToMessage(raw)) { + is NegErrMessage -> return@withTimeoutOrNull Diff(have.toList(), need.toList(), rounds, "${msg.subId}: ${msg.reason}") + is NegMsgMessage -> { + val r = session.processMessage(msg.message) + have += r.haveIds + need += r.needIds + if (r.isComplete()) { + return@withTimeoutOrNull Diff(have.toList(), need.toList(), rounds, null) + } + ws.send(OptimizedJsonMapper.toJson(r.nextCmd!!)) + } + else -> {} // ignore NOTICE/etc. and keep waiting + } + } + Diff(have.toList(), need.toList(), rounds, "did not converge in $maxRounds rounds") + } + } finally { + ws.close(1000, "amy-sync-done") + } + } +} From 69d03baccaed86c6c73915f962a46f29fecdb10a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:13:21 +0000 Subject: [PATCH 08/26] feat(cli): add nak-style git (NIP-34) + podcast (NIP-F4) commands git (repository metadata + collaboration events; clone/push packfile transport is out of scope): - git announce publish a kind:30617 repository announcement - git list list a user's repo announcements - git show print one announcement (naddr or kind:pubkey:id), cache-first - git issue publish a kind:1621 issue against a repo (fetches the repo announcement to build the EventHintBundle) podcast (NIP-F4): - podcast metadata publish kind:10154 show metadata (replaceable) - podcast publish publish a kind:54 episode (--audio URL[,URL]) - podcast list list a user's metadata + episodes Thin assembly over quartz GitRepositoryEvent / GitIssueEvent / PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce-> show cache round-trip (name/clone/hashtags), issue kind:1621 against the repo, podcast metadata kind:10154 + episode kind:54. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 19 ++ .../com/vitorpamplona/amethyst/cli/Main.kt | 22 ++ .../amethyst/cli/commands/GitCommands.kt | 248 ++++++++++++++++++ .../amethyst/cli/commands/PodcastCommands.kt | 178 +++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt diff --git a/cli/README.md b/cli/README.md index 47cacd7383..416f23dbd9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -245,6 +245,25 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | | `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | +### Git (NIP-34) + +nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events. + +| Command | What it does | +|---|---| +| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git list [USER]` | List a user's repo announcements (defaults to self). | +| `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | +| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. | + +### Podcasts (NIP-F4) + +| Command | What it does | +|---|---| +| `amy podcast metadata --title T --image URL --description D [--website URL[,URL]]` | Publish kind:10154 show metadata (replaceable). | +| `amy podcast publish --title T --description D --audio URL[,URL] [--audio-type MIME] [--image URL] [--content MD]` | Publish a kind:54 episode. | +| `amy podcast list [USER] [--limit N]` | List a user's show metadata + episodes. | + ### Blossom blobs (NIP-B7) | Command | What it does | 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 903d023c5b..22ffa2f68a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.cli.commands.FetchCommand import com.vitorpamplona.amethyst.cli.commands.FilterCommand import com.vitorpamplona.amethyst.cli.commands.FollowCommand import com.vitorpamplona.amethyst.cli.commands.GiftCommands +import com.vitorpamplona.amethyst.cli.commands.GitCommands import com.vitorpamplona.amethyst.cli.commands.GroupCommands import com.vitorpamplona.amethyst.cli.commands.InitCommands import com.vitorpamplona.amethyst.cli.commands.KeyCommands @@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands import com.vitorpamplona.amethyst.cli.commands.NsiteCommands import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand +import com.vitorpamplona.amethyst.cli.commands.PodcastCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands @@ -209,6 +211,8 @@ private suspend fun dispatch(argv: Array): Int { "outbox" -> OutboxCommand.run(dataDir, tail) "blossom" -> BlossomCommands.dispatch(dataDir, tail) "sync" -> SyncCommand.run(dataDir, tail) + "git" -> GitCommands.dispatch(dataDir, tail) + "podcast" -> PodcastCommands.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -414,6 +418,24 @@ private fun printUsage() { | blossom list --server URL [USER] list a user's blobs (defaults to self) | blossom delete HASH --server URL delete a blob you own | + |Git (NIP-34): + | git announce --name N [--description D] publish a kind:30617 repo announcement + | [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name) + | [--relay URL[,URL]] [--maintainer HEX[,]] + | [--hashtag T[,T]] [--earliest-commit C] [--d ID] + | git list [USER] list a user's repo announcements (default self) + | git show NADDR|kind:pubkey:id print one repo announcement + | git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo + | [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin) + | + |Podcasts (NIP-F4): + | podcast metadata --title T --image URL publish kind:10154 show metadata + | --description D [--website URL[,URL]] + | podcast publish --title T --description D publish a kind:54 episode + | --audio URL[,URL] [--audio-type MIME] + | [--image URL] [--content MARKDOWN] + | podcast list [USER] [--limit N] list a user's metadata + episodes + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt new file mode 100644 index 0000000000..9b14eeac03 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * `amy git ` — NIP-34 Nostr-native git + * repositories (nak's `git`, adapted to amy's event-publish model). + * + * announce publish a kind:30617 repository announcement + * list list a user's repository announcements + * show print one repository announcement (naddr or coordinates) + * issue publish a kind:1621 issue against a repository + * + * nak's clone/push/pull (git-packfile transport over relays/GRASP) are out + * of scope — they need a real git plumbing layer. These are the metadata / + * collaboration events. Thin assembly only: every event lives in quartz + * (`GitRepositoryEvent`, `GitIssueEvent`). + */ +object GitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "git", + tail, + "git ", + mapOf( + "announce" to { rest -> announce(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "issue" to { rest -> issue(dataDir, rest) }, + ), + ) + + private suspend fun announce( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.flag("name") ?: return Output.error("bad_args", "git announce requires --name") + val csv = { key: String -> + args + .flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + GitRepositoryEvent.build( + name = name, + description = args.flag("description"), + webUrls = csv("web"), + cloneUrls = csv("clone"), + relays = csv("relay"), + maintainers = csv("maintainer"), + hashtags = csv("hashtag"), + earliestUniqueCommit = args.flag("earliest-commit"), + personalFork = args.bool("personal-fork"), + dTag = args.flag("d") ?: name, + ) + val signed = ctx.signer.sign(template) + val targets = RawEventSupport.publishTargets(ctx, args) + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event_id" to signed.id, + "address" to Address.assemble(signed.kind, signed.pubKey, args.flag("d") ?: name), + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = ctx.drain(relays.associateWith { listOf(Filter(kinds = listOf(GitRepositoryEvent.KIND), authors = listOf(author))) }) + val repos = + received + .asSequence() + .map { it.second } + .filterIsInstance() + .distinctBy { it.dTag() } + .sortedByDescending { it.createdAt } + .map { repoSummary(it) } + .toList() + Output.emit(mapOf("pubkey" to author, "count" to repos.size, "repositories" to repos)) + return 0 + } + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "naddr-or-coordinates") + val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)") + if (addr.kind != GitRepositoryEvent.KIND) { + return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content)) + return 0 + } + } + + private suspend fun issue( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val subject = args.flag("subject") ?: return Output.error("bad_args", "git issue requires --subject") + val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val body = args.positionalOrNull(1) ?: "" + val topics = + args + .flag("hashtag") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + val template = + GitIssueEvent.build( + subject = subject, + content = body, + repository = EventHintBundle(repo), + notify = emptyList(), + topics = topics, + ) + val signed = ctx.signer.sign(template) + // Deliver to the repo's advertised relays when present, else our targets. + val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag), + "subject" to subject, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + // ------------------------------------------------------------------ + + private suspend fun fetchRepo( + ctx: Context, + addr: Address, + args: Args, + ): GitRepositoryEvent? { + // Cache-first, then drain the query relays. + val filter = + Filter( + kinds = listOf(GitRepositoryEvent.KIND), + authors = listOf(addr.pubKeyHex), + tags = mapOf("d" to listOf(addr.dTag)), + limit = 1, + ) + ctx.store + .query(filter) + .firstOrNull() + ?.let { return it as? GitRepositoryEvent } + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? GitRepositoryEvent + } + + /** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */ + private fun resolveAddress(input: String): Address? { + val trimmed = input.trim().removePrefix("nostr:") + if (trimmed.startsWith("naddr")) { + val n = NAddress.parse(trimmed) ?: return null + return Address(n.kind, n.author, n.dTag) + } + Address.parse(trimmed)?.let { return it } + val parts = trimmed.split(":") + return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null + } + + private fun repoSummary(repo: GitRepositoryEvent): Map = + mapOf( + "identifier" to repo.dTag(), + "name" to repo.name(), + "description" to repo.description(), + "clone" to repo.clones(), + "web" to repo.webs(), + "relays" to repo.relays(), + "maintainers" to repo.maintainers(), + "hashtags" to repo.hashtags(), + "address" to Address.assemble(repo.kind, repo.pubKey, repo.dTag()), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt new file mode 100644 index 0000000000..098b83194d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * `amy podcast ` — NIP-F4 podcasts (nak's `podcast`). + * + * metadata publish a kind:10154 show metadata (replaceable) + * publish publish a kind:54 episode + * list list a user's podcast metadata + episodes + * + * Thin assembly only: events live in quartz (`PodcastMetadataEvent`, + * `PodcastEpisodeEvent`). + */ +object PodcastCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "podcast", + tail, + "podcast ", + mapOf( + "metadata" to { rest -> metadata(dataDir, rest) }, + "publish" to { rest -> publish(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + ), + ) + + private suspend fun metadata( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast metadata requires --title") + val image = args.flag("image") ?: return Output.error("bad_args", "podcast metadata requires --image") + val description = args.flag("description") ?: return Output.error("bad_args", "podcast metadata requires --description") + val websites = + args + .flag("website") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(PodcastMetadataEvent.build(title, image, description, websites)) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "title" to title, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun publish( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast publish requires --title") + val description = args.flag("description") ?: return Output.error("bad_args", "podcast publish requires --description") + val audioType = args.flag("audio-type") + val audios = + args + .flag("audio") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { AudioTag(it, audioType) } + .orEmpty() + if (audios.isEmpty()) return Output.error("bad_args", "podcast publish requires --audio URL[,URL…]") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + PodcastEpisodeEvent.build( + title = title, + description = description, + audios = audios, + markdownContent = args.flag("content", "") ?: "", + image = args.flag("image"), + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "title" to title, + "audios" to audios.map { it.url }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.intFlag("limit", 50) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = + ctx.drain( + relays.associateWith { + listOf(Filter(kinds = listOf(PodcastMetadataEvent.KIND, PodcastEpisodeEvent.KIND), authors = listOf(author), limit = limit)) + }, + ) + val events = received.map { it.second }.distinctBy { it.id } + val show = events.filterIsInstance().maxByOrNull { it.createdAt } + val episodes = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "title" to it.title(), + "description" to it.description(), + "audios" to it.audios().map { a -> a.url }, + "created_at" to it.createdAt, + ) + } + Output.emit( + mapOf( + "pubkey" to author, + "metadata" to + show?.let { + mapOf("title" to it.title(), "description" to it.description(), "image" to it.image(), "websites" to it.websites()) + }, + "episode_count" to episodes.size, + "episodes" to episodes, + ), + ) + return 0 + } + } +} From 54a6b4432487102e1cc33dd558d808aed223b0ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:38:56 +0000 Subject: [PATCH 09/26] =?UTF-8?q?feat(cli):=20NIP-46=20bunker=20=E2=80=94?= =?UTF-8?q?=20remote=20signer=20server=20+=20bunker=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add both halves of NIP-46 remote signing so two amy processes interop: - `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote signer for the active local-key account: prints a bunker:// URI, then subscribes to kind:24133, decrypts each BunkerRequest, dispatches to ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and publishes the encrypted BunkerResponse. Long-running like `subscribe`. - `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer account: mints a local transport keypair, records the connection, and acts as PUBKEY. Context builds a NostrSignerRemote (vs the local NostrSignerInternal) and runs openSubscription()+connect() in prepare(); every signing/encryption call is delegated to the bunker. Storage: IdentityFile gains a `bunker` block; the transport key is kept in the existing SecretStore `secret` field. Identity/DataDir load+save handle the remote-signer account type; `canSign` reflects "writeable via bunker". Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote, BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end over relay.damus.io: alice hosts a bunker, bob logs in and `amy event` signs remotely — the note is authored by alice's key and verifies (id_ok + signature_ok); bunker logs connect→ok, sign_event→ok. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 21 ++ cli/ROADMAP.md | 3 +- .../com/vitorpamplona/amethyst/cli/Config.kt | 86 ++++++++ .../com/vitorpamplona/amethyst/cli/Context.kt | 35 +++- .../com/vitorpamplona/amethyst/cli/Main.kt | 10 +- .../amethyst/cli/commands/BunkerCommand.kt | 192 ++++++++++++++++++ .../amethyst/cli/commands/LoginCommand.kt | 6 +- .../amethyst/cli/secrets/IdentitySecret.kt | 16 ++ 8 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt diff --git a/cli/README.md b/cli/README.md index 416f23dbd9..268306027b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -216,6 +216,27 @@ 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 relay info URL` | Fetch and print a relay's NIP-11 information document. | +### Remote signing (NIP-46 bunker) + +Two amy processes can talk: one **hosts** a bunker with its local key; the other **logs in** through it and signs remotely (events come out authored by the host's key). + +| Command | What it does | +|---|---| +| `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` | Run a NIP-46 remote signer for the active local-key account. Prints a `bunker://…` URI, then services sign / nip04 / nip44 / get_public_key / ping requests until interrupted (or `--timeout`). | +| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker. Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. | + +Example (two terminals, shared `$HOME`): + +```bash +# terminal 1 — host alice's key as a bunker +amy --account alice bunker --relay wss://relay.example --secret s3cret +# → bunker://?relay=wss://relay.example/&secret=s3cret + +# terminal 2 — bob signs through it +amy --account bob login 'bunker://?relay=wss://relay.example/&secret=s3cret' +amy --account bob event --kind 1 --content "signed remotely" # authored by alice +``` + ### Raw events | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index aafe3e17a3..2206a6beec 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -97,7 +97,8 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | -| `bunker` / `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | +| `bunker` | `amy bunker` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Two amy processes interop. | +| `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, `publish`, `fetch`, `subscribe`, `count`, `encrypt`, `decrypt`, `gift`, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index f8b10c6c22..80dcef8ef2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.cli import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.secrets.BunkerFile import com.vitorpamplona.amethyst.cli.secrets.IdentityFile import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret import com.vitorpamplona.amethyst.cli.secrets.SecretStore @@ -48,10 +49,15 @@ data class Identity( val pubKeyHex: String, val nsec: String?, val npub: String, + val bunker: Bunker? = null, ) { @get:com.fasterxml.jackson.annotation.JsonIgnore val hasPrivateKey: Boolean get() = privKeyHex != null + /** Whether this identity can sign — directly (local key) or via a bunker. */ + @get:com.fasterxml.jackson.annotation.JsonIgnore + val canSign: Boolean get() = privKeyHex != null || bunker != null + fun keyPair(): KeyPair = if (privKeyHex != null) { KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) @@ -59,11 +65,49 @@ data class Identity( KeyPair(pubKey = pubKeyHex.hexToByteArray()) } + /** The local transport keypair for a NIP-46 bunker account. */ + fun clientKeyPair(): KeyPair = KeyPair(privKey = bunker!!.clientPrivKeyHex.hexToByteArray()) + companion object { fun create(): Identity = fromPrivateKey(KeyPair().privKey!!) fun fromNsec(nsec: String): Identity = fromPrivateKey(nsec.bechToBytes()) + /** + * Parse a `bunker://?relay=…&secret=…` URI into a + * remote-signer identity. The account acts as `remote-pubkey` (the + * key the bunker signs with); a fresh local keypair is minted for the + * NIP-46 transport. Mirrors the parsing in Quartz's + * `NostrSignerRemote.fromBunkerUri`. + */ + fun fromBunkerUri(uri: String): Identity { + require(uri.startsWith("bunker://")) { "not a bunker:// uri" } + val parts = uri.removePrefix("bunker://").split("?", limit = 2) + val remotePubkey = parts[0].lowercase() + require(remotePubkey.length == 64 && remotePubkey.all { it in "0123456789abcdef" }) { + "bunker uri must carry a 64-hex remote pubkey" + } + val relays = mutableListOf() + var secret: String? = null + parts.getOrNull(1)?.split("&")?.forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size < 2) return@forEach + when (kv[0]) { + "relay" -> relays.add(kv[1]) + "secret" -> secret = kv[1] + } + } + require(relays.isNotEmpty()) { "bunker uri must carry at least one relay" } + val clientPriv = KeyPair().privKey!!.toHexKey() + return Identity( + privKeyHex = null, + pubKeyHex = remotePubkey, + nsec = null, + npub = remotePubkey.hexToByteArray().toNpub(), + bunker = Bunker(remotePubkey, relays, secret, clientPriv), + ) + } + fun fromPrivateKey(priv: ByteArray): Identity { val pub = KeyPair(privKey = priv).pubKey return Identity( @@ -93,16 +137,31 @@ data class Identity( pubKeyHex: String, npub: String, privKeyHex: String?, + bunker: Bunker? = null, ): Identity = Identity( privKeyHex = privKeyHex, pubKeyHex = pubKeyHex, nsec = privKeyHex?.hexToByteArray()?.toNsec(), npub = npub, + bunker = bunker, ) } } +/** + * In-memory NIP-46 bunker connection. [clientPrivKeyHex] is the local + * transport key (persisted via the [SecretStore], not in the clear); + * [remotePubkey] is the user key the bunker signs as. + */ +data class Bunker( + val remotePubkey: String, + val relays: List, + val connectSecret: String?, + @get:com.fasterxml.jackson.annotation.JsonIgnore + val clientPrivKeyHex: String, +) + /** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */ data class RunState( var giftWrapSince: Long? = null, @@ -165,6 +224,18 @@ class DataDir( */ fun loadIdentityOrNull(): Identity? { val file = loadIdentityFileOrNull() ?: return null + // Bunker (NIP-46) account: `secret` holds the local transport key, and + // `bunker` records the remote signer. The account has no user privkey. + file.bunker?.let { b -> + val clientPriv = file.secret?.let { secrets.resolve(it) } ?: file.privKeyHex + requireNotNull(clientPriv) { "bunker identity is missing its transport key" } + return Identity.fromDisk( + pubKeyHex = file.pubKeyHex, + npub = file.npub, + privKeyHex = null, + bunker = Bunker(b.remotePubkey, b.relays, b.connectSecret, clientPriv), + ) + } val privHex: String? = when { file.secret != null -> secrets.resolve(file.secret) @@ -182,6 +253,21 @@ class DataDir( * to disk. Read-only identities persist `secret: null`. */ fun saveIdentity(id: Identity) { + if (id.bunker != null) { + // Persist the local transport key under its own pubkey; record the + // remote signer connection alongside it. + val clientPub = id.clientKeyPair().pubKey.toHexKey() + val secret = secrets.store(clientPub, id.bunker.clientPrivKeyHex) + val file = + IdentityFile( + pubKeyHex = id.pubKeyHex, + npub = id.npub, + secret = secret, + bunker = BunkerFile(id.bunker.remotePubkey, id.bunker.relays, id.bunker.connectSecret), + ) + SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) + return + } val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) } val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret) SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 6ab7d37abb..77edbb6699 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -42,12 +42,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.CompletableDeferred @@ -93,8 +96,6 @@ class Context( val identity: Identity, val state: RunState, ) : AutoCloseable { - val signer = NostrSignerInternal(identity.keyPair()) - private val okhttp = OkHttpClient.Builder().build() val client: NostrClient = @@ -102,6 +103,24 @@ class Context( websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }, ) + /** + * The account's signer. For a local account this is a [NostrSignerInternal] + * over the stored key; for a NIP-46 bunker account it is a + * [NostrSignerRemote] that delegates signing/encryption to the remote + * signer over [client]. The remote signer's subscription + connect + * handshake are driven from [prepare]. + */ + val signer: NostrSigner = + identity.bunker?.let { b -> + NostrSignerRemote( + signer = NostrSignerInternal(identity.clientKeyPair()), + remotePubkey = b.remotePubkey, + relays = b.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), + client = client, + secret = b.connectSecret, + ) + } ?: NostrSignerInternal(identity.keyPair()) + /** * NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys. * Uses the same OkHttp instance as the WebSocket client so we share connection @@ -152,6 +171,12 @@ class Context( if (prepared) return marmot.restoreAll() client.connect() + // A bunker account must open its NIP-46 response subscription and run + // the connect handshake before any signing/encryption call. + (signer as? NostrSignerRemote)?.let { + it.openSubscription() + it.connect() + } prepared = true } @@ -608,6 +633,12 @@ class Context( override fun close() { dataDir.saveRunState(state) + (signer as? NostrSignerRemote)?.let { + try { + it.closeSubscription() + } catch (_: Exception) { + } + } try { client.close() } catch (_: Exception) { 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 22ffa2f68a..2df33fbbb9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.cli import com.vitorpamplona.amethyst.cli.commands.AwaitCommands import com.vitorpamplona.amethyst.cli.commands.BlossomCommands +import com.vitorpamplona.amethyst.cli.commands.BunkerCommand import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.DebitCommands @@ -213,6 +214,7 @@ private suspend fun dispatch(argv: Array): Int { "sync" -> SyncCommand.run(dataDir, tail) "git" -> GitCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) + "bunker" -> BunkerCommand.run(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -346,9 +348,15 @@ private fun printUsage() { |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events - | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05) + | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://) | whoami print current identity | + |Remote signing (NIP-46): + | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a + | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout + | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local + | transport key; the account acts as PUBKEY) + | |Relays: | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) | relay list print configured relays diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt new file mode 100644 index 0000000000..ad26b24d60 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` + * + * Run a NIP-46 remote signer (a "bunker") for the active LOCAL account + * (nak's `bunker`). Prints a `bunker://…` connection string, then listens on + * the relays for kind:24133 requests, decrypts each one, performs it with the + * account's key (sign / nip04 / nip44 / get_public_key / ping), and publishes + * the encrypted reply. + * + * Pair it with `amy login bunker://…` in another amy: that account then signs + * remotely through this bunker. Long-running — stops at `--timeout` SECS or on + * interrupt. + * + * Thin assembly only: every request/response type + the encrypted wrapper + * live in quartz (`BunkerRequest*`, `BunkerResponse*`, `NostrConnectEvent`); + * this file dispatches to `ctx.signer`. + */ +object BunkerCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + // A self-bunker needs the real key to sign; a remote (bunker) account can't host one. + if (dataDir.loadIdentityFileOrNull()?.bunker != null) { + return Output.error("bad_account", "this account signs through a remote bunker — host a bunker from a local-key account") + } + + Context.open(dataDir).use { ctx -> + if (!ctx.identity.hasPrivateKey) { + return Output.error("read_only", "bunker host needs a local private key (this account is read-only)") + } + ctx.prepare() + + val relays = RawEventSupport.relayFlag(args).ifEmpty { ctx.outboxRelays() } + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32) + val self = ctx.identity.pubKeyHex + + val uri = + buildString { + append("bunker://").append(self) + append("?").append(relays.joinToString("&") { "relay=${it.url}" }) + append("&secret=").append(secret) + } + Output.emit( + mapOf( + "bunker_uri" to uri, + "pubkey" to self, + "relays" to relays.map { it.url }, + "secret" to secret, + ), + ) + System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") + + val events = Channel(UNLIMITED) + val seen = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) + } + } + + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + val loop: suspend () -> Unit = { + while (true) handle(ctx, events.receive(), secret, relays) + } + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() + } finally { + ctx.client.unsubscribe(subId) + events.close() + } + return 0 + } + } + + private suspend fun handle( + ctx: Context, + event: NostrConnectEvent, + secret: String, + relays: Set, + ) { + val signer = ctx.signer + val client = event.talkingWith(signer.pubKey) + val request = + try { + event.decryptMessage(signer) as? BunkerRequest ?: return + } catch (e: Exception) { + System.err.println("[bunker] could not decrypt request ${event.id.take(8)}: ${e.message}") + return + } + + val response: BunkerResponse = + try { + when (request) { + is BunkerRequestConnect -> + if (request.secret == secret) { + BunkerResponseAck(request.id) + } else { + BunkerResponseError(request.id, "invalid secret") + } + is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + is BunkerRequestPing -> BunkerResponsePong(request.id) + is BunkerRequestSign -> { + val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) + BunkerResponseEvent(request.id, signed) + } + is BunkerRequestNip04Encrypt -> BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) + is BunkerRequestNip04Decrypt -> BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) + is BunkerRequestNip44Encrypt -> BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) + is BunkerRequestNip44Decrypt -> BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } + } catch (e: Exception) { + BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") + } + + System.err.println("[bunker] ${request.method} from ${client.take(8)}… → ${if (response is BunkerResponseError) "error: ${response.error}" else "ok"}") + + try { + val reply = NostrConnectEvent.create(response, client, signer) + ctx.client.publish(reply, relays) + } catch (e: Exception) { + System.err.println("[bunker] failed to send reply for ${request.method}: ${e.message}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index c7aa12e330..232fe25286 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -71,7 +71,9 @@ object LoginCommand { mapOf( "npub" to identity.npub, "hex" to identity.pubKeyHex, - "read_only" to !identity.hasPrivateKey, + "read_only" to !identity.canSign, + "signer" to if (identity.bunker != null) "bunker" else "local", + "bunker_relays" to identity.bunker?.relays, "data_dir" to dataDir.root.absolutePath, ), ) @@ -82,6 +84,8 @@ object LoginCommand { key: String, args: Args, ): Identity? { + // 0. NIP-46 bunker connection string. + if (key.startsWith("bunker://")) return Identity.fromBunkerUri(key) // 1. ncryptsec — password mandatory. if (key.startsWith("ncryptsec")) { val pw = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt index f91000fd04..ea1f670120 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt @@ -80,4 +80,20 @@ data class IdentityFile( // never written by current code. val privKeyHex: String? = null, val nsec: String? = null, + // Present for NIP-46 remote-signer (bunker) accounts. [pubKeyHex] is the + // user pubkey the bunker signs as; [secret] holds the LOCAL transport + // keypair used to encrypt NIP-46 traffic (not the user's key). + val bunker: BunkerFile? = null, +) + +/** + * NIP-46 bunker connection persisted in `identity.json`. The transport + * (client) private key lives in [IdentityFile.secret]; this records where + * to reach the remote signer and the optional connect secret. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BunkerFile( + val remotePubkey: String, + val relays: List, + val connectSecret: String? = null, ) From 695a5b4b25cf86190be09fe0c3045bc4584e788d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:52:37 +0000 Subject: [PATCH 10/26] =?UTF-8?q?fix(cli):=20nak-compatible=20bunker=20?= =?UTF-8?q?=E2=80=94=20percent-encoded=20URIs=20+=20get=5Frelays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the NIP-46 interop gap against the real nak binary: - `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak emits `bunker://?relay=wss%3A%2F%2F…&secret=…`; without decoding, `amy login` of a nak bunker URI produced a broken relay and never connected. (This was the one real break — found by testing vs nak.) - `amy bunker` now percent-encodes the relay/secret in the URI it prints, matching nak's output format. - `amy bunker` implements `get_relays` (returns its relay set), so nak clients that probe it get a proper reply instead of an error. Verified end-to-end with `go install`-built nak over relay.damus.io, both directions: - `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by nak's key, signature valid. - `nak event --sec bunker://` ⇄ `amy bunker`: nak signs through amy, event authored by amy's key; amy logs connect→ok, sign_event→ok. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 4 +++- cli/ROADMAP.md | 2 +- .../kotlin/com/vitorpamplona/amethyst/cli/Config.kt | 7 +++++-- .../amethyst/cli/commands/BunkerCommand.kt | 10 ++++++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index 268306027b..45ad3aa204 100644 --- a/cli/README.md +++ b/cli/README.md @@ -223,7 +223,9 @@ Two amy processes can talk: one **hosts** a bunker with its local key; the other | Command | What it does | |---|---| | `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` | Run a NIP-46 remote signer for the active local-key account. Prints a `bunker://…` URI, then services sign / nip04 / nip44 / get_public_key / ping requests until interrupted (or `--timeout`). | -| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker. Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. | +| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker. Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. Percent-encoded relay params are decoded. | + +Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary, both directions: `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`. Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. The `nostrconnect://` reverse flow and `auth_url` challenges are not implemented. Example (two terminals, shared `$HOME`): diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 2206a6beec..f88ec2de7e 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -97,7 +97,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | -| `bunker` | `amy bunker` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Two amy processes interop. | +| `bunker` | `amy bunker` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Interop-verified vs real `nak` both directions; connect/get_public_key/get_relays/sign/nip04/nip44/ping. `nostrconnect://` + `auth_url` still pending. | | `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 80dcef8ef2..7f3cbc1d52 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -92,9 +92,12 @@ data class Identity( parts.getOrNull(1)?.split("&")?.forEach { param -> val kv = param.split("=", limit = 2) if (kv.size < 2) return@forEach + // Relay/secret params are percent-encoded by spec-compliant + // emitters (nak does: `relay=wss%3A%2F%2F…`), so decode them. + val value = java.net.URLDecoder.decode(kv[1], "UTF-8") when (kv[0]) { - "relay" -> relays.add(kv[1]) - "secret" -> secret = kv[1] + "relay" -> relays.add(value) + "secret" -> secret = value } } require(relays.isNotEmpty()) { "bunker uri must carry at least one relay" } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index ad26b24d60..10fe304ed5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt @@ -46,9 +47,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull @@ -93,11 +96,13 @@ object BunkerCommand { val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32) val self = ctx.identity.pubKeyHex + // Percent-encode params (spec/nak convention: relay=wss%3A%2F%2F…). + val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } val uri = buildString { append("bunker://").append(self) - append("?").append(relays.joinToString("&") { "relay=${it.url}" }) - append("&secret=").append(secret) + append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) + append("&secret=").append(enc(secret)) } Output.emit( mapOf( @@ -165,6 +170,7 @@ object BunkerCommand { BunkerResponseError(request.id, "invalid secret") } is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) }) is BunkerRequestPing -> BunkerResponsePong(request.id) is BunkerRequestSign -> { val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) From 8b7caa5b019198346200ae6db236a92a76e96903 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:04:03 +0000 Subject: [PATCH 11/26] feat(cli): NIP-46 nostrconnect:// reverse flow (both sides) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the client-initiated NostrConnect flow to complete NIP-46 parity: - `amy login --nostrconnect [--relay …] [--name N]` (client) mints a transport keypair, prints a nostrconnect:// offer, subscribes, and waits for the signer's connect ACK (a kind:24133 whose decrypted result echoes our secret). The ACK's author is the signer; it persists a bunker account acting as that key. - `amy bunker connect nostrconnect://…` (signer) parses a client offer, sends the secret-echo ACK, then services that client's requests on the offer's relays. Shares the serve loop with `amy bunker`. NostrConnect.kt holds the offer parse/build (+percent-decode) and the client handshake. BunkerCommand grew a `connect` sub-mode and factored the request loop into serve(). Verified end-to-end: - amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs; event authored by nak, signature valid. - amy client ⇄ amy `bunker connect`: same, authored by the host key. Only `auth_url` challenges remain unimplemented in the NIP-46 surface. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 10 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Config.kt | 29 ++- .../com/vitorpamplona/amethyst/cli/Main.kt | 4 + .../amethyst/cli/commands/BunkerCommand.kt | 130 +++++++++--- .../amethyst/cli/commands/LoginCommand.kt | 8 +- .../amethyst/cli/commands/NostrConnect.kt | 196 ++++++++++++++++++ 7 files changed, 337 insertions(+), 42 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt diff --git a/cli/README.md b/cli/README.md index 45ad3aa204..9e629be4f9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -223,9 +223,15 @@ Two amy processes can talk: one **hosts** a bunker with its local key; the other | Command | What it does | |---|---| | `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` | Run a NIP-46 remote signer for the active local-key account. Prints a `bunker://…` URI, then services sign / nip04 / nip44 / get_public_key / ping requests until interrupted (or `--timeout`). | -| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker. Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. Percent-encoded relay params are decoded. | +| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker (signer advertises). Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. Percent-encoded relay params are decoded. | +| `amy bunker connect nostrconnect://…` | Client-initiated (NostrConnect) flow, signer side: ack a client's offer (echo its secret) and service its requests. | +| `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` | Client-initiated flow, client side: print a `nostrconnect://` offer, wait for a signer to connect, then persist a bunker account that acts as the signer's key. | -Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary, both directions: `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`. Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. The `nostrconnect://` reverse flow and `auth_url` challenges are not implemented. +Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary: +- **bunker:// both directions** — `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`. +- **nostrconnect:// client** — `amy login --nostrconnect` ⇄ `nak bunker connect` (amy signs, event authored by nak's key). + +Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. `auth_url` challenges are not implemented. Example (two terminals, shared `$HOME`): diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index f88ec2de7e..19250eb1fe 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -97,7 +97,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | -| `bunker` | `amy bunker` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Interop-verified vs real `nak` both directions; connect/get_public_key/get_relays/sign/nip04/nip44/ping. `nostrconnect://` + `auth_url` still pending. | +| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction. Interop-verified vs real `nak`. `auth_url` challenge still pending. | | `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 7f3cbc1d52..c3bc468c76 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -101,16 +101,29 @@ data class Identity( } } require(relays.isNotEmpty()) { "bunker uri must carry at least one relay" } - val clientPriv = KeyPair().privKey!!.toHexKey() - return Identity( - privKeyHex = null, - pubKeyHex = remotePubkey, - nsec = null, - npub = remotePubkey.hexToByteArray().toNpub(), - bunker = Bunker(remotePubkey, relays, secret, clientPriv), - ) + return bunkerIdentity(remotePubkey, relays, secret, KeyPair().privKey!!.toHexKey()) } + /** + * Build a remote-signer identity from already-resolved parts (used by + * the NostrConnect flow once the signer pubkey is discovered). The + * account acts as [signerPubkey]; [clientPrivKeyHex] is the local + * transport key. + */ + fun bunkerIdentity( + signerPubkey: String, + relays: List, + connectSecret: String?, + clientPrivKeyHex: String, + ): Identity = + Identity( + privKeyHex = null, + pubKeyHex = signerPubkey, + nsec = null, + npub = signerPubkey.hexToByteArray().toNpub(), + bunker = Bunker(signerPubkey, relays, connectSecret, clientPrivKeyHex), + ) + fun fromPrivateKey(priv: ByteArray): Identity { val pub = KeyPair(privKey = priv).pubKey return Identity( 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 2df33fbbb9..244aadaac5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -354,8 +354,12 @@ private fun printUsage() { |Remote signing (NIP-46): | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout + | bunker connect NOSTRCONNECT-URI act as signer for a client's nostrconnect:// + | [--timeout SECS] offer (acks + services its requests) | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local | transport key; the account acts as PUBKEY) + | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, + | [--name N] [--timeout SECS] wait for a signer to connect, then persist it | |Relays: | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index 10fe304ed5..12f68685d7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -77,13 +77,22 @@ object BunkerCommand { suspend fun run( dataDir: DataDir, rest: Array, + ): Int = + if (rest.firstOrNull() == "connect") { + connect(dataDir, rest.drop(1).toTypedArray()) + } else { + advertise(dataDir, rest) + } + + /** `amy bunker …` — advertise a bunker:// uri and service requests. */ + private suspend fun advertise( + dataDir: DataDir, + rest: Array, ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } - // A self-bunker needs the real key to sign; a remote (bunker) account can't host one. - if (dataDir.loadIdentityFileOrNull()?.bunker != null) { - return Output.error("bad_account", "this account signs through a remote bunker — host a bunker from a local-key account") - } + val accountError = checkHostable(dataDir) + if (accountError != null) return accountError Context.open(dataDir).use { ctx -> if (!ctx.identity.hasPrivateKey) { @@ -114,36 +123,97 @@ object BunkerCommand { ) System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") - val events = Channel(UNLIMITED) - val seen = mutableSetOf() - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) - } - } - - val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) - ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) - try { - val loop: suspend () -> Unit = { - while (true) handle(ctx, events.receive(), secret, relays) - } - if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() - } finally { - ctx.client.unsubscribe(subId) - events.close() - } + serve(ctx, relays, secret, timeoutMs) return 0 } } + /** + * `amy bunker connect ` — the client-initiated + * (NostrConnect) flow: parse a client's offer, send the connect ACK that + * echoes the offer's secret back (so the client learns our signer pubkey), + * then service that client's requests on the offer's relays. + */ + private suspend fun connect( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + val uri = args.positional(0, "nostrconnect-uri") + val offer = NostrConnect.parseOffer(uri) ?: return Output.error("bad_args", "not a valid nostrconnect:// uri") + val accountError = checkHostable(dataDir) + if (accountError != null) return accountError + + Context.open(dataDir).use { ctx -> + if (!ctx.identity.hasPrivateKey) { + return Output.error("read_only", "bunker host needs a local private key (this account is read-only)") + } + ctx.prepare() + if (offer.relays.isEmpty()) return Output.error("bad_args", "nostrconnect uri carries no relay") + + // Send the connect ACK (result == secret) to the client. + val ack = BunkerResponse(newSubId(), offer.secret, null) + val reply = NostrConnectEvent.create(ack, offer.clientPubkey, ctx.signer) + ctx.client.publish(reply, offer.relays) + Output.emit( + mapOf( + "connected_to" to offer.clientPubkey, + "pubkey" to ctx.identity.pubKeyHex, + "relays" to offer.relays.map { it.url }, + ), + ) + System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") + + serve(ctx, offer.relays, offer.secret, timeoutMs) + return 0 + } + } + + /** A remote-signer (bunker) account cannot itself host a bunker. */ + private fun checkHostable(dataDir: DataDir): Int? = + if (dataDir.loadIdentityFileOrNull()?.bunker != null) { + Output.error("bad_account", "this account signs through a remote bunker — host a bunker from a local-key account") + } else { + null + } + + /** Subscribe for kind:24133 requests addressed to us and service them until timeout/interrupt. */ + private suspend fun serve( + ctx: Context, + relays: Set, + secret: String, + timeoutMs: Long?, + ) { + val self = ctx.identity.pubKeyHex + val events = Channel(UNLIMITED) + val seen = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) + } + } + + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + val loop: suspend () -> Unit = { + while (true) handle(ctx, events.receive(), secret, relays) + } + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() + } finally { + ctx.client.unsubscribe(subId) + events.close() + } + } + private suspend fun handle( ctx: Context, event: NostrConnectEvent, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index 232fe25286..7e2f3bce1b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -49,8 +49,14 @@ object LoginCommand { dataDir: DataDir, rest: Array, ): Int { + // NIP-46 NostrConnect (client-initiated) login: no key positional — + // amy mints a transport key, prints an offer, and waits for a signer. + val preArgs = Args(rest) + if (preArgs.bool("nostrconnect")) { + return NostrConnect.login(dataDir, preArgs) + } if (rest.isEmpty()) { - return Output.error("bad_args", "login [--password X]") + return Output.error("bad_args", "login [--password X]") } if (dataDir.identityExists()) { return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt new file mode 100644 index 0000000000..29810dffaa --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient + +/** + * NIP-46 NostrConnect (client-initiated) flow helpers. + * + * Parsing/encoding the `nostrconnect://` offer is shared between the client + * (`amy login --nostrconnect`, [login]) and the signer (`amy bunker connect`, + * which only parses). The signer side lives in [BunkerCommand]. + */ +object NostrConnect { + data class Offer( + val clientPubkey: String, + val relays: Set, + val secret: String, + val name: String?, + ) + + /** Parse `nostrconnect://?relay=…&secret=…&name=…` (percent-decoded). */ + fun parseOffer(uri: String): Offer? { + if (!uri.startsWith("nostrconnect://")) return null + val parts = uri.removePrefix("nostrconnect://").split("?", limit = 2) + val clientPubkey = parts[0].lowercase() + if (clientPubkey.length != 64 || clientPubkey.any { it !in "0123456789abcdef" }) return null + val relays = mutableSetOf() + var secret: String? = null + var name: String? = null + parts.getOrNull(1)?.split("&")?.forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size < 2) return@forEach + val value = java.net.URLDecoder.decode(kv[1], "UTF-8") + when (kv[0]) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + "name" -> name = value + } + } + if (secret == null) return null + return Offer(clientPubkey, relays, secret!!, name) + } + + private fun buildOffer( + clientPubkey: String, + relays: Set, + secret: String, + name: String?, + ): String { + val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } + return buildString { + append("nostrconnect://").append(clientPubkey) + append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) + append("&secret=").append(enc(secret)) + if (name != null) append("&name=").append(enc(name)) + } + } + + /** + * `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` + * + * Mint a local transport keypair, print a `nostrconnect://` offer for the + * user to paste into a signer, then wait for the signer's connect ACK + * (a kind:24133 whose decrypted result echoes our secret). The ACK's author + * is the remote signer; persist a bunker account that acts as that key. + */ + suspend fun login( + dataDir: DataDir, + args: Args, + ): Int { + if (dataDir.identityExists()) { + return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") + } + val relays = RawEventSupport.relayFlag(args).ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return Output.error("bad_args", "no relays; pass --relay URL[,URL…]") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 120L) * 1000 + val name = args.flag("name") + + val clientKey = KeyPair() + val clientSigner = NostrSignerInternal(clientKey) + val clientPub = clientKey.pubKey.toHexKey() + val secret = KeyPair().privKey!!.toHexKey().take(32) + val offer = buildOffer(clientPub, relays, secret, name) + + // Surface the offer immediately so the human/harness can paste it. + System.err.println("[nostrconnect] paste this into your signer within ${timeoutMs / 1000}s:") + System.err.println(offer) + + val okhttp = OkHttpClient.Builder().build() + val client = NostrClient(websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }) + val incoming = Channel(UNLIMITED) + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent) incoming.trySend(event) + } + } + + try { + client.connect() + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(clientPub))) + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + + val signerPub = + withTimeoutOrNull(timeoutMs) { + // Drain kind:24133 until one decrypts to a BunkerResponse echoing our secret. + var found: String? = null + while (found == null) { + found = verifyAck(incoming.receive(), clientSigner, secret) + } + found + } + + if (signerPub == null) { + return Output.error("timeout", "no signer connected within ${timeoutMs / 1000}s") + } + + val identity = Identity.bunkerIdentity(signerPub, relays.map { it.url }, secret, clientKey.privKey!!.toHexKey()) + dataDir.saveIdentity(identity) + Output.emit( + mapOf( + "npub" to identity.npub, + "hex" to identity.pubKeyHex, + "read_only" to false, + "signer" to "bunker", + "bunker_relays" to relays.map { it.url }, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } finally { + client.unsubscribe(subId) + incoming.close() + client.close() + } + } + + /** Returns the signer pubkey if [event] is the connect ACK echoing [secret], else null. */ + private suspend fun verifyAck( + event: NostrConnectEvent, + clientSigner: NostrSignerInternal, + secret: String, + ): String? = + try { + val msg = event.decryptMessage(clientSigner) + if (msg is BunkerResponse && msg.result == secret) event.pubKey else null + } catch (e: Exception) { + null + } +} From 2c37ae642cf2c64d66b00f8d6ca92c8415167f58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:19:01 +0000 Subject: [PATCH 12/26] feat(nip46): handle auth_url challenges in the remote-signer client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote signer, so amy (and Amethyst) can use auth-requiring bunkers like nsec.app / nsecbunker: - Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson one actually used by OptimizedJsonMapper) now special-case `{result:"auth_url", error:}` BEFORE the generic error branch, which previously flattened it to a plain error and dropped the marker. - RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on an auth_url response it surfaces the URL once and keeps waiting for the real response under the same request id (UNLIMITED channel so both are buffered). newResponse peeks the pending entry (get, not remove) so the follow-up isn't dropped; the waiter still removes it in finally. - NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to stderr and the pending command keeps waiting. Tests: a deserializer test (auth_url keeps result+error) and a manager test (auth_url surfaced via callback, then the real response resumes the request) — both green; existing duplicate/late-response manager tests still pass after the get-vs-remove change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 2 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 2 + .../nip46RemoteSigner/BunkerResponse.kt | 7 ++- .../BunkerResponseKSerializer.kt | 8 +++ .../signer/NostrSignerRemote.kt | 7 +++ .../signer/RemoteSignerManager.kt | 35 +++++++++++- .../signer/RemoteSignerManagerRetryTest.kt | 57 +++++++++++++++++++ .../jackson/BunkerResponseDeserializer.kt | 8 +++ 9 files changed, 122 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index 9e629be4f9..01b706e749 100644 --- a/cli/README.md +++ b/cli/README.md @@ -231,7 +231,7 @@ Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary: - **bunker:// both directions** — `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`. - **nostrconnect:// client** — `amy login --nostrconnect` ⇄ `nak bunker connect` (amy signs, event authored by nak's key). -Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. `auth_url` challenges are not implemented. +Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. When a bunker answers with an `auth_url` challenge, amy prints the authorization URL to stderr and keeps waiting for the real response (open the URL in a browser to authorize). Example (two terminals, shared `$HOME`): diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 19250eb1fe..6dc6b0772a 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -97,7 +97,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | -| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction. Interop-verified vs real `nak`. `auth_url` challenge still pending. | +| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | | `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 77edbb6699..e734f17af0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -118,6 +118,8 @@ class Context( relays = b.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), client = client, secret = b.connectSecret, + // Bunker requires web authorization: surface the URL; the request keeps waiting. + onAuthUrl = { url -> System.err.println("[nip46] authorize this request in a browser, then it will continue:\n $url") }, ) } ?: NostrSignerInternal(identity.keyPair()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt index 4829578777..a2efda7826 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt @@ -24,4 +24,9 @@ open class BunkerResponse( val id: String, val result: String?, val error: String?, -) : BunkerMessage() +) : BunkerMessage() { + companion object { + /** NIP-46 auth-challenge marker: `result == "auth_url"`, with the URL carried in `error`. */ + const val RESULT_AUTH_URL = "auth_url" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt index 9787b10eb3..8d6c80ab05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -75,6 +75,14 @@ object BunkerResponseKSerializer : KSerializer { val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + // NIP-46 auth challenge: `{"result":"auth_url","error":""}`. It carries + // an `error` (the URL) but is NOT a failure — keep both fields so the client + // can surface the URL and keep waiting for the real response. Must precede the + // generic error branch below, which would otherwise drop the `auth_url` marker. + if (result == BunkerResponse.RESULT_AUTH_URL) { + return BunkerResponse(id, result, error) + } + if (error != null) { return BunkerResponseError.parse(id, result, error) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index ccc750e5a3..a1f419ca2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -57,6 +57,12 @@ class NostrSignerRemote( val client: INostrClient, val permissions: String? = null, val secret: String? = null, + /** + * Invoked with the authorization URL when the bunker answers with a NIP-46 + * `auth_url` challenge. Surface it (open a browser / print it); the pending + * request keeps waiting for the real response. + */ + val onAuthUrl: ((String) -> Unit)? = null, ) : NostrSigner(signer.pubKey) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -66,6 +72,7 @@ class NostrSignerRemote( remoteKey = remotePubkey, relayList = relays, client = client, + onAuthUrl = onAuthUrl, ) val subscription = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt index 432c715e41..44fbc2ca90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -41,6 +41,13 @@ class RemoteSignerManager( val remoteKey: String, val relayList: Set, val maxRetries: Int = 1, + /** + * Invoked with the authorization URL when the bunker answers a request with + * a NIP-46 `auth_url` challenge. The caller should surface it (open a + * browser / print it) so the user can authorize; the manager keeps waiting + * for the real response on the same request id. + */ + val onAuthUrl: ((String) -> Unit)? = null, ) { private val pending = LargeCache>() @@ -48,7 +55,10 @@ class RemoteSignerManager( val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) - val channel = pending.remove(bunkerResponse.id) + // Peek (don't remove): a request may receive an `auth_url` challenge + // followed by the real response under the same id. The waiting + // continuation removes the entry in its `finally`. + val channel = pending.get(bunkerResponse.id) if (channel == null) { Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate, unknown, or late)" } return @@ -89,13 +99,32 @@ class RemoteSignerManager( signer = signer, ) - val channel = Channel(capacity = 1) + // UNLIMITED so an `auth_url` challenge and the follow-up real + // response (same id) can both be buffered without dropping either. + val channel = Channel(capacity = Channel.UNLIMITED) pending.put(attemptRequest.id, channel) + var announcedAuthUrl: String? = null val response = try { client.publish(event, relayList = relayList) - withTimeoutOrNull(timeout) { channel.receive() } + withTimeoutOrNull(timeout) { + var real: BunkerResponse? = null + while (real == null) { + val r = channel.receive() + if (r.result == BunkerResponse.RESULT_AUTH_URL) { + // Surface the auth URL once and keep waiting for the real response. + val url = r.error + if (url != null && url != announcedAuthUrl) { + announcedAuthUrl = url + onAuthUrl?.invoke(url) + } + } else { + real = r + } + } + real + } } finally { pending.remove(attemptRequest.id) channel.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt index 99d18cb353..3f346531b8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.Hex @@ -72,6 +73,62 @@ class RemoteSignerManagerRetryTest { signer = bunkerSigner, ) + private suspend fun bunkerAuthUrlFor( + requestId: String, + url: String, + ): NostrConnectEvent = + NostrConnectEvent.create( + message = BunkerResponse(requestId, BunkerResponse.RESULT_AUTH_URL, url), + remoteKey = signer.pubKey, + signer = bunkerSigner, + ) + + @Test + fun authUrlResponseKeepsBothResultAndError() { + val json = """{"id":"abc","result":"auth_url","error":"https://signer.example/auth?x=1"}""" + val resp = OptimizedJsonMapper.fromJsonTo(json) + // Must NOT be flattened into a plain error — the auth_url marker has to survive. + assertEquals(BunkerResponse.RESULT_AUTH_URL, resp.result) + assertEquals("https://signer.example/auth?x=1", resp.error) + } + + @Test + fun authUrlChallengeIsSurfacedThenRealResponseResumes() = + runTest { + val capturing = CapturingNostrClient() + val seenUrls = mutableListOf() + val manager = + RemoteSignerManager( + timeout = 5_000, + client = capturing, + signer = signer, + remoteKey = remoteKey, + relayList = setOf(relay), + maxRetries = 0, + onAuthUrl = { seenUrls.add(it) }, + ) + + val deferred = + async { + manager.launchWaitAndParse( + bunkerRequestBuilder = { BunkerRequestPing() }, + parser = PingResponse::parse, + ) + } + runCurrent() + + val requestId = decodeRequestId(capturing.publishedEvents.single()) + // The bunker first asks for web authorization, then (after the user + // authorizes) sends the real response under the same id. + manager.newResponse(bunkerAuthUrlFor(requestId, "https://signer.example/auth")) + manager.newResponse(bunkerPongFor(requestId)) + advanceUntilIdle() + + val result = deferred.await() + assertIs>(result) + assertEquals(listOf("https://signer.example/auth"), seenUrls) + } + @Test fun timeoutReturnsTimedOutAfterMaxRetries() = runTest { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt index ccaf834c4b..40f61798fb 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt @@ -44,6 +44,14 @@ class BunkerResponseDeserializer : StdDeserializer(BunkerRespons val result = jsonObject.get("result")?.asText() val error = jsonObject.get("error")?.asText() + // NIP-46 auth challenge: `{"result":"auth_url","error":""}`. It carries + // an `error` (the URL) but is NOT a failure — keep both fields so the client + // can surface the URL and keep waiting for the real response. Must precede the + // generic error branch below, which would otherwise drop the `auth_url` marker. + if (result == BunkerResponse.RESULT_AUTH_URL) { + return BunkerResponse(id, result, error) + } + if (error != null) { return BunkerResponseError.parse(id, result, error) } From 749a301469b2b71354f720ce233135211516c4d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:50:02 +0000 Subject: [PATCH 13/26] =?UTF-8?q?feat(cli):=20cheap=20nak-parity=20wins=20?= =?UTF-8?q?=E2=80=94=20key=20nip49,=20nip=20lookup,=20blossom=20check/mirr?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `amy key encrypt|decrypt` (NIP-49): encrypt a secret key to ncryptsec1… and back. Bidirectionally interop-verified vs the real nak binary (amy-encrypt → nak-decrypt and nak-encrypt → amy-decrypt both match). - `amy nip N` / `amy nip list`: look up a NIP — the nostr-protocol/nips git repo FIRST, then a Nostr fallback (NIP-50 search over wiki kind:30818 + long-form kind:30023 on search-capable relays). Slug normalization handles 1→01 and hex-suffixed NIPs (7d→7D, 5a→5A); titles parsed from the setext headings NIP docs use. - `amy blossom check|mirror`: HEAD-check blobs (exit 1 if any missing, like nak) and request BUD-04 mirroring of a blob from a source URL. Also persists the full introspected nak comparison into ROADMAP. `kind` stays deferred — it needs a kind→schema registry that doesn't exist in quartz (would be data/logic that doesn't belong in cli). Verified: key round-trip + nak cross-impl both ways; nip repo titles for 01/46/5A/7D + Nostr fallback on miss; blossom check 200/404 + exit codes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 5 + cli/ROADMAP.md | 37 +++- .../com/vitorpamplona/amethyst/cli/Main.kt | 8 + .../amethyst/cli/commands/BlossomCommands.kt | 83 +++++++- .../amethyst/cli/commands/KeyCommands.kt | 44 +++- .../amethyst/cli/commands/NipCommand.kt | 199 ++++++++++++++++++ 6 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt diff --git a/cli/README.md b/cli/README.md index 01b706e749..2b4c55eb7e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -213,7 +213,10 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | +| `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | +| `amy key decrypt NCRYPTSEC --password X` | NIP-49 decrypt back to nsec/hex/npub. | | `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 relay info URL` | Fetch and print a relay's NIP-11 information document. | ### Remote signing (NIP-46 bunker) @@ -301,6 +304,8 @@ nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out o | `amy blossom download URL [--out FILE]` | Download a blob (public). Accepts a full URL, or a `HASH` plus `--server URL`. | | `amy blossom list --server URL [USER]` | List a user's blobs (BUD-04). USER defaults to the active account. | | `amy blossom delete HASH --server URL` | Delete a blob you own (BUD-02). | +| `amy blossom check --server URL HASH[,HASH]` | HEAD-check the server has each blob; exit 1 if any is missing. | +| `amy blossom mirror --server URL SOURCE-URL` | Ask the server to mirror a blob from SOURCE-URL (BUD-04). | ### Identity diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 6dc6b0772a..3c0308f249 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -81,7 +81,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `decode` | `amy decode` | ✅ | NIP-19/21 → JSON. Quartz `Nip19Parser`. | | `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | | `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | -| `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. | +| `key` | `amy key generate\|public\|encrypt\|decrypt` | ✅ | generate/derive + NIP-49 encrypt/decrypt (bidirectionally nak-verified). `expand`/`combine`/`validate`/`default` still 🆕. | | `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. | | `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | | `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. | @@ -92,18 +92,41 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `relay` (NIP-11) | `amy relay info` | ✅ | stateless NIP-11 doc fetch. | | `outbox` | `amy outbox` | ✅ | NIP-65 read/write relays, cache-first. | | `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | -| `blossom` | `amy blossom` | ✅ | upload/download/list/delete (reuses commons `BlossomClient`). | -| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry — only remaining Tier-1 gap). | +| `blossom` | `amy blossom` | ✅ | upload/download/list/delete/check/mirror (reuses commons `BlossomClient`). | +| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr wiki/long-form fallback; `nip list`. | +| `kind` | `amy kind` | 🆕 | needs a kind→schema registry (nak embeds one); none in quartz today. | | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | | `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | | `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | -**Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, -`publish`, `fetch`, `subscribe`, `count`, `encrypt`, `decrypt`, `gift`, -`filter`, `relay info`, `outbox`, `blossom`. Remaining: `kind` / `nip` -(reference lookups, pending a kind registry). +### Full nak comparison (introspected both binaries) + +nak has 34 functional commands. Coverage: + +- **Full / equivalent (18):** `event`, `req`(→`fetch`+`subscribe`), `filter`, + `count`, `decode`, `encode`, `verify`, `relay`, `bunker`(+nostrconnect+auth_url), + `encrypt`, `decrypt`, `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`, + `blossom`. Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, + `encode`/`decode`) are interop-verified against the real `nak` binary. +- **Partial / adapted (4):** `key` (no `expand`/`combine`/`validate`/`default`), + `git` (NIP-34 events only — no packfile transport), `outbox` (shows NIP-65 vs + nak's hints DB), `fetch` (filter-based, not nip19-hint resolution). +- **Missing (12):** `kind` (needs a kind-schema registry), `admin` (NIP-86), + `serve` (geode is a standalone relay), `dekey` (NIP-4E), `wallet` (NIP-60 + Cashu), `mcp`, `curl` (NIP-98), `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has + MLS/Marmot instead), `spell` (MuSig2/FROST), `validate` (RoK schema). + +**Design differences (not gaps):** amy is a *stateful client* (accounts, +`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless* +per-invocation tool that prints bare values for shell substitution. amy also has +a large surface nak lacks: Marmot/MLS, NIP-17 DMs, zaps, CLINK offer/debit, +NIP-02 follow, NIP-50 search, napplets, profile edit, store management, account +management. + +**Cheap remaining wins:** `kind` (needs a registry), the `key` +`expand`/`combine`/`validate`/`default` sub-verbs. --- 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 244aadaac5..b800811eb0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.cli.commands.LoginCommand import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand import com.vitorpamplona.amethyst.cli.commands.MessageCommands import com.vitorpamplona.amethyst.cli.commands.NappletCommands +import com.vitorpamplona.amethyst.cli.commands.NipCommand import com.vitorpamplona.amethyst.cli.commands.NotesCommands import com.vitorpamplona.amethyst.cli.commands.NsiteCommands import com.vitorpamplona.amethyst.cli.commands.OfferCommands @@ -170,6 +171,7 @@ private suspend fun dispatch(argv: Array): Int { "verify" -> return VerifyCommand.run(tail) "key" -> return KeyCommands.dispatch(tail) "filter" -> return FilterCommand.run(tail) + "nip" -> return NipCommand.run(tail) } // `relay info URL` is a stateless NIP-11 fetch — no account needed. The @@ -342,8 +344,12 @@ private fun printUsage() { | (reads stdin when the arg is omitted or `-`) | key generate mint a fresh keypair (nsec + npub + hex) | key public NSEC|HEX derive the public key from a secret key + | key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1… + | key decrypt NCRYPTSEC --password X NIP-49 decrypt back to a secret key | filter [--kind …] [--author …] assemble + print a NIP-01 filter JSON from the | [--id …] [--tag …] … same flags fetch/subscribe use (no query sent) + | nip N show a NIP (repo first, then a Nostr wiki/long-form fallback) + | nip list fetch the NIP index (README) from the repo | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) @@ -429,6 +435,8 @@ private fun printUsage() { | blossom download HASH --server URL or a HASH plus --server. | blossom list --server URL [USER] list a user's blobs (defaults to self) | blossom delete HASH --server URL delete a blob you own + | blossom check --server URL HASH[,HASH] HEAD-check blobs exist (fails if any missing) + | blossom mirror --server URL SOURCE-URL ask the server to mirror a blob (BUD-04) | |Git (NIP-34): | git announce --name N [--description D] publish a kind:30617 repo announcement diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt index d8244b66d2..f7ed5a8288 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -30,8 +30,10 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.sha256.sha256 +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.io.File import java.nio.file.Files @@ -58,15 +60,94 @@ object BlossomCommands { route( "blossom", tail, - "blossom ", + "blossom ", mapOf( "upload" to { rest -> upload(dataDir, rest) }, "download" to { rest -> download(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "delete" to { rest -> delete(dataDir, rest) }, + "check" to { rest -> check(dataDir, rest) }, + "mirror" to { rest -> mirror(dataDir, rest) }, ), ) + /** `blossom check --server URL HASH[,HASH]` — HEAD each blob; fail if any is missing (BUD-01). */ + private suspend fun check( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom check requires --server URL") + val hashes = + args + .positional(0, "sha256") + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + Context.open(dataDir).use { _ -> + val http = OkHttpClient() + val results = + hashes.map { hash -> + val req = + Request + .Builder() + .url(BlossomServerUrl.blob(server, hash)) + .head() + .build() + val (found, status) = + try { + http.newCall(req).execute().use { it.isSuccessful to it.code } + } catch (e: Exception) { + false to -1 + } + mapOf("sha256" to hash, "found" to found, "status" to status) + } + val allFound = results.all { it["found"] == true } + Output.emit(mapOf("server" to server, "all_found" to allFound, "results" to results)) + return if (allFound) 0 else 1 + } + } + + /** + * `blossom mirror --server URL SOURCE-URL` — ask the server to mirror the + * blob at SOURCE-URL (BUD-04). The sha256 (last path segment of the source + * URL) is signed into the upload auth. + */ + private suspend fun mirror( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom mirror requires --server URL") + val sourceUrl = args.positional(0, "source-url") + val hash = sourceUrl.substringAfterLast('/').substringBefore('.') + if (hash.length != 64 || hash.any { it !in "0123456789abcdef" }) { + return Output.error("bad_args", "could not extract a sha256 from the source url '$sourceUrl'") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val auth = BlossomAuthorizationEvent.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer).toAuthorizationHeader() + val body = """{"url":${Output.mapper.writeValueAsString(sourceUrl)}}""".toRequestBody("application/json".toMediaType()) + val req = + Request + .Builder() + .url(server.removeSuffix("/") + "/mirror") + .header("Authorization", auth) + .put(body) + .build() + OkHttpClient().newCall(req).execute().use { response -> + if (!response.isSuccessful) { + return Output.error("http_error", "mirror failed: HTTP ${response.code} ${response.headers[BlossomServerUrl.REASON_HEADER] ?: ""}") + } + val node = Output.mapper.readTree(response.body.string()) + Output.emit(mapOf("server" to server, "sha256" to hash, "blob" to node)) + } + return 0 + } + } + private suspend fun upload( dataDir: DataDir, rest: Array, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt index ea5f370cf5..38a4657298 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Identity import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 /** * `amy key …` — standalone key utilities (nak's `key`). Local, no network, @@ -32,22 +35,59 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray * * key generate mint a fresh keypair (prints nsec + npub + hex) * key public derive the public key from a secret key + * key encrypt --password X NIP-49 encrypt to an ncryptsec1… + * key decrypt --password X NIP-49 decrypt back to a secret key * * Thin assembly only: key generation + bech32 derivation live in quartz - * (reused here via [Identity], which wraps Quartz's `KeyPair`). + * (reused here via [Identity] / [Nip49]). */ object KeyCommands { suspend fun dispatch(rest: Array): Int = route( "key", rest, - "key ", + "key ", mapOf( "generate" to { _ -> generate() }, "public" to { tail -> public(tail) }, + "encrypt" to { tail -> encrypt(tail) }, + "decrypt" to { tail -> decrypt(tail) }, ), ) + /** Read the private key (nsec or 64-hex) into hex, or null if unparseable. */ + private fun privHexOrNull(input: String): String? = + when { + input.startsWith("nsec") -> runCatching { input.bechToBytes().toHexKey() }.getOrNull() + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> input.lowercase() + else -> null + } + + private fun encrypt(rest: Array): Int { + val args = Args(rest) + val priv = privHexOrNull(args.positional(0, "secret-key").trim()) ?: return Output.error("bad_args", "expected an nsec or 64-char hex secret key") + val password = args.flag("password") ?: args.flag("pw") ?: return Output.error("bad_args", "key encrypt requires --password") + val ncryptsec = Nip49().encrypt(priv, password) + Output.emit(mapOf("ncryptsec" to ncryptsec)) + return 0 + } + + private fun decrypt(rest: Array): Int { + val args = Args(rest) + val ncryptsec = args.positional(0, "ncryptsec").trim() + if (!ncryptsec.startsWith("ncryptsec")) return Output.error("bad_args", "expected an ncryptsec1… string") + val password = args.flag("password") ?: args.flag("pw") ?: return Output.error("bad_args", "key decrypt requires --password") + val privHex = + try { + Nip49().decrypt(ncryptsec, password) + } catch (e: Exception) { + return Output.error("decrypt_failed", e.message ?: "could not decrypt (wrong password?)") + } + val id = Identity.fromPrivateKey(privHex.hexToByteArray()) + Output.emit(mapOf("nsec" to id.nsec, "npub" to id.npub, "private_key" to id.privKeyHex, "pubkey" to id.pubKeyHex)) + return 0 + } + private fun generate(): Int { val id = Identity.create() Output.emit( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt new file mode 100644 index 0000000000..74cac41713 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt @@ -0,0 +1,199 @@ +/* + * 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.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * `amy nip ` / `amy nip list` — look up a NIP (nak's `nip`). Local-ish and + * accountless. + * + * Resolution order, per request: the canonical `nostr-protocol/nips` git repo + * **first**, then a fallback search on Nostr (NIP-50 over wiki kind:30818 + + * long-form kind:30023) for relays/clients that publish NIPs natively. + * + * nip 46 fetch NIP-46 from the repo (falls back to Nostr on a miss) + * nip 7D hex-suffixed NIPs work too (case-insensitive) + * nip list fetch the repo's NIP index (README) + */ +object NipCommand { + private const val RAW_BASE = "https://raw.githubusercontent.com/nostr-protocol/nips/master" + + // NIP-50-capable relays for the Nostr fallback (search isn't universal). + private val SEARCH_RELAYS = + listOf("wss://relay.nostr.band", "wss://relay.damus.io") + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + + private const val WIKI_KIND = 30818 + private const val LONGFORM_KIND = 30023 + + suspend fun run(rest: Array): Int { + if (rest.firstOrNull() == "list") return list() + val args = Args(rest) + val raw = args.positional(0, "nip-number").trim() + val slug = normalizeSlug(raw) + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + + // 1. Canonical repo first. + val url = "$RAW_BASE/$slug.md" + fetchText(url)?.let { text -> + Output.emit(mapOf("nip" to slug, "source" to "repo", "url" to url, "title" to titleOf(text), "text" to text)) + return 0 + } + + // 2. Fall back to Nostr. + val candidates = searchNostr(slug, timeoutMs) + if (candidates.isEmpty()) { + return Output.error("not_found", "NIP-$slug not in the repo and no matching wiki/long-form event on Nostr") + } + Output.emit(mapOf("nip" to slug, "source" to "nostr", "count" to candidates.size, "events" to candidates)) + return 0 + } + + private fun list(): Int { + val url = "$RAW_BASE/README.md" + val text = fetchText(url) ?: return Output.error("fetch_failed", "could not fetch the NIP index from $url") + Output.emit(mapOf("source" to "repo", "url" to url, "text" to text)) + return 0 + } + + /** `1` -> `01`, `7d` -> `7D`, `46` -> `46`. The repo names files in 2-char upper-case. */ + private fun normalizeSlug(input: String): String { + val cleaned = input.removePrefix("NIP-").removePrefix("nip-").uppercase() + return if (cleaned.length == 1) "0$cleaned" else cleaned + } + + /** + * NIP docs use setext headings — `NIP-46\n======` (H1) then a descriptive + * `Nostr Remote Signing\n--------` (H2). Prefer the descriptive H2, then + * the H1, then any ATX `#` heading. + */ + private fun titleOf(markdown: String): String? { + val lines = markdown.lines() + + fun underlinedBy( + ch: Char, + i: Int, + ): Boolean { + val text = lines.getOrNull(i)?.trim().orEmpty() + val rule = lines.getOrNull(i + 1)?.trim().orEmpty() + return text.isNotEmpty() && !text.startsWith("#") && rule.length >= 3 && rule.all { it == ch } + } + for (i in lines.indices) if (underlinedBy('-', i)) return lines[i].trim() + for (i in lines.indices) if (underlinedBy('=', i)) return lines[i].trim() + return lines.firstOrNull { it.startsWith("# ") || it.startsWith("## ") }?.trimStart('#', ' ')?.trim() + } + + private fun fetchText(url: String): String? = + try { + OkHttpClient() + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { response -> + if (response.isSuccessful) response.body.string() else null + } + } catch (e: Exception) { + null + } + + /** NIP-50 search across wiki + long-form for events that document the NIP. */ + private suspend fun searchNostr( + slug: String, + timeoutMs: Long, + ): List> { + if (SEARCH_RELAYS.isEmpty()) return emptyList() + val okhttp = OkHttpClient.Builder().build() + val client = NostrClient(websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }) + val filter = Filter(kinds = listOf(WIKI_KIND, LONGFORM_KIND), search = "NIP-$slug", limit = 10) + val subId = newSubId() + val events = Channel(UNLIMITED) + val done = Channel(UNLIMITED) + val remaining = SEARCH_RELAYS.toMutableSet() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + events.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.trySend(relay) + } + } + val collected = mutableListOf() + try { + client.connect() + client.subscribe(subId, SEARCH_RELAYS.associateWith { listOf(filter) }, listener) + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + select { + events.onReceive { collected.add(it) } + done.onReceive { remaining.remove(it) } + } + } + } + } finally { + client.unsubscribe(subId) + client.close() + events.close() + done.close() + } + return collected + .distinctBy { it.id } + .take(10) + .map { ev -> + mapOf( + "id" to ev.id, + "kind" to ev.kind, + "pubkey" to ev.pubKey, + "title" to (ev.tags.firstOrNull { it.size > 1 && (it[0] == "title" || it[0] == "d") }?.get(1)), + ) + } + } +} From b6c065d4216d82183a4c855089528b9c7ef0bde4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 21:00:54 +0000 Subject: [PATCH 14/26] feat(quartz,cli): add KindNames registry + amy kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a canonical, i18n-free event-kind registry in quartz: `com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known kinds to an English label + the defining NIP. The data is ported from Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from each event class's package), so the "what is kind N" knowledge now lives once in quartz instead of only in the Android UI. `amy kind ` looks a kind up by number (label + NIP) or searches labels by name — a thin wrapper over KindNames, dispatched statelessly (no account/network). i18n split: quartz holds the canonical English (quartz is intentionally translation-free); localized front ends overlay their own strings and can fall back to KindNames.nameFor() for kinds they don't translate. amy prints the English directly. Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast" → 4), unknown → known:false, text + JSON output. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 1 + cli/ROADMAP.md | 11 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 3 + .../amethyst/cli/commands/KindCommand.kt | 65 ++++ .../vitorpamplona/quartz/kinds/KindNames.kt | 357 ++++++++++++++++++ 5 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt diff --git a/cli/README.md b/cli/README.md index 2b4c55eb7e..bfef61f701 100644 --- a/cli/README.md +++ b/cli/README.md @@ -217,6 +217,7 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy key decrypt NCRYPTSEC --password X` | NIP-49 decrypt back to nsec/hex/npub. | | `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 relay info URL` | Fetch and print a relay's NIP-11 information document. | ### Remote signing (NIP-46 bunker) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 3c0308f249..f5f0ea52c1 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -94,7 +94,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | | `blossom` | `amy blossom` | ✅ | upload/download/list/delete/check/mirror (reuses commons `BlossomClient`). | | `nip` | `amy nip` | ✅ | repo-first lookup + Nostr wiki/long-form fallback; `nip list`. | -| `kind` | `amy kind` | 🆕 | needs a kind→schema registry (nak embeds one); none in quartz today. | +| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP), ported from the Android relay view; number lookup + name search. | | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | @@ -105,15 +105,15 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. nak has 34 functional commands. Coverage: -- **Full / equivalent (18):** `event`, `req`(→`fetch`+`subscribe`), `filter`, +- **Full / equivalent (19):** `event`, `req`(→`fetch`+`subscribe`), `filter`, `count`, `decode`, `encode`, `verify`, `relay`, `bunker`(+nostrconnect+auth_url), `encrypt`, `decrypt`, `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`, - `blossom`. Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, + `kind`, `blossom`. Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`) are interop-verified against the real `nak` binary. - **Partial / adapted (4):** `key` (no `expand`/`combine`/`validate`/`default`), `git` (NIP-34 events only — no packfile transport), `outbox` (shows NIP-65 vs nak's hints DB), `fetch` (filter-based, not nip19-hint resolution). -- **Missing (12):** `kind` (needs a kind-schema registry), `admin` (NIP-86), +- **Missing (11):** `admin` (NIP-86), `serve` (geode is a standalone relay), `dekey` (NIP-4E), `wallet` (NIP-60 Cashu), `mcp`, `curl` (NIP-98), `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has MLS/Marmot instead), `spell` (MuSig2/FROST), `validate` (RoK schema). @@ -125,8 +125,7 @@ a large surface nak lacks: Marmot/MLS, NIP-17 DMs, zaps, CLINK offer/debit, NIP-02 follow, NIP-50 search, napplets, profile edit, store management, account management. -**Cheap remaining wins:** `kind` (needs a registry), the `key` -`expand`/`combine`/`validate`/`default` sub-verbs. +**Cheap remaining wins:** the `key` `expand`/`combine`/`validate`/`default` sub-verbs. --- 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 b800811eb0..5cda741f43 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.cli.commands.GroupCommands import com.vitorpamplona.amethyst.cli.commands.InitCommands import com.vitorpamplona.amethyst.cli.commands.KeyCommands import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands +import com.vitorpamplona.amethyst.cli.commands.KindCommand import com.vitorpamplona.amethyst.cli.commands.LoginCommand import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand import com.vitorpamplona.amethyst.cli.commands.MessageCommands @@ -172,6 +173,7 @@ private suspend fun dispatch(argv: Array): Int { "key" -> return KeyCommands.dispatch(tail) "filter" -> return FilterCommand.run(tail) "nip" -> return NipCommand.run(tail) + "kind" -> return KindCommand.run(tail) } // `relay info URL` is a stateless NIP-11 fetch — no account needed. The @@ -350,6 +352,7 @@ private fun printUsage() { | [--id …] [--tag …] … same flags fetch/subscribe use (no query sent) | 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) | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt new file mode 100644 index 0000000000..e2654428d6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt @@ -0,0 +1,65 @@ +/* + * 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.kinds.KindNames + +/** + * `amy kind ` — look up a Nostr event kind (nak's `kind`). Local, + * accountless. A number prints that kind's label + defining NIP; a text query + * searches labels. + * + * kind 1 -> {"kind":1,"name":"Short Text Note","nip":"10",...} + * kind "text note" -> {"query":…,"matches":[…]} + * + * Thin assembly only: the canonical kind registry lives in quartz + * (`KindNames`) — the same data the Android relay view localizes on top of. + */ +object KindCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val arg = args.positional(0, "kind-number-or-name").trim() + val n = arg.toIntOrNull() + if (n != null) { + val info = KindNames.infoFor(n) + Output.emit( + mapOf( + "kind" to n, + "name" to info?.name, + "nip" to info?.nip, + "known" to (info != null), + ), + ) + return 0 + } + val matches = KindNames.search(arg) + Output.emit( + mapOf( + "query" to arg, + "count" to matches.size, + "matches" to matches.map { (kind, info) -> mapOf("kind" to kind, "name" to info.name, "nip" to info.nip) }, + ), + ) + return 0 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt new file mode 100644 index 0000000000..f09a5fe5fc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -0,0 +1,357 @@ +/* + * 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.kinds + +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * Human-readable label and defining NIP for a Nostr event kind. + */ +data class KindName( + val name: String, + val nip: String?, +) + +/** + * Canonical, **i18n-free** registry mapping event-kind numbers to an English + * label and the NIP that defines them. This is the single source of truth for + * "what is kind N" across the project: + * + * - headless consumers (the `amy` CLI) print [nameFor] directly; + * - localized front ends (the Android app) overlay their own translated + * strings on top and fall back to [nameFor] for kinds they don't translate. + * + * Keep this list as the place new kinds are registered; translations are a + * platform concern layered on top, never a fork of this data. + */ +object KindNames { + val names: Map = + mapOf( + AcceptedBadgeSetEvent.KIND to KindName("Accepted Badge Set", "58"), + AdvertisedRelayListEvent.KIND to KindName("Outbox Relays", "65"), + AppDefinitionEvent.KIND to KindName("Apps", "89"), + AppRecommendationEvent.KIND to KindName("App Recommendations", "89"), + AppSpecificDataEvent.KIND to KindName("User Settings", "78"), + AudioHeaderEvent.KIND to KindName("Audio Header", null), + AudioTrackEvent.KIND to KindName("Audio Track", null), + MusicTrackEvent.KIND to KindName("Music Track", null), + MusicPlaylistEvent.KIND to KindName("Music Playlist", null), + PodcastEpisodeEvent.KIND to KindName("Podcast Episode", "F4"), + PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"), + AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"), + FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"), + AttestationEvent.KIND to KindName("Attestation", null), + AttestationRequestEvent.KIND to KindName("Attestation Request", null), + AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null), + AttestorProficiencyEvent.KIND to KindName("Attestor Proficiency", null), + BadgeAwardEvent.KIND to KindName("Badge Awards", "58"), + BadgeDefinitionEvent.KIND to KindName("Badge Definitions", "58"), + BlockedRelayListEvent.KIND to KindName("Blocked Relays", "51"), + BlossomServersEvent.KIND to KindName("Blossom Servers", "B7"), + NestsServersEvent.KIND to KindName("Nests Servers", "53"), + BlossomAuthorizationEvent.KIND to KindName("Blossom Auth", "B7"), + BroadcastRelayListEvent.KIND to KindName("Broadcast Relays", "51"), + BookmarkListEvent.KIND to KindName("Bookmark List", "51"), + OldBookmarkListEvent.KIND to KindName("Old Bookmark List", "51"), + CalendarDateSlotEvent.KIND to KindName("Day Appointment", "52"), + CalendarEvent.KIND to KindName("Calendar", "52"), + CalendarTimeSlotEvent.KIND to KindName("Appointment", "52"), + CalendarRSVPEvent.KIND to KindName("Appt RSVP", "52"), + ChessGameEvent.KIND to KindName("Chess Games", "64"), + JesterEvent.KIND to KindName("Chess Auth", "64"), + RelayFeedsListEvent.KIND to KindName("Favorite Relays", "51"), + LiveChessGameChallengeEvent.KIND to KindName("Chess Challenges", "64"), + LiveChessGameAcceptEvent.KIND to KindName("Chess Game Accept", "64"), + LiveChessMoveEvent.KIND to KindName("Chess Move", "64"), + LiveChessGameEndEvent.KIND to KindName("Chess Game End", "64"), + LiveChessDrawOfferEvent.KIND to KindName("Chess Draw Offer", "64"), + ChannelCreateEvent.KIND to KindName("Channel Definition", "28"), + ChannelHideMessageEvent.KIND to KindName("Channel Hide Msg", "28"), + ChannelListEvent.KIND to KindName("Channel List", "28"), + ChannelMessageEvent.KIND to KindName("Channel Message", "28"), + ChannelMetadataEvent.KIND to KindName("Channel Metadata", "28"), + ChannelMuteUserEvent.KIND to KindName("Channel Mute User", "28"), + ChatMessageEncryptedFileHeaderEvent.KIND to KindName("DM File", "17"), + ChatMessageEvent.KIND to KindName("DM Message", "17"), + ChatMessageRelayListEvent.KIND to KindName("DM Relays", "17"), + ClassifiedsEvent.KIND to KindName("Classifieds", "99"), + CommentEvent.KIND to KindName("Comments", "22"), + CommunityDefinitionEvent.KIND to KindName("Community Def", "72"), + CommunityListEvent.KIND to KindName("Community List", "72"), + CommunityPostApprovalEvent.KIND to KindName("Community Post", "72"), + ContactListEvent.KIND to KindName("Follow List", "02"), + DeletionEvent.KIND to KindName("Deletions", "09"), + DraftWrapEvent.KIND to KindName("Drafts", "37"), + EmojiPackEvent.KIND to KindName("Emoji Packs", "30"), + EmojiPackSelectionEvent.KIND to KindName("Emoji Pack List", "30"), + EphemeralChatEvent.KIND to KindName("Ephemeral Chat", null), + EphemeralChatListEvent.KIND to KindName("Ephemeral Chatrooms", null), + FileHeaderEvent.KIND to KindName("File Headers", "94"), + ProfileGalleryEntryEvent.KIND to KindName("Profile Gallery", null), + FileServersEvent.KIND to KindName("File Servers", "96"), + FileStorageEvent.KIND to KindName("Blob Data", null), + FileStorageHeaderEvent.KIND to KindName("Blob Headers", null), + FhirResourceEvent.KIND to KindName("Medical Data", null), + FollowListEvent.KIND to KindName("Follow Packs", "51"), + GenericRepostEvent.KIND to KindName("Reposts (16)", "18"), + GeohashListEvent.KIND to KindName("Geohash Follows", "51"), + GiftWrapEvent.KIND to KindName("GiftWraps", "59"), + EphemeralGiftWrapEvent.KIND to KindName("GiftWraps", "59"), + GitIssueEvent.KIND to KindName("Git Issue", "34"), + GitPatchEvent.KIND to KindName("Git Patch", "34"), + GitRepositoryEvent.KIND to KindName("Git Repo", "34"), + GitReplyEvent.KIND to KindName("Git Reply", "34"), + GoalEvent.KIND to KindName("Zap Goals", "75"), + HashtagListEvent.KIND to KindName("Hashtag Follows", "51"), + HighlightEvent.KIND to KindName("Highlights", "84"), + HTTPAuthorizationEvent.KIND to KindName("Http Auth", "98"), + IndexerRelayListEvent.KIND to KindName("Index Relay List", "51"), + InteractiveStoryPrologueEvent.KIND to KindName("Adventure Prologue", null), + InteractiveStorySceneEvent.KIND to KindName("Adventure Scene", null), + InteractiveStoryReadingStateEvent.KIND to KindName("Adventure Reading", null), + LabeledBookmarkListEvent.KIND to KindName("Named Bookmarks", "51"), + LiveActivitiesChatMessageEvent.KIND to KindName("Live Chats", "53"), + LiveActivitiesEvent.KIND to KindName("Live Streams", "53"), + LnZapEvent.KIND to KindName("Zaps", "57"), + LnZapPaymentRequestEvent.KIND to KindName("NWC Request", "47"), + LnZapPaymentResponseEvent.KIND to KindName("NWC Response", "47"), + LnZapPrivateEvent.KIND to KindName("Private Zaps", "57"), + LnZapRequestEvent.KIND to KindName("Zap Req", "57"), + LongTextNoteEvent.KIND to KindName("Blogs", "23"), + MeetingRoomEvent.KIND to KindName("Meeting Room", "53"), + MeetingRoomPresenceEvent.KIND to KindName("Room Presence", "53"), + MeetingSpaceEvent.KIND to KindName("Meeting Space", "53"), + MetadataEvent.KIND to KindName("Profile", "01"), + MuteListEvent.KIND to KindName("Mute List", "51"), + NNSEvent.KIND to KindName("NNS", null), + NipTextEvent.KIND to KindName("NIP", null), + NostrConnectEvent.KIND to KindName("Nostr Connect", "46"), + NIP90StatusEvent.KIND to KindName("DVM Status", "90"), + NIP90ContentDiscoveryRequestEvent.KIND to KindName("DVM Content Req", "90"), + NIP90ContentDiscoveryResponseEvent.KIND to KindName("DVM Content Resp", "90"), + NIP90UserDiscoveryRequestEvent.KIND to KindName("DVM User Req", "90"), + NIP90UserDiscoveryResponseEvent.KIND to KindName("DVM User Resp", "90"), + OtsEvent.KIND to KindName("OTS", "03"), + PaymentTargetsEvent.KIND to KindName("PayTo", null), + PeopleListEvent.KIND to KindName("People Lists", "51"), + ProfileBadgesEvent.KIND to KindName("Profile Badges", "58"), + PictureEvent.KIND to KindName("Pictures", "68"), + WorkoutRecordEvent.KIND to KindName("Workouts", null), + PinListEvent.KIND to KindName("Pins", "51"), + ZapPollEvent.KIND to KindName("Zap Poll", null), + PollEvent.KIND to KindName("Poll", "88"), + PollResponseEvent.KIND to KindName("Poll Response", "88"), + PrivateDmEvent.KIND to KindName("NIP-04 DMs", "04"), + PrivateOutboxRelayListEvent.KIND to KindName("Private Relays", "37"), + ProxyRelayListEvent.KIND to KindName("Proxy Relays", "51"), + PublicMessageEvent.KIND to KindName("Public Message", "A4"), + ReactionEvent.KIND to KindName("Reactions", "25"), + ContactCardEvent.KIND to KindName("Contact Card", "85"), + RelayAuthEvent.KIND to KindName("Relay Auth", "42"), + RelayDiscoveryEvent.KIND to KindName("Relay Discovery", "66"), + RelayMonitorEvent.KIND to KindName("Relay Monitor Announcement", "66"), + RelaySetEvent.KIND to KindName("Relay Set", "51"), + ReportEvent.KIND to KindName("Reports", "56"), + RepostEvent.KIND to KindName("Reposts", "18"), + RequestToVanishEvent.KIND to KindName("User Delete", "62"), + SealedRumorEvent.KIND to KindName("Seals", "59"), + SearchRelayListEvent.KIND to KindName("Search Relays", "50"), + StatusEvent.KIND to KindName("User Status", "38"), + TextNoteEvent.KIND to KindName("Notes", "10"), + TextNoteModificationEvent.KIND to KindName("Edits", null), + TorrentEvent.KIND to KindName("Torrents", "35"), + TorrentCommentEvent.KIND to KindName("Torrent Comments", "35"), + TrustedRelayListEvent.KIND to KindName("Trusted Relays", "51"), + TrustProviderListEvent.KIND to KindName("Trusted Providers", "85"), + VideoHorizontalEvent.KIND to KindName("Video (Repl)", "71"), + VideoVerticalEvent.KIND to KindName("Shorts (Repl)", "71"), + VideoNormalEvent.KIND to KindName("Video", "71"), + VideoShortEvent.KIND to KindName("Shorts", "71"), + VoiceEvent.KIND to KindName("Voice Msg", "A0"), + VoiceReplyEvent.KIND to KindName("Voice Reply", "A0"), + WakeUpEvent.KIND to KindName("WakeUp", null), + WebBookmarkEvent.KIND to KindName("Web Bookmark", "B0"), + WikiNoteEvent.KIND to KindName("Wiki", "54"), + ) + + fun infoFor(kind: Int): KindName? = names[kind] + + fun nameFor(kind: Int): String? = names[kind]?.name + + fun nipFor(kind: Int): String? = names[kind]?.nip + + /** Case-insensitive substring search by label; returns matching (kind, info) sorted by kind. */ + fun search(query: String): List> { + val q = query.trim().lowercase() + if (q.isEmpty()) return emptyList() + return names.entries + .filter { + it.value.name + .lowercase() + .contains(q) + }.map { it.key to it.value } + .sortedBy { it.first } + } +} From 938f40d568125bcf120969e2a135ef0ffc5ce7df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 21:10:31 +0000 Subject: [PATCH 15/26] refactor(amethyst): fall back to quartz KindNames for unknown kinds The relay kind chips, NIP-86 management screen, and app-recommendation labels all map kinds to translated R.string labels via kindDisplayName(), returning -1 for kinds without a localized name. Route that -1 case through the shared quartz KindNames registry so any kind known to the protocol layer renders its canonical English label instead of a bare "k"/empty placeholder. Translations still win where they exist; quartz is the canonical source for the rest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../apps/recommendations/ProfileAppRecommendationsScreen.kt | 3 ++- .../ui/screen/loggedIn/relays/RelayInformationScreen.kt | 3 ++- .../ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index 15403e6a07..a3fd3f8bfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers @@ -427,7 +428,7 @@ private fun supportedKindsLabel(kinds: List): String { val names = kinds.take(VISIBLE_KIND_NAMES).map { kind -> val nameRes = kindDisplayName(kind) - if (nameRes != -1) stringRes(nameRes) else "k$kind" + if (nameRes != -1) stringRes(nameRes) else (KindNames.nameFor(kind) ?: "k$kind") } val overflow = kinds.size - VISIBLE_KIND_NAMES val suffix = if (overflow > 0) " +$overflow" else "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index f521fa90d1..ea9ed897bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -133,6 +133,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage @@ -694,7 +695,7 @@ val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, Re @Composable fun KindChip(kind: Int) { val nameResId = kindDisplayName(kind) - val name = if (nameResId != -1) stringResource(nameResId) else "k$kind" + val name = if (nameResId != -1) stringResource(nameResId) else (KindNames.nameFor(kind) ?: "k$kind") val (bg, fg) = when (kind) { in posts -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index 79a87fffbf..db406f30eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.nip05 +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -897,7 +898,7 @@ private fun KindEntryCard( verticalAlignment = Alignment.CenterVertically, ) { val nameResId = kindDisplayName(kind) - val name = if (nameResId != -1) stringResource(nameResId) else "" + val name = if (nameResId != -1) stringResource(nameResId) else (KindNames.nameFor(kind) ?: "") Text( "Kind $kind: $name", From 3df9f831b804115494c42cd5ab34121012dfeb1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 21:41:52 +0000 Subject: [PATCH 16/26] feat(quartz): complete KindNames registry for every supported kind Fill the KindNames registry from 145 to 280 entries so every event kind quartz defines a class for has a canonical English label + NIP. Adds the whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61 Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC), marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation sets, NIP-85 assertions, Marmot MLS events, and more. For the handful of kind numbers shared by multiple classes, the registry keeps one canonical entry (e.g. CashuToken over the deprecated nip61 TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add helper that reuses it. Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind 30817 (NipTextEvent) alongside the wiki and long-form kinds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/ROADMAP.md | 4 +- .../amethyst/cli/commands/NipCommand.kt | 11 +- .../vitorpamplona/quartz/kinds/KindNames.kt | 270 ++++++++++++++++++ 3 files changed, 280 insertions(+), 5 deletions(-) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index f5f0ea52c1..32ca77110e 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -93,8 +93,8 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `outbox` | `amy outbox` | ✅ | NIP-65 read/write relays, cache-first. | | `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | | `blossom` | `amy blossom` | ✅ | upload/download/list/delete/check/mirror (reuses commons `BlossomClient`). | -| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr wiki/long-form fallback; `nip list`. | -| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP), ported from the Android relay view; number lookup + name search. | +| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. | +| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. | | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt index 74cac41713..515a2c17a2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener @@ -42,8 +43,9 @@ import okhttp3.Request * accountless. * * Resolution order, per request: the canonical `nostr-protocol/nips` git repo - * **first**, then a fallback search on Nostr (NIP-50 over wiki kind:30818 + - * long-form kind:30023) for relays/clients that publish NIPs natively. + * **first**, then a fallback search on Nostr (NIP-50 over the NipText kind:30817, + * wiki kind:30818 and long-form kind:30023) for relays/clients that publish NIPs + * natively. * * nip 46 fetch NIP-46 from the repo (falls back to Nostr on a miss) * nip 7D hex-suffixed NIPs work too (case-insensitive) @@ -58,6 +60,9 @@ object NipCommand { .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } .toSet() + // Quartz's canonical "NIP published on Nostr" kind (NipTextEvent), plus the + // wiki + long-form kinds clients also use to mirror NIP text. + private const val NIPTEXT_KIND = NipTextEvent.KIND private const val WIKI_KIND = 30818 private const val LONGFORM_KIND = 30023 @@ -143,7 +148,7 @@ object NipCommand { if (SEARCH_RELAYS.isEmpty()) return emptyList() val okhttp = OkHttpClient.Builder().build() val client = NostrClient(websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }) - val filter = Filter(kinds = listOf(WIKI_KIND, LONGFORM_KIND), search = "NIP-$slug", limit = 10) + val filter = Filter(kinds = listOf(NIPTEXT_KIND, WIKI_KIND, LONGFORM_KIND), search = "NIP-$slug", limit = 10) val subId = newSubId() val events = Channel(UNLIMITED) val done = Channel(UNLIMITED) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index f09a5fe5fc..3376178217 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -20,15 +20,22 @@ */ package com.vitorpamplona.quartz.kinds +import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -36,6 +43,9 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -43,13 +53,30 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.NotificationRequestEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent +import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent +import com.vitorpamplona.quartz.nip15Marketplace.bidConfirmation.BidConfirmationEvent +import com.vitorpamplona.quartz.nip15Marketplace.marketplace.MarketplaceEvent +import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent +import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -64,31 +91,70 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent +import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.inviteRequest.RelayInviteRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent +import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent +import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent +import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent +import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent @@ -96,15 +162,20 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -118,6 +189,17 @@ import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent @@ -130,6 +212,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent @@ -137,20 +220,63 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationResponseEvent import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent @@ -158,9 +284,18 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent @@ -334,6 +469,141 @@ object KindNames { WakeUpEvent.KIND to KindName("WakeUp", null), WebBookmarkEvent.KIND to KindName("Web Bookmark", "B0"), WikiNoteEvent.KIND to KindName("Wiki", "54"), + ChatEvent.KIND to KindName("Relay Chat", "C7"), + ThreadEvent.KIND to KindName("Thread", "7D"), + AppDataEvent.KIND to KindName("App Data", "78"), + WelcomeEvent.KIND to KindName("MLS Welcome", null), + GroupEvent.KIND to KindName("MLS Group Message", null), + NotificationRequestEvent.KIND to KindName("MLS Notification Request", null), + TokenRequestEvent.KIND to KindName("MLS Token Request", null), + TokenListEvent.KIND to KindName("MLS Token List", null), + TokenRemovalEvent.KIND to KindName("MLS Token Removal", null), + BidEvent.KIND to KindName("Marketplace Bid", "15"), + BidConfirmationEvent.KIND to KindName("Bid Confirmation", "15"), + LiveActivitiesRaidEvent.KIND to KindName("Live Raid", "53"), + LiveActivitiesClipEvent.KIND to KindName("Live Clip", "53"), + RoadEventReportEvent.KIND to KindName("Road Report", null), + RoadEventConfirmationEvent.KIND to KindName("Road Confirmation", null), + CodeSnippetEvent.KIND to KindName("Code Snippet", "C0"), + GitPullRequestEvent.KIND to KindName("Git Pull Request", "34"), + GitPullRequestUpdateEvent.KIND to KindName("Git PR Update", "34"), + LabelEvent.KIND to KindName("Label", "32"), + SoftwareAssetEvent.KIND to KindName("Software Asset", "82"), + AdminCommandEvent.KIND to KindName("Nests Admin Command", null), + NIP90TextExtractionRequestEvent.KIND to KindName("DVM Text Extraction Req", "90"), + NIP90SummarizationRequestEvent.KIND to KindName("DVM Summarization Req", "90"), + NIP90TranslationRequestEvent.KIND to KindName("DVM Translation Req", "90"), + NIP90TextGenerationRequestEvent.KIND to KindName("DVM Text Generation Req", "90"), + NIP90ImageGenerationRequestEvent.KIND to KindName("DVM Image Generation Req", "90"), + NappletSnapshotEvent.KIND to KindName("Napplet Snapshot", "5D"), + NIP90VideoConversionRequestEvent.KIND to KindName("DVM Video Conversion Req", "90"), + NIP90VideoTranslationRequestEvent.KIND to KindName("DVM Video Translation Req", "90"), + NIP90ImageToVideoRequestEvent.KIND to KindName("DVM Image To Video Req", "90"), + NIP90TextToSpeechRequestEvent.KIND to KindName("DVM Text To Speech Req", "90"), + NIP90ContentSearchRequestEvent.KIND to KindName("DVM Content Search Req", "90"), + NIP90PeopleSearchRequestEvent.KIND to KindName("DVM People Search Req", "90"), + NIP90EventCountRequestEvent.KIND to KindName("DVM Event Count Req", "90"), + NIP90MalwareScanRequestEvent.KIND to KindName("DVM Malware Scan Req", "90"), + NIP90EventTimestampingRequestEvent.KIND to KindName("DVM Timestamping Req", "90"), + NIP90OpReturnRequestEvent.KIND to KindName("DVM OpReturn Req", "90"), + NIP90EventPublishScheduleRequestEvent.KIND to KindName("DVM Publish Schedule Req", "90"), + NIP90EventPowDelegationRequestEvent.KIND to KindName("DVM PoW Delegation Req", "90"), + NIP90TextExtractionResponseEvent.KIND to KindName("DVM Text Extraction Resp", "90"), + NIP90SummarizationResponseEvent.KIND to KindName("DVM Summarization Resp", "90"), + NIP90TranslationResponseEvent.KIND to KindName("DVM Translation Resp", "90"), + NIP90TextGenerationResponseEvent.KIND to KindName("DVM Text Generation Resp", "90"), + NIP90ImageGenerationResponseEvent.KIND to KindName("DVM Image Generation Resp", "90"), + NIP90VideoConversionResponseEvent.KIND to KindName("DVM Video Conversion Resp", "90"), + NIP90VideoTranslationResponseEvent.KIND to KindName("DVM Video Translation Resp", "90"), + NIP90ImageToVideoResponseEvent.KIND to KindName("DVM Image To Video Resp", "90"), + NIP90TextToSpeechResponseEvent.KIND to KindName("DVM Text To Speech Resp", "90"), + NIP90ContentSearchResponseEvent.KIND to KindName("DVM Content Search Resp", "90"), + NIP90PeopleSearchResponseEvent.KIND to KindName("DVM People Search Resp", "90"), + NIP90EventCountResponseEvent.KIND to KindName("DVM Event Count Resp", "90"), + NIP90MalwareScanResponseEvent.KIND to KindName("DVM Malware Scan Resp", "90"), + NIP90EventTimestampingResponseEvent.KIND to KindName("DVM Timestamping Resp", "90"), + NIP90OpReturnResponseEvent.KIND to KindName("DVM OpReturn Resp", "90"), + NIP90EventPublishScheduleResponseEvent.KIND to KindName("DVM Publish Schedule Resp", "90"), + NIP90EventPowDelegationResponseEvent.KIND to KindName("DVM PoW Delegation Resp", "90"), + CashuMintQuoteEvent.KIND to KindName("Cashu Mint Quote", "60"), + CashuTokenEvent.KIND to KindName("Cashu Token", "60"), + CashuSpendingHistoryEvent.KIND to KindName("Cashu History", "60"), + RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"), + RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), + OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), + PutUserEvent.KIND to KindName("Group Put User", "29"), + RemoveUserEvent.KIND to KindName("Group Remove User", "29"), + EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), + DeleteEventEvent.KIND to KindName("Group Delete Event", "29"), + CreateGroupEvent.KIND to KindName("Group Create", "29"), + DeleteGroupEvent.KIND to KindName("Group Delete", "29"), + CreateInviteEvent.KIND to KindName("Group Create Invite", "29"), + JoinRequestEvent.KIND to KindName("Group Join Request", "29"), + LeaveRequestEvent.KIND to KindName("Group Leave Request", "29"), + NutzapEvent.KIND to KindName("Nutzap", "61"), + SimpleGroupListEvent.KIND to KindName("Group List", "51"), + ExternalIdentitiesEvent.KIND to KindName("External Identities", "39"), + GitAuthorListEvent.KIND to KindName("Git Authors", "51"), + GitRepositoryListEvent.KIND to KindName("Git Repos", "51"), + NutzapInfoEvent.KIND to KindName("Nutzap Info", "61"), + MediaFollowListEvent.KIND to KindName("Media Follows", "51"), + EncryptionKeyListEvent.KIND to KindName("Encryption Keys", null), + KeyPackageRelayListEvent.KIND to KindName("MLS KeyPackage Relays", null), + FavoriteAlgoFeedsListEvent.KIND to KindName("Favorite Feeds", "51"), + GoodWikiAuthorListEvent.KIND to KindName("Wiki Authors", "51"), + GoodWikiRelayListEvent.KIND to KindName("Wiki Relays", "51"), + UserGraspListEvent.KIND to KindName("GRASP Servers", "34"), + BirdexEvent.KIND to KindName("Birdex", null), + NwcInfoEvent.KIND to KindName("NWC Info", "47"), + RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"), + RootSiteEvent.KIND to KindName("Website Root", "5A"), + RootNappletEvent.KIND to KindName("Napplet Root", "5D"), + CashuWalletEvent.KIND to KindName("Cashu Wallet", "60"), + OfferEvent.KIND to KindName("CLINK Offer", null), + DebitEvent.KIND to KindName("CLINK Debit", null), + ManageEvent.KIND to KindName("CLINK Manage", null), + NwcNotificationEvent.KIND to KindName("NWC Notification", "47"), + CallOfferEvent.KIND to KindName("Call Offer", "AC"), + CallAnswerEvent.KIND to KindName("Call Answer", "AC"), + CallIceCandidateEvent.KIND to KindName("Call ICE Candidate", "AC"), + CallHangupEvent.KIND to KindName("Call Hangup", "AC"), + CallRejectEvent.KIND to KindName("Call Reject", "AC"), + CallRenegotiateEvent.KIND to KindName("Call Renegotiate", "AC"), + RelayJoinRequestEvent.KIND to KindName("Relay Join Request", "43"), + RelayInviteRequestEvent.KIND to KindName("Relay Invite Request", "43"), + RelayLeaveRequestEvent.KIND to KindName("Relay Leave Request", "43"), + ArticleCurationSetEvent.KIND to KindName("Article Sets", "51"), + VideoCurationSetEvent.KIND to KindName("Video Sets", "51"), + PictureCurationSetEvent.KIND to KindName("Picture Sets", "51"), + KindMuteSetEvent.KIND to KindName("Kind Mute Sets", "51"), + InterestSetEvent.KIND to KindName("Interest Sets", "51"), + StallEvent.KIND to KindName("Marketplace Stall", "15"), + ProductEvent.KIND to KindName("Marketplace Product", "15"), + MarketplaceEvent.KIND to KindName("Marketplace", "15"), + AuctionEvent.KIND to KindName("Marketplace Auction", "15"), + ReleaseArtifactSetEvent.KIND to KindName("Release Artifacts", "51"), + AppCurationSetEvent.KIND to KindName("App Sets", "51"), + EventAssertionEvent.KIND to KindName("Event Assertion", "85"), + AddressableAssertionEvent.KIND to KindName("Addressable Assertion", "85"), + ExternalIdAssertionEvent.KIND to KindName("External ID Assertion", "85"), + KeyPackageEvent.KIND to KindName("MLS KeyPackage", null), + GitRepositoryStateEvent.KIND to KindName("Git Repo State", "34"), + FeedDefinitionEvent.KIND to KindName("Feed Definition", null), + SoftwareApplicationEvent.KIND to KindName("Software Application", "82"), + ExerciseTemplateEvent.KIND to KindName("Exercise Template", null), + FundraiserEvent.KIND to KindName("Fundraiser", null), + CommunityRulesEvent.KIND to KindName("Community Rules", "72"), + NamedSiteEvent.KIND to KindName("Website", "5A"), + NamedNappletEvent.KIND to KindName("Napplet", "5D"), + MintRecommendationEvent.KIND to KindName("Mint Recommendation", "87"), + CashuMintEvent.KIND to KindName("Cashu Mint", "87"), + FedimintEvent.KIND to KindName("Fedimint", "87"), + P2POrderEvent.KIND to KindName("P2P Order", "69"), + GroupMetadataEvent.KIND to KindName("Group Metadata", "29"), + GroupAdminsEvent.KIND to KindName("Group Admins", "29"), + GroupMembersEvent.KIND to KindName("Group Members", "29"), + SupportedRolesEvent.KIND to KindName("Group Roles", "29"), + MediaStarterPackEvent.KIND to KindName("Media Starter Pack", "51"), ) fun infoFor(kind: Int): KindName? = names[kind] From fc7db088b24077199fc513d6f91556cdf8eb3611 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:17:04 +0000 Subject: [PATCH 17/26] refactor(commons): extract CashuWalletOps to commons for amy reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the NIP-60/61 wallet orchestration layer (CashuWalletOps + its result types: TokenEntry, MintQuoteStarted, MintCompleted, MeltCompleted, SendTokenCompleted, RedeemCompleted, NutzapSent, RestoreOutcome, MigrationResult, CreatedWallet, describeMintError) out of amethyst into commons/jvmAndroid/cashu/ops. The class already had zero Android dependencies — it takes signer, publish, okHttpClient, secretFactory, and the NUT-13 counter callbacks as constructor params. It lands in the jvmAndroid source set (not commonMain) because it composes quartz's jvmAndroid CashuMintOperations/MintHttpClient and uses ConcurrentHashMap. Both Android (amethyst) and the JVM CLI (amy) can now drive the exact same wallet code path. This is Extraction B of cli/plans/2026-05-28-cashu-cli.md. Android callers (CashuWalletState, the wallet ViewModels, AccountViewModel) updated to the new package; no behavioral change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../amethyst/model/nip60Cashu/CashuWalletState.kt | 7 +++++++ .../amethyst/service/cashu/melt/MeltProcessor.kt | 2 +- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 7 +++---- .../ui/screen/loggedIn/wallet/CashuWalletViewModel.kt | 8 ++++---- .../ui/screen/loggedIn/wallet/ReloadMintViewModel.kt | 2 +- .../ui/screen/loggedIn/wallet/TopUpMintViewModel.kt | 4 ++-- .../amethyst/commons/cashu/ops}/CashuWalletOps.kt | 2 +- 7 files changed, 19 insertions(+), 13 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops}/CashuWalletOps.kt (99%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 938dccf661..6279e1b4ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -20,6 +20,13 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted +import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent +import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome +import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState import com.vitorpamplona.amethyst.model.AccountSettings diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index 48724428b3..d9d13813de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -35,7 +35,7 @@ import kotlin.coroutines.cancellation.CancellationException * via NUT-05 melt (`POST /v1/melt/quote/bolt11` + `POST /v1/melt/bolt11`). * * This is the "Redeem" button on a received cashu token preview — distinct - * from the NIP-60 wallet's own send-LN flow ([com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.meltToLightning]), + * from the NIP-60 wallet's own send-LN flow ([com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps.meltToLightning]), * which spends the user's stored proofs and rolls change back into the wallet. * Here there is no wallet: the proofs come straight off the pasted token and * any leftover stays with the mint. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 43c295b649..966455a274 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -1009,8 +1010,7 @@ class AccountViewModel( } catch (e: Exception) { onError( stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), - com.vitorpamplona.amethyst.model.nip60Cashu - .describeMintError(e), + describeMintError(e), baseNote.author, ) } @@ -1042,8 +1042,7 @@ class AccountViewModel( if (e is CancellationException) throw e onError( stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), - com.vitorpamplona.amethyst.model.nip60Cashu - .describeMintError(e), + describeMintError(e), getUserIfExists(recipientPubKey), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 0d0cd0ce79..fbdd93f772 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.MintQuoteStarted +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState -import com.vitorpamplona.amethyst.model.nip60Cashu.MintQuoteStarted -import com.vitorpamplona.amethyst.model.nip60Cashu.TokenEntry -import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 3fcb017ec3..51db1320fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState -import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index 5a73201124..cd6e902cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState -import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod @@ -47,7 +47,7 @@ import kotlinx.coroutines.launch * * Reuses the same funding primitives as [ReloadMintViewModel] — * [CashuWalletState.rebalance] for a mint-to-mint move and - * [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.startMintFromLightning] / + * [com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps.startMintFromLightning] / * `completeMintFromLightning` for an LN top-up — but without any of the * zap-specific machinery (recipient, shared-mint intersection, fixed send * amount + shortfall, terminal nutzap, fund-then-send atomicity). The user diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index bd8472aa6a..11aa8484ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -18,7 +18,7 @@ * 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.model.nip60Cashu +package com.vitorpamplona.amethyst.commons.cashu.ops import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event From d111c2589d3bb175ad6cee2ecbb5b2d6a7ce466c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:20:25 +0000 Subject: [PATCH 18/26] refactor(commons): extract CashuWalletReader projection for amy reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure, stateless CashuWalletReader in commons that projects a stream of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events, decrypted mints, unspent token entries, history, pending quotes, nutzaps, recommendations, plus balance + per-mint balances). Android's CashuWalletState keeps its incremental dirty-tracking and StateFlow plumbing but now delegates the two tricky computations — del-rollover over decrypted tokens (computeUnspent) and the destroyed/expired pending-quote filter (computePending) — to the shared reader instead of carrying its own copies. amy will call project() once per command over its event store. Extraction C of cli/plans/2026-05-28-cashu-cli.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../model/nip60Cashu/CashuWalletState.kt | 41 +--- .../commons/cashu/CashuWalletReader.kt | 190 ++++++++++++++++++ 2 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 6279e1b4ab..fdb2580d04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu +import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent @@ -52,7 +53,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -719,44 +719,13 @@ class CashuWalletState( } } - // Apply `del` rollover. - val deletedIds = mutableSetOf() - all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) } - - val unspent = - all - .filter { it.id !in deletedIds && tokenContents.containsKey(it.id) } - .mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } } - .sortedByDescending { it.event.createdAt } - - _tokenEntries.value = unspent + // Shared del-rollover + sort with the headless reader. + _tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents) } private fun recomputePending() { - val now = TimeUtils.now() - // A quote is "pending" if (1) not expired, and (2) no kind:7376 history - // event references its id with a "destroyed" marker — completion of the - // mint flow deletes the kind:7374, and history records a `destroyed` - // reference to the now-fulfilled quote. - val destroyedQuoteIds = - historyEvents.values - .asSequence() - .flatMap { it.tags.asSequence() } - .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } - .map { it[1] } - .toSet() - - _pendingQuotes.value = - quoteEvents.values - .filter { it.id !in destroyedQuoteIds } - .filter { evt -> - val exp = - evt.tags - .firstOrNull { it.size >= 2 && it[0] == "expiration" } - ?.get(1) - ?.toLongOrNull() - exp == null || exp > now - }.sortedByDescending { it.createdAt } + // Shared destroyed/expired filter with the headless reader. + _pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values) } private fun scanCacheForOwnEvents(): List { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt new file mode 100644 index 0000000000..1ea5966ad5 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt @@ -0,0 +1,190 @@ +/* + * 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.commons.cashu + +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Pure, stateless projection of a stream of NIP-60 / NIP-61 / NIP-87 events + * into a [WalletSnapshot]. This is the read half of the wallet, extracted out + * of the Android-only `CashuWalletState` so the headless CLI (`amy`) and the + * reactive Android holder run the **same** decrypt + del-rollover + + * pending-quote logic. + * + * - Android's `CashuWalletState` keeps its incremental dirty-tracking and + * StateFlow side effects but delegates the two tricky computations + * ([computeUnspent] and [computePending]) here. + * - `amy` calls [project] once per command over `store.allOfKinds(...)`. + * + * Decryption uses the supplied [signer]; failures are logged and the offending + * event is skipped, exactly as the Android holder does. + */ +class CashuWalletReader( + private val signer: NostrSigner, +) { + data class WalletSnapshot( + val walletEvent: CashuWalletEvent?, + val nutzapInfoEvent: NutzapInfoEvent?, + val mints: List, + val tokenEntries: List, + val history: List, + val pendingQuotes: List, + val nutzapEvents: List, + val recommendations: List, + ) { + /** Total spendable balance in the base unit (sats). */ + val balanceSats: Long get() = tokenEntries.sumOf { it.content.totalAmount() } + + /** Spendable balance grouped by mint URL. */ + val balancesByMint: Map + get() = + tokenEntries + .groupBy { it.content.mint } + .mapValues { (_, byMint) -> byMint.sumOf { it.content.totalAmount() } } + } + + suspend fun project(events: Iterable): WalletSnapshot { + var walletEvent: CashuWalletEvent? = null + var nutzapInfoEvent: NutzapInfoEvent? = null + val tokenEvents = LinkedHashMap() + val historyEvents = LinkedHashMap() + val quoteEvents = LinkedHashMap() + val nutzapEvents = LinkedHashMap() + val recommendationEvents = LinkedHashMap() + + for (event in events) { + when (event) { + is CashuWalletEvent -> + if (walletEvent == null || event.createdAt > walletEvent.createdAt) walletEvent = event + is NutzapInfoEvent -> + if (nutzapInfoEvent == null || event.createdAt > nutzapInfoEvent.createdAt) nutzapInfoEvent = event + is CashuTokenEvent -> tokenEvents.putIfAbsent(event.id, event) + is CashuSpendingHistoryEvent -> historyEvents.putIfAbsent(event.id, event) + is CashuMintQuoteEvent -> quoteEvents.putIfAbsent(event.id, event) + is NutzapEvent -> nutzapEvents.putIfAbsent(event.id, event) + is MintRecommendationEvent -> { + val key = event.dTag() ?: event.id + val current = recommendationEvents[key] + if (current == null || event.createdAt > current.createdAt) recommendationEvents[key] = event + } + else -> Unit + } + } + + val mints = + walletEvent?.let { evt -> + runCatching { evt.mints(signer) } + .onFailure { Log.w("CashuWalletReader") { "Failed to decrypt wallet mints: ${it.message}" } } + .getOrNull() + } ?: emptyList() + + val contents = decryptTokens(tokenEvents.values) + val tokenEntries = computeUnspent(tokenEvents.values, contents) + val pendingQuotes = computePending(quoteEvents.values, historyEvents.values) + + return WalletSnapshot( + walletEvent = walletEvent, + nutzapInfoEvent = nutzapInfoEvent, + mints = mints, + tokenEntries = tokenEntries, + history = historyEvents.values.sortedByDescending { it.createdAt }, + pendingQuotes = pendingQuotes, + nutzapEvents = nutzapEvents.values.sortedByDescending { it.createdAt }, + recommendations = recommendationEvents.values.sortedByDescending { it.createdAt }, + ) + } + + /** Decrypt every token event's content, skipping (and logging) failures. */ + suspend fun decryptTokens(events: Iterable): Map { + val out = HashMap() + events.forEach { evt -> + val content = + runCatching { evt.tokenContent(signer) } + .onFailure { Log.w("CashuWalletReader") { "Failed to decrypt token ${evt.id.take(8)}: ${it.message}" } } + .getOrNull() + if (content != null) out[evt.id] = content + } + return out + } + + companion object { + /** + * Project decrypted token events into the unspent set: drop anything a + * later token's `del` rollover marked destroyed or that failed to + * decrypt, then sort newest-first. + */ + fun computeUnspent( + events: Iterable, + contents: Map, + ): List { + val all = events.toList() + val deletedIds = mutableSetOf() + all.forEach { evt -> contents[evt.id]?.del?.let(deletedIds::addAll) } + return all + .filter { it.id !in deletedIds && contents.containsKey(it.id) } + .mapNotNull { evt -> contents[evt.id]?.let { TokenEntry(evt, it) } } + .sortedByDescending { it.event.createdAt } + } + + /** + * A quote is pending when it is neither expired nor referenced as + * "destroyed" by a kind:7376 history event (completion of a mint flow + * deletes the kind:7374 and records a destroyed reference to it). + */ + fun computePending( + quotes: Iterable, + history: Iterable, + now: Long = TimeUtils.now(), + ): List { + val destroyedQuoteIds = + history + .asSequence() + .flatMap { it.tags.asSequence() } + .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } + .map { it[1] } + .toSet() + + return quotes + .filter { it.id !in destroyedQuoteIds } + .filter { evt -> + val exp = + evt.tags + .firstOrNull { it.size >= 2 && it[0] == "expiration" } + ?.get(1) + ?.toLongOrNull() + exp == null || exp > now + }.sortedByDescending { it.createdAt } + } + } +} From 8bb537438fc9f9035f8366ce7eef9c9d6004a32f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:30:59 +0000 Subject: [PATCH 19/26] feat(cli): amy cashu wallet/mint/balance on shared NIP-60/61 code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the offline Cashu command tier to amy, all driven by the shared commons wallet code so amy exercises the same path as the Android app: amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2] amy cashu wallet show amy cashu wallet export-key amy cashu wallet destroy amy cashu mint ping URL (stateless) amy cashu mint info URL (stateless) amy cashu balance [--mint URL] - create/destroy reuse commons CashuWalletOps.publishWalletEvents / deleteWallet; show/balance reuse the CashuWalletReader projection over the local event store; mint ping/info hit quartz's MintHttpClient. - Context gains cashuOps() (wired to the file NUT-13 counter store + a per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json. - Extraction D: CashuKeysetCounterStore contract in commons + FileCashuKeysetCounterStore (atomic ~/.amy//cashu.json). PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline tier). receive/send/maintenance/mint-rec + interop harness still pending. Verified end-to-end against mint.minibits.cash and live relays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/ROADMAP.md | 1 + cli/plans/2026-05-28-cashu-cli.md | 17 ++- .../com/vitorpamplona/amethyst/cli/Config.kt | 1 + .../com/vitorpamplona/amethyst/cli/Context.kt | 89 +++++++++++ .../com/vitorpamplona/amethyst/cli/Main.kt | 9 ++ .../cli/commands/cashu/CashuBalanceCommand.kt | 57 +++++++ .../cli/commands/cashu/CashuCommands.kt | 50 ++++++ .../cli/commands/cashu/CashuMintCommands.kt | 82 ++++++++++ .../cli/commands/cashu/CashuWalletCommands.kt | 143 ++++++++++++++++++ .../cli/stores/FileCashuKeysetCounterStore.kt | 70 +++++++++ .../commons/cashu/CashuKeysetCounterStore.kt | 50 ++++++ 11 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 32ca77110e..ebcebc1a53 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -63,6 +63,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Long-form (NIP-23) publish / read | 🆕 | | | Live activities / chess (NIP-53 / NIP-64) | 🆕 | | | Blossom uploads (NIP-B7) | 🆕 | | +| NIP-60 / 61 Cashu wallet + nutzaps | ✅ in part | `amy cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance` shipped — all on shared `commons` `CashuWalletOps` + `CashuWalletReader`. `receive`/`send`/`maintenance`/`mint-rec` (live-mint) pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). | | NIP-47 Wallet Connect | 🆕 | | | NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. | | Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. | diff --git a/cli/plans/2026-05-28-cashu-cli.md b/cli/plans/2026-05-28-cashu-cli.md index 9afe7bee8d..c86c17b41a 100644 --- a/cli/plans/2026-05-28-cashu-cli.md +++ b/cli/plans/2026-05-28-cashu-cli.md @@ -1,8 +1,19 @@ # Cashu (NIP-60 / NIP-61 / NIP-87) in `amy` -**Status:** plan · **Date:** 2026-05-28 · **Roadmap row:** new (no -row today). To be added at the end of the parity matrix in -`cli/ROADMAP.md` once PR 1 lands. +**Status:** in progress · **Date:** 2026-05-28 · **Roadmap row:** added to +the parity matrix in `cli/ROADMAP.md`. + +**Landed so far:** Extraction B (`CashuWalletOps` → `commons/jvmAndroid`), +Extraction C (`CashuWalletReader` → `commons/jvmAndroid`), Extraction D (the +`CashuKeysetCounterStore` contract in `commons` + `FileCashuKeysetCounterStore` +in `cli`), and the offline command tier: `cashu wallet {create, show, +export-key, destroy}`, `cashu mint {ping, info}`, `cashu balance`. Extraction A +(token parsers) turned out to be unnecessary — `V4Encoder`, +`CashuTokenB64Parser`, and friends already live in `quartz`. **Note:** +`CashuWalletOps`/`CashuWalletReader` land in the `jvmAndroid` source set, not +`commonMain` as originally sketched, because they compose quartz's jvmAndroid +`CashuMintOperations`/`MintHttpClient`. **Still pending:** `receive`, `send`, +`receive nutzap-sweep`, `maintenance`, `mint-rec`, and the interop harness. ## Why diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index c3bc468c76..dd7c0fa984 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -208,6 +208,7 @@ class DataDir( val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") val aliasesFile = File(root, "aliases.json") + val cashuFile = File(root, "cashu.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index e734f17af0..889c363698 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -20,9 +20,12 @@ */ package com.vitorpamplona.amethyst.cli +import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager @@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -52,7 +56,16 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -162,6 +175,82 @@ class Context( /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) + // ------------------------------------------------------------------ + // Cashu (NIP-60 / NIP-61) — shared wallet code from commons + // ------------------------------------------------------------------ + + /** Durable NUT-13 counter store at `/cashu.json`. */ + private val cashuCounters by lazy { FileCashuKeysetCounterStore(dataDir.cashuFile) } + + @Volatile private var cachedCashuSeed: ByteArray? = null + + /** + * Decrypt the wallet's NUT-13 seed once per run and cache it. The + * [DeterministicSecretFactory] thunk reads this synchronously, so any + * mint/swap op must warm it first (CashuWalletOps' seedWarmer does). + */ + private suspend fun warmCashuSeed() { + if (cachedCashuSeed != null) return + val priv = cashuSnapshot().walletEvent?.let { runCatching { it.privkey(signer) }.getOrNull() } ?: return + cachedCashuSeed = CashuDeterministic.deriveWalletSeed(priv.hexToByteArray()) + } + + /** + * Wallet operations driven by the exact same `commons` [CashuWalletOps] + * the Android app uses. Wired to publish on the account's outbox relays, + * the shared OkHttp instance for mint HTTP, and the file-backed NUT-13 + * counter store. + */ + fun cashuOps(): CashuWalletOps = + CashuWalletOps( + signer = signer, + publish = { event -> publish(event, outboxRelays()) }, + okHttpClient = { okhttp }, + secretFactory = + DeterministicSecretFactory( + seedProvider = { cachedCashuSeed }, + reserveCounters = { keysetId, count -> cashuCounters.reserve(keysetId, count) }, + ), + seedWarmer = { warmCashuSeed() }, + seedForRestore = { + warmCashuSeed() + cachedCashuSeed + }, + peekCashuCounter = { keysetId -> cashuCounters.peek(keysetId) }, + reserveCashuCounters = { keysetId, count -> cashuCounters.reserve(keysetId, count) }, + ) + + /** + * Project this account's locally-stored NIP-60/61/87 events into a wallet + * snapshot via the shared [CashuWalletReader] — the same decrypt + + * del-rollover + pending-quote logic the Android holder runs. Reads the + * cache only; commands that need fresh state should [drain] first. + */ + suspend fun cashuSnapshot(): CashuWalletReader.WalletSnapshot { + val pk = identity.pubKeyHex + val authored = + store.query( + Filter( + authors = listOf(pk), + kinds = + listOf( + CashuWalletEvent.KIND, + NutzapInfoEvent.KIND, + CashuTokenEvent.KIND, + CashuSpendingHistoryEvent.KIND, + CashuMintQuoteEvent.KIND, + NutzapEvent.KIND, + MintRecommendationEvent.KIND, + ), + ), + ) + val inboundNutzaps = + store.query( + Filter(kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pk))), + ) + return CashuWalletReader(signer).project(authored + inboundNutzaps) + } + private var prepared = false /** 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 5cda741f43..eeaa80a898 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand import com.vitorpamplona.amethyst.cli.commands.VerifyCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand +import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands +import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands import com.vitorpamplona.amethyst.cli.commands.route import com.vitorpamplona.amethyst.cli.secrets.SecretStore import kotlinx.coroutines.runBlocking @@ -183,6 +185,12 @@ private suspend fun dispatch(argv: Array): Int { return RelayCommands.info(tail.drop(1).toTypedArray()) } + // `cashu mint ping|info URL` is a stateless NIP-60 /v1/info probe — no + // account, no relays. The rest of `cashu …` operates on the account. + if (head == "cashu" && tail.firstOrNull() == "mint") { + return CashuMintCommands.dispatch(tail.drop(1).toTypedArray()) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) @@ -217,6 +225,7 @@ private suspend fun dispatch(argv: Array): Int { "blossom" -> BlossomCommands.dispatch(dataDir, tail) "sync" -> SyncCommand.run(dataDir, tail) "git" -> GitCommands.dispatch(dataDir, tail) + "cashu" -> CashuCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) else -> { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt new file mode 100644 index 0000000000..881574d7ca --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt @@ -0,0 +1,57 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy cashu balance [--mint URL]` — spendable balance from the local store, + * via the shared CashuWalletReader projection. Optionally filtered to one mint. + */ +object CashuBalanceCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + val snap = ctx.cashuSnapshot() + val byMint = + snap.balancesByMint.let { all -> + if (mintFilter == null) all else all.filterKeys { it.trimEnd('/') == mintFilter } + } + Output.emit( + mapOf( + "balance_sats" to byMint.values.sum(), + "balances_by_mint" to byMint, + "proofs_count" to + snap.tokenEntries + .filter { mintFilter == null || it.content.mint.trimEnd('/') == mintFilter } + .sumOf { it.content.proofs.size }, + ), + ) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt new file mode 100644 index 0000000000..c59c1489f5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -0,0 +1,50 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.commands.route + +/** + * `amy cashu …` — NIP-60 Cashu wallet + NIP-61 nutzaps, driven entirely + * through the shared `commons` wallet code (CashuWalletOps + CashuWalletReader) + * so amy exercises the exact path the Android app runs. + * + * See `cli/plans/2026-05-28-cashu-cli.md` for the full command surface and the + * stable `--json` contract. + */ +object CashuCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu", + tail = tail, + usage = "cashu ", + routes = + mapOf( + "wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) }, + "mint" to { rest -> CashuMintCommands.dispatch(rest) }, + "balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) }, + ), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt new file mode 100644 index 0000000000..687e1fee50 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt @@ -0,0 +1,82 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException +import okhttp3.OkHttpClient + +/** + * `amy cashu mint URL` — stateless NIP-60 mint /v1/info probes. + * No account or relays; talks straight to the mint over HTTP. + */ +object CashuMintCommands { + suspend fun dispatch(tail: Array): Int = + route( + name = "cashu mint", + tail = tail, + usage = "cashu mint URL", + routes = + mapOf( + "ping" to { rest -> ping(rest) }, + "info" to { rest -> info(rest) }, + ), + ) + + private val okhttp = OkHttpClient.Builder().build() + + private suspend fun ping(rest: Array): Int { + val url = Args(rest).positional(0, "mint-url") + return try { + val dto = MintHttpClient(url) { okhttp }.info() + Output.emit( + mapOf( + "mint_url" to url, + "name" to dto.name, + "pubkey" to dto.pubkey, + "version" to dto.version, + "description" to dto.description, + ), + ) + 0 + } catch (e: MintHttpException) { + Output.error("mint_http_${e.code}", e.message) + } catch (e: Exception) { + Output.error("mint_unreachable", e.message) + } + } + + private suspend fun info(rest: Array): Int { + val url = Args(rest).positional(0, "mint-url") + return try { + val dto = MintHttpClient(url) { okhttp }.info(force = true) + Output.emit(mapOf("mint_url" to url, "mint_info" to dto)) + 0 + } catch (e: MintHttpException) { + Output.error("mint_http_${e.code}", e.message) + } catch (e: Exception) { + Output.error("mint_unreachable", e.message) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt new file mode 100644 index 0000000000..19efab438f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt @@ -0,0 +1,143 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * `amy cashu wallet `. + * + * create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2] + * show + * export-key + * destroy + */ +object CashuWalletCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu wallet", + tail = tail, + usage = "cashu wallet ", + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "export-key" to { rest -> exportKey(dataDir, rest) }, + "destroy" to { rest -> destroy(dataDir, rest) }, + ), + ) + + private fun Args.csv(key: String): List = + flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val mints = (args.csv("mint") + args.csv("mints") + args.positional.toList()).distinct() + if (mints.isEmpty()) return Output.error("bad_args", "at least one --mint URL is required") + val privkey = args.flag("privkey") + val nutzapRelays = args.csv("relay").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val created = + try { + ctx.cashuOps().publishWalletEvents(mints, privkey, nutzapRelays) + } catch (e: IllegalArgumentException) { + return Output.error("bad_args", e.message) + } + Output.emit( + mapOf( + "wallet_event_id" to created.walletEvent.id, + "nutzap_info_event_id" to created.nutzapInfo.id, + "p2pk_pubkey" to created.p2pkPubkeyHex, + "mints" to mints, + ), + ) + } + return 0 + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + val snap = ctx.cashuSnapshot() + if (snap.walletEvent == null) return Output.error("no_wallet", "no kind:17375 wallet in the local store — run `cashu wallet create`") + Output.emit( + mapOf( + "p2pk_pubkey" to snap.nutzapInfoEvent?.p2pkPubkey(), + "mints" to snap.mints, + "balance_sats" to snap.balanceSats, + "balances_by_mint" to snap.balancesByMint, + "proofs_count" to snap.tokenEntries.sumOf { it.content.proofs.size }, + "history_count" to snap.history.size, + "pending_quotes" to snap.pendingQuotes.size, + ), + ) + } + return 0 + } + + private suspend fun exportKey( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + val wallet = ctx.cashuSnapshot().walletEvent ?: return Output.error("no_wallet", "no wallet to export a key from") + val priv = + runCatching { wallet.privkey(ctx.signer) }.getOrNull() + ?: return Output.error("signer_error", "could not decrypt the wallet P2PK key") + Output.emit(mapOf("privkey_hex" to priv)) + } + return 0 + } + + private suspend fun destroy( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + ctx.prepare() + val wallet = ctx.cashuSnapshot().walletEvent ?: return Output.error("no_wallet", "no wallet to destroy") + // Withdraws the nutzap advertisement and NIP-09 deletes the + // kind:17375; leaves token events (the ecash still lives at the mint). + ctx.cashuOps().deleteWallet(wallet) + Output.emit(mapOf("destroyed_wallet_event_id" to wallet.id)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt new file mode 100644 index 0000000000..c561cf2b10 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt @@ -0,0 +1,70 @@ +/* + * 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.stores + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.SecureFileIO +import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore +import java.io.File + +/** + * File-backed NUT-13 counter store for `amy`, persisted at + * `~/.amy//cashu.json` as `{ "keyset_counters": { "": } }`. + * + * [reserve] is the durability-critical path: it bumps the counter and + * rewrites the file **atomically** (tempfile + rename via [SecureFileIO]) + * before returning, so a crash after a swap can never replay a counter and + * trip the mint's `outputs already signed`. Access is serialized on the + * instance — amy is single-process, but a `synchronized` block keeps two + * concurrent mint ops within one run safe. + */ +class FileCashuKeysetCounterStore( + private val file: File, +) : CashuKeysetCounterStore { + private val mapper = jacksonObjectMapper() + private val lock = Any() + + private data class Persisted( + val keyset_counters: MutableMap = mutableMapOf(), + ) + + private fun load(): Persisted = + if (file.exists()) { + runCatching { mapper.readValue(file.readText()) }.getOrDefault(Persisted()) + } else { + Persisted() + } + + override fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } + + override fun reserve( + keysetId: String, + count: Int, + ): Long = + synchronized(lock) { + val state = load() + val first = state.keyset_counters[keysetId] ?: 0L + state.keyset_counters[keysetId] = first + count.coerceAtLeast(0) + SecureFileIO.writeTextAtomic(file, mapper.writeValueAsString(state)) + first + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt new file mode 100644 index 0000000000..1da6f2855e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt @@ -0,0 +1,50 @@ +/* + * 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.commons.cashu + +/** + * Durable NUT-13 deterministic-secret counter store, keyed by mint keyset id. + * + * Cashu NUT-13 derives blinded secrets from the wallet seed plus a per-keyset + * monotonically increasing counter. Reusing a counter makes the mint reply + * `outputs already signed`, so every reservation MUST be persisted **before** + * the blinded outputs hit the mint. Implementations therefore make + * [reserve] atomic and durable. + * + * - Android backs this with `AccountSettings` / `CashuPreferences`. + * - `amy` backs this with `~/.amy//cashu.json`. + * + * `CashuWalletOps` consumes the two operations as plain function references + * ([peek] / [reserve]); this interface gives both hosts one named contract. + */ +interface CashuKeysetCounterStore { + /** The next counter for [keysetId] without advancing it (0 if unseen). */ + fun peek(keysetId: String): Long + + /** + * Atomically reserve [count] consecutive counters for [keysetId] and + * return the first reserved index. Persists before returning. + */ + fun reserve( + keysetId: String, + count: Int, + ): Long +} From 72a7f59d140ab06b11d6185e052fd02ba5f7a244 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 23:54:48 +0000 Subject: [PATCH 20/26] fix(cli): align amy cashu publish + nutzap relays with Amethyst Two behavioral divergences from the Android wallet, found while auditing for parity: - Publish targets: Amethyst sends every cashu event via sendLiterallyEverywhere (all the user's relays). amy published only to outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the CLI's closest analog, so the wallet lands on the same broad relay set. - Nutzap relays on create: Amethyst always advertises the account's outbox relays in kind:10019 (so senders publish nutzaps where the user reads). amy defaulted to none; default to outboxRelays() unless --relay overrides. Also align cashuSnapshot()'s store query with commons' CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk], inbound nutzaps by #p) so amy projects the same event set the app subscribes to. Verified the emitted kind:10019 now carries relay/mint/ pubkey tags identical to NutzapInfoEvent.build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../kotlin/com/vitorpamplona/amethyst/cli/Context.kt | 12 +++++++++--- .../cli/commands/cashu/CashuWalletCommands.kt | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 889c363698..99771c6420 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -204,7 +204,11 @@ class Context( fun cashuOps(): CashuWalletOps = CashuWalletOps( signer = signer, - publish = { event -> publish(event, outboxRelays()) }, + // Amethyst publishes cashu events via sendLiterallyEverywhere + // (all the user's relays). anyRelays() — outbox + inbox + + // keypackage — is the CLI's closest analog, so the wallet lands + // on the same broad relay set the app would use, not just outbox. + publish = { event -> publish(event, anyRelays()) }, okHttpClient = { okhttp }, secretFactory = DeterministicSecretFactory( @@ -228,6 +232,9 @@ class Context( */ suspend fun cashuSnapshot(): CashuWalletReader.WalletSnapshot { val pk = identity.pubKeyHex + // Mirror commons' CashuWalletFilterAssembler exactly: authored wallet + // kinds by authors=[pk], inbound nutzaps by #p — so amy projects the + // same event set the Android app subscribes to. val authored = store.query( Filter( @@ -235,11 +242,10 @@ class Context( kinds = listOf( CashuWalletEvent.KIND, - NutzapInfoEvent.KIND, CashuTokenEvent.KIND, CashuSpendingHistoryEvent.KIND, CashuMintQuoteEvent.KIND, - NutzapEvent.KIND, + NutzapInfoEvent.KIND, MintRecommendationEvent.KIND, ), ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt index 19efab438f..3d0a9d32d2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt @@ -68,10 +68,14 @@ object CashuWalletCommands { val mints = (args.csv("mint") + args.csv("mints") + args.positional.toList()).distinct() if (mints.isEmpty()) return Output.error("bad_args", "at least one --mint URL is required") val privkey = args.flag("privkey") - val nutzapRelays = args.csv("relay").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + val explicitRelays = args.csv("relay").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } Context.open(dataDir).use { ctx -> ctx.prepare() + // Match Amethyst: kind:10019 advertises the account's outbox + // relays as nutzap-receiving relays unless the caller overrides + // with --relay, so senders publish nutzaps where we actually read. + val nutzapRelays = explicitRelays.ifEmpty { ctx.outboxRelays().toList() } val created = try { ctx.cashuOps().publishWalletEvents(mints, privkey, nutzapRelays) From 42a82c7e8b00f17af5e0b33c2c8eee9a14cde9b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:10:40 +0000 Subject: [PATCH 21/26] feat(cli): amy cashu receive/send/maintenance/mint-rec tiers Complete the Cashu command surface, every verb a thin wrapper over the shared commons CashuWalletOps the Android wallet runs: cashu receive ln SATS [--mint] start mint, return bolt11 + kind:7374 cashu receive complete QUOTE_ID poll mint; on settle, mint proofs cashu receive resume QUOTE_ID alias of complete cashu receive token TOKEN redeem a cashuB token cashu receive nutzap-sweep [--mint] redeem inbound NIP-61 nutzaps cashu send ln INVOICE [--mint] melt to a bolt11 (scrubs first) cashu send token SATS [--mint --memo] export a cashuB token (scrubs first) cashu send nutzap USER SATS [--zapped --message] P2PK-locked nutzap cashu maintenance scrub [--mint] NUT-07 + NIP-09 prune spent proofs cashu maintenance restore MINT_URL NUT-09 restore from seed cashu maintenance migrate-keysets [--mint] consolidate onto active keyset cashu mint-rec show [--author] / add URL [--dtag --review] / remove ID - scrubStaleProofs extracted into CashuWalletOps so Android's CashuWalletState.scrubLocallyStaleProofs and amy share one impl. - Context.cashuRestore mirrors CashuWalletState.restoreFromMint (seed + NUT-13 counter bump); Context.cashuSeed warms the per-run seed. - receive complete recovers the mint amount by decoding the quote's bolt11 (kind:7374 stores only the quote id), so it works statelessly. Verified against mint.minibits.cash + live relays: receive ln returns a real invoice, complete/resume poll a pending quote, mint-rec round-trips, and every error path (insufficient_funds, no_mint, mint_quote_gone) is clean. Happy-path mint/melt completions need a payable bolt11 (interop harness, PR 9). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../model/nip60Cashu/CashuWalletState.kt | 38 +--- cli/ROADMAP.md | 2 +- cli/plans/2026-05-28-cashu-cli.md | 11 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 27 +++ .../cli/commands/cashu/CashuCommands.kt | 6 +- .../cashu/CashuMaintenanceCommands.kt | 134 ++++++++++++ .../commands/cashu/CashuMintRecCommands.kt | 117 ++++++++++ .../commands/cashu/CashuReceiveCommands.kt | 198 +++++++++++++++++ .../cli/commands/cashu/CashuSendCommands.kt | 202 ++++++++++++++++++ .../commons/cashu/ops/CashuWalletOps.kt | 27 +++ 10 files changed, 730 insertions(+), 32 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index fdb2580d04..3018ddb4f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -43,7 +43,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto -import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -1118,38 +1117,21 @@ class CashuWalletState( .groupBy { it.content.mint } .filterKeys { mintUrlFilter == null || it == mintUrlFilter } for ((mintUrl, entries) in byMint) { - val allProofs = entries.flatMap { it.content.proofs } - if (allProofs.isEmpty()) continue - val states = - runCatching { ops.checkProofStates(mintUrl, allProofs) } + // Shared NUT-07 check + NIP-09 delete with amy's `cashu maintenance + // scrub`. Returns the stale token events it published a deletion for. + val staleEvents = + runCatching { ops.scrubStaleProofs(mintUrl, entries) } .onFailure { - Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it) + Log.w("CashuWallet", "scrubStaleProofs($mintUrl) failed; skipping sweep", it) }.getOrNull() ?: continue - - // Any entry with at least one SPENT proof gets purged. Keeping - // mixed-state entries around would let the next send pick them - // and trip the same HTTP 400 we're trying to prevent. - val staleEntries = - entries.filter { entry -> - entry.content.proofs.any { states[it.secret] == ProofState.SPENT } - } - if (staleEntries.isEmpty()) continue + if (staleEvents.isEmpty()) continue Log.i("CashuWallet") { - "Scrubbing ${staleEntries.size} stale kind:7375 event(s) at $mintUrl" + "Scrubbing ${staleEvents.size} stale kind:7375 event(s) at $mintUrl" } - val staleIds = staleEntries.map { it.event.id }.toSet() - runCatching { - val template = DeletionEvent.build(staleEntries.map { it.event }) - val signed = signer.sign(template) - publishEvent(signed) - }.onFailure { - Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it) - } - // Drop from internal indexes regardless of publish success — even - // if the kind:5 didn't go out, we know these proofs are unusable - // and shouldn't be selected for the next swap. - removeEvents(staleIds) + // Drop from internal indexes — even if the kind:5 didn't reach a + // relay, these proofs are unusable and must not be re-selected. + removeEvents(staleEvents.map { it.id }.toSet()) } } diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index ebcebc1a53..24e37a2ff2 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -63,7 +63,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Long-form (NIP-23) publish / read | 🆕 | | | Live activities / chess (NIP-53 / NIP-64) | 🆕 | | | Blossom uploads (NIP-B7) | 🆕 | | -| NIP-60 / 61 Cashu wallet + nutzaps | ✅ in part | `amy cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance` shipped — all on shared `commons` `CashuWalletOps` + `CashuWalletReader`. `receive`/`send`/`maintenance`/`mint-rec` (live-mint) pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). | +| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `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). Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). | | NIP-47 Wallet Connect | 🆕 | | | NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. | | Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. | diff --git a/cli/plans/2026-05-28-cashu-cli.md b/cli/plans/2026-05-28-cashu-cli.md index c86c17b41a..12886f8244 100644 --- a/cli/plans/2026-05-28-cashu-cli.md +++ b/cli/plans/2026-05-28-cashu-cli.md @@ -12,8 +12,15 @@ export-key, destroy}`, `cashu mint {ping, info}`, `cashu balance`. Extraction A `CashuTokenB64Parser`, and friends already live in `quartz`. **Note:** `CashuWalletOps`/`CashuWalletReader` land in the `jvmAndroid` source set, not `commonMain` as originally sketched, because they compose quartz's jvmAndroid -`CashuMintOperations`/`MintHttpClient`. **Still pending:** `receive`, `send`, -`receive nutzap-sweep`, `maintenance`, `mint-rec`, and the interop harness. +`CashuMintOperations`/`MintHttpClient`. The full command surface now ships — +`receive {ln, complete, resume, token, nutzap-sweep}`, `send {ln, token, +nutzap}`, `maintenance {scrub, restore, migrate-keysets}`, `mint-rec {show, add, +remove}` — each a thin wrapper over a shared `CashuWalletOps` method. +`scrubStaleProofs` was extracted into `CashuWalletOps` so Android and amy prune +identically; `Context.cashuRestore` mirrors `CashuWalletState.restoreFromMint` +(seed + NUT-13 counter bump). **Still pending:** the interop harness (PR 9) — +the happy-path mint/melt/send completions need a payable bolt11 to exercise +end-to-end. ## Why diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 99771c6420..f135e6b407 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager @@ -257,6 +258,32 @@ class Context( return CashuWalletReader(signer).project(authored + inboundNutzaps) } + /** Warm and return the wallet's NUT-13 seed, or null if no wallet. */ + suspend fun cashuSeed(): ByteArray? { + warmCashuSeed() + return cachedCashuSeed + } + + /** + * NUT-09 restore for one mint, mirroring Android's + * `CashuWalletState.restoreFromMint`: re-derive proofs from the seed + * (skipping secrets we already hold), then bump the persisted NUT-13 + * counter past every slot the scan confirmed in use so later mints can't + * reuse one. Returns null when the wallet has no seed yet. + */ + suspend fun cashuRestore(mintUrl: String): RestoreOutcome? { + val seed = cashuSeed() ?: return null + val existingSecrets = + cashuSnapshot() + .tokenEntries + .flatMap { it.content.proofs } + .mapTo(HashSet()) { it.secret } + val outcome = cashuOps().restoreFromMint(mintUrl = mintUrl, seed = seed, startCounter = 0L, existingSecrets = existingSecrets) + val delta = (outcome.nextCounterAfterScan - cashuCounters.peek(outcome.keysetId)).coerceAtLeast(0L) + if (delta > 0) cashuCounters.reserve(outcome.keysetId, delta.toInt()) + return outcome + } + private var prepared = false /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt index c59c1489f5..877e99c222 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -39,12 +39,16 @@ object CashuCommands { route( name = "cashu", tail = tail, - usage = "cashu ", + usage = "cashu ", routes = mapOf( "wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) }, "mint" to { rest -> CashuMintCommands.dispatch(rest) }, "balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) }, + "receive" to { rest -> CashuReceiveCommands.dispatch(dataDir, rest) }, + "send" to { rest -> CashuSendCommands.dispatch(dataDir, rest) }, + "maintenance" to { rest -> CashuMaintenanceCommands.dispatch(dataDir, rest) }, + "mint-rec" to { rest -> CashuMintRecCommands.dispatch(dataDir, rest) }, ), ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt new file mode 100644 index 0000000000..274e83d60a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt @@ -0,0 +1,134 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route + +/** + * `amy cashu maintenance ` — wallet hygiene, + * all on the shared commons CashuWalletOps. + */ +object CashuMaintenanceCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu maintenance", + tail = tail, + usage = "cashu maintenance ", + routes = + mapOf( + "scrub" to { rest -> scrub(dataDir, rest) }, + "restore" to { rest -> restore(dataDir, rest) }, + "migrate-keysets" to { rest -> migrate(dataDir, rest) }, + ), + ) + + private suspend fun scrub( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val byMint = + ctx + .cashuSnapshot() + .tokenEntries + .groupBy { it.content.mint } + .filterKeys { mintFilter == null || it.trimEnd('/') == mintFilter } + val scrubbed = mutableListOf>() + var keptCount = 0 + for ((mint, entries) in byMint) { + val staleEvents = runCatching { ctx.cashuOps().scrubStaleProofs(mint, entries) }.getOrDefault(emptyList()) + val staleIds = staleEvents.map { it.id }.toSet() + entries.filter { it.event.id in staleIds }.forEach { + scrubbed.add(mapOf("event_id" to it.event.id, "amount_sats" to it.content.totalAmount(), "mint_url" to mint)) + } + keptCount += entries.count { it.event.id !in staleIds } + } + Output.emit(mapOf("scrubbed" to scrubbed, "kept_count" to keptCount)) + } + return 0 + } + + private suspend fun restore( + dataDir: DataDir, + rest: Array, + ): Int { + val mint = Args(rest).positional(0, "mint-url").trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + return try { + val outcome = ctx.cashuRestore(mint) ?: return Output.error("no_wallet", "no wallet seed to restore from") + Output.emit( + mapOf( + "mint_url" to mint, + "sats_recovered" to outcome.amountRecoveredSats, + "proofs_recovered" to outcome.proofsRecovered, + "token_event_id" to outcome.tokenEvent?.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", e.message ?: "restore failed") + } + } + } + + private suspend fun migrate( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val byMint = + ctx + .cashuSnapshot() + .tokenEntries + .groupBy { it.content.mint } + .filterKeys { mintFilter == null || it.trimEnd('/') == mintFilter } + val migrated = mutableListOf>() + for ((mint, entries) in byMint) { + val activeId = runCatching { ctx.cashuOps().fetchActiveKeysetId(mint) }.getOrNull() ?: continue + val stale = entries.filter { entry -> entry.content.proofs.any { it.id != activeId } } + if (stale.isEmpty()) continue + val result = runCatching { ctx.cashuOps().migrateToActiveKeyset(mint, stale, activeId) }.getOrNull() ?: continue + migrated.add( + mapOf( + "mint_url" to mint, + "old_event_ids" to stale.map { it.event.id }, + "amount_sats" to result.amountMigrated, + "proofs_migrated" to result.proofsMigrated, + ), + ) + } + Output.emit(mapOf("migrated" to migrated)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt new file mode 100644 index 0000000000..3f7a7bb6bc --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt @@ -0,0 +1,117 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent + +/** + * `amy cashu mint-rec ` — NIP-87 mint recommendations + * (kind:38000), on the shared commons CashuWalletOps. + */ +object CashuMintRecCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu mint-rec", + tail = tail, + usage = "cashu mint-rec ", + routes = + mapOf( + "show" to { rest -> show(dataDir, rest) }, + "add" to { rest -> add(dataDir, rest) }, + "remove" to { rest -> remove(dataDir, rest) }, + ), + ) + + private fun render(e: MintRecommendationEvent) = + mapOf( + "event_id" to e.id, + "mint_url" to e.mintUrls().firstOrNull(), + "dtag" to e.dTag(), + "review" to e.content, + "pubkey_hex" to e.pubKey, + "created_at" to e.createdAt, + ) + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val author = Args(rest).flag("author") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recs = + if (author == null) { + ctx.cashuSnapshot().recommendations + } else { + val pk = ctx.requireUserHex(author) + val filter = Filter(authors = listOf(pk), kinds = listOf(MintRecommendationEvent.KIND)) + if (ctx.store.query(filter).isEmpty()) ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) }) + ctx.store + .query(filter) + .filterIsInstance() + .sortedByDescending { it.createdAt } + } + Output.emit(mapOf("recommendations" to recs.map { render(it) })) + } + return 0 + } + + private suspend fun add( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val url = args.positional(0, "mint-url").trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val event = ctx.cashuOps().recommendMint(mintUrl = url, mintAnnouncementDTag = args.flag("dtag"), review = args.flag("review") ?: "") + Output.emit(mapOf("event_id" to event.id, "mint_url" to url)) + } + return 0 + } + + private suspend fun remove( + dataDir: DataDir, + rest: Array, + ): Int { + val id = Args(rest).positional(0, "event-id") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val event = + ctx.cashuSnapshot().recommendations.firstOrNull { it.id == id } + ?: (ctx.store.query(Filter(ids = listOf(id), limit = 1)).firstOrNull() as? MintRecommendationEvent) + ?: return Output.error("bad_args", "no recommendation event $id") + ctx.cashuOps().deleteRecommendation(event) + Output.emit(mapOf("deletion_event_id" to id, "deleted" to true)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt new file mode 100644 index 0000000000..f2c52589d2 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -0,0 +1,198 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser + +/** + * `amy cashu receive ` — inbound flows, + * all on the shared commons CashuWalletOps the Android wallet runs. + */ +object CashuReceiveCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu receive", + tail = tail, + usage = "cashu receive ", + routes = + mapOf( + "ln" to { rest -> ln(dataDir, rest) }, + "complete" to { rest -> complete(dataDir, rest) }, + "resume" to { rest -> complete(dataDir, rest) }, + "token" to { rest -> token(dataDir, rest) }, + "nutzap-sweep" to { rest -> nutzapSweep(dataDir, rest) }, + ), + ) + + /** Resolve the mint to use: --mint, else the wallet's first configured mint. */ + private fun pickMint( + flag: String?, + mints: List, + ): String? = flag?.trimEnd('/') ?: mints.firstOrNull() + + private suspend fun ln( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val sats = args.positional(0, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer") + if (sats <= 0) return Output.error("bad_args", "sats must be positive") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val mint = pickMint(args.flag("mint"), ctx.cashuSnapshot().mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL") + return try { + val started = ctx.cashuOps().startMintFromLightning(mint, sats) + Output.emit( + mapOf( + "quote_id" to started.mintQuote.quote, + "invoice" to started.invoice, + "mint_url" to mint, + "amount_sats" to sats, + "kind_7374_event_id" to started.quoteEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun complete( + dataDir: DataDir, + rest: Array, + ): Int { + val quoteId = Args(rest).positional(0, "quote-id") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val quoteEvent = + snap.pendingQuotes.firstOrNull { runCatching { it.quoteId(ctx.signer) }.getOrNull() == quoteId } + ?: return Output.error("mint_quote_gone", "no pending kind:7374 quote with id $quoteId") + val mint = quoteEvent.mint() ?: snap.mints.firstOrNull() ?: return Output.error("no_mint", "quote has no mint") + return try { + // Poll the mint: a quote that isn't settled yet can't be minted. + val status = ctx.cashuOps().checkMintQuote(mint, quoteId) + if (!status.isSettled()) { + Output.emit(mapOf("status" to "pending", "quote_id" to quoteId, "mint_url" to mint)) + return 0 + } + // The kind:7374 stores only the quote id; recover the mint amount + // from the quote's bolt11 invoice (what the mint settled). + val amount = LnInvoiceUtil.getAmountInSats(status.request).toLong() + val done = ctx.cashuOps().completeMintFromLightning(mint, quoteEvent, amount) + Output.emit( + mapOf( + "status" to "paid", + "amount_sats" to done.mintedAmount, + "token_event_id" to done.tokenEvent.id, + "history_event_id" to done.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun token( + dataDir: DataDir, + rest: Array, + ): Int { + val raw = Args(rest).positional(0, "token").trim() + val parsed = CashuTokenB64Parser.parse(raw) ?: return Output.error("bad_args", "could not parse cashu token") + if (parsed.isEmpty()) return Output.error("bad_args", "token has no proofs") + Context.open(dataDir).use { ctx -> + ctx.prepare() + return try { + var total = 0L + var lastTokenEventId: String? = null + var lastHistoryEventId: String? = null + var mint = "" + for (t in parsed) { + val redeemed = ctx.cashuOps().redeemToken(raw, t.proofs, t.mint) + total += redeemed.amount + lastTokenEventId = redeemed.tokenEvent.id + lastHistoryEventId = redeemed.historyEvent.id + mint = t.mint + } + Output.emit( + mapOf( + "amount_sats" to total, + "mint_url" to mint, + "token_event_id" to lastTokenEventId, + "history_event_id" to lastHistoryEventId, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_proofs_spent", describe(e)) + } + } + } + + private suspend fun nutzapSweep( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val wallet = snap.walletEvent ?: return Output.error("no_wallet", "no wallet to redeem into") + val privkey = runCatching { wallet.privkey(ctx.signer) }.getOrNull() ?: return Output.error("signer_error", "could not decrypt wallet key") + val p2pkPubkey = snap.nutzapInfoEvent?.p2pkPubkey() ?: return Output.error("no_wallet", "no kind:10019 nutzap pubkey") + + val redeemed = mutableListOf>() + val skipped = mutableListOf>() + for (nutzap in snap.nutzapEvents) { + if (mintFilter != null && nutzap.mintUrl()?.trimEnd('/') != mintFilter) continue + try { + val r = ctx.cashuOps().redeemNutzap(nutzap, privkey, p2pkPubkey) + redeemed.add( + mapOf( + "nutzap_id" to nutzap.id, + "amount_sats" to r.amount, + "token_event_id" to r.tokenEvent.id, + "history_event_id" to r.historyEvent.id, + ), + ) + } catch (e: Exception) { + skipped.add(mapOf("nutzap_id" to nutzap.id, "reason" to describe(e))) + } + } + Output.emit(mapOf("redeemed" to redeemed, "skipped" to skipped)) + } + return 0 + } + + private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error" +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt new file mode 100644 index 0000000000..87efd169d4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt @@ -0,0 +1,202 @@ +/* + * 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.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent + +/** + * `amy cashu send ` — outbound flows on the shared + * commons CashuWalletOps. All spends scrub the source mint first (NUT-07 + + * NIP-09), exactly like the Android wallet, so a stale proof can't trip + * "proofs already spent". + */ +object CashuSendCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu send", + tail = tail, + usage = "cashu send ", + routes = + mapOf( + "ln" to { rest -> ln(dataDir, rest) }, + "token" to { rest -> token(dataDir, rest) }, + "nutzap" to { rest -> nutzap(dataDir, rest) }, + ), + ) + + private fun pickMint( + flag: String?, + mints: List, + ): String? = flag?.trimEnd('/') ?: mints.firstOrNull() + + private suspend fun ln( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val invoice = args.positional(0, "invoice").trim() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val mint = pickMint(args.flag("mint"), snap.mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL") + return try { + val quote = ctx.cashuOps().requestMeltQuote(mint, invoice) + // Scrub stale proofs at this mint before melting (matches Amethyst). + val scrubbed = + ctx + .cashuOps() + .scrubStaleProofs(mint, snap.tokenEntries.filter { it.content.mint == mint }) + .map { it.id } + .toSet() + val available = snap.tokenEntries.filter { it.content.mint == mint && it.event.id !in scrubbed } + if (available.isEmpty()) return Output.error("insufficient_funds", "no proofs available at $mint") + val done = ctx.cashuOps().meltToLightning(mint, quote, available) + Output.emit( + mapOf( + "amount_sats" to done.paidAmount, + "fee_paid_sats" to done.fees, + "preimage" to done.preimage, + "history_event_id" to done.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun token( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val sats = args.positional(0, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer") + if (sats <= 0) return Output.error("bad_args", "sats must be positive") + val memo = args.flag("memo") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val mint = pickMint(args.flag("mint"), snap.mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL") + return try { + val scrubbed = + ctx + .cashuOps() + .scrubStaleProofs(mint, snap.tokenEntries.filter { it.content.mint == mint }) + .map { it.id } + .toSet() + val available = snap.tokenEntries.filter { it.content.mint == mint && it.event.id !in scrubbed } + val balance = available.sumOf { it.content.totalAmount() } + if (balance < sats) return Output.error("insufficient_funds", "mint $mint has only $balance sat") + val done = ctx.cashuOps().sendAsToken(mint, sats, available, memo) + Output.emit( + mapOf( + "token" to done.cashuToken, + "amount_sats" to done.amount, + "mint_url" to mint, + "history_event_id" to done.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun nutzap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val userArg = args.positional(0, "user") + val sats = args.positional(1, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer") + if (sats <= 0) return Output.error("bad_args", "sats must be positive") + val message = args.flag("message") ?: "" + val zappedId = args.flag("zapped") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recipient = ctx.requireUserHex(userArg) + val snap = ctx.cashuSnapshot() + + // Resolve the recipient's kind:10019 (cache then relay). + val info = resolveNutzapInfo(ctx, recipient) ?: return Output.error("no_mint", "recipient has no kind:10019 nutzap info") + val recipientP2pk = info.p2pkPubkey() ?: return Output.error("nutzap_locked_to_wrong_key", "recipient kind:10019 has no P2PK pubkey") + val recipientMints = info.mints().map { it.mintUrl.trimEnd('/') }.toSet() + + // Pick a mint we both share AND hold a balance at. + val mint = + args.flag("mint")?.trimEnd('/')?.takeIf { it in recipientMints } + ?: snap.balancesByMint.keys.firstOrNull { it.trimEnd('/') in recipientMints } + ?: return Output.error("no_mint", "no shared mint with a balance; recipient mints=$recipientMints") + + val available = snap.tokenEntries.filter { it.content.mint.trimEnd('/') == mint.trimEnd('/') } + if (available.sumOf { it.content.totalAmount() } < sats) return Output.error("insufficient_funds", "mint $mint has insufficient balance") + + val zapped = + zappedId?.let { id -> + val ev = ctx.store.query(Filter(ids = listOf(id), limit = 1)).firstOrNull() + ev?.let { EventHintBundle(it) } + } + + return try { + val sent = ctx.cashuOps().sendNutzap(mint, sats, recipient, recipientP2pk, zapped, message, available) + Output.emit( + mapOf( + "nutzap_event_id" to sent.nutzapEvent.id, + "recipient_pubkey" to recipient, + "mint_url" to mint, + "amount_sats" to sent.amount, + "history_event_id" to sent.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + /** Recipient kind:10019 from the local store, falling back to a relay drain. */ + private suspend fun resolveNutzapInfo( + ctx: Context, + pubkey: String, + ): NutzapInfoEvent? { + val filter = Filter(authors = listOf(pubkey), kinds = listOf(NutzapInfoEvent.KIND), limit = 1) + (ctx.store.query(filter).firstOrNull() as? NutzapInfoEvent)?.let { return it } + ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? NutzapInfoEvent + } + + private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error" +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 11aa8484ee..cea2a3f9c2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -945,6 +945,33 @@ class CashuWalletOps( proofs: List, ): Map = ops(mintUrl).checkStates(proofs) + /** + * NUT-07 scrub for a single mint: check every proof in [entries] against + * the mint's `/checkstate`, then NIP-09 delete every kind:7375 event that + * holds at least one SPENT proof (a mixed-state entry is unusable — the + * next swap would pick it and trip HTTP 400). Returns the deleted token + * events so the caller can drop them from its own indexes. + * + * Shared by Android's `CashuWalletState.scrubLocallyStaleProofs` and amy's + * `cashu maintenance scrub`, so both prune identically. + */ + suspend fun scrubStaleProofs( + mintUrl: String, + entries: List, + ): List { + val allProofs = entries.flatMap { it.content.proofs } + if (allProofs.isEmpty()) return emptyList() + val states = ops(mintUrl).checkStates(allProofs) + val stale = + entries.filter { entry -> + entry.content.proofs.any { states[it.secret] == ProofState.SPENT } + } + if (stale.isEmpty()) return emptyList() + val delTemplate = DeletionEvent.build(stale.map { it.event }) + publish(signer.sign(delTemplate)) + return stale.map { it.event } + } + /** * Swap every proof inside [entries] (which the caller has already * filtered to "contains at least one stale-keyset proof") onto the From 47cc76d9f1e88d9b0ac3c17680c91277db4899ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:19:29 +0000 Subject: [PATCH 22/26] feat(cli): amy admin (NIP-86) + serve (embed geode relay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new nak-parity commands: - `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98 HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow event, allow/disallow kind, block/unblock IP, change name/description/icon, and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98 auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to commons/jvmAndroid so amy and the Android relay-management screen share it. - `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by embedding geode (the standalone Ktor relay on quartz's relay-server code). In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The account's own pubkey is always an admin, so `amy admin` works against it out of the box. cli gains a :geode dependency (geode depends only on :quartz) and kotlinx-serialization-json (to render NIP-86 JSON results). Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip cleanly over real HTTP + NIP-98 against the live geode relay. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../relays/nip86/RelayManagementScreen.kt | 2 +- .../relays/nip86/RelayManagementViewModel.kt | 2 +- cli/ROADMAP.md | 19 ++-- cli/build.gradle.kts | 4 + .../com/vitorpamplona/amethyst/cli/Main.kt | 4 + .../amethyst/cli/commands/AdminCommand.kt | 106 ++++++++++++++++++ .../amethyst/cli/commands/ServeCommand.kt | 106 ++++++++++++++++++ .../relayManagement}/Nip86Retriever.kt | 2 +- 8 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement}/Nip86Retriever.kt (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index db406f30eb..7a7a063cc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -78,9 +78,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt index cf570b2e77..2b5c79e79b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86 import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 24e37a2ff2..f665a6590b 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -100,24 +100,27 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | | `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | -| `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | +| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | +| `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. | +| `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. | +| `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. | ### Full nak comparison (introspected both binaries) nak has 34 functional commands. Coverage: -- **Full / equivalent (19):** `event`, `req`(→`fetch`+`subscribe`), `filter`, +- **Full / equivalent (22):** `event`, `req`(→`fetch`+`subscribe`), `filter`, `count`, `decode`, `encode`, `verify`, `relay`, `bunker`(+nostrconnect+auth_url), `encrypt`, `decrypt`, `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`, - `kind`, `blossom`. Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, - `encode`/`decode`) are interop-verified against the real `nak` binary. + `kind`, `blossom`, `admin`(NIP-86), `serve`(geode), `wallet`(NIP-60/61 Cashu). + Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`, + `admin`) are interop-verified against the real `nak` binary or `amy serve`. - **Partial / adapted (4):** `key` (no `expand`/`combine`/`validate`/`default`), `git` (NIP-34 events only — no packfile transport), `outbox` (shows NIP-65 vs nak's hints DB), `fetch` (filter-based, not nip19-hint resolution). -- **Missing (11):** `admin` (NIP-86), - `serve` (geode is a standalone relay), `dekey` (NIP-4E), `wallet` (NIP-60 - Cashu), `mcp`, `curl` (NIP-98), `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has - MLS/Marmot instead), `spell` (MuSig2/FROST), `validate` (RoK schema). +- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), + `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has MLS/Marmot instead), + `spell` (MuSig2/FROST), `validate` (RoK schema). **Design differences (not gaps):** amy is a *stateful client* (accounts, `~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless* diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 5fdf00d2fd..489e604740 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -22,8 +22,12 @@ sourceSets { dependencies { implementation(project(":quartz")) implementation(project(":commons")) + // `amy serve` embeds geode (the standalone Ktor relay built on quartz's + // relay-server code). geode depends only on :quartz, never on :amethyst. + implementation(project(":geode")) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) implementation(libs.okhttpCoroutines) implementation(libs.jackson.module.kotlin) 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 eeaa80a898..ff0e032bb0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.cli +import com.vitorpamplona.amethyst.cli.commands.AdminCommand import com.vitorpamplona.amethyst.cli.commands.AwaitCommands import com.vitorpamplona.amethyst.cli.commands.BlossomCommands import com.vitorpamplona.amethyst.cli.commands.BunkerCommand @@ -56,6 +57,7 @@ import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand +import com.vitorpamplona.amethyst.cli.commands.ServeCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand import com.vitorpamplona.amethyst.cli.commands.SyncCommand @@ -225,6 +227,8 @@ private suspend fun dispatch(argv: Array): Int { "blossom" -> BlossomCommands.dispatch(dataDir, tail) "sync" -> SyncCommand.run(dataDir, tail) "git" -> GitCommands.dispatch(dataDir, tail) + "admin" -> AdminCommand.run(dataDir, tail) + "serve" -> ServeCommand.run(dataDir, tail) "cashu" -> CashuCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt new file mode 100644 index 0000000000..d377861911 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import okhttp3.OkHttpClient + +/** + * `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API (nak's + * `relay`/admin). Signs a NIP-98 request with the account key and POSTs it to + * the relay's HTTP endpoint. Reuses quartz's `Nip86Client` (request build + + * NIP-98 auth + response parse) and the shared `commons` `Nip86Retriever` + * (the exact HTTP path Amethyst's relay-management screen runs). + * + * admin wss://relay supported-methods + * admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX + * admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys + * admin wss://relay list-banned-pubkeys + * admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events + * admin wss://relay list-needing-moderation + * admin wss://relay change-name S / change-description S / change-icon URL + * admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds + * admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips + */ +object AdminCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val relayArg = args.positionalOrNull(0) ?: return Output.error("bad_args", "usage: admin RELAY METHOD [args]") + val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods") + val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.error("bad_args", "invalid relay url: $relayArg") + val p2 = args.positionalOrNull(2) + val reason = args.flag("reason") + + fun needArg(name: String): String? = + p2 ?: run { + Output.error("bad_args", "$method requires a $name argument") + null + } + + val request: Nip86Request = + when (method) { + "supported-methods" -> Nip86Request.supportedMethods() + "ban-pubkey" -> Nip86Request.banPubkey(needArg("pubkey") ?: return 2, reason) + "unban-pubkey" -> Nip86Request.unbanPubkey(needArg("pubkey") ?: return 2, reason) + "list-banned-pubkeys" -> Nip86Request.listBannedPubkeys() + "allow-pubkey" -> Nip86Request.allowPubkey(needArg("pubkey") ?: return 2, reason) + "unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason) + "list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys() + "ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason) + "allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason) + "list-banned-events" -> Nip86Request.listBannedEvents() + "list-needing-moderation" -> Nip86Request.listEventsNeedingModeration() + "change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2) + "change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2) + "change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2) + "allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) + "disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) + "list-allowed-kinds" -> Nip86Request.listAllowedKinds() + "block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason) + "unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2) + "list-blocked-ips" -> Nip86Request.listBlockedIps() + else -> return Output.error("bad_args", "unknown method: $method") + } + + Context.open(dataDir).use { ctx -> + val client = Nip86Client(relay, ctx.signer) + val http = OkHttpClient.Builder().build() + val retriever = Nip86Retriever { _ -> http } + val response = retriever.execute(client, request) + if (response.error != null) return Output.error("relay_error", response.error) + // Reparse the kotlinx JSON result through Jackson so it renders as + // structured JSON (text + --json) instead of a toString blob. + val resultNode = response.result?.toString()?.let { Output.mapper.readTree(it) } + Output.emit(mapOf("relay" to relay.url, "method" to method, "result" to resultNode)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt new file mode 100644 index 0000000000..c2dc9eb8a5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.geode.KtorRelay +import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.awaitCancellation + +/** + * `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` — + * run a Nostr relay (nak's `serve`). Embeds **geode** (the standalone Ktor + * relay built on quartz's relay-server code), so amy serves the exact same + * relay implementation, including NIP-86 admin and NIP-77 Negentropy. + * + * In-memory by default (ephemeral, like nak serve); pass `--db FILE` for a + * persistent SQLite store. The account's own pubkey is always an admin so + * `amy admin ws://host:port …` works against it out of the box; `--admin` + * adds more (comma-separated npub/hex). Blocks until interrupted. + */ +object ServeCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val host = args.flag("host") ?: "127.0.0.1" + val port = args.intFlag("port", 7447) + val path = args.flag("path") ?: "/" + val dbFile = args.flag("db") + val extraAdmins = + args + .flag("admin") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + // Resolve admin pubkeys (self + --admin) up front, then drop the + // Context — the embedded relay owns its own store and needs no account. + val adminPubkeys = + Context.open(dataDir).use { ctx -> + buildSet { + add(ctx.identity.pubKeyHex) + extraAdmins.forEach { add(ctx.requireUserHex(it)) } + } + } + + // 0.0.0.0 isn't routable in a NIP-42 challenge; advertise loopback. + val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host + val url = "ws://$advertisedHost:$port$path".normalizeRelayUrl() + // In-memory is RelayEngine's default; only build a SQLite store for --db. + val relay = + if (dbFile != null) { + RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), adminPubkeys = adminPubkeys) + } else { + RelayEngine(url, adminPubkeys = adminPubkeys) + } + val server = KtorRelay(relay, host = host, port = port, path = path).start() + + Runtime.getRuntime().addShutdownHook( + Thread { + runCatching { server.stop() } + runCatching { relay.close() } + }, + ) + + Output.emit( + mapOf( + "listening" to server.url, + "host" to host, + "port" to port, + "path" to path, + "persistent" to (dbFile != null), + "admin_pubkeys" to adminPubkeys.toList(), + ), + ) + System.err.println("[serve] relay up at ${server.url} — Ctrl-C to stop") + + // Block until the process is interrupted; the shutdown hook tears down. + awaitCancellation() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt index 306dc530a8..f9a07a6e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt @@ -18,7 +18,7 @@ * 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.model.nip86RelayManagement +package com.vitorpamplona.amethyst.commons.relayManagement import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client From 9a5e9090c02945b6a2e21f7c9320bd4594a25314 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 01:06:01 +0000 Subject: [PATCH 23/26] feat(cli): amy key validate + fetch nip19/nip05 outbox resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex pubkey and report {valid, pubkey, npub}; never errors on bad input (reports valid:false) so scripts branch on the field. - `fetch ` — code mode that resolves relays the way the Android app opens a shared link: the relay hints embedded in the nip19 code UNION the author's NIP-65 write (outbox) relays, draining the author's kind:10002 on a cache miss. This is nak's `fetch` (nip19-hint resolution); filter mode is unchanged. Verified: key validate accepts fiatjaf's npub/hex and rejects garbage + bad-checksum npubs; `fetch ` drained the author's kind:10002, queried their actual advertised write relays, and returned kind:0. nak parity: 23 full / 3 partial / 6 missing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/ROADMAP.md | 9 +- .../amethyst/cli/commands/FetchCommand.kt | 123 +++++++++++++++++- .../amethyst/cli/commands/KeyCommands.kt | 27 ++++ 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index f665a6590b..e2b1e436ef 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -82,10 +82,10 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `decode` | `amy decode` | ✅ | NIP-19/21 → JSON. Quartz `Nip19Parser`. | | `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | | `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | -| `key` | `amy key generate\|public\|encrypt\|decrypt` | ✅ | generate/derive + NIP-49 encrypt/decrypt (bidirectionally nak-verified). `expand`/`combine`/`validate`/`default` still 🆕. | +| `key` | `amy key generate\|public\|encrypt\|decrypt\|validate` | ✅ | generate/derive + NIP-49 encrypt/decrypt (bidirectionally nak-verified) + `validate` (npub/hex parse check). `expand`/`combine`(MuSig2)/`default` still 🆕. | | `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. | | `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | -| `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. | +| `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. Also accepts a nip19/nip05 code and resolves relays via the outbox model (code hints + author's NIP-65 write relays), like nak's `fetch`. | | `req` (stream) | `amy subscribe` | ✅ | filter → live NDJSON stream to stdout. | | `count` | `amy count` | ✅ | NIP-45, per-relay counts. | | `encrypt` / `decrypt` | `amy encrypt\|decrypt` | ✅ | raw NIP-44 (default) / NIP-04. | @@ -115,9 +115,10 @@ nak has 34 functional commands. Coverage: `kind`, `blossom`, `admin`(NIP-86), `serve`(geode), `wallet`(NIP-60/61 Cashu). Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`, `admin`) are interop-verified against the real `nak` binary or `amy serve`. -- **Partial / adapted (4):** `key` (no `expand`/`combine`/`validate`/`default`), +- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`), `git` (NIP-34 events only — no packfile transport), `outbox` (shows NIP-65 vs - nak's hints DB), `fetch` (filter-based, not nip19-hint resolution). + nak's hints DB). `fetch` now does both filter mode AND nip19/nip05-hint + resolution (outbox model), so it's no longer partial. - **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has MLS/Marmot instead), `spell` (MuSig2/FROST), `validate` (RoK schema). diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt index 509da561f6..2adffc664c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -24,18 +24,35 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent /** * `amy fetch [--kind …] [--author …] [--id …] [--tag …] [--since/--until TS] * [--limit N] [--search TEXT] [--relay URL[,URL…]] [--timeout SECS]` * + * amy fetch + * * One-shot query: open a subscription, collect everything until every relay * sends EOSE (or the timeout fires), then print and exit. This is the bounded * half of nak's `req`; the live-streaming half is `amy subscribe`. * + * Two modes: + * - **filter mode** (flags) — assemble a filter and query `--relay`/outbox. + * - **code mode** (a nip19/nip05 positional) — Amethyst's outbox-model + * resolution: query the relay hints embedded in the code PLUS the author's + * NIP-65 write relays, exactly how the app downloads an event/profile from + * a shared link. This is nak's `fetch` (nip19-hint resolution). + * * Results are deduplicated by id, sorted newest-first, capped at `--limit` * (default 100), and emitted as full event JSON under an `events` array. - * Relays default to the account's outbox, then the bootstrap union. */ object FetchCommand { suspend fun run( @@ -46,6 +63,13 @@ object FetchCommand { val limit = args.flag("limit")?.toIntOrNull() ?: 100 if (limit <= 0) return Output.error("bad_args", "--limit must be > 0") val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + + // Code mode: a nip19/nip05 positional resolves its own relays via the + // outbox model rather than using a hand-built filter. + args.positionalOrNull(0)?.takeIf { looksLikeCode(it) }?.let { + return fetchByCode(dataDir, it, limit, timeoutMs) + } + val filter = RawEventSupport.buildFilter(args) Context.open(dataDir).use { ctx -> @@ -74,4 +98,101 @@ object FetchCommand { return 0 } } + + private fun looksLikeCode(s: String): Boolean { + val t = s.removePrefix("nostr:") + return t.startsWith("npub1") || t.startsWith("nprofile1") || t.startsWith("nevent1") || + t.startsWith("naddr1") || t.startsWith("note1") || ('@' in t && '.' in t) + } + + /** + * Outbox-model fetch from a nip19/nip05 code: decode it into a filter + + * relay hints + author, then query the hint relays UNION the author's + * NIP-65 write relays — the same resolution the Android app uses to open a + * shared `nevent`/`naddr`/profile link. + */ + private suspend fun fetchByCode( + dataDir: DataDir, + codeArg: String, + limit: Int, + timeoutMs: Long, + ): Int { + val code = codeArg.removePrefix("nostr:") + Context.open(dataDir).use { ctx -> + ctx.prepare() + + var filter: Filter + val hintRelays = mutableSetOf() + var author: String? = null + + if ('@' in code) { + // nip05 → pubkey, then fetch that author's profile metadata. + author = ctx.requireUserHex(code) + filter = Filter(authors = listOf(author), kinds = listOf(0), limit = 1) + } else { + val entity = Nip19Parser.uriToRoute(code)?.entity ?: return Output.error("bad_args", "could not decode: $codeArg") + filter = + when (entity) { + is NEvent -> { + hintRelays.addAll(entity.relay) + author = entity.author + Filter(ids = listOf(entity.hex), limit = 1) + } + is NNote -> Filter(ids = listOf(entity.hex), limit = 1) + is NAddress -> { + hintRelays.addAll(entity.relay) + author = entity.author + Filter(kinds = listOf(entity.kind), authors = listOf(entity.author), tags = mapOf("d" to listOf(entity.dTag)), limit = 1) + } + is NProfile -> { + hintRelays.addAll(entity.relay) + author = entity.hex + Filter(authors = listOf(entity.hex), kinds = listOf(0), limit = 1) + } + is NPub -> { + author = entity.hex + Filter(authors = listOf(entity.hex), kinds = listOf(0), limit = 1) + } + else -> return Output.error("bad_args", "unsupported code for fetch: $codeArg") + } + } + + // Outbox model: hint relays + the author's advertised write relays. + val relays = (hintRelays + (author?.let { authorWriteRelays(ctx, it) } ?: emptySet())).ifEmpty { ctx.bootstrapRelays() } + + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + val events = + received + .asSequence() + .map { it.second } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + .take(limit) + .map { Output.mapper.readTree(it.toJson()) } + .toList() + + Output.emit( + mapOf( + "code" to codeArg, + "resolved_author" to author, + "queried_relays" to relays.map { it.url }, + "count" to events.size, + "events" to events, + ), + ) + return 0 + } + } + + /** The author's NIP-65 write (outbox) relays, draining their kind:10002 on a cache miss. */ + private suspend fun authorWriteRelays( + ctx: Context, + author: String, + ): Set { + if (ctx.relaysOf(author) == null) { + val f = Filter(authors = listOf(author), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1) + ctx.drain(ctx.bootstrapRelays().associateWith { listOf(f) }) + } + return ctx.relaysOf(author)?.writeRelaysNorm()?.toSet() ?: emptySet() + } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt index 38a4657298..22079d8634 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 /** @@ -52,9 +53,35 @@ object KeyCommands { "public" to { tail -> public(tail) }, "encrypt" to { tail -> encrypt(tail) }, "decrypt" to { tail -> decrypt(tail) }, + "validate" to { tail -> validate(tail) }, ), ) + /** + * `key validate PUBKEY` — nak's `key validate`. Parses an npub or 64-hex + * public key and reports whether it is a structurally valid x-only pubkey. + * Never errors on a bad key — it reports `{"valid": false}` so scripts can + * branch on the field rather than the exit code. + */ + private fun validate(rest: Array): Int { + val input = Args(rest).positional(0, "pubkey").trim() + val hex = + when { + input.startsWith("npub") -> runCatching { input.bechToBytes().toHexKey() }.getOrNull() + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> input.lowercase() + else -> null + } + // A Nostr pubkey is a 32-byte x-only key. Confirm length after decode. + val valid = hex != null && hex.length == 64 + if (!valid) { + Output.emit(mapOf("valid" to false)) + return 0 + } + val npub = hex!!.hexToByteArray().toNpub() + Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub)) + return 0 + } + /** Read the private key (nsec or 64-hex) into hex, or null if unparseable. */ private fun privHexOrNull(input: String): String? = when { From 7a3c35c645f2ada0d11387a246724c860741a5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 02:14:11 +0000 Subject: [PATCH 24/26] docs: document cashu, admin, serve, fetch code mode, key validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the amy docs to reflect the new command surface: - cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86 admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05 code mode and key validate. - cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the command-family --json contracts (cashu keys/error codes + pointer to the cashu plan, admin {relay,method,result}, serve startup object). - .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement shared modules, and the allowed :geode dependency. - .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on :amethyst/:desktopApp. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .claude/CLAUDE.md | 4 ++- .claude/skills/amy-expert/SKILL.md | 17 +++++++++- cli/DEVELOPMENT.md | 17 ++++++++++ cli/README.md | 51 ++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c1ba392b9d..3ee4d735e9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -76,7 +76,9 @@ amethyst/ - `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport, Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio. - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation -- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed) +- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic + allowed). May also depend on `:geode` (for `amy serve`, which embeds the + standalone relay); never on `:amethyst` or `:desktopApp`. **Plans per module:** design docs for new subsystems live in the owning module's `plans/YYYY-MM-DD-.md` (e.g. `cli/plans/`, `commons/plans/`). diff --git a/.claude/skills/amy-expert/SKILL.md b/.claude/skills/amy-expert/SKILL.md index f6cbce8416..b7eaa489f6 100644 --- a/.claude/skills/amy-expert/SKILL.md +++ b/.claude/skills/amy-expert/SKILL.md @@ -189,15 +189,30 @@ cli/ ├── MessageCommands.kt ├── MarmotResetCommand.kt ├── AwaitCommands.kt - └── StoreCommands.kt + ├── StoreCommands.kt + ├── AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86) + ├── ServeCommand.kt # `amy serve` (embeds :geode) + └── cashu/ # `amy cashu …` (NIP-60/61) — thin wrappers + ├── CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader + ├── CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt + └── CashuReceiveCommands.kt + CashuSendCommands.kt + + CashuMaintenanceCommands.kt + CashuMintRecCommands.kt ``` Shared logic consumed by Amy lives in `commons/`: - `commons/account/` — account bootstrap - `commons/marmot/` — MLS / group state +- `commons/cashu/` — `ops/CashuWalletOps` (jvmAndroid) + `CashuWalletReader` + + `CashuKeysetCounterStore`; the NIP-60/61 wallet, shared with Android. +- `commons/relayManagement/Nip86Retriever` — NIP-86 HTTP client, shared with + the Android relay-management screen. - `commons/defaults/` — default relays, kinds - Consult `commons/plans/` for cross-cutting design work in flight. +A few amy verbs lean on modules beyond `quartz`/`commons`: `amy serve` +depends on `:geode` (the standalone relay) — the one allowed extra module +dependency. `:amethyst` / `:desktopApp` remain forbidden (Rule 5). + ## Common mistakes to refuse - **Adding protocol logic to `cli/`.** Push back, offer to extract. diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index b9f37b73b8..6b9eb42376 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -227,6 +227,22 @@ contract. - Errors via `Output.error("code","detail")` — single lower_snake code, free-form detail. +**Command-family schemas:** + +- **Cashu** (`amy cashu …`, NIP-60/61): the full per-verb `--json` key table + and the `cashu.json` NUT-13 counter layout are pinned in + [`plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). Common + keys: `wallet_event_id`, `mint_url`, `amount_sats` (Long), `proofs_count`, + `token_event_id`, `history_event_id`, `quote_id`, `p2pk_pubkey`. Error codes + include `no_wallet`, `no_mint`, `insufficient_funds`, `mint_unreachable`, + `mint_http_`, `mint_proofs_spent`, `mint_quote_gone`. +- **Admin** (`amy admin …`, NIP-86): `{relay, method, result}` where `result` + is the relay's raw JSON-RPC result (list/boolean/null). Relay-side failures + surface as `error: relay_error`. +- **Serve** (`amy serve`): a single startup object `{listening, host, port, + path, persistent, admin_pubkeys[]}`, then the process blocks (it embeds + geode; teardown is on SIGINT). + --- ## Testing @@ -360,6 +376,7 @@ events. │ ├── identity.json # nsec/npub/hex — the account │ ├── state.json # sync cursors (giftWrapSince, groupSince) │ ├── aliases.json # local name → npub map (init writes a self-entry) +│ ├── cashu.json # NIP-60 NUT-13 counters: {"keyset_counters":{"":}} │ └── marmot/ │ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) │ └── groups/ diff --git a/cli/README.md b/cli/README.md index bfef61f701..548b1b94c7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -215,6 +215,7 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | | `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | | `amy key decrypt NCRYPTSEC --password X` | NIP-49 decrypt back to nsec/hex/npub. | +| `amy key validate NPUB\|HEX` | Parse-check a public key. Prints `{valid, pubkey, npub}` or `{valid:false}` — never errors, so scripts branch on the field. | | `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. | @@ -264,6 +265,7 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | Command | What it does | |---|---| | `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. | +| `amy fetch CODE [--timeout SECS]` | Code mode — pass a single `nevent`/`naddr`/`nprofile`/`npub`/`note` or `name@domain`. Resolves relays the outbox way: the hints embedded in the code **plus** the author's NIP-65 write relays (draining their kind:10002 on a cache miss), exactly how the app opens a shared link. | | `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | | `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. | | `amy outbox USER [--refresh] [--timeout SECS]` | Show USER's NIP-65 read/write relays (outbox model). Cache-first; `--refresh` forces a relay drain. | @@ -308,6 +310,55 @@ nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out o | `amy blossom check --server URL HASH[,HASH]` | HEAD-check the server has each blob; exit 1 if any is missing. | | `amy blossom mirror --server URL SOURCE-URL` | Ask the server to mirror a blob from SOURCE-URL (BUD-04). | +### Cashu wallet (NIP-60 / NIP-61) + +A NIP-60 ecash wallet + NIP-61 nutzaps, driven by the **same** shared +`commons` `CashuWalletOps` / `CashuWalletReader` the Android wallet runs — so +amy's on-relay events match the app's. NUT-13 counters persist in +`~/.amy//cashu.json`. `mint ping`/`info` are stateless (no account). + +| Command | What it does | +|---|---| +| `amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]` | Publish a kind:17375 wallet + kind:10019 nutzap info. Advertises your outbox relays for nutzaps unless `--relay` overrides. | +| `amy cashu wallet show` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. | +| `amy cashu wallet export-key` | Decrypt and print the wallet's P2PK private key. | +| `amy cashu wallet destroy` | Withdraw the nutzap advertisement and NIP-09 delete the wallet (leaves token events — the ecash still lives at the mint). | +| `amy cashu balance [--mint URL]` | Spendable balance from the local store (optionally one mint). | +| `amy cashu mint ping URL` / `info URL` | Stateless `/v1/info` probe (name/pubkey/version) / full DTO. | +| `amy cashu receive ln SATS [--mint URL]` | Request a mint quote; prints the bolt11 + kind:7374 quote. | +| `amy cashu receive complete QUOTE_ID` / `resume QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). | +| `amy cashu receive token TOKEN` | Redeem a `cashuB…` token into the wallet. | +| `amy cashu receive nutzap-sweep [--mint URL]` | Redeem inbound NIP-61 nutzaps locked to your wallet key. | +| `amy cashu send ln INVOICE [--mint URL]` | Melt proofs to pay a bolt11 (scrubs stale proofs first). | +| `amy cashu send token SATS [--mint URL] [--memo S]` | Export a `cashuB…` token of SATS. | +| `amy cashu send nutzap USER SATS [--zapped EVENT_ID] [--message S]` | Send a P2PK-locked nutzap to USER (resolves their kind:10019). | +| `amy cashu maintenance scrub [--mint URL]` | NUT-07 + NIP-09 prune of spent proofs. | +| `amy cashu maintenance restore MINT_URL` | NUT-09 restore unspent proofs from the wallet seed. | +| `amy cashu maintenance migrate-keysets [--mint URL]` | Consolidate proofs onto each mint's active keyset. | +| `amy cashu mint-rec show [--author NPUB]` / `add URL [--dtag X] [--review T]` / `remove EVENT_ID` | NIP-87 mint recommendations (kind:38000). | + +### Relay management — admin (NIP-86) + +Signs a NIP-98 request with the active account and POSTs it to the relay's +HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` +(the same path Amethyst's relay-management screen runs). + +| Command | What it does | +|---|---| +| `amy admin RELAY supported-methods` | List the NIP-86 methods the relay implements. | +| `amy admin RELAY ban-pubkey HEX [--reason R]` / `unban-pubkey HEX` / `list-banned-pubkeys` | Pubkey ban list. | +| `amy admin RELAY allow-pubkey HEX [--reason R]` / `unallow-pubkey HEX` / `list-allowed-pubkeys` | Pubkey allow list. | +| `amy admin RELAY ban-event ID [--reason R]` / `allow-event ID` / `list-banned-events` / `list-needing-moderation` | Event moderation. | +| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` | Kind allow list. | +| `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. | +| `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. | + +### Run a relay — serve + +| Command | What it does | +|---|---| +| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. Blocks until interrupted. | + ### Identity | Command | What it does | From 188746485d4cf4ef91a15266657549e83ac060b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 14:45:14 +0000 Subject: [PATCH 25/26] docs(cli): correct nak parity tally (24 full / 3 partial / 7 missing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-introspected the real nak binary: 34 functional commands. Fixes the stale count — adds nsite to full (amy has NsiteCommands), corrects missing to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence, not a gap. Drops the stale "validate" cheap-win (key validate shipped). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/ROADMAP.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index e2b1e436ef..9be2ba2f3b 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -107,21 +107,22 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. ### Full nak comparison (introspected both binaries) -nak has 34 functional commands. Coverage: +nak has 34 functional commands (introspected from `nak --help`). Coverage: -- **Full / equivalent (22):** `event`, `req`(→`fetch`+`subscribe`), `filter`, - `count`, `decode`, `encode`, `verify`, `relay`, `bunker`(+nostrconnect+auth_url), - `encrypt`, `decrypt`, `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`, - `kind`, `blossom`, `admin`(NIP-86), `serve`(geode), `wallet`(NIP-60/61 Cashu). +- **Full / equivalent (24):** `event`, `req`(→`fetch`+`subscribe`), `fetch` + (nip19/nip05-hint resolution), `filter`, `count`, `decode`, `encode`, + `verify`, `relay`, `bunker`(+nostrconnect+auth_url), `encrypt`, `decrypt`, + `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`, `kind`, `blossom`, + `nsite`(NIP-5A), `admin`(NIP-86), `serve`(geode), `wallet`(NIP-60/61 Cashu). Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`, `admin`) are interop-verified against the real `nak` binary or `amy serve`. - **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`), - `git` (NIP-34 events only — no packfile transport), `outbox` (shows NIP-65 vs - nak's hints DB). `fetch` now does both filter mode AND nip19/nip05-hint - resolution (outbox model), so it's no longer partial. -- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), - `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has MLS/Marmot instead), - `spell` (MuSig2/FROST), `validate` (RoK schema). + `git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs + nak's local hints DB). +- **Missing (7):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE), + `spell` (MuSig2/FROST), `validate` (event-schema validation), and + `group`/`nip29` (NIP-29 — amy ships MLS/Marmot instead, an intentional + divergence rather than a gap). **Design differences (not gaps):** amy is a *stateful client* (accounts, `~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless* @@ -130,7 +131,8 @@ a large surface nak lacks: Marmot/MLS, NIP-17 DMs, zaps, CLINK offer/debit, NIP-02 follow, NIP-50 search, napplets, profile edit, store management, account management. -**Cheap remaining wins:** the `key` `expand`/`combine`/`validate`/`default` sub-verbs. +**Cheap remaining wins:** the `key` `expand` (hex left-pad) and `default` +(print the active account's key) sub-verbs. `key combine` needs MuSig2. --- From 2c4fd48c96f79e688d327935cdc347e20637ebdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 14:58:51 +0000 Subject: [PATCH 26/26] fix(cli): realign amy nutzap relays with upstream NIP-65 inbox model Upstream reworked kind:10019 to advertise the account's NIP-65 inbox (read) relays as nutzap-receiving relays (was outbox). Realign amy: `cashu wallet create` now defaults nutzapRelays to a new Context.nip65ReadRelays() (kind:10002 read relays, falling back to outbox) instead of outboxRelays(), so amy's kind:10019 matches the Android wallet's again. The event's publish destination (anyRelays / sendLiterallyEverywhere) is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../kotlin/com/vitorpamplona/amethyst/cli/Context.kt | 11 +++++++++++ .../cli/commands/cashu/CashuWalletCommands.kt | 9 +++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index f135e6b407..36cc92a817 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -330,6 +330,17 @@ class Context( relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() ?: DefaultNIP65RelaySet + /** + * NIP-65 *read* (inbox) relays for this account — "where others reach me". + * Mirrors the Android app's NIP-65 inbox set (the `notificationRelays` + * flow). Used as the default `relay` tags advertised in a kind:10019 + * nutzap-info event. Falls back to [outboxRelays] when no read relays are + * marked. + */ + suspend fun nip65ReadRelays(): Set = + relaysOf(identity.pubKeyHex)?.readRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() + ?: outboxRelays() + /** * DM inbox relays (NIP-17 kind:10050) for this account. Falls back * to [DefaultDMRelayList] when no kind:10050 has been seen. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt index 3d0a9d32d2..88e2fb40ba 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt @@ -72,10 +72,11 @@ object CashuWalletCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - // Match Amethyst: kind:10019 advertises the account's outbox - // relays as nutzap-receiving relays unless the caller overrides - // with --relay, so senders publish nutzaps where we actually read. - val nutzapRelays = explicitRelays.ifEmpty { ctx.outboxRelays().toList() } + // Match Amethyst: kind:10019 advertises the account's NIP-65 + // inbox (read) relays as nutzap-receiving relays unless the caller + // overrides with --relay, so senders publish kind:9321 where we + // read incoming events. + val nutzapRelays = explicitRelays.ifEmpty { ctx.nip65ReadRelays().toList() } val created = try { ctx.cashuOps().publishWalletEvents(mints, privkey, nutzapRelays)