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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 │ └────────┬─────────┘
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion config.toml.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
117 changes: 102 additions & 15 deletions src/nostr/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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
);
}
}
}
Expand All @@ -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<Kind> {
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<String> {
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);
}
}
Loading