From 305d6bc7335d983300a46bd77f355534a9a7151b Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Fri, 26 Jun 2026 17:56:17 +1000 Subject: [PATCH] feat(cli): amy namecoin resolve + servers verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Namecoin NIP-05 resolution to the amy CLI as a stateless verb group, matching the Android and Desktop apps' resolution surface. amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS] amy namecoin servers IDENT accepts the same shapes the apps accept: raw `d/` / `id/` names, bare `.bit` domains, and `alice@example.bit` NIP-05-style local-parts. Output is the resolved Nostr pubkey + relay list (+ the resolved Namecoin name + matched local-part) as machine-readable JSON (with `--json`) or human-readable text. The verb is stateless — no account, no `~/.amy/`, no relays — so it dispatches alongside `decode`/`encode`/`verify`/`nip`/`kind` before account resolution and the secret store. Zero new logic in cli/: the implementation is a thin command-file wrapper around quartz's existing `NamecoinNameResolver` + `ElectrumXClient` + the canonical `DEFAULT_ELECTRUMX_SERVERS` set the apps already ship with, including the pinned trust store for the self-signed Namecoin ElectrumX ecosystem. amy is headless so no UI piece is wired in. The `--server` flag accepts `host`, `host:port`, `tcp://`, `tls://`, `ssl://` per entry (defaults to TLS on 50002); empty / malformed entries fail with `bad_args` rather than silently using the default set, so a fat- fingered override can't go unnoticed. Outcomes from `NamecoinResolveOutcome` map to amy error codes: Success -> emit JSON, exit 0 NameNotFound -> error not_found NoNostrField -> error no_nostr_field MalformedRecord -> error malformed_record (+ namecoin_name extra) ServersUnreachable-> error servers_unreachable InvalidIdentifier -> error invalid_identifier Timeout -> error timeout Smoke-tested end-to-end on macOS arm64 against the live ElectrumX fleet: $ amy --json namecoin resolve d/testls {"identifier":"d/testls","namecoin_name":"d/testls", "local_part":"_","pubkey":"460c25e6…","relays":[]} $ amy namecoin servers count: 6 servers: - host: electrumx.testls.space port: 50002 tls: yes … No new runtime deps. The "no Compose UI in the amy image" CI assertion still passes — `NamecoinNameResolver` + `ElectrumXClient` are pure JVM (kotlinx.coroutines + kotlinx.serialization, both already on the CLI classpath via :quartz). Tests: the resolver, ElectrumX client, identifier parser, and the default server set already have JVM tests under `quartz/src/jvmTest/.../namecoin/` — no new core code in this PR, so the existing coverage applies. CLI verbs are exercised via the shell harnesses in `cli/tests/`; a Namecoin harness fits the same pattern but isn't included here. Parity matrix in `cli/ROADMAP.md` flags `name_history` and the Namecoin Core JSON-RPC backend as pending separate PRs — both already exist on Android and Desktop but aren't on upstream main yet (open PRs against this repo carry them). --- cli/README.md | 2 + cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 6 + .../amethyst/cli/commands/NamecoinCommand.kt | 230 ++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NamecoinCommand.kt diff --git a/cli/README.md b/cli/README.md index 548b1b94c7..b10bd54726 100644 --- a/cli/README.md +++ b/cli/README.md @@ -219,6 +219,8 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. | | `amy nip N` / `amy nip list` | Look up a NIP — the `nostr-protocol/nips` repo first, then a Nostr wiki/long-form fallback. `list` fetches the index. | | `amy kind N` / `amy kind NAME` | Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's `KindNames` registry. | +| `amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS]` | Resolve a Namecoin identifier (`.bit`, `d/`, `id/`, `alice@example.bit`) to a Nostr pubkey + relays via the Namecoin blockchain. Stateless: talks directly to one or more ElectrumX servers over TLS, no account needed. Reuses the same NIP-05-Namecoin parser, server set, and pinned trust store as the Android and Desktop apps. | +| `amy namecoin servers` | Print the default ElectrumX server list (host, port, TLS flag). | | `amy relay info URL` | Fetch and print a relay's NIP-11 information document. | ### Remote signing (NIP-46 bunker) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 9be2ba2f3b..62c660d076 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -70,6 +70,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Thread view (`amy thread show EVENT_ID`) | ⚠️ | Same. | | Notifications feed | 🆕 | | | Search (NIP-50) | 🆕 | | +| Namecoin NIP-05 resolve (`amy namecoin resolve .bit\|d/\|id/`) | ✅ | `NamecoinCommand` — reuses Quartz `NamecoinNameResolver` + `ElectrumXClient` + the default ElectrumX server set the Android/Desktop apps ship with. Stateless. On-chain `name_history` + Core RPC backend pending separate PRs. | ### `nak` parity — army-knife primitives 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 ff0e032bb0..f457cb6887 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -46,6 +46,7 @@ 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 +import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand import com.vitorpamplona.amethyst.cli.commands.NappletCommands import com.vitorpamplona.amethyst.cli.commands.NipCommand import com.vitorpamplona.amethyst.cli.commands.NotesCommands @@ -178,6 +179,7 @@ private suspend fun dispatch(argv: Array): Int { "filter" -> return FilterCommand.run(tail) "nip" -> return NipCommand.run(tail) "kind" -> return KindCommand.run(tail) + "namecoin" -> return NamecoinCommand.dispatch(tail) } // `relay info URL` is a stateless NIP-11 fetch — no account needed. The @@ -366,6 +368,10 @@ private fun printUsage() { | nip N show a NIP (repo first, then a Nostr wiki/long-form fallback) | nip list fetch the NIP index (README) from the repo | kind N|NAME look up an event kind's label + NIP (number, or search by name) + | namecoin resolve IDENT resolve a Namecoin identifier (.bit, d/, id/, alice@x.bit) + | [--server URL[,URL]] to a Nostr pubkey + relays via the Namecoin blockchain + | [--timeout SECS] (no account, talks to ElectrumX over TLS) + | namecoin servers print the default ElectrumX server list | |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/NamecoinCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NamecoinCommand.kt new file mode 100644 index 0000000000..619cbb1e25 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NamecoinCommand.kt @@ -0,0 +1,230 @@ +/* + * 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.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome + +/** + * `amy namecoin …` — resolve `.bit` / `d/` / `id/` identifiers to a + * Nostr pubkey via the Namecoin blockchain. + * + * Stateless: no account, no `~/.amy/`, no relays. Talks directly to + * one or more ElectrumX servers over TLS to fetch the latest + * `name_show` value for the requested name and extracts the `nostr` + * field per the NIP-05-Namecoin parser (priority: exact local-part → + * `_` wildcard → first valid entry; `nostr:` prefix tolerated; + * accepts the simple-string, single-identity-object, and + * extended-NIP-05-like `names` map forms). + * + * Verbs: + * resolve IDENTIFIER resolve to a Nostr pubkey (+ relays) + * servers list the default ElectrumX server set + * + * IDENTIFIER accepts the same forms the Amethyst Android/Desktop apps + * accept: + * d/example raw `d/` Namecoin name (root `_` local-part) + * id/alice raw `id/` Namecoin name (always `_`) + * example.bit domain form (canonicalised to `d/example`) + * alice@example.bit NIP-05-style local-part in a `.bit` domain + * + * Flags (resolve): + * --server URL[,URL] override the ElectrumX server list (host:port, + * one or more; default: the same hard-coded + * mainnet set the apps ship with) + * --timeout SECS overall lookup timeout (default 20) + * + * Exit codes follow amy convention: + * 0 success (Output.emit was called) + * 1 Output.error was called (bad_args, network, name not found, …) + * + * The JSON shape on success (resolve): + * { + * "identifier": "alice@example.bit", + * "namecoin_name": "d/example", + * "local_part": "alice", + * "pubkey": "<64 hex>", + * "relays": ["wss://relay.damus.io/", …] + * } + * + * The JSON shape on success (servers): + * { + * "count": 7, + * "servers": [ + * { "host": "electrumx.testls.space", "port": 50002, "tls": true }, + * … + * ] + * } + */ +object NamecoinCommand { + private val DEFAULT_TIMEOUT_SECS = 20L + + suspend fun dispatch(rest: Array): Int = + route( + name = "namecoin", + tail = rest, + usage = "namecoin …", + routes = + mapOf( + "resolve" to { tail -> resolve(tail) }, + "servers" to { tail -> servers(tail) }, + ), + ) + + private suspend fun resolve(rest: Array): Int { + val args = Args(rest) + val identifier = args.positional(0, "identifier").trim() + if (identifier.isEmpty()) { + return Output.error("bad_args", "namecoin resolve ") + } + if (!NamecoinNameResolver.isNamecoinIdentifier(identifier)) { + return Output.error( + "bad_args", + "not a Namecoin identifier (expected .bit, d/, or id/): $identifier", + ) + } + + val serverFlag = args.flag("server") + val servers = parseServerFlag(serverFlag) + if (servers != null && servers.isEmpty()) { + return Output.error("bad_args", "--server: no valid host:port entries in '$serverFlag'") + } + + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: DEFAULT_TIMEOUT_SECS + if (timeoutSecs <= 0) { + return Output.error("bad_args", "--timeout must be positive (was $timeoutSecs)") + } + val timeoutMs = timeoutSecs * 1000 + + val resolver = + NamecoinNameResolver( + electrumxClient = ElectrumXClient(), + lookupTimeoutMs = timeoutMs, + serverListProvider = { servers ?: DEFAULT_ELECTRUMX_SERVERS }, + ) + + return when (val outcome = resolver.resolveDetailed(identifier)) { + is NamecoinResolveOutcome.Success -> { + val r = outcome.result + Output.emit( + mapOf( + "identifier" to identifier, + "namecoin_name" to r.namecoinName, + "local_part" to r.localPart, + "pubkey" to r.pubkey, + "relays" to r.relays, + ), + ) + 0 + } + is NamecoinResolveOutcome.NameNotFound -> + Output.error("not_found", "Namecoin name does not exist: ${outcome.name}") + is NamecoinResolveOutcome.NoNostrField -> + Output.error( + "no_nostr_field", + "Namecoin name has no Nostr field: ${outcome.name}", + ) + is NamecoinResolveOutcome.MalformedRecord -> + Output.error( + "malformed_record", + outcome.error, + extra = mapOf("namecoin_name" to outcome.name), + ) + is NamecoinResolveOutcome.ServersUnreachable -> + Output.error("servers_unreachable", outcome.message) + is NamecoinResolveOutcome.InvalidIdentifier -> + Output.error("invalid_identifier", outcome.identifier) + NamecoinResolveOutcome.Timeout -> + Output.error("timeout", "lookup timed out after ${timeoutSecs}s") + } + } + + private fun servers(rest: Array): Int { + // No positional/flags today — but reject unknown ones so future + // additions don't silently break. + val args = Args(rest) + if (args.positionalOrNull(0) != null) { + return Output.error("bad_args", "namecoin servers takes no arguments") + } + Output.emit( + mapOf( + "count" to DEFAULT_ELECTRUMX_SERVERS.size, + "servers" to + DEFAULT_ELECTRUMX_SERVERS.map { srv -> + mapOf( + "host" to srv.host, + "port" to srv.port, + "tls" to srv.useSsl, + ) + }, + ), + ) + return 0 + } + + /** + * Parse the `--server URL[,URL]` flag. + * + * Accepted forms per entry: + * host → host:50002 TLS + * host:port → TLS + * tcp://host:port → plaintext + * tls://host:port → TLS + * ssl://host:port → TLS (alias for tls://) + * + * Returns null when the flag is absent (caller falls back to the + * default server list). Returns an empty list when the flag is + * present but produced zero valid entries — the caller treats that + * as a hard `bad_args` rather than silently using defaults, so a + * fat-fingered `--server foo:bar` is impossible to overlook. + */ + private fun parseServerFlag(raw: String?): List? { + if (raw == null) return null + return raw + .split(',') + .mapNotNull { entry -> + val trimmed = entry.trim() + if (trimmed.isEmpty()) return@mapNotNull null + val (scheme, rest) = + when { + trimmed.startsWith("tcp://") -> "tcp" to trimmed.removePrefix("tcp://") + trimmed.startsWith("tls://") -> "tls" to trimmed.removePrefix("tls://") + trimmed.startsWith("ssl://") -> "tls" to trimmed.removePrefix("ssl://") + else -> "tls" to trimmed + } + val (host, portStr) = + if (':' in rest) { + rest.substringBeforeLast(':') to rest.substringAfterLast(':') + } else { + rest to "50002" + } + if (host.isEmpty()) return@mapNotNull null + val port = portStr.toIntOrNull() ?: return@mapNotNull null + if (port !in 1..65535) return@mapNotNull null + ElectrumxServer(host = host, port = port, useSsl = scheme == "tls") + } + } +}