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;
15
16pub struct EventPublishTracker {
46 event_id: EventId,
47 successes: Mutex<Vec<RelayUrl>>,
50 notify: tokio::sync::Notify,
51 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 fn note_success(&self, url: RelayUrl) {
69 self.successes.lock().unwrap().push(url);
70 self.notify.notify_waiters();
71 }
72
73 fn note_settled(&self) {
77 let mut trackers = PUBLISH_TRACKERS.lock().unwrap();
81 if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
82 self.notify.notify_waiters();
83 match trackers.get(&self.event_id) {
84 Some(current) if std::ptr::eq(Arc::as_ptr(current), self) => {
85 trackers.remove(&self.event_id);
86 }
87 _ => {}
88 }
89 }
90 }
91
92 pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
98 loop {
99 let notified = self.notify.notified();
103 tokio::pin!(notified);
104 notified.as_mut().enable();
105
106 let (next, done) = {
107 let successes = self.successes.lock().unwrap();
108 let next = successes.get(*cursor).cloned();
109 let done = self.in_flight.load(Ordering::SeqCst) == 0
110 && *cursor >= successes.len();
111 (next, done)
112 };
113
114 if let Some(url) = next {
115 *cursor += 1;
116 return Some(url);
117 }
118 if done {
119 return None;
120 }
121
122 notified.await;
123 }
124 }
125}
126
127static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
130 LazyLock::new(|| Mutex::new(HashMap::new()));
131
132pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
138 PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
139}
140
141pub fn spawn_tracked_publish(
152 resolved: Vec<(RelayUrl, Relay)>,
153 event: Event,
154) -> Vec<tokio::task::JoinHandle<(RelayUrl, Result<EventId, String>)>> {
155 let event_id = event.id;
156 if resolved.is_empty() {
159 return Vec::new();
160 }
161 let tracker = {
165 let mut trackers = PUBLISH_TRACKERS.lock().unwrap();
166 match trackers.get(&event_id) {
167 Some(existing) if existing.in_flight.load(Ordering::SeqCst) > 0 => {
168 existing.in_flight.fetch_add(resolved.len(), Ordering::SeqCst);
169 existing.clone()
170 }
171 _ => {
172 let t = EventPublishTracker::new(event_id, resolved.len());
173 trackers.insert(event_id, t.clone());
174 t
175 }
176 }
177 };
178
179 let mut handles = Vec::with_capacity(resolved.len());
180 for (url, relay) in resolved {
181 let event = event.clone();
182 let tracker = tracker.clone();
183 handles.push(tokio::spawn(async move {
184 let result = relay
185 .send_event(&event)
186 .await
187 .map_err(|e| e.to_string());
188 if result.is_ok() {
189 tracker.note_success(url.clone());
190 }
191 tracker.note_settled();
192 (url, result)
193 }));
194 }
195 handles
196}
197
198const CACHE_TTL_SECS: u64 = 3600; const CACHE_TTL_ERROR_SECS: u64 = 60; struct CachedRelays {
209 relays: Vec<String>,
210 fetched_at: Instant,
211 fetch_ok: bool,
214}
215
216static INBOX_RELAY_CACHE: LazyLock<Mutex<HashMap<PublicKey, CachedRelays>>> =
217 LazyLock::new(|| Mutex::new(HashMap::new()));
218
219pub fn clear_inbox_relay_cache() {
225 if let Ok(mut cache) = INBOX_RELAY_CACHE.lock() {
226 cache.clear();
227 }
228}
229
230static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
237 LazyLock::new(|| Mutex::new(HashMap::new()));
238
239static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
243
244#[cfg(not(test))]
247const PRUNE_INTERVAL: u64 = 100;
248
249#[cfg(test)]
252const PRUNE_INTERVAL: u64 = 1;
253
254struct FetchLockEntryCleanup {
257 pubkey: PublicKey,
258 key_lock: Arc<tokio::sync::Mutex<()>>,
259}
260
261impl FetchLockEntryCleanup {
262 fn new(pubkey: PublicKey, key_lock: Arc<tokio::sync::Mutex<()>>) -> Self {
263 Self { pubkey, key_lock }
264 }
265}
266
267impl Drop for FetchLockEntryCleanup {
268 fn drop(&mut self) {
269 let mut locks = match FETCH_LOCKS.lock() {
270 Ok(locks) => locks,
271 Err(_) => return, };
273
274 let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
275 Some(current) => {
276 Arc::ptr_eq(¤t, &self.key_lock) && Arc::strong_count(¤t) == 2
280 }
281 None => false,
282 };
283 if should_remove {
284 locks.remove(&self.pubkey);
285 }
286 }
287}
288
289pub fn normalize_relay_url(s: &str) -> String {
297 s.trim_end_matches('/').to_ascii_lowercase()
298}
299
300async fn inbox_query_targets(client: &Client) -> Vec<RelayUrl> {
305 let discovery: HashSet<String> = crate::state::discovery_relay_iter()
306 .map(normalize_relay_url)
307 .collect();
308 client
309 .pool()
310 .all_relays()
311 .await
312 .iter()
313 .filter(|(url, relay)| {
314 relay.flags().has_read() || discovery.contains(&normalize_relay_url(url.as_str()))
315 })
316 .map(|(url, _)| url.clone())
317 .collect()
318}
319
320struct FetchResult {
322 relays: Vec<String>,
323 fetch_ok: bool,
325}
326
327async fn fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> FetchResult {
331 let filter = Filter::new()
332 .author(*pubkey)
333 .kind(Kind::Custom(10050))
334 .limit(1);
335
336 let targets = inbox_query_targets(client).await;
337 let fetched = if targets.is_empty() {
338 client
339 .fetch_events(filter, std::time::Duration::from_secs(5))
340 .await
341 } else {
342 client
343 .fetch_events_from(targets, filter, std::time::Duration::from_secs(5))
344 .await
345 };
346 let events = match fetched {
347 Ok(events) => events,
348 Err(e) => {
349 eprintln!("[InboxRelays] Failed to fetch 10050 for {}: {}", pubkey, e);
350 return FetchResult { relays: Vec::new(), fetch_ok: false };
351 }
352 };
353
354 let event = match events.into_iter().max_by_key(|e| e.created_at) {
357 Some(e) => e,
358 None => return FetchResult { relays: Vec::new(), fetch_ok: true },
359 };
360
361 FetchResult { relays: parse_relay_tags(&event.tags), fetch_ok: true }
362}
363
364fn parse_relay_tags(tags: &Tags) -> Vec<String> {
367 tags.iter()
368 .filter_map(|tag| {
369 let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
370 if values.len() >= 2 && values[0] == "relay" {
371 Some(values[1].to_string())
372 } else {
373 None
374 }
375 })
376 .collect()
377}
378
379async fn get_or_fetch_with_lock<F, Fut>(pubkey: &PublicKey, fetch_fn: F) -> Vec<String>
384where
385 F: FnOnce() -> Fut,
386 Fut: std::future::Future<Output = FetchResult>,
387{
388 {
390 let cache = INBOX_RELAY_CACHE.lock().unwrap();
391 if let Some(entry) = cache.get(pubkey) {
392 let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
393 if entry.fetched_at.elapsed().as_secs() < ttl {
394 return entry.relays.clone();
395 }
396 }
397 }
398
399 let cleanup_guard = {
402 let mut locks = FETCH_LOCKS.lock().unwrap();
403
404 if PRUNE_COUNTER.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL == 0 {
408 locks.retain(|_, weak| Weak::strong_count(weak) > 0);
409 }
410
411 let weak = locks.entry(*pubkey).or_insert_with(|| Weak::new());
412 let key_lock = match weak.upgrade() {
415 Some(arc) => arc,
416 None => {
417 let new_arc = Arc::new(tokio::sync::Mutex::new(()));
418 *weak = Arc::downgrade(&new_arc);
419 new_arc
420 }
421 };
422 FetchLockEntryCleanup::new(*pubkey, key_lock)
424 };
425 let relays = {
426 let _guard = cleanup_guard.key_lock.lock().await;
427
428 let cached_relays = {
430 let cache = INBOX_RELAY_CACHE.lock().unwrap();
431 if let Some(entry) = cache.get(pubkey) {
432 let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
433 if entry.fetched_at.elapsed().as_secs() < ttl {
434 Some(entry.relays.clone())
435 } else {
436 None
437 }
438 } else {
439 None
440 }
441 };
442
443 match cached_relays {
444 Some(relays) => relays,
445 None => {
446 let result = fetch_fn().await;
448
449 {
451 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
452 cache.insert(
453 *pubkey,
454 CachedRelays {
455 relays: result.relays.clone(),
456 fetched_at: Instant::now(),
457 fetch_ok: result.fetch_ok,
458 },
459 );
460 }
461
462 result.relays
463 }
464 }
465 }; drop(cleanup_guard);
470 relays
471}
472
473async fn get_or_fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> Vec<String> {
475 get_or_fetch_with_lock(pubkey, || fetch_inbox_relays(client, pubkey)).await
476}
477
478static TRUSTED_RELAY_URLS: LazyLock<Vec<RelayUrl>> = LazyLock::new(|| {
484 crate::state::TRUSTED_RELAYS
485 .iter()
486 .filter_map(|s| RelayUrl::parse(s).ok())
487 .collect()
488});
489
490pub fn trusted_relay_urls() -> Vec<RelayUrl> {
492 TRUSTED_RELAY_URLS.clone()
493}
494
495pub async fn send_event_first_ok(
505 client: &Client,
506 urls: Vec<RelayUrl>,
507 event: &Event,
508) -> Result<Output<EventId>, nostr_sdk::client::Error> {
509 let pool = client.pool();
510 let relays = pool.relays().await;
511 let event_id = event.id;
512
513 let mut resolved: Vec<(RelayUrl, Relay)> = Vec::new();
515 for url in urls {
516 if let Some(relay) = relays.get(&url) {
517 resolved.push((url, relay.clone()));
518 }
519 }
520
521 if resolved.is_empty() {
522 return client.send_event(event).await;
523 }
524
525 let handles = spawn_tracked_publish(resolved, event.clone());
529
530 let mut output = Output {
532 val: event_id,
533 success: std::collections::HashSet::new(),
534 failed: HashMap::new(),
535 };
536
537 let mut remaining = handles;
538 while !remaining.is_empty() {
539 let (result, _index, rest) = futures_util::future::select_all(remaining).await;
540 remaining = rest;
541
542 if let Ok((url, relay_result)) = result {
543 match relay_result {
544 Ok(_) => {
545 output.success.insert(url);
546 drop(remaining);
550 return Ok(output);
551 }
552 Err(e) => {
553 output.failed.insert(url, e);
554 }
555 }
556 }
557 }
558
559 Ok(output)
561}
562
563pub async fn send_event_pool_first_ok(
566 client: &Client,
567 event: &Event,
568) -> Result<Output<EventId>, nostr_sdk::client::Error> {
569 let pool = client.pool();
570 let relays = pool.relays().await;
571 let write_urls: Vec<RelayUrl> = relays
572 .iter()
573 .filter(|(_, r)| r.flags().has_write())
574 .map(|(url, _)| url.clone())
575 .collect();
576 send_event_first_ok(&client, write_urls, event).await
577}
578
579pub fn wrap_with_retained_key(
589 receiver: &PublicKey,
590 seal: &Event,
591 extra_tags: impl IntoIterator<Item = Tag>,
592) -> Result<(Event, SecretKey), String> {
593 use nostr_sdk::nips::nip44;
594 use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
595
596 if seal.kind != Kind::Seal {
597 return Err(format!("expected Seal kind, got {:?}", seal.kind));
598 }
599 let keys = Keys::generate();
600 let secret = keys.secret_key().clone();
601 let content = nip44::encrypt(
602 keys.secret_key(),
603 receiver,
604 seal.as_json(),
605 nip44::Version::default(),
606 )
607 .map_err(|e| format!("nip44 encrypt: {}", e))?;
608 let mut tags: Vec<Tag> = extra_tags.into_iter().collect();
609 tags.push(Tag::public_key(*receiver));
610 let event = EventBuilder::new(Kind::GiftWrap, content)
611 .tags(tags)
612 .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
613 .sign_with_keys(&keys)
614 .map_err(|e| format!("sign wrap: {}", e))?;
615 Ok((event, secret))
616}
617
618pub struct GiftWrapSendOutcome {
622 pub output: Output<EventId>,
623 pub wrap_event_id: EventId,
624 pub wrap_secret: SecretKey,
625 pub targeted_relays: Vec<String>,
628}
629
630pub struct BuiltGiftWrap {
637 pub event: Event,
638 pub secret: SecretKey,
639}
640
641pub async fn build_gift_wrap_retained(
643 client: &Client,
644 recipient: &PublicKey,
645 rumor: UnsignedEvent,
646 extra_tags: impl IntoIterator<Item = Tag>,
647) -> Result<BuiltGiftWrap, String> {
648 let signer = client.signer().await.map_err(|e| e.to_string())?;
649 let seal: Event = EventBuilder::seal(&signer, recipient, rumor)
650 .await
651 .map_err(|e| e.to_string())?
652 .sign(&signer)
653 .await
654 .map_err(|e| e.to_string())?;
655 let (event, secret) = wrap_with_retained_key(recipient, &seal, extra_tags)?;
656 Ok(BuiltGiftWrap { event, secret })
657}
658
659pub struct GiftWrapTargets {
665 pub resolved: Vec<(RelayUrl, Relay)>,
666 pub targeted_relays: Vec<String>,
669 transient_added: Vec<RelayUrl>,
670}
671
672pub async fn send_gift_wrap_retained(
683 client: &Client,
684 recipient: &PublicKey,
685 rumor: UnsignedEvent,
686 extra_tags: impl IntoIterator<Item = Tag>,
687) -> Result<GiftWrapSendOutcome, String> {
688 let built = build_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
689 let targets = resolve_gift_wrap_targets(client, recipient).await;
690 let publish_result = publish_gift_wrap_to_targets(client, &targets, &built.event).await;
691 teardown_gift_wrap_targets(client, &targets).await;
692 Ok(GiftWrapSendOutcome {
693 output: publish_result?,
694 wrap_event_id: built.event.id,
695 wrap_secret: built.secret,
696 targeted_relays: targets.targeted_relays,
697 })
698}
699
700pub async fn resolve_gift_wrap_targets(
705 client: &Client,
706 recipient: &PublicKey,
707) -> GiftWrapTargets {
708 let inbox_strs = get_or_fetch_inbox_relays(client, recipient).await;
709 let targeted_strs: Vec<String> = if !inbox_strs.is_empty() {
710 inbox_strs.clone()
711 } else {
712 let pool = client.pool();
713 let relays = pool.relays().await;
714 relays.iter()
715 .filter(|(_, r)| r.flags().has_write())
716 .map(|(url, _)| url.to_string())
717 .collect()
718 };
719 use normalize_relay_url as normalize_url_for_match;
726 let pool = client.pool();
727 let pool_relays = pool.all_relays().await;
731 let pool_norm: Vec<(String, RelayUrl, Relay)> = pool_relays.iter()
732 .map(|(url, relay)| (
733 normalize_url_for_match(&url.to_string()),
734 url.clone(),
735 relay.clone(),
736 ))
737 .collect();
738 let mut resolved: Vec<(RelayUrl, Relay)> = targeted_strs
739 .iter()
740 .filter_map(|s| {
741 let norm = normalize_url_for_match(s);
742 pool_norm.iter()
743 .find(|(pnorm, _, _)| pnorm == &norm)
744 .map(|(_, url, relay)| (url.clone(), relay.clone()))
745 })
746 .collect();
747
748 let mut transient_added: Vec<RelayUrl> = Vec::new();
754 if !inbox_strs.is_empty() {
755 for s in &targeted_strs {
756 let norm = normalize_url_for_match(s);
757 let in_pool = pool_norm.iter().any(|(p, _, _)| p == &norm);
758 let already_added = transient_added.iter()
759 .any(|u| normalize_url_for_match(&u.to_string()) == norm);
760 if in_pool || already_added { continue; }
761
762 let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
763 if pool.add_relay(s.as_str(), opts).await.is_ok() {
764 if let Ok(relay) = pool.relay(s.as_str()).await {
765 let _ = relay.try_connect(std::time::Duration::from_secs(6)).await;
766 transient_added.push(relay.url().clone());
767 resolved.push((relay.url().clone(), relay));
768 }
769 }
770 }
771 if !transient_added.is_empty() {
772 crate::log_info!(
773 "[InboxRelays] on-demand connected {} inbox relay(s) for {} (transient)",
774 transient_added.len(),
775 recipient,
776 );
777 }
778 }
779
780 if !inbox_strs.is_empty() {
781 println!(
782 "[InboxRelays] Routing gift-wrap to {} inbox relays for {}",
783 resolved.len(),
784 recipient
785 );
786 }
787
788 GiftWrapTargets {
789 resolved,
790 targeted_relays: targeted_strs,
791 transient_added,
792 }
793}
794
795pub async fn reconnect_gift_wrap_targets(targets: &GiftWrapTargets) {
800 let stale: Vec<&Relay> = targets.resolved.iter()
801 .filter(|(_, r)| r.status() != RelayStatus::Connected)
802 .map(|(_, r)| r)
803 .collect();
804 if stale.is_empty() {
805 return;
806 }
807 futures_util::future::join_all(
808 stale.into_iter()
809 .map(|r| r.try_connect(std::time::Duration::from_secs(6)))
810 ).await;
811}
812
813pub async fn publish_gift_wrap_to_targets(
825 client: &Client,
826 targets: &GiftWrapTargets,
827 event: &Event,
828) -> Result<Output<EventId>, String> {
829 if targets.resolved.is_empty() {
832 return client
835 .send_event(event)
836 .await
837 .map_err(|e| e.to_string());
838 }
839
840 let handles = spawn_tracked_publish(targets.resolved.clone(), event.clone());
841
842 let mut output = Output {
847 val: event.id,
848 success: HashSet::new(),
849 failed: HashMap::new(),
850 };
851 let mut remaining = handles;
852 while !remaining.is_empty() {
853 let (result, _idx, rest) = futures_util::future::select_all(remaining).await;
854 remaining = rest;
855 if let Ok((url, relay_result)) = result {
856 match relay_result {
857 Ok(_) => {
858 output.success.insert(url);
859 drop(remaining);
860 break;
861 }
862 Err(e) => {
863 output.failed.insert(url, e.to_string());
864 }
865 }
866 }
867 }
868 Ok(output)
869}
870
871pub async fn teardown_gift_wrap_targets(client: &Client, targets: &GiftWrapTargets) {
876 let pool = client.pool();
877 for url in &targets.transient_added {
878 let _ = pool.remove_relay(url).await;
879 }
880}
881
882pub async fn send_gift_wrap(
895 client: &Client,
896 recipient: &PublicKey,
897 rumor: UnsignedEvent,
898 extra_tags: impl IntoIterator<Item = Tag>,
899) -> Result<Output<EventId>, String> {
900 let outcome = send_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
901 Ok(outcome.output)
902}
903
904const CONTRIBUTED_KEY: &str = "dm_relays_contributed";
914
915const MAX_FOREIGN_RELAYS: usize = 10;
918
919fn load_contributed() -> HashSet<String> {
920 crate::db::get_sql_setting(CONTRIBUTED_KEY.to_string())
921 .ok()
922 .flatten()
923 .and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
924 .map(|v| v.into_iter().map(|s| normalize_relay_url(&s)).collect())
925 .unwrap_or_default()
926}
927
928fn store_contributed(contributed: &[String]) {
929 if let Ok(json) = serde_json::to_string(contributed) {
930 let _ = crate::db::set_sql_setting(CONTRIBUTED_KEY.to_string(), json);
931 }
932}
933
934struct MergePlan {
936 list: Vec<String>,
939 changed: bool,
941 contributed: Vec<String>,
944}
945
946fn merge_inbox_relays(
951 remote: &[String],
952 contributed_before: &HashSet<String>,
953 ours: &[String],
954) -> MergePlan {
955 let mut seen: HashSet<String> = HashSet::new();
956 let mut list: Vec<String> = Vec::new();
957 let mut foreign_norm: HashSet<String> = HashSet::new();
958 let mut dropped_foreign = 0usize;
959
960 for url in remote {
961 let norm = normalize_relay_url(url);
962 if seen.contains(&norm) || contributed_before.contains(&norm) {
963 continue;
964 }
965 if foreign_norm.len() >= MAX_FOREIGN_RELAYS {
966 dropped_foreign += 1;
967 continue;
968 }
969 seen.insert(norm.clone());
970 foreign_norm.insert(norm);
971 list.push(url.clone());
972 }
973 if dropped_foreign > 0 {
974 crate::log_warn!(
975 "[InboxRelays] remote 10050 over the {}-relay foreign cap, dropped {}",
976 MAX_FOREIGN_RELAYS,
977 dropped_foreign
978 );
979 }
980
981 let mut contributed: Vec<String> = Vec::new();
982 for url in ours {
983 let norm = normalize_relay_url(url);
984 if seen.insert(norm.clone()) {
985 list.push(url.clone());
986 }
987 if !foreign_norm.contains(&norm) && !contributed.contains(&norm) {
988 contributed.push(norm);
989 }
990 }
991
992 let remote_set: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
997 let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
998 let has_addition = ours_norm.iter().any(|n| !remote_set.contains(n));
999 let has_removal = contributed_before
1000 .iter()
1001 .any(|n| remote_set.contains(n) && !ours_norm.contains(n));
1002 MergePlan { list, changed: has_addition || has_removal, contributed }
1003}
1004
1005pub async fn fetch_own_inbox_list(client: &Client) -> Result<Option<(Vec<String>, u64)>, String> {
1011 let me = crate::state::my_public_key().ok_or("no active pubkey")?;
1012 let targets = inbox_query_targets(client).await;
1013 if targets.is_empty() {
1014 return Err("no query targets in pool".to_string());
1015 }
1016
1017 let discovery: HashSet<String> = crate::state::discovery_relay_iter()
1024 .map(normalize_relay_url)
1025 .collect();
1026 let deadline = Instant::now() + std::time::Duration::from_secs(8);
1027 loop {
1028 let relays = client.pool().all_relays().await;
1029 let connected: Vec<&RelayUrl> = targets
1030 .iter()
1031 .filter(|url| {
1032 relays
1033 .get(url)
1034 .map(|r| r.status() == RelayStatus::Connected)
1035 .unwrap_or(false)
1036 })
1037 .collect();
1038 let discovery_up = connected
1039 .iter()
1040 .any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
1041 if discovery_up {
1042 break;
1043 }
1044 if Instant::now() >= deadline {
1045 if connected.is_empty() {
1046 return Err("no query target connected".to_string());
1047 }
1048 break;
1049 }
1050 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
1051 }
1052
1053 let filter = Filter::new().author(me).kind(Kind::Custom(10050)).limit(1);
1054 let events = client
1055 .fetch_events_from(targets.clone(), filter, std::time::Duration::from_secs(6))
1056 .await
1057 .map_err(|e| e.to_string())?;
1058 let newest = events
1060 .into_iter()
1061 .max_by(|a, b| a.created_at.cmp(&b.created_at).then(b.id.cmp(&a.id)))
1062 .map(|e| (parse_relay_tags(&e.tags), e.created_at.as_u64()));
1063
1064 if newest.is_none() {
1071 let has_discovery_target = targets
1072 .iter()
1073 .any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
1074 if has_discovery_target {
1075 let relays = client.pool().all_relays().await;
1076 let discovery_answered = targets.iter().any(|url| {
1077 discovery.contains(&normalize_relay_url(url.as_str()))
1078 && relays
1079 .get(url)
1080 .map(|r| r.status() == RelayStatus::Connected)
1081 .unwrap_or(false)
1082 });
1083 if !discovery_answered {
1084 return Err(
1085 "no 10050 found and no Discovery Relay connected; refusing to bootstrap"
1086 .to_string(),
1087 );
1088 }
1089 }
1090 }
1091 Ok(newest)
1092}
1093
1094const LIST_SEEN_TS_KEY: &str = "dm_list_last_ts";
1099
1100fn load_list_seen() -> u64 {
1101 crate::db::get_sql_setting(LIST_SEEN_TS_KEY.to_string())
1102 .ok()
1103 .flatten()
1104 .and_then(|v| v.parse::<u64>().ok())
1105 .unwrap_or(0)
1106}
1107
1108pub fn note_contributed(urls: &[String]) {
1113 if urls.is_empty() {
1114 return;
1115 }
1116 let mut set = load_contributed();
1117 for url in urls {
1118 set.insert(normalize_relay_url(url));
1119 }
1120 let list: Vec<String> = set.into_iter().collect();
1121 store_contributed(&list);
1122}
1123
1124pub fn note_list_seen(ts: u64) {
1126 if ts > load_list_seen() {
1127 let _ = crate::db::set_sql_setting(LIST_SEEN_TS_KEY.to_string(), ts.to_string());
1128 }
1129}
1130
1131#[derive(Debug, Default, PartialEq)]
1134pub struct InboundReconcile {
1135 pub adopt: Vec<String>,
1137 pub revive: Vec<String>,
1139 pub retire: Vec<String>,
1142}
1143
1144pub fn plan_inbound_reconcile(
1148 remote: &[String],
1149 remote_ts: u64,
1150 ours: &[String],
1151 declined: &[String],
1152) -> InboundReconcile {
1153 plan_inbound_reconcile_pure(
1154 remote,
1155 remote_ts,
1156 ours,
1157 declined,
1158 &load_contributed(),
1159 load_list_seen(),
1160 )
1161}
1162
1163fn plan_inbound_reconcile_pure(
1164 remote: &[String],
1165 remote_ts: u64,
1166 ours: &[String],
1167 declined: &[String],
1168 contributed_before: &HashSet<String>,
1169 last_seen_ts: u64,
1170) -> InboundReconcile {
1171 if remote_ts <= last_seen_ts {
1172 return InboundReconcile::default();
1173 }
1174 let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
1175 let declined_norm: HashSet<String> = declined.iter().map(|s| normalize_relay_url(s)).collect();
1176 let remote_norm: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
1177
1178 let mut seen: HashSet<String> = HashSet::new();
1179 let mut adopt: Vec<String> = Vec::new();
1180 let mut revive: Vec<String> = Vec::new();
1181 for url in remote {
1182 let norm = normalize_relay_url(url);
1183 if !seen.insert(norm.clone()) {
1184 continue;
1185 }
1186 if ours_norm.contains(&norm) {
1187 continue;
1188 }
1189 if declined_norm.contains(&norm) {
1190 revive.push(url.clone());
1191 } else if adopt.len() < MAX_FOREIGN_RELAYS
1192 && url.starts_with("wss://")
1193 && url.len() <= 256
1194 {
1195 adopt.push(url.clone());
1196 }
1197 }
1198
1199 let retire: Vec<String> = ours
1200 .iter()
1201 .filter(|url| {
1202 let norm = normalize_relay_url(url);
1203 contributed_before.contains(&norm) && !remote_norm.contains(&norm)
1204 })
1205 .cloned()
1206 .collect();
1207
1208 InboundReconcile { adopt, revive, retire }
1209}
1210
1211static PUBLISH_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1215
1216pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
1222 let remote = fetch_own_inbox_list(client).await?;
1225 publish_inbox_relays_synced(client, remote, None).await
1226}
1227
1228pub async fn publish_inbox_relays_synced(
1235 client: &Client,
1236 remote: Option<(Vec<String>, u64)>,
1237 ours_override: Option<Vec<String>>,
1238) -> Result<(), String> {
1239 let _serial = PUBLISH_MUTEX.lock().await;
1240 let session = crate::state::SessionGuard::capture();
1241
1242 let ours: Vec<String> = match ours_override {
1244 Some(list) => list,
1245 None => client
1246 .pool()
1247 .relays()
1248 .await
1249 .iter()
1250 .filter(|(_, relay)| relay.flags().has_read())
1251 .map(|(url, _)| url.to_string())
1252 .collect(),
1253 };
1254
1255 let remote_found = remote.is_some();
1256 let (remote, remote_ts) = remote.unwrap_or_default();
1257 if remote_found && remote_ts < load_list_seen() {
1262 return Err("stale 10050 fetch (older than last seen), skipping publish".to_string());
1263 }
1264
1265 let plan = merge_inbox_relays(&remote, &load_contributed(), &ours);
1266
1267 if !session.is_valid() {
1268 return Ok(());
1269 }
1270 store_contributed(&plan.contributed);
1271 if remote_found {
1272 note_list_seen(remote_ts);
1273 }
1274
1275 if remote_found && !plan.changed {
1276 crate::log_info!(
1277 "[InboxRelays] kind 10050 already in sync ({} relay(s)), not publishing",
1278 plan.list.len()
1279 );
1280 return Ok(());
1281 }
1282 if plan.list.is_empty() && !remote_found {
1283 return Ok(());
1285 }
1286
1287 let mut builder = EventBuilder::new(Kind::Custom(10050), "");
1288 for url in &plan.list {
1289 builder = builder.tag(Tag::custom(TagKind::custom("relay"), vec![url.clone()]));
1290 }
1291 let event = client
1292 .sign_event_builder(builder)
1293 .await
1294 .map_err(|e| format!("Failed to sign inbox relays: {}", e))?;
1295
1296 if !session.is_valid() {
1297 return Ok(());
1298 }
1299 let pool_send = client.send_event(&event).await;
1300
1301 let discovery: HashSet<String> = crate::state::DISCOVERY_RELAYS
1305 .iter()
1306 .map(|s| normalize_relay_url(s))
1307 .collect();
1308 let discovery_targets: Vec<RelayUrl> = client
1309 .pool()
1310 .all_relays()
1311 .await
1312 .iter()
1313 .filter(|(url, relay)| {
1314 !relay.flags().has_write() && discovery.contains(&normalize_relay_url(url.as_str()))
1315 })
1316 .map(|(url, _)| url.clone())
1317 .collect();
1318 let mut discovery_ok = false;
1319 if !discovery_targets.is_empty() {
1320 if let Ok(out) = client.send_event_to(discovery_targets, &event).await {
1321 discovery_ok = !out.success.is_empty();
1322 }
1323 }
1324
1325 let pool_ok = matches!(&pool_send, Ok(out) if !out.success.is_empty());
1326 if !pool_ok {
1327 if !discovery_ok {
1328 return Err(match pool_send {
1329 Err(e) => format!("Failed to publish inbox relays: {}", e),
1330 Ok(_) => "Failed to publish inbox relays: no relay accepted it".to_string(),
1331 });
1332 }
1333 crate::log_warn!(
1334 "[InboxRelays] pool publish failed, list delivered via Discovery Relays only"
1335 );
1336 }
1337 if session.is_valid() {
1340 note_list_seen(event.created_at.as_u64().max(remote_ts));
1341 }
1342
1343 println!(
1344 "[InboxRelays] Published kind 10050 with {} relay(s) ({} foreign preserved)",
1345 plan.list.len(),
1346 plan.list.len().saturating_sub(plan.contributed.len())
1347 );
1348 Ok(())
1349}
1350
1351static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
1354
1355#[cfg(test)]
1357static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
1358
1359pub fn republish_inbox_relays_debounced() {
1363 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1364 let session = crate::state::SessionGuard::capture();
1369 tokio::spawn(async move {
1370 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1373 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
1374 return; }
1376 if !session.is_valid() {
1377 return; }
1379 #[cfg(test)]
1380 DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
1381 let client = match nostr_client() {
1382 Some(c) => c,
1383 None => return,
1384 };
1385 if let Err(e) = publish_inbox_relays(&client).await {
1386 eprintln!("[InboxRelays] Failed to republish after config change: {}", e);
1387 }
1388 });
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394
1395 fn strs(v: &[&str]) -> Vec<String> {
1398 v.iter().map(|s| s.to_string()).collect()
1399 }
1400
1401 fn norm_set(v: &[&str]) -> HashSet<String> {
1402 v.iter().map(|s| normalize_relay_url(s)).collect()
1403 }
1404
1405 #[test]
1406 fn merge_preserves_foreign_entries() {
1407 let remote = strs(&["wss://other-app.example", "wss://alice.example"]);
1408 let ours = strs(&["wss://vector.example"]);
1409 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1410 assert!(plan.changed);
1411 assert_eq!(plan.list, strs(&["wss://other-app.example", "wss://alice.example", "wss://vector.example"]));
1412 assert_eq!(plan.contributed, strs(&["wss://vector.example"]));
1413 }
1414
1415 #[test]
1416 fn merge_noop_when_remote_covers_ours() {
1417 let remote = strs(&["wss://other-app.example", "wss://vector.example/"]);
1418 let ours = strs(&["wss://vector.example"]);
1419 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1420 assert!(!plan.changed, "trailing-slash variants are the same relay");
1421 assert_eq!(plan.list.len(), 2);
1422 }
1423
1424 #[test]
1425 fn merge_drops_only_our_own_removed_contribution() {
1426 let remote = strs(&["wss://foreign.example", "wss://x.example"]);
1429 let contributed = norm_set(&["wss://x.example"]);
1430 let ours = strs(&["wss://new.example"]);
1431 let plan = merge_inbox_relays(&remote, &contributed, &ours);
1432 assert!(plan.changed);
1433 assert_eq!(plan.list, strs(&["wss://foreign.example", "wss://new.example"]));
1434 }
1435
1436 #[test]
1437 fn merge_never_clears_a_foreign_list() {
1438 let remote = strs(&["wss://foreign.example"]);
1440 let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
1441 assert!(!plan.changed);
1442 assert_eq!(plan.list, remote);
1443 assert!(plan.contributed.is_empty());
1444 }
1445
1446 #[test]
1447 fn merge_contributed_excludes_foreign_overlap() {
1448 let remote = strs(&["wss://shared.example"]);
1454 let ours = strs(&["wss://shared.example", "wss://mine.example"]);
1455 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1456 assert_eq!(plan.contributed, strs(&["wss://mine.example"]));
1457 let next = merge_inbox_relays(
1458 &plan.list,
1459 &plan.contributed.iter().cloned().collect(),
1460 &[],
1461 );
1462 assert!(next.list.contains(&"wss://shared.example".to_string()));
1463 assert!(!next.list.contains(&"wss://mine.example".to_string()));
1464 }
1465
1466 #[test]
1467 fn merge_caps_foreign_bloat_without_publishing() {
1468 let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
1472 let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
1473 assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS);
1474 assert!(!plan.changed, "a trim alone must not drive a publish");
1475 }
1476
1477 #[test]
1478 fn merge_cap_applies_when_own_diff_publishes() {
1479 let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
1480 let ours = strs(&["wss://mine.example"]);
1481 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1482 assert!(plan.changed, "our addition is a real diff");
1483 assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS + 1);
1484 assert!(plan.list.contains(&"wss://mine.example".to_string()));
1485 }
1486
1487 #[test]
1488 fn merge_two_devices_reach_fixpoint() {
1489 let ours_a = strs(&["wss://a1.example", "wss://shared.example"]);
1492 let ours_b = strs(&["wss://b1.example", "wss://shared.example"]);
1493 let mut network = strs(&["wss://foreign.example"]);
1494 let mut contributed_a: HashSet<String> = HashSet::new();
1495 let mut contributed_b: HashSet<String> = HashSet::new();
1496 let mut publishes = 0;
1497 for round in 0..6 {
1498 for device in 0..2 {
1499 let (ours, contributed) = if device == 0 {
1500 (&ours_a, &mut contributed_a)
1501 } else {
1502 (&ours_b, &mut contributed_b)
1503 };
1504 let plan = merge_inbox_relays(&network, contributed, ours);
1505 *contributed = plan.contributed.iter().cloned().collect();
1506 if plan.changed {
1507 publishes += 1;
1508 network = plan.list;
1509 }
1510 if round >= 2 {
1511 assert!(!plan.changed, "no publish after convergence (round {round})");
1512 }
1513 }
1514 }
1515 assert!(publishes <= 2, "one publish per device to converge, got {publishes}");
1516 for url in ["wss://foreign.example", "wss://a1.example", "wss://b1.example", "wss://shared.example"] {
1517 assert!(network.contains(&url.to_string()), "union must hold {url}");
1518 }
1519 }
1520
1521 #[test]
1522 fn merge_first_run_publishes_ours() {
1523 let ours = strs(&["wss://a.example", "wss://b.example"]);
1524 let plan = merge_inbox_relays(&[], &HashSet::new(), &ours);
1525 assert!(plan.changed);
1526 assert_eq!(plan.list, ours);
1527 assert_eq!(plan.contributed, ours);
1528 }
1529
1530 #[test]
1533 fn reconcile_stale_remote_is_a_no_op() {
1534 let remote = strs(&["wss://foreign.example"]);
1535 let plan = plan_inbound_reconcile_pure(&remote, 100, &[], &[], &HashSet::new(), 100);
1536 assert_eq!(plan, InboundReconcile::default(), "ts <= last_seen must not act");
1537 }
1538
1539 #[test]
1540 fn reconcile_adopts_unknown_entries_capped_and_wss_only() {
1541 let mut remote: Vec<String> = (0..12).map(|i| format!("wss://r{}.example", i)).collect();
1542 remote.push("ws://plaintext.example".to_string());
1543 remote.push("http://nope.example".to_string());
1544 let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &[], &HashSet::new(), 100);
1545 assert_eq!(plan.adopt.len(), MAX_FOREIGN_RELAYS);
1546 assert!(plan.adopt.iter().all(|u| u.starts_with("wss://")));
1547 assert!(plan.revive.is_empty() && plan.retire.is_empty());
1548 }
1549
1550 #[test]
1551 fn reconcile_revives_locally_disabled_entry() {
1552 let remote = strs(&["wss://back.example"]);
1553 let declined = strs(&["wss://back.example/"]);
1554 let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &declined, &HashSet::new(), 100);
1555 assert_eq!(plan.revive, strs(&["wss://back.example"]));
1556 assert!(plan.adopt.is_empty());
1557 }
1558
1559 #[test]
1560 fn reconcile_retires_contributed_entry_dropped_by_newer_remote() {
1561 let remote = strs(&["wss://keep.example"]);
1562 let ours = strs(&["wss://keep.example", "wss://gone.example"]);
1563 let contributed = norm_set(&["wss://keep.example", "wss://gone.example"]);
1564 let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
1565 assert_eq!(plan.retire, strs(&["wss://gone.example"]));
1566 }
1567
1568 #[test]
1569 fn reconcile_never_retires_unpublished_local_addition() {
1570 let remote = strs(&["wss://old.example"]);
1573 let ours = strs(&["wss://old.example", "wss://just-added.example"]);
1574 let contributed = norm_set(&["wss://old.example"]);
1575 let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
1576 assert!(plan.retire.is_empty());
1577 }
1578
1579 #[test]
1580 fn reconcile_two_devices_propagates_default_disable() {
1581 #[derive(Clone)]
1585 struct Device {
1586 ours: Vec<String>,
1587 declined: Vec<String>,
1588 contributed: HashSet<String>,
1589 last_seen: u64,
1590 }
1591 impl Device {
1592 fn new(defaults: &[&str]) -> Self {
1593 Device {
1594 ours: strs(defaults),
1595 declined: Vec::new(),
1596 contributed: HashSet::new(),
1597 last_seen: 0,
1598 }
1599 }
1600 fn sync(&mut self, network: &mut (Vec<String>, u64)) -> bool {
1602 let (remote, ts) = network.clone();
1603 for u in &self.ours {
1604 if remote.iter().any(|r| normalize_relay_url(r) == normalize_relay_url(u)) {
1605 self.contributed.insert(normalize_relay_url(u));
1606 }
1607 }
1608 let plan = plan_inbound_reconcile_pure(
1609 &remote, ts, &self.ours, &self.declined, &self.contributed, self.last_seen,
1610 );
1611 for u in &plan.retire {
1612 self.ours.retain(|o| o != u);
1613 self.declined.push(u.clone());
1614 }
1615 for u in &plan.revive {
1616 self.declined.retain(|d| normalize_relay_url(d) != normalize_relay_url(u));
1617 self.ours.push(u.clone());
1618 self.contributed.insert(normalize_relay_url(u));
1619 }
1620 for u in &plan.adopt {
1621 self.ours.push(u.clone());
1622 self.contributed.insert(normalize_relay_url(u));
1623 }
1624 self.last_seen = self.last_seen.max(ts);
1625 let m = merge_inbox_relays(&remote, &self.contributed, &self.ours);
1626 self.contributed = m.contributed.iter().cloned().collect();
1627 if m.changed {
1628 network.1 += 1;
1629 network.0 = m.list;
1630 self.last_seen = network.1;
1631 }
1632 m.changed
1633 }
1634 }
1635
1636 const DEFAULTS: &[&str] = &["wss://d1.example", "wss://d2.example"];
1637 let mut network: (Vec<String>, u64) = (Vec::new(), 0);
1638 let mut a = Device::new(DEFAULTS);
1639 let mut b = Device::new(DEFAULTS);
1640
1641 assert!(a.sync(&mut network), "first device bootstraps the list");
1642 assert!(!b.sync(&mut network), "second device is already in sync");
1643
1644 b.ours.retain(|u| u != "wss://d2.example");
1646 b.declined.push("wss://d2.example".to_string());
1647 assert!(b.sync(&mut network), "disable must publish");
1648 assert!(!network.0.contains(&"wss://d2.example".to_string()));
1649
1650 assert!(!a.sync(&mut network), "A must adopt the removal, not republish d2");
1652 assert!(a.declined.contains(&"wss://d2.example".to_string()));
1653 assert!(!network.0.contains(&"wss://d2.example".to_string()), "no resurrection");
1654
1655 b.declined.retain(|u| u != "wss://d2.example");
1657 b.ours.push("wss://d2.example".to_string());
1658 assert!(b.sync(&mut network), "re-enable must publish");
1659 assert!(!a.sync(&mut network), "revive is inbound-only, no republish");
1660 assert!(a.ours.contains(&"wss://d2.example".to_string()), "A revives d2");
1661
1662 for _ in 0..3 {
1664 assert!(!a.sync(&mut network));
1665 assert!(!b.sync(&mut network));
1666 }
1667 }
1668
1669 #[test]
1672 fn parse_relay_tags_extracts_urls() {
1673 let tags = Tags::from_list(vec![
1674 Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
1675 Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
1676 ]);
1677 let result = parse_relay_tags(&tags);
1678 assert_eq!(result, vec![
1679 "wss://relay.example.com".to_string(),
1680 "wss://other.example.com".to_string(),
1681 ]);
1682 }
1683
1684 #[test]
1685 fn parse_relay_tags_ignores_non_relay_tags() {
1686 let tags = Tags::from_list(vec![
1687 Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
1688 Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
1689 Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
1690 ]);
1691 let result = parse_relay_tags(&tags);
1692 assert_eq!(result, vec!["wss://good.example.com".to_string()]);
1693 }
1694
1695 #[test]
1696 fn parse_relay_tags_empty() {
1697 let tags = Tags::new();
1698 let result = parse_relay_tags(&tags);
1699 assert!(result.is_empty());
1700 }
1701
1702 #[test]
1703 fn parse_relay_tags_ignores_relay_tag_without_value() {
1704 let tags = Tags::from_list(vec![
1706 Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
1707 ]);
1708 let result = parse_relay_tags(&tags);
1709 assert!(result.is_empty());
1710 }
1711
1712 fn test_pubkey() -> PublicKey {
1715 let keys = Keys::generate();
1716 keys.public_key()
1717 }
1718
1719 static TEST_GLOBALS_LOCK: LazyLock<tokio::sync::Mutex<()>> =
1721 LazyLock::new(|| tokio::sync::Mutex::new(()));
1722
1723 #[test]
1724 fn cache_stores_and_retrieves() {
1725 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1726 let pk = test_pubkey();
1727 let relays = vec!["wss://a.example.com".to_string()];
1728
1729 {
1730 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1731 cache.insert(pk, CachedRelays {
1732 relays: relays.clone(),
1733 fetched_at: Instant::now(),
1734 fetch_ok: true,
1735 });
1736 }
1737
1738 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1739 let entry = cache.get(&pk).unwrap();
1740 assert_eq!(entry.relays, relays);
1741 assert!(entry.fetch_ok);
1742 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1743 }
1744
1745 #[test]
1746 fn cache_expires_after_ttl() {
1747 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1748 let pk = test_pubkey();
1749
1750 {
1751 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1752 cache.insert(pk, CachedRelays {
1753 relays: vec!["wss://stale.example.com".to_string()],
1754 fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
1755 fetch_ok: true,
1756 });
1757 }
1758
1759 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1760 let entry = cache.get(&pk).unwrap();
1761 assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
1762 }
1763
1764 #[test]
1765 fn cache_stores_empty_results() {
1766 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1767 let pk = test_pubkey();
1768
1769 {
1770 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1771 cache.insert(pk, CachedRelays {
1772 relays: vec![],
1773 fetched_at: Instant::now(),
1774 fetch_ok: true,
1775 });
1776 }
1777
1778 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1779 let entry = cache.get(&pk).unwrap();
1780 assert!(entry.relays.is_empty());
1781 assert!(entry.fetch_ok);
1782 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1783 }
1784
1785 #[test]
1786 fn cache_error_uses_short_ttl() {
1787 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1788 let pk = test_pubkey();
1789
1790 {
1791 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1792 cache.insert(pk, CachedRelays {
1793 relays: vec![],
1794 fetched_at: Instant::now() - std::time::Duration::from_secs(120),
1796 fetch_ok: false,
1797 });
1798 }
1799
1800 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1801 let entry = cache.get(&pk).unwrap();
1802 assert!(!entry.fetch_ok);
1803 assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
1805 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1807 }
1808
1809 #[tokio::test]
1812 async fn concurrent_fetches_for_same_pubkey_serialize() {
1813 let _guard = TEST_GLOBALS_LOCK.lock().await;
1814 let pk = test_pubkey();
1815
1816 {
1818 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1819 cache.remove(&pk);
1820 }
1821
1822 let fetch_counter = Arc::new(AtomicU64::new(0));
1823
1824 let mut handles = vec![];
1827 for _ in 0..10 {
1828 let counter = fetch_counter.clone();
1829 let handle = tokio::spawn(async move {
1830 get_or_fetch_with_lock(&pk, || async {
1831 counter.fetch_add(1, Ordering::SeqCst);
1832 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1834 FetchResult {
1835 relays: vec!["wss://test.example.com".to_string()],
1836 fetch_ok: true,
1837 }
1838 })
1839 .await
1840 });
1841 handles.push(handle);
1842 }
1843
1844 let results = futures_util::future::join_all(handles).await;
1846
1847 for result in &results {
1849 assert!(result.is_ok());
1850 let relays = result.as_ref().unwrap();
1851 assert_eq!(relays, &vec!["wss://test.example.com".to_string()]);
1852 }
1853
1854 assert_eq!(
1856 fetch_counter.load(Ordering::SeqCst),
1857 1,
1858 "Expected exactly 1 fetch for 10 concurrent requests to same pubkey"
1859 );
1860
1861 let locks_after = {
1862 let locks = FETCH_LOCKS.lock().unwrap();
1863 locks.len()
1864 };
1865 assert_eq!(locks_after, 0, "Lock entry should be removed after all waiters complete");
1866 }
1867
1868 #[tokio::test]
1869 async fn fetch_locks_do_not_accumulate_after_calls_complete() {
1870 let _guard = TEST_GLOBALS_LOCK.lock().await;
1871
1872 let pk1 = test_pubkey();
1876 let pk2 = test_pubkey();
1877 let pk3 = test_pubkey();
1878
1879 {
1881 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1882 cache.clear();
1883 }
1884 {
1885 let mut locks = FETCH_LOCKS.lock().unwrap();
1886 locks.clear();
1887 }
1888
1889 get_or_fetch_with_lock(&pk1, || async {
1891 FetchResult {
1892 relays: vec!["wss://relay1.example.com".to_string()],
1893 fetch_ok: true,
1894 }
1895 })
1896 .await;
1897
1898 let locks_after_pk1 = {
1901 let locks = FETCH_LOCKS.lock().unwrap();
1902 locks.len()
1903 };
1904 assert_eq!(locks_after_pk1, 0, "No lock entries should remain after pk1 call");
1905
1906 get_or_fetch_with_lock(&pk2, || async {
1908 FetchResult {
1909 relays: vec!["wss://relay2.example.com".to_string()],
1910 fetch_ok: true,
1911 }
1912 })
1913 .await;
1914
1915 let locks_after_pk2 = {
1916 let locks = FETCH_LOCKS.lock().unwrap();
1917 locks.len()
1918 };
1919 assert_eq!(locks_after_pk2, 0, "No lock entries should remain after pk2 call");
1920
1921 get_or_fetch_with_lock(&pk3, || async {
1923 FetchResult {
1924 relays: vec!["wss://relay3.example.com".to_string()],
1925 fetch_ok: true,
1926 }
1927 })
1928 .await;
1929
1930 let locks_after_pk3 = {
1931 let locks = FETCH_LOCKS.lock().unwrap();
1932 locks.len()
1933 };
1934 assert_eq!(locks_after_pk3, 0, "No lock entries should remain after pk3 call");
1935 }
1936
1937 #[tokio::test]
1938 async fn cancelled_fetch_cleans_up_lock_entry() {
1939 let _guard = TEST_GLOBALS_LOCK.lock().await;
1940 let pk = test_pubkey();
1941
1942 {
1943 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1944 cache.clear();
1945 }
1946 {
1947 let mut locks = FETCH_LOCKS.lock().unwrap();
1948 locks.clear();
1949 }
1950
1951 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1952 let task_pk = pk;
1953 let handle = tokio::spawn(async move {
1954 get_or_fetch_with_lock(&task_pk, || async move {
1955 let _ = started_tx.send(());
1956 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
1957 FetchResult { relays: Vec::new(), fetch_ok: false }
1958 })
1959 .await
1960 });
1961
1962 started_rx.await.expect("fetch closure should start before abort");
1963 handle.abort();
1964 let _ = handle.await;
1965 tokio::task::yield_now().await;
1966
1967 let locks_after = {
1968 let locks = FETCH_LOCKS.lock().unwrap();
1969 locks.len()
1970 };
1971 assert_eq!(
1972 locks_after, 0,
1973 "Lock entry should be removed even if fetch task is cancelled"
1974 );
1975 }
1976
1977 #[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}