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