Skip to main content

bsv_messagebox_client/
websocket.rs

1//! MessageBox WebSocket layer over the shared [`authsocket`] client.
2//!
3//! The generic BRC-103/Socket.IO transport (connect + namespace-ack gate,
4//! client-initiated handshake, `authenticationSuccess` oneshot, receive loop,
5//! keepalive + read-deadline watchdog, signed emit/join_room/leave_room) lives
6//! in the `authsocket` crate's `client` feature — it was extracted from this
7//! file. What remains here is the MessageBox **application** protocol:
8//!
9//! - `sendMessage-{roomId}` room deliveries: decode [`ServerPeerMessage`],
10//!   decrypt (BRC-78) with authenticated-decrypt provenance, and dispatch to
11//!   the room subscription callback.
12//! - `sendMessageAck-{roomId}` FIFO ack correlation: the server's ack is
13//!   *room*-scoped (no messageId), so N concurrent sends to one room resolve
14//!   oldest-first against a per-key `VecDeque` of waiters.
15//! - the public [`MessageBoxWebSocket`] API surface, unchanged for `client.rs`
16//!   and `delivery.rs`.
17//!
18//! Inbound routing preserves the old dispatcher's strict FIFO semantics: the
19//! authsocket fallback handler forwards `(event_name, data)` onto an internal
20//! channel consumed by one dispatcher task, so per-room message/ack ordering
21//! is exactly as the server emitted it.
22
23use std::collections::{HashMap, HashSet, VecDeque};
24use std::sync::Arc;
25
26use tokio::sync::{mpsc, oneshot, Mutex};
27
28use authsocket::client::AuthSocketClient;
29use bsv::wallet::interfaces::WalletInterface;
30
31use crate::encryption;
32use crate::error::MessageBoxError;
33use crate::types::{AuthenticatedPeerMessage, ServerPeerMessage};
34
35/// Subscriber callback: event key → message handler.
36type SubscriptionMap =
37    Arc<Mutex<HashMap<String, Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync>>>>;
38
39/// Pending ack queue keyed by `sendMessageAck-{roomId}`.
40///
41/// The server's `sendMessageAck-{roomId}` carries only a `status` — it is
42/// *room*-scoped, not *message*-scoped (no `messageId` to correlate on). So when
43/// N concurrent sends target the same room, they must be matched to acks in FIFO
44/// order: the server processes a room's sends serially and acks each in turn, so
45/// the i-th ack belongs to the i-th still-pending send. A `VecDeque` per key
46/// preserves that ordering.
47type PendingAcks = Arc<Mutex<HashMap<String, VecDeque<oneshot::Sender<bool>>>>>;
48
49/// WebSocket connection to the MessageBox server: the shared authsocket
50/// BRC-103 client plus the MessageBox application routing.
51pub struct MessageBoxWebSocket {
52    /// The shared authenticated Socket.IO client (authsocket crate).
53    ws: AuthSocketClient,
54    /// Map of event key (e.g. "sendMessage-{roomId}") to subscriber callback.
55    subscriptions: SubscriptionMap,
56    /// Pending ack waiters keyed by "sendMessageAck-{roomId}", FIFO per key.
57    pending_acks: PendingAcks,
58    /// Rooms tracked locally so `leave_room` can clear subscriptions.
59    joined_rooms: Arc<Mutex<HashSet<String>>>,
60}
61
62impl MessageBoxWebSocket {
63    /// Connect to the MessageBox Socket.IO server and authenticate via BRC-103.
64    ///
65    /// Performs the full BRC-103 mutual authentication handshake before
66    /// returning (the authsocket client blocks until the server's signed
67    /// `authenticationSuccess`).
68    pub async fn connect<W>(
69        url: &str,
70        identity_key: &str,
71        wallet: W,
72        originator: Option<String>,
73    ) -> Result<Self, MessageBoxError>
74    where
75        W: WalletInterface + Clone + Send + Sync + 'static,
76    {
77        let ws = AuthSocketClient::connect(url, identity_key, wallet.clone())
78            .await
79            .map_err(|e| MessageBoxError::WebSocket(e.to_string()))?;
80
81        let subscriptions: SubscriptionMap = Arc::new(Mutex::new(HashMap::new()));
82        let pending_acks: PendingAcks = Arc::new(Mutex::new(HashMap::new()));
83        let joined_rooms: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
84
85        // Internal FIFO: the authsocket fallback (sync) forwards every verified
86        // event here; ONE dispatcher task consumes it so per-room ordering of
87        // deliveries and acks matches the server's emit order exactly.
88        let (event_tx, mut event_rx) = mpsc::unbounded_channel::<(String, serde_json::Value)>();
89        ws.set_fallback(Arc::new(move |event_name, data| {
90            let _ = event_tx.send((event_name, data));
91        }))
92        .await;
93
94        // Dispatcher task: MessageBox application routing (extracted verbatim
95        // from the old general_msg_dispatcher, minus the transport concerns
96        // that moved into the authsocket crate).
97        {
98            let subscriptions = subscriptions.clone();
99            let pending_acks = pending_acks.clone();
100            let wallet = wallet.clone();
101            let originator = originator.clone();
102            tokio::spawn(async move {
103                while let Some((event_name, data)) = event_rx.recv().await {
104                    if let Some(room_id) = event_name.strip_prefix("sendMessage-") {
105                        let room_id = room_id.to_string();
106                        let Ok(server_msg) =
107                            serde_json::from_value::<ServerPeerMessage>(data.clone())
108                        else {
109                            continue;
110                        };
111                        let callback = {
112                            let guard = subscriptions.lock().await;
113                            guard.get(&event_name).cloned()
114                        };
115                        if let Some(cb) = callback {
116                            // Typed decrypt: carry authenticated-decrypt
117                            // provenance to the subscriber so the MPC transport
118                            // can fail closed.
119                            let outcome = encryption::try_decrypt_message_typed(
120                                &wallet,
121                                &server_msg.body,
122                                &server_msg.sender,
123                                originator.as_deref(),
124                            )
125                            .await;
126                            let authenticated_decrypt = outcome.is_authenticated();
127                            let (recipient, message_box) = split_room_id(&room_id);
128                            cb(AuthenticatedPeerMessage {
129                                message_id: server_msg.message_id,
130                                sender: server_msg.sender,
131                                recipient,
132                                message_box,
133                                body: outcome.into_body(),
134                                authenticated_decrypt,
135                            });
136                        }
137                    } else if event_name.starts_with("sendMessageAck-") {
138                        // Room-scoped ack: resolve the OLDEST still-pending
139                        // send for this room (FIFO).
140                        let success =
141                            data.get("status").and_then(|s| s.as_str()) == Some("success");
142                        let mut guard = pending_acks.lock().await;
143                        if let Some(queue) = guard.get_mut(&event_name) {
144                            if let Some(tx) = queue.pop_front() {
145                                let _ = tx.send(success);
146                            }
147                            if queue.is_empty() {
148                                guard.remove(&event_name);
149                            }
150                        }
151                    }
152                    // authenticationSuccess and other transport-level events are
153                    // handled inside the authsocket client.
154                }
155            });
156        }
157
158        Ok(Self {
159            ws,
160            subscriptions,
161            pending_acks,
162            joined_rooms,
163        })
164    }
165
166    /// Return true if the connection is currently authenticated.
167    pub fn is_connected(&self) -> bool {
168        self.ws.is_connected()
169    }
170
171    /// Milliseconds since the last inbound frame of any kind (the read-deadline
172    /// tracker used for half-open detection).
173    pub fn ms_since_last_inbound(&self) -> u64 {
174        self.ws.ms_since_last_inbound()
175    }
176
177    /// Return the server's BRC-103 identity key captured during the handshake.
178    pub fn server_identity_key(&self) -> &str {
179        self.ws.server_identity_key()
180    }
181
182    /// Join a Socket.IO room (idempotent — no-op if already joined).
183    ///
184    /// The joinRoom event is sent as a signed BRC-103 authMessage envelope.
185    pub async fn join_room(&self, room_id: &str) -> Result<(), MessageBoxError> {
186        {
187            let guard = self.joined_rooms.lock().await;
188            if guard.contains(room_id) {
189                return Ok(());
190            }
191        }
192        self.ws
193            .join_room(room_id)
194            .await
195            .map_err(|e| MessageBoxError::WebSocket(e.to_string()))?;
196        self.joined_rooms.lock().await.insert(room_id.to_string());
197        Ok(())
198    }
199
200    /// Leave a Socket.IO room and remove its subscription.
201    ///
202    /// Local subscription state is torn down unconditionally before the wire
203    /// emit, so a dead socket cannot leave a stale subscription behind.
204    pub async fn leave_room(&self, room_id: &str) -> Result<(), MessageBoxError> {
205        self.joined_rooms.lock().await.remove(room_id);
206        let event_key = format!("sendMessage-{room_id}");
207        self.subscriptions.lock().await.remove(&event_key);
208        self.ws
209            .leave_room(room_id)
210            .await
211            .map_err(|e| MessageBoxError::WebSocket(e.to_string()))
212    }
213
214    /// Register a callback for incoming messages on a given event key.
215    ///
216    /// `event_key` is typically `"sendMessage-{room_id}"`.
217    pub async fn subscribe(
218        &self,
219        event_key: String,
220        callback: Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync>,
221    ) {
222        self.subscriptions.lock().await.insert(event_key, callback);
223    }
224
225    /// Emit a sendMessage event and register an ack waiter.
226    ///
227    /// The sendMessage event is signed as a BRC-103 general message and emitted
228    /// directly on the socket — concurrent calls sign + emit + await their acks
229    /// in parallel. The ack waiter is enqueued under `sendMessageAck-{ack_key}`
230    /// and resolved FIFO when the server emits that room-scoped ack.
231    pub async fn emit_send_message(
232        &self,
233        payload: serde_json::Value,
234        ack_key: String,
235        ack_tx: oneshot::Sender<bool>,
236    ) -> Result<(), MessageBoxError> {
237        // Register the ack waiter BEFORE sending — avoids the race where the
238        // server acks before we enqueue.
239        self.pending_acks
240            .lock()
241            .await
242            .entry(ack_key.clone())
243            .or_default()
244            .push_back(ack_tx);
245
246        if let Err(e) = self.ws.emit("sendMessage", &payload).await {
247            // Send failed — pull our just-enqueued waiter back off the tail so
248            // it doesn't leak (and isn't mismatched to a later ack).
249            let mut guard = self.pending_acks.lock().await;
250            if let Some(queue) = guard.get_mut(&ack_key) {
251                queue.pop_back();
252                if queue.is_empty() {
253                    guard.remove(&ack_key);
254                }
255            }
256            return Err(MessageBoxError::WebSocket(e.to_string()));
257        }
258        Ok(())
259    }
260
261    /// Remove a pending ack entry (called on timeout to prevent leaking channels).
262    ///
263    /// Pops the OLDEST waiter for the key — under FIFO ack matching, a timeout
264    /// on the oldest in-flight send is the one most likely to have been abandoned.
265    pub async fn remove_pending_ack(&self, key: &str) {
266        let mut guard = self.pending_acks.lock().await;
267        if let Some(queue) = guard.get_mut(key) {
268            queue.pop_front();
269            if queue.is_empty() {
270                guard.remove(key);
271            }
272        }
273    }
274
275    /// Disconnect from the server and clear all state.
276    ///
277    /// Sends `false` to all remaining pending ack waiters before dropping them.
278    pub async fn disconnect(&self) -> Result<(), MessageBoxError> {
279        {
280            let mut guard = self.pending_acks.lock().await;
281            for (_, queue) in guard.drain() {
282                for tx in queue {
283                    let _ = tx.send(false);
284                }
285            }
286        }
287        self.subscriptions.lock().await.clear();
288        self.joined_rooms.lock().await.clear();
289        self.ws
290            .disconnect()
291            .await
292            .map_err(|e| MessageBoxError::WebSocket(e.to_string()))
293    }
294}
295
296/// Split a room ID into (recipient/owner_identity_key, message_box_name).
297///
298/// Room ID format when listening: `"{identityKey}-{messageBox}"`. Identity
299/// keys are 66-char hex strings, so split after index 66; falls back to the
300/// first `-` for non-standard ids.
301fn split_room_id(room_id: &str) -> (String, String) {
302    const HEX_KEY_LEN: usize = 66;
303    if room_id.len() > HEX_KEY_LEN && room_id.as_bytes()[HEX_KEY_LEN] == b'-' {
304        let key = room_id[..HEX_KEY_LEN].to_string();
305        let mb = room_id[HEX_KEY_LEN + 1..].to_string();
306        return (key, mb);
307    }
308    if let Some(pos) = room_id.find('-') {
309        (room_id[..pos].to_string(), room_id[pos + 1..].to_string())
310    } else {
311        (room_id.to_string(), String::new())
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Tests
317// ---------------------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::types::{WsSendMessageData, WsSendMessagePayload};
323    use serde_json::json;
324    use std::collections::HashSet;
325
326    /// Room ID format: listen room is `{identity_key}-{message_box}`, send room
327    /// is `{recipient}-{message_box}` — asymmetric by design.
328    #[test]
329    fn room_id_format() {
330        let my_key = "03abc";
331        let recipient = "03def";
332        let message_box = "payment_inbox";
333
334        let listen_room = format!("{my_key}-{message_box}");
335        let send_room = format!("{recipient}-{message_box}");
336
337        assert_eq!(listen_room, "03abc-payment_inbox");
338        assert_eq!(send_room, "03def-payment_inbox");
339        assert_ne!(listen_room, send_room);
340    }
341
342    /// WsSendMessageData must serialize with camelCase field names.
343    #[test]
344    fn send_message_data_serializes_camel_case() {
345        let data = WsSendMessageData {
346            room_id: "03abc-payment_inbox".to_string(),
347            message: WsSendMessagePayload {
348                message_id: "deadbeef".to_string(),
349                recipient: "03abc".to_string(),
350                body: "encrypted".to_string(),
351            },
352        };
353        let json = serde_json::to_string(&data).unwrap();
354        assert!(json.contains("\"roomId\""), "roomId field name");
355        assert!(json.contains("\"messageId\""), "messageId field name");
356        assert!(!json.contains("room_id"), "no snake_case leakage");
357        assert!(!json.contains("message_id"), "no snake_case leakage");
358    }
359
360    /// WsSendMessagePayload must round-trip through JSON.
361    #[test]
362    fn send_message_payload_round_trip() {
363        let payload = WsSendMessagePayload {
364            message_id: "abc123".to_string(),
365            recipient: "03def456".to_string(),
366            body: r#"{"encryptedMessage":"abc=="}"#.to_string(),
367        };
368        let json = serde_json::to_string(&payload).unwrap();
369        let back: WsSendMessagePayload = serde_json::from_str(&json).unwrap();
370        assert_eq!(back.message_id, "abc123");
371        assert_eq!(back.recipient, "03def456");
372        assert_eq!(back.body, r#"{"encryptedMessage":"abc=="}"#);
373    }
374
375    /// The authenticated event must serialize to `{"identityKey": "03abc"}`.
376    #[test]
377    fn authenticated_event_format() {
378        let identity_key = "03abcdef1234567890";
379        let v = json!({"identityKey": identity_key});
380        let json = serde_json::to_string(&v).unwrap();
381        assert_eq!(json, r#"{"identityKey":"03abcdef1234567890"}"#);
382    }
383
384    /// HashSet insert returns false on second insert — confirms idempotency logic.
385    #[test]
386    fn join_room_idempotency_uses_hashset() {
387        let mut rooms: HashSet<String> = HashSet::new();
388        let room_id = "03abc-payment_inbox";
389        let first = rooms.insert(room_id.to_string());
390        let second = rooms.insert(room_id.to_string());
391        assert!(first, "first insert returns true");
392        assert!(!second, "second insert returns false (already present)");
393        assert_eq!(rooms.len(), 1, "only one entry in set");
394    }
395
396    /// split_room_id correctly extracts key and message box for 66-char keys.
397    #[test]
398    fn split_room_id_hex_key() {
399        let key = "a".repeat(66);
400        let mb = "my_inbox";
401        let room_id = format!("{key}-{mb}");
402        let (got_key, got_mb) = split_room_id(&room_id);
403        assert_eq!(got_key, key);
404        assert_eq!(got_mb, mb);
405    }
406
407    /// split_room_id handles message box names with hyphens.
408    #[test]
409    fn split_room_id_mb_with_hyphen() {
410        let key = "b".repeat(66);
411        let mb = "payment-inbox-v2";
412        let room_id = format!("{key}-{mb}");
413        let (got_key, got_mb) = split_room_id(&room_id);
414        assert_eq!(got_key, key);
415        assert_eq!(got_mb, mb);
416    }
417
418    // -----------------------------------------------------------------------
419    // FIFO ack router — concurrent same-room send correlation
420    // -----------------------------------------------------------------------
421
422    /// Two concurrent sends to the SAME room each enqueue a waiter; two acks
423    /// resolve them oldest-first.
424    #[tokio::test]
425    async fn fifo_acks_resolve_concurrent_same_room_sends_in_order() {
426        let acks: PendingAcks = Arc::new(Mutex::new(HashMap::new()));
427        let key = "sendMessageAck-03abc-inbox".to_string();
428
429        let (tx1, rx1) = oneshot::channel::<bool>();
430        let (tx2, rx2) = oneshot::channel::<bool>();
431        {
432            let mut g = acks.lock().await;
433            g.entry(key.clone()).or_default().push_back(tx1);
434            g.entry(key.clone()).or_default().push_back(tx2);
435            assert_eq!(g.get(&key).unwrap().len(), 2, "both waiters queued");
436        }
437
438        {
439            let mut g = acks.lock().await;
440            let q = g.get_mut(&key).unwrap();
441            let _ = q.pop_front().unwrap().send(true);
442            assert!(!q.is_empty(), "second waiter still queued");
443        }
444        assert!(rx1.await.unwrap(), "first send resolved by first ack");
445
446        {
447            let mut g = acks.lock().await;
448            let q = g.get_mut(&key).unwrap();
449            let _ = q.pop_front().unwrap().send(false);
450            if q.is_empty() {
451                g.remove(&key);
452            }
453            assert!(!g.contains_key(&key), "key removed once queue drains");
454        }
455        assert!(!rx2.await.unwrap(), "second send resolved by second ack");
456    }
457
458    /// `remove_pending_ack` semantics: popping the oldest waiter on timeout and
459    /// removing the key once the queue drains — no leaked entries.
460    #[tokio::test]
461    async fn remove_pending_ack_pops_oldest_and_clears_empty_key() {
462        let acks: PendingAcks = Arc::new(Mutex::new(HashMap::new()));
463        let key = "sendMessageAck-03abc-inbox".to_string();
464        let (tx, _rx) = oneshot::channel::<bool>();
465        acks.lock().await.entry(key.clone()).or_default().push_back(tx);
466
467        {
468            let mut g = acks.lock().await;
469            if let Some(q) = g.get_mut(&key) {
470                q.pop_front();
471                if q.is_empty() {
472                    g.remove(&key);
473                }
474            }
475        }
476        assert!(acks.lock().await.is_empty(), "no leaked ack entries");
477    }
478
479    /// Acks for DIFFERENT rooms are independent.
480    #[tokio::test]
481    async fn acks_are_per_room_independent() {
482        let acks: PendingAcks = Arc::new(Mutex::new(HashMap::new()));
483        let key_a = "sendMessageAck-03aaa-inbox".to_string();
484        let key_b = "sendMessageAck-03bbb-inbox".to_string();
485        let (tx_a, rx_a) = oneshot::channel::<bool>();
486        let (tx_b, rx_b) = oneshot::channel::<bool>();
487        {
488            let mut g = acks.lock().await;
489            g.entry(key_a.clone()).or_default().push_back(tx_a);
490            g.entry(key_b.clone()).or_default().push_back(tx_b);
491        }
492        {
493            let mut g = acks.lock().await;
494            let q = g.get_mut(&key_a).unwrap();
495            let _ = q.pop_front().unwrap().send(true);
496            if q.is_empty() {
497                g.remove(&key_a);
498            }
499        }
500        assert!(rx_a.await.unwrap(), "room A resolved");
501        assert!(acks.lock().await.contains_key(&key_b), "room B still pending");
502        drop(rx_b);
503    }
504}