diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md index e3bb1aa..60ddb6d 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:** Phase 1 implemented · Phases 2–3 pending +**Status:** Phases 1–2 implemented · Phase 3 pending **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** @@ -43,7 +43,8 @@ verification; the CLI only chooses which wrap/unwrap entry point to call. > **Note — kind 14 is overloaded.** The CLI already uses kind 14 for NIP-17 > peer-to-peer chat (`SendDm` / `dm-to-user`). Protocol-v2 Mostro messages are -> *also* kind 14 but use mostro-core's `wrap_message_nip44` layout and are +> *also* kind 14 but use mostro-core's protocol-v2 layout (produced by the +> `wrap_message_with(transport, …)` dispatcher the CLI calls) and are > authored by / addressed to Mostro. The two are disambiguated on receive by > author + `p` tag and by which conversation key decrypts (a non-matching > event yields `Ok(None)` from `unwrap_incoming`). Peer chat is out of scope @@ -93,29 +94,51 @@ Acceptance: `cargo build`, `cargo test`, `cargo clippy --all-targets --all-features`, `cargo fmt --check` all clean; behaviour identical to before against a gift-wrap node. -### Phase 2 — Transport selection (v2 capability) — PENDING - -Teach the CLI to send and receive on either transport, selected explicitly. - -- **Config:** a `TRANSPORT` env var / `--transport ` flag, - parsed into `Transport` (default `gift-wrap` — wire-identical to today). - Mirrors the daemon's `[mostro] transport` knob. Store it on `Context`. -- **Send:** route the Mostro-protocol path of `send_dm` through - `wrap_message_with(ctx.transport, …)` instead of the hard-wired - `wrap_message`. The NIP-17 peer-chat path (`to_user`) is untouched. -- **Receive:** replace the hard-coded `Kind::GiftWrap` filter in `wait_for_dm` - (and the notification-loop kind check) with `ctx.transport.event_kind()`. - For v2, additionally constrain the filter to `author = mostro_pubkey` so the - Mostro reply is not confused with NIP-17 peer chat on the same kind. -- **Unwrap:** `parse_dm_events` calls `unwrap_incoming` instead of - `unwrap_message`, so it transparently handles whichever kind arrived. -- **Blast radius:** the ~12 command call sites of `send_dm` thread - `ctx.transport` through; no per-command logic changes. - -Acceptance: against a `transport = "nip44"` daemon, a full -`new-order → take → add-invoice → fiat-sent → release` round-trips; against a -gift-wrap daemon, behaviour is unchanged. This is the phase that lets us test -the daemon's Phase 2 anti-spam gates. +### Phase 2 — Transport selection (v2 capability) — IMPLEMENTED + +Teaches the CLI to send and receive on either transport, selected explicitly. + +- **Config:** a `--transport ` flag (`-t`) that sets a + `TRANSPORT` env var, resolved via `messaging::parse_transport_env()` into + `Transport` (default `gift-wrap` — wire-identical to today). This mirrors how + `POW` / `SECRET` are already read from the environment rather than threaded + through every call site, so `send_dm`'s signature (and its ~14 callers) is + untouched. Mirrors the daemon's `[mostro] transport` knob. +- **Send:** the Mostro-protocol path of `send_dm` / `send_plain_text_dm` goes + through a new `publish_wrapped` → `wrap_message_with(transport, …)`, + replacing the hard-wired `wrap_message`. The NIP-17 peer-chat path + (`to_user`) is untouched. +- **Receive:** `wait_for_dm` subscribes on `transport.event_kind()` (and its + notification loop matches that kind). For v2 it additionally pins + `author = mostro_pubkey` so the Mostro reply is never confused with NIP-17 + peer chat on the same kind 14. +- **Unwrap:** `parse_dm_events` gains a `mostro_protocol: bool`; when `true` it + decodes via `unwrap_incoming` (dispatches on kind: 1059 / 14), when `false` + it keeps the NIP-17 peer-chat path. All Mostro-reply / Mostro→user-DM call + sites pass `true`; the one peer-chat listing call passes `false`. + +Tests: `Transport::from_str` → `event_kind` mapping, and a +`wrap_message_with(Nip44Direct) → unwrap_incoming` roundtrip (kind 14, author = +trade key, message round-trips). Full suite green; clippy + fmt clean. + +- **Listing / follow-up fetches:** `create_filter` for the `DirectMessages*` + kinds is transport-aware as well — it uses `transport.event_kind()` and pins + `author = mostro_pubkey` on v2 (new `mostro_pubkey` param). This covers both + the `get-dm` historical listing **and** the range-order child-order follow-up + fetched after a `release` (which a v2 node delivers as a kind-14 event + authored by Mostro), so the whole interactive path is fully v2. + +Acceptance: against a `transport = "nip44"` daemon, run the CLI with +`--transport nip44` and a full `new-order → take → add-invoice → fiat-sent → +release` round-trips; against a gift-wrap daemon (default), behaviour is +unchanged. This is the phase that lets us test the daemon's Phase 2 anti-spam +gates. + +> The daemon arms those anti-spam gates on the **event kind** (14 = NIP-44 +> direct), not on `Message.version`. So choosing `--transport nip44` is what +> triggers them — not the fact that Phase 1 already bumped `Message.version` +> 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 @@ -123,7 +146,12 @@ the daemon's Phase 2 anti-spam gates. (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. + 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. diff --git a/src/cli.rs b/src/cli.rs index dccc495..5c0383d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -92,6 +92,11 @@ pub struct Cli { pub pow: Option, #[arg(short, long)] pub secret: bool, + /// Wire transport to speak to the node: "gift-wrap" (protocol v1, default) + /// or "nip44" (protocol v2). Must match the node's `transport` setting + /// (advertised on its kind-38385 info event). See docs/TRANSPORT_V2_SPEC.md. + #[arg(short, long)] + pub transport: Option, } #[derive(Subcommand, Clone)] @@ -371,6 +376,10 @@ fn get_env_var(cli: &Cli) { if cli.secret { set_var("SECRET", "true"); } + + if let Some(ref transport) = cli.transport { + set_var("TRANSPORT", transport.clone()); + } } // Check range with two values value diff --git a/src/cli/last_trade_index.rs b/src/cli/last_trade_index.rs index bdf5746..203527f 100644 --- a/src/cli/last_trade_index.rs +++ b/src/cli/last_trade_index.rs @@ -48,7 +48,7 @@ pub async fn execute_last_trade_index( let recv_event = wait_for_dm(ctx, Some(identity_keys), sent_message).await?; // Parse the incoming DM - let messages = parse_dm_events(recv_event, identity_keys, None).await; + let messages = parse_dm_events(recv_event, identity_keys, None, true).await; if let Some((message, _, _)) = messages.first() { let message = message.get_inner_message_kind(); if message.action == Action::LastTradeIndex { diff --git a/src/cli/orders_info.rs b/src/cli/orders_info.rs index 9b061fb..f8e6dc1 100644 --- a/src/cli/orders_info.rs +++ b/src/cli/orders_info.rs @@ -52,7 +52,8 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<() let recv_event = wait_for_dm(ctx, Some(&ctx.identity_keys), sent_message).await?; // Parse the incoming DM and handle the response - let messages = crate::parser::dms::parse_dm_events(recv_event, &ctx.identity_keys, None).await; + let messages = + crate::parser::dms::parse_dm_events(recv_event, &ctx.identity_keys, None, true).await; if let Some((message, _, _)) = messages.first() { let message_kind = message.get_inner_message_kind(); diff --git a/src/cli/restore.rs b/src/cli/restore.rs index c913ae0..3555173 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -57,7 +57,7 @@ pub async fn execute_restore( let recv_event = wait_for_dm(ctx, Some(identity_keys), sent_message).await?; // Parse the incoming DM - let messages = parse_dm_events(recv_event, identity_keys, None).await; + let messages = parse_dm_events(recv_event, identity_keys, None, true).await; if let Some((message, _, _)) = messages.first() { let message = message.get_inner_message_kind(); if message.action == Action::RestoreSession { diff --git a/src/cli/send_msg.rs b/src/cli/send_msg.rs index 4fd6e30..216076e 100644 --- a/src/cli/send_msg.rs +++ b/src/cli/send_msg.rs @@ -120,10 +120,13 @@ pub async fn execute_send_msg( // Get the correct keys for decoding the child order message let next_trade_key = User::get_trade_keys(&ctx.pool, *index as i64).await?; // Fake timestamp for giftwraps + // Transport-aware so the v2 child-order event (kind 14, + // authored by Mostro) is fetched too, not just gift wraps. let subscription = create_filter( ListKind::DirectMessagesUser, next_trade_key.public_key, None, + ctx.mostro_pubkey, )?; // Wait for potential new order message from Mostro @@ -131,7 +134,7 @@ pub async fn execute_send_msg( .client .fetch_events(subscription, FETCH_EVENTS_TIMEOUT) .await?; - let messages = parse_dm_events(events, &next_trade_key, Some(&2)).await; + let messages = parse_dm_events(events, &next_trade_key, Some(&2), true).await; if !messages.is_empty() { for (message, _, _) in messages { let message_kind = message.get_inner_message_kind(); diff --git a/src/cli/take_dispute.rs b/src/cli/take_dispute.rs index da31869..ab1e07b 100644 --- a/src/cli/take_dispute.rs +++ b/src/cli/take_dispute.rs @@ -112,7 +112,7 @@ pub async fn execute_admin_cancel_dispute( ) })?; - let messages = parse_dm_events(recv_event, admin_keys, None).await; + let messages = parse_dm_events(recv_event, admin_keys, None, true).await; let (message, _, sender_pubkey) = messages .first() .ok_or_else(|| anyhow::anyhow!("No response received from Mostro"))?; @@ -204,7 +204,7 @@ pub async fn execute_admin_settle_dispute( ) })?; - let messages = parse_dm_events(recv_event, admin_keys, None).await; + let messages = parse_dm_events(recv_event, admin_keys, None, true).await; let (message, _, sender_pubkey) = messages .first() .ok_or_else(|| anyhow::anyhow!("No response received from Mostro"))?; @@ -277,7 +277,7 @@ pub async fn execute_take_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<() let recv_event = wait_for_dm(ctx, Some(admin_keys), sent_message).await?; // Parse the incoming DM - let messages = parse_dm_events(recv_event, admin_keys, None).await; + let messages = parse_dm_events(recv_event, admin_keys, None, true).await; if let Some((message, _, sender_pubkey)) = messages.first() { let message_kind = message.get_inner_message_kind(); if *sender_pubkey != ctx.mostro_pubkey { diff --git a/src/parser/dms.rs b/src/parser/dms.rs index 287a940..f99ef08 100644 --- a/src/parser/dms.rs +++ b/src/parser/dms.rs @@ -876,10 +876,25 @@ pub async fn print_commands_results(message: &MessageKind, ctx: &Context) -> Res } } +/// Parse a batch of incoming events into `(Message, created_at, sender)`. +/// +/// `mostro_protocol` selects how each event is decoded: +/// - `true` — Mostro-protocol messages: route every event through +/// mostro-core's [`unwrap_incoming`], which dispatches on the event kind +/// (1059 gift wrap, v1 / 14 NIP-44 direct, v2) and returns the same +/// `UnwrappedMessage`. This is the receive path for replies to commands and +/// for Mostro→user DM listings. +/// - `false` — NIP-17 peer-to-peer chat: kind-14 events are decrypted with the +/// trade↔peer conversation key (these are *not* Mostro-protocol messages and +/// carry a bare `Message`, not the v2 tuple). +/// +/// Keeping the two explicit avoids misparsing peer chat as a Mostro message on +/// the v2 transport, where both share kind 14 (see docs/TRANSPORT_V2_SPEC.md). pub async fn parse_dm_events( events: Events, pubkey: &Keys, since: Option<&i64>, + mostro_protocol: bool, ) -> Vec<(Message, u64, PublicKey)> { let mut id_set = HashSet::::new(); let mut direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new(); @@ -890,49 +905,61 @@ pub async fn parse_dm_events( continue; } - let (created_at, message, sender) = match dm.kind { - nostr_sdk::Kind::GiftWrap => match unwrap_message(dm, pubkey).await { + let (created_at, message, sender) = if mostro_protocol { + match unwrap_incoming(dm, pubkey).await { Ok(Some(u)) => (u.created_at, u.message, u.sender), - Ok(None) => continue, // outer NIP-44 failed → not addressed to us + Ok(None) => continue, // decrypt failed → not addressed to us Err(e) => { - eprintln!("Warning: could not unwrap gift wrap (event {}): {e}", dm.id); + eprintln!("Warning: could not unwrap message (event {}): {e}", dm.id); continue; } - }, - nostr_sdk::Kind::PrivateDirectMessage => { - let ck = if let Ok(ck) = ConversationKey::derive(pubkey.secret_key(), &dm.pubkey) { - ck - } else { - continue; - }; - let b64decoded_content = - match general_purpose::STANDARD.decode(dm.content.as_bytes()) { - Ok(b64decoded_content) => b64decoded_content, + } + } else { + match dm.kind { + nostr_sdk::Kind::GiftWrap => match unwrap_message(dm, pubkey).await { + Ok(Some(u)) => (u.created_at, u.message, u.sender), + Ok(None) => continue, // outer NIP-44 failed → not addressed to us + Err(e) => { + eprintln!("Warning: could not unwrap gift wrap (event {}): {e}", dm.id); + continue; + } + }, + nostr_sdk::Kind::PrivateDirectMessage => { + let ck = + if let Ok(ck) = ConversationKey::derive(pubkey.secret_key(), &dm.pubkey) { + ck + } else { + continue; + }; + let b64decoded_content = + match general_purpose::STANDARD.decode(dm.content.as_bytes()) { + Ok(b64decoded_content) => b64decoded_content, + Err(_) => { + continue; + } + }; + let unencrypted_content = match decrypt_to_bytes(&ck, &b64decoded_content) { + Ok(bytes) => bytes, Err(_) => { continue; } }; - let unencrypted_content = match decrypt_to_bytes(&ck, &b64decoded_content) { - Ok(bytes) => bytes, - Err(_) => { - continue; - } - }; - let message_str = match String::from_utf8(unencrypted_content) { - Ok(s) => s, - Err(_) => { - continue; - } - }; - let message = match Message::from_json(&message_str) { - Ok(m) => m, - Err(_) => { - continue; - } - }; - (dm.created_at, message, dm.pubkey) + let message_str = match String::from_utf8(unencrypted_content) { + Ok(s) => s, + Err(_) => { + continue; + } + }; + let message = match Message::from_json(&message_str) { + Ok(m) => m, + Err(_) => { + continue; + } + }; + (dm.created_at, message, dm.pubkey) + } + _ => continue, } - _ => continue, }; // check if the message is older than the since time if it is, skip it if let Some(since_time) = since { diff --git a/src/util/events.rs b/src/util/events.rs index 40435e4..4aea254 100644 --- a/src/util/events.rs +++ b/src/util/events.rs @@ -42,6 +42,7 @@ pub fn create_filter( list_kind: ListKind, pubkey: PublicKey, since: Option<&i64>, + mostro_pubkey: PublicKey, ) -> Result { match list_kind { ListKind::Orders => create_seven_days_filter( @@ -58,10 +59,20 @@ pub fn create_filter( ), ListKind::DirectMessagesAdmin | ListKind::DirectMessagesUser => { let fake_timestamp = create_fake_timestamp()?; - Ok(Filter::new() - .kind(nostr_sdk::Kind::GiftWrap) + // Mostro→user/admin DMs travel on the node's transport: gift wrap + // (v1, kind 1059) or NIP-44 direct (v2, kind 14). On v2 the reply + // is authored by Mostro's own key, so pin the author to keep it + // distinct from NIP-17 peer chat that shares kind 14 + // (docs/TRANSPORT_V2_SPEC.md §2). + let transport = crate::util::messaging::parse_transport_env()?; + let mut filter = Filter::new() + .kind(transport.event_kind()) .pubkey(pubkey) - .since(fake_timestamp)) + .since(fake_timestamp); + if transport == Transport::Nip44Direct { + filter = filter.author(mostro_pubkey); + } + Ok(filter) } ListKind::PrivateDirectMessagesUser => { let since = if let Some(mins) = since { @@ -175,7 +186,7 @@ pub async fn fetch_events_list( ) -> Result> { match list_kind { ListKind::Orders => { - let filters = create_filter(list_kind, ctx.mostro_pubkey, None)?; + let filters = create_filter(list_kind, ctx.mostro_pubkey, None, ctx.mostro_pubkey)?; let fetched_events = ctx .client .fetch_events(filters, FETCH_EVENTS_TIMEOUT) @@ -187,12 +198,14 @@ pub async fn fetch_events_list( // Get admin keys let admin_keys = get_admin_keys(ctx)?; // Create filter - let filters = create_filter(list_kind, admin_keys.public_key(), None)?; + let filters = + create_filter(list_kind, admin_keys.public_key(), None, ctx.mostro_pubkey)?; let fetched_events = ctx .client .fetch_events(filters, FETCH_EVENTS_TIMEOUT) .await?; - let direct_messages_mostro = parse_dm_events(fetched_events, admin_keys, since).await; + let direct_messages_mostro = + parse_dm_events(fetched_events, admin_keys, since, true).await; Ok(direct_messages_mostro .into_iter() .map(|(message, timestamp, sender_pubkey)| { @@ -208,13 +221,16 @@ pub async fn fetch_events_list( ListKind::PrivateDirectMessagesUser, trade_key.public_key(), None, + ctx.mostro_pubkey, )?; let fetched_user_messages = ctx .client .fetch_events(filter, FETCH_EVENTS_TIMEOUT) .await?; + // NIP-17 peer-to-peer chat (not Mostro-protocol): decode with + // the trade↔peer conversation key. let direct_messages_for_trade_key = - parse_dm_events(fetched_user_messages, &trade_key, since).await; + parse_dm_events(fetched_user_messages, &trade_key, since, false).await; // Extend the direct messages direct_messages.extend(direct_messages_for_trade_key); } @@ -227,14 +243,22 @@ pub async fn fetch_events_list( let mut direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new(); for index in 1..=ctx.trade_index { let trade_key = User::get_trade_keys(&ctx.pool, index).await?; - let filter = - create_filter(ListKind::DirectMessagesUser, trade_key.public_key(), None)?; + let filter = create_filter( + ListKind::DirectMessagesUser, + trade_key.public_key(), + None, + ctx.mostro_pubkey, + )?; let fetched_user_messages = ctx .client .fetch_events(filter, FETCH_EVENTS_TIMEOUT) .await?; + // Mostro→user DMs (Mostro-protocol): the filter above already + // selected the right kind per transport — gift wrap (v1) or + // kind-14 NIP-44 direct (v2) — and `unwrap_incoming` decodes + // whichever arrived. let direct_messages_for_trade_key = - parse_dm_events(fetched_user_messages, &trade_key, since).await; + parse_dm_events(fetched_user_messages, &trade_key, since, true).await; // Extend the direct messages direct_messages.extend(direct_messages_for_trade_key); } @@ -244,7 +268,7 @@ pub async fn fetch_events_list( .collect()) } ListKind::Disputes => { - let filters = create_filter(list_kind, ctx.mostro_pubkey, None)?; + let filters = create_filter(list_kind, ctx.mostro_pubkey, None, ctx.mostro_pubkey)?; let fetched_events = ctx .client .fetch_events(filters, FETCH_EVENTS_TIMEOUT) diff --git a/src/util/messaging.rs b/src/util/messaging.rs index eeb97cd..4d4bdc4 100644 --- a/src/util/messaging.rs +++ b/src/util/messaging.rs @@ -5,6 +5,7 @@ use mostro_core::prelude::*; use nip44::v2::{encrypt_to_bytes, ConversationKey}; use nostr_sdk::prelude::*; use std::env::var; +use std::str::FromStr; use crate::cli::Context; use crate::parser::dms::print_commands_results; @@ -200,23 +201,47 @@ pub async fn fetch_gift_wraps_for_shared_key( Ok(messages) } -/// Internal: wrap a Mostro `Message` via [`wrap_message`] and publish it. +/// Resolve the wire transport from the `TRANSPORT` env var (set from the +/// `--transport` flag in `get_env_var`). Absent/empty ⇒ `gift-wrap` +/// (protocol v1), so existing invocations are wire-identical. Mirrors how +/// `POW` / `SECRET` are read here rather than threaded through every call +/// site. See docs/TRANSPORT_V2_SPEC.md. +pub fn parse_transport_env() -> Result { + match var("TRANSPORT") { + Ok(s) if !s.trim().is_empty() => Transport::from_str(s.trim()) + .map_err(|e| anyhow::anyhow!("Invalid TRANSPORT '{}': {e}", s.trim())), + _ => Ok(Transport::default()), + } +} + +/// Internal: wrap a Mostro `Message` for the configured `transport` and +/// publish it. /// -/// Follows the mostro-core 0.10 dual-key split: `identity_keys` sign the -/// seal (long-lived reputation binding), `trade_keys` author the rumor and -/// produce the inner tuple signature. Pass the same `Keys` for both to -/// opt into full-privacy mode. -async fn publish_gift_wrap( +/// Routes through mostro-core's [`wrap_message_with`], which dispatches to the +/// protocol-v1 gift wrap (kind 1059) or the protocol-v2 NIP-44 direct event +/// (kind 14) per `transport`. Follows the dual-key split: `identity_keys` +/// sign the seal / identity proof (long-lived reputation binding), +/// `trade_keys` author the event and produce the inner tuple signature. Pass +/// the same `Keys` for both to opt into full-privacy mode. +async fn publish_wrapped( client: &Client, + transport: Transport, identity_keys: &Keys, trade_keys: &Keys, receiver_pubkey: &PublicKey, message: &Message, opts: WrapOptions, ) -> Result<()> { - let event = wrap_message(message, identity_keys, trade_keys, *receiver_pubkey, opts) - .await - .map_err(|e| anyhow::anyhow!("Failed to wrap message: {e}"))?; + let event = wrap_message_with( + transport, + message, + identity_keys, + trade_keys, + *receiver_pubkey, + opts, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to wrap message: {e}"))?; client.send_event(&event).await?; Ok(()) } @@ -245,8 +270,9 @@ pub async fn send_plain_text_dm( expiration: None, signed: false, }; - publish_gift_wrap( + publish_wrapped( client, + parse_transport_env()?, identity_keys, trade_keys, receiver_pubkey, @@ -318,13 +344,24 @@ where { let trade_keys = order_trade_keys.unwrap_or(&ctx.trade_keys); let trade_pubkey = trade_keys.public_key(); + // Subscribe on the configured transport's event kind: 1059 (gift wrap, + // v1) or 14 (NIP-44 direct, v2). On v2, kind 14 is shared with NIP-17 + // peer chat, so additionally pin the author to Mostro's key to keep the + // reply unambiguous (see docs/TRANSPORT_V2_SPEC.md §2). + let transport = parse_transport_env()?; + let accepted_kind = transport.event_kind(); + let is_v2 = transport == Transport::Nip44Direct; + let mostro_pubkey = ctx.mostro_pubkey; let mut notifications = ctx.client.notifications(); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::WaitForEventsAfterEOSE(1)); - let subscription = Filter::new() + let mut subscription = Filter::new() .pubkey(trade_pubkey) - .kind(nostr_sdk::Kind::GiftWrap) + .kind(accepted_kind) .limit(0); + if is_v2 { + subscription = subscription.author(mostro_pubkey); + } ctx.client.subscribe(subscription, Some(opts)).await?; // Send message here after opening notifications to avoid missing messages. @@ -354,12 +391,17 @@ where loop { match notifications.recv().await { Ok(RelayPoolNotification::Event { event, .. }) => { - if event.kind != nostr_sdk::Kind::GiftWrap { + if event.kind != accepted_kind { continue; } if !event.tags.public_keys().any(|pk| *pk == trade_pubkey) { continue; } + // v2: reject any kind-14 not authored by Mostro (e.g. an + // unrelated NIP-17 peer chat tagged to this trade key). + if is_v2 && event.pubkey != mostro_pubkey { + continue; + } return Ok(*event); } Ok(_) => continue, @@ -486,8 +528,9 @@ pub async fn send_dm( signed: !private, }; - publish_gift_wrap( + publish_wrapped( client, + parse_transport_env()?, identity_keys, trade_keys, receiver_pubkey, @@ -504,7 +547,8 @@ pub async fn print_dm_events( order_trade_keys: Option<&Keys>, ) -> Result<()> { let trade_keys = order_trade_keys.unwrap_or(&ctx.trade_keys); - let messages = parse_dm_events(recv_event, trade_keys, None).await; + // Mostro-protocol reply: unwrap via the transport-agnostic dispatcher. + let messages = parse_dm_events(recv_event, trade_keys, None, true).await; let (message, _, _) = messages .first() .ok_or_else(|| anyhow::anyhow!("No response received from Mostro"))?; @@ -598,6 +642,64 @@ mod tests { // Message we hand to send_dm survives a wrap→unwrap roundtrip and that // our WrapOptions knobs (signed, pow) reach the outer event. + #[test] + fn transport_from_str_maps_to_event_kind() { + // The CLI resolves `TRANSPORT` via Transport::from_str and subscribes + // on `event_kind()`. Lock down the mapping the receive/send paths rely + // on (docs/TRANSPORT_V2_SPEC.md §3). + let v1 = Transport::from_str("gift-wrap").expect("gift-wrap parses"); + assert_eq!(v1, Transport::GiftWrap); + assert_eq!(v1.event_kind(), nostr_sdk::Kind::GiftWrap); + + let v2 = Transport::from_str("nip44").expect("nip44 parses"); + assert_eq!(v2, Transport::Nip44Direct); + assert_eq!(v2.event_kind(), nostr_sdk::Kind::PrivateDirectMessage); + + assert_eq!(Transport::default(), Transport::GiftWrap); + assert!(Transport::from_str("bogus").is_err()); + } + + #[tokio::test] + async fn nip44_transport_roundtrips_via_unwrap_incoming() { + // The v2 send/receive wiring: wrap_message_with(Nip44Direct, …) must + // produce a kind-14 event that unwrap_incoming (used by + // parse_dm_events on the Mostro-protocol path) decodes back to the + // same message, authored by the trade key. + let identity_keys = Keys::generate(); + let trade_keys = Keys::generate(); + let mostro_keys = Keys::generate(); + let message = sample_protocol_message(Some(99)); + + let event = wrap_message_with( + Transport::Nip44Direct, + &message, + &identity_keys, + &trade_keys, + mostro_keys.public_key(), + WrapOptions::default(), + ) + .await + .expect("wrap v2"); + + assert_eq!(event.kind, nostr_sdk::Kind::PrivateDirectMessage); + assert_eq!( + event.pubkey, + trade_keys.public_key(), + "author is the trade key" + ); + + let unwrapped = unwrap_incoming(&event, &mostro_keys) + .await + .expect("unwrap result") + .expect("addressed to mostro_keys"); + assert_eq!(unwrapped.sender, trade_keys.public_key()); + assert_eq!(unwrapped.identity, identity_keys.public_key()); + assert_eq!( + unwrapped.message.as_json().unwrap(), + message.as_json().unwrap() + ); + } + #[tokio::test] async fn send_dm_gift_wrap_roundtrips_via_unwrap_message() { let identity_keys = Keys::generate(); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 456bfc5..0989ddf 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -64,6 +64,7 @@ async fn test_filter_creation_integration() { mostro_client::util::ListKind::Orders, ctx.mostro_pubkey, None, + ctx.mostro_pubkey, ) .unwrap(); @@ -75,3 +76,37 @@ async fn test_filter_creation_integration() { .unwrap() .contains(&ctx.mostro_pubkey)); } + +// Phase 2: `create_filter` for the `DirectMessages*` kinds is transport-aware. +// On v2 (nip44) it must select kind 14 AND pin `author = mostro_pubkey` (kind 14 +// is shared with NIP-17 peer chat, so the author pin is what disambiguates the +// Mostro reply); on the default gift-wrap it stays kind 1059 with no author pin +// (the outer event is signed by a throwaway key). The transport is read from the +// `TRANSPORT` env var — no other test in this binary reads it, so set/remove here +// is deterministic. +#[test] +fn direct_messages_filter_is_transport_aware() { + use mostro_client::util::{create_filter, ListKind}; + + let trade = Keys::generate().public_key(); + let mostro = Keys::generate().public_key(); + + // v2: kind 14 + author pinned to Mostro. + std::env::set_var("TRANSPORT", "nip44"); + let v2 = create_filter(ListKind::DirectMessagesUser, trade, None, mostro).unwrap(); + std::env::remove_var("TRANSPORT"); + assert!(v2 + .kinds + .as_ref() + .unwrap() + .contains(&Kind::PrivateDirectMessage)); + assert!( + v2.authors.as_ref().unwrap().contains(&mostro), + "v2 DM filter must pin author = mostro_pubkey" + ); + + // Default (gift-wrap): kind 1059, no author pin. + let v1 = create_filter(ListKind::DirectMessagesUser, trade, None, mostro).unwrap(); + assert!(v1.kinds.as_ref().unwrap().contains(&Kind::GiftWrap)); + assert!(v1.authors.is_none(), "v1 DM filter must not pin an author"); +} diff --git a/tests/parser_dms.rs b/tests/parser_dms.rs index 08d3398..87a9aaa 100644 --- a/tests/parser_dms.rs +++ b/tests/parser_dms.rs @@ -6,7 +6,7 @@ use nostr_sdk::prelude::*; async fn parse_dm_empty() { let keys = Keys::generate(); let events = Events::new(&Filter::new()); - let out = parse_dm_events(events, &keys, None).await; + let out = parse_dm_events(events, &keys, None, true).await; assert!(out.is_empty()); } @@ -224,7 +224,7 @@ async fn parse_dm_with_time_filter() { let keys = Keys::generate(); let events = Events::new(&Filter::new()); let since = 1700000000i64; - let out = parse_dm_events(events, &keys, Some(&since)).await; + let out = parse_dm_events(events, &keys, Some(&since), true).await; assert!(out.is_empty()); } @@ -258,7 +258,7 @@ async fn parse_dm_events_accepts_wrap_message_output() { let mut events = Events::new(&Filter::new()); events.insert(wrapped); - let parsed = parse_dm_events(events, &receiver_keys, None).await; + let parsed = parse_dm_events(events, &receiver_keys, None, true).await; assert_eq!(parsed.len(), 1); let (message, _, sender) = &parsed[0]; assert_eq!(sender, &sender_trade_keys.public_key()); @@ -291,7 +291,7 @@ async fn parse_dm_events_skips_events_for_other_keys() { let mut events = Events::new(&Filter::new()); events.insert(wrapped); - let parsed = parse_dm_events(events, &eavesdropper, None).await; + let parsed = parse_dm_events(events, &eavesdropper, None, true).await; assert!(parsed.is_empty()); }