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 if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
78 self.notify.notify_waiters();
79 PUBLISH_TRACKERS.lock().unwrap().remove(&self.event_id);
80 }
81 }
82
83 pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
89 loop {
90 let notified = self.notify.notified();
94 tokio::pin!(notified);
95 notified.as_mut().enable();
96
97 let (next, done) = {
98 let successes = self.successes.lock().unwrap();
99 let next = successes.get(*cursor).cloned();
100 let done = self.in_flight.load(Ordering::SeqCst) == 0
101 && *cursor >= successes.len();
102 (next, done)
103 };
104
105 if let Some(url) = next {
106 *cursor += 1;
107 return Some(url);
108 }
109 if done {
110 return None;
111 }
112
113 notified.await;
114 }
115 }
116}
117
118static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
121 LazyLock::new(|| Mutex::new(HashMap::new()));
122
123pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
129 PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
130}
131
132pub fn spawn_tracked_publish(
143 resolved: Vec<(RelayUrl, Relay)>,
144 event: Event,
145) -> Vec<tokio::task::JoinHandle<(RelayUrl, Result<EventId, String>)>> {
146 let event_id = event.id;
147 if resolved.is_empty() {
150 return Vec::new();
151 }
152 let tracker = EventPublishTracker::new(event_id, resolved.len());
153 PUBLISH_TRACKERS.lock().unwrap().insert(event_id, tracker.clone());
154
155 let mut handles = Vec::with_capacity(resolved.len());
156 for (url, relay) in resolved {
157 let event = event.clone();
158 let tracker = tracker.clone();
159 handles.push(tokio::spawn(async move {
160 let result = relay
161 .send_event(&event)
162 .await
163 .map_err(|e| e.to_string());
164 if result.is_ok() {
165 tracker.note_success(url.clone());
166 }
167 tracker.note_settled();
168 (url, result)
169 }));
170 }
171 handles
172}
173
174const CACHE_TTL_SECS: u64 = 3600; const CACHE_TTL_ERROR_SECS: u64 = 60; struct CachedRelays {
185 relays: Vec<String>,
186 fetched_at: Instant,
187 fetch_ok: bool,
190}
191
192static INBOX_RELAY_CACHE: LazyLock<Mutex<HashMap<PublicKey, CachedRelays>>> =
193 LazyLock::new(|| Mutex::new(HashMap::new()));
194
195pub fn clear_inbox_relay_cache() {
201 if let Ok(mut cache) = INBOX_RELAY_CACHE.lock() {
202 cache.clear();
203 }
204}
205
206static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
213 LazyLock::new(|| Mutex::new(HashMap::new()));
214
215static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
219
220#[cfg(not(test))]
223const PRUNE_INTERVAL: u64 = 100;
224
225#[cfg(test)]
228const PRUNE_INTERVAL: u64 = 1;
229
230struct FetchLockEntryCleanup {
233 pubkey: PublicKey,
234 key_lock: Arc<tokio::sync::Mutex<()>>,
235}
236
237impl FetchLockEntryCleanup {
238 fn new(pubkey: PublicKey, key_lock: Arc<tokio::sync::Mutex<()>>) -> Self {
239 Self { pubkey, key_lock }
240 }
241}
242
243impl Drop for FetchLockEntryCleanup {
244 fn drop(&mut self) {
245 let mut locks = match FETCH_LOCKS.lock() {
246 Ok(locks) => locks,
247 Err(_) => return, };
249
250 let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
251 Some(current) => {
252 Arc::ptr_eq(¤t, &self.key_lock) && Arc::strong_count(¤t) == 2
256 }
257 None => false,
258 };
259 if should_remove {
260 locks.remove(&self.pubkey);
261 }
262 }
263}
264
265pub fn normalize_relay_url(s: &str) -> String {
273 s.trim_end_matches('/').to_ascii_lowercase()
274}
275
276async fn inbox_query_targets(client: &Client) -> Vec<RelayUrl> {
281 let discovery: HashSet<String> = crate::state::discovery_relay_iter()
282 .map(normalize_relay_url)
283 .collect();
284 client
285 .pool()
286 .all_relays()
287 .await
288 .iter()
289 .filter(|(url, relay)| {
290 relay.flags().has_read() || discovery.contains(&normalize_relay_url(url.as_str()))
291 })
292 .map(|(url, _)| url.clone())
293 .collect()
294}
295
296struct FetchResult {
298 relays: Vec<String>,
299 fetch_ok: bool,
301}
302
303async fn fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> FetchResult {
307 let filter = Filter::new()
308 .author(*pubkey)
309 .kind(Kind::Custom(10050))
310 .limit(1);
311
312 let targets = inbox_query_targets(client).await;
313 let fetched = if targets.is_empty() {
314 client
315 .fetch_events(filter, std::time::Duration::from_secs(5))
316 .await
317 } else {
318 client
319 .fetch_events_from(targets, filter, std::time::Duration::from_secs(5))
320 .await
321 };
322 let events = match fetched {
323 Ok(events) => events,
324 Err(e) => {
325 eprintln!("[InboxRelays] Failed to fetch 10050 for {}: {}", pubkey, e);
326 return FetchResult { relays: Vec::new(), fetch_ok: false };
327 }
328 };
329
330 let event = match events.into_iter().max_by_key(|e| e.created_at) {
333 Some(e) => e,
334 None => return FetchResult { relays: Vec::new(), fetch_ok: true },
335 };
336
337 FetchResult { relays: parse_relay_tags(&event.tags), fetch_ok: true }
338}
339
340fn parse_relay_tags(tags: &Tags) -> Vec<String> {
343 tags.iter()
344 .filter_map(|tag| {
345 let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
346 if values.len() >= 2 && values[0] == "relay" {
347 Some(values[1].to_string())
348 } else {
349 None
350 }
351 })
352 .collect()
353}
354
355async fn get_or_fetch_with_lock<F, Fut>(pubkey: &PublicKey, fetch_fn: F) -> Vec<String>
360where
361 F: FnOnce() -> Fut,
362 Fut: std::future::Future<Output = FetchResult>,
363{
364 {
366 let cache = INBOX_RELAY_CACHE.lock().unwrap();
367 if let Some(entry) = cache.get(pubkey) {
368 let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
369 if entry.fetched_at.elapsed().as_secs() < ttl {
370 return entry.relays.clone();
371 }
372 }
373 }
374
375 let cleanup_guard = {
378 let mut locks = FETCH_LOCKS.lock().unwrap();
379
380 if PRUNE_COUNTER.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL == 0 {
384 locks.retain(|_, weak| Weak::strong_count(weak) > 0);
385 }
386
387 let weak = locks.entry(*pubkey).or_insert_with(|| Weak::new());
388 let key_lock = match weak.upgrade() {
391 Some(arc) => arc,
392 None => {
393 let new_arc = Arc::new(tokio::sync::Mutex::new(()));
394 *weak = Arc::downgrade(&new_arc);
395 new_arc
396 }
397 };
398 FetchLockEntryCleanup::new(*pubkey, key_lock)
400 };
401 let relays = {
402 let _guard = cleanup_guard.key_lock.lock().await;
403
404 let cached_relays = {
406 let cache = INBOX_RELAY_CACHE.lock().unwrap();
407 if let Some(entry) = cache.get(pubkey) {
408 let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
409 if entry.fetched_at.elapsed().as_secs() < ttl {
410 Some(entry.relays.clone())
411 } else {
412 None
413 }
414 } else {
415 None
416 }
417 };
418
419 match cached_relays {
420 Some(relays) => relays,
421 None => {
422 let result = fetch_fn().await;
424
425 {
427 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
428 cache.insert(
429 *pubkey,
430 CachedRelays {
431 relays: result.relays.clone(),
432 fetched_at: Instant::now(),
433 fetch_ok: result.fetch_ok,
434 },
435 );
436 }
437
438 result.relays
439 }
440 }
441 }; drop(cleanup_guard);
446 relays
447}
448
449async fn get_or_fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> Vec<String> {
451 get_or_fetch_with_lock(pubkey, || fetch_inbox_relays(client, pubkey)).await
452}
453
454static TRUSTED_RELAY_URLS: LazyLock<Vec<RelayUrl>> = LazyLock::new(|| {
460 crate::state::TRUSTED_RELAYS
461 .iter()
462 .filter_map(|s| RelayUrl::parse(s).ok())
463 .collect()
464});
465
466pub fn trusted_relay_urls() -> Vec<RelayUrl> {
468 TRUSTED_RELAY_URLS.clone()
469}
470
471pub async fn send_event_first_ok(
481 client: &Client,
482 urls: Vec<RelayUrl>,
483 event: &Event,
484) -> Result<Output<EventId>, nostr_sdk::client::Error> {
485 let pool = client.pool();
486 let relays = pool.relays().await;
487 let event_id = event.id;
488
489 let mut resolved: Vec<(RelayUrl, Relay)> = Vec::new();
491 for url in urls {
492 if let Some(relay) = relays.get(&url) {
493 resolved.push((url, relay.clone()));
494 }
495 }
496
497 if resolved.is_empty() {
498 return client.send_event(event).await;
499 }
500
501 let handles = spawn_tracked_publish(resolved, event.clone());
505
506 let mut output = Output {
508 val: event_id,
509 success: std::collections::HashSet::new(),
510 failed: HashMap::new(),
511 };
512
513 let mut remaining = handles;
514 while !remaining.is_empty() {
515 let (result, _index, rest) = futures_util::future::select_all(remaining).await;
516 remaining = rest;
517
518 if let Ok((url, relay_result)) = result {
519 match relay_result {
520 Ok(_) => {
521 output.success.insert(url);
522 drop(remaining);
526 return Ok(output);
527 }
528 Err(e) => {
529 output.failed.insert(url, e);
530 }
531 }
532 }
533 }
534
535 Ok(output)
537}
538
539pub async fn send_event_pool_first_ok(
542 client: &Client,
543 event: &Event,
544) -> Result<Output<EventId>, nostr_sdk::client::Error> {
545 let pool = client.pool();
546 let relays = pool.relays().await;
547 let write_urls: Vec<RelayUrl> = relays
548 .iter()
549 .filter(|(_, r)| r.flags().has_write())
550 .map(|(url, _)| url.clone())
551 .collect();
552 send_event_first_ok(&client, write_urls, event).await
553}
554
555pub fn wrap_with_retained_key(
565 receiver: &PublicKey,
566 seal: &Event,
567 extra_tags: impl IntoIterator<Item = Tag>,
568) -> Result<(Event, SecretKey), String> {
569 use nostr_sdk::nips::nip44;
570 use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
571
572 if seal.kind != Kind::Seal {
573 return Err(format!("expected Seal kind, got {:?}", seal.kind));
574 }
575 let keys = Keys::generate();
576 let secret = keys.secret_key().clone();
577 let content = nip44::encrypt(
578 keys.secret_key(),
579 receiver,
580 seal.as_json(),
581 nip44::Version::default(),
582 )
583 .map_err(|e| format!("nip44 encrypt: {}", e))?;
584 let mut tags: Vec<Tag> = extra_tags.into_iter().collect();
585 tags.push(Tag::public_key(*receiver));
586 let event = EventBuilder::new(Kind::GiftWrap, content)
587 .tags(tags)
588 .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
589 .sign_with_keys(&keys)
590 .map_err(|e| format!("sign wrap: {}", e))?;
591 Ok((event, secret))
592}
593
594pub struct GiftWrapSendOutcome {
598 pub output: Output<EventId>,
599 pub wrap_event_id: EventId,
600 pub wrap_secret: SecretKey,
601 pub targeted_relays: Vec<String>,
604}
605
606pub async fn send_gift_wrap_retained(
617 client: &Client,
618 recipient: &PublicKey,
619 rumor: UnsignedEvent,
620 extra_tags: impl IntoIterator<Item = Tag>,
621) -> Result<GiftWrapSendOutcome, String> {
622 let signer = client.signer().await.map_err(|e| e.to_string())?;
623 let seal: Event = EventBuilder::seal(&signer, recipient, rumor)
624 .await
625 .map_err(|e| e.to_string())?
626 .sign(&signer)
627 .await
628 .map_err(|e| e.to_string())?;
629 let (event, secret) = wrap_with_retained_key(recipient, &seal, extra_tags)?;
630 let wrap_event_id = event.id;
631
632 let inbox_strs = get_or_fetch_inbox_relays(client, recipient).await;
635 let targeted_strs: Vec<String> = if !inbox_strs.is_empty() {
636 inbox_strs.clone()
637 } else {
638 let pool = client.pool();
639 let relays = pool.relays().await;
640 relays.iter()
641 .filter(|(_, r)| r.flags().has_write())
642 .map(|(url, _)| url.to_string())
643 .collect()
644 };
645 use normalize_relay_url as normalize_url_for_match;
652 let pool = client.pool();
653 let pool_relays = pool.all_relays().await;
657 let pool_norm: Vec<(String, RelayUrl, Relay)> = pool_relays.iter()
658 .map(|(url, relay)| (
659 normalize_url_for_match(&url.to_string()),
660 url.clone(),
661 relay.clone(),
662 ))
663 .collect();
664 let mut resolved: Vec<(RelayUrl, Relay)> = targeted_strs
665 .iter()
666 .filter_map(|s| {
667 let norm = normalize_url_for_match(s);
668 pool_norm.iter()
669 .find(|(pnorm, _, _)| pnorm == &norm)
670 .map(|(_, url, relay)| (url.clone(), relay.clone()))
671 })
672 .collect();
673
674 let mut transient_added: Vec<RelayUrl> = Vec::new();
680 if !inbox_strs.is_empty() {
681 for s in &targeted_strs {
682 let norm = normalize_url_for_match(s);
683 let in_pool = pool_norm.iter().any(|(p, _, _)| p == &norm);
684 let already_added = transient_added.iter()
685 .any(|u| normalize_url_for_match(&u.to_string()) == norm);
686 if in_pool || already_added { continue; }
687
688 let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
689 if pool.add_relay(s.as_str(), opts).await.is_ok() {
690 if let Ok(relay) = pool.relay(s.as_str()).await {
691 let _ = relay.try_connect(std::time::Duration::from_secs(6)).await;
692 transient_added.push(relay.url().clone());
693 resolved.push((relay.url().clone(), relay));
694 }
695 }
696 }
697 if !transient_added.is_empty() {
698 crate::log_info!(
699 "[InboxRelays] on-demand connected {} inbox relay(s) for {} (transient)",
700 transient_added.len(),
701 recipient,
702 );
703 }
704 }
705
706 if resolved.is_empty() {
709 let output = client
712 .send_event(&event)
713 .await
714 .map_err(|e| e.to_string())?;
715 return Ok(GiftWrapSendOutcome {
716 output,
717 wrap_event_id,
718 wrap_secret: secret,
719 targeted_relays: targeted_strs,
720 });
721 }
722
723 if !inbox_strs.is_empty() {
724 println!(
725 "[InboxRelays] Routing gift-wrap to {} inbox relays for {}",
726 resolved.len(),
727 recipient
728 );
729 }
730
731 let handles = spawn_tracked_publish(resolved, event.clone());
739
740 let mut output = Output {
745 val: wrap_event_id,
746 success: HashSet::new(),
747 failed: HashMap::new(),
748 };
749 let mut remaining = handles;
750 while !remaining.is_empty() {
751 let (result, _idx, rest) = futures_util::future::select_all(remaining).await;
752 remaining = rest;
753 if let Ok((url, relay_result)) = result {
754 match relay_result {
755 Ok(_) => {
756 output.success.insert(url);
757 drop(remaining);
758 break;
759 }
760 Err(e) => {
761 output.failed.insert(url, e.to_string());
762 }
763 }
764 }
765 }
766
767 for url in &transient_added {
772 let _ = pool.remove_relay(url).await;
773 }
774
775 Ok(GiftWrapSendOutcome {
776 output,
777 wrap_event_id,
778 wrap_secret: secret,
779 targeted_relays: targeted_strs,
780 })
781}
782
783pub async fn send_gift_wrap(
796 client: &Client,
797 recipient: &PublicKey,
798 rumor: UnsignedEvent,
799 extra_tags: impl IntoIterator<Item = Tag>,
800) -> Result<Output<EventId>, String> {
801 let outcome = send_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
802 Ok(outcome.output)
803}
804
805const CONTRIBUTED_KEY: &str = "dm_relays_contributed";
815
816const MAX_FOREIGN_RELAYS: usize = 10;
819
820fn load_contributed() -> HashSet<String> {
821 crate::db::get_sql_setting(CONTRIBUTED_KEY.to_string())
822 .ok()
823 .flatten()
824 .and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
825 .map(|v| v.into_iter().map(|s| normalize_relay_url(&s)).collect())
826 .unwrap_or_default()
827}
828
829fn store_contributed(contributed: &[String]) {
830 if let Ok(json) = serde_json::to_string(contributed) {
831 let _ = crate::db::set_sql_setting(CONTRIBUTED_KEY.to_string(), json);
832 }
833}
834
835struct MergePlan {
837 list: Vec<String>,
840 changed: bool,
842 contributed: Vec<String>,
845}
846
847fn merge_inbox_relays(
852 remote: &[String],
853 contributed_before: &HashSet<String>,
854 ours: &[String],
855) -> MergePlan {
856 let mut seen: HashSet<String> = HashSet::new();
857 let mut list: Vec<String> = Vec::new();
858 let mut foreign_norm: HashSet<String> = HashSet::new();
859 let mut dropped_foreign = 0usize;
860
861 for url in remote {
862 let norm = normalize_relay_url(url);
863 if seen.contains(&norm) || contributed_before.contains(&norm) {
864 continue;
865 }
866 if foreign_norm.len() >= MAX_FOREIGN_RELAYS {
867 dropped_foreign += 1;
868 continue;
869 }
870 seen.insert(norm.clone());
871 foreign_norm.insert(norm);
872 list.push(url.clone());
873 }
874 if dropped_foreign > 0 {
875 crate::log_warn!(
876 "[InboxRelays] remote 10050 over the {}-relay foreign cap, dropped {}",
877 MAX_FOREIGN_RELAYS,
878 dropped_foreign
879 );
880 }
881
882 let mut contributed: Vec<String> = Vec::new();
883 for url in ours {
884 let norm = normalize_relay_url(url);
885 if seen.insert(norm.clone()) {
886 list.push(url.clone());
887 }
888 if !foreign_norm.contains(&norm) && !contributed.contains(&norm) {
889 contributed.push(norm);
890 }
891 }
892
893 let remote_set: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
898 let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
899 let has_addition = ours_norm.iter().any(|n| !remote_set.contains(n));
900 let has_removal = contributed_before
901 .iter()
902 .any(|n| remote_set.contains(n) && !ours_norm.contains(n));
903 MergePlan { list, changed: has_addition || has_removal, contributed }
904}
905
906pub async fn fetch_own_inbox_list(client: &Client) -> Result<Option<(Vec<String>, u64)>, String> {
912 let me = crate::state::my_public_key().ok_or("no active pubkey")?;
913 let targets = inbox_query_targets(client).await;
914 if targets.is_empty() {
915 return Err("no query targets in pool".to_string());
916 }
917
918 let discovery: HashSet<String> = crate::state::discovery_relay_iter()
925 .map(normalize_relay_url)
926 .collect();
927 let deadline = Instant::now() + std::time::Duration::from_secs(8);
928 loop {
929 let relays = client.pool().all_relays().await;
930 let connected: Vec<&RelayUrl> = targets
931 .iter()
932 .filter(|url| {
933 relays
934 .get(url)
935 .map(|r| r.status() == RelayStatus::Connected)
936 .unwrap_or(false)
937 })
938 .collect();
939 let discovery_up = connected
940 .iter()
941 .any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
942 if discovery_up {
943 break;
944 }
945 if Instant::now() >= deadline {
946 if connected.is_empty() {
947 return Err("no query target connected".to_string());
948 }
949 break;
950 }
951 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
952 }
953
954 let filter = Filter::new().author(me).kind(Kind::Custom(10050)).limit(1);
955 let events = client
956 .fetch_events_from(targets.clone(), filter, std::time::Duration::from_secs(6))
957 .await
958 .map_err(|e| e.to_string())?;
959 let newest = events
961 .into_iter()
962 .max_by(|a, b| a.created_at.cmp(&b.created_at).then(b.id.cmp(&a.id)))
963 .map(|e| (parse_relay_tags(&e.tags), e.created_at.as_u64()));
964
965 if newest.is_none() {
972 let has_discovery_target = targets
973 .iter()
974 .any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
975 if has_discovery_target {
976 let relays = client.pool().all_relays().await;
977 let discovery_answered = targets.iter().any(|url| {
978 discovery.contains(&normalize_relay_url(url.as_str()))
979 && relays
980 .get(url)
981 .map(|r| r.status() == RelayStatus::Connected)
982 .unwrap_or(false)
983 });
984 if !discovery_answered {
985 return Err(
986 "no 10050 found and no Discovery Relay connected; refusing to bootstrap"
987 .to_string(),
988 );
989 }
990 }
991 }
992 Ok(newest)
993}
994
995const LIST_SEEN_TS_KEY: &str = "dm_list_last_ts";
1000
1001fn load_list_seen() -> u64 {
1002 crate::db::get_sql_setting(LIST_SEEN_TS_KEY.to_string())
1003 .ok()
1004 .flatten()
1005 .and_then(|v| v.parse::<u64>().ok())
1006 .unwrap_or(0)
1007}
1008
1009pub fn note_contributed(urls: &[String]) {
1014 if urls.is_empty() {
1015 return;
1016 }
1017 let mut set = load_contributed();
1018 for url in urls {
1019 set.insert(normalize_relay_url(url));
1020 }
1021 let list: Vec<String> = set.into_iter().collect();
1022 store_contributed(&list);
1023}
1024
1025pub fn note_list_seen(ts: u64) {
1027 if ts > load_list_seen() {
1028 let _ = crate::db::set_sql_setting(LIST_SEEN_TS_KEY.to_string(), ts.to_string());
1029 }
1030}
1031
1032#[derive(Debug, Default, PartialEq)]
1035pub struct InboundReconcile {
1036 pub adopt: Vec<String>,
1038 pub revive: Vec<String>,
1040 pub retire: Vec<String>,
1043}
1044
1045pub fn plan_inbound_reconcile(
1049 remote: &[String],
1050 remote_ts: u64,
1051 ours: &[String],
1052 declined: &[String],
1053) -> InboundReconcile {
1054 plan_inbound_reconcile_pure(
1055 remote,
1056 remote_ts,
1057 ours,
1058 declined,
1059 &load_contributed(),
1060 load_list_seen(),
1061 )
1062}
1063
1064fn plan_inbound_reconcile_pure(
1065 remote: &[String],
1066 remote_ts: u64,
1067 ours: &[String],
1068 declined: &[String],
1069 contributed_before: &HashSet<String>,
1070 last_seen_ts: u64,
1071) -> InboundReconcile {
1072 if remote_ts <= last_seen_ts {
1073 return InboundReconcile::default();
1074 }
1075 let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
1076 let declined_norm: HashSet<String> = declined.iter().map(|s| normalize_relay_url(s)).collect();
1077 let remote_norm: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
1078
1079 let mut seen: HashSet<String> = HashSet::new();
1080 let mut adopt: Vec<String> = Vec::new();
1081 let mut revive: Vec<String> = Vec::new();
1082 for url in remote {
1083 let norm = normalize_relay_url(url);
1084 if !seen.insert(norm.clone()) {
1085 continue;
1086 }
1087 if ours_norm.contains(&norm) {
1088 continue;
1089 }
1090 if declined_norm.contains(&norm) {
1091 revive.push(url.clone());
1092 } else if adopt.len() < MAX_FOREIGN_RELAYS
1093 && url.starts_with("wss://")
1094 && url.len() <= 256
1095 {
1096 adopt.push(url.clone());
1097 }
1098 }
1099
1100 let retire: Vec<String> = ours
1101 .iter()
1102 .filter(|url| {
1103 let norm = normalize_relay_url(url);
1104 contributed_before.contains(&norm) && !remote_norm.contains(&norm)
1105 })
1106 .cloned()
1107 .collect();
1108
1109 InboundReconcile { adopt, revive, retire }
1110}
1111
1112static PUBLISH_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1116
1117pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
1123 let remote = fetch_own_inbox_list(client).await?;
1126 publish_inbox_relays_synced(client, remote, None).await
1127}
1128
1129pub async fn publish_inbox_relays_synced(
1136 client: &Client,
1137 remote: Option<(Vec<String>, u64)>,
1138 ours_override: Option<Vec<String>>,
1139) -> Result<(), String> {
1140 let _serial = PUBLISH_MUTEX.lock().await;
1141 let session = crate::state::SessionGuard::capture();
1142
1143 let ours: Vec<String> = match ours_override {
1145 Some(list) => list,
1146 None => client
1147 .pool()
1148 .relays()
1149 .await
1150 .iter()
1151 .filter(|(_, relay)| relay.flags().has_read())
1152 .map(|(url, _)| url.to_string())
1153 .collect(),
1154 };
1155
1156 let remote_found = remote.is_some();
1157 let (remote, remote_ts) = remote.unwrap_or_default();
1158 if remote_found && remote_ts < load_list_seen() {
1163 return Err("stale 10050 fetch (older than last seen), skipping publish".to_string());
1164 }
1165
1166 let plan = merge_inbox_relays(&remote, &load_contributed(), &ours);
1167
1168 if !session.is_valid() {
1169 return Ok(());
1170 }
1171 store_contributed(&plan.contributed);
1172 if remote_found {
1173 note_list_seen(remote_ts);
1174 }
1175
1176 if remote_found && !plan.changed {
1177 crate::log_info!(
1178 "[InboxRelays] kind 10050 already in sync ({} relay(s)), not publishing",
1179 plan.list.len()
1180 );
1181 return Ok(());
1182 }
1183 if plan.list.is_empty() && !remote_found {
1184 return Ok(());
1186 }
1187
1188 let mut builder = EventBuilder::new(Kind::Custom(10050), "");
1189 for url in &plan.list {
1190 builder = builder.tag(Tag::custom(TagKind::custom("relay"), vec![url.clone()]));
1191 }
1192 let event = client
1193 .sign_event_builder(builder)
1194 .await
1195 .map_err(|e| format!("Failed to sign inbox relays: {}", e))?;
1196
1197 if !session.is_valid() {
1198 return Ok(());
1199 }
1200 let pool_send = client.send_event(&event).await;
1201
1202 let discovery: HashSet<String> = crate::state::DISCOVERY_RELAYS
1206 .iter()
1207 .map(|s| normalize_relay_url(s))
1208 .collect();
1209 let discovery_targets: Vec<RelayUrl> = client
1210 .pool()
1211 .all_relays()
1212 .await
1213 .iter()
1214 .filter(|(url, relay)| {
1215 !relay.flags().has_write() && discovery.contains(&normalize_relay_url(url.as_str()))
1216 })
1217 .map(|(url, _)| url.clone())
1218 .collect();
1219 let mut discovery_ok = false;
1220 if !discovery_targets.is_empty() {
1221 if let Ok(out) = client.send_event_to(discovery_targets, &event).await {
1222 discovery_ok = !out.success.is_empty();
1223 }
1224 }
1225
1226 let pool_ok = matches!(&pool_send, Ok(out) if !out.success.is_empty());
1227 if !pool_ok {
1228 if !discovery_ok {
1229 return Err(match pool_send {
1230 Err(e) => format!("Failed to publish inbox relays: {}", e),
1231 Ok(_) => "Failed to publish inbox relays: no relay accepted it".to_string(),
1232 });
1233 }
1234 crate::log_warn!(
1235 "[InboxRelays] pool publish failed, list delivered via Discovery Relays only"
1236 );
1237 }
1238 if session.is_valid() {
1241 note_list_seen(event.created_at.as_u64().max(remote_ts));
1242 }
1243
1244 println!(
1245 "[InboxRelays] Published kind 10050 with {} relay(s) ({} foreign preserved)",
1246 plan.list.len(),
1247 plan.list.len().saturating_sub(plan.contributed.len())
1248 );
1249 Ok(())
1250}
1251
1252static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
1255
1256#[cfg(test)]
1258static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
1259
1260pub fn republish_inbox_relays_debounced() {
1264 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1265 let session = crate::state::SessionGuard::capture();
1270 tokio::spawn(async move {
1271 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1274 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
1275 return; }
1277 if !session.is_valid() {
1278 return; }
1280 #[cfg(test)]
1281 DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
1282 let client = match nostr_client() {
1283 Some(c) => c,
1284 None => return,
1285 };
1286 if let Err(e) = publish_inbox_relays(&client).await {
1287 eprintln!("[InboxRelays] Failed to republish after config change: {}", e);
1288 }
1289 });
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295
1296 fn strs(v: &[&str]) -> Vec<String> {
1299 v.iter().map(|s| s.to_string()).collect()
1300 }
1301
1302 fn norm_set(v: &[&str]) -> HashSet<String> {
1303 v.iter().map(|s| normalize_relay_url(s)).collect()
1304 }
1305
1306 #[test]
1307 fn merge_preserves_foreign_entries() {
1308 let remote = strs(&["wss://other-app.example", "wss://alice.example"]);
1309 let ours = strs(&["wss://vector.example"]);
1310 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1311 assert!(plan.changed);
1312 assert_eq!(plan.list, strs(&["wss://other-app.example", "wss://alice.example", "wss://vector.example"]));
1313 assert_eq!(plan.contributed, strs(&["wss://vector.example"]));
1314 }
1315
1316 #[test]
1317 fn merge_noop_when_remote_covers_ours() {
1318 let remote = strs(&["wss://other-app.example", "wss://vector.example/"]);
1319 let ours = strs(&["wss://vector.example"]);
1320 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1321 assert!(!plan.changed, "trailing-slash variants are the same relay");
1322 assert_eq!(plan.list.len(), 2);
1323 }
1324
1325 #[test]
1326 fn merge_drops_only_our_own_removed_contribution() {
1327 let remote = strs(&["wss://foreign.example", "wss://x.example"]);
1330 let contributed = norm_set(&["wss://x.example"]);
1331 let ours = strs(&["wss://new.example"]);
1332 let plan = merge_inbox_relays(&remote, &contributed, &ours);
1333 assert!(plan.changed);
1334 assert_eq!(plan.list, strs(&["wss://foreign.example", "wss://new.example"]));
1335 }
1336
1337 #[test]
1338 fn merge_never_clears_a_foreign_list() {
1339 let remote = strs(&["wss://foreign.example"]);
1341 let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
1342 assert!(!plan.changed);
1343 assert_eq!(plan.list, remote);
1344 assert!(plan.contributed.is_empty());
1345 }
1346
1347 #[test]
1348 fn merge_contributed_excludes_foreign_overlap() {
1349 let remote = strs(&["wss://shared.example"]);
1355 let ours = strs(&["wss://shared.example", "wss://mine.example"]);
1356 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1357 assert_eq!(plan.contributed, strs(&["wss://mine.example"]));
1358 let next = merge_inbox_relays(
1359 &plan.list,
1360 &plan.contributed.iter().cloned().collect(),
1361 &[],
1362 );
1363 assert!(next.list.contains(&"wss://shared.example".to_string()));
1364 assert!(!next.list.contains(&"wss://mine.example".to_string()));
1365 }
1366
1367 #[test]
1368 fn merge_caps_foreign_bloat_without_publishing() {
1369 let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
1373 let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
1374 assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS);
1375 assert!(!plan.changed, "a trim alone must not drive a publish");
1376 }
1377
1378 #[test]
1379 fn merge_cap_applies_when_own_diff_publishes() {
1380 let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
1381 let ours = strs(&["wss://mine.example"]);
1382 let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
1383 assert!(plan.changed, "our addition is a real diff");
1384 assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS + 1);
1385 assert!(plan.list.contains(&"wss://mine.example".to_string()));
1386 }
1387
1388 #[test]
1389 fn merge_two_devices_reach_fixpoint() {
1390 let ours_a = strs(&["wss://a1.example", "wss://shared.example"]);
1393 let ours_b = strs(&["wss://b1.example", "wss://shared.example"]);
1394 let mut network = strs(&["wss://foreign.example"]);
1395 let mut contributed_a: HashSet<String> = HashSet::new();
1396 let mut contributed_b: HashSet<String> = HashSet::new();
1397 let mut publishes = 0;
1398 for round in 0..6 {
1399 for device in 0..2 {
1400 let (ours, contributed) = if device == 0 {
1401 (&ours_a, &mut contributed_a)
1402 } else {
1403 (&ours_b, &mut contributed_b)
1404 };
1405 let plan = merge_inbox_relays(&network, contributed, ours);
1406 *contributed = plan.contributed.iter().cloned().collect();
1407 if plan.changed {
1408 publishes += 1;
1409 network = plan.list;
1410 }
1411 if round >= 2 {
1412 assert!(!plan.changed, "no publish after convergence (round {round})");
1413 }
1414 }
1415 }
1416 assert!(publishes <= 2, "one publish per device to converge, got {publishes}");
1417 for url in ["wss://foreign.example", "wss://a1.example", "wss://b1.example", "wss://shared.example"] {
1418 assert!(network.contains(&url.to_string()), "union must hold {url}");
1419 }
1420 }
1421
1422 #[test]
1423 fn merge_first_run_publishes_ours() {
1424 let ours = strs(&["wss://a.example", "wss://b.example"]);
1425 let plan = merge_inbox_relays(&[], &HashSet::new(), &ours);
1426 assert!(plan.changed);
1427 assert_eq!(plan.list, ours);
1428 assert_eq!(plan.contributed, ours);
1429 }
1430
1431 #[test]
1434 fn reconcile_stale_remote_is_a_no_op() {
1435 let remote = strs(&["wss://foreign.example"]);
1436 let plan = plan_inbound_reconcile_pure(&remote, 100, &[], &[], &HashSet::new(), 100);
1437 assert_eq!(plan, InboundReconcile::default(), "ts <= last_seen must not act");
1438 }
1439
1440 #[test]
1441 fn reconcile_adopts_unknown_entries_capped_and_wss_only() {
1442 let mut remote: Vec<String> = (0..12).map(|i| format!("wss://r{}.example", i)).collect();
1443 remote.push("ws://plaintext.example".to_string());
1444 remote.push("http://nope.example".to_string());
1445 let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &[], &HashSet::new(), 100);
1446 assert_eq!(plan.adopt.len(), MAX_FOREIGN_RELAYS);
1447 assert!(plan.adopt.iter().all(|u| u.starts_with("wss://")));
1448 assert!(plan.revive.is_empty() && plan.retire.is_empty());
1449 }
1450
1451 #[test]
1452 fn reconcile_revives_locally_disabled_entry() {
1453 let remote = strs(&["wss://back.example"]);
1454 let declined = strs(&["wss://back.example/"]);
1455 let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &declined, &HashSet::new(), 100);
1456 assert_eq!(plan.revive, strs(&["wss://back.example"]));
1457 assert!(plan.adopt.is_empty());
1458 }
1459
1460 #[test]
1461 fn reconcile_retires_contributed_entry_dropped_by_newer_remote() {
1462 let remote = strs(&["wss://keep.example"]);
1463 let ours = strs(&["wss://keep.example", "wss://gone.example"]);
1464 let contributed = norm_set(&["wss://keep.example", "wss://gone.example"]);
1465 let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
1466 assert_eq!(plan.retire, strs(&["wss://gone.example"]));
1467 }
1468
1469 #[test]
1470 fn reconcile_never_retires_unpublished_local_addition() {
1471 let remote = strs(&["wss://old.example"]);
1474 let ours = strs(&["wss://old.example", "wss://just-added.example"]);
1475 let contributed = norm_set(&["wss://old.example"]);
1476 let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
1477 assert!(plan.retire.is_empty());
1478 }
1479
1480 #[test]
1481 fn reconcile_two_devices_propagates_default_disable() {
1482 #[derive(Clone)]
1486 struct Device {
1487 ours: Vec<String>,
1488 declined: Vec<String>,
1489 contributed: HashSet<String>,
1490 last_seen: u64,
1491 }
1492 impl Device {
1493 fn new(defaults: &[&str]) -> Self {
1494 Device {
1495 ours: strs(defaults),
1496 declined: Vec::new(),
1497 contributed: HashSet::new(),
1498 last_seen: 0,
1499 }
1500 }
1501 fn sync(&mut self, network: &mut (Vec<String>, u64)) -> bool {
1503 let (remote, ts) = network.clone();
1504 for u in &self.ours {
1505 if remote.iter().any(|r| normalize_relay_url(r) == normalize_relay_url(u)) {
1506 self.contributed.insert(normalize_relay_url(u));
1507 }
1508 }
1509 let plan = plan_inbound_reconcile_pure(
1510 &remote, ts, &self.ours, &self.declined, &self.contributed, self.last_seen,
1511 );
1512 for u in &plan.retire {
1513 self.ours.retain(|o| o != u);
1514 self.declined.push(u.clone());
1515 }
1516 for u in &plan.revive {
1517 self.declined.retain(|d| normalize_relay_url(d) != normalize_relay_url(u));
1518 self.ours.push(u.clone());
1519 self.contributed.insert(normalize_relay_url(u));
1520 }
1521 for u in &plan.adopt {
1522 self.ours.push(u.clone());
1523 self.contributed.insert(normalize_relay_url(u));
1524 }
1525 self.last_seen = self.last_seen.max(ts);
1526 let m = merge_inbox_relays(&remote, &self.contributed, &self.ours);
1527 self.contributed = m.contributed.iter().cloned().collect();
1528 if m.changed {
1529 network.1 += 1;
1530 network.0 = m.list;
1531 self.last_seen = network.1;
1532 }
1533 m.changed
1534 }
1535 }
1536
1537 const DEFAULTS: &[&str] = &["wss://d1.example", "wss://d2.example"];
1538 let mut network: (Vec<String>, u64) = (Vec::new(), 0);
1539 let mut a = Device::new(DEFAULTS);
1540 let mut b = Device::new(DEFAULTS);
1541
1542 assert!(a.sync(&mut network), "first device bootstraps the list");
1543 assert!(!b.sync(&mut network), "second device is already in sync");
1544
1545 b.ours.retain(|u| u != "wss://d2.example");
1547 b.declined.push("wss://d2.example".to_string());
1548 assert!(b.sync(&mut network), "disable must publish");
1549 assert!(!network.0.contains(&"wss://d2.example".to_string()));
1550
1551 assert!(!a.sync(&mut network), "A must adopt the removal, not republish d2");
1553 assert!(a.declined.contains(&"wss://d2.example".to_string()));
1554 assert!(!network.0.contains(&"wss://d2.example".to_string()), "no resurrection");
1555
1556 b.declined.retain(|u| u != "wss://d2.example");
1558 b.ours.push("wss://d2.example".to_string());
1559 assert!(b.sync(&mut network), "re-enable must publish");
1560 assert!(!a.sync(&mut network), "revive is inbound-only, no republish");
1561 assert!(a.ours.contains(&"wss://d2.example".to_string()), "A revives d2");
1562
1563 for _ in 0..3 {
1565 assert!(!a.sync(&mut network));
1566 assert!(!b.sync(&mut network));
1567 }
1568 }
1569
1570 #[test]
1573 fn parse_relay_tags_extracts_urls() {
1574 let tags = Tags::from_list(vec![
1575 Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
1576 Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
1577 ]);
1578 let result = parse_relay_tags(&tags);
1579 assert_eq!(result, vec![
1580 "wss://relay.example.com".to_string(),
1581 "wss://other.example.com".to_string(),
1582 ]);
1583 }
1584
1585 #[test]
1586 fn parse_relay_tags_ignores_non_relay_tags() {
1587 let tags = Tags::from_list(vec![
1588 Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
1589 Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
1590 Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
1591 ]);
1592 let result = parse_relay_tags(&tags);
1593 assert_eq!(result, vec!["wss://good.example.com".to_string()]);
1594 }
1595
1596 #[test]
1597 fn parse_relay_tags_empty() {
1598 let tags = Tags::new();
1599 let result = parse_relay_tags(&tags);
1600 assert!(result.is_empty());
1601 }
1602
1603 #[test]
1604 fn parse_relay_tags_ignores_relay_tag_without_value() {
1605 let tags = Tags::from_list(vec![
1607 Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
1608 ]);
1609 let result = parse_relay_tags(&tags);
1610 assert!(result.is_empty());
1611 }
1612
1613 fn test_pubkey() -> PublicKey {
1616 let keys = Keys::generate();
1617 keys.public_key()
1618 }
1619
1620 static TEST_GLOBALS_LOCK: LazyLock<tokio::sync::Mutex<()>> =
1622 LazyLock::new(|| tokio::sync::Mutex::new(()));
1623
1624 #[test]
1625 fn cache_stores_and_retrieves() {
1626 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1627 let pk = test_pubkey();
1628 let relays = vec!["wss://a.example.com".to_string()];
1629
1630 {
1631 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1632 cache.insert(pk, CachedRelays {
1633 relays: relays.clone(),
1634 fetched_at: Instant::now(),
1635 fetch_ok: true,
1636 });
1637 }
1638
1639 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1640 let entry = cache.get(&pk).unwrap();
1641 assert_eq!(entry.relays, relays);
1642 assert!(entry.fetch_ok);
1643 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1644 }
1645
1646 #[test]
1647 fn cache_expires_after_ttl() {
1648 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1649 let pk = test_pubkey();
1650
1651 {
1652 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1653 cache.insert(pk, CachedRelays {
1654 relays: vec!["wss://stale.example.com".to_string()],
1655 fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
1656 fetch_ok: true,
1657 });
1658 }
1659
1660 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1661 let entry = cache.get(&pk).unwrap();
1662 assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
1663 }
1664
1665 #[test]
1666 fn cache_stores_empty_results() {
1667 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1668 let pk = test_pubkey();
1669
1670 {
1671 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1672 cache.insert(pk, CachedRelays {
1673 relays: vec![],
1674 fetched_at: Instant::now(),
1675 fetch_ok: true,
1676 });
1677 }
1678
1679 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1680 let entry = cache.get(&pk).unwrap();
1681 assert!(entry.relays.is_empty());
1682 assert!(entry.fetch_ok);
1683 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1684 }
1685
1686 #[test]
1687 fn cache_error_uses_short_ttl() {
1688 let _guard = TEST_GLOBALS_LOCK.blocking_lock();
1689 let pk = test_pubkey();
1690
1691 {
1692 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1693 cache.insert(pk, CachedRelays {
1694 relays: vec![],
1695 fetched_at: Instant::now() - std::time::Duration::from_secs(120),
1697 fetch_ok: false,
1698 });
1699 }
1700
1701 let cache = INBOX_RELAY_CACHE.lock().unwrap();
1702 let entry = cache.get(&pk).unwrap();
1703 assert!(!entry.fetch_ok);
1704 assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
1706 assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
1708 }
1709
1710 #[tokio::test]
1713 async fn concurrent_fetches_for_same_pubkey_serialize() {
1714 let _guard = TEST_GLOBALS_LOCK.lock().await;
1715 let pk = test_pubkey();
1716
1717 {
1719 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1720 cache.remove(&pk);
1721 }
1722
1723 let fetch_counter = Arc::new(AtomicU64::new(0));
1724
1725 let mut handles = vec![];
1728 for _ in 0..10 {
1729 let counter = fetch_counter.clone();
1730 let handle = tokio::spawn(async move {
1731 get_or_fetch_with_lock(&pk, || async {
1732 counter.fetch_add(1, Ordering::SeqCst);
1733 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1735 FetchResult {
1736 relays: vec!["wss://test.example.com".to_string()],
1737 fetch_ok: true,
1738 }
1739 })
1740 .await
1741 });
1742 handles.push(handle);
1743 }
1744
1745 let results = futures_util::future::join_all(handles).await;
1747
1748 for result in &results {
1750 assert!(result.is_ok());
1751 let relays = result.as_ref().unwrap();
1752 assert_eq!(relays, &vec!["wss://test.example.com".to_string()]);
1753 }
1754
1755 assert_eq!(
1757 fetch_counter.load(Ordering::SeqCst),
1758 1,
1759 "Expected exactly 1 fetch for 10 concurrent requests to same pubkey"
1760 );
1761
1762 let locks_after = {
1763 let locks = FETCH_LOCKS.lock().unwrap();
1764 locks.len()
1765 };
1766 assert_eq!(locks_after, 0, "Lock entry should be removed after all waiters complete");
1767 }
1768
1769 #[tokio::test]
1770 async fn fetch_locks_do_not_accumulate_after_calls_complete() {
1771 let _guard = TEST_GLOBALS_LOCK.lock().await;
1772
1773 let pk1 = test_pubkey();
1777 let pk2 = test_pubkey();
1778 let pk3 = test_pubkey();
1779
1780 {
1782 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1783 cache.clear();
1784 }
1785 {
1786 let mut locks = FETCH_LOCKS.lock().unwrap();
1787 locks.clear();
1788 }
1789
1790 get_or_fetch_with_lock(&pk1, || async {
1792 FetchResult {
1793 relays: vec!["wss://relay1.example.com".to_string()],
1794 fetch_ok: true,
1795 }
1796 })
1797 .await;
1798
1799 let locks_after_pk1 = {
1802 let locks = FETCH_LOCKS.lock().unwrap();
1803 locks.len()
1804 };
1805 assert_eq!(locks_after_pk1, 0, "No lock entries should remain after pk1 call");
1806
1807 get_or_fetch_with_lock(&pk2, || async {
1809 FetchResult {
1810 relays: vec!["wss://relay2.example.com".to_string()],
1811 fetch_ok: true,
1812 }
1813 })
1814 .await;
1815
1816 let locks_after_pk2 = {
1817 let locks = FETCH_LOCKS.lock().unwrap();
1818 locks.len()
1819 };
1820 assert_eq!(locks_after_pk2, 0, "No lock entries should remain after pk2 call");
1821
1822 get_or_fetch_with_lock(&pk3, || async {
1824 FetchResult {
1825 relays: vec!["wss://relay3.example.com".to_string()],
1826 fetch_ok: true,
1827 }
1828 })
1829 .await;
1830
1831 let locks_after_pk3 = {
1832 let locks = FETCH_LOCKS.lock().unwrap();
1833 locks.len()
1834 };
1835 assert_eq!(locks_after_pk3, 0, "No lock entries should remain after pk3 call");
1836 }
1837
1838 #[tokio::test]
1839 async fn cancelled_fetch_cleans_up_lock_entry() {
1840 let _guard = TEST_GLOBALS_LOCK.lock().await;
1841 let pk = test_pubkey();
1842
1843 {
1844 let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
1845 cache.clear();
1846 }
1847 {
1848 let mut locks = FETCH_LOCKS.lock().unwrap();
1849 locks.clear();
1850 }
1851
1852 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1853 let task_pk = pk;
1854 let handle = tokio::spawn(async move {
1855 get_or_fetch_with_lock(&task_pk, || async move {
1856 let _ = started_tx.send(());
1857 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
1858 FetchResult { relays: Vec::new(), fetch_ok: false }
1859 })
1860 .await
1861 });
1862
1863 started_rx.await.expect("fetch closure should start before abort");
1864 handle.abort();
1865 let _ = handle.await;
1866 tokio::task::yield_now().await;
1867
1868 let locks_after = {
1869 let locks = FETCH_LOCKS.lock().unwrap();
1870 locks.len()
1871 };
1872 assert_eq!(
1873 locks_after, 0,
1874 "Lock entry should be removed even if fetch task is cancelled"
1875 );
1876 }
1877
1878 #[tokio::test(start_paused = true)]
1885 async fn debounce_coalesces_rapid_calls_into_one() {
1886 let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
1888 let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1889
1890 republish_inbox_relays_debounced();
1892 republish_inbox_relays_debounced();
1893 republish_inbox_relays_debounced();
1894
1895 let gen_after = REPUBLISH_GEN.load(Ordering::SeqCst);
1896 assert_eq!(gen_after, gen_before + 3);
1897
1898 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1900
1901 let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
1902 assert_eq!(pass_after - pass_before, 1);
1906 }
1907}