diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md index 60ddb6d..c801840 100644 --- a/docs/TRANSPORT_V2_SPEC.md +++ b/docs/TRANSPORT_V2_SPEC.md @@ -1,6 +1,6 @@ # mostro-cli — Transport v2 (NIP-44 Direct) client support -**Status:** Phases 1–2 implemented · Phase 3 pending +**Status:** Phases 1–3 implemented **Daemon spec:** `MostroP2P/mostro` → `docs/TRANSPORT_V2_SPEC.md` **Issue:** [#626 — Messaging Transport Abstraction Layer](https://github.com/MostroP2P/mostro/issues/626) **Core:** `transport` module shipped in **mostro-core 0.13.0** @@ -17,7 +17,7 @@ transport). The daemon now speaks one of two wire transports per node, selected by its `[mostro] transport` setting and advertised on the kind-`38385` instance-info -event via a `protocol_versions` tag (`"1"` or `"2"`). A v1-only client cannot +event via a `protocol_version` tag (`"1"` or `"2"`). A v1-only client cannot talk to a `transport = "nip44"` node at all (it never sees kind-14 traffic and its gift wraps are ignored). To test and use v2 nodes, the CLI must: @@ -140,27 +140,37 @@ gates. > to 2. Phase 1 (gift-wrap, kind 1059, `version = 2`) never hits the gate, so > its backward-compatibility is unaffected. -### Phase 3 — Capability auto-detection + docs/UX — PENDING - -- Read the node's `protocol_versions` tag from its kind-`38385` info event - (same fetch path as the existing `pow` probe) and, when `--transport` is not - given, auto-select the matching transport — warning on a mismatch - ("this node speaks v2; re-run with --transport nip44") instead of silently - timing out. This `protocol_versions` probe is also the **backward-compat - guard** for the version-skew risk Phase 1 flagged: a pre-0.13 daemon (or a - misconfigured pairing) advertises no `protocol_versions` tag, so the CLI - treats it as v1/gift-wrap and warns rather than silently failing. Until this - lands, the explicit `--transport` flag is the only negotiation — an operator - pointing a 0.13 CLI at an older daemon must match transports manually. -- Surface the active transport in verbose output. -- Update `docs/architecture.md`, `docs/commands.md`, and the README. +### Phase 3 — Capability auto-detection + docs/UX — IMPLEMENTED + +- **Auto-detection.** `events::fetch_protocol_version_with` reads the node's + `protocol_version` tag from its kind-38385 info event (short + `INFO_PROBE_TIMEOUT` so a node without one degrades fast). `init_context` → + `resolve_transport` runs it **once at startup** when `--transport` / + `TRANSPORT` is unset: `2` → set `TRANSPORT=nip44`, `1` / absent / unreachable + → leave it unset so the messaging layer defaults to gift-wrap. An explicit + `--transport` is authoritative and skips the probe entirely. +- **Backward-compat guard.** Because a pre-v2 daemon publishes no + `protocol_version` tag, auto-detect leaves the CLI on gift-wrap (v1) rather + than silently mis-pairing — addressing the version-skew risk Phase 1 flagged. + An operator can still force either transport with `--transport`. +- **Verbose surface.** `resolve_transport` logs the active transport and how it + was chosen (`explicit` / `auto-detected protocol vN` / default fallback) at + `info` (shown with `-v`). +- **Docs.** `docs/commands.md` documents the global `--transport` flag and the + auto-detection; this spec is marked complete. (The `get-dm` listing and + range-order follow-up became transport-aware in Phase 2 via `create_filter`.) + +Tests: `protocol_version` tag read + parse (deterministic, offline). The +auto-detect wiring is exercised end to end by the manual checks below (they +depend on a live relay/node). ## 5. Testing notes - The daemon under test (`MostroP2P/mostro` PR #780) defaults to `transport = "gift-wrap"`; set `transport = "nip44"` in its `settings.toml` to exercise v2 + the anti-spam gate. -- After Phase 2, run the CLI with `--transport nip44` (or `TRANSPORT=nip44`) - against that node. +- Run the CLI with `--transport nip44` (or `TRANSPORT=nip44`) against that + node. As of Phase 3 you can also omit it: the CLI auto-detects the node's + transport from its `protocol_version` info tag at startup. - The daemon's first-contact PoW lane (`pow_first_contact`) is testable by combining `--transport nip44` with `--pow ` on the CLI. diff --git a/docs/commands.md b/docs/commands.md index fdf6d1a..4316c82 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,6 +4,24 @@ This document lists all `mostro-cli` subcommands defined in `src/cli.rs`, their All commands are part of the `Commands` enum and are dispatched via `Commands::run(&self, ctx: &Context)`. +### Global options + +These flags apply to every subcommand (parsed on the top-level `Cli`; each also +settable via the matching env var): + +- `-m, --mostropubkey ` (`MOSTRO_PUBKEY`): the Mostro node to talk to. Required. +- `-r, --relays ` (`RELAYS`): comma-separated relay URLs. +- `-p, --pow ` (`POW`): NIP-13 proof-of-work difficulty mined on outgoing events. +- `-s, --secret` (`SECRET=true`): full-privacy mode (unsigned inner tuple; identity = trade key). +- `-t, --transport ` (`TRANSPORT`): wire transport to speak — + `gift-wrap` (protocol v1, kind 1059) or `nip44` (protocol v2, signed kind 14). + **Optional**: when omitted, the CLI auto-detects it at startup from the node's + `protocol_version` tag on its kind-38385 info event (a node that advertises + nothing — e.g. a pre-v2 daemon — is treated as `gift-wrap`). Pass the flag to + override auto-detection. Must match the node's `transport` setting. See + `docs/TRANSPORT_V2_SPEC.md`. +- `-v, --verbose`: enable info-level logging (also surfaces the resolved transport). + ### Orders - **`listorders`** diff --git a/src/cli.rs b/src/cli.rs index 5c0383d..834401d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -479,6 +479,12 @@ async fn init_context(cli: &Cli) -> Result { // Connect to Nostr relays let client = util::connect_nostr().await?; + // Resolve the wire transport once, at startup. An explicit + // `--transport`/`TRANSPORT` wins; otherwise auto-detect from the node's + // advertised `protocol_version` so the operator need not match it by hand + // (docs/TRANSPORT_V2_SPEC.md Phase 3). + resolve_transport(&client, mostro_pubkey).await; + Ok(Context { client, identity_keys, @@ -490,6 +496,42 @@ async fn init_context(cli: &Cli) -> Result { }) } +/// Resolve the wire transport into the `TRANSPORT` env var the messaging layer +/// reads (`parse_transport_env`). An explicit `--transport` / `TRANSPORT` is +/// authoritative and skips the network probe; otherwise the node's advertised +/// `protocol_version` tag (kind-38385 info event) selects it. A node that +/// publishes nothing — a pre-v2 daemon, or an unreachable relay — leaves the +/// var unset, so the messaging layer falls back to the gift-wrap default. This +/// also guards against accidentally pairing a v2-capable CLI with an older +/// daemon: absent the tag, the CLI stays on v1. +async fn resolve_transport(client: &Client, mostro_pubkey: PublicKey) { + // Only an explicit, non-empty value is authoritative — mirror + // `parse_transport_env`, which treats empty/whitespace as unset and falls + // back to the default. Otherwise `TRANSPORT=""` (e.g. `--transport ""`) + // would skip auto-detection and leave the var empty, silently pairing a v2 + // node to the gift-wrap default. + if let Ok(explicit) = std::env::var("TRANSPORT") { + if !explicit.trim().is_empty() { + log::info!("Transport: {explicit} (explicit)"); + return; + } + } + match util::events::fetch_protocol_version_with(client.clone(), mostro_pubkey).await { + Some(2) => { + set_var("TRANSPORT", "nip44"); + log::info!("Transport: nip44 (auto-detected protocol v2)"); + } + Some(1) => log::info!("Transport: gift-wrap (auto-detected protocol v1)"), + Some(other) => { + log::warn!("Node advertised unknown protocol_version={other}; defaulting to gift-wrap") + } + None => log::info!( + "Could not detect node transport (no kind-38385 protocol_version tag); \ + defaulting to gift-wrap" + ), + } +} + fn is_admin_command(command: &Option) -> bool { matches!( command, diff --git a/src/util/events.rs b/src/util/events.rs index 7da5f7b..a910984 100644 --- a/src/util/events.rs +++ b/src/util/events.rs @@ -175,6 +175,30 @@ pub async fn fetch_required_pow_with(client: Client, mostro_pubkey: PublicKey) - read_info_tag_from_event(event, "pow").and_then(|v| v.parse::().ok()) } +/// Timeout for the startup transport-capability probe. Deliberately short: it +/// runs before every command when `--transport`/`TRANSPORT` is unset, so a +/// node that publishes no info event must degrade to the gift-wrap default +/// quickly rather than blocking the command for the full +/// [`FETCH_EVENTS_TIMEOUT`]. +pub const INFO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Fetch the node's advertised protocol version from the kind-38385 info +/// event's `protocol_version` tag (`"1"` = gift wrap, `"2"` = NIP-44 direct; +/// see the daemon's docs/TRANSPORT_V2_SPEC.md §4). +/// +/// `None` when the node publishes no info event, the tag is absent (a pre-v2 +/// daemon), or the value is unparseable — every such case is treated by the +/// caller as "assume v1/gift-wrap". Used by the CLI's startup auto-detection +/// when the operator didn't pick a transport explicitly. +pub async fn fetch_protocol_version_with(client: Client, mostro_pubkey: PublicKey) -> Option { + let filter = Filter::new() + .author(mostro_pubkey) + .kind(nostr_sdk::Kind::Custom(NOSTR_INFO_EVENT_KIND)); + let events = client.fetch_events(filter, INFO_PROBE_TIMEOUT).await.ok()?; + let event = events.iter().max_by_key(|e| e.created_at)?; + read_info_tag_from_event(event, "protocol_version").and_then(|v| v.trim().parse::().ok()) +} + #[allow(clippy::too_many_arguments)] pub async fn fetch_events_list( list_kind: ListKind, @@ -314,6 +338,31 @@ mod tests { assert_eq!(read_info_tag_from_event(&event, "pow"), None); } + #[tokio::test] + async fn protocol_version_tag_reads_and_parses() { + // Mirrors how `fetch_protocol_version_with` extracts the daemon's + // single-value `protocol_version` tag ("1" = gift wrap, "2" = nip44) + // and parses it to the u8 the CLI's auto-detect maps to a Transport. + let keys = Keys::generate(); + let event = make_info_event( + &keys, + vec![pow_tag("0"), Tag::parse(["protocol_version", "2"]).unwrap()], + ) + .await; + assert_eq!( + read_info_tag_from_event(&event, "protocol_version").as_deref(), + Some("2") + ); + assert_eq!( + read_info_tag_from_event(&event, "protocol_version") + .and_then(|v| v.trim().parse::().ok()), + Some(2) + ); + // Absent tag (a pre-v2 daemon) → None → caller assumes gift-wrap. + let bare = make_info_event(&keys, vec![pow_tag("0")]).await; + assert_eq!(read_info_tag_from_event(&bare, "protocol_version"), None); + } + #[tokio::test] async fn pow_tag_parses_as_u8() { // u8 parse is what fetch_required_pow chains after the helper. diff --git a/src/util/net.rs b/src/util/net.rs index 0bf2409..4d46eda 100644 --- a/src/util/net.rs +++ b/src/util/net.rs @@ -1,6 +1,15 @@ use anyhow::Result; use nostr_sdk::prelude::*; use std::env::var; +use std::time::Duration; + +/// Upper bound on how long [`connect_nostr`] blocks waiting for the relay +/// handshakes to complete. `Client::connect` only *spawns* background +/// connection tasks and returns immediately, so without this wait the very +/// next network op (the transport probe, then `subscribe` + `send_dm`) races +/// the still-in-progress handshake. On a fast/local relay this returns in +/// milliseconds; it only blocks the full budget when a relay is unreachable. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub async fn connect_nostr() -> Result { let my_keys = Keys::generate(); @@ -12,5 +21,12 @@ pub async fn connect_nostr() -> Result { client.add_relay(r).await?; } client.connect().await; + // `connect` is fire-and-forget: it doesn't wait for the sockets to come up. + // Block until the relays are actually connected so the immediately + // following transport auto-detection, subscription and DM publish don't + // race the handshake — otherwise a fast (e.g. local) Mostro can reply + // before our subscription lands and, with `limit(0)`, the live-only + // subscription never sees the stored reply → `wait_for_dm` times out. + client.wait_for_connection(CONNECT_TIMEOUT).await; Ok(client) }