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