Skip to content

Commit 07a3b18

Browse files
grunchclaude
andcommitted
feat(transport): Phase 2 — protocol-v2 (NIP-44 direct) selection
Phase 2 of docs/TRANSPORT_V2_SPEC.md — the CLI can now speak the node's wire transport (protocol v1 gift wrap, kind 1059, or protocol v2 NIP-44 direct, kind 14), enabling trading against a `transport = "nip44"` node and exercising the daemon's anti-spam gates (MostroP2P/mostro#780). - Config: `--transport <gift-wrap|nip44>` (-t) sets a TRANSPORT env var, resolved via `messaging::parse_transport_env()` (default gift-wrap → wire-identical to today). Read from the env like POW/SECRET already are, so send_dm's signature and its ~14 callers are untouched. - Send: send_dm / send_plain_text_dm route 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 matches it in the notification loop; on v2 it additionally pins `author = mostro_pubkey` so a Mostro reply is never confused with NIP-17 peer chat (same kind 14). - Unwrap: `parse_dm_events` gains `mostro_protocol: bool` — true → decode via `unwrap_incoming` (dispatches on kind 1059/14); false → NIP-17 peer-chat path. All Mostro-reply / Mostro→user-DM call sites pass true; the one peer-chat listing passes false. Tests: Transport::from_str→event_kind mapping; a wrap_message_with(Nip44Direct) → unwrap_incoming roundtrip (kind 14, author = trade key, message round-trips). Full suite green; clippy --all-targets --all-features and fmt clean. Known gap (Phase 3): the get-dm historical-listing filter still hard-codes gift wrap, so listing past Mostro DMs on a v2 node returns nothing. The interactive request/response path (what exercises the anti-spam gate) is fully v2. Based on the mostro-core 0.13.0 bump (Phase 1, #176). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 630d833 commit 07a3b18

11 files changed

Lines changed: 248 additions & 87 deletions

File tree

‎docs/TRANSPORT_V2_SPEC.md‎

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# mostro-cli — Transport v2 (NIP-44 Direct) client support
22

3-
**Status:** Phase 1 implemented · Phases 2–3 pending
3+
**Status:** Phases 1–2 implemented · Phase 3 pending
44
**Daemon spec:** `MostroP2P/mostro` → `docs/TRANSPORT_V2_SPEC.md`
55
**Issue:** [#626 — Messaging Transport Abstraction Layer](https://github.com/MostroP2P/mostro/issues/626)
66
**Core:** `transport` module shipped in **mostro-core 0.13.0**
@@ -93,32 +93,50 @@ Acceptance: `cargo build`, `cargo test`, `cargo clippy --all-targets
9393
--all-features`, `cargo fmt --check` all clean; behaviour identical to before
9494
against a gift-wrap node.
9595

96-
### Phase 2 — Transport selection (v2 capability) — PENDING
97-
98-
Teach the CLI to send and receive on either transport, selected explicitly.
99-
100-
- **Config:** a `TRANSPORT` env var / `--transport <gift-wrap|nip44>` flag,
101-
parsed into `Transport` (default `gift-wrap` — wire-identical to today).
102-
Mirrors the daemon's `[mostro] transport` knob. Store it on `Context`.
103-
- **Send:** route the Mostro-protocol path of `send_dm` through
104-
`wrap_message_with(ctx.transport, …)` instead of the hard-wired
105-
`wrap_message`. The NIP-17 peer-chat path (`to_user`) is untouched.
106-
- **Receive:** replace the hard-coded `Kind::GiftWrap` filter in `wait_for_dm`
107-
(and the notification-loop kind check) with `ctx.transport.event_kind()`.
108-
For v2, additionally constrain the filter to `author = mostro_pubkey` so the
109-
Mostro reply is not confused with NIP-17 peer chat on the same kind.
110-
- **Unwrap:** `parse_dm_events` calls `unwrap_incoming` instead of
111-
`unwrap_message`, so it transparently handles whichever kind arrived.
112-
- **Blast radius:** the ~12 command call sites of `send_dm` thread
113-
`ctx.transport` through; no per-command logic changes.
114-
115-
Acceptance: against a `transport = "nip44"` daemon, a full
116-
`new-order → take → add-invoice → fiat-sent → release` round-trips; against a
117-
gift-wrap daemon, behaviour is unchanged. This is the phase that lets us test
118-
the daemon's Phase 2 anti-spam gates.
96+
### Phase 2 — Transport selection (v2 capability) — IMPLEMENTED
97+
98+
Teaches the CLI to send and receive on either transport, selected explicitly.
99+
100+
- **Config:** a `--transport <gift-wrap|nip44>` flag (`-t`) that sets a
101+
`TRANSPORT` env var, resolved via `messaging::parse_transport_env()` into
102+
`Transport` (default `gift-wrap` — wire-identical to today). This mirrors how
103+
`POW` / `SECRET` are already read from the environment rather than threaded
104+
through every call site, so `send_dm`'s signature (and its ~14 callers) is
105+
untouched. Mirrors the daemon's `[mostro] transport` knob.
106+
- **Send:** the Mostro-protocol path of `send_dm` / `send_plain_text_dm` goes
107+
through a new `publish_wrapped` → `wrap_message_with(transport, …)`,
108+
replacing the hard-wired `wrap_message`. The NIP-17 peer-chat path
109+
(`to_user`) is untouched.
110+
- **Receive:** `wait_for_dm` subscribes on `transport.event_kind()` (and its
111+
notification loop matches that kind). For v2 it additionally pins
112+
`author = mostro_pubkey` so the Mostro reply is never confused with NIP-17
113+
peer chat on the same kind 14.
114+
- **Unwrap:** `parse_dm_events` gains a `mostro_protocol: bool`; when `true` it
115+
decodes via `unwrap_incoming` (dispatches on kind: 1059 / 14), when `false`
116+
it keeps the NIP-17 peer-chat path. All Mostro-reply / Mostro→user-DM call
117+
sites pass `true`; the one peer-chat listing call passes `false`.
118+
119+
Tests: `Transport::from_str` → `event_kind` mapping, and a
120+
`wrap_message_with(Nip44Direct) → unwrap_incoming` roundtrip (kind 14, author =
121+
trade key, message round-trips). Full suite green; clippy + fmt clean.
122+
123+
Known gap (deferred to Phase 3): the `get-dm` **historical listing** filter
124+
(`create_filter` for the `DirectMessages*` kinds) still hard-codes gift wrap,
125+
so listing past Mostro DMs on a v2 node returns nothing. The interactive
126+
request/response path (the one that exercises the daemon's anti-spam gate) is
127+
fully v2.
128+
129+
Acceptance: against a `transport = "nip44"` daemon, run the CLI with
130+
`--transport nip44` and a full `new-order → take → add-invoice → fiat-sent →
131+
release` round-trips; against a gift-wrap daemon (default), behaviour is
132+
unchanged. This is the phase that lets us test the daemon's Phase 2 anti-spam
133+
gates.
119134

120135
### Phase 3 — Capability auto-detection + docs/UX — PENDING
121136

137+
- Make the `get-dm` historical-listing filter transport-aware
138+
(`create_filter` for the `DirectMessages*` kinds: kind 14 + `author =
139+
mostro_pubkey` on v2), closing the Phase 2 known gap.
122140
- Read the node's `protocol_versions` tag from its kind-`38385` info event
123141
(same fetch path as the existing `pow` probe) and, when `--transport` is not
124142
given, auto-select the matching transport — warning on a mismatch

‎src/cli.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ pub struct Cli {
9292
pub pow: Option<String>,
9393
#[arg(short, long)]
9494
pub secret: bool,
95+
/// Wire transport to speak to the node: "gift-wrap" (protocol v1, default)
96+
/// or "nip44" (protocol v2). Must match the node's `transport` setting
97+
/// (advertised on its kind-38385 info event). See docs/TRANSPORT_V2_SPEC.md.
98+
#[arg(short, long)]
99+
pub transport: Option<String>,
95100
}
96101

97102
#[derive(Subcommand, Clone)]
@@ -371,6 +376,10 @@ fn get_env_var(cli: &Cli) {
371376
if cli.secret {
372377
set_var("SECRET", "true");
373378
}
379+
380+
if let Some(ref transport) = cli.transport {
381+
set_var("TRANSPORT", transport.clone());
382+
}
374383
}
375384

376385
// Check range with two values value

‎src/cli/last_trade_index.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub async fn execute_last_trade_index(
4848
let recv_event = wait_for_dm(ctx, Some(identity_keys), sent_message).await?;
4949

5050
// Parse the incoming DM
51-
let messages = parse_dm_events(recv_event, identity_keys, None).await;
51+
let messages = parse_dm_events(recv_event, identity_keys, None, true).await;
5252
if let Some((message, _, _)) = messages.first() {
5353
let message = message.get_inner_message_kind();
5454
if message.action == Action::LastTradeIndex {

‎src/cli/orders_info.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<()
5252
let recv_event = wait_for_dm(ctx, Some(&ctx.identity_keys), sent_message).await?;
5353

5454
// Parse the incoming DM and handle the response
55-
let messages = crate::parser::dms::parse_dm_events(recv_event, &ctx.identity_keys, None).await;
55+
let messages =
56+
crate::parser::dms::parse_dm_events(recv_event, &ctx.identity_keys, None, true).await;
5657
if let Some((message, _, _)) = messages.first() {
5758
let message_kind = message.get_inner_message_kind();
5859

‎src/cli/restore.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub async fn execute_restore(
5757
let recv_event = wait_for_dm(ctx, Some(identity_keys), sent_message).await?;
5858

5959
// Parse the incoming DM
60-
let messages = parse_dm_events(recv_event, identity_keys, None).await;
60+
let messages = parse_dm_events(recv_event, identity_keys, None, true).await;
6161
if let Some((message, _, _)) = messages.first() {
6262
let message = message.get_inner_message_kind();
6363
if message.action == Action::RestoreSession {

‎src/cli/send_msg.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ pub async fn execute_send_msg(
131131
.client
132132
.fetch_events(subscription, FETCH_EVENTS_TIMEOUT)
133133
.await?;
134-
let messages = parse_dm_events(events, &next_trade_key, Some(&2)).await;
134+
let messages = parse_dm_events(events, &next_trade_key, Some(&2), true).await;
135135
if !messages.is_empty() {
136136
for (message, _, _) in messages {
137137
let message_kind = message.get_inner_message_kind();

‎src/cli/take_dispute.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ pub async fn execute_admin_cancel_dispute(
112112
)
113113
})?;
114114

115-
let messages = parse_dm_events(recv_event, admin_keys, None).await;
115+
let messages = parse_dm_events(recv_event, admin_keys, None, true).await;
116116
let (message, _, sender_pubkey) = messages
117117
.first()
118118
.ok_or_else(|| anyhow::anyhow!("No response received from Mostro"))?;
@@ -204,7 +204,7 @@ pub async fn execute_admin_settle_dispute(
204204
)
205205
})?;
206206

207-
let messages = parse_dm_events(recv_event, admin_keys, None).await;
207+
let messages = parse_dm_events(recv_event, admin_keys, None, true).await;
208208
let (message, _, sender_pubkey) = messages
209209
.first()
210210
.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<()
277277
let recv_event = wait_for_dm(ctx, Some(admin_keys), sent_message).await?;
278278

279279
// Parse the incoming DM
280-
let messages = parse_dm_events(recv_event, admin_keys, None).await;
280+
let messages = parse_dm_events(recv_event, admin_keys, None, true).await;
281281
if let Some((message, _, sender_pubkey)) = messages.first() {
282282
let message_kind = message.get_inner_message_kind();
283283
if *sender_pubkey != ctx.mostro_pubkey {

‎src/parser/dms.rs‎

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -876,10 +876,25 @@ pub async fn print_commands_results(message: &MessageKind, ctx: &Context) -> Res
876876
}
877877
}
878878

879+
/// Parse a batch of incoming events into `(Message, created_at, sender)`.
880+
///
881+
/// `mostro_protocol` selects how each event is decoded:
882+
/// - `true` — Mostro-protocol messages: route every event through
883+
/// mostro-core's [`unwrap_incoming`], which dispatches on the event kind
884+
/// (1059 gift wrap, v1 / 14 NIP-44 direct, v2) and returns the same
885+
/// `UnwrappedMessage`. This is the receive path for replies to commands and
886+
/// for Mostro→user DM listings.
887+
/// - `false` — NIP-17 peer-to-peer chat: kind-14 events are decrypted with the
888+
/// trade↔peer conversation key (these are *not* Mostro-protocol messages and
889+
/// carry a bare `Message`, not the v2 tuple).
890+
///
891+
/// Keeping the two explicit avoids misparsing peer chat as a Mostro message on
892+
/// the v2 transport, where both share kind 14 (see docs/TRANSPORT_V2_SPEC.md).
879893
pub async fn parse_dm_events(
880894
events: Events,
881895
pubkey: &Keys,
882896
since: Option<&i64>,
897+
mostro_protocol: bool,
883898
) -> Vec<(Message, u64, PublicKey)> {
884899
let mut id_set = HashSet::<EventId>::new();
885900
let mut direct_messages: Vec<(Message, u64, PublicKey)> = Vec::new();
@@ -890,49 +905,61 @@ pub async fn parse_dm_events(
890905
continue;
891906
}
892907

893-
let (created_at, message, sender) = match dm.kind {
894-
nostr_sdk::Kind::GiftWrap => match unwrap_message(dm, pubkey).await {
908+
let (created_at, message, sender) = if mostro_protocol {
909+
match unwrap_incoming(dm, pubkey).await {
895910
Ok(Some(u)) => (u.created_at, u.message, u.sender),
896-
Ok(None) => continue, // outer NIP-44 failed → not addressed to us
911+
Ok(None) => continue, // decrypt failed → not addressed to us
897912
Err(e) => {
898-
eprintln!("Warning: could not unwrap gift wrap (event {}): {e}", dm.id);
913+
eprintln!("Warning: could not unwrap message (event {}): {e}", dm.id);
899914
continue;
900915
}
901-
},
902-
nostr_sdk::Kind::PrivateDirectMessage => {
903-
let ck = if let Ok(ck) = ConversationKey::derive(pubkey.secret_key(), &dm.pubkey) {
904-
ck
905-
} else {
906-
continue;
907-
};
908-
let b64decoded_content =
909-
match general_purpose::STANDARD.decode(dm.content.as_bytes()) {
910-
Ok(b64decoded_content) => b64decoded_content,
916+
}
917+
} else {
918+
match dm.kind {
919+
nostr_sdk::Kind::GiftWrap => match unwrap_message(dm, pubkey).await {
920+
Ok(Some(u)) => (u.created_at, u.message, u.sender),
921+
Ok(None) => continue, // outer NIP-44 failed → not addressed to us
922+
Err(e) => {
923+
eprintln!("Warning: could not unwrap gift wrap (event {}): {e}", dm.id);
924+
continue;
925+
}
926+
},
927+
nostr_sdk::Kind::PrivateDirectMessage => {
928+
let ck =
929+
if let Ok(ck) = ConversationKey::derive(pubkey.secret_key(), &dm.pubkey) {
930+
ck
931+
} else {
932+
continue;
933+
};
934+
let b64decoded_content =
935+
match general_purpose::STANDARD.decode(dm.content.as_bytes()) {
936+
Ok(b64decoded_content) => b64decoded_content,
937+
Err(_) => {
938+
continue;
939+
}
940+
};
941+
let unencrypted_content = match decrypt_to_bytes(&ck, &b64decoded_content) {
942+
Ok(bytes) => bytes,
911943
Err(_) => {
912944
continue;
913945
}
914946
};
915-
let unencrypted_content = match decrypt_to_bytes(&ck, &b64decoded_content) {
916-
Ok(bytes) => bytes,
917-
Err(_) => {
918-
continue;
919-
}
920-
};
921-
let message_str = match String::from_utf8(unencrypted_content) {
922-
Ok(s) => s,
923-
Err(_) => {
924-
continue;
925-
}
926-
};
927-
let message = match Message::from_json(&message_str) {
928-
Ok(m) => m,
929-
Err(_) => {
930-
continue;
931-
}
932-
};
933-
(dm.created_at, message, dm.pubkey)
947+
let message_str = match String::from_utf8(unencrypted_content) {
948+
Ok(s) => s,
949+
Err(_) => {
950+
continue;
951+
}
952+
};
953+
let message = match Message::from_json(&message_str) {
954+
Ok(m) => m,
955+
Err(_) => {
956+
continue;
957+
}
958+
};
959+
(dm.created_at, message, dm.pubkey)
960+
}
961+
_ => continue,
934962
}
935-
_ => continue,
936963
};
937964
// check if the message is older than the since time if it is, skip it
938965
if let Some(since_time) = since {

‎src/util/events.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ pub async fn fetch_events_list(
192192
.client
193193
.fetch_events(filters, FETCH_EVENTS_TIMEOUT)
194194
.await?;
195-
let direct_messages_mostro = parse_dm_events(fetched_events, admin_keys, since).await;
195+
let direct_messages_mostro =
196+
parse_dm_events(fetched_events, admin_keys, since, true).await;
196197
Ok(direct_messages_mostro
197198
.into_iter()
198199
.map(|(message, timestamp, sender_pubkey)| {
@@ -213,8 +214,10 @@ pub async fn fetch_events_list(
213214
.client
214215
.fetch_events(filter, FETCH_EVENTS_TIMEOUT)
215216
.await?;
217+
// NIP-17 peer-to-peer chat (not Mostro-protocol): decode with
218+
// the trade↔peer conversation key.
216219
let direct_messages_for_trade_key =
217-
parse_dm_events(fetched_user_messages, &trade_key, since).await;
220+
parse_dm_events(fetched_user_messages, &trade_key, since, false).await;
218221
// Extend the direct messages
219222
direct_messages.extend(direct_messages_for_trade_key);
220223
}
@@ -233,8 +236,9 @@ pub async fn fetch_events_list(
233236
.client
234237
.fetch_events(filter, FETCH_EVENTS_TIMEOUT)
235238
.await?;
239+
// Mostro→user DMs (gift wrap, v1): Mostro-protocol messages.
236240
let direct_messages_for_trade_key =
237-
parse_dm_events(fetched_user_messages, &trade_key, since).await;
241+
parse_dm_events(fetched_user_messages, &trade_key, since, true).await;
238242
// Extend the direct messages
239243
direct_messages.extend(direct_messages_for_trade_key);
240244
}

0 commit comments

Comments
 (0)