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