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") + } + } +}