feat(cli): amy namecoin resolve + servers verbs

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).
This commit is contained in:
mstrofnone
2026-06-26 18:23:13 -04:00
committed by Vitor Pamplona
parent f5793937a7
commit 305d6bc733
4 changed files with 239 additions and 0 deletions
@@ -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<String>): 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)
@@ -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<String>): Int =
route(
name = "namecoin",
tail = rest,
usage = "namecoin <resolve|servers> …",
routes =
mapOf(
"resolve" to { tail -> resolve(tail) },
"servers" to { tail -> servers(tail) },
),
)
private suspend fun resolve(rest: Array<String>): Int {
val args = Args(rest)
val identifier = args.positional(0, "identifier").trim()
if (identifier.isEmpty()) {
return Output.error("bad_args", "namecoin resolve <identifier>")
}
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<String>): 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<ElectrumxServer>? {
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")
}
}
}