Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions docs/TRANSPORT_V2_SPEC.md
Original file line number Diff line number Diff line change
@@ -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**
Expand All @@ -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:

Expand Down Expand Up @@ -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 <bits>` on the CLI.
18 changes: 18 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NPUB|HEX>` (`MOSTRO_PUBKEY`): the Mostro node to talk to. Required.
- `-r, --relays <CSV>` (`RELAYS`): comma-separated relay URLs.
- `-p, --pow <BITS>` (`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 <gift-wrap|nip44>` (`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`**
Expand Down
42 changes: 42 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,12 @@ async fn init_context(cli: &Cli) -> Result<Context> {
// 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,
Expand All @@ -490,6 +496,42 @@ async fn init_context(cli: &Cli) -> Result<Context> {
})
}

/// 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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<Commands>) -> bool {
matches!(
command,
Expand Down
49 changes: 49 additions & 0 deletions src/util/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u8>().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<u8> {
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::<u8>().ok())
}

#[allow(clippy::too_many_arguments)]
pub async fn fetch_events_list(
list_kind: ListKind,
Expand Down Expand Up @@ -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::<u8>().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.
Expand Down
16 changes: 16 additions & 0 deletions src/util/net.rs
Original file line number Diff line number Diff line change
@@ -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<Client> {
let my_keys = Keys::generate();
Expand All @@ -12,5 +21,12 @@ pub async fn connect_nostr() -> Result<Client> {
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)
}
Loading