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(¶ms)?;
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}