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