Skip to main content

bsv_messagebox_client/
client.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::sync::Arc;
3
4use bsv::auth::clients::auth_fetch::{AuthFetch, AuthFetchResponse};
5use bsv::remittance::types::PeerMessage;
6use bsv::services::overlay_tools::Network;
7use bsv::wallet::interfaces::{GetPublicKeyArgs, WalletInterface};
8use tokio::sync::{Mutex, OnceCell};
9// `Mutex` is still used for WebSocket state, joined rooms, and the subscription
10// registry — see the corresponding fields below. It is NO LONGER used for
11// `auth_fetch`, which is now concurrency-safe (`&self`) and shared bare via `Arc`.
12
13use crate::delivery::DeliveryMode;
14use crate::error::MessageBoxError;
15use crate::types::{AuthenticatedPeerMessage, ListMessagesParams, ListMessagesResponse};
16
17/// Internal callback type for live message subscriptions. Carries the
18/// authenticated-decrypt provenance flag end-to-end (WS dispatch, reconnect
19/// replay, HTTP poll backstop). The public `listen_for_live_messages` adapts a
20/// caller's `Fn(PeerMessage)` onto this by dropping the flag; the public
21/// `listen_for_live_messages_typed` exposes it directly.
22type SubscriptionCallback = Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync>;
23
24/// Backoff bounds for the proactive reconnect supervisor.
25const RECONNECT_BASE_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
26const RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(10);
27
28/// Establish a fresh WebSocket connection and swap it into `ws_state`, replaying
29/// joinRoom + re-subscribe for every active subscription.
30///
31/// Shared by `ensure_ws_connected` (lazy/on-demand connect) and the reconnect
32/// supervisor (proactive heal). Lives as a free function — taking `Arc` handles
33/// rather than `&self` — so the `'static` supervisor task can call it without a
34/// self-reference. On success the new socket replaces whatever was in
35/// `ws_state`; the old (dead) socket is dropped, tearing down its tasks.
36async fn reconnect_ws<W>(
37    ws_state: &Arc<Mutex<Option<crate::websocket::MessageBoxWebSocket>>>,
38    subscriptions: &Arc<Mutex<HashMap<String, SubscriptionCallback>>>,
39    ws_url: &str,
40    identity_key: &str,
41    wallet: W,
42    originator: Option<String>,
43) -> Result<(), MessageBoxError>
44where
45    W: WalletInterface + Clone + Send + Sync + 'static,
46{
47    let ws =
48        crate::websocket::MessageBoxWebSocket::connect(ws_url, identity_key, wallet, originator)
49            .await?;
50
51    // Replay subscriptions on the fresh socket so the general_msg_dispatcher
52    // has callbacks registered for every active room. Without this, events
53    // delivered to the reconnected socket are silently dropped.
54    {
55        let subs = subscriptions.lock().await;
56        for (room_id, callback) in subs.iter() {
57            let event_key = format!("sendMessage-{room_id}");
58            // Re-join the room — ignore errors (server may already know us)
59            if let Err(e) = ws.join_room(room_id).await {
60                tracing::warn!(room_id, error = %e, "joinRoom replay failed on reconnect");
61            } else {
62                ws.subscribe(event_key, callback.clone()).await;
63                tracing::info!(room_id, "replayed subscription on reconnected socket");
64            }
65        }
66    }
67
68    *ws_state.lock().await = Some(ws);
69    Ok(())
70}
71
72/// Authenticated HTTP client for the MessageBox protocol.
73///
74/// `MessageBoxClient<W>` shares a single `AuthFetch<W>` via a bare `Arc` (no
75/// `Mutex`). `AuthFetch` is now concurrency-safe: every method takes `&self`,
76/// so any number of requests can be in flight on one session simultaneously.
77/// The client therefore never serializes HTTP round-trips — concurrent
78/// `send`/`list`/`poll` calls on the same `MessageBoxClient` execute in
79/// parallel, bounded only by the wallet and the network, not by a client-side
80/// lock. (Previously `AuthFetch::fetch()` took `&mut self`, forcing an
81/// `Arc<Mutex<AuthFetch>>` that funnelled all traffic through one lock and
82/// capped throughput at ~35 req/s; that bottleneck is gone.)
83pub struct MessageBoxClient<W: WalletInterface + Clone + 'static> {
84    /// Base URL of the MessageBox server (trailing whitespace trimmed on construction).
85    host: String,
86    /// BRC-31 authenticated HTTP client. `&self`-concurrency-safe, so it is shared
87    /// via a bare `Arc` — N requests can be in flight on one session at once.
88    /// The `Arc` lets background polling tasks share the same wallet auth state
89    /// without a lock or duplicating it.
90    auth_fetch: Arc<AuthFetch<W>>,
91    /// Wallet retained for direct encrypt / decrypt calls in `http_ops`.
92    wallet: W,
93    /// Optional originator string forwarded to wallet operations.
94    originator: Option<String>,
95    /// Cached identity public key (hex) — populated on first call.
96    identity_key: OnceCell<String>,
97    /// Ensures assert_initialized runs the full init path at most once.
98    pub(crate) init_once: OnceCell<()>,
99    /// Network preset for overlay tools (LookupResolver, TopicBroadcaster).
100    /// Defaults to Mainnet; pass Network::Local for localhost integration tests.
101    pub(crate) network: Network,
102    /// WebSocket connection state (None until first live message call).
103    ///
104    /// `Arc` so the background reconnect supervisor task can share it and swap a
105    /// freshly reconnected socket in without a self-reference.
106    ws_state: Arc<Mutex<Option<crate::websocket::MessageBoxWebSocket>>>,
107    /// Ensures the reconnect supervisor task is spawned at most once per client.
108    reconnect_supervisor: OnceCell<()>,
109    /// Tracks which message box rooms have been joined via join_room.
110    /// Updated on join_room (insert) and leave_room (remove).
111    /// Arc allows sharing with background polling tasks.
112    joined_rooms: Arc<Mutex<std::collections::HashSet<String>>>,
113    /// Registry of active subscriptions: room_id → callback. Survives reconnects.
114    /// Entries are added by listen_for_live_messages and removed by leave_room.
115    /// On WS reconnect, ensure_ws_connected replays joinRoom + re-subscribes
116    /// each entry on the fresh socket.
117    subscriptions: Arc<Mutex<HashMap<String, SubscriptionCallback>>>,
118}
119
120impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
121    /// Construct a new `MessageBoxClient`.
122    ///
123    /// * `host` — Base URL of the MessageBox server.  Trailing whitespace is
124    ///   trimmed so callers do not need to sanitize.
125    /// * `wallet` — Any `WalletInterface` implementation.
126    /// * `originator` — Optional originator string forwarded to wallet ops.
127    /// * `network` — Network preset for overlay tools (use `Network::Local` for localhost).
128    pub fn new(host: String, wallet: W, originator: Option<String>, network: Network) -> Self {
129        MessageBoxClient {
130            host: host.trim().to_string(),
131            auth_fetch: Arc::new(AuthFetch::new(wallet.clone())),
132            wallet,
133            originator,
134            identity_key: OnceCell::new(),
135            init_once: OnceCell::new(),
136            network,
137            ws_state: Arc::new(Mutex::new(None)),
138            reconnect_supervisor: OnceCell::new(),
139            joined_rooms: Arc::new(Mutex::new(std::collections::HashSet::new())),
140            subscriptions: Arc::new(Mutex::new(HashMap::new())),
141        }
142    }
143
144    /// Convenience constructor defaulting to `Network::Mainnet`.
145    pub fn new_mainnet(host: String, wallet: W, originator: Option<String>) -> Self {
146        Self::new(host, wallet, originator, Network::Mainnet)
147    }
148
149    // -----------------------------------------------------------------------
150    // Public getters (needed by http_ops)
151    // -----------------------------------------------------------------------
152
153    /// Return the trimmed host URL.
154    pub fn host(&self) -> &str {
155        &self.host
156    }
157
158    /// Return a reference to the underlying wallet.
159    pub fn wallet(&self) -> &W {
160        &self.wallet
161    }
162
163    /// Return the originator string, if any.
164    pub fn originator(&self) -> Option<&str> {
165        self.originator.as_deref()
166    }
167
168    /// Return the network preset used for overlay operations.
169    pub fn network(&self) -> &Network {
170        &self.network
171    }
172
173    // -----------------------------------------------------------------------
174    // Identity key
175    // -----------------------------------------------------------------------
176
177    /// Return the wallet's identity public key as a DER hex string.
178    ///
179    /// The result is cached in a `OnceCell` — subsequent calls return the
180    /// cached value without calling the wallet again.
181    pub async fn get_identity_key(&self) -> Result<String, MessageBoxError> {
182        if let Some(k) = self.identity_key.get() {
183            return Ok(k.clone());
184        }
185
186        let result = self
187            .wallet
188            .get_public_key(
189                GetPublicKeyArgs {
190                    identity_key: true,
191                    protocol_id: None,
192                    key_id: None,
193                    counterparty: None,
194                    privileged: false,
195                    privileged_reason: None,
196                    for_self: None,
197                    seek_permission: None,
198                },
199                self.originator.as_deref(),
200            )
201            .await
202            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
203
204        let key = result.public_key.to_der_hex();
205        // Ignore the error — if another caller set the cell first, we just use
206        // the stored value.
207        let _ = self.identity_key.set(key.clone());
208        Ok(key)
209    }
210
211    // -----------------------------------------------------------------------
212    // Initialization guard
213    // -----------------------------------------------------------------------
214
215    /// Ensure the client is initialized before performing any HTTP operation.
216    ///
217    /// Uses `init_once.get_or_try_init` so the full init path runs at most once
218    /// even under concurrent callers — matching the TS `initializeConnection`
219    /// pattern which defers work until first use.
220    ///
221    /// Init sequence:
222    /// 1. Cache identity key.
223    /// 2. Query overlay advertisements for this identity + host.
224    /// 3. If no matching ad exists, call `anoint_host`.
225    /// 4. CRITICAL TS PARITY: catch anoint errors and continue — TS logs
226    ///    "Failed to anoint host, continuing with default functionality".
227    pub(crate) async fn assert_initialized(&self) -> Result<(), MessageBoxError> {
228        self.init_once
229            .get_or_try_init(|| async {
230                let identity_key = self.get_identity_key().await?;
231                // Query existing advertisements for this identity+host pair.
232                // unwrap_or_default() because query_advertisements never fails (TS parity).
233                let ads = self
234                    .query_advertisements(Some(&identity_key), Some(&self.host))
235                    .await
236                    .unwrap_or_default();
237                if ads.iter().all(|ad| ad.host.trim() != self.host.trim()) {
238                    // No matching advertisement — anoint this host.
239                    // CRITICAL TS PARITY: catch anoint errors and continue.
240                    // TS: "Failed to anoint host, continuing with default functionality"
241                    if let Err(e) = self.anoint_host(&self.host).await {
242                        eprintln!("Warning: failed to anoint host: {e}");
243                    }
244                }
245                Ok::<(), MessageBoxError>(())
246            })
247            .await?;
248        Ok(())
249    }
250
251    /// Initialize the client — ensures overlay advertisement exists.
252    ///
253    /// User-facing wrapper for `assert_initialized`. Safe to call multiple times —
254    /// the init path runs exactly once due to `init_once` OnceCell semantics.
255    ///
256    /// `target_host`: when Some, uses that host for the anoint_host call instead of
257    /// `self.host()`. Matches the TS `init(targetHost?)` signature.
258    pub async fn init(&self, target_host: Option<&str>) -> Result<(), MessageBoxError> {
259        match target_host {
260            Some(host) => {
261                // TS parity: if targetHost provided, anoint THAT host directly
262                // instead of going through assert_initialized's self.host logic.
263                self.init_once
264                    .get_or_try_init(|| async {
265                        let _identity_key = self.get_identity_key().await?;
266                        // CRITICAL TS PARITY: catch anoint errors and continue.
267                        if let Err(e) = self.anoint_host(host).await {
268                            eprintln!("Warning: failed to anoint host: {e}");
269                        }
270                        Ok::<(), MessageBoxError>(())
271                    })
272                    .await?;
273                Ok(())
274            }
275            None => self.assert_initialized().await,
276        }
277    }
278
279    /// Ensure the WebSocket connection is established.
280    ///
281    /// User-facing wrapper for `ensure_ws_connected`. Mirrors the TS
282    /// `initializeConnection` method. When `override_host` is Some, the WS
283    /// connection uses that host instead of `self.host()`.
284    pub async fn initialize_connection(
285        &self,
286        override_host: Option<&str>,
287    ) -> Result<(), MessageBoxError> {
288        self.ensure_ws_connected(override_host).await
289    }
290
291    /// Returns a clone of the set of currently joined message box room names.
292    ///
293    /// Each entry is a raw room ID of the form `{identityKey}-{messageBox}`.
294    /// Mirrors the TS `joinedRooms` Map accessor.
295    pub fn get_joined_rooms(&self) -> std::collections::HashSet<String> {
296        // Use blocking lock — this is only called from synchronous test contexts.
297        // For async callers, they should be fine as the lock is never held across await.
298        self.joined_rooms.blocking_lock().clone()
299    }
300
301    /// Returns `Some(true)` if a WebSocket connection is active, `None` otherwise.
302    ///
303    /// Sync test utility mirroring the TS `testSocket` accessor. Only callable from
304    /// sync test contexts (e.g., unit tests) where `blocking_lock` is safe.
305    #[cfg(test)]
306    pub fn test_socket(&self) -> Option<bool> {
307        let guard = self.ws_state.blocking_lock();
308        guard.as_ref().map(|ws| ws.is_connected())
309    }
310
311    /// Returns the current WebSocket connection state.
312    ///
313    /// `None` = no WebSocket connected yet. `Some(true)` = connected and authenticated.
314    /// `Some(false)` = connected but BRC-103 handshake not yet complete.
315    ///
316    /// Async version of `test_socket` — safe to call from integration tests and any
317    /// async context. Mirrors the TS `testSocket` accessor.
318    pub async fn is_ws_connected(&self) -> Option<bool> {
319        let guard = self.ws_state.lock().await;
320        guard.as_ref().map(|ws| ws.is_connected())
321    }
322
323    /// Join a Socket.IO room for a message box and track it in `joined_rooms`.
324    ///
325    /// Constructs the room ID as `{identityKey}-{messageBox}` (matching TS joinRoom).
326    /// Ensures the WebSocket is connected before joining.
327    /// No-op if the room is already joined (idempotent like TS joinRoom).
328    pub async fn join_room(&self, message_box: &str) -> Result<(), MessageBoxError> {
329        let identity_key = self.get_identity_key().await?;
330        let room_id = format!("{identity_key}-{message_box}");
331
332        self.ensure_ws_connected(None).await?;
333
334        {
335            let guard = self.ws_state.lock().await;
336            if let Some(ref ws) = *guard {
337                ws.join_room(&room_id).await?;
338            }
339        }
340
341        self.joined_rooms.lock().await.insert(room_id);
342        Ok(())
343    }
344
345    /// POST JSON bytes to `url` using BRC-31 authenticated transport.
346    ///
347    /// `fetch()` takes `&self` and is concurrency-safe, so this call holds no
348    /// client-side lock — multiple `post_json` calls on the same client run in
349    /// parallel.
350    pub(crate) async fn post_json(
351        &self,
352        url: &str,
353        body_bytes: Vec<u8>,
354    ) -> Result<AuthFetchResponse, MessageBoxError> {
355        let mut headers = HashMap::new();
356        headers.insert("content-type".to_string(), "application/json".to_string());
357
358        let response = self
359            .auth_fetch
360            .fetch(url, "POST", Some(body_bytes), Some(headers))
361            .await
362            .map_err(|e| MessageBoxError::Auth(e.to_string()))?;
363
364        if response.status < 200 || response.status >= 300 {
365            return Err(MessageBoxError::Http(response.status, url.to_string()));
366        }
367
368        Ok(response)
369    }
370
371    /// POST JSON bytes to `url`, returning the response **regardless of HTTP
372    /// status** so the caller can inspect the body on a non-2xx reply.
373    ///
374    /// `post_json` early-returns `Err(Http(status))` on any non-2xx response and
375    /// discards the body — which is correct for most endpoints, but loses the
376    /// relay's structured error payload. `sendMessage` needs that payload to
377    /// distinguish the idempotent `ERR_DUPLICATE_MESSAGE` rejection (delivered
378    /// HTTP 400) from genuine failures, so it uses this raw variant instead.
379    pub(crate) async fn post_json_raw(
380        &self,
381        url: &str,
382        body_bytes: Vec<u8>,
383    ) -> Result<AuthFetchResponse, MessageBoxError> {
384        let mut headers = HashMap::new();
385        headers.insert("content-type".to_string(), "application/json".to_string());
386
387        self.auth_fetch
388            .fetch(url, "POST", Some(body_bytes), Some(headers))
389            .await
390            .map_err(|e| MessageBoxError::Auth(e.to_string()))
391    }
392
393    // -----------------------------------------------------------------------
394    // WebSocket live messaging
395    // -----------------------------------------------------------------------
396
397    /// Ensure a WebSocket connection is established and authenticated.
398    ///
399    /// If no connection exists or the existing connection is no longer connected,
400    /// creates a new `MessageBoxWebSocket` with the current identity key.
401    /// `rust_socketio` handles the Socket.IO handshake and HTTP-to-WS upgrade
402    /// internally — we pass the same base URL used for HTTP requests.
403    async fn ensure_ws_connected(
404        &self,
405        override_host: Option<&str>,
406    ) -> Result<(), MessageBoxError> {
407        // Fast path: already connected.
408        if self
409            .ws_state
410            .lock()
411            .await
412            .as_ref()
413            .map(|ws| ws.is_connected())
414            .unwrap_or(false)
415        {
416            // Still make sure the supervisor is watching this live socket.
417            self.spawn_reconnect_supervisor(override_host).await?;
418            return Ok(());
419        }
420
421        let identity_key = self.get_identity_key().await?;
422        let ws_url = override_host.unwrap_or_else(|| self.host()).to_string();
423        reconnect_ws(
424            &self.ws_state,
425            &self.subscriptions,
426            &ws_url,
427            &identity_key,
428            self.wallet.clone(),
429            self.originator.clone(),
430        )
431        .await?;
432
433        // Start the proactive reconnect supervisor (idempotent) so a later
434        // half-open death is healed in the background, not on the next call.
435        self.spawn_reconnect_supervisor(override_host).await?;
436        Ok(())
437    }
438
439    /// Spawn the background reconnect supervisor exactly once.
440    ///
441    /// The supervisor watches the WS connection's `is_connected()` flag. When the
442    /// watchdog (websocket.rs) flips it false on a half-open death — within
443    /// ~READ_DEADLINE, not 20 s+ — the supervisor proactively re-establishes the
444    /// socket with jittered backoff and replays joinRoom + re-subscribes every
445    /// active subscription, WITHOUT waiting for the next send/receive call. This
446    /// is what makes `is_ws_connected()` reflect reality and live delivery resume
447    /// on its own after a transient partition.
448    async fn spawn_reconnect_supervisor(
449        &self,
450        override_host: Option<&str>,
451    ) -> Result<(), MessageBoxError> {
452        if self.reconnect_supervisor.get().is_some() {
453            return Ok(());
454        }
455        let identity_key = self.get_identity_key().await?;
456        let ws_url = override_host.unwrap_or_else(|| self.host()).to_string();
457
458        // OnceCell::set races are benign — only one initializer wins; the loser
459        // simply skips spawning a duplicate.
460        if self.reconnect_supervisor.set(()).is_err() {
461            return Ok(());
462        }
463
464        let ws_state = self.ws_state.clone();
465        let subscriptions = self.subscriptions.clone();
466        let wallet = self.wallet.clone();
467        let originator = self.originator.clone();
468
469        tokio::spawn(async move {
470            loop {
471                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
472
473                // Only reconnect if (a) a socket exists, (b) it is dead, and
474                // (c) there is at least one active subscription to keep alive.
475                // A fresh client with no live listeners should not hold a socket
476                // open just because the supervisor is running.
477                let needs_reconnect = {
478                    let guard = ws_state.lock().await;
479                    match guard.as_ref() {
480                        Some(ws) => !ws.is_connected(),
481                        None => false,
482                    }
483                };
484                if !needs_reconnect {
485                    continue;
486                }
487                if subscriptions.lock().await.is_empty() {
488                    continue;
489                }
490
491                // Reconnect with jittered exponential backoff until it succeeds
492                // or the listeners go away.
493                let mut backoff = RECONNECT_BASE_BACKOFF;
494                loop {
495                    if subscriptions.lock().await.is_empty() {
496                        break;
497                    }
498                    match reconnect_ws(
499                        &ws_state,
500                        &subscriptions,
501                        &ws_url,
502                        &identity_key,
503                        wallet.clone(),
504                        originator.clone(),
505                    )
506                    .await
507                    {
508                        Ok(()) => {
509                            tracing::info!("WS reconnected proactively after half-open death");
510                            break;
511                        }
512                        Err(e) => {
513                            tracing::warn!(error = %e, backoff_ms = backoff.as_millis() as u64, "proactive WS reconnect failed; retrying");
514                            // Jitter: sleep backoff ± up to 25% to avoid
515                            // thundering-herd reconnects across many clients.
516                            let jitter = rand::random::<f64>() * 0.5 - 0.25; // [-0.25, +0.25)
517                            let millis = backoff.as_millis() as f64 * (1.0 + jitter);
518                            tokio::time::sleep(std::time::Duration::from_millis(
519                                millis.max(1.0) as u64
520                            ))
521                            .await;
522                            backoff = (backoff * 2).min(RECONNECT_MAX_BACKOFF);
523                        }
524                    }
525                }
526            }
527        });
528
529        Ok(())
530    }
531
532    /// Listen for live messages on a message box via WebSocket.
533    ///
534    /// Joins the Socket.IO room `{identity_key}-{message_box}` and registers
535    /// the provided callback. Messages can arrive via three paths, all funnelled
536    /// through one shared dedup wrapper so the callback fires at most once per id:
537    ///
538    /// 1. **WebSocket push (primary):** the BRC-103 `general_msg_dispatcher` fires
539    ///    the callback when the server broadcasts a signed `sendMessage-{roomId}`.
540    /// 2. **WebSocket `on_any` (fallback):** the same callback, for servers that
541    ///    emit raw (unsigned) room events.
542    /// 3. **HTTP poll (backstop):** a background task that *stands down* for any
543    ///    interval in which WS push already delivered (so it does not poll every
544    ///    2 s when live push is healthy), and otherwise polls `/listMessages` to
545    ///    catch anything the WS paths missed — forcing a catch-up at least every
546    ///    `MAX_POLL_SKIPS` intervals. Stops when `leave_room` removes the room
547    ///    from `joined_rooms`.
548    ///
549    /// All paths deliver `PeerMessage` with decrypted body to the same callback.
550    ///
551    /// Establishes a WebSocket connection if one is not already active.
552    /// `override_host` is reserved for future multi-host WS routing.
553    pub async fn listen_for_live_messages(
554        &self,
555        message_box: &str,
556        on_message: Arc<dyn Fn(PeerMessage) + Send + Sync>,
557        override_host: Option<&str>,
558    ) -> Result<(), MessageBoxError> {
559        // Adapt the legacy `Fn(PeerMessage)` consumer onto the typed core by
560        // dropping the authenticated-decrypt flag. Existing consumers (peerpay,
561        // payment_requests, adapter) are unaffected — same `PeerMessage` delivery.
562        let typed: Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync> =
563            Arc::new(move |m: AuthenticatedPeerMessage| {
564                on_message(PeerMessage {
565                    message_id: m.message_id,
566                    sender: m.sender,
567                    recipient: m.recipient,
568                    message_box: m.message_box,
569                    body: m.body,
570                });
571            });
572        self.listen_for_live_messages_typed(message_box, typed, override_host)
573            .await
574    }
575
576    /// Typed sibling of [`Self::listen_for_live_messages`] that delivers an
577    /// [`AuthenticatedPeerMessage`] carrying the authenticated-decrypt provenance
578    /// flag. Use this when a consumer must reject bodies that did not AEAD-decrypt
579    /// against the claimed sender (the MPC transport's fail-closed boundary).
580    ///
581    /// Identical delivery semantics to `listen_for_live_messages` (WS primary,
582    /// WS `on_any` fallback, HTTP poll backstop; exactly-once dedup; reconnect
583    /// replay) — only the delivered type differs.
584    pub async fn listen_for_live_messages_typed(
585        &self,
586        message_box: &str,
587        on_message: Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync>,
588        override_host: Option<&str>,
589    ) -> Result<(), MessageBoxError> {
590        let identity_key = self.get_identity_key().await?;
591        let room_id = format!("{identity_key}-{message_box}");
592        let event_key = format!("sendMessage-{room_id}");
593
594        self.ensure_ws_connected(override_host).await?;
595
596        // One user callback, three possible delivery paths (WS primary
597        // dispatcher, WS `on_any` fallback, HTTP poll). Wrap once so a
598        // `message_id` reaches the user at most once regardless of which path
599        // wins the race.
600        let deduped = exactly_once(on_message);
601
602        // WS-only delivery counter. Bumped on every WebSocket delivery so the
603        // HTTP poll backstop below can detect healthy live-push and stand down,
604        // instead of hitting /listMessages every 2s for every active mailbox.
605        let ws_activity = Arc::new(std::sync::atomic::AtomicU64::new(0));
606        let ws_callback = record_ws_activity(deduped.clone(), ws_activity.clone());
607
608        {
609            let guard = self.ws_state.lock().await;
610            if let Some(ref ws) = *guard {
611                ws.join_room(&room_id).await?;
612                ws.subscribe(event_key.clone(), ws_callback.clone()).await;
613            }
614        }
615
616        // Register in the subscription registry so ensure_ws_connected can replay
617        // this subscription (with its dedup + activity wrappers) on any reconnect.
618        self.subscriptions
619            .lock()
620            .await
621            .insert(room_id.clone(), ws_callback.clone());
622
623        self.joined_rooms.lock().await.insert(room_id.clone());
624
625        // Spawn the HTTP poll BACKSTOP. With the server signing room broadcasts
626        // onto the client's authenticated primary path, live WS push is the fast
627        // path. The poll now exists only to (a) support servers that don't push
628        // and (b) recover if the WS connection silently stalls — so it stands
629        // down for any interval in which a WS delivery already occurred. This
630        // keeps redundant /listMessages load off the server under high-frequency,
631        // many-connection traffic while preserving a correctness backstop.
632        let poll_auth_fetch = self.auth_fetch.clone();
633        let poll_joined_rooms = self.joined_rooms.clone();
634        let poll_host = self.host.clone();
635        let poll_message_box = message_box.to_string();
636        let poll_identity_key = identity_key.clone();
637        let poll_wallet = self.wallet.clone();
638        let poll_originator = self.originator.clone();
639        let poll_room_id = room_id.clone();
640        let poll_callback = deduped;
641        let poll_ws_activity = ws_activity;
642
643        tokio::spawn(async move {
644            use std::sync::atomic::Ordering;
645            let mut last_activity = poll_ws_activity.load(Ordering::Relaxed);
646            let mut skipped: u32 = 0;
647
648            loop {
649                tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
650
651                // Stop when the room is no longer active (leave_room was called)
652                if !poll_joined_rooms.lock().await.contains(&poll_room_id) {
653                    break;
654                }
655
656                // Stand down while WS push is healthy, but force a catch-up at
657                // least every MAX_POLL_SKIPS intervals so partial push loss on an
658                // otherwise-active connection can't permanently suppress the poll.
659                let activity = poll_ws_activity.load(Ordering::Relaxed);
660                if !poll_should_run(activity, &mut last_activity, &mut skipped) {
661                    continue;
662                }
663
664                // Backstop poll. Cheap no-op when the mailbox is simply idle; a
665                // genuine catch-up when live push stalled. The shared `deduped`
666                // callback suppresses anything WS already delivered.
667                match poll_list_messages(
668                    &poll_auth_fetch,
669                    &poll_host,
670                    &poll_message_box,
671                    &poll_identity_key,
672                    &poll_wallet,
673                    poll_originator.as_deref(),
674                )
675                .await
676                {
677                    Ok(messages) => {
678                        for msg in messages {
679                            poll_callback(msg);
680                        }
681                    }
682                    // The poll IS the correctness backstop — never swallow its
683                    // error silently. A persistent failure here means both live
684                    // push (already stalled) and the backstop are down.
685                    Err(e) => {
686                        tracing::warn!(
687                            room_id = %poll_room_id,
688                            error = %e,
689                            "poll backstop failed — message catch-up unavailable this interval"
690                        );
691                    }
692                }
693
694                // Refresh in case a WS delivery landed while we were polling.
695                last_activity = poll_ws_activity.load(Ordering::Relaxed);
696            }
697        });
698
699        Ok(())
700    }
701
702    /// Send a message via WebSocket with 10-second ack timeout and HTTP fallback.
703    ///
704    /// Mirrors TS `sendLiveMessage`: auto-connects if needed, joins the sender's
705    /// own room (required for ack routing), then emits. Falls back to HTTP if the
706    /// connection cannot be established or the ack times out / fails.
707    ///
708    /// TS parity: HTTP fallback resolves recipient's host via overlay before sending.
709    /// The WS path connects to `self.host()` — overlay resolution affects the fallback path.
710    /// `override_host`: when Some, the HTTP fallback path sends to that host directly.
711    /// Send a message via WebSocket with 10-second ack timeout and HTTP fallback.
712    ///
713    /// Mirrors TS `sendLiveMessage(params, overrideHost?)` which accepts the full
714    /// `SendMessageParams` including `skipEncryption`, `checkPermissions`, and `messageId`.
715    ///
716    /// - `skip_encryption`: when true, sends body as-is without BRC-78 encryption.
717    /// - `check_permissions`: when true, the HTTP fallback path fetches quotes and pays fees.
718    /// - `message_id`: when Some, uses caller-supplied ID instead of HMAC-derived ID.
719    /// - `override_host`: when Some, the HTTP fallback sends to that host directly.
720    #[allow(clippy::too_many_arguments)]
721    pub async fn send_live_message(
722        &self,
723        recipient: &str,
724        message_box: &str,
725        body: &str,
726        skip_encryption: bool,
727        check_permissions: bool,
728        message_id: Option<&str>,
729        override_host: Option<&str>,
730    ) -> Result<DeliveryMode, MessageBoxError> {
731        // If no host override, resolve the recipient's host via overlay.
732        // If the recipient is on a DIFFERENT host than ours, we must use HTTP
733        // (send_message with overlay resolution) since our WebSocket is only
734        // connected to self.host.
735        if override_host.is_none() {
736            let resolved = self
737                .resolve_host_for_recipient(recipient)
738                .await
739                .unwrap_or_else(|_| self.host().to_string());
740            if resolved.trim() != self.host().trim() {
741                // Recipient is on a different MessageBox server — use HTTP with overlay.
742                // This is a persisted delivery (no live WS ack possible cross-host).
743                let msg_id = self
744                    .send_message(
745                        recipient,
746                        message_box,
747                        body,
748                        skip_encryption,
749                        check_permissions,
750                        message_id,
751                        None,
752                    )
753                    .await?;
754                return Ok(DeliveryMode::Persisted { message_id: msg_id });
755            }
756        }
757
758        // Auto-connect or reconnect — if the WebSocket is disconnected, drop the
759        // stale connection and establish a fresh one with a new BRC-103 handshake.
760        // This handles proxy timeouts and server restarts without falling through
761        // to the slower HTTP fallback path.
762        {
763            let guard = self.ws_state.lock().await;
764            if guard.as_ref().map(|ws| !ws.is_connected()).unwrap_or(false) {
765                drop(guard);
766                // Stale connection — tear down and reconnect
767                if let Err(e) = self.disconnect_web_socket().await {
768                    eprintln!(
769                        "Warning: stale WebSocket disconnect failed (proceeding to reconnect): {e}"
770                    );
771                }
772            }
773        }
774        if let Err(e) = self.ensure_ws_connected(override_host).await {
775            eprintln!("Warning: WebSocket connection failed, falling back to HTTP: {e}");
776            // HTTP fallback: use override_host if provided, otherwise overlay resolution.
777            // Returns Persisted because no WS ack was received.
778            let msg_id = match override_host {
779                Some(host) => {
780                    self.send_message_to_host(
781                        host,
782                        recipient,
783                        message_box,
784                        body,
785                        skip_encryption,
786                        check_permissions,
787                        message_id,
788                        None,
789                    )
790                    .await?
791                }
792                None => {
793                    self.send_message(
794                        recipient,
795                        message_box,
796                        body,
797                        skip_encryption,
798                        check_permissions,
799                        message_id,
800                        None,
801                    )
802                    .await?
803                }
804            };
805            return Ok(DeliveryMode::Persisted { message_id: msg_id });
806        }
807
808        // Join sender's own room before send — TS calls joinRoom(messageBox) which
809        // joins `${myIdentityKey}-${messageBox}`. Required so the server can route
810        // the sendMessageAck back to this socket.
811        let identity_key = self.get_identity_key().await?;
812        let my_room = format!("{identity_key}-{message_box}");
813        {
814            let guard = self.ws_state.lock().await;
815            if let Some(ref ws) = *guard {
816                if ws.join_room(&my_room).await.is_err() {
817                    drop(guard);
818                    let msg_id = match override_host {
819                        Some(host) => {
820                            self.send_message_to_host(
821                                host,
822                                recipient,
823                                message_box,
824                                body,
825                                skip_encryption,
826                                check_permissions,
827                                message_id,
828                                None,
829                            )
830                            .await?
831                        }
832                        None => {
833                            self.send_message(
834                                recipient,
835                                message_box,
836                                body,
837                                skip_encryption,
838                                check_permissions,
839                                message_id,
840                                None,
841                            )
842                            .await?
843                        }
844                    };
845                    return Ok(DeliveryMode::Persisted { message_id: msg_id });
846                }
847            } else {
848                drop(guard);
849                let msg_id = match override_host {
850                    Some(host) => {
851                        self.send_message_to_host(
852                            host,
853                            recipient,
854                            message_box,
855                            body,
856                            skip_encryption,
857                            check_permissions,
858                            message_id,
859                            None,
860                        )
861                        .await?
862                    }
863                    None => {
864                        self.send_message(
865                            recipient,
866                            message_box,
867                            body,
868                            skip_encryption,
869                            check_permissions,
870                            message_id,
871                            None,
872                        )
873                        .await?
874                    }
875                };
876                return Ok(DeliveryMode::Persisted { message_id: msg_id });
877            }
878        }
879
880        // Encrypt (unless skip_encryption) and resolve message ID for the WebSocket path
881        let encrypted = if skip_encryption {
882            body.to_string()
883        } else {
884            crate::encryption::encrypt_body(self.wallet(), body, recipient, self.originator())
885                .await?
886        };
887        let message_id = if let Some(id) = message_id {
888            id.to_string()
889        } else {
890            crate::encryption::generate_message_id(
891                self.wallet(),
892                body,
893                recipient,
894                self.originator(),
895            )
896            .await?
897        };
898
899        let room_id = format!("{recipient}-{message_box}");
900        let ack_key = format!("sendMessageAck-{room_id}");
901
902        let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::<bool>();
903
904        let payload = serde_json::json!({
905            "roomId": room_id,
906            "message": {
907                "messageId": message_id,
908                "recipient": recipient,
909                "body": encrypted
910            }
911        });
912
913        // Emit — acquire lock briefly then release before awaiting ack
914        {
915            let guard = self.ws_state.lock().await;
916            if let Some(ref ws) = *guard {
917                ws.emit_send_message(payload, ack_key.clone(), ack_tx)
918                    .await?;
919            }
920        }
921
922        // Await ack with 10-second timeout.
923        // Live: server acked via WS within the window → DeliveryMode::Live.
924        // Anything else (timeout, channel error, ack=false): fall back to HTTP
925        // and return DeliveryMode::Persisted.
926        match tokio::time::timeout(std::time::Duration::from_secs(10), ack_rx).await {
927            Ok(Ok(true)) => Ok(DeliveryMode::Live { message_id }),
928            _ => {
929                // Clean up the pending ack to prevent channel leaks (Pitfall 7)
930                let guard = self.ws_state.lock().await;
931                if let Some(ref ws) = *guard {
932                    ws.remove_pending_ack(&ack_key).await;
933                }
934                drop(guard);
935                tracing::debug!(
936                    "send_live_message: WS ack timed out or failed; falling back to HTTP"
937                );
938                // Fall back to HTTP — pass through all feature params.
939                // The HTTP path generates a fresh message ID; use that for the Persisted ID.
940                let http_id = match override_host {
941                    Some(host) => {
942                        self.send_message_to_host(
943                            host,
944                            recipient,
945                            message_box,
946                            body,
947                            skip_encryption,
948                            check_permissions,
949                            None,
950                            None,
951                        )
952                        .await?
953                    }
954                    None => {
955                        self.send_message(
956                            recipient,
957                            message_box,
958                            body,
959                            skip_encryption,
960                            check_permissions,
961                            None,
962                            None,
963                        )
964                        .await?
965                    }
966                };
967                Ok(DeliveryMode::Persisted {
968                    message_id: http_id,
969                })
970            }
971        }
972    }
973
974    /// Leave a Socket.IO room and remove its subscription.
975    ///
976    /// Mirrors TS `leaveRoom(messageBox)`. Constructs the room ID as
977    /// `{identityKey}-{messageBox}` and emits `leaveRoom` to the server.
978    /// No-op if the WebSocket is not connected.
979    /// `override_host` is reserved for future multi-host WS routing.
980    pub async fn leave_room(
981        &self,
982        message_box: &str,
983        override_host: Option<&str>,
984    ) -> Result<(), MessageBoxError> {
985        let _ = override_host;
986        let identity_key = self.get_identity_key().await?;
987        let room_id = format!("{identity_key}-{message_box}");
988        // Tear down LOCAL subscription state FIRST and UNCONDITIONALLY, before the
989        // WS emit. The HTTP poll backstop loop breaks only when `joined_rooms` no
990        // longer contains the room, and reconnect-replay is driven by
991        // `subscriptions`. Neither must depend on the WS `leaveRoom` emit
992        // succeeding: if the socket is flaky at teardown (exactly when churn
993        // peaks), gating removal on the emit would leave the per-box
994        // `GET /listMessages` poll running forever and re-subscribing on
995        // reconnect — the listener leak this teardown exists to close.
996        self.joined_rooms.lock().await.remove(&room_id);
997        self.subscriptions.lock().await.remove(&room_id);
998        // Best-effort server-side leave. The local teardown above already stopped
999        // the poll loop; the server also drops the room on disconnect. A failed
1000        // emit is surfaced to the caller but no longer leaves a leak behind.
1001        {
1002            let guard = self.ws_state.lock().await;
1003            if let Some(ref ws) = *guard {
1004                ws.leave_room(&room_id).await?;
1005            }
1006        }
1007        Ok(())
1008    }
1009
1010    /// Disconnect the WebSocket connection and clear its state.
1011    ///
1012    /// Safe to call when no connection is active (no-op).
1013    pub async fn disconnect_web_socket(&self) -> Result<(), MessageBoxError> {
1014        let mut guard = self.ws_state.lock().await;
1015        if let Some(ws) = guard.take() {
1016            ws.disconnect().await?;
1017        }
1018        Ok(())
1019    }
1020
1021    // -----------------------------------------------------------------------
1022    // Internal HTTP helpers
1023    // -----------------------------------------------------------------------
1024
1025    /// GET `url` using BRC-31 authenticated transport.
1026    ///
1027    /// Mirrors `post_json` but sends no body and no content-type header.
1028    /// The caller is responsible for building the full URL including query string.
1029    pub(crate) async fn get_json(&self, url: &str) -> Result<AuthFetchResponse, MessageBoxError> {
1030        let response = self
1031            .auth_fetch
1032            .fetch(url, "GET", None, None)
1033            .await
1034            .map_err(|e| MessageBoxError::Auth(e.to_string()))?;
1035
1036        if response.status < 200 || response.status >= 300 {
1037            return Err(MessageBoxError::Http(response.status, url.to_string()));
1038        }
1039
1040        Ok(response)
1041    }
1042}
1043
1044// ---------------------------------------------------------------------------
1045// Delivery callback wrappers
1046// ---------------------------------------------------------------------------
1047
1048/// Maximum consecutive poll-backstop skips before a poll is forced regardless of
1049/// WS activity. At the 2 s interval this caps the catch-up window at ~16 s, so a
1050/// chatty room that keeps WS *activity* alive can never permanently suppress the
1051/// backstop even if *individual* pushes are being lost.
1052const MAX_POLL_SKIPS: u32 = 7;
1053
1054/// Wrap a subscriber callback so each `message_id` is delivered **at most once**,
1055/// no matter which path produced it: the WS primary dispatcher, the WS `on_any`
1056/// fallback, or the HTTP poll backstop. Without this, a server that both pushes
1057/// and is polled (or an `on_any`/dispatcher race) fires the callback twice.
1058///
1059/// Dedup is bounded to the most recent 10,000 distinct ids (FIFO eviction), so a
1060/// duplicate separated from its original by more than 10,000 intervening ids can
1061/// still slip through — acceptable for the single-use-mailbox traffic this
1062/// serves. This suppresses duplicates only; delivery liveness comes from the
1063/// WS + poll paths, not from this wrapper.
1064fn exactly_once(
1065    inner: Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync>,
1066) -> Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync> {
1067    let seen = Arc::new(std::sync::Mutex::new(BoundedIdSet::new(10_000)));
1068    Arc::new(move |msg: AuthenticatedPeerMessage| {
1069        // Recover rather than panic on poison: a poisoned dedup set is harmless
1070        // (worst case one duplicate delivery), whereas panicking here would kill
1071        // the WS dispatcher or the spawned poll task and stop delivery entirely.
1072        let fresh = match seen.lock() {
1073            Ok(mut g) => g.insert(msg.message_id.clone()),
1074            Err(poisoned) => poisoned.into_inner().insert(msg.message_id.clone()),
1075        };
1076        if fresh {
1077            inner(msg);
1078        }
1079    })
1080}
1081
1082/// Wrap a callback so every invocation first bumps `activity`. Installed only on
1083/// the WebSocket delivery path, so the HTTP poll backstop can tell when live
1084/// push is healthy and stand down (see the poll loop in
1085/// [`MessageBoxClient::listen_for_live_messages`]).
1086fn record_ws_activity(
1087    inner: Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync>,
1088    activity: Arc<std::sync::atomic::AtomicU64>,
1089) -> Arc<dyn Fn(AuthenticatedPeerMessage) + Send + Sync> {
1090    Arc::new(move |msg: AuthenticatedPeerMessage| {
1091        activity.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1092        inner(msg);
1093    })
1094}
1095
1096/// Decide whether the poll backstop should run this interval, given the current
1097/// WS-activity counter vs the value at the last check.
1098///
1099/// Skips (returns `false`) while WS push is delivering — *unless* `MAX_POLL_SKIPS`
1100/// consecutive skips have accrued, in which case it forces a poll so partial push
1101/// loss on an otherwise-active connection can't permanently suppress catch-up.
1102/// Updates `last_activity` and `skipped` in place.
1103fn poll_should_run(current_activity: u64, last_activity: &mut u64, skipped: &mut u32) -> bool {
1104    if current_activity != *last_activity && *skipped < MAX_POLL_SKIPS {
1105        *last_activity = current_activity;
1106        *skipped += 1;
1107        return false; // WS push is healthy this interval — stand down
1108    }
1109    *last_activity = current_activity;
1110    *skipped = 0;
1111    true
1112}
1113
1114// ---------------------------------------------------------------------------
1115// Bounded dedup set
1116// ---------------------------------------------------------------------------
1117
1118/// A bounded set with FIFO eviction for deduplicating message IDs.
1119///
1120/// Prevents unbounded memory growth in the HTTP polling fallback task.
1121/// When the set reaches `capacity`, the oldest entry is evicted before
1122/// inserting the new one.
1123struct BoundedIdSet {
1124    set: HashSet<String>,
1125    order: VecDeque<String>,
1126    capacity: usize,
1127}
1128
1129impl BoundedIdSet {
1130    fn new(capacity: usize) -> Self {
1131        assert!(capacity > 0, "BoundedIdSet capacity must be at least 1");
1132        Self {
1133            set: HashSet::with_capacity(capacity),
1134            order: VecDeque::with_capacity(capacity),
1135            capacity,
1136        }
1137    }
1138
1139    /// Insert an ID. Returns `true` if the ID was new (not previously seen).
1140    fn insert(&mut self, id: String) -> bool {
1141        if self.set.contains(&id) {
1142            return false;
1143        }
1144        if self.order.len() >= self.capacity {
1145            if let Some(old) = self.order.pop_front() {
1146                self.set.remove(&old);
1147            }
1148        }
1149        self.set.insert(id.clone());
1150        self.order.push_back(id);
1151        debug_assert_eq!(
1152            self.set.len(),
1153            self.order.len(),
1154            "BoundedIdSet internal invariant violated: set/deque size mismatch"
1155        );
1156        true
1157    }
1158
1159    /// Returns the number of IDs currently tracked.
1160    #[cfg(test)]
1161    fn len(&self) -> usize {
1162        self.order.len()
1163    }
1164
1165    /// Returns true if the given ID is currently in the set.
1166    #[cfg(test)]
1167    fn contains(&self, id: &str) -> bool {
1168        self.set.contains(id)
1169    }
1170}
1171
1172// ---------------------------------------------------------------------------
1173// Standalone helpers
1174// ---------------------------------------------------------------------------
1175
1176/// Poll `/listMessages` for a given message box using a shared authenticated HTTP client.
1177///
1178/// This is used by the background polling task spawned in `listen_for_live_messages`
1179/// to deliver messages as a fallback when the server does not broadcast via WS push.
1180///
1181/// Returns a `Vec<AuthenticatedPeerMessage>` with decrypted bodies (and their
1182/// authenticated-decrypt provenance), or an empty vec on error.
1183/// `accept_payments` is always false — the polling path does not handle delivery fees.
1184async fn poll_list_messages<W>(
1185    auth_fetch: &Arc<AuthFetch<W>>,
1186    host: &str,
1187    message_box: &str,
1188    identity_key: &str,
1189    wallet: &W,
1190    originator: Option<&str>,
1191) -> Result<Vec<AuthenticatedPeerMessage>, MessageBoxError>
1192where
1193    W: WalletInterface + Clone + Send + Sync + 'static,
1194{
1195    let params = ListMessagesParams {
1196        message_box: message_box.to_string(),
1197    };
1198    let body_bytes = serde_json::to_vec(&params)?;
1199    let url = format!("{host}/listMessages");
1200    let mut headers = HashMap::new();
1201    headers.insert("content-type".to_string(), "application/json".to_string());
1202
1203    let response = auth_fetch
1204        .fetch(&url, "POST", Some(body_bytes), Some(headers))
1205        .await
1206        .map_err(|e| MessageBoxError::Auth(e.to_string()))?;
1207
1208    if response.status < 200 || response.status >= 300 {
1209        return Err(MessageBoxError::Http(response.status, url));
1210    }
1211
1212    check_status_error(&response.body)?;
1213
1214    let list_response: ListMessagesResponse = serde_json::from_slice(&response.body)?;
1215
1216    let mut result = Vec::with_capacity(list_response.messages.len());
1217    for msg in list_response.messages {
1218        // Simple body extraction: if the body is a wrapped envelope, extract the message field.
1219        let plain_body = extract_plain_body(&msg.body);
1220        // Typed decrypt: carry authenticated-decrypt provenance through the poll
1221        // backstop so the MPC transport fails-closed identically on this path.
1222        let outcome = crate::encryption::try_decrypt_message_typed(
1223            wallet,
1224            &plain_body,
1225            &msg.sender,
1226            originator,
1227        )
1228        .await;
1229
1230        result.push(AuthenticatedPeerMessage {
1231            message_id: msg.message_id,
1232            sender: msg.sender,
1233            recipient: identity_key.to_string(),
1234            message_box: message_box.to_string(),
1235            authenticated_decrypt: outcome.is_authenticated(),
1236            body: outcome.into_body(),
1237        });
1238    }
1239
1240    Ok(result)
1241}
1242
1243/// Extract the plain message body from a potentially server-wrapped envelope.
1244///
1245/// The server sometimes wraps messages as `{"message": "...", "payment": {...}}`.
1246/// This helper unwraps it. If the body isn't a wrapped envelope, returns it as-is.
1247fn extract_plain_body(body: &str) -> String {
1248    if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
1249        if let Some(message) = v.get("message") {
1250            return match message {
1251                serde_json::Value::String(s) => s.clone(),
1252                other => other.to_string(),
1253            };
1254        }
1255    }
1256    body.to_string()
1257}
1258
1259/// Detect the relay's *duplicate-message* rejection in a response body.
1260///
1261/// The MessageBox relay rejects a re-send of an already-stored `messageId` with
1262/// HTTP 400 and body `{"status":"error","code":"ERR_DUPLICATE_MESSAGE",
1263/// "description":"Duplicate message."}` (see
1264/// `rust-messagebox-server/src/handlers/send_message.rs` — `error_response(
1265/// BAD_REQUEST, "ERR_DUPLICATE_MESSAGE", "Duplicate message.")`).
1266///
1267/// Because `generate_message_id` is a deterministic HMAC over
1268/// `(body, recipient, originator)`, two concurrent sends of the *same logical
1269/// message* derive the *same* `messageId`: one wins the insert, the other gets
1270/// this rejection. A duplicate rejection therefore means the message was
1271/// **already delivered** — it is idempotent-delivery success, NOT a failure.
1272///
1273/// We match on the precise `code == "ERR_DUPLICATE_MESSAGE"` signal (not a
1274/// blanket "ignore all 400s"), so genuine auth / validation rejections
1275/// (`ERR_INVALID_RECIPIENT_KEY`, `ERR_MESSAGE_REQUIRED`, BRC-31 auth failures,
1276/// …) still surface as errors.
1277///
1278/// Note: NOT a TS-parity behaviour. `@bsv/message-box-client` (v2.0.6) treats
1279/// any non-`success` send response as a thrown error and has no idempotent
1280/// duplicate handling; this is a deliberate Rust-side correctness improvement
1281/// for concurrent presign sends that collide on the deterministic messageId.
1282pub(crate) fn is_duplicate_message_rejection(body: &[u8]) -> bool {
1283    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
1284        if v.get("status").and_then(|s| s.as_str()) == Some("error")
1285            && v.get("code").and_then(|c| c.as_str()) == Some("ERR_DUPLICATE_MESSAGE")
1286        {
1287            return true;
1288        }
1289    }
1290    false
1291}
1292
1293/// Check if a successful (2xx) HTTP response body contains a server-level
1294/// error indicator (`{"status": "error", "description": "..."}`).
1295///
1296/// The MessageBox server can return HTTP 200 with a logical error payload —
1297/// this helper normalises that into `MessageBoxError::Auth`.
1298pub(crate) fn check_status_error(body: &[u8]) -> Result<(), MessageBoxError> {
1299    // Attempt a lightweight parse — ignore failures (malformed JSON is not
1300    // a server error in this sense).
1301    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
1302        if v.get("status").and_then(|s| s.as_str()) == Some("error") {
1303            let description = v
1304                .get("description")
1305                .and_then(|d| d.as_str())
1306                .unwrap_or("unknown error")
1307                .to_string();
1308            return Err(MessageBoxError::Auth(description));
1309        }
1310    }
1311    Ok(())
1312}
1313
1314// ---------------------------------------------------------------------------
1315// Tests
1316// ---------------------------------------------------------------------------
1317
1318#[cfg(test)]
1319mod tests {
1320    use super::*;
1321    use bsv::primitives::private_key::PrivateKey;
1322    use bsv::services::overlay_tools::Network;
1323    use bsv::wallet::error::WalletError;
1324    use bsv::wallet::interfaces::*;
1325    use bsv::wallet::proto_wallet::ProtoWallet;
1326    use std::sync::Arc;
1327
1328    /// Thin Arc wrapper that makes ProtoWallet clone-able for test purposes.
1329    ///
1330    /// ProtoWallet does not implement Clone because it holds a non-Clone
1331    /// KeyDeriver.  Wrapping in Arc satisfies the Clone bound while sharing
1332    /// the same underlying wallet across the clone and the AuthFetch instance.
1333    #[derive(Clone)]
1334    struct ArcWallet(Arc<ProtoWallet>);
1335
1336    impl ArcWallet {
1337        fn new() -> Self {
1338            let key = PrivateKey::from_random().expect("random key");
1339            ArcWallet(Arc::new(ProtoWallet::new(key)))
1340        }
1341    }
1342
1343    // Delegate every WalletInterface method to the inner ProtoWallet.
1344    #[async_trait::async_trait]
1345    impl WalletInterface for ArcWallet {
1346        async fn create_action(
1347            &self,
1348            args: CreateActionArgs,
1349            orig: Option<&str>,
1350        ) -> Result<CreateActionResult, WalletError> {
1351            self.0.create_action(args, orig).await
1352        }
1353        async fn sign_action(
1354            &self,
1355            args: SignActionArgs,
1356            orig: Option<&str>,
1357        ) -> Result<SignActionResult, WalletError> {
1358            self.0.sign_action(args, orig).await
1359        }
1360        async fn abort_action(
1361            &self,
1362            args: AbortActionArgs,
1363            orig: Option<&str>,
1364        ) -> Result<AbortActionResult, WalletError> {
1365            self.0.abort_action(args, orig).await
1366        }
1367        async fn list_actions(
1368            &self,
1369            args: ListActionsArgs,
1370            orig: Option<&str>,
1371        ) -> Result<ListActionsResult, WalletError> {
1372            self.0.list_actions(args, orig).await
1373        }
1374        async fn internalize_action(
1375            &self,
1376            args: InternalizeActionArgs,
1377            orig: Option<&str>,
1378        ) -> Result<InternalizeActionResult, WalletError> {
1379            self.0.internalize_action(args, orig).await
1380        }
1381        async fn list_outputs(
1382            &self,
1383            args: ListOutputsArgs,
1384            orig: Option<&str>,
1385        ) -> Result<ListOutputsResult, WalletError> {
1386            self.0.list_outputs(args, orig).await
1387        }
1388        async fn relinquish_output(
1389            &self,
1390            args: RelinquishOutputArgs,
1391            orig: Option<&str>,
1392        ) -> Result<RelinquishOutputResult, WalletError> {
1393            self.0.relinquish_output(args, orig).await
1394        }
1395        async fn get_public_key(
1396            &self,
1397            args: GetPublicKeyArgs,
1398            orig: Option<&str>,
1399        ) -> Result<GetPublicKeyResult, WalletError> {
1400            self.0.get_public_key(args, orig).await
1401        }
1402        async fn reveal_counterparty_key_linkage(
1403            &self,
1404            args: RevealCounterpartyKeyLinkageArgs,
1405            orig: Option<&str>,
1406        ) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> {
1407            self.0.reveal_counterparty_key_linkage(args, orig).await
1408        }
1409        async fn reveal_specific_key_linkage(
1410            &self,
1411            args: RevealSpecificKeyLinkageArgs,
1412            orig: Option<&str>,
1413        ) -> Result<RevealSpecificKeyLinkageResult, WalletError> {
1414            self.0.reveal_specific_key_linkage(args, orig).await
1415        }
1416        async fn encrypt(
1417            &self,
1418            args: EncryptArgs,
1419            orig: Option<&str>,
1420        ) -> Result<EncryptResult, WalletError> {
1421            self.0.encrypt(args, orig).await
1422        }
1423        async fn decrypt(
1424            &self,
1425            args: DecryptArgs,
1426            orig: Option<&str>,
1427        ) -> Result<DecryptResult, WalletError> {
1428            self.0.decrypt(args, orig).await
1429        }
1430        async fn create_hmac(
1431            &self,
1432            args: CreateHmacArgs,
1433            orig: Option<&str>,
1434        ) -> Result<CreateHmacResult, WalletError> {
1435            self.0.create_hmac(args, orig).await
1436        }
1437        async fn verify_hmac(
1438            &self,
1439            args: VerifyHmacArgs,
1440            orig: Option<&str>,
1441        ) -> Result<VerifyHmacResult, WalletError> {
1442            self.0.verify_hmac(args, orig).await
1443        }
1444        async fn create_signature(
1445            &self,
1446            args: CreateSignatureArgs,
1447            orig: Option<&str>,
1448        ) -> Result<CreateSignatureResult, WalletError> {
1449            self.0.create_signature(args, orig).await
1450        }
1451        async fn verify_signature(
1452            &self,
1453            args: VerifySignatureArgs,
1454            orig: Option<&str>,
1455        ) -> Result<VerifySignatureResult, WalletError> {
1456            self.0.verify_signature(args, orig).await
1457        }
1458        async fn acquire_certificate(
1459            &self,
1460            args: AcquireCertificateArgs,
1461            orig: Option<&str>,
1462        ) -> Result<Certificate, WalletError> {
1463            self.0.acquire_certificate(args, orig).await
1464        }
1465        async fn list_certificates(
1466            &self,
1467            args: ListCertificatesArgs,
1468            orig: Option<&str>,
1469        ) -> Result<ListCertificatesResult, WalletError> {
1470            self.0.list_certificates(args, orig).await
1471        }
1472        async fn prove_certificate(
1473            &self,
1474            args: ProveCertificateArgs,
1475            orig: Option<&str>,
1476        ) -> Result<ProveCertificateResult, WalletError> {
1477            self.0.prove_certificate(args, orig).await
1478        }
1479        async fn relinquish_certificate(
1480            &self,
1481            args: RelinquishCertificateArgs,
1482            orig: Option<&str>,
1483        ) -> Result<RelinquishCertificateResult, WalletError> {
1484            self.0.relinquish_certificate(args, orig).await
1485        }
1486        async fn discover_by_identity_key(
1487            &self,
1488            args: DiscoverByIdentityKeyArgs,
1489            orig: Option<&str>,
1490        ) -> Result<DiscoverCertificatesResult, WalletError> {
1491            self.0.discover_by_identity_key(args, orig).await
1492        }
1493        async fn discover_by_attributes(
1494            &self,
1495            args: DiscoverByAttributesArgs,
1496            orig: Option<&str>,
1497        ) -> Result<DiscoverCertificatesResult, WalletError> {
1498            self.0.discover_by_attributes(args, orig).await
1499        }
1500        async fn is_authenticated(
1501            &self,
1502            orig: Option<&str>,
1503        ) -> Result<AuthenticatedResult, WalletError> {
1504            self.0.is_authenticated(orig).await
1505        }
1506        async fn wait_for_authentication(
1507            &self,
1508            orig: Option<&str>,
1509        ) -> Result<AuthenticatedResult, WalletError> {
1510            self.0.wait_for_authentication(orig).await
1511        }
1512        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> {
1513            self.0.get_height(orig).await
1514        }
1515        async fn get_header_for_height(
1516            &self,
1517            args: GetHeaderArgs,
1518            orig: Option<&str>,
1519        ) -> Result<GetHeaderResult, WalletError> {
1520            self.0.get_header_for_height(args, orig).await
1521        }
1522        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> {
1523            self.0.get_network(orig).await
1524        }
1525        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> {
1526            self.0.get_version(orig).await
1527        }
1528    }
1529
1530    /// `new()` must trim leading/trailing whitespace from the host URL.
1531    #[tokio::test]
1532    async fn new_trims_host_url() {
1533        let wallet = ArcWallet::new();
1534        let client = MessageBoxClient::new(
1535            "https://example.com ".to_string(),
1536            wallet,
1537            None,
1538            Network::Mainnet,
1539        );
1540        assert_eq!(client.host(), "https://example.com");
1541    }
1542
1543    /// `get_identity_key` returns a non-empty hex string.
1544    #[tokio::test]
1545    async fn get_identity_key_returns_non_empty_hex() {
1546        let wallet = ArcWallet::new();
1547        let client = MessageBoxClient::new(
1548            "https://example.com".to_string(),
1549            wallet,
1550            None,
1551            Network::Mainnet,
1552        );
1553        let key = client.get_identity_key().await.expect("get_identity_key");
1554        assert!(!key.is_empty(), "identity key must be non-empty");
1555        assert!(
1556            key.chars().all(|c| c.is_ascii_hexdigit()),
1557            "identity key must be hex"
1558        );
1559    }
1560
1561    /// `get_json` exists — compile check via type coercion to async fn pointer.
1562    ///
1563    /// We verify the method resolves without calling it (no live network needed).
1564    #[allow(dead_code)]
1565    fn get_json_compiles(client: &MessageBoxClient<ArcWallet>) {
1566        // If get_json does not exist or has wrong signature, this fn fails to compile.
1567        let _fut = client.get_json("https://example.com/test");
1568    }
1569
1570    // -----------------------------------------------------------------------
1571    // Fix 2: Subscription registry tests
1572    // -----------------------------------------------------------------------
1573
1574    /// Subscription registry starts empty on construction.
1575    #[tokio::test]
1576    async fn subscription_registry_starts_empty() {
1577        let wallet = ArcWallet::new();
1578        let client = MessageBoxClient::new(
1579            "https://example.com".to_string(),
1580            wallet,
1581            None,
1582            Network::Mainnet,
1583        );
1584        let subs = client.subscriptions.lock().await;
1585        assert!(subs.is_empty(), "subscriptions must be empty on new client");
1586    }
1587
1588    /// Subscription registry can be populated and queried directly.
1589    ///
1590    /// This exercises the same path as listen_for_live_messages inserting into
1591    /// the registry. Full reconnect replay requires a live Socket.IO server.
1592    #[tokio::test]
1593    async fn subscription_registry_insert_and_lookup() {
1594        use std::sync::atomic::{AtomicBool, Ordering};
1595        let wallet = ArcWallet::new();
1596        let client = MessageBoxClient::new(
1597            "https://example.com".to_string(),
1598            wallet.clone(),
1599            None,
1600            Network::Mainnet,
1601        );
1602        let identity_key = client.get_identity_key().await.expect("identity key");
1603
1604        // Simulate what listen_for_live_messages does: build room_id and insert callback.
1605        let room_id = format!("{identity_key}-test_inbox");
1606        let fired = Arc::new(AtomicBool::new(false));
1607        let fired_clone = fired.clone();
1608        let callback: Arc<dyn Fn(crate::types::AuthenticatedPeerMessage) + Send + Sync> =
1609            Arc::new(move |_msg| {
1610                fired_clone.store(true, Ordering::SeqCst);
1611            });
1612
1613        client
1614            .subscriptions
1615            .lock()
1616            .await
1617            .insert(room_id.clone(), callback.clone());
1618
1619        // Verify it was stored
1620        let subs = client.subscriptions.lock().await;
1621        assert!(subs.contains_key(&room_id), "room_id must be in registry");
1622        assert_eq!(subs.len(), 1, "registry must have exactly one entry");
1623
1624        // Verify the stored callback is callable
1625        let cb = subs.get(&room_id).cloned().expect("callback must exist");
1626        drop(subs);
1627        cb(crate::types::AuthenticatedPeerMessage {
1628            message_id: "test".to_string(),
1629            sender: "03sender".to_string(),
1630            recipient: identity_key.clone(),
1631            message_box: "test_inbox".to_string(),
1632            body: "hello".to_string(),
1633            authenticated_decrypt: true,
1634        });
1635        assert!(
1636            fired.load(Ordering::SeqCst),
1637            "callback must have been invoked"
1638        );
1639    }
1640
1641    /// Subscription registry entry is removed when leave_room is called — compile check.
1642    ///
1643    /// Full removal requires ws_state to be Some (live connection). Here we verify
1644    /// the direct remove path on the registry in isolation.
1645    #[tokio::test]
1646    async fn subscription_registry_remove_on_leave() {
1647        let wallet = ArcWallet::new();
1648        let client = MessageBoxClient::new(
1649            "https://example.com".to_string(),
1650            wallet,
1651            None,
1652            Network::Mainnet,
1653        );
1654        let identity_key = client.get_identity_key().await.expect("identity key");
1655        let room_id = format!("{identity_key}-inbox");
1656
1657        // Insert a dummy callback
1658        let cb: Arc<dyn Fn(crate::types::AuthenticatedPeerMessage) + Send + Sync> =
1659            Arc::new(|_| {});
1660        client
1661            .subscriptions
1662            .lock()
1663            .await
1664            .insert(room_id.clone(), cb);
1665        assert_eq!(client.subscriptions.lock().await.len(), 1, "inserted");
1666
1667        // Remove it directly (simulates what leave_room does)
1668        client.subscriptions.lock().await.remove(&room_id);
1669        assert!(client.subscriptions.lock().await.is_empty(), "removed");
1670    }
1671
1672    /// `check_status_error` returns Ok for success body.
1673    #[test]
1674    fn check_status_error_passes_success_body() {
1675        use super::check_status_error;
1676        let body = br#"{"status":"success","data":{}}"#;
1677        assert!(check_status_error(body).is_ok());
1678    }
1679
1680    /// `check_status_error` returns Err for server error body.
1681    #[test]
1682    fn check_status_error_returns_err_for_error_body() {
1683        use super::check_status_error;
1684        let body = br#"{"status":"error","description":"permission denied"}"#;
1685        let err = check_status_error(body).unwrap_err();
1686        assert!(matches!(err, crate::error::MessageBoxError::Auth(_)));
1687        assert_eq!(err.to_string(), "auth error: permission denied");
1688    }
1689
1690    /// `is_duplicate_message_rejection` matches the relay's precise duplicate signal.
1691    #[test]
1692    fn is_duplicate_message_rejection_matches_exact_code() {
1693        use super::is_duplicate_message_rejection;
1694        // The exact body the relay returns (HTTP 400) for a duplicate messageId.
1695        let dup = br#"{"status":"error","code":"ERR_DUPLICATE_MESSAGE","description":"Duplicate message."}"#;
1696        assert!(
1697            is_duplicate_message_rejection(dup),
1698            "must match ERR_DUPLICATE_MESSAGE"
1699        );
1700    }
1701
1702    /// Genuine validation / auth errors must NOT be treated as duplicates.
1703    #[test]
1704    fn is_duplicate_message_rejection_rejects_other_errors() {
1705        use super::is_duplicate_message_rejection;
1706        let invalid =
1707            br#"{"status":"error","code":"ERR_INVALID_RECIPIENT_KEY","description":"bad key"}"#;
1708        let missing =
1709            br#"{"status":"error","code":"ERR_MESSAGE_REQUIRED","description":"no body"}"#;
1710        // An error body with no `code` field (e.g. a raw BRC-31 auth failure).
1711        let no_code = br#"{"status":"error","description":"permission denied"}"#;
1712        let success = br#"{"status":"success","messageId":"abc"}"#;
1713        assert!(
1714            !is_duplicate_message_rejection(invalid),
1715            "ERR_INVALID_RECIPIENT_KEY is not a duplicate"
1716        );
1717        assert!(
1718            !is_duplicate_message_rejection(missing),
1719            "ERR_MESSAGE_REQUIRED is not a duplicate"
1720        );
1721        assert!(
1722            !is_duplicate_message_rejection(no_code),
1723            "no-code error is not a duplicate"
1724        );
1725        assert!(
1726            !is_duplicate_message_rejection(success),
1727            "success is not a duplicate"
1728        );
1729        assert!(
1730            !is_duplicate_message_rejection(b"not json"),
1731            "malformed body is not a duplicate"
1732        );
1733    }
1734
1735    /// `get_identity_key` returns the same value on a second call (OnceCell cache).
1736    #[tokio::test]
1737    async fn get_identity_key_caches_result() {
1738        let wallet = ArcWallet::new();
1739        let client = MessageBoxClient::new(
1740            "https://example.com".to_string(),
1741            wallet,
1742            None,
1743            Network::Mainnet,
1744        );
1745        let key1 = client.get_identity_key().await.expect("first call");
1746        let key2 = client.get_identity_key().await.expect("second call");
1747        assert_eq!(key1, key2, "OnceCell must return the same value on re-call");
1748    }
1749
1750    /// `init_once` field is of type `OnceCell<()>` — compile check.
1751    ///
1752    /// The init_once field must be retained so assert_initialized can be wired
1753    /// through it in Phase 5. This test verifies the field type and existence.
1754    #[test]
1755    fn test_init_compiles() {
1756        let wallet = ArcWallet::new();
1757        let client = MessageBoxClient::new(
1758            "https://example.com".to_string(),
1759            wallet,
1760            None,
1761            Network::Mainnet,
1762        );
1763        // Verify the init_once field can be referenced and the public init() method exists.
1764        // If init_once were removed or its type changed, this compile-check fails.
1765        let _cell: &OnceCell<()> = &client.init_once;
1766        // Public init() must exist — verified by type resolution.
1767        // Verify init() exists and returns a Future — drop without awaiting.
1768        drop(client.init(None));
1769    }
1770
1771    // -----------------------------------------------------------------------
1772    // BoundedIdSet tests
1773    // -----------------------------------------------------------------------
1774
1775    /// BoundedIdSet rejects duplicate inserts.
1776    #[test]
1777    fn bounded_id_set_rejects_duplicates() {
1778        let mut set = super::BoundedIdSet::new(100);
1779        assert!(set.insert("a".to_string()), "first insert returns true");
1780        assert!(!set.insert("a".to_string()), "duplicate returns false");
1781        assert_eq!(set.len(), 1);
1782    }
1783
1784    /// BoundedIdSet evicts oldest entries when at capacity.
1785    #[test]
1786    fn bounded_id_set_evicts_oldest() {
1787        let mut set = super::BoundedIdSet::new(3);
1788        set.insert("a".to_string());
1789        set.insert("b".to_string());
1790        set.insert("c".to_string());
1791        // At capacity — next insert should evict "a"
1792        assert!(set.insert("d".to_string()));
1793        assert!(!set.contains("a"), "oldest entry must be evicted");
1794        assert!(set.contains("b"));
1795        assert!(set.contains("c"));
1796        assert!(set.contains("d"));
1797        // "a" is now unknown — should be insertable again
1798        assert!(
1799            set.insert("a".to_string()),
1800            "evicted entry can be re-inserted"
1801        );
1802    }
1803
1804    /// BoundedIdSet panics on capacity=0.
1805    #[test]
1806    #[should_panic(expected = "capacity must be at least 1")]
1807    fn bounded_id_set_rejects_zero_capacity() {
1808        super::BoundedIdSet::new(0);
1809    }
1810
1811    fn peer_msg(id: &str) -> crate::types::AuthenticatedPeerMessage {
1812        crate::types::AuthenticatedPeerMessage {
1813            message_id: id.to_string(),
1814            sender: "03sender".to_string(),
1815            recipient: "02recipient".to_string(),
1816            message_box: "inbox".to_string(),
1817            body: "body".to_string(),
1818            authenticated_decrypt: true,
1819        }
1820    }
1821
1822    #[test]
1823    fn exactly_once_delivers_each_message_id_once() {
1824        use std::sync::atomic::{AtomicUsize, Ordering};
1825        let count = Arc::new(AtomicUsize::new(0));
1826        let c = count.clone();
1827        let inner: Arc<dyn Fn(crate::types::AuthenticatedPeerMessage) + Send + Sync> =
1828            Arc::new(move |_m| {
1829                c.fetch_add(1, Ordering::SeqCst);
1830            });
1831        let deduped = super::exactly_once(inner);
1832
1833        // Same id arriving on three "paths" (WS dispatcher, WS on_any, poll).
1834        deduped(peer_msg("m1"));
1835        deduped(peer_msg("m1"));
1836        deduped(peer_msg("m1"));
1837        // A distinct id still gets through.
1838        deduped(peer_msg("m2"));
1839
1840        assert_eq!(count.load(Ordering::SeqCst), 2, "m1 once + m2 once");
1841    }
1842
1843    #[test]
1844    fn record_ws_activity_bumps_counter_and_forwards() {
1845        use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1846        let activity = Arc::new(AtomicU64::new(0));
1847        let count = Arc::new(AtomicUsize::new(0));
1848        let c = count.clone();
1849        let inner: Arc<dyn Fn(crate::types::AuthenticatedPeerMessage) + Send + Sync> =
1850            Arc::new(move |_m| {
1851                c.fetch_add(1, Ordering::SeqCst);
1852            });
1853        let wrapped = super::record_ws_activity(inner, activity.clone());
1854
1855        wrapped(peer_msg("m1"));
1856        wrapped(peer_msg("m2"));
1857
1858        assert_eq!(
1859            activity.load(Ordering::Relaxed),
1860            2,
1861            "counter bumped per delivery"
1862        );
1863        assert_eq!(
1864            count.load(Ordering::SeqCst),
1865            2,
1866            "inner callback forwarded each time"
1867        );
1868    }
1869
1870    #[test]
1871    fn poll_should_run_runs_when_ws_quiet() {
1872        // current == last → WS delivered nothing this interval → poll runs.
1873        let mut last = 5u64;
1874        let mut skipped = 0u32;
1875        assert!(super::poll_should_run(5, &mut last, &mut skipped));
1876        assert_eq!(skipped, 0);
1877    }
1878
1879    #[test]
1880    fn poll_should_run_stands_down_when_ws_active() {
1881        // current != last → WS delivered → stand down (within the skip budget).
1882        let mut last = 5u64;
1883        let mut skipped = 0u32;
1884        assert!(!super::poll_should_run(6, &mut last, &mut skipped));
1885        assert_eq!(last, 6, "last_activity advances to current");
1886        assert_eq!(skipped, 1);
1887    }
1888
1889    #[test]
1890    fn poll_should_run_forces_catch_up_after_max_skips() {
1891        // A perpetually-active connection must still be polled at least every
1892        // MAX_POLL_SKIPS intervals so partial push loss can't suppress catch-up.
1893        let mut last = 0u64;
1894        let mut skipped = 0u32;
1895        let mut runs = 0u32;
1896        for tick in 1..=(super::MAX_POLL_SKIPS as u64 * 3) {
1897            // WS "delivers" every interval → counter always changes.
1898            if super::poll_should_run(tick, &mut last, &mut skipped) {
1899                runs += 1;
1900            }
1901        }
1902        // Over 3*MAX_POLL_SKIPS always-active intervals, the forced poll fires
1903        // roughly every (MAX_POLL_SKIPS+1) intervals — at least twice.
1904        assert!(
1905            runs >= 2,
1906            "forced catch-up must fire periodically, got {runs}"
1907        );
1908    }
1909
1910    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1911    async fn exactly_once_is_safe_under_concurrent_delivery() {
1912        use std::sync::atomic::{AtomicUsize, Ordering};
1913        // The deduped callback is shared across the WS dispatcher, on_any, and
1914        // the poll task concurrently. Hammer the same ids from many tasks and
1915        // assert each id is delivered exactly once.
1916        let count = Arc::new(AtomicUsize::new(0));
1917        let c = count.clone();
1918        let inner: Arc<dyn Fn(crate::types::AuthenticatedPeerMessage) + Send + Sync> =
1919            Arc::new(move |_m| {
1920                c.fetch_add(1, Ordering::SeqCst);
1921            });
1922        let deduped = super::exactly_once(inner);
1923
1924        let mut handles = Vec::new();
1925        for _ in 0..8 {
1926            let cb = deduped.clone();
1927            handles.push(tokio::spawn(async move {
1928                for i in 0..100 {
1929                    cb(peer_msg(&format!("m{i}")));
1930                }
1931            }));
1932        }
1933        for h in handles {
1934            h.await.unwrap();
1935        }
1936        // 100 distinct ids, each delivered exactly once despite 8 racing tasks.
1937        assert_eq!(count.load(Ordering::SeqCst), 100);
1938    }
1939
1940    #[test]
1941    fn exactly_once_composes_with_record_ws_activity() {
1942        // Mirrors the wiring in listen_for_live_messages: the WS path stamps
1943        // activity then delivers through the shared dedup; the poll path shares
1944        // the same dedup. A message delivered by WS must not be re-delivered by
1945        // a later poll of the same id.
1946        use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1947        let activity = Arc::new(AtomicU64::new(0));
1948        let count = Arc::new(AtomicUsize::new(0));
1949        let c = count.clone();
1950        let inner: Arc<dyn Fn(crate::types::AuthenticatedPeerMessage) + Send + Sync> =
1951            Arc::new(move |_m| {
1952                c.fetch_add(1, Ordering::SeqCst);
1953            });
1954        let deduped = super::exactly_once(inner);
1955        let ws_path = super::record_ws_activity(deduped.clone(), activity.clone());
1956        let poll_path = deduped;
1957
1958        ws_path(peer_msg("m1")); // delivered via WS, stamps activity
1959        poll_path(peer_msg("m1")); // poll re-sees the same id → suppressed
1960
1961        assert_eq!(
1962            count.load(Ordering::SeqCst),
1963            1,
1964            "delivered exactly once across paths"
1965        );
1966        assert_eq!(
1967            activity.load(Ordering::Relaxed),
1968            1,
1969            "only the WS path stamps activity"
1970        );
1971    }
1972}