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