Skip to main content

vector_core/
inbox_relays.rs

1//! NIP-17 Kind 10050 (DM Relay List) support.
2//!
3//! Fetches, caches, and publishes kind 10050 events so that DM gift wraps
4//! are delivered to the recipient's preferred inbox relays.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8use std::sync::{Arc, Mutex, Weak};
9use std::time::Instant;
10
11use nostr_sdk::prelude::*;
12use std::sync::LazyLock;
13
14use crate::state::nostr_client;
15
16// ============================================================================
17// Per-relay publish tracker — closes "dependent-event-races-parent" races
18// ============================================================================
19//
20// Vector returns on the first relay ack so the UI can mark a message
21// as "Sent" without waiting for stragglers. Other relays continue
22// receiving the event in the background. Any operation that publishes
23// a *dependent* event referencing the just-sent one (NIP-09 deletion,
24// edit, reaction, reply, …) can race those background publishes: at
25// a relay where the parent hasn't arrived yet, the dependent gets
26// dropped or stored disconnected, and when the parent arrives later
27// it stays without the dependent ever being applied.
28//
29// `EventPublishTracker` exposes a per-relay event stream of "parent
30// successfully published to X". Dependent senders subscribe, drain
31// relays that have already settled, then wait for stragglers and
32// fire their dependent event to each one as soon as it confirms the
33// parent. Every relay that ever received the parent gets the
34// dependent in real time, and the user sees no UX latency on either
35// the parent send or the dependent action.
36//
37// This pattern is generic: deletion is the first consumer, but rapid
38// edits, self-reactions, and replies-to-just-sent all benefit. The
39// tracker doesn't care what the event is or what the dependent
40// operation does — it only knows "this parent landed at this relay".
41
42/// Per-relay publish tracker. One per outbound event whose dependents
43/// (deletions, edits, reactions, replies, ...) need to fire only
44/// after the parent has actually landed at each individual relay.
45pub struct EventPublishTracker {
46    event_id: EventId,
47    /// Successful relays in arrival order. Subscribers walk this with
48    /// a cursor and wait on `notify` for new entries.
49    successes: Mutex<Vec<RelayUrl>>,
50    notify: tokio::sync::Notify,
51    /// Relays still publishing. When this hits 0, the tracker
52    /// removes itself from the global registry and any pending
53    /// `next_success` waiters are woken so they observe end-of-stream.
54    in_flight: AtomicUsize,
55}
56
57impl EventPublishTracker {
58    fn new(event_id: EventId, initial_in_flight: usize) -> Arc<Self> {
59        Arc::new(Self {
60            event_id,
61            successes: Mutex::new(Vec::new()),
62            notify: tokio::sync::Notify::new(),
63            in_flight: AtomicUsize::new(initial_in_flight),
64        })
65    }
66
67    /// Called by a per-relay publish task on success.
68    fn note_success(&self, url: RelayUrl) {
69        self.successes.lock().unwrap().push(url);
70        self.notify.notify_waiters();
71    }
72
73    /// Called by every per-relay task on completion (success OR fail).
74    /// When the last in-flight task settles, drops the tracker from
75    /// the global registry.
76    fn note_settled(&self) {
77        if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
78            self.notify.notify_waiters();
79            PUBLISH_TRACKERS.lock().unwrap().remove(&self.event_id);
80        }
81    }
82
83    /// Async iterator over successful relays. Yields each URL once,
84    /// regardless of whether it settled before or after the call.
85    /// Returns `None` when every spawned per-relay task has settled
86    /// AND the cursor has consumed every success — i.e. the dependent
87    /// sender has visited every relay that ever held the parent.
88    pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
89        loop {
90            // Pre-create the notified future BEFORE inspecting state
91            // so a notify_waiters() that fires between the check and
92            // the await doesn't get lost.
93            let notified = self.notify.notified();
94            tokio::pin!(notified);
95            notified.as_mut().enable();
96
97            let (next, done) = {
98                let successes = self.successes.lock().unwrap();
99                let next = successes.get(*cursor).cloned();
100                let done = self.in_flight.load(Ordering::SeqCst) == 0
101                    && *cursor >= successes.len();
102                (next, done)
103            };
104
105            if let Some(url) = next {
106                *cursor += 1;
107                return Some(url);
108            }
109            if done {
110                return None;
111            }
112
113            notified.await;
114        }
115    }
116}
117
118/// Global registry of in-flight tracked publishes. Keyed by event id.
119/// Trackers self-remove once all per-relay tasks settle.
120static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
121    LazyLock::new(|| Mutex::new(HashMap::new()));
122
123/// Look up the tracker for an event currently being published.
124/// Returns `None` if the publish has fully settled (all relays done)
125/// or if the tracker never existed (e.g. the event was sent in a
126/// previous app session, or via a non-tracked send path). Dependent
127/// senders fall back to a best-effort broadcast in that case.
128pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
129    PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
130}
131
132/// Spawn one publish task per resolved relay and register a tracker
133/// keyed by the event id. Returns the join handles so the caller can
134/// race them for first-ok or wait for all to settle as needed. The
135/// spawned tasks continue updating the tracker after the caller
136/// stops waiting on the handles.
137///
138/// Generic primitive — any send path that wants its event referenced
139/// by a future dependent (deletions, edits, reactions, replies)
140/// should publish via this helper so the dependent can later look up
141/// the tracker via `get_publish_tracker(parent_id)`.
142pub fn spawn_tracked_publish(
143    resolved: Vec<(RelayUrl, Relay)>,
144    event: Event,
145) -> Vec<tokio::task::JoinHandle<(RelayUrl, Result<EventId, String>)>> {
146    let event_id = event.id;
147    // Zero relays → zero tasks → nobody ever calls note_settled; a registered tracker
148    // would leak forever.
149    if resolved.is_empty() {
150        return Vec::new();
151    }
152    let tracker = EventPublishTracker::new(event_id, resolved.len());
153    PUBLISH_TRACKERS.lock().unwrap().insert(event_id, tracker.clone());
154
155    let mut handles = Vec::with_capacity(resolved.len());
156    for (url, relay) in resolved {
157        let event = event.clone();
158        let tracker = tracker.clone();
159        handles.push(tokio::spawn(async move {
160            let result = relay
161                .send_event(&event)
162                .await
163                .map_err(|e| e.to_string());
164            if result.is_ok() {
165                tracker.note_success(url.clone());
166            }
167            tracker.note_settled();
168            (url, result)
169        }));
170    }
171    handles
172}
173
174// ============================================================================
175// Cache
176// ============================================================================
177
178/// How long cached relay lists stay valid before re-fetching.
179const CACHE_TTL_SECS: u64 = 3600; // 1 hour
180
181/// Shorter TTL for failed fetches so transient errors don't suppress routing too long.
182const CACHE_TTL_ERROR_SECS: u64 = 60; // 1 minute
183
184struct CachedRelays {
185    relays: Vec<String>,
186    fetched_at: Instant,
187    /// Whether the fetch succeeded (true) or failed/timed out (false).
188    /// Failed fetches use a shorter cache TTL.
189    fetch_ok: bool,
190}
191
192static INBOX_RELAY_CACHE: LazyLock<Mutex<HashMap<PublicKey, CachedRelays>>> =
193    LazyLock::new(|| Mutex::new(HashMap::new()));
194
195/// Drop every cached recipient relay list — called by `reset_session()`.
196/// The cache is recipient-keyed (so technically account-agnostic) but
197/// grows unboundedly across sessions; the 1-hour TTL only reclaims
198/// re-queried entries. Clear on swap to free memory and avoid
199/// stale-data revivals.
200pub fn clear_inbox_relay_cache() {
201    if let Ok(mut cache) = INBOX_RELAY_CACHE.lock() {
202        cache.clear();
203    }
204}
205
206/// Per-key locks to prevent cache stampede (thundering herd).
207/// When multiple messages target the same recipient with a cold cache, only the
208/// first fetch runs — others wait on the per-key lock, then hit the cache.
209/// Uses Weak references: the Mutex allocation is freed when Arc refcount drops.
210/// HashMap entries are removed eagerly by a per-call drop guard (normal return,
211/// cancellation, or panic unwind). Periodic retain() remains a fallback safety net.
212static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
213    LazyLock::new(|| Mutex::new(HashMap::new()));
214
215/// Counter for periodic fallback pruning of dead Weak entries in FETCH_LOCKS.
216/// Prune every PRUNE_INTERVAL cache misses to avoid O(n) scans on every access.
217/// This complements eager per-key cleanup after each completed call.
218static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
219
220/// Prune dead Weak entries every N cache misses. Lower = more CPU for pruning,
221/// higher = more memory for stale entries. 100 is a good balance for production.
222#[cfg(not(test))]
223const PRUNE_INTERVAL: u64 = 100;
224
225/// In tests, prune every access for deterministic behavior (tests rely on
226/// immediate cleanup to verify pruning logic works correctly).
227#[cfg(test)]
228const PRUNE_INTERVAL: u64 = 1;
229
230/// Drop-guard for eager per-key lock-map cleanup.
231/// Runs on normal return and when the future is dropped (e.g. cancellation).
232struct FetchLockEntryCleanup {
233    pubkey: PublicKey,
234    key_lock: Arc<tokio::sync::Mutex<()>>,
235}
236
237impl FetchLockEntryCleanup {
238    fn new(pubkey: PublicKey, key_lock: Arc<tokio::sync::Mutex<()>>) -> Self {
239        Self { pubkey, key_lock }
240    }
241}
242
243impl Drop for FetchLockEntryCleanup {
244    fn drop(&mut self) {
245        let mut locks = match FETCH_LOCKS.lock() {
246            Ok(locks) => locks,
247            Err(_) => return, // fallback retain() handles stale entries later
248        };
249
250        let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
251            Some(current) => {
252                // upgrade() adds one temporary Arc. If strong_count == 2, only:
253                // 1) this drop-guard's Arc, 2) upgrade() temporary Arc.
254                // That means no other in-flight callers still hold this key lock.
255                Arc::ptr_eq(&current, &self.key_lock) && Arc::strong_count(&current) == 2
256            }
257            None => false,
258        };
259        if should_remove {
260            locks.remove(&self.pubkey);
261        }
262    }
263}
264
265// ============================================================================
266// Fetch
267// ============================================================================
268
269/// Result of a 10050 fetch: relays found, or whether the fetch itself failed.
270struct FetchResult {
271    relays: Vec<String>,
272    /// `true` if the network request succeeded (even if no events were found).
273    fetch_ok: bool,
274}
275
276/// Fetch a pubkey's kind 10050 relay list from the network.
277async fn fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> FetchResult {
278    let filter = Filter::new()
279        .author(*pubkey)
280        .kind(Kind::Custom(10050))
281        .limit(1);
282
283    let events = match client
284        .fetch_events(filter, std::time::Duration::from_secs(5))
285        .await
286    {
287        Ok(events) => events,
288        Err(e) => {
289            eprintln!("[InboxRelays] Failed to fetch 10050 for {}: {}", pubkey, e);
290            return FetchResult { relays: Vec::new(), fetch_ok: false };
291        }
292    };
293
294    // The SDK returns Events (implements IntoIterator), take the first (most recent).
295    let event = match events.into_iter().next() {
296        Some(e) => e,
297        None => return FetchResult { relays: Vec::new(), fetch_ok: true },
298    };
299
300    FetchResult { relays: parse_relay_tags(&event.tags), fetch_ok: true }
301}
302
303/// Extract relay URLs from kind 10050 event tags.
304/// Looks for `["relay", "wss://..."]` tag entries.
305fn parse_relay_tags(tags: &Tags) -> Vec<String> {
306    tags.iter()
307        .filter_map(|tag| {
308            let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
309            if values.len() >= 2 && values[0] == "relay" {
310                Some(values[1].to_string())
311            } else {
312                None
313            }
314        })
315        .collect()
316}
317
318/// Generic cache-with-lock implementation used by both production and test code.
319/// Uses double-checked locking to prevent cache stampede: rapid requests to the
320/// same pubkey serialize through a per-key lock, so only one fetch happens.
321/// Different pubkeys never block each other.
322async fn get_or_fetch_with_lock<F, Fut>(pubkey: &PublicKey, fetch_fn: F) -> Vec<String>
323where
324    F: FnOnce() -> Fut,
325    Fut: std::future::Future<Output = FetchResult>,
326{
327    // Fast path: cache hit (no per-key lock needed, no pruning)
328    {
329        let cache = INBOX_RELAY_CACHE.lock().unwrap();
330        if let Some(entry) = cache.get(pubkey) {
331            let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
332            if entry.fetched_at.elapsed().as_secs() < ttl {
333                return entry.relays.clone();
334            }
335        }
336    }
337
338    // Per-key lock — serializes fetches for the same pubkey only.
339    // Uses Weak references + periodic pruning (every PRUNE_INTERVAL cache misses).
340    let cleanup_guard = {
341        let mut locks = FETCH_LOCKS.lock().unwrap();
342
343        // Periodic cleanup: remove dead Weak entries every PRUNE_INTERVAL accesses.
344        // Avoids O(n) scan in global critical section on every cache miss; instead
345        // amortizes cost to O(n/PRUNE_INTERVAL) per miss under heavy fan-out.
346        if PRUNE_COUNTER.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL == 0 {
347            locks.retain(|_, weak| Weak::strong_count(weak) > 0);
348        }
349
350        let weak = locks.entry(*pubkey).or_insert_with(|| Weak::new());
351        // Try to upgrade the weak reference; if it fails (Arc was dropped),
352        // create a new Arc and update the map.
353        let key_lock = match weak.upgrade() {
354            Some(arc) => arc,
355            None => {
356                let new_arc = Arc::new(tokio::sync::Mutex::new(()));
357                *weak = Arc::downgrade(&new_arc);
358                new_arc
359            }
360        };
361        // Wrap lock Arc in drop-guard so map cleanup runs even on cancellation.
362        FetchLockEntryCleanup::new(*pubkey, key_lock)
363    };
364    let relays = {
365        let _guard = cleanup_guard.key_lock.lock().await;
366
367        // Double-check: another task may have filled the cache while we waited
368        let cached_relays = {
369            let cache = INBOX_RELAY_CACHE.lock().unwrap();
370            if let Some(entry) = cache.get(pubkey) {
371                let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
372                if entry.fetched_at.elapsed().as_secs() < ttl {
373                    Some(entry.relays.clone())
374                } else {
375                    None
376                }
377            } else {
378                None
379            }
380        };
381
382        match cached_relays {
383            Some(relays) => relays,
384            None => {
385                // We won the race — do the actual fetch
386                let result = fetch_fn().await;
387
388                // Store in cache (even empty/error results to avoid hammering relays)
389                {
390                    let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
391                    cache.insert(
392                        *pubkey,
393                        CachedRelays {
394                            relays: result.relays.clone(),
395                            fetched_at: Instant::now(),
396                            fetch_ok: result.fetch_ok,
397                        },
398                    );
399                }
400
401                result.relays
402            }
403        }
404    }; // per-key lock guard dropped here
405
406    // Explicit drop on normal path. On cancellation/panic unwind this still runs
407    // via Drop when the future is torn down.
408    drop(cleanup_guard);
409    relays
410}
411
412/// Get inbox relays for a pubkey, using cache when available.
413async fn get_or_fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> Vec<String> {
414    get_or_fetch_with_lock(pubkey, || fetch_inbox_relays(client, pubkey)).await
415}
416
417// ============================================================================
418// Send helper
419// ============================================================================
420
421/// Parsed `TRUSTED_RELAYS` URLs — computed once on first access.
422static TRUSTED_RELAY_URLS: LazyLock<Vec<RelayUrl>> = LazyLock::new(|| {
423    crate::state::TRUSTED_RELAYS
424        .iter()
425        .filter_map(|s| RelayUrl::parse(s).ok())
426        .collect()
427});
428
429/// Get the cached parsed trusted relay URLs.
430pub fn trusted_relay_urls() -> Vec<RelayUrl> {
431    TRUSTED_RELAY_URLS.clone()
432}
433
434/// Send an event to specific relays, returning as soon as the **first** relay
435/// acknowledges success. Remaining relays continue sending in the background.
436///
437/// Uses `spawn_tracked_publish` under the hood, so every event published
438/// here automatically registers an `EventPublishTracker` keyed by event
439/// id. Dependent operations (NIP-09 deletions, edits, reactions, replies)
440/// can look up the tracker via `get_publish_tracker(event_id)` and drive
441/// per-relay dispatch only after each relay confirms the parent — closing
442/// the publish/dependent race for any send that goes through here.
443pub async fn send_event_first_ok(
444    client: &Client,
445    urls: Vec<RelayUrl>,
446    event: &Event,
447) -> Result<Output<EventId>, nostr_sdk::client::Error> {
448    let pool = client.pool();
449    let relays = pool.relays().await;
450    let event_id = event.id;
451
452    // Resolve URL -> Relay handles, filtering to relays we actually have
453    let mut resolved: Vec<(RelayUrl, Relay)> = Vec::new();
454    for url in urls {
455        if let Some(relay) = relays.get(&url) {
456            resolved.push((url, relay.clone()));
457        }
458    }
459
460    if resolved.is_empty() {
461        return client.send_event(event).await;
462    }
463
464    // Spawn tracked per-relay tasks. This registers a tracker so any
465    // future dependent send (deletion, edit, reaction) can fire only
466    // after each relay confirms the parent.
467    let handles = spawn_tracked_publish(resolved, event.clone());
468
469    // Race: return as soon as the first relay succeeds
470    let mut output = Output {
471        val: event_id,
472        success: std::collections::HashSet::new(),
473        failed: HashMap::new(),
474    };
475
476    let mut remaining = handles;
477    while !remaining.is_empty() {
478        let (result, _index, rest) = futures_util::future::select_all(remaining).await;
479        remaining = rest;
480
481        if let Ok((url, relay_result)) = result {
482            match relay_result {
483                Ok(_) => {
484                    output.success.insert(url);
485                    // First success — remaining spawned tasks continue in background
486                    // updating the tracker as they settle. Dropping JoinHandles
487                    // detaches but does NOT cancel them.
488                    drop(remaining);
489                    return Ok(output);
490                }
491                Err(e) => {
492                    output.failed.insert(url, e);
493                }
494            }
495        }
496    }
497
498    // All relays failed — return output so caller can inspect .failed
499    Ok(output)
500}
501
502/// Send an event to all write-relays in the pool, returning as soon as the
503/// **first** relay acknowledges success.
504pub async fn send_event_pool_first_ok(
505    client: &Client,
506    event: &Event,
507) -> Result<Output<EventId>, nostr_sdk::client::Error> {
508    let pool = client.pool();
509    let relays = pool.relays().await;
510    let write_urls: Vec<RelayUrl> = relays
511        .iter()
512        .filter(|(_, r)| r.flags().has_write())
513        .map(|(url, _)| url.clone())
514        .collect();
515    send_event_first_ok(&client, write_urls, event).await
516}
517
518/// Build a NIP-59 kind-1059 gift wrap from a sealed event, returning
519/// **both** the signed wrap event and the ephemeral secp256k1 secret
520/// used to sign it.
521///
522/// Wire-compatible with `EventBuilder::gift_wrap_from_seal` — other
523/// clients cannot tell the wraps apart. The only difference is that we
524/// keep the ephemeral key instead of dropping it on the floor, so the
525/// user can later sign a NIP-09 deletion against the wrap event id and
526/// have relays drop it. This is Vector's "delete from network" primitive.
527pub fn wrap_with_retained_key(
528    receiver: &PublicKey,
529    seal: &Event,
530    extra_tags: impl IntoIterator<Item = Tag>,
531) -> Result<(Event, SecretKey), String> {
532    use nostr_sdk::nips::nip44;
533    use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
534
535    if seal.kind != Kind::Seal {
536        return Err(format!("expected Seal kind, got {:?}", seal.kind));
537    }
538    let keys = Keys::generate();
539    let secret = keys.secret_key().clone();
540    let content = nip44::encrypt(
541        keys.secret_key(),
542        receiver,
543        seal.as_json(),
544        nip44::Version::default(),
545    )
546    .map_err(|e| format!("nip44 encrypt: {}", e))?;
547    let mut tags: Vec<Tag> = extra_tags.into_iter().collect();
548    tags.push(Tag::public_key(*receiver));
549    let event = EventBuilder::new(Kind::GiftWrap, content)
550        .tags(tags)
551        .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
552        .sign_with_keys(&keys)
553        .map_err(|e| format!("sign wrap: {}", e))?;
554    Ok((event, secret))
555}
556
557/// Outcome of a retained-key gift-wrap send. Caller is expected to
558/// persist `wrap_event_id`, `wrap_secret`, and `targeted_relays` for
559/// future deletion.
560pub struct GiftWrapSendOutcome {
561    pub output: Output<EventId>,
562    pub wrap_event_id: EventId,
563    pub wrap_secret: SecretKey,
564    /// Relay URL set we attempted (inbox if known, pool write-relays as
565    /// fallback). Deletion publishes the NIP-09 to this same set.
566    pub targeted_relays: Vec<String>,
567}
568
569/// Send a gift-wrapped rumor to a recipient using a retained ephemeral
570/// key. Routes to the recipient's inbox relays (kind 10050) when
571/// available, falling back to pool write-relays otherwise.
572///
573/// Spawns one publish task per resolved relay and registers a
574/// `EventPublishTracker` keyed by wrap event id so the deletion path
575/// can fire NIP-09 to each relay as soon as that relay confirms the
576/// wrap (closing the publish/delete race for fast deleters). Returns
577/// the wrap event id, the ephemeral secret, and the relay set
578/// attempted.
579pub async fn send_gift_wrap_retained(
580    client: &Client,
581    recipient: &PublicKey,
582    rumor: UnsignedEvent,
583    extra_tags: impl IntoIterator<Item = Tag>,
584) -> Result<GiftWrapSendOutcome, String> {
585    let signer = client.signer().await.map_err(|e| e.to_string())?;
586    let seal: Event = EventBuilder::seal(&signer, recipient, rumor)
587        .await
588        .map_err(|e| e.to_string())?
589        .sign(&signer)
590        .await
591        .map_err(|e| e.to_string())?;
592    let (event, secret) = wrap_with_retained_key(recipient, &seal, extra_tags)?;
593    let wrap_event_id = event.id;
594
595    // Resolve target relays: recipient's inbox relays (NIP-17) if they
596    // advertise any, otherwise our pool's write-relays.
597    let inbox_strs = get_or_fetch_inbox_relays(client, recipient).await;
598    let targeted_strs: Vec<String> = if !inbox_strs.is_empty() {
599        inbox_strs.clone()
600    } else {
601        let pool = client.pool();
602        let relays = pool.relays().await;
603        relays.iter()
604            .filter(|(_, r)| r.flags().has_write())
605            .map(|(url, _)| url.to_string())
606            .collect()
607    };
608    // Resolve to live Relay handles in the pool. Strict HashMap lookup by
609    // `RelayUrl` was missing visually-identical URLs because nostr-sdk
610    // canonicalises differently between published-10050 strings and pool
611    // keys (trailing slashes, default ports, case). Normalise both sides
612    // and match on the canonical string form so e.g. `wss://relay.damus.io`
613    // and `wss://relay.damus.io/` count as the same relay.
614    fn normalize_url_for_match(s: &str) -> String {
615        s.trim_end_matches('/').to_ascii_lowercase()
616    }
617    let pool = client.pool();
618    let pool_relays = pool.relays().await;
619    let pool_norm: Vec<(String, RelayUrl, Relay)> = pool_relays.iter()
620        .map(|(url, relay)| (
621            normalize_url_for_match(&url.to_string()),
622            url.clone(),
623            relay.clone(),
624        ))
625        .collect();
626    let mut resolved: Vec<(RelayUrl, Relay)> = targeted_strs
627        .iter()
628        .filter_map(|s| {
629            let norm = normalize_url_for_match(s);
630            pool_norm.iter()
631                .find(|(pnorm, _, _)| pnorm == &norm)
632                .map(|(_, url, relay)| (url.clone(), relay.clone()))
633        })
634        .collect();
635
636    // On-demand connect: inbox relays not already in the pool are added +
637    // connected just for this send, then removed afterwards (transient_added).
638    // The recipient's inbox relays are theirs, not ours — keeping them would
639    // pollute the pool, which the reconcile loop owns. Only for real inbox
640    // relays; the pool-write fallback already targets live pool members.
641    let mut transient_added: Vec<RelayUrl> = Vec::new();
642    if !inbox_strs.is_empty() {
643        for s in &targeted_strs {
644            let norm = normalize_url_for_match(s);
645            let in_pool = pool_norm.iter().any(|(p, _, _)| p == &norm);
646            let already_added = transient_added.iter()
647                .any(|u| normalize_url_for_match(&u.to_string()) == norm);
648            if in_pool || already_added { continue; }
649
650            let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
651            if pool.add_relay(s.as_str(), opts).await.is_ok() {
652                if let Ok(relay) = pool.relay(s.as_str()).await {
653                    let _ = relay.try_connect(std::time::Duration::from_secs(6)).await;
654                    transient_added.push(relay.url().clone());
655                    resolved.push((relay.url().clone(), relay));
656                }
657            }
658        }
659        if !transient_added.is_empty() {
660            crate::log_info!(
661                "[InboxRelays] on-demand connected {} inbox relay(s) for {} (transient)",
662                transient_added.len(),
663                recipient,
664            );
665        }
666    }
667
668    // `resolved.is_empty()` implies no transient add succeeded (each success
669    // pushes onto `resolved`), so this branch can't leak a transient relay.
670    if resolved.is_empty() {
671        // No matching relays in the pool — last-ditch broadcast via
672        // client.send_event(). No tracker (no per-relay machinery).
673        let output = client
674            .send_event(&event)
675            .await
676            .map_err(|e| e.to_string())?;
677        return Ok(GiftWrapSendOutcome {
678            output,
679            wrap_event_id,
680            wrap_secret: secret,
681            targeted_relays: targeted_strs,
682        });
683    }
684
685    if !inbox_strs.is_empty() {
686        println!(
687            "[InboxRelays] Routing gift-wrap to {} inbox relays for {}",
688            resolved.len(),
689            recipient
690        );
691    }
692
693    // Spawn tracked per-relay publish tasks. The tracker is keyed by
694    // the wrap event id; the deletion path looks it up via
695    // get_publish_tracker(wrap_event_id) and walks next_success() to
696    // fire NIP-09 only at relays that have actually received the
697    // wrap. The same primitive is used by any other operation whose
698    // dependent event must arrive after the parent on each relay
699    // (rapid edits, self-reactions, replies-to-just-sent).
700    let handles = spawn_tracked_publish(resolved, event.clone());
701
702    // Race for first-ok so the caller (and UI) sees "Sent" the
703    // moment any one relay accepts. Remaining tasks continue in
704    // the background, updating the tracker as they settle. The
705    // dropped JoinHandles detach but do not cancel the tasks.
706    let mut output = Output {
707        val: wrap_event_id,
708        success: HashSet::new(),
709        failed: HashMap::new(),
710    };
711    let mut remaining = handles;
712    while !remaining.is_empty() {
713        let (result, _idx, rest) = futures_util::future::select_all(remaining).await;
714        remaining = rest;
715        if let Ok((url, relay_result)) = result {
716            match relay_result {
717                Ok(_) => {
718                    output.success.insert(url);
719                    drop(remaining);
720                    break;
721                }
722                Err(e) => {
723                    output.failed.insert(url, e.to_string());
724                }
725            }
726        }
727    }
728
729    // Tear down transiently-added inbox relays — they belong to the
730    // recipient, not us. Delivery has already raced to first-ok; one
731    // confirmed inbox relay satisfies NIP-17, so cutting any still-in-flight
732    // background publishes to the others is acceptable.
733    for url in &transient_added {
734        let _ = pool.remove_relay(url).await;
735    }
736
737    Ok(GiftWrapSendOutcome {
738        output,
739        wrap_event_id,
740        wrap_secret: secret,
741        targeted_relays: targeted_strs,
742    })
743}
744
745/// Send a gift-wrapped rumor to a recipient, routing to their inbox relays
746/// (kind 10050) when available. Falls back to pool broadcast if no inbox
747/// relays are found or if targeted delivery fails entirely.
748///
749/// Returns as soon as the first relay acknowledges success — remaining relays
750/// continue in the background. This minimises the time messages spend in
751/// "pending" state.
752///
753/// Thin wrapper over `send_gift_wrap_retained`. Discards the retained
754/// ephemeral key — use this for sends where future deletion is not
755/// required (e.g. PIVX payment rumors). For user-facing DMs, prefer
756/// `send_gift_wrap_retained` and persist the secret.
757pub async fn send_gift_wrap(
758    client: &Client,
759    recipient: &PublicKey,
760    rumor: UnsignedEvent,
761    extra_tags: impl IntoIterator<Item = Tag>,
762) -> Result<Output<EventId>, String> {
763    let outcome = send_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
764    Ok(outcome.output)
765}
766
767// ============================================================================
768// Publish own inbox relays
769// ============================================================================
770
771/// Publish our own kind 10050 event advertising readable relays as DM inboxes.
772/// Write-only relays are excluded since senders need to write to them.
773/// If no readable relays exist, publishes an empty 10050 to clear any stale list.
774pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
775    // Gather relay URLs that have the READ flag (i.e. relays we read from,
776    // which means senders should write to them so we can receive DMs).
777    let relays: Vec<String> = client
778        .pool()
779        .relays()
780        .await
781        .iter()
782        .filter(|(_, relay)| relay.flags().has_read())
783        .map(|(url, _)| url.to_string())
784        .collect();
785
786    // Build kind 10050 replaceable event with ["relay", url] tags.
787    // An empty event (no relay tags) replaces any prior 10050, clearing stale lists.
788    let mut builder = EventBuilder::new(Kind::Custom(10050), "");
789    for url in &relays {
790        builder = builder.tag(Tag::custom(TagKind::custom("relay"), vec![url.clone()]));
791    }
792
793    client
794        .send_event_builder(builder)
795        .await
796        .map_err(|e| format!("Failed to publish inbox relays: {}", e))?;
797
798    println!(
799        "[InboxRelays] Published kind 10050 with {} relay(s)",
800        relays.len()
801    );
802    Ok(())
803}
804
805/// Monotonic generation counter used to debounce republish calls.
806/// Only the most recent spawn actually publishes; earlier ones exit early.
807static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
808
809/// Counts how many spawned tasks pass the generation gate (test-only).
810#[cfg(test)]
811static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
812
813/// Republish kind 10050 in the background (debounced).
814/// Called after relay config changes (add/remove/toggle/mode update).
815/// Rapid successive calls coalesce into a single publish.
816pub fn republish_inbox_relays_debounced() {
817    let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
818    // REPUBLISH_GEN dedupes within a session; SessionGuard dedupes
819    // across sessions. Without the guard, a swap during the 800ms
820    // debounce window would publish account A's inbox-relay claim
821    // signed by account B's client.
822    let session = crate::state::SessionGuard::capture();
823    tokio::spawn(async move {
824        // Wait for the relay pool to settle; if another call arrives
825        // during this window it will bump the generation and we'll exit.
826        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
827        if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
828            return; // superseded by a newer call
829        }
830        if !session.is_valid() {
831            return; // swap occurred during the debounce window
832        }
833        #[cfg(test)]
834        DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
835        let client = match nostr_client() {
836            Some(c) => c,
837            None => return,
838        };
839        if let Err(e) = publish_inbox_relays(&client).await {
840            eprintln!("[InboxRelays] Failed to republish after config change: {}", e);
841        }
842    });
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    // ---- Tag parsing ----
850
851    #[test]
852    fn parse_relay_tags_extracts_urls() {
853        let tags = Tags::from_list(vec![
854            Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
855            Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
856        ]);
857        let result = parse_relay_tags(&tags);
858        assert_eq!(result, vec![
859            "wss://relay.example.com".to_string(),
860            "wss://other.example.com".to_string(),
861        ]);
862    }
863
864    #[test]
865    fn parse_relay_tags_ignores_non_relay_tags() {
866        let tags = Tags::from_list(vec![
867            Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
868            Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
869            Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
870        ]);
871        let result = parse_relay_tags(&tags);
872        assert_eq!(result, vec!["wss://good.example.com".to_string()]);
873    }
874
875    #[test]
876    fn parse_relay_tags_empty() {
877        let tags = Tags::new();
878        let result = parse_relay_tags(&tags);
879        assert!(result.is_empty());
880    }
881
882    #[test]
883    fn parse_relay_tags_ignores_relay_tag_without_value() {
884        // A ["relay"] tag with no URL should be skipped (len < 2)
885        let tags = Tags::from_list(vec![
886            Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
887        ]);
888        let result = parse_relay_tags(&tags);
889        assert!(result.is_empty());
890    }
891
892    // ---- Cache ----
893
894    fn test_pubkey() -> PublicKey {
895        let keys = Keys::generate();
896        keys.public_key()
897    }
898
899    // Serialize tests that mutate global cache/lock statics.
900    static TEST_GLOBALS_LOCK: LazyLock<tokio::sync::Mutex<()>> =
901        LazyLock::new(|| tokio::sync::Mutex::new(()));
902
903    #[test]
904    fn cache_stores_and_retrieves() {
905        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
906        let pk = test_pubkey();
907        let relays = vec!["wss://a.example.com".to_string()];
908
909        {
910            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
911            cache.insert(pk, CachedRelays {
912                relays: relays.clone(),
913                fetched_at: Instant::now(),
914                fetch_ok: true,
915            });
916        }
917
918        let cache = INBOX_RELAY_CACHE.lock().unwrap();
919        let entry = cache.get(&pk).unwrap();
920        assert_eq!(entry.relays, relays);
921        assert!(entry.fetch_ok);
922        assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
923    }
924
925    #[test]
926    fn cache_expires_after_ttl() {
927        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
928        let pk = test_pubkey();
929
930        {
931            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
932            cache.insert(pk, CachedRelays {
933                relays: vec!["wss://stale.example.com".to_string()],
934                fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
935                fetch_ok: true,
936            });
937        }
938
939        let cache = INBOX_RELAY_CACHE.lock().unwrap();
940        let entry = cache.get(&pk).unwrap();
941        assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
942    }
943
944    #[test]
945    fn cache_stores_empty_results() {
946        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
947        let pk = test_pubkey();
948
949        {
950            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
951            cache.insert(pk, CachedRelays {
952                relays: vec![],
953                fetched_at: Instant::now(),
954                fetch_ok: true,
955            });
956        }
957
958        let cache = INBOX_RELAY_CACHE.lock().unwrap();
959        let entry = cache.get(&pk).unwrap();
960        assert!(entry.relays.is_empty());
961        assert!(entry.fetch_ok);
962        assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
963    }
964
965    #[test]
966    fn cache_error_uses_short_ttl() {
967        let _guard = TEST_GLOBALS_LOCK.blocking_lock();
968        let pk = test_pubkey();
969
970        {
971            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
972            cache.insert(pk, CachedRelays {
973                relays: vec![],
974                // Inserted 2 minutes ago — past the error TTL (60s) but within success TTL (3600s)
975                fetched_at: Instant::now() - std::time::Duration::from_secs(120),
976                fetch_ok: false,
977            });
978        }
979
980        let cache = INBOX_RELAY_CACHE.lock().unwrap();
981        let entry = cache.get(&pk).unwrap();
982        assert!(!entry.fetch_ok);
983        // Should be considered expired under error TTL
984        assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
985        // But would still be valid under success TTL
986        assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
987    }
988
989    // ---- Concurrency / stampede prevention ----
990
991    #[tokio::test]
992    async fn concurrent_fetches_for_same_pubkey_serialize() {
993        let _guard = TEST_GLOBALS_LOCK.lock().await;
994        let pk = test_pubkey();
995
996        // Clear cache so all tasks see a cold cache
997        {
998            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
999            cache.remove(&pk);
1000        }
1001
1002        let fetch_counter = Arc::new(AtomicU64::new(0));
1003
1004        // Spawn 10 concurrent tasks all trying to fetch the same pubkey.
1005        // Uses production get_or_fetch_with_lock so this tests actual code path.
1006        let mut handles = vec![];
1007        for _ in 0..10 {
1008            let counter = fetch_counter.clone();
1009            let handle = tokio::spawn(async move {
1010                get_or_fetch_with_lock(&pk, || async {
1011                    counter.fetch_add(1, Ordering::SeqCst);
1012                    // Simulate network delay so concurrent tasks pile up
1013                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1014                    FetchResult {
1015                        relays: vec!["wss://test.example.com".to_string()],
1016                        fetch_ok: true,
1017                    }
1018                })
1019                .await
1020            });
1021            handles.push(handle);
1022        }
1023
1024        // Wait for all tasks to complete
1025        let results = futures_util::future::join_all(handles).await;
1026
1027        // All tasks should succeed and get the same result
1028        for result in &results {
1029            assert!(result.is_ok());
1030            let relays = result.as_ref().unwrap();
1031            assert_eq!(relays, &vec!["wss://test.example.com".to_string()]);
1032        }
1033
1034        // CRITICAL: Only ONE fetch should have executed (others waited on lock + hit cache)
1035        assert_eq!(
1036            fetch_counter.load(Ordering::SeqCst),
1037            1,
1038            "Expected exactly 1 fetch for 10 concurrent requests to same pubkey"
1039        );
1040
1041        let locks_after = {
1042            let locks = FETCH_LOCKS.lock().unwrap();
1043            locks.len()
1044        };
1045        assert_eq!(locks_after, 0, "Lock entry should be removed after all waiters complete");
1046    }
1047
1048    #[tokio::test]
1049    async fn fetch_locks_do_not_accumulate_after_calls_complete() {
1050        let _guard = TEST_GLOBALS_LOCK.lock().await;
1051
1052        // Verify that lock entries are removed eagerly when the last in-flight
1053        // caller for a key exits (true bounded growth, no idle-after-burst leak).
1054
1055        let pk1 = test_pubkey();
1056        let pk2 = test_pubkey();
1057        let pk3 = test_pubkey();
1058
1059        // Clear both cache and locks to avoid interference from other tests
1060        {
1061            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1062            cache.clear();
1063        }
1064        {
1065            let mut locks = FETCH_LOCKS.lock().unwrap();
1066            locks.clear();
1067        }
1068
1069        // Step 1: Fetch for pk1 (cache miss -> creates lock entry)
1070        get_or_fetch_with_lock(&pk1, || async {
1071            FetchResult {
1072                relays: vec!["wss://relay1.example.com".to_string()],
1073                fetch_ok: true,
1074            }
1075        })
1076        .await;
1077
1078        // Single-call path: no waiters, so eager cleanup should remove key immediately.
1079
1080        let locks_after_pk1 = {
1081            let locks = FETCH_LOCKS.lock().unwrap();
1082            locks.len()
1083        };
1084        assert_eq!(locks_after_pk1, 0, "No lock entries should remain after pk1 call");
1085
1086        // Step 2: repeat with pk2
1087        get_or_fetch_with_lock(&pk2, || async {
1088            FetchResult {
1089                relays: vec!["wss://relay2.example.com".to_string()],
1090                fetch_ok: true,
1091            }
1092        })
1093        .await;
1094
1095        let locks_after_pk2 = {
1096            let locks = FETCH_LOCKS.lock().unwrap();
1097            locks.len()
1098        };
1099        assert_eq!(locks_after_pk2, 0, "No lock entries should remain after pk2 call");
1100
1101        // Step 3: repeat with pk3
1102        get_or_fetch_with_lock(&pk3, || async {
1103            FetchResult {
1104                relays: vec!["wss://relay3.example.com".to_string()],
1105                fetch_ok: true,
1106            }
1107        })
1108        .await;
1109
1110        let locks_after_pk3 = {
1111            let locks = FETCH_LOCKS.lock().unwrap();
1112            locks.len()
1113        };
1114        assert_eq!(locks_after_pk3, 0, "No lock entries should remain after pk3 call");
1115    }
1116
1117    #[tokio::test]
1118    async fn cancelled_fetch_cleans_up_lock_entry() {
1119        let _guard = TEST_GLOBALS_LOCK.lock().await;
1120        let pk = test_pubkey();
1121
1122        {
1123            let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1124            cache.clear();
1125        }
1126        {
1127            let mut locks = FETCH_LOCKS.lock().unwrap();
1128            locks.clear();
1129        }
1130
1131        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1132        let task_pk = pk;
1133        let handle = tokio::spawn(async move {
1134            get_or_fetch_with_lock(&task_pk, || async move {
1135                let _ = started_tx.send(());
1136                tokio::time::sleep(std::time::Duration::from_secs(30)).await;
1137                FetchResult { relays: Vec::new(), fetch_ok: false }
1138            })
1139            .await
1140        });
1141
1142        started_rx.await.expect("fetch closure should start before abort");
1143        handle.abort();
1144        let _ = handle.await;
1145        tokio::task::yield_now().await;
1146
1147        let locks_after = {
1148            let locks = FETCH_LOCKS.lock().unwrap();
1149            locks.len()
1150        };
1151        assert_eq!(
1152            locks_after, 0,
1153            "Lock entry should be removed even if fetch task is cancelled"
1154        );
1155    }
1156
1157    // ---- Debounce ----
1158
1159    // `start_paused`: drive the 800ms debounce window on tokio's VIRTUAL clock, which auto-advances when
1160    // all tasks are parked. The spawned timers then resolve deterministically — no dependence on wall-clock
1161    // timing, so heavy parallel CPU load (e.g. the serialized vault stress tests) can't make the gate fire
1162    // after the test's wait and spuriously fail. (Was a real 1000ms sleep with only a 200ms margin.)
1163    #[tokio::test(start_paused = true)]
1164    async fn debounce_coalesces_rapid_calls_into_one() {
1165        // Snapshot counters before the burst.
1166        let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
1167        let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1168
1169        // Three rapid calls — only the last should survive the debounce gate.
1170        republish_inbox_relays_debounced();
1171        republish_inbox_relays_debounced();
1172        republish_inbox_relays_debounced();
1173
1174        let gen_after = REPUBLISH_GEN.load(Ordering::SeqCst);
1175        assert_eq!(gen_after, gen_before + 3);
1176
1177        // Past the 800ms window on the virtual clock (auto-advanced) so all spawned tasks resolve.
1178        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1179
1180        let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1181        // Exactly one task should have passed the generation gate.
1182        // (It then exits at nostr_client() since the client isn't
1183        // initialised in tests, but the coalescing behaviour is proven.)
1184        assert_eq!(pass_after - pass_before, 1);
1185    }
1186}