From 59346e879aea96f0d9b60b250accf1a39b62f288 Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 14 Jul 2026 16:11:13 -0300 Subject: [PATCH] feat: subscribe to protocol v2 (kind 14) events in the Nostr listener Mostro daemons advertising protocol_version=2 reply to clients with signed kind-14 NIP-44 direct messages addressed to the trade pubkey in the p tag, instead of kind-1059 gift wraps. The listener only watched kind 1059, so against a v2 node it never matched any event: no FCM/ UnifiedPush wake was ever dispatched and mobile users stopped getting notifications while the app was killed or dozing (reported after the mobile v1.3.0 transport v2 rollout). Subscribe to kinds 1059 and 14 and dispatch on either, still matching by p tag only. No author filter is added (hard constraint #1 stands): pushes fire solely for registered trade pubkeys, so the p-tag match is sufficient for kind 14 as well. Also extract the p-tag lookup into extract_recipient() and add unit tests for the watched-kind set and recipient extraction. --- CLAUDE.md | 4 +- README.md | 6 +-- config.toml.example | 2 +- docs/README.md | 2 +- docs/architecture.md | 6 +-- docs/configuration.md | 2 +- src/config.rs | 2 +- src/nostr/listener.rs | 117 ++++++++++++++++++++++++++++++++++++------ 8 files changed, 114 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4736992..2064083 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Mostro Push Server -Privacy-preserving push notification backend for the Mostro P2P trading ecosystem. Rust + Actix-web + Tokio. The server observes Nostr Gift Wrap events (`kind 1059`) on configured relays, looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). +Privacy-preserving push notification backend for the Mostro P2P trading ecosystem. Rust + Actix-web + Tokio. The server observes Nostr Gift Wrap events (`kind 1059`, Mostro protocol v1) and NIP-44 direct messages (`kind 14`, Mostro protocol v2) on configured relays, looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). For deeper context (data flow, components, ops): [docs/architecture.md](docs/architecture.md), [docs/api.md](docs/api.md), [docs/configuration.md](docs/configuration.md). @@ -68,7 +68,7 @@ src/ │ ├── notify.rs # /api/notify handler + request_id_mw │ ├── rate_limit.rs # per-IP / per-pubkey limiter middleware (governor) │ └── test_support.rs # In-process test fixtures -├── nostr/listener.rs # Persistent subscription, kind 1059 dispatch +├── nostr/listener.rs # Persistent subscription, kind 1059 / kind 14 dispatch ├── push/ │ ├── mod.rs # PushService trait │ ├── dispatcher.rs # PushDispatcher (lock-free) diff --git a/README.md b/README.md index 9c18d0d..e96e1ec 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Privacy-preserving push notification backend for the [Mostro](https://mostro.network/) P2P trading ecosystem. -The server observes Nostr Gift Wrap events (`kind 1059`), looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush so Mostro Mobile clients can wake up and process trade events. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). +The server observes Nostr Gift Wrap events (`kind 1059`, Mostro protocol v1) and NIP-44 direct messages (`kind 14`, Mostro protocol v2), looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush so Mostro Mobile clients can wake up and process trade events. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). ## How it works @@ -17,7 +17,7 @@ The server observes Nostr Gift Wrap events (`kind 1059`), looks up registered de │ │ trade_pubkey │ │ └─────────────────┘ └────────┬─────────┘ │ -┌─────────────────┐ 2. Publishes kind 1059 ┌────────▼─────────┐ +┌─────────────────┐ 2. Publishes kind 1059 / 14 ┌────────▼─────────┐ │ Mostro Daemon │ ──────────────────────────────────▶│ Nostr Relay │ │ / dispute │ p: trade_pubkey │ │ │ admin / peer │ └────────┬─────────┘ @@ -41,7 +41,7 @@ The server observes Nostr Gift Wrap events (`kind 1059`), looks up registered de Two ingress paths feed the same dispatcher: -1. **Listener path** — the Nostr listener subscribes to `kind 1059` on configured relays and dispatches when a `p` tag matches a registered `trade_pubkey`. +1. **Listener path** — the Nostr listener subscribes to `kind 1059` (protocol v1 Gift Wrap) and `kind 14` (protocol v2 NIP-44 direct) on configured relays and dispatches when a `p` tag matches a registered `trade_pubkey`. 2. **Sender-triggered path** — `POST /api/notify` lets a sender ask the server to wake the recipient when an event was sent peer-to-peer without going through the Mostro daemon (e.g. dispute admin DMs). ## Privacy properties diff --git a/config.toml.example b/config.toml.example index e647a24..1e49cb7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,7 +1,7 @@ [nostr] relays = ["wss://relay.mostro.network"] subscription_id = "mostro-push-listener" -event_kinds = [1059] +event_kinds = [1059, 14] [push] fcm_enabled = true diff --git a/docs/README.md b/docs/README.md index ac49368..4888c57 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Operator and integrator documentation for the Mostro Push Server, a privacy-pres ## What this server does -- Subscribes to Nostr relays and observes Gift Wrap events (`kind 1059`). +- Subscribes to Nostr relays and observes Gift Wrap events (`kind 1059`, Mostro protocol v1) and NIP-44 direct messages (`kind 14`, Mostro protocol v2). - Maintains an in-memory map of `trade_pubkey -> device_token` populated by mobile clients via `POST /api/register`. - On a matching event, dispatches a silent push via Firebase Cloud Messaging (FCM) and/or UnifiedPush. - Exposes `POST /api/notify` for the mobile client to trigger a sender-side wake-up (silent push) when peer-to-peer chat events are sent without going through the Mostro daemon. diff --git a/docs/architecture.md b/docs/architecture.md index d71054f..3aefde5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ Five always-on endpoints (`/api/health`, `/api/info`, `/api/status`, `/api/regis ### Nostr listener (`nostr-sdk`) -Connects to all configured relays, subscribes to `kind 1059` events with no author filter, and reconnects automatically on close (5 s) or error (10 s). For each event it extracts the `p` tag and looks up the corresponding token in the store; on hit it calls `PushDispatcher::dispatch`. +Connects to all configured relays, subscribes to `kind 1059` (Mostro protocol v1 Gift Wrap) and `kind 14` (Mostro protocol v2 NIP-44 direct) events with no author filter, and reconnects automatically on close (5 s) or error (10 s). For each event it extracts the `p` tag and looks up the corresponding token in the store; on hit it calls `PushDispatcher::dispatch`. The listener generates an ephemeral `Keys::generate()` for the connection itself; this key only signs subscriptions, it never identifies a user. @@ -68,12 +68,12 @@ A salted truncated BLAKE3 keyed hash. The salt is a 32-byte random value generat ## Data flow -### Listener path (`kind 1059` from a relay) +### Listener path (`kind 1059` / `kind 14` from a relay) ``` Sender (any Nostr client) │ - │ publish kind 1059 (p tag = trade_pubkey) + │ publish kind 1059 or kind 14 (p tag = trade_pubkey) ▼ Nostr relay │ diff --git a/docs/configuration.md b/docs/configuration.md index 57ed09a..1477191 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ cp .env.example .env | Variable | Description | |-----------------|----------------------------------------------------------------------------------------------| -| `NOSTR_RELAYS` | Comma-separated list of Nostr relay URLs. Used by `NostrListener` to subscribe to kind 1059. | +| `NOSTR_RELAYS` | Comma-separated list of Nostr relay URLs. Used by `NostrListener` to subscribe to kinds 1059 and 14. | `NOSTR_RELAYS` is the only variable without a default; the server fails to boot if it is unset. diff --git a/src/config.rs b/src/config.rs index 22bc7ad..f2884af 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,7 +96,7 @@ impl Config { nostr: NostrConfig { relays, subscription_id: "mostro-push-listener".to_string(), - event_kinds: vec![1059], + event_kinds: vec![1059, 14], }, push: PushConfig { fcm_enabled: env::var("FCM_ENABLED") diff --git a/src/nostr/listener.rs b/src/nostr/listener.rs index ed7af98..a8fa079 100644 --- a/src/nostr/listener.rs +++ b/src/nostr/listener.rs @@ -70,12 +70,18 @@ impl NostrListener { // 2. Admin DMs in disputes are sent directly user-to-user, NOT through the Mostro daemon. // A mostro_pubkey author filter would silently drop every dispute notification. // See PROJECT.md anti-requirement OOS-19 / PITFALLS CRIT-1. + // + // Kind 14 is Mostro protocol v2 (NIP-44 direct): daemons advertising + // protocol_version=2 address the trade pubkey in the `p` tag of a + // signed kind-14 event instead of a Gift Wrap. It is matched by `p` + // tag only, like kind 1059 — pushes fire solely for registered trade + // pubkeys, so no author filter is needed here either. let since = Timestamp::now() - Duration::from_secs(60); - let filter = Filter::new().kinds(vec![Kind::Custom(1059)]).since(since); + let filter = Filter::new().kinds(watched_kinds()).since(since); // Subscribe to events client.subscribe(vec![filter]).await; - info!("Subscribed to kind 1059 (Gift Wrap) events on relay"); + info!("Subscribed to kind 1059 (Gift Wrap) and kind 14 (protocol v2) events on relay"); // Handle incoming events let token_store = self.token_store.clone(); @@ -85,20 +91,16 @@ impl NostrListener { client .handle_notifications(|notification| async { if let RelayPoolNotification::Event { event, .. } = notification { - if event.kind == Kind::Custom(1059) { - // Log every Gift Wrap event received - info!("Received Gift Wrap (kind 1059) event: {}", event.id); + if is_watched_kind(event.kind) { + // Log every watched event received + info!( + "Received {} event: {}", + kind_label(event.kind), + event.id + ); // Extract recipient from 'p' tag - let recipient_pubkey = event.tags.iter() - .find_map(|tag| { - let tag_vec = tag.as_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "p" { - Some(tag_vec[1].clone()) - } else { - None - } - }); + let recipient_pubkey = extract_recipient(&event); if let Some(trade_pubkey) = recipient_pubkey { let log_pk = log_pubkey(&log_salt, &trade_pubkey); @@ -132,7 +134,11 @@ impl NostrListener { debug!("No registered token pk={}", log_pk); } } else { - warn!("No 'p' tag found in Gift Wrap event {}", event.id); + warn!( + "No 'p' tag found in {} event {}", + kind_label(event.kind), + event.id + ); } } } @@ -143,3 +149,84 @@ impl NostrListener { Ok(()) } } + +/// Event kinds the listener subscribes to and dispatches on: +/// - 1059 — Gift Wrap (NIP-59), Mostro protocol v1 and dispute admin DMs. +/// - 14 — NIP-44 direct message, Mostro protocol v2 (daemons advertising +/// `protocol_version=2` reply with signed kind-14 events addressed to the +/// trade pubkey in the `p` tag instead of a Gift Wrap). +fn watched_kinds() -> Vec { + vec![Kind::Custom(1059), Kind::Custom(14)] +} + +fn is_watched_kind(kind: Kind) -> bool { + watched_kinds().contains(&kind) +} + +fn kind_label(kind: Kind) -> &'static str { + match kind { + Kind::Custom(1059) => "Gift Wrap (kind 1059)", + Kind::Custom(14) => "protocol v2 (kind 14)", + _ => "unexpected kind", + } +} + +/// Extracts the recipient trade pubkey from the first `p` tag, shared by both +/// watched kinds (v1 Gift Wrap and v2 NIP-44 direct address the recipient the +/// same way). +fn extract_recipient(event: &Event) -> Option { + event.tags.iter().find_map(|tag| { + let tag_vec = tag.as_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "p" { + Some(tag_vec[1].clone()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn watched_kinds_include_gift_wrap_and_protocol_v2() { + assert!(is_watched_kind(Kind::Custom(1059))); + assert!(is_watched_kind(Kind::Custom(14))); + } + + #[test] + fn unrelated_kinds_are_not_watched() { + assert!(!is_watched_kind(Kind::Custom(1))); + assert!(!is_watched_kind(Kind::Custom(38385))); + assert!(!is_watched_kind(Kind::Custom(10002))); + } + + #[test] + fn extract_recipient_returns_first_p_tag() { + let keys = Keys::generate(); + let recipient = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(14), + "ciphertext", + [Tag::public_key(recipient.public_key())], + ) + .to_event(&keys) + .unwrap(); + + assert_eq!( + extract_recipient(&event), + Some(recipient.public_key().to_string()) + ); + } + + #[test] + fn extract_recipient_returns_none_without_p_tag() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(14), "ciphertext", []) + .to_event(&keys) + .unwrap(); + + assert_eq!(extract_recipient(&event), None); + } +}