1use 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
17pub struct EventPublishTracker {
47 event_id: EventId,
48 successes: Mutex<Vec<RelayUrl>>,
51 notify: tokio::sync::Notify,
52 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 fn note_success(&self, url: RelayUrl) {
70 self.successes.lock().unwrap().push(url);
71 self.notify.notify_waiters();
72 }
73
74 fn note_settled(&self) {
78 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 pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
99 loop {
100 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
128static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
131 LazyLock::new(|| Mutex::new(HashMap::new()));
132
133pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
139 PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
140}
141
142pub 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 if resolved.is_empty() {
160 return Vec::new();
161 }
162 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
200const CACHE_TTL_SECS: u64 = 3600; const CACHE_TTL_ERROR_SECS: u64 = 60; struct CachedRelays {
211 relays: Vec<String>,
212 fetched_at: Instant,
213 fetch_ok: bool,
216}
217
218struct InboxRelayCache;
221
222fn inbox_relay_cache() -> Arc<Mutex<HashMap<PublicKey, CachedRelays>>> {
223 crate::db::current_session().scoped::<InboxRelayCache, _>()
224}
225
226static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
233 LazyLock::new(|| Mutex::new(HashMap::new()));
234
235static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
239
240#[cfg(not(test))]
243const PRUNE_INTERVAL: u64 = 100;
244
245#[cfg(test)]
248const PRUNE_INTERVAL: u64 = 1;
249
250struct 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, };
269
270 let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
271 Some(current) => {
272 Arc::ptr_eq(¤t, &self.key_lock) && Arc::strong_count(¤t) == 2
276 }
277 None => false,
278 };
279 if should_remove {
280 locks.remove(&self.pubkey);
281 }
282 }
283}
284
285pub fn normalize_relay_url(s: &str) -> String {
293 s.trim_end_matches('/').to_ascii_lowercase()
294}
295
296async 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
315struct FetchResult {
317 relays: Vec<String>,
318 fetch_ok: bool,
320}
321
322async 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 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
362fn 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
377async 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 {
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 let cleanup_guard = {
401 let mut locks = FETCH_LOCKS.lock().unwrap();
402
403 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 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 FetchLockEntryCleanup::new(*pubkey, key_lock)
423 };
424 let relays = {
425 let _guard = cleanup_guard.key_lock.lock().await;
426
427 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 let result = fetch_fn().await;
448
449 {
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 }; drop(cleanup_guard);
471 relays
472}
473
474async 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
479static 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
491pub fn trusted_relay_urls() -> Vec<RelayUrl> {
493 TRUSTED_RELAY_URLS.clone()
494}
495
496pub 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 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 let handles = spawn_tracked_publish(resolved, event.clone());
530
531 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 drop(remaining);
547 return Ok(output);
548 }
549 Err(e) => {
550 output.failed.insert(url, e);
551 }
552 }
553 }
554 }
555
556 Ok(output)
558}
559
560pub 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
576pub 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
614pub struct GiftWrapSendOutcome {
618 pub output: nostr_sdk::prelude::SendEventOutput,
619 pub wrap_event_id: EventId,
620 pub wrap_secret: SecretKey,
621 pub targeted_relays: Vec<String>,
624}
625
626pub struct BuiltGiftWrap {
633 pub event: Event,
634 pub secret: SecretKey,
635}
636
637pub 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
653pub struct GiftWrapTargets {
659 pub resolved: Vec<(RelayUrl, Relay)>,
660 pub targeted_relays: Vec<String>,
663 transient_added: Vec<RelayUrl>,
664}
665
666pub 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
694pub 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 use normalize_relay_url as normalize_url_for_match;
720 let pool = client;
721 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 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
787pub 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 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
806pub async fn publish_gift_wrap_to_targets(
818 client: &Client,
819 targets: &GiftWrapTargets,
820 event: &Event,
821) -> Result<nostr_sdk::prelude::SendEventOutput, String> {
822 if targets.resolved.is_empty() {
825 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 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
860pub 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
871pub 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
893const CONTRIBUTED_KEY: &str = "dm_relays_contributed";
903
904const 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
923struct MergePlan {
925 list: Vec<String>,
928 changed: bool,
930 contributed: Vec<String>,
933}
934
935fn 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 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
994pub 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 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 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 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
1086const 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
1100pub 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
1116pub 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#[derive(Debug, Default, PartialEq)]
1126pub struct InboundReconcile {
1127 pub adopt: Vec<String>,
1129 pub revive: Vec<String>,
1131 pub retire: Vec<String>,
1134}
1135
1136pub 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
1203static PUBLISH_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1207
1208pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
1214 let remote = fetch_own_inbox_list(client).await?;
1217 publish_inbox_relays_synced(client, remote, None).await
1218}
1219
1220pub 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 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 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 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 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 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
1340static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
1343
1344#[cfg(test)]
1346static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
1347
1348pub fn republish_inbox_relays_debounced() {
1352 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1353 let session = crate::db::current_session();
1358 crate::db::spawn_bound(async move {
1359 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1362 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
1363 return; }
1365 if !session.is_live() {
1366 return; }
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 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 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 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 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 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 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 #[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 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 #[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 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.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 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.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 for _ in 0..3 {
1653 assert!(!a.sync(&mut network));
1654 assert!(!b.sync(&mut network));
1655 }
1656 }
1657
1658 #[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 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 fn test_pubkey() -> PublicKey {
1704 let keys = Keys::generate();
1705 keys.public_key()
1706 }
1707
1708 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 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 assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
1802 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1804 }
1805
1806 #[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 {
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 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 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 let results = futures_util::future::join_all(handles).await;
1844
1845 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 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 let pk1 = test_pubkey();
1874 let pk2 = test_pubkey();
1875 let pk3 = test_pubkey();
1876
1877 {
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 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 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 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 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 #[tokio::test(start_paused = true)]
1984 async fn debounce_coalesces_rapid_calls_into_one() {
1985 let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
1987 let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1988
1989 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 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1999
2000 let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
2001 assert_eq!(pass_after - pass_before, 1);
2005 }
2006}