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
80 changes: 54 additions & 26 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:** 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**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -93,37 +94,64 @@ 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 <gift-wrap|nip44>` 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 <gift-wrap|nip44>` 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

- 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.
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.

Expand Down
9 changes: 9 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ pub struct Cli {
pub pow: Option<String>,
#[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<String>,
}

#[derive(Subcommand, Clone)]
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cli/last_trade_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/cli/orders_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/cli/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/cli/send_msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,21 @@ 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
let events = ctx
.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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make range-order follow-up fetch use the selected transport

When --transport nip44 is used and a release of a range order generates the follow-up NewOrder, this path still feeds parse_dm_events from create_filter(ListKind::DirectMessagesUser, ...), whose DirectMessages branch is hard-coded to Kind::GiftWrap in src/util/events.rs. The v2 child-order event is kind 14 and authored by Mostro, so it is never fetched here and print_commands_results is not reached to display/record the replacement order. Please make this follow-up subscription transport-aware as well, including the Mostro author pin for v2.

Useful? React with 👍 / 👎.

if !messages.is_empty() {
for (message, _, _) in messages {
let message_kind = message.get_inner_message_kind();
Expand Down
6 changes: 3 additions & 3 deletions src/cli/take_dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))?;
Expand Down Expand Up @@ -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"))?;
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 61 additions & 34 deletions src/parser/dms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<EventId>::new();
let mut direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new();
Expand All @@ -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 {
Expand Down
Loading
Loading