Skip to main content

vector_core/
inbox_relays.rs

1//! NIP-17 Kind 10050 (DM Relay List) support.
2//!
3//! Fetches, caches, and publishes kind 10050 events so that DM gift wraps
4//! are delivered to the recipient's preferred inbox relays.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8use std::sync::{Arc, Mutex, Weak};
9use std::time::Instant;
10
11use nostr_sdk::prelude::*;
12use std::sync::LazyLock;
13
14use crate::state::nostr_client;
15
16// ============================================================================
17// Per-relay publish tracker — closes "dependent-event-races-parent" races
18// ============================================================================
19//
20// Vector returns on the first relay ack so the UI can mark a message
21// as "Sent" without waiting for stragglers. Other relays continue
22// receiving the event in the background. Any operation that publishes
23// a *dependent* event referencing the just-sent one (NIP-09 deletion,
24// edit, reaction, reply, …) can race those background publishes: at
25// a relay where the parent hasn't arrived yet, the dependent gets
26// dropped or stored disconnected, and when the parent arrives later
27// it stays without the dependent ever being applied.
28//
29// `EventPublishTracker` exposes a per-relay event stream of "parent
30// successfully published to X". Dependent senders subscribe, drain
31// relays that have already settled, then wait for stragglers and
32// fire their dependent event to each one as soon as it confirms the
33// parent. Every relay that ever received the parent gets the
34// dependent in real time, and the user sees no UX latency on either
35// the parent send or the dependent action.
36//
37// This pattern is generic: deletion is the first consumer, but rapid
38// edits, self-reactions, and replies-to-just-sent all benefit. The
39// tracker doesn't care what the event is or what the dependent
40// operation does — it only knows "this parent landed at this relay".
41
42/// Per-relay publish tracker. One per outbound event whose dependents
43/// (deletions, edits, reactions, replies, ...) need to fire only
44/// after the parent has actually landed at each individual relay.
45pub struct EventPublishTracker {
46    event_id: EventId,
47    /// Successful relays in arrival order. Subscribers walk this with
48    /// a cursor and wait on `notify` for new entries.
49    successes: Mutex<Vec<RelayUrl>>,
50    notify: tokio::sync::Notify,
51    /// Relays still publishing. When this hits 0, the tracker
52    /// removes itself from the global registry and any pending
53    /// `next_success` waiters are woken so they observe end-of-stream.
54    in_flight: AtomicUsize,
55}
56
57impl EventPublishTracker {
58    fn new(event_id: EventId, initial_in_flight: usize) -> Arc<Self> {
59        Arc::new(Self {
60            event_id,
61            successes: Mutex::new(Vec::new()),
62            notify: tokio::sync::Notify::new(),
63            in_flight: AtomicUsize::new(initial_in_flight),
64        })
65    }
66
67    /// Called by a per-relay publish task on success.
68    fn note_success(&self, url: RelayUrl) {
69        self.successes.lock().unwrap().push(url);
70        self.notify.notify_waiters();
71    }
72
73    /// Called by every per-relay task on completion (success OR fail).
74    /// When the last in-flight task settles, drops the tracker from
75    /// the global registry.
76    fn note_settled(&self) {
77        if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
78            self.notify.notify_waiters();
79            PUBLISH_TRACKERS.lock().unwrap().remove(&self.event_id);
80        }
81    }
82
83    /// Async iterator over successful relays. Yields each URL once,
84    /// regardless of whether it settled before or after the call.
85    /// Returns `None` when every spawned per-relay task has settled
86    /// AND the cursor has consumed every success — i.e. the dependent
87    /// sender has visited every relay that ever held the parent.
88    pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
89        loop {
90            // Pre-create the notified future BEFORE inspecting state
91            // so a notify_waiters() that fires between the check and
92            // the await doesn't get lost.
93            let notified = self.notify.notified();
94            tokio::pin!(notified);
95            notified.as_mut().enable();
96
97            let (next, done) = {
98                let successes = self.successes.lock().unwrap();
99                let next = successes.get(*cursor).cloned();
100                let done = self.in_flight.load(Ordering::SeqCst) == 0
101                    && *cursor >= successes.len();
102                (next, done)
103            };
104
105            if let Some(url) = next {
106                *cursor += 1;
107                return Some(url);
108            }
109            if done {
110                return None;
111            }
112
113            notified.await;
114        }
115    }
116}
117
118/// Global registry of in-flight tracked publishes. Keyed by event id.
119/// Trackers self-remove once all per-relay tasks settle.
120static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
121    LazyLock::new(|| Mutex::new(HashMap::new()));
122
123/// Look up the tracker for an event currently being published.
124/// Returns `None` if the publish has fully settled (all relays done)
125/// or if the tracker never existed (e.g. the event was sent in a
126/// previous app session, or via a non-tracked send path). Dependent
127/// senders fall back to a best-effort broadcast in that case.
128pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
129    PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
130}
131
132/// Spawn one publish task per resolved relay and register a tracker
133/// keyed by the event id. Returns the join handles so the caller can
134/// race them for first-ok or wait for all to settle as needed. The
135/// spawned tasks continue updating the tracker after the caller
136/// stops waiting on the handles.
137///
138/// Generic primitive — any send path that wants its event referenced
139/// by a future dependent (deletions, edits, reactions, replies)
140/// should publish via this helper so the dependent can later look up
141/// the tracker via `get_publish_tracker(parent_id)`.
142pub fn spawn_tracked_publish(
143    resolved: Vec<(RelayUrl, Relay)>,
144    event: Event,
145) -> Vec<tokio::task::JoinHandle<(RelayUrl, Result<EventId, String>)>> {
146    let event_id = event.id;
147    // Zero relays → zero tasks → nobody ever calls note_settled; a registered tracker
148    // would leak forever.
149    if resolved.is_empty() {
150        return Vec::new();
151    }
152    let tracker = EventPublishTracker::new(event_id, resolved.len());
153    PUBLISH_TRACKERS.lock().unwrap().insert(event_id, tracker.clone());
154
155    let mut handles = Vec::with_capacity(resolved.len());
156    for (url, relay) in resolved {
157        let event = event.clone();
158        let tracker = tracker.clone();
159        handles.push(tokio::spawn(async move {
160            let result = relay
161                .send_event(&event)
162                .await
163                .map_err(|e| e.to_string());
164            if result.is_ok() {
165                tracker.note_success(url.clone());
166            }
167            tracker.note_settled();
168            (url, result)
169        }));
170    }
171    handles
172}
173
174// ============================================================================
175// Cache
176// ============================================================================
177
178/// How long cached relay lists stay valid before re-fetching.
179const CACHE_TTL_SECS: u64 = 3600; // 1 hour
180
181/// Shorter TTL for failed fetches so transient errors don't suppress routing too long.
182const CACHE_TTL_ERROR_SECS: u64 = 60; // 1 minute
183
184struct CachedRelays {
185    relays: Vec<String>,
186    fetched_at: Instant,
187    /// Whether the fetch succeeded (true) or failed/timed out (false).
188    /// Failed fetches use a shorter cache TTL.
189    fetch_ok: bool,
190}
191
192static INBOX_RELAY_CACHE: LazyLock<Mutex<HashMap<PublicKey, CachedRelays>>> =
193    LazyLock::new(|| Mutex::new(HashMap::new()));
194
195/// Drop every cached recipient relay list — called by `reset_session()`.
196/// The cache is recipient-keyed (so technically account-agnostic) but
197/// grows unboundedly across sessions; the 1-hour TTL only reclaims
198/// re-queried entries. Clear on swap to free memory and avoid
199/// stale-data revivals.
200pub fn clear_inbox_relay_cache() {
201    if let Ok(mut cache) = INBOX_RELAY_CACHE.lock() {
202        cache.clear();
203    }
204}
205
206/// Per-key locks to prevent cache stampede (thundering herd).
207/// When multiple messages target the same recipient with a cold cache, only the
208/// first fetch runs — others wait on the per-key lock, then hit the cache.
209/// Uses Weak references: the Mutex allocation is freed when Arc refcount drops.
210/// HashMap entries are removed eagerly by a per-call drop guard (normal return,
211/// cancellation, or panic unwind). Periodic retain() remains a fallback safety net.
212static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
213    LazyLock::new(|| Mutex::new(HashMap::new()));
214
215/// Counter for periodic fallback pruning of dead Weak entries in FETCH_LOCKS.
216/// Prune every PRUNE_INTERVAL cache misses to avoid O(n) scans on every access.
217/// This complements eager per-key cleanup after each completed call.
218static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
219
220/// Prune dead Weak entries every N cache misses. Lower = more CPU for pruning,
221/// higher = more memory for stale entries. 100 is a good balance for production.
222#[cfg(not(test))]
223const PRUNE_INTERVAL: u64 = 100;
224
225/// In tests, prune every access for deterministic behavior (tests rely on
226/// immediate cleanup to verify pruning logic works correctly).
227#[cfg(test)]
228const PRUNE_INTERVAL: u64 = 1;
229
230/// Drop-guard for eager per-key lock-map cleanup.
231/// Runs on normal return and when the future is dropped (e.g. cancellation).
232struct FetchLockEntryCleanup {
233    pubkey: PublicKey,
234    key_lock: Arc<tokio::sync::Mutex<()>>,
235}
236
237impl FetchLockEntryCleanup {
238    fn new(pubkey: PublicKey, key_lock: Arc<tokio::sync::Mutex<()>>) -> Self {
239        Self { pubkey, key_lock }
240    }
241}
242
243impl Drop for FetchLockEntryCleanup {
244    fn drop(&mut self) {
245        let mut locks = match FETCH_LOCKS.lock() {
246            Ok(locks) => locks,
247            Err(_) => return, // fallback retain() handles stale entries later
248        };
249
250        let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
251            Some(current) => {
252                // upgrade() adds one temporary Arc. If strong_count == 2, only:
253                // 1) this drop-guard's Arc, 2) upgrade() temporary Arc.
254                // That means no other in-flight callers still hold this key lock.
255                Arc::ptr_eq(&current, &self.key_lock) && Arc::strong_count(&current) == 2
256            }
257            None => false,
258        };
259        if should_remove {
260            locks.remove(&self.pubkey);
261        }
262    }
263}
264
265// ============================================================================
266// Fetch
267// ============================================================================
268
269/// Canonical string form for relay-url comparison. nostr-sdk canonicalises
270/// differently between published-10050 strings and pool keys (trailing
271/// slashes, default ports, case), so equality checks must go through this.
272pub fn normalize_relay_url(s: &str) -> String {
273    s.trim_end_matches('/').to_ascii_lowercase()
274}
275
276/// Relay-url targets for 10050 traffic: every READ-flagged pool relay plus
277/// any pooled Discovery Relay (GOSSIP-flagged, so invisible to `has_read()`
278/// — matched by url instead). Only urls actually in the pool are returned;
279/// `fetch_events_from`/`send_event_to` error on unknown urls.
280async fn inbox_query_targets(client: &Client) -> Vec<RelayUrl> {
281    let discovery: HashSet<String> = crate::state::discovery_relay_iter()
282        .map(normalize_relay_url)
283        .collect();
284    client
285        .pool()
286        .all_relays()
287        .await
288        .iter()
289        .filter(|(url, relay)| {
290            relay.flags().has_read() || discovery.contains(&normalize_relay_url(url.as_str()))
291        })
292        .map(|(url, _)| url.clone())
293        .collect()
294}
295
296/// Result of a 10050 fetch: relays found, or whether the fetch itself failed.
297struct FetchResult {
298    relays: Vec<String>,
299    /// `true` if the network request succeeded (even if no events were found).
300    fetch_ok: bool,
301}
302
303/// Fetch a pubkey's kind 10050 relay list from the network. Queries the
304/// Discovery Relays alongside our read relays: a recipient on another client
305/// often publishes their list where our own relay set has no overlap.
306async fn fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> FetchResult {
307    let filter = Filter::new()
308        .author(*pubkey)
309        .kind(Kind::Custom(10050))
310        .limit(1);
311
312    let targets = inbox_query_targets(client).await;
313    let fetched = if targets.is_empty() {
314        client
315            .fetch_events(filter, std::time::Duration::from_secs(5))
316            .await
317    } else {
318        client
319            .fetch_events_from(targets, filter, std::time::Duration::from_secs(5))
320            .await
321    };
322    let events = match fetched {
323        Ok(events) => events,
324        Err(e) => {
325            eprintln!("[InboxRelays] Failed to fetch 10050 for {}: {}", pubkey, e);
326            return FetchResult { relays: Vec::new(), fetch_ok: false };
327        }
328    };
329
330    // Replaceable event: several relays can answer with different revisions —
331    // only the newest is the user's current list.
332    let event = match events.into_iter().max_by_key(|e| e.created_at) {
333        Some(e) => e,
334        None => return FetchResult { relays: Vec::new(), fetch_ok: true },
335    };
336
337    FetchResult { relays: parse_relay_tags(&event.tags), fetch_ok: true }
338}
339
340/// Extract relay URLs from kind 10050 event tags.
341/// Looks for `["relay", "wss://..."]` tag entries.
342fn parse_relay_tags(tags: &Tags) -> Vec<String> {
343    tags.iter()
344        .filter_map(|tag| {
345            let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
346            if values.len() >= 2 && values[0] == "relay" {
347                Some(values[1].to_string())
348            } else {
349                None
350            }
351        })
352        .collect()
353}
354
355/// Generic cache-with-lock implementation used by both production and test code.
356/// Uses double-checked locking to prevent cache stampede: rapid requests to the
357/// same pubkey serialize through a per-key lock, so only one fetch happens.
358/// Different pubkeys never block each other.
359async fn get_or_fetch_with_lock<F, Fut>(pubkey: &PublicKey, fetch_fn: F) -> Vec<String>
360where
361    F: FnOnce() -> Fut,
362    Fut: std::future::Future<Output = FetchResult>,
363{
364    // Fast path: cache hit (no per-key lock needed, no pruning)
365    {
366        let cache = INBOX_RELAY_CACHE.lock().unwrap();
367        if let Some(entry) = cache.get(pubkey) {
368            let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
369            if entry.fetched_at.elapsed().as_secs() < ttl {
370                return entry.relays.clone();
371            }
372        }
373    }
374
375    // Per-key lock — serializes fetches for the same pubkey only.
376    // Uses Weak references + periodic pruning (every PRUNE_INTERVAL cache misses).
377    let cleanup_guard = {
378        let mut locks = FETCH_LOCKS.lock().unwrap();
379
380        // Periodic cleanup: remove dead Weak entries every PRUNE_INTERVAL accesses.
381        // Avoids O(n) scan in global critical section on every cache miss; instead
382        // amortizes cost to O(n/PRUNE_INTERVAL) per miss under heavy fan-out.
383        if PRUNE_COUNTER.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL == 0 {
384            locks.retain(|_, weak| Weak::strong_count(weak) > 0);
385        }
386
387        let weak = locks.entry(*pubkey).or_insert_with(|| Weak::new());
388        // Try to upgrade the weak reference; if it fails (Arc was dropped),
389        // create a new Arc and update the map.
390        let key_lock = match weak.upgrade() {
391            Some(arc) => arc,
392            None => {
393                let new_arc = Arc::new(tokio::sync::Mutex::new(()));
394                *weak = Arc::downgrade(&new_arc);
395                new_arc
396            }
397        };
398        // Wrap lock Arc in drop-guard so map cleanup runs even on cancellation.
399        FetchLockEntryCleanup::new(*pubkey, key_lock)
400    };
401    let relays = {
402        let _guard = cleanup_guard.key_lock.lock().await;
403
404        // Double-check: another task may have filled the cache while we waited
405        let cached_relays = {
406            let cache = INBOX_RELAY_CACHE.lock().unwrap();
407            if let Some(entry) = cache.get(pubkey) {
408                let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
409                if entry.fetched_at.elapsed().as_secs() < ttl {
410                    Some(entry.relays.clone())
411                } else {
412                    None
413                }
414            } else {
415                None
416            }
417        };
418
419        match cached_relays {
420            Some(relays) => relays,
421            None => {
422                // We won the race — do the actual fetch
423                let result = fetch_fn().await;
424
425                // Store in cache (even empty/error results to avoid hammering relays)
426                {
427                    let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
428                    cache.insert(
429                        *pubkey,
430                        CachedRelays {
431                            relays: result.relays.clone(),
432                            fetched_at: Instant::now(),
433                            fetch_ok: result.fetch_ok,
434                        },
435                    );
436                }
437
438                result.relays
439            }
440        }
441    }; // per-key lock guard dropped here
442
443    // Explicit drop on normal path. On cancellation/panic unwind this still runs
444    // via Drop when the future is torn down.
445    drop(cleanup_guard);
446    relays
447}
448
449/// Get inbox relays for a pubkey, using cache when available.
450async fn get_or_fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> Vec<String> {
451    get_or_fetch_with_lock(pubkey, || fetch_inbox_relays(client, pubkey)).await
452}
453
454// ============================================================================
455// Send helper
456// ============================================================================
457
458/// Parsed `TRUSTED_RELAYS` URLs — computed once on first access.
459static TRUSTED_RELAY_URLS: LazyLock<Vec<RelayUrl>> = LazyLock::new(|| {
460    crate::state::TRUSTED_RELAYS
461        .iter()
462        .filter_map(|s| RelayUrl::parse(s).ok())
463        .collect()
464});
465
466/// Get the cached parsed trusted relay URLs.
467pub fn trusted_relay_urls() -> Vec<RelayUrl> {
468    TRUSTED_RELAY_URLS.clone()
469}
470
471/// Send an event to specific relays, returning as soon as the **first** relay
472/// acknowledges success. Remaining relays continue sending in the background.
473///
474/// Uses `spawn_tracked_publish` under the hood, so every event published
475/// here automatically registers an `EventPublishTracker` keyed by event
476/// id. Dependent operations (NIP-09 deletions, edits, reactions, replies)
477/// can look up the tracker via `get_publish_tracker(event_id)` and drive
478/// per-relay dispatch only after each relay confirms the parent — closing
479/// the publish/dependent race for any send that goes through here.
480pub async fn send_event_first_ok(
481    client: &Client,
482    urls: Vec<RelayUrl>,
483    event: &Event,
484) -> Result<Output<EventId>, nostr_sdk::client::Error> {
485    let pool = client.pool();
486    let relays = pool.relays().await;
487    let event_id = event.id;
488
489    // Resolve URL -> Relay handles, filtering to relays we actually have
490    let mut resolved: Vec<(RelayUrl, Relay)> = Vec::new();
491    for url in urls {
492        if let Some(relay) = relays.get(&url) {
493            resolved.push((url, relay.clone()));
494        }
495    }
496
497    if resolved.is_empty() {
498        return client.send_event(event).await;
499    }
500
501    // Spawn tracked per-relay tasks. This registers a tracker so any
502    // future dependent send (deletion, edit, reaction) can fire only
503    // after each relay confirms the parent.
504    let handles = spawn_tracked_publish(resolved, event.clone());
505
506    // Race: return as soon as the first relay succeeds
507    let mut output = Output {
508        val: event_id,
509        success: std::collections::HashSet::new(),
510        failed: HashMap::new(),
511    };
512
513    let mut remaining = handles;
514    while !remaining.is_empty() {
515        let (result, _index, rest) = futures_util::future::select_all(remaining).await;
516        remaining = rest;
517
518        if let Ok((url, relay_result)) = result {
519            match relay_result {
520                Ok(_) => {
521                    output.success.insert(url);
522                    // First success — remaining spawned tasks continue in background
523                    // updating the tracker as they settle. Dropping JoinHandles
524                    // detaches but does NOT cancel them.
525                    drop(remaining);
526                    return Ok(output);
527                }
528                Err(e) => {
529                    output.failed.insert(url, e);
530                }
531            }
532        }
533    }
534
535    // All relays failed — return output so caller can inspect .failed
536    Ok(output)
537}
538
539/// Send an event to all write-relays in the pool, returning as soon as the
540/// **first** relay acknowledges success.
541pub async fn send_event_pool_first_ok(
542    client: &Client,
543    event: &Event,
544) -> Result<Output<EventId>, nostr_sdk::client::Error> {
545    let pool = client.pool();
546    let relays = pool.relays().await;
547    let write_urls: Vec<RelayUrl> = relays
548        .iter()
549        .filter(|(_, r)| r.flags().has_write())
550        .map(|(url, _)| url.clone())
551        .collect();
552    send_event_first_ok(&client, write_urls, event).await
553}
554
555/// Build a NIP-59 kind-1059 gift wrap from a sealed event, returning
556/// **both** the signed wrap event and the ephemeral secp256k1 secret
557/// used to sign it.
558///
559/// Wire-compatible with `EventBuilder::gift_wrap_from_seal` — other
560/// clients cannot tell the wraps apart. The only difference is that we
561/// keep the ephemeral key instead of dropping it on the floor, so the
562/// user can later sign a NIP-09 deletion against the wrap event id and
563/// have relays drop it. This is Vector's "delete from network" primitive.
564pub fn wrap_with_retained_key(
565    receiver: &PublicKey,
566    seal: &Event,
567    extra_tags: impl IntoIterator<Item = Tag>,
568) -> Result<(Event, SecretKey), String> {
569    use nostr_sdk::nips::nip44;
570    use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
571
572    if seal.kind != Kind::Seal {
573        return Err(format!("expected Seal kind, got {:?}", seal.kind));
574    }
575    let keys = Keys::generate();
576    let secret = keys.secret_key().clone();
577    let content = nip44::encrypt(
578        keys.secret_key(),
579        receiver,
580        seal.as_json(),
581        nip44::Version::default(),
582    )
583    .map_err(|e| format!("nip44 encrypt: {}", e))?;
584    let mut tags: Vec<Tag> = extra_tags.into_iter().collect();
585    tags.push(Tag::public_key(*receiver));
586    let event = EventBuilder::new(Kind::GiftWrap, content)
587        .tags(tags)
588        .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
589        .sign_with_keys(&keys)
590        .map_err(|e| format!("sign wrap: {}", e))?;
591    Ok((event, secret))
592}
593
594/// Outcome of a retained-key gift-wrap send. Caller is expected to
595/// persist `wrap_event_id`, `wrap_secret`, and `targeted_relays` for
596/// future deletion.
597pub struct GiftWrapSendOutcome {
598    pub output: Output<EventId>,
599    pub wrap_event_id: EventId,
600    pub wrap_secret: SecretKey,
601    /// Relay URL set we attempted (inbox if known, pool write-relays as
602    /// fallback). Deletion publishes the NIP-09 to this same set.
603    pub targeted_relays: Vec<String>,
604}
605
606/// Send a gift-wrapped rumor to a recipient using a retained ephemeral
607/// key. Routes to the recipient's inbox relays (kind 10050) when
608/// available, falling back to pool write-relays otherwise.
609///
610/// Spawns one publish task per resolved relay and registers a
611/// `EventPublishTracker` keyed by wrap event id so the deletion path
612/// can fire NIP-09 to each relay as soon as that relay confirms the
613/// wrap (closing the publish/delete race for fast deleters). Returns
614/// the wrap event id, the ephemeral secret, and the relay set
615/// attempted.
616pub async fn send_gift_wrap_retained(
617    client: &Client,
618    recipient: &PublicKey,
619    rumor: UnsignedEvent,
620    extra_tags: impl IntoIterator<Item = Tag>,
621) -> Result<GiftWrapSendOutcome, String> {
622    let signer = client.signer().await.map_err(|e| e.to_string())?;
623    let seal: Event = EventBuilder::seal(&signer, recipient, rumor)
624        .await
625        .map_err(|e| e.to_string())?
626        .sign(&signer)
627        .await
628        .map_err(|e| e.to_string())?;
629    let (event, secret) = wrap_with_retained_key(recipient, &seal, extra_tags)?;
630    let wrap_event_id = event.id;
631
632    // Resolve target relays: recipient's inbox relays (NIP-17) if they
633    // advertise any, otherwise our pool's write-relays.
634    let inbox_strs = get_or_fetch_inbox_relays(client, recipient).await;
635    let targeted_strs: Vec<String> = if !inbox_strs.is_empty() {
636        inbox_strs.clone()
637    } else {
638        let pool = client.pool();
639        let relays = pool.relays().await;
640        relays.iter()
641            .filter(|(_, r)| r.flags().has_write())
642            .map(|(url, _)| url.to_string())
643            .collect()
644    };
645    // Resolve to live Relay handles in the pool. Strict HashMap lookup by
646    // `RelayUrl` was missing visually-identical URLs because nostr-sdk
647    // canonicalises differently between published-10050 strings and pool
648    // keys (trailing slashes, default ports, case). Normalise both sides
649    // and match on the canonical string form so e.g. `wss://relay.damus.io`
650    // and `wss://relay.damus.io/` count as the same relay.
651    use normalize_relay_url as normalize_url_for_match;
652    let pool = client.pool();
653    // all_relays(): GOSSIP-flagged pool members (discovery/community relays)
654    // must count as pooled here — classifying one as transient would remove
655    // it from the pool after the send, silently killing its real role.
656    let pool_relays = pool.all_relays().await;
657    let pool_norm: Vec<(String, RelayUrl, Relay)> = pool_relays.iter()
658        .map(|(url, relay)| (
659            normalize_url_for_match(&url.to_string()),
660            url.clone(),
661            relay.clone(),
662        ))
663        .collect();
664    let mut resolved: Vec<(RelayUrl, Relay)> = targeted_strs
665        .iter()
666        .filter_map(|s| {
667            let norm = normalize_url_for_match(s);
668            pool_norm.iter()
669                .find(|(pnorm, _, _)| pnorm == &norm)
670                .map(|(_, url, relay)| (url.clone(), relay.clone()))
671        })
672        .collect();
673
674    // On-demand connect: inbox relays not already in the pool are added +
675    // connected just for this send, then removed afterwards (transient_added).
676    // The recipient's inbox relays are theirs, not ours — keeping them would
677    // pollute the pool, which the reconcile loop owns. Only for real inbox
678    // relays; the pool-write fallback already targets live pool members.
679    let mut transient_added: Vec<RelayUrl> = Vec::new();
680    if !inbox_strs.is_empty() {
681        for s in &targeted_strs {
682            let norm = normalize_url_for_match(s);
683            let in_pool = pool_norm.iter().any(|(p, _, _)| p == &norm);
684            let already_added = transient_added.iter()
685                .any(|u| normalize_url_for_match(&u.to_string()) == norm);
686            if in_pool || already_added { continue; }
687
688            let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
689            if pool.add_relay(s.as_str(), opts).await.is_ok() {
690                if let Ok(relay) = pool.relay(s.as_str()).await {
691                    let _ = relay.try_connect(std::time::Duration::from_secs(6)).await;
692                    transient_added.push(relay.url().clone());
693                    resolved.push((relay.url().clone(), relay));
694                }
695            }
696        }
697        if !transient_added.is_empty() {
698            crate::log_info!(
699                "[InboxRelays] on-demand connected {} inbox relay(s) for {} (transient)",
700                transient_added.len(),
701                recipient,
702            );
703        }
704    }
705
706    // `resolved.is_empty()` implies no transient add succeeded (each success
707    // pushes onto `resolved`), so this branch can't leak a transient relay.
708    if resolved.is_empty() {
709        // No matching relays in the pool — last-ditch broadcast via
710        // client.send_event(). No tracker (no per-relay machinery).
711        let output = client
712            .send_event(&event)
713            .await
714            .map_err(|e| e.to_string())?;
715        return Ok(GiftWrapSendOutcome {
716            output,
717            wrap_event_id,
718            wrap_secret: secret,
719            targeted_relays: targeted_strs,
720        });
721    }
722
723    if !inbox_strs.is_empty() {
724        println!(
725            "[InboxRelays] Routing gift-wrap to {} inbox relays for {}",
726            resolved.len(),
727            recipient
728        );
729    }
730
731    // Spawn tracked per-relay publish tasks. The tracker is keyed by
732    // the wrap event id; the deletion path looks it up via
733    // get_publish_tracker(wrap_event_id) and walks next_success() to
734    // fire NIP-09 only at relays that have actually received the
735    // wrap. The same primitive is used by any other operation whose
736    // dependent event must arrive after the parent on each relay
737    // (rapid edits, self-reactions, replies-to-just-sent).
738    let handles = spawn_tracked_publish(resolved, event.clone());
739
740    // Race for first-ok so the caller (and UI) sees "Sent" the
741    // moment any one relay accepts. Remaining tasks continue in
742    // the background, updating the tracker as they settle. The
743    // dropped JoinHandles detach but do not cancel the tasks.
744    let mut output = Output {
745        val: wrap_event_id,
746        success: HashSet::new(),
747        failed: HashMap::new(),
748    };
749    let mut remaining = handles;
750    while !remaining.is_empty() {
751        let (result, _idx, rest) = futures_util::future::select_all(remaining).await;
752        remaining = rest;
753        if let Ok((url, relay_result)) = result {
754            match relay_result {
755                Ok(_) => {
756                    output.success.insert(url);
757                    drop(remaining);
758                    break;
759                }
760                Err(e) => {
761                    output.failed.insert(url, e.to_string());
762                }
763            }
764        }
765    }
766
767    // Tear down transiently-added inbox relays — they belong to the
768    // recipient, not us. Delivery has already raced to first-ok; one
769    // confirmed inbox relay satisfies NIP-17, so cutting any still-in-flight
770    // background publishes to the others is acceptable.
771    for url in &transient_added {
772        let _ = pool.remove_relay(url).await;
773    }
774
775    Ok(GiftWrapSendOutcome {
776        output,
777        wrap_event_id,
778        wrap_secret: secret,
779        targeted_relays: targeted_strs,
780    })
781}
782
783/// Send a gift-wrapped rumor to a recipient, routing to their inbox relays
784/// (kind 10050) when available. Falls back to pool broadcast if no inbox
785/// relays are found or if targeted delivery fails entirely.
786///
787/// Returns as soon as the first relay acknowledges success — remaining relays
788/// continue in the background. This minimises the time messages spend in
789/// "pending" state.
790///
791/// Thin wrapper over `send_gift_wrap_retained`. Discards the retained
792/// ephemeral key — use this for sends where future deletion is not
793/// required (e.g. PIVX payment rumors). For user-facing DMs, prefer
794/// `send_gift_wrap_retained` and persist the secret.
795pub async fn send_gift_wrap(
796    client: &Client,
797    recipient: &PublicKey,
798    rumor: UnsignedEvent,
799    extra_tags: impl IntoIterator<Item = Tag>,
800) -> Result<Output<EventId>, String> {
801    let outcome = send_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
802    Ok(outcome.output)
803}
804
805// ============================================================================
806// Publish own inbox relays (sync-first, merge, never clobber)
807// ============================================================================
808
809/// KV key holding the relay urls Vector itself contributed to the published
810/// kind 10050 (JSON array, normalized). Foreign entries (added by other
811/// clients) are everything in the network list minus this set — they are
812/// preserved verbatim on every publish and never removed by a Vector config
813/// change.
814const CONTRIBUTED_KEY: &str = "dm_relays_contributed";
815
816/// Cap on foreign relay entries adopted into our published list. Bounds a
817/// hostile or bloated remote list from being re-signed and amplified by us.
818const MAX_FOREIGN_RELAYS: usize = 10;
819
820fn load_contributed() -> HashSet<String> {
821    crate::db::get_sql_setting(CONTRIBUTED_KEY.to_string())
822        .ok()
823        .flatten()
824        .and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
825        .map(|v| v.into_iter().map(|s| normalize_relay_url(&s)).collect())
826        .unwrap_or_default()
827}
828
829fn store_contributed(contributed: &[String]) {
830    if let Ok(json) = serde_json::to_string(contributed) {
831        let _ = crate::db::set_sql_setting(CONTRIBUTED_KEY.to_string(), json);
832    }
833}
834
835/// The merged list to publish and whether the network needs updating.
836struct MergePlan {
837    /// Final relay list, original string forms preserved (remote order first,
838    /// then our additions).
839    list: Vec<String>,
840    /// `true` if the final list differs (as a set) from the remote list.
841    changed: bool,
842    /// Normalized urls that count as Vector's contribution going forward:
843    /// our current read relays minus anything another client also lists.
844    contributed: Vec<String>,
845}
846
847/// Merge the network's current 10050 with our read relays. Foreign entries
848/// (remote minus our previous contribution) always survive; entries we
849/// contributed earlier but no longer read are dropped — a relay removed in
850/// Vector's settings leaves the list, a relay added by another app never does.
851fn merge_inbox_relays(
852    remote: &[String],
853    contributed_before: &HashSet<String>,
854    ours: &[String],
855) -> MergePlan {
856    let mut seen: HashSet<String> = HashSet::new();
857    let mut list: Vec<String> = Vec::new();
858    let mut foreign_norm: HashSet<String> = HashSet::new();
859    let mut dropped_foreign = 0usize;
860
861    for url in remote {
862        let norm = normalize_relay_url(url);
863        if seen.contains(&norm) || contributed_before.contains(&norm) {
864            continue;
865        }
866        if foreign_norm.len() >= MAX_FOREIGN_RELAYS {
867            dropped_foreign += 1;
868            continue;
869        }
870        seen.insert(norm.clone());
871        foreign_norm.insert(norm);
872        list.push(url.clone());
873    }
874    if dropped_foreign > 0 {
875        crate::log_warn!(
876            "[InboxRelays] remote 10050 over the {}-relay foreign cap, dropped {}",
877            MAX_FOREIGN_RELAYS,
878            dropped_foreign
879        );
880    }
881
882    let mut contributed: Vec<String> = Vec::new();
883    for url in ours {
884        let norm = normalize_relay_url(url);
885        if seen.insert(norm.clone()) {
886            list.push(url.clone());
887        }
888        if !foreign_norm.contains(&norm) && !contributed.contains(&norm) {
889            contributed.push(norm);
890        }
891    }
892
893    // Publish only on an OWN diff: something of ours missing from the remote
894    // list, or a previous contribution of ours still listed that we no longer
895    // read. A foreign-cap trim alone must never drive a publish — two devices
896    // straddling the cap would ping-pong trimmed/untrimmed lists forever.
897    let remote_set: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
898    let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
899    let has_addition = ours_norm.iter().any(|n| !remote_set.contains(n));
900    let has_removal = contributed_before
901        .iter()
902        .any(|n| remote_set.contains(n) && !ours_norm.contains(n));
903    MergePlan { list, changed: has_addition || has_removal, contributed }
904}
905
906/// Fetch our OWN current 10050 from read + Discovery relays, with its
907/// created_at. `Ok(None)` means the network answered and no list exists;
908/// `Err` means we could not get a trustworthy answer (offline, nothing
909/// connected) — callers must NOT publish on `Err`, that is exactly the blind
910/// overwrite this module exists to stop.
911pub async fn fetch_own_inbox_list(client: &Client) -> Result<Option<(Vec<String>, u64)>, String> {
912    let me = crate::state::my_public_key().ok_or("no active pubkey")?;
913    let targets = inbox_query_targets(client).await;
914    if targets.is_empty() {
915        return Err("no query targets in pool".to_string());
916    }
917
918    // Wait (bounded) for targets to be live: a fetch raced against boot-time
919    // connection setup returns empty, which reads as "no list exists" and
920    // triggers a bootstrap publish over a list we simply couldn't see yet.
921    // A Discovery Relay counts for an early exit (they are the rendezvous
922    // most likely to hold the list); otherwise any connected target at the
923    // deadline is best-effort, and zero connected targets aborts the sync.
924    let discovery: HashSet<String> = crate::state::discovery_relay_iter()
925        .map(normalize_relay_url)
926        .collect();
927    let deadline = Instant::now() + std::time::Duration::from_secs(8);
928    loop {
929        let relays = client.pool().all_relays().await;
930        let connected: Vec<&RelayUrl> = targets
931            .iter()
932            .filter(|url| {
933                relays
934                    .get(url)
935                    .map(|r| r.status() == RelayStatus::Connected)
936                    .unwrap_or(false)
937            })
938            .collect();
939        let discovery_up = connected
940            .iter()
941            .any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
942        if discovery_up {
943            break;
944        }
945        if Instant::now() >= deadline {
946            if connected.is_empty() {
947                return Err("no query target connected".to_string());
948            }
949            break;
950        }
951        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
952    }
953
954    let filter = Filter::new().author(me).kind(Kind::Custom(10050)).limit(1);
955    let events = client
956        .fetch_events_from(targets.clone(), filter, std::time::Duration::from_secs(6))
957        .await
958        .map_err(|e| e.to_string())?;
959    // NIP-01 replaceable tie-break: newest created_at, lowest id on a tie.
960    let newest = events
961        .into_iter()
962        .max_by(|a, b| a.created_at.cmp(&b.created_at).then(b.id.cmp(&a.id)))
963        .map(|e| (parse_relay_tags(&e.tags), e.created_at.as_u64()));
964
965    // "No list found" is only trustworthy when a Discovery Relay answered:
966    // a list curated elsewhere may live on none of our own relays, and a
967    // bootstrap publish over an unseen list is a PERMANENT clobber (the
968    // bootstrap gets the newer created_at, so later syncs adopt it and
969    // relays delete the old list). Pools with no Discovery Relay at all
970    // (SDK/CLI) keep the any-target answer.
971    if newest.is_none() {
972        let has_discovery_target = targets
973            .iter()
974            .any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
975        if has_discovery_target {
976            let relays = client.pool().all_relays().await;
977            let discovery_answered = targets.iter().any(|url| {
978                discovery.contains(&normalize_relay_url(url.as_str()))
979                    && relays
980                        .get(url)
981                        .map(|r| r.status() == RelayStatus::Connected)
982                        .unwrap_or(false)
983            });
984            if !discovery_answered {
985                return Err(
986                    "no 10050 found and no Discovery Relay connected; refusing to bootstrap"
987                        .to_string(),
988                );
989            }
990        }
991    }
992    Ok(newest)
993}
994
995/// KV key holding the created_at of the newest 10050 we have published or
996/// applied locally. Inbound reconcile actions are gated on a STRICTLY newer
997/// remote event: acting on a stale fetch would resurrect relays the user
998/// already retired.
999const LIST_SEEN_TS_KEY: &str = "dm_list_last_ts";
1000
1001fn load_list_seen() -> u64 {
1002    crate::db::get_sql_setting(LIST_SEEN_TS_KEY.to_string())
1003        .ok()
1004        .flatten()
1005        .and_then(|v| v.parse::<u64>().ok())
1006        .unwrap_or(0)
1007}
1008
1009/// Union urls into the contributed set. Adopted/revived relays must count as
1010/// OUR contribution immediately: retire only fires for contributed entries,
1011/// and a merge would otherwise re-add ("resurrect") a relay that a newer
1012/// remote list deliberately dropped.
1013pub fn note_contributed(urls: &[String]) {
1014    if urls.is_empty() {
1015        return;
1016    }
1017    let mut set = load_contributed();
1018    for url in urls {
1019        set.insert(normalize_relay_url(url));
1020    }
1021    let list: Vec<String> = set.into_iter().collect();
1022    store_contributed(&list);
1023}
1024
1025/// Advance the list-freshness anchor (monotonic).
1026pub fn note_list_seen(ts: u64) {
1027    if ts > load_list_seen() {
1028        let _ = crate::db::set_sql_setting(LIST_SEEN_TS_KEY.to_string(), ts.to_string());
1029    }
1030}
1031
1032/// Local actions needed to make the in-app relay list mirror a newer remote
1033/// 10050 (the Relays tab IS the DM Relay List, synced across devices/apps).
1034#[derive(Debug, Default, PartialEq)]
1035pub struct InboundReconcile {
1036    /// Remote entries unknown locally: add as enabled relays.
1037    pub adopt: Vec<String>,
1038    /// Locally-disabled entries a newer remote (re-)lists: re-enable.
1039    pub revive: Vec<String>,
1040    /// Entries we previously published that a newer remote dropped: another
1041    /// client removed them, disable locally.
1042    pub retire: Vec<String>,
1043}
1044
1045/// Plan the inbound half of the sync. `ours` = locally enabled relay urls,
1046/// `declined` = locally known but disabled urls. Reads the contributed set
1047/// and freshness anchor from the account KV.
1048pub fn plan_inbound_reconcile(
1049    remote: &[String],
1050    remote_ts: u64,
1051    ours: &[String],
1052    declined: &[String],
1053) -> InboundReconcile {
1054    plan_inbound_reconcile_pure(
1055        remote,
1056        remote_ts,
1057        ours,
1058        declined,
1059        &load_contributed(),
1060        load_list_seen(),
1061    )
1062}
1063
1064fn plan_inbound_reconcile_pure(
1065    remote: &[String],
1066    remote_ts: u64,
1067    ours: &[String],
1068    declined: &[String],
1069    contributed_before: &HashSet<String>,
1070    last_seen_ts: u64,
1071) -> InboundReconcile {
1072    if remote_ts <= last_seen_ts {
1073        return InboundReconcile::default();
1074    }
1075    let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
1076    let declined_norm: HashSet<String> = declined.iter().map(|s| normalize_relay_url(s)).collect();
1077    let remote_norm: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
1078
1079    let mut seen: HashSet<String> = HashSet::new();
1080    let mut adopt: Vec<String> = Vec::new();
1081    let mut revive: Vec<String> = Vec::new();
1082    for url in remote {
1083        let norm = normalize_relay_url(url);
1084        if !seen.insert(norm.clone()) {
1085            continue;
1086        }
1087        if ours_norm.contains(&norm) {
1088            continue;
1089        }
1090        if declined_norm.contains(&norm) {
1091            revive.push(url.clone());
1092        } else if adopt.len() < MAX_FOREIGN_RELAYS
1093            && url.starts_with("wss://")
1094            && url.len() <= 256
1095        {
1096            adopt.push(url.clone());
1097        }
1098    }
1099
1100    let retire: Vec<String> = ours
1101        .iter()
1102        .filter(|url| {
1103            let norm = normalize_relay_url(url);
1104            contributed_before.contains(&norm) && !remote_norm.contains(&norm)
1105        })
1106        .cloned()
1107        .collect();
1108
1109    InboundReconcile { adopt, revive, retire }
1110}
1111
1112/// Serializes publish_inbox_relays bodies. Overlapping runs (boot spawn vs a
1113/// debounced republish) can sign in inverted order — the stale list gets the
1114/// newer created_at and its stale contributed-set lands last in the KV.
1115static PUBLISH_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1116
1117/// Sync our kind 10050 from the network, merge our readable relays into it,
1118/// and publish ONLY if that changes the list. Lists curated in other clients
1119/// are preserved: foreign entries are re-signed verbatim, never dropped.
1120/// Publishes to our write relays AND the Discovery Relays so the result is
1121/// findable by clients with no relay overlap.
1122pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
1123    // A failed sync must skip the publish: publishing blind clobbers lists
1124    // curated in other clients.
1125    let remote = fetch_own_inbox_list(client).await?;
1126    publish_inbox_relays_synced(client, remote, None).await
1127}
1128
1129/// Publish with an already-synced remote list (avoids a second network fetch
1130/// when the caller just ran the inbound reconcile). `ours_override` supplies
1131/// the caller's store-derived relay list: the live pool momentarily contains
1132/// relays that are not ours (a recipient's transient inbox relays mid-DM) and
1133/// can be missing ours (an adopted relay whose connect failed) — publishing
1134/// pool state would leak the former fleet-wide and oscillate the latter.
1135pub async fn publish_inbox_relays_synced(
1136    client: &Client,
1137    remote: Option<(Vec<String>, u64)>,
1138    ours_override: Option<Vec<String>>,
1139) -> Result<(), String> {
1140    let _serial = PUBLISH_MUTEX.lock().await;
1141    let session = crate::state::SessionGuard::capture();
1142
1143    // Relays we read from — senders must write to these for us to receive DMs.
1144    let ours: Vec<String> = match ours_override {
1145        Some(list) => list,
1146        None => client
1147            .pool()
1148            .relays()
1149            .await
1150            .iter()
1151            .filter(|(_, relay)| relay.flags().has_read())
1152            .map(|(url, _)| url.to_string())
1153            .collect(),
1154    };
1155
1156    let remote_found = remote.is_some();
1157    let (remote, remote_ts) = remote.unwrap_or_default();
1158    // A fetch can answer with an OLDER revision than one we've already seen
1159    // (the relay holding the newest missed the timeout). Merging against it
1160    // would republish removed-elsewhere entries at a newer created_at —
1161    // resurrection the inbound gate cannot defend against.
1162    if remote_found && remote_ts < load_list_seen() {
1163        return Err("stale 10050 fetch (older than last seen), skipping publish".to_string());
1164    }
1165
1166    let plan = merge_inbox_relays(&remote, &load_contributed(), &ours);
1167
1168    if !session.is_valid() {
1169        return Ok(());
1170    }
1171    store_contributed(&plan.contributed);
1172    if remote_found {
1173        note_list_seen(remote_ts);
1174    }
1175
1176    if remote_found && !plan.changed {
1177        crate::log_info!(
1178            "[InboxRelays] kind 10050 already in sync ({} relay(s)), not publishing",
1179            plan.list.len()
1180        );
1181        return Ok(());
1182    }
1183    if plan.list.is_empty() && !remote_found {
1184        // Nothing to say and nothing to update.
1185        return Ok(());
1186    }
1187
1188    let mut builder = EventBuilder::new(Kind::Custom(10050), "");
1189    for url in &plan.list {
1190        builder = builder.tag(Tag::custom(TagKind::custom("relay"), vec![url.clone()]));
1191    }
1192    let event = client
1193        .sign_event_builder(builder)
1194        .await
1195        .map_err(|e| format!("Failed to sign inbox relays: {}", e))?;
1196
1197    if !session.is_valid() {
1198        return Ok(());
1199    }
1200    let pool_send = client.send_event(&event).await;
1201
1202    // Copy to the pooled Discovery Relays (GOSSIP-flagged, so the pool-wide
1203    // send skipped them). Runs regardless of the pool send: a user with zero
1204    // write relays still gets their list onto the rendezvous points.
1205    let discovery: HashSet<String> = crate::state::DISCOVERY_RELAYS
1206        .iter()
1207        .map(|s| normalize_relay_url(s))
1208        .collect();
1209    let discovery_targets: Vec<RelayUrl> = client
1210        .pool()
1211        .all_relays()
1212        .await
1213        .iter()
1214        .filter(|(url, relay)| {
1215            !relay.flags().has_write() && discovery.contains(&normalize_relay_url(url.as_str()))
1216        })
1217        .map(|(url, _)| url.clone())
1218        .collect();
1219    let mut discovery_ok = false;
1220    if !discovery_targets.is_empty() {
1221        if let Ok(out) = client.send_event_to(discovery_targets, &event).await {
1222            discovery_ok = !out.success.is_empty();
1223        }
1224    }
1225
1226    let pool_ok = matches!(&pool_send, Ok(out) if !out.success.is_empty());
1227    if !pool_ok {
1228        if !discovery_ok {
1229            return Err(match pool_send {
1230                Err(e) => format!("Failed to publish inbox relays: {}", e),
1231                Ok(_) => "Failed to publish inbox relays: no relay accepted it".to_string(),
1232            });
1233        }
1234        crate::log_warn!(
1235            "[InboxRelays] pool publish failed, list delivered via Discovery Relays only"
1236        );
1237    }
1238    // Anchor only on a confirmed landing in a still-current session: a
1239    // wrongly-advanced anchor gates future syncs off real network state.
1240    if session.is_valid() {
1241        note_list_seen(event.created_at.as_u64().max(remote_ts));
1242    }
1243
1244    println!(
1245        "[InboxRelays] Published kind 10050 with {} relay(s) ({} foreign preserved)",
1246        plan.list.len(),
1247        plan.list.len().saturating_sub(plan.contributed.len())
1248    );
1249    Ok(())
1250}
1251
1252/// Monotonic generation counter used to debounce republish calls.
1253/// Only the most recent spawn actually publishes; earlier ones exit early.
1254static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
1255
1256/// Counts how many spawned tasks pass the generation gate (test-only).
1257#[cfg(test)]
1258static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
1259
1260/// Republish kind 10050 in the background (debounced).
1261/// Called after relay config changes (add/remove/toggle/mode update).
1262/// Rapid successive calls coalesce into a single publish.
1263pub fn republish_inbox_relays_debounced() {
1264    let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1265    // REPUBLISH_GEN dedupes within a session; SessionGuard dedupes
1266    // across sessions. Without the guard, a swap during the 800ms
1267    // debounce window would publish account A's inbox-relay claim
1268    // signed by account B's client.
1269    let session = crate::state::SessionGuard::capture();
1270    tokio::spawn(async move {
1271        // Wait for the relay pool to settle; if another call arrives
1272        // during this window it will bump the generation and we'll exit.
1273        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1274        if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
1275            return; // superseded by a newer call
1276        }
1277        if !session.is_valid() {
1278            return; // swap occurred during the debounce window
1279        }
1280        #[cfg(test)]
1281        DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
1282        let client = match nostr_client() {
1283            Some(c) => c,
1284            None => return,
1285        };
1286        if let Err(e) = publish_inbox_relays(&client).await {
1287            eprintln!("[InboxRelays] Failed to republish after config change: {}", e);
1288        }
1289    });
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295
1296    // ---- Merge (sync-first publish) ----
1297
1298    fn strs(v: &[&str]) -> Vec<String> {
1299        v.iter().map(|s| s.to_string()).collect()
1300    }
1301
1302    fn norm_set(v: &[&str]) -> HashSet<String> {
1303        v.iter().map(|s| normalize_relay_url(s)).collect()
1304    }
1305
1306    #[test]
1307    fn merge_preserves_foreign_entries() {
1308        let remote = strs(&["wss://other-app.example", "wss://alice.example"]);
1309        let ours = strs(&["wss://vector.example"]);
1310        let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1311        assert!(plan.changed);
1312        assert_eq!(plan.list, strs(&["wss://other-app.example", "wss://alice.example", "wss://vector.example"]));
1313        assert_eq!(plan.contributed, strs(&["wss://vector.example"]));
1314    }
1315
1316    #[test]
1317    fn merge_noop_when_remote_covers_ours() {
1318        let remote = strs(&["wss://other-app.example", "wss://vector.example/"]);
1319        let ours = strs(&["wss://vector.example"]);
1320        let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1321        assert!(!plan.changed, "trailing-slash variants are the same relay");
1322        assert_eq!(plan.list.len(), 2);
1323    }
1324
1325    #[test]
1326    fn merge_drops_only_our_own_removed_contribution() {
1327        // We contributed X earlier; the user removed it in Vector. Foreign F
1328        // stays, X leaves.
1329        let remote = strs(&["wss://foreign.example", "wss://x.example"]);
1330        let contributed = norm_set(&["wss://x.example"]);
1331        let ours = strs(&["wss://new.example"]);
1332        let plan = merge_inbox_relays(&remote, &contributed, &ours);
1333        assert!(plan.changed);
1334        assert_eq!(plan.list, strs(&["wss://foreign.example", "wss://new.example"]));
1335    }
1336
1337    #[test]
1338    fn merge_never_clears_a_foreign_list() {
1339        // No readable relays of our own must NOT nuke another client's list.
1340        let remote = strs(&["wss://foreign.example"]);
1341        let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
1342        assert!(!plan.changed);
1343        assert_eq!(plan.list, remote);
1344        assert!(plan.contributed.is_empty());
1345    }
1346
1347    #[test]
1348    fn merge_contributed_excludes_foreign_overlap() {
1349        // At the MERGE layer a co-listed relay classifies foreign-first (a
1350        // bare publish never strips it). Propagating removals of co-listed
1351        // entries is the reconcile layer's job: it seeds co-ownership into
1352        // the contributed set for entries both remote-listed and locally
1353        // enabled (see reconcile_two_devices_propagates_default_disable).
1354        let remote = strs(&["wss://shared.example"]);
1355        let ours = strs(&["wss://shared.example", "wss://mine.example"]);
1356        let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1357        assert_eq!(plan.contributed, strs(&["wss://mine.example"]));
1358        let next = merge_inbox_relays(
1359            &plan.list,
1360            &plan.contributed.iter().cloned().collect(),
1361            &[],
1362        );
1363        assert!(next.list.contains(&"wss://shared.example".to_string()));
1364        assert!(!next.list.contains(&"wss://mine.example".to_string()));
1365    }
1366
1367    #[test]
1368    fn merge_caps_foreign_bloat_without_publishing() {
1369        // The cap bounds what WE would re-sign, but a trim alone is not an
1370        // own diff — publishing it would ping-pong against any client
1371        // maintaining an 11+ list.
1372        let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
1373        let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
1374        assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS);
1375        assert!(!plan.changed, "a trim alone must not drive a publish");
1376    }
1377
1378    #[test]
1379    fn merge_cap_applies_when_own_diff_publishes() {
1380        let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
1381        let ours = strs(&["wss://mine.example"]);
1382        let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1383        assert!(plan.changed, "our addition is a real diff");
1384        assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS + 1);
1385        assert!(plan.list.contains(&"wss://mine.example".to_string()));
1386    }
1387
1388    #[test]
1389    fn merge_two_devices_reach_fixpoint() {
1390        // Two devices with different configs alternating sync->merge->publish
1391        // must converge: bounded publishes, then permanent in-sync.
1392        let ours_a = strs(&["wss://a1.example", "wss://shared.example"]);
1393        let ours_b = strs(&["wss://b1.example", "wss://shared.example"]);
1394        let mut network = strs(&["wss://foreign.example"]);
1395        let mut contributed_a: HashSet<String> = HashSet::new();
1396        let mut contributed_b: HashSet<String> = HashSet::new();
1397        let mut publishes = 0;
1398        for round in 0..6 {
1399            for device in 0..2 {
1400                let (ours, contributed) = if device == 0 {
1401                    (&ours_a, &mut contributed_a)
1402                } else {
1403                    (&ours_b, &mut contributed_b)
1404                };
1405                let plan = merge_inbox_relays(&network, contributed, ours);
1406                *contributed = plan.contributed.iter().cloned().collect();
1407                if plan.changed {
1408                    publishes += 1;
1409                    network = plan.list;
1410                }
1411                if round >= 2 {
1412                    assert!(!plan.changed, "no publish after convergence (round {round})");
1413                }
1414            }
1415        }
1416        assert!(publishes <= 2, "one publish per device to converge, got {publishes}");
1417        for url in ["wss://foreign.example", "wss://a1.example", "wss://b1.example", "wss://shared.example"] {
1418            assert!(network.contains(&url.to_string()), "union must hold {url}");
1419        }
1420    }
1421
1422    #[test]
1423    fn merge_first_run_publishes_ours() {
1424        let ours = strs(&["wss://a.example", "wss://b.example"]);
1425        let plan = merge_inbox_relays(&[], &HashSet::new(), &ours);
1426        assert!(plan.changed);
1427        assert_eq!(plan.list, ours);
1428        assert_eq!(plan.contributed, ours);
1429    }
1430
1431    // ---- Inbound reconcile planner ----
1432
1433    #[test]
1434    fn reconcile_stale_remote_is_a_no_op() {
1435        let remote = strs(&["wss://foreign.example"]);
1436        let plan = plan_inbound_reconcile_pure(&remote, 100, &[], &[], &HashSet::new(), 100);
1437        assert_eq!(plan, InboundReconcile::default(), "ts <= last_seen must not act");
1438    }
1439
1440    #[test]
1441    fn reconcile_adopts_unknown_entries_capped_and_wss_only() {
1442        let mut remote: Vec<String> = (0..12).map(|i| format!("wss://r{}.example", i)).collect();
1443        remote.push("ws://plaintext.example".to_string());
1444        remote.push("http://nope.example".to_string());
1445        let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &[], &HashSet::new(), 100);
1446        assert_eq!(plan.adopt.len(), MAX_FOREIGN_RELAYS);
1447        assert!(plan.adopt.iter().all(|u| u.starts_with("wss://")));
1448        assert!(plan.revive.is_empty() && plan.retire.is_empty());
1449    }
1450
1451    #[test]
1452    fn reconcile_revives_locally_disabled_entry() {
1453        let remote = strs(&["wss://back.example"]);
1454        let declined = strs(&["wss://back.example/"]);
1455        let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &declined, &HashSet::new(), 100);
1456        assert_eq!(plan.revive, strs(&["wss://back.example"]));
1457        assert!(plan.adopt.is_empty());
1458    }
1459
1460    #[test]
1461    fn reconcile_retires_contributed_entry_dropped_by_newer_remote() {
1462        let remote = strs(&["wss://keep.example"]);
1463        let ours = strs(&["wss://keep.example", "wss://gone.example"]);
1464        let contributed = norm_set(&["wss://keep.example", "wss://gone.example"]);
1465        let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
1466        assert_eq!(plan.retire, strs(&["wss://gone.example"]));
1467    }
1468
1469    #[test]
1470    fn reconcile_never_retires_unpublished_local_addition() {
1471        // A relay added locally but not yet published is absent from any
1472        // remote; retiring it would erase fresh user intent.
1473        let remote = strs(&["wss://old.example"]);
1474        let ours = strs(&["wss://old.example", "wss://just-added.example"]);
1475        let contributed = norm_set(&["wss://old.example"]);
1476        let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
1477        assert!(plan.retire.is_empty());
1478    }
1479
1480    #[test]
1481    fn reconcile_two_devices_propagates_default_disable() {
1482        // Both devices ship the same defaults. The co-ownership seed (ours
1483        // that are also remote-listed join contributed) is what lets a
1484        // disable on one device propagate instead of being reverted.
1485        #[derive(Clone)]
1486        struct Device {
1487            ours: Vec<String>,
1488            declined: Vec<String>,
1489            contributed: HashSet<String>,
1490            last_seen: u64,
1491        }
1492        impl Device {
1493            fn new(defaults: &[&str]) -> Self {
1494                Device {
1495                    ours: strs(defaults),
1496                    declined: Vec::new(),
1497                    contributed: HashSet::new(),
1498                    last_seen: 0,
1499                }
1500            }
1501            /// Boot reconcile + publish, mirroring reconcile_dm_relay_list.
1502            fn sync(&mut self, network: &mut (Vec<String>, u64)) -> bool {
1503                let (remote, ts) = network.clone();
1504                for u in &self.ours {
1505                    if remote.iter().any(|r| normalize_relay_url(r) == normalize_relay_url(u)) {
1506                        self.contributed.insert(normalize_relay_url(u));
1507                    }
1508                }
1509                let plan = plan_inbound_reconcile_pure(
1510                    &remote, ts, &self.ours, &self.declined, &self.contributed, self.last_seen,
1511                );
1512                for u in &plan.retire {
1513                    self.ours.retain(|o| o != u);
1514                    self.declined.push(u.clone());
1515                }
1516                for u in &plan.revive {
1517                    self.declined.retain(|d| normalize_relay_url(d) != normalize_relay_url(u));
1518                    self.ours.push(u.clone());
1519                    self.contributed.insert(normalize_relay_url(u));
1520                }
1521                for u in &plan.adopt {
1522                    self.ours.push(u.clone());
1523                    self.contributed.insert(normalize_relay_url(u));
1524                }
1525                self.last_seen = self.last_seen.max(ts);
1526                let m = merge_inbox_relays(&remote, &self.contributed, &self.ours);
1527                self.contributed = m.contributed.iter().cloned().collect();
1528                if m.changed {
1529                    network.1 += 1;
1530                    network.0 = m.list;
1531                    self.last_seen = network.1;
1532                }
1533                m.changed
1534            }
1535        }
1536
1537        const DEFAULTS: &[&str] = &["wss://d1.example", "wss://d2.example"];
1538        let mut network: (Vec<String>, u64) = (Vec::new(), 0);
1539        let mut a = Device::new(DEFAULTS);
1540        let mut b = Device::new(DEFAULTS);
1541
1542        assert!(a.sync(&mut network), "first device bootstraps the list");
1543        assert!(!b.sync(&mut network), "second device is already in sync");
1544
1545        // B disables d2 locally, publishes the removal.
1546        b.ours.retain(|u| u != "wss://d2.example");
1547        b.declined.push("wss://d2.example".to_string());
1548        assert!(b.sync(&mut network), "disable must publish");
1549        assert!(!network.0.contains(&"wss://d2.example".to_string()));
1550
1551        // A's next boot retires d2 rather than re-adding it.
1552        assert!(!a.sync(&mut network), "A must adopt the removal, not republish d2");
1553        assert!(a.declined.contains(&"wss://d2.example".to_string()));
1554        assert!(!network.0.contains(&"wss://d2.example".to_string()), "no resurrection");
1555
1556        // B re-enables d2: A revives it too.
1557        b.declined.retain(|u| u != "wss://d2.example");
1558        b.ours.push("wss://d2.example".to_string());
1559        assert!(b.sync(&mut network), "re-enable must publish");
1560        assert!(!a.sync(&mut network), "revive is inbound-only, no republish");
1561        assert!(a.ours.contains(&"wss://d2.example".to_string()), "A revives d2");
1562
1563        // Fixpoint: quiet forever after.
1564        for _ in 0..3 {
1565            assert!(!a.sync(&mut network));
1566            assert!(!b.sync(&mut network));
1567        }
1568    }
1569
1570    // ---- Tag parsing ----
1571
1572    #[test]
1573    fn parse_relay_tags_extracts_urls() {
1574        let tags = Tags::from_list(vec![
1575            Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
1576            Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
1577        ]);
1578        let result = parse_relay_tags(&tags);
1579        assert_eq!(result, vec![
1580            "wss://relay.example.com".to_string(),
1581            "wss://other.example.com".to_string(),
1582        ]);
1583    }
1584
1585    #[test]
1586    fn parse_relay_tags_ignores_non_relay_tags() {
1587        let tags = Tags::from_list(vec![
1588            Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
1589            Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
1590            Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
1591        ]);
1592        let result = parse_relay_tags(&tags);
1593        assert_eq!(result, vec!["wss://good.example.com".to_string()]);
1594    }
1595
1596    #[test]
1597    fn parse_relay_tags_empty() {
1598        let tags = Tags::new();
1599        let result = parse_relay_tags(&tags);
1600        assert!(result.is_empty());
1601    }
1602
1603    #[test]
1604    fn parse_relay_tags_ignores_relay_tag_without_value() {
1605        // A ["relay"] tag with no URL should be skipped (len < 2)
1606        let tags = Tags::from_list(vec![
1607            Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
1608        ]);
1609        let result = parse_relay_tags(&tags);
1610        assert!(result.is_empty());
1611    }
1612
1613    // ---- Cache ----
1614
1615    fn test_pubkey() -> PublicKey {
1616        let keys = Keys::generate();
1617        keys.public_key()
1618    }
1619
1620    // Serialize tests that mutate global cache/lock statics.
1621    static TEST_GLOBALS_LOCK: LazyLock<tokio::sync::Mutex<()>> =
1622        LazyLock::new(|| tokio::sync::Mutex::new(()));
1623
1624    #[test]
1625    fn cache_stores_and_retrieves() {
1626        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1627        let pk = test_pubkey();
1628        let relays = vec!["wss://a.example.com".to_string()];
1629
1630        {
1631            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1632            cache.insert(pk, CachedRelays {
1633                relays: relays.clone(),
1634                fetched_at: Instant::now(),
1635                fetch_ok: true,
1636            });
1637        }
1638
1639        let cache = INBOX_RELAY_CACHE.lock().unwrap();
1640        let entry = cache.get(&pk).unwrap();
1641        assert_eq!(entry.relays, relays);
1642        assert!(entry.fetch_ok);
1643        assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1644    }
1645
1646    #[test]
1647    fn cache_expires_after_ttl() {
1648        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1649        let pk = test_pubkey();
1650
1651        {
1652            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1653            cache.insert(pk, CachedRelays {
1654                relays: vec!["wss://stale.example.com".to_string()],
1655                fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
1656                fetch_ok: true,
1657            });
1658        }
1659
1660        let cache = INBOX_RELAY_CACHE.lock().unwrap();
1661        let entry = cache.get(&pk).unwrap();
1662        assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
1663    }
1664
1665    #[test]
1666    fn cache_stores_empty_results() {
1667        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1668        let pk = test_pubkey();
1669
1670        {
1671            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1672            cache.insert(pk, CachedRelays {
1673                relays: vec![],
1674                fetched_at: Instant::now(),
1675                fetch_ok: true,
1676            });
1677        }
1678
1679        let cache = INBOX_RELAY_CACHE.lock().unwrap();
1680        let entry = cache.get(&pk).unwrap();
1681        assert!(entry.relays.is_empty());
1682        assert!(entry.fetch_ok);
1683        assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1684    }
1685
1686    #[test]
1687    fn cache_error_uses_short_ttl() {
1688        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1689        let pk = test_pubkey();
1690
1691        {
1692            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1693            cache.insert(pk, CachedRelays {
1694                relays: vec![],
1695                // Inserted 2 minutes ago — past the error TTL (60s) but within success TTL (3600s)
1696                fetched_at: Instant::now() - std::time::Duration::from_secs(120),
1697                fetch_ok: false,
1698            });
1699        }
1700
1701        let cache = INBOX_RELAY_CACHE.lock().unwrap();
1702        let entry = cache.get(&pk).unwrap();
1703        assert!(!entry.fetch_ok);
1704        // Should be considered expired under error TTL
1705        assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
1706        // But would still be valid under success TTL
1707        assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1708    }
1709
1710    // ---- Concurrency / stampede prevention ----
1711
1712    #[tokio::test]
1713    async fn concurrent_fetches_for_same_pubkey_serialize() {
1714        let _guard = TEST_GLOBALS_LOCK.lock().await;
1715        let pk = test_pubkey();
1716
1717        // Clear cache so all tasks see a cold cache
1718        {
1719            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1720            cache.remove(&pk);
1721        }
1722
1723        let fetch_counter = Arc::new(AtomicU64::new(0));
1724
1725        // Spawn 10 concurrent tasks all trying to fetch the same pubkey.
1726        // Uses production get_or_fetch_with_lock so this tests actual code path.
1727        let mut handles = vec![];
1728        for _ in 0..10 {
1729            let counter = fetch_counter.clone();
1730            let handle = tokio::spawn(async move {
1731                get_or_fetch_with_lock(&pk, || async {
1732                    counter.fetch_add(1, Ordering::SeqCst);
1733                    // Simulate network delay so concurrent tasks pile up
1734                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1735                    FetchResult {
1736                        relays: vec!["wss://test.example.com".to_string()],
1737                        fetch_ok: true,
1738                    }
1739                })
1740                .await
1741            });
1742            handles.push(handle);
1743        }
1744
1745        // Wait for all tasks to complete
1746        let results = futures_util::future::join_all(handles).await;
1747
1748        // All tasks should succeed and get the same result
1749        for result in &results {
1750            assert!(result.is_ok());
1751            let relays = result.as_ref().unwrap();
1752            assert_eq!(relays, &vec!["wss://test.example.com".to_string()]);
1753        }
1754
1755        // CRITICAL: Only ONE fetch should have executed (others waited on lock + hit cache)
1756        assert_eq!(
1757            fetch_counter.load(Ordering::SeqCst),
1758            1,
1759            "Expected exactly 1 fetch for 10 concurrent requests to same pubkey"
1760        );
1761
1762        let locks_after = {
1763            let locks = FETCH_LOCKS.lock().unwrap();
1764            locks.len()
1765        };
1766        assert_eq!(locks_after, 0, "Lock entry should be removed after all waiters complete");
1767    }
1768
1769    #[tokio::test]
1770    async fn fetch_locks_do_not_accumulate_after_calls_complete() {
1771        let _guard = TEST_GLOBALS_LOCK.lock().await;
1772
1773        // Verify that lock entries are removed eagerly when the last in-flight
1774        // caller for a key exits (true bounded growth, no idle-after-burst leak).
1775
1776        let pk1 = test_pubkey();
1777        let pk2 = test_pubkey();
1778        let pk3 = test_pubkey();
1779
1780        // Clear both cache and locks to avoid interference from other tests
1781        {
1782            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1783            cache.clear();
1784        }
1785        {
1786            let mut locks = FETCH_LOCKS.lock().unwrap();
1787            locks.clear();
1788        }
1789
1790        // Step 1: Fetch for pk1 (cache miss -> creates lock entry)
1791        get_or_fetch_with_lock(&pk1, || async {
1792            FetchResult {
1793                relays: vec!["wss://relay1.example.com".to_string()],
1794                fetch_ok: true,
1795            }
1796        })
1797        .await;
1798
1799        // Single-call path: no waiters, so eager cleanup should remove key immediately.
1800
1801        let locks_after_pk1 = {
1802            let locks = FETCH_LOCKS.lock().unwrap();
1803            locks.len()
1804        };
1805        assert_eq!(locks_after_pk1, 0, "No lock entries should remain after pk1 call");
1806
1807        // Step 2: repeat with pk2
1808        get_or_fetch_with_lock(&pk2, || async {
1809            FetchResult {
1810                relays: vec!["wss://relay2.example.com".to_string()],
1811                fetch_ok: true,
1812            }
1813        })
1814        .await;
1815
1816        let locks_after_pk2 = {
1817            let locks = FETCH_LOCKS.lock().unwrap();
1818            locks.len()
1819        };
1820        assert_eq!(locks_after_pk2, 0, "No lock entries should remain after pk2 call");
1821
1822        // Step 3: repeat with pk3
1823        get_or_fetch_with_lock(&pk3, || async {
1824            FetchResult {
1825                relays: vec!["wss://relay3.example.com".to_string()],
1826                fetch_ok: true,
1827            }
1828        })
1829        .await;
1830
1831        let locks_after_pk3 = {
1832            let locks = FETCH_LOCKS.lock().unwrap();
1833            locks.len()
1834        };
1835        assert_eq!(locks_after_pk3, 0, "No lock entries should remain after pk3 call");
1836    }
1837
1838    #[tokio::test]
1839    async fn cancelled_fetch_cleans_up_lock_entry() {
1840        let _guard = TEST_GLOBALS_LOCK.lock().await;
1841        let pk = test_pubkey();
1842
1843        {
1844            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1845            cache.clear();
1846        }
1847        {
1848            let mut locks = FETCH_LOCKS.lock().unwrap();
1849            locks.clear();
1850        }
1851
1852        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1853        let task_pk = pk;
1854        let handle = tokio::spawn(async move {
1855            get_or_fetch_with_lock(&task_pk, || async move {
1856                let _ = started_tx.send(());
1857                tokio::time::sleep(std::time::Duration::from_secs(30)).await;
1858                FetchResult { relays: Vec::new(), fetch_ok: false }
1859            })
1860            .await
1861        });
1862
1863        started_rx.await.expect("fetch closure should start before abort");
1864        handle.abort();
1865        let _ = handle.await;
1866        tokio::task::yield_now().await;
1867
1868        let locks_after = {
1869            let locks = FETCH_LOCKS.lock().unwrap();
1870            locks.len()
1871        };
1872        assert_eq!(
1873            locks_after, 0,
1874            "Lock entry should be removed even if fetch task is cancelled"
1875        );
1876    }
1877
1878    // ---- Debounce ----
1879
1880    // `start_paused`: drive the 800ms debounce window on tokio's VIRTUAL clock, which auto-advances when
1881    // all tasks are parked. The spawned timers then resolve deterministically — no dependence on wall-clock
1882    // timing, so heavy parallel CPU load (e.g. the serialized vault stress tests) can't make the gate fire
1883    // after the test's wait and spuriously fail. (Was a real 1000ms sleep with only a 200ms margin.)
1884    #[tokio::test(start_paused = true)]
1885    async fn debounce_coalesces_rapid_calls_into_one() {
1886        // Snapshot counters before the burst.
1887        let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
1888        let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1889
1890        // Three rapid calls — only the last should survive the debounce gate.
1891        republish_inbox_relays_debounced();
1892        republish_inbox_relays_debounced();
1893        republish_inbox_relays_debounced();
1894
1895        let gen_after = REPUBLISH_GEN.load(Ordering::SeqCst);
1896        assert_eq!(gen_after, gen_before + 3);
1897
1898        // Past the 800ms window on the virtual clock (auto-advanced) so all spawned tasks resolve.
1899        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1900
1901        let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1902        // Exactly one task should have passed the generation gate.
1903        // (It then exits at nostr_client() since the client isn't
1904        // initialised in tests, but the coalescing behaviour is proven.)
1905        assert_eq!(pass_after - pass_before, 1);
1906    }
1907}