use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Instant;
use nostr_sdk::prelude::*;
use std::sync::LazyLock;
use crate::state::nostr_client;
pub struct EventPublishTracker {
event_id: EventId,
successes: Mutex<Vec<RelayUrl>>,
notify: tokio::sync::Notify,
in_flight: AtomicUsize,
}
impl EventPublishTracker {
fn new(event_id: EventId, initial_in_flight: usize) -> Arc<Self> {
Arc::new(Self {
event_id,
successes: Mutex::new(Vec::new()),
notify: tokio::sync::Notify::new(),
in_flight: AtomicUsize::new(initial_in_flight),
})
}
fn note_success(&self, url: RelayUrl) {
self.successes.lock().unwrap().push(url);
self.notify.notify_waiters();
}
fn note_settled(&self) {
if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
self.notify.notify_waiters();
PUBLISH_TRACKERS.lock().unwrap().remove(&self.event_id);
}
}
pub async fn next_success(&self, cursor: &mut usize) -> Option<RelayUrl> {
loop {
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let (next, done) = {
let successes = self.successes.lock().unwrap();
let next = successes.get(*cursor).cloned();
let done = self.in_flight.load(Ordering::SeqCst) == 0
&& *cursor >= successes.len();
(next, done)
};
if let Some(url) = next {
*cursor += 1;
return Some(url);
}
if done {
return None;
}
notified.await;
}
}
}
static PUBLISH_TRACKERS: LazyLock<Mutex<HashMap<EventId, Arc<EventPublishTracker>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn get_publish_tracker(event_id: &EventId) -> Option<Arc<EventPublishTracker>> {
PUBLISH_TRACKERS.lock().unwrap().get(event_id).cloned()
}
pub fn spawn_tracked_publish(
resolved: Vec<(RelayUrl, Relay)>,
event: Event,
) -> Vec<tokio::task::JoinHandle<(RelayUrl, Result<EventId, String>)>> {
let event_id = event.id;
if resolved.is_empty() {
return Vec::new();
}
let tracker = EventPublishTracker::new(event_id, resolved.len());
PUBLISH_TRACKERS.lock().unwrap().insert(event_id, tracker.clone());
let mut handles = Vec::with_capacity(resolved.len());
for (url, relay) in resolved {
let event = event.clone();
let tracker = tracker.clone();
handles.push(tokio::spawn(async move {
let result = relay
.send_event(&event)
.await
.map_err(|e| e.to_string());
if result.is_ok() {
tracker.note_success(url.clone());
}
tracker.note_settled();
(url, result)
}));
}
handles
}
const CACHE_TTL_SECS: u64 = 3600;
const CACHE_TTL_ERROR_SECS: u64 = 60;
struct CachedRelays {
relays: Vec<String>,
fetched_at: Instant,
fetch_ok: bool,
}
static INBOX_RELAY_CACHE: LazyLock<Mutex<HashMap<PublicKey, CachedRelays>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn clear_inbox_relay_cache() {
if let Ok(mut cache) = INBOX_RELAY_CACHE.lock() {
cache.clear();
}
}
static FETCH_LOCKS: LazyLock<Mutex<HashMap<PublicKey, Weak<tokio::sync::Mutex<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static PRUNE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(not(test))]
const PRUNE_INTERVAL: u64 = 100;
#[cfg(test)]
const PRUNE_INTERVAL: u64 = 1;
struct FetchLockEntryCleanup {
pubkey: PublicKey,
key_lock: Arc<tokio::sync::Mutex<()>>,
}
impl FetchLockEntryCleanup {
fn new(pubkey: PublicKey, key_lock: Arc<tokio::sync::Mutex<()>>) -> Self {
Self { pubkey, key_lock }
}
}
impl Drop for FetchLockEntryCleanup {
fn drop(&mut self) {
let mut locks = match FETCH_LOCKS.lock() {
Ok(locks) => locks,
Err(_) => return, };
let should_remove = match locks.get(&self.pubkey).and_then(|weak| weak.upgrade()) {
Some(current) => {
Arc::ptr_eq(¤t, &self.key_lock) && Arc::strong_count(¤t) == 2
}
None => false,
};
if should_remove {
locks.remove(&self.pubkey);
}
}
}
pub fn normalize_relay_url(s: &str) -> String {
s.trim_end_matches('/').to_ascii_lowercase()
}
async fn inbox_query_targets(client: &Client) -> Vec<RelayUrl> {
let discovery: HashSet<String> = crate::state::discovery_relay_iter()
.map(normalize_relay_url)
.collect();
client
.pool()
.all_relays()
.await
.iter()
.filter(|(url, relay)| {
relay.flags().has_read() || discovery.contains(&normalize_relay_url(url.as_str()))
})
.map(|(url, _)| url.clone())
.collect()
}
struct FetchResult {
relays: Vec<String>,
fetch_ok: bool,
}
async fn fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> FetchResult {
let filter = Filter::new()
.author(*pubkey)
.kind(Kind::Custom(10050))
.limit(1);
let targets = inbox_query_targets(client).await;
let fetched = if targets.is_empty() {
client
.fetch_events(filter, std::time::Duration::from_secs(5))
.await
} else {
client
.fetch_events_from(targets, filter, std::time::Duration::from_secs(5))
.await
};
let events = match fetched {
Ok(events) => events,
Err(e) => {
eprintln!("[InboxRelays] Failed to fetch 10050 for {}: {}", pubkey, e);
return FetchResult { relays: Vec::new(), fetch_ok: false };
}
};
let event = match events.into_iter().max_by_key(|e| e.created_at) {
Some(e) => e,
None => return FetchResult { relays: Vec::new(), fetch_ok: true },
};
FetchResult { relays: parse_relay_tags(&event.tags), fetch_ok: true }
}
fn parse_relay_tags(tags: &Tags) -> Vec<String> {
tags.iter()
.filter_map(|tag| {
let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if values.len() >= 2 && values[0] == "relay" {
Some(values[1].to_string())
} else {
None
}
})
.collect()
}
async fn get_or_fetch_with_lock<F, Fut>(pubkey: &PublicKey, fetch_fn: F) -> Vec<String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = FetchResult>,
{
{
let cache = INBOX_RELAY_CACHE.lock().unwrap();
if let Some(entry) = cache.get(pubkey) {
let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
if entry.fetched_at.elapsed().as_secs() < ttl {
return entry.relays.clone();
}
}
}
let cleanup_guard = {
let mut locks = FETCH_LOCKS.lock().unwrap();
if PRUNE_COUNTER.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL == 0 {
locks.retain(|_, weak| Weak::strong_count(weak) > 0);
}
let weak = locks.entry(*pubkey).or_insert_with(|| Weak::new());
let key_lock = match weak.upgrade() {
Some(arc) => arc,
None => {
let new_arc = Arc::new(tokio::sync::Mutex::new(()));
*weak = Arc::downgrade(&new_arc);
new_arc
}
};
FetchLockEntryCleanup::new(*pubkey, key_lock)
};
let relays = {
let _guard = cleanup_guard.key_lock.lock().await;
let cached_relays = {
let cache = INBOX_RELAY_CACHE.lock().unwrap();
if let Some(entry) = cache.get(pubkey) {
let ttl = if entry.fetch_ok { CACHE_TTL_SECS } else { CACHE_TTL_ERROR_SECS };
if entry.fetched_at.elapsed().as_secs() < ttl {
Some(entry.relays.clone())
} else {
None
}
} else {
None
}
};
match cached_relays {
Some(relays) => relays,
None => {
let result = fetch_fn().await;
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(
*pubkey,
CachedRelays {
relays: result.relays.clone(),
fetched_at: Instant::now(),
fetch_ok: result.fetch_ok,
},
);
}
result.relays
}
}
};
drop(cleanup_guard);
relays
}
async fn get_or_fetch_inbox_relays(client: &Client, pubkey: &PublicKey) -> Vec<String> {
get_or_fetch_with_lock(pubkey, || fetch_inbox_relays(client, pubkey)).await
}
static TRUSTED_RELAY_URLS: LazyLock<Vec<RelayUrl>> = LazyLock::new(|| {
crate::state::TRUSTED_RELAYS
.iter()
.filter_map(|s| RelayUrl::parse(s).ok())
.collect()
});
pub fn trusted_relay_urls() -> Vec<RelayUrl> {
TRUSTED_RELAY_URLS.clone()
}
pub async fn send_event_first_ok(
client: &Client,
urls: Vec<RelayUrl>,
event: &Event,
) -> Result<Output<EventId>, nostr_sdk::client::Error> {
let pool = client.pool();
let relays = pool.relays().await;
let event_id = event.id;
let mut resolved: Vec<(RelayUrl, Relay)> = Vec::new();
for url in urls {
if let Some(relay) = relays.get(&url) {
resolved.push((url, relay.clone()));
}
}
if resolved.is_empty() {
return client.send_event(event).await;
}
let handles = spawn_tracked_publish(resolved, event.clone());
let mut output = Output {
val: event_id,
success: std::collections::HashSet::new(),
failed: HashMap::new(),
};
let mut remaining = handles;
while !remaining.is_empty() {
let (result, _index, rest) = futures_util::future::select_all(remaining).await;
remaining = rest;
if let Ok((url, relay_result)) = result {
match relay_result {
Ok(_) => {
output.success.insert(url);
drop(remaining);
return Ok(output);
}
Err(e) => {
output.failed.insert(url, e);
}
}
}
}
Ok(output)
}
pub async fn send_event_pool_first_ok(
client: &Client,
event: &Event,
) -> Result<Output<EventId>, nostr_sdk::client::Error> {
let pool = client.pool();
let relays = pool.relays().await;
let write_urls: Vec<RelayUrl> = relays
.iter()
.filter(|(_, r)| r.flags().has_write())
.map(|(url, _)| url.clone())
.collect();
send_event_first_ok(&client, write_urls, event).await
}
pub fn wrap_with_retained_key(
receiver: &PublicKey,
seal: &Event,
extra_tags: impl IntoIterator<Item = Tag>,
) -> Result<(Event, SecretKey), String> {
use nostr_sdk::nips::nip44;
use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
if seal.kind != Kind::Seal {
return Err(format!("expected Seal kind, got {:?}", seal.kind));
}
let keys = Keys::generate();
let secret = keys.secret_key().clone();
let content = nip44::encrypt(
keys.secret_key(),
receiver,
seal.as_json(),
nip44::Version::default(),
)
.map_err(|e| format!("nip44 encrypt: {}", e))?;
let mut tags: Vec<Tag> = extra_tags.into_iter().collect();
tags.push(Tag::public_key(*receiver));
let event = EventBuilder::new(Kind::GiftWrap, content)
.tags(tags)
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
.sign_with_keys(&keys)
.map_err(|e| format!("sign wrap: {}", e))?;
Ok((event, secret))
}
pub struct GiftWrapSendOutcome {
pub output: Output<EventId>,
pub wrap_event_id: EventId,
pub wrap_secret: SecretKey,
pub targeted_relays: Vec<String>,
}
pub async fn send_gift_wrap_retained(
client: &Client,
recipient: &PublicKey,
rumor: UnsignedEvent,
extra_tags: impl IntoIterator<Item = Tag>,
) -> Result<GiftWrapSendOutcome, String> {
let signer = client.signer().await.map_err(|e| e.to_string())?;
let seal: Event = EventBuilder::seal(&signer, recipient, rumor)
.await
.map_err(|e| e.to_string())?
.sign(&signer)
.await
.map_err(|e| e.to_string())?;
let (event, secret) = wrap_with_retained_key(recipient, &seal, extra_tags)?;
let wrap_event_id = event.id;
let inbox_strs = get_or_fetch_inbox_relays(client, recipient).await;
let targeted_strs: Vec<String> = if !inbox_strs.is_empty() {
inbox_strs.clone()
} else {
let pool = client.pool();
let relays = pool.relays().await;
relays.iter()
.filter(|(_, r)| r.flags().has_write())
.map(|(url, _)| url.to_string())
.collect()
};
use normalize_relay_url as normalize_url_for_match;
let pool = client.pool();
let pool_relays = pool.all_relays().await;
let pool_norm: Vec<(String, RelayUrl, Relay)> = pool_relays.iter()
.map(|(url, relay)| (
normalize_url_for_match(&url.to_string()),
url.clone(),
relay.clone(),
))
.collect();
let mut resolved: Vec<(RelayUrl, Relay)> = targeted_strs
.iter()
.filter_map(|s| {
let norm = normalize_url_for_match(s);
pool_norm.iter()
.find(|(pnorm, _, _)| pnorm == &norm)
.map(|(_, url, relay)| (url.clone(), relay.clone()))
})
.collect();
let mut transient_added: Vec<RelayUrl> = Vec::new();
if !inbox_strs.is_empty() {
for s in &targeted_strs {
let norm = normalize_url_for_match(s);
let in_pool = pool_norm.iter().any(|(p, _, _)| p == &norm);
let already_added = transient_added.iter()
.any(|u| normalize_url_for_match(&u.to_string()) == norm);
if in_pool || already_added { continue; }
let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
if pool.add_relay(s.as_str(), opts).await.is_ok() {
if let Ok(relay) = pool.relay(s.as_str()).await {
let _ = relay.try_connect(std::time::Duration::from_secs(6)).await;
transient_added.push(relay.url().clone());
resolved.push((relay.url().clone(), relay));
}
}
}
if !transient_added.is_empty() {
crate::log_info!(
"[InboxRelays] on-demand connected {} inbox relay(s) for {} (transient)",
transient_added.len(),
recipient,
);
}
}
if resolved.is_empty() {
let output = client
.send_event(&event)
.await
.map_err(|e| e.to_string())?;
return Ok(GiftWrapSendOutcome {
output,
wrap_event_id,
wrap_secret: secret,
targeted_relays: targeted_strs,
});
}
if !inbox_strs.is_empty() {
println!(
"[InboxRelays] Routing gift-wrap to {} inbox relays for {}",
resolved.len(),
recipient
);
}
let handles = spawn_tracked_publish(resolved, event.clone());
let mut output = Output {
val: wrap_event_id,
success: HashSet::new(),
failed: HashMap::new(),
};
let mut remaining = handles;
while !remaining.is_empty() {
let (result, _idx, rest) = futures_util::future::select_all(remaining).await;
remaining = rest;
if let Ok((url, relay_result)) = result {
match relay_result {
Ok(_) => {
output.success.insert(url);
drop(remaining);
break;
}
Err(e) => {
output.failed.insert(url, e.to_string());
}
}
}
}
for url in &transient_added {
let _ = pool.remove_relay(url).await;
}
Ok(GiftWrapSendOutcome {
output,
wrap_event_id,
wrap_secret: secret,
targeted_relays: targeted_strs,
})
}
pub async fn send_gift_wrap(
client: &Client,
recipient: &PublicKey,
rumor: UnsignedEvent,
extra_tags: impl IntoIterator<Item = Tag>,
) -> Result<Output<EventId>, String> {
let outcome = send_gift_wrap_retained(client, recipient, rumor, extra_tags).await?;
Ok(outcome.output)
}
const CONTRIBUTED_KEY: &str = "dm_relays_contributed";
const MAX_FOREIGN_RELAYS: usize = 10;
fn load_contributed() -> HashSet<String> {
crate::db::get_sql_setting(CONTRIBUTED_KEY.to_string())
.ok()
.flatten()
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
.map(|v| v.into_iter().map(|s| normalize_relay_url(&s)).collect())
.unwrap_or_default()
}
fn store_contributed(contributed: &[String]) {
if let Ok(json) = serde_json::to_string(contributed) {
let _ = crate::db::set_sql_setting(CONTRIBUTED_KEY.to_string(), json);
}
}
struct MergePlan {
list: Vec<String>,
changed: bool,
contributed: Vec<String>,
}
fn merge_inbox_relays(
remote: &[String],
contributed_before: &HashSet<String>,
ours: &[String],
) -> MergePlan {
let mut seen: HashSet<String> = HashSet::new();
let mut list: Vec<String> = Vec::new();
let mut foreign_norm: HashSet<String> = HashSet::new();
let mut dropped_foreign = 0usize;
for url in remote {
let norm = normalize_relay_url(url);
if seen.contains(&norm) || contributed_before.contains(&norm) {
continue;
}
if foreign_norm.len() >= MAX_FOREIGN_RELAYS {
dropped_foreign += 1;
continue;
}
seen.insert(norm.clone());
foreign_norm.insert(norm);
list.push(url.clone());
}
if dropped_foreign > 0 {
crate::log_warn!(
"[InboxRelays] remote 10050 over the {}-relay foreign cap, dropped {}",
MAX_FOREIGN_RELAYS,
dropped_foreign
);
}
let mut contributed: Vec<String> = Vec::new();
for url in ours {
let norm = normalize_relay_url(url);
if seen.insert(norm.clone()) {
list.push(url.clone());
}
if !foreign_norm.contains(&norm) && !contributed.contains(&norm) {
contributed.push(norm);
}
}
let remote_set: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
let has_addition = ours_norm.iter().any(|n| !remote_set.contains(n));
let has_removal = contributed_before
.iter()
.any(|n| remote_set.contains(n) && !ours_norm.contains(n));
MergePlan { list, changed: has_addition || has_removal, contributed }
}
pub async fn fetch_own_inbox_list(client: &Client) -> Result<Option<(Vec<String>, u64)>, String> {
let me = crate::state::my_public_key().ok_or("no active pubkey")?;
let targets = inbox_query_targets(client).await;
if targets.is_empty() {
return Err("no query targets in pool".to_string());
}
let discovery: HashSet<String> = crate::state::discovery_relay_iter()
.map(normalize_relay_url)
.collect();
let deadline = Instant::now() + std::time::Duration::from_secs(8);
loop {
let relays = client.pool().all_relays().await;
let connected: Vec<&RelayUrl> = targets
.iter()
.filter(|url| {
relays
.get(url)
.map(|r| r.status() == RelayStatus::Connected)
.unwrap_or(false)
})
.collect();
let discovery_up = connected
.iter()
.any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
if discovery_up {
break;
}
if Instant::now() >= deadline {
if connected.is_empty() {
return Err("no query target connected".to_string());
}
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
let filter = Filter::new().author(me).kind(Kind::Custom(10050)).limit(1);
let events = client
.fetch_events_from(targets.clone(), filter, std::time::Duration::from_secs(6))
.await
.map_err(|e| e.to_string())?;
let newest = events
.into_iter()
.max_by(|a, b| a.created_at.cmp(&b.created_at).then(b.id.cmp(&a.id)))
.map(|e| (parse_relay_tags(&e.tags), e.created_at.as_u64()));
if newest.is_none() {
let has_discovery_target = targets
.iter()
.any(|url| discovery.contains(&normalize_relay_url(url.as_str())));
if has_discovery_target {
let relays = client.pool().all_relays().await;
let discovery_answered = targets.iter().any(|url| {
discovery.contains(&normalize_relay_url(url.as_str()))
&& relays
.get(url)
.map(|r| r.status() == RelayStatus::Connected)
.unwrap_or(false)
});
if !discovery_answered {
return Err(
"no 10050 found and no Discovery Relay connected; refusing to bootstrap"
.to_string(),
);
}
}
}
Ok(newest)
}
const LIST_SEEN_TS_KEY: &str = "dm_list_last_ts";
fn load_list_seen() -> u64 {
crate::db::get_sql_setting(LIST_SEEN_TS_KEY.to_string())
.ok()
.flatten()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0)
}
pub fn note_contributed(urls: &[String]) {
if urls.is_empty() {
return;
}
let mut set = load_contributed();
for url in urls {
set.insert(normalize_relay_url(url));
}
let list: Vec<String> = set.into_iter().collect();
store_contributed(&list);
}
pub fn note_list_seen(ts: u64) {
if ts > load_list_seen() {
let _ = crate::db::set_sql_setting(LIST_SEEN_TS_KEY.to_string(), ts.to_string());
}
}
#[derive(Debug, Default, PartialEq)]
pub struct InboundReconcile {
pub adopt: Vec<String>,
pub revive: Vec<String>,
pub retire: Vec<String>,
}
pub fn plan_inbound_reconcile(
remote: &[String],
remote_ts: u64,
ours: &[String],
declined: &[String],
) -> InboundReconcile {
plan_inbound_reconcile_pure(
remote,
remote_ts,
ours,
declined,
&load_contributed(),
load_list_seen(),
)
}
fn plan_inbound_reconcile_pure(
remote: &[String],
remote_ts: u64,
ours: &[String],
declined: &[String],
contributed_before: &HashSet<String>,
last_seen_ts: u64,
) -> InboundReconcile {
if remote_ts <= last_seen_ts {
return InboundReconcile::default();
}
let ours_norm: HashSet<String> = ours.iter().map(|s| normalize_relay_url(s)).collect();
let declined_norm: HashSet<String> = declined.iter().map(|s| normalize_relay_url(s)).collect();
let remote_norm: HashSet<String> = remote.iter().map(|s| normalize_relay_url(s)).collect();
let mut seen: HashSet<String> = HashSet::new();
let mut adopt: Vec<String> = Vec::new();
let mut revive: Vec<String> = Vec::new();
for url in remote {
let norm = normalize_relay_url(url);
if !seen.insert(norm.clone()) {
continue;
}
if ours_norm.contains(&norm) {
continue;
}
if declined_norm.contains(&norm) {
revive.push(url.clone());
} else if adopt.len() < MAX_FOREIGN_RELAYS
&& url.starts_with("wss://")
&& url.len() <= 256
{
adopt.push(url.clone());
}
}
let retire: Vec<String> = ours
.iter()
.filter(|url| {
let norm = normalize_relay_url(url);
contributed_before.contains(&norm) && !remote_norm.contains(&norm)
})
.cloned()
.collect();
InboundReconcile { adopt, revive, retire }
}
static PUBLISH_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
let remote = fetch_own_inbox_list(client).await?;
publish_inbox_relays_synced(client, remote, None).await
}
pub async fn publish_inbox_relays_synced(
client: &Client,
remote: Option<(Vec<String>, u64)>,
ours_override: Option<Vec<String>>,
) -> Result<(), String> {
let _serial = PUBLISH_MUTEX.lock().await;
let session = crate::state::SessionGuard::capture();
let ours: Vec<String> = match ours_override {
Some(list) => list,
None => client
.pool()
.relays()
.await
.iter()
.filter(|(_, relay)| relay.flags().has_read())
.map(|(url, _)| url.to_string())
.collect(),
};
let remote_found = remote.is_some();
let (remote, remote_ts) = remote.unwrap_or_default();
if remote_found && remote_ts < load_list_seen() {
return Err("stale 10050 fetch (older than last seen), skipping publish".to_string());
}
let plan = merge_inbox_relays(&remote, &load_contributed(), &ours);
if !session.is_valid() {
return Ok(());
}
store_contributed(&plan.contributed);
if remote_found {
note_list_seen(remote_ts);
}
if remote_found && !plan.changed {
crate::log_info!(
"[InboxRelays] kind 10050 already in sync ({} relay(s)), not publishing",
plan.list.len()
);
return Ok(());
}
if plan.list.is_empty() && !remote_found {
return Ok(());
}
let mut builder = EventBuilder::new(Kind::Custom(10050), "");
for url in &plan.list {
builder = builder.tag(Tag::custom(TagKind::custom("relay"), vec![url.clone()]));
}
let event = client
.sign_event_builder(builder)
.await
.map_err(|e| format!("Failed to sign inbox relays: {}", e))?;
if !session.is_valid() {
return Ok(());
}
let pool_send = client.send_event(&event).await;
let discovery: HashSet<String> = crate::state::DISCOVERY_RELAYS
.iter()
.map(|s| normalize_relay_url(s))
.collect();
let discovery_targets: Vec<RelayUrl> = client
.pool()
.all_relays()
.await
.iter()
.filter(|(url, relay)| {
!relay.flags().has_write() && discovery.contains(&normalize_relay_url(url.as_str()))
})
.map(|(url, _)| url.clone())
.collect();
let mut discovery_ok = false;
if !discovery_targets.is_empty() {
if let Ok(out) = client.send_event_to(discovery_targets, &event).await {
discovery_ok = !out.success.is_empty();
}
}
let pool_ok = matches!(&pool_send, Ok(out) if !out.success.is_empty());
if !pool_ok {
if !discovery_ok {
return Err(match pool_send {
Err(e) => format!("Failed to publish inbox relays: {}", e),
Ok(_) => "Failed to publish inbox relays: no relay accepted it".to_string(),
});
}
crate::log_warn!(
"[InboxRelays] pool publish failed, list delivered via Discovery Relays only"
);
}
if session.is_valid() {
note_list_seen(event.created_at.as_u64().max(remote_ts));
}
println!(
"[InboxRelays] Published kind 10050 with {} relay(s) ({} foreign preserved)",
plan.list.len(),
plan.list.len().saturating_sub(plan.contributed.len())
);
Ok(())
}
static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
pub fn republish_inbox_relays_debounced() {
let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
return; }
if !session.is_valid() {
return; }
#[cfg(test)]
DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
let client = match nostr_client() {
Some(c) => c,
None => return,
};
if let Err(e) = publish_inbox_relays(&client).await {
eprintln!("[InboxRelays] Failed to republish after config change: {}", e);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn strs(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn norm_set(v: &[&str]) -> HashSet<String> {
v.iter().map(|s| normalize_relay_url(s)).collect()
}
#[test]
fn merge_preserves_foreign_entries() {
let remote = strs(&["wss://other-app.example", "wss://alice.example"]);
let ours = strs(&["wss://vector.example"]);
let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
assert!(plan.changed);
assert_eq!(plan.list, strs(&["wss://other-app.example", "wss://alice.example", "wss://vector.example"]));
assert_eq!(plan.contributed, strs(&["wss://vector.example"]));
}
#[test]
fn merge_noop_when_remote_covers_ours() {
let remote = strs(&["wss://other-app.example", "wss://vector.example/"]);
let ours = strs(&["wss://vector.example"]);
let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
assert!(!plan.changed, "trailing-slash variants are the same relay");
assert_eq!(plan.list.len(), 2);
}
#[test]
fn merge_drops_only_our_own_removed_contribution() {
let remote = strs(&["wss://foreign.example", "wss://x.example"]);
let contributed = norm_set(&["wss://x.example"]);
let ours = strs(&["wss://new.example"]);
let plan = merge_inbox_relays(&remote, &contributed, &ours);
assert!(plan.changed);
assert_eq!(plan.list, strs(&["wss://foreign.example", "wss://new.example"]));
}
#[test]
fn merge_never_clears_a_foreign_list() {
let remote = strs(&["wss://foreign.example"]);
let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
assert!(!plan.changed);
assert_eq!(plan.list, remote);
assert!(plan.contributed.is_empty());
}
#[test]
fn merge_contributed_excludes_foreign_overlap() {
let remote = strs(&["wss://shared.example"]);
let ours = strs(&["wss://shared.example", "wss://mine.example"]);
let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
assert_eq!(plan.contributed, strs(&["wss://mine.example"]));
let next = merge_inbox_relays(
&plan.list,
&plan.contributed.iter().cloned().collect(),
&[],
);
assert!(next.list.contains(&"wss://shared.example".to_string()));
assert!(!next.list.contains(&"wss://mine.example".to_string()));
}
#[test]
fn merge_caps_foreign_bloat_without_publishing() {
let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
let plan = merge_inbox_relays(&remote, &HashSet::new(), &[]);
assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS);
assert!(!plan.changed, "a trim alone must not drive a publish");
}
#[test]
fn merge_cap_applies_when_own_diff_publishes() {
let remote: Vec<String> = (0..30).map(|i| format!("wss://r{}.example", i)).collect();
let ours = strs(&["wss://mine.example"]);
let plan = merge_inbox_relays(&remote, &HashSet::new(), &ours);
assert!(plan.changed, "our addition is a real diff");
assert_eq!(plan.list.len(), MAX_FOREIGN_RELAYS + 1);
assert!(plan.list.contains(&"wss://mine.example".to_string()));
}
#[test]
fn merge_two_devices_reach_fixpoint() {
let ours_a = strs(&["wss://a1.example", "wss://shared.example"]);
let ours_b = strs(&["wss://b1.example", "wss://shared.example"]);
let mut network = strs(&["wss://foreign.example"]);
let mut contributed_a: HashSet<String> = HashSet::new();
let mut contributed_b: HashSet<String> = HashSet::new();
let mut publishes = 0;
for round in 0..6 {
for device in 0..2 {
let (ours, contributed) = if device == 0 {
(&ours_a, &mut contributed_a)
} else {
(&ours_b, &mut contributed_b)
};
let plan = merge_inbox_relays(&network, contributed, ours);
*contributed = plan.contributed.iter().cloned().collect();
if plan.changed {
publishes += 1;
network = plan.list;
}
if round >= 2 {
assert!(!plan.changed, "no publish after convergence (round {round})");
}
}
}
assert!(publishes <= 2, "one publish per device to converge, got {publishes}");
for url in ["wss://foreign.example", "wss://a1.example", "wss://b1.example", "wss://shared.example"] {
assert!(network.contains(&url.to_string()), "union must hold {url}");
}
}
#[test]
fn merge_first_run_publishes_ours() {
let ours = strs(&["wss://a.example", "wss://b.example"]);
let plan = merge_inbox_relays(&[], &HashSet::new(), &ours);
assert!(plan.changed);
assert_eq!(plan.list, ours);
assert_eq!(plan.contributed, ours);
}
#[test]
fn reconcile_stale_remote_is_a_no_op() {
let remote = strs(&["wss://foreign.example"]);
let plan = plan_inbound_reconcile_pure(&remote, 100, &[], &[], &HashSet::new(), 100);
assert_eq!(plan, InboundReconcile::default(), "ts <= last_seen must not act");
}
#[test]
fn reconcile_adopts_unknown_entries_capped_and_wss_only() {
let mut remote: Vec<String> = (0..12).map(|i| format!("wss://r{}.example", i)).collect();
remote.push("ws://plaintext.example".to_string());
remote.push("http://nope.example".to_string());
let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &[], &HashSet::new(), 100);
assert_eq!(plan.adopt.len(), MAX_FOREIGN_RELAYS);
assert!(plan.adopt.iter().all(|u| u.starts_with("wss://")));
assert!(plan.revive.is_empty() && plan.retire.is_empty());
}
#[test]
fn reconcile_revives_locally_disabled_entry() {
let remote = strs(&["wss://back.example"]);
let declined = strs(&["wss://back.example/"]);
let plan = plan_inbound_reconcile_pure(&remote, 200, &[], &declined, &HashSet::new(), 100);
assert_eq!(plan.revive, strs(&["wss://back.example"]));
assert!(plan.adopt.is_empty());
}
#[test]
fn reconcile_retires_contributed_entry_dropped_by_newer_remote() {
let remote = strs(&["wss://keep.example"]);
let ours = strs(&["wss://keep.example", "wss://gone.example"]);
let contributed = norm_set(&["wss://keep.example", "wss://gone.example"]);
let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
assert_eq!(plan.retire, strs(&["wss://gone.example"]));
}
#[test]
fn reconcile_never_retires_unpublished_local_addition() {
let remote = strs(&["wss://old.example"]);
let ours = strs(&["wss://old.example", "wss://just-added.example"]);
let contributed = norm_set(&["wss://old.example"]);
let plan = plan_inbound_reconcile_pure(&remote, 200, &ours, &[], &contributed, 100);
assert!(plan.retire.is_empty());
}
#[test]
fn reconcile_two_devices_propagates_default_disable() {
#[derive(Clone)]
struct Device {
ours: Vec<String>,
declined: Vec<String>,
contributed: HashSet<String>,
last_seen: u64,
}
impl Device {
fn new(defaults: &[&str]) -> Self {
Device {
ours: strs(defaults),
declined: Vec::new(),
contributed: HashSet::new(),
last_seen: 0,
}
}
fn sync(&mut self, network: &mut (Vec<String>, u64)) -> bool {
let (remote, ts) = network.clone();
for u in &self.ours {
if remote.iter().any(|r| normalize_relay_url(r) == normalize_relay_url(u)) {
self.contributed.insert(normalize_relay_url(u));
}
}
let plan = plan_inbound_reconcile_pure(
&remote, ts, &self.ours, &self.declined, &self.contributed, self.last_seen,
);
for u in &plan.retire {
self.ours.retain(|o| o != u);
self.declined.push(u.clone());
}
for u in &plan.revive {
self.declined.retain(|d| normalize_relay_url(d) != normalize_relay_url(u));
self.ours.push(u.clone());
self.contributed.insert(normalize_relay_url(u));
}
for u in &plan.adopt {
self.ours.push(u.clone());
self.contributed.insert(normalize_relay_url(u));
}
self.last_seen = self.last_seen.max(ts);
let m = merge_inbox_relays(&remote, &self.contributed, &self.ours);
self.contributed = m.contributed.iter().cloned().collect();
if m.changed {
network.1 += 1;
network.0 = m.list;
self.last_seen = network.1;
}
m.changed
}
}
const DEFAULTS: &[&str] = &["wss://d1.example", "wss://d2.example"];
let mut network: (Vec<String>, u64) = (Vec::new(), 0);
let mut a = Device::new(DEFAULTS);
let mut b = Device::new(DEFAULTS);
assert!(a.sync(&mut network), "first device bootstraps the list");
assert!(!b.sync(&mut network), "second device is already in sync");
b.ours.retain(|u| u != "wss://d2.example");
b.declined.push("wss://d2.example".to_string());
assert!(b.sync(&mut network), "disable must publish");
assert!(!network.0.contains(&"wss://d2.example".to_string()));
assert!(!a.sync(&mut network), "A must adopt the removal, not republish d2");
assert!(a.declined.contains(&"wss://d2.example".to_string()));
assert!(!network.0.contains(&"wss://d2.example".to_string()), "no resurrection");
b.declined.retain(|u| u != "wss://d2.example");
b.ours.push("wss://d2.example".to_string());
assert!(b.sync(&mut network), "re-enable must publish");
assert!(!a.sync(&mut network), "revive is inbound-only, no republish");
assert!(a.ours.contains(&"wss://d2.example".to_string()), "A revives d2");
for _ in 0..3 {
assert!(!a.sync(&mut network));
assert!(!b.sync(&mut network));
}
}
#[test]
fn parse_relay_tags_extracts_urls() {
let tags = Tags::from_list(vec![
Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
]);
let result = parse_relay_tags(&tags);
assert_eq!(result, vec![
"wss://relay.example.com".to_string(),
"wss://other.example.com".to_string(),
]);
}
#[test]
fn parse_relay_tags_ignores_non_relay_tags() {
let tags = Tags::from_list(vec![
Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
]);
let result = parse_relay_tags(&tags);
assert_eq!(result, vec!["wss://good.example.com".to_string()]);
}
#[test]
fn parse_relay_tags_empty() {
let tags = Tags::new();
let result = parse_relay_tags(&tags);
assert!(result.is_empty());
}
#[test]
fn parse_relay_tags_ignores_relay_tag_without_value() {
let tags = Tags::from_list(vec![
Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
]);
let result = parse_relay_tags(&tags);
assert!(result.is_empty());
}
fn test_pubkey() -> PublicKey {
let keys = Keys::generate();
keys.public_key()
}
static TEST_GLOBALS_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
#[test]
fn cache_stores_and_retrieves() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
let relays = vec!["wss://a.example.com".to_string()];
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: relays.clone(),
fetched_at: Instant::now(),
fetch_ok: true,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert_eq!(entry.relays, relays);
assert!(entry.fetch_ok);
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
}
#[test]
fn cache_expires_after_ttl() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: vec!["wss://stale.example.com".to_string()],
fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
fetch_ok: true,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
}
#[test]
fn cache_stores_empty_results() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: vec![],
fetched_at: Instant::now(),
fetch_ok: true,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert!(entry.relays.is_empty());
assert!(entry.fetch_ok);
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
}
#[test]
fn cache_error_uses_short_ttl() {
let _guard = TEST_GLOBALS_LOCK.blocking_lock();
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.insert(pk, CachedRelays {
relays: vec![],
fetched_at: Instant::now() - std::time::Duration::from_secs(120),
fetch_ok: false,
});
}
let cache = INBOX_RELAY_CACHE.lock().unwrap();
let entry = cache.get(&pk).unwrap();
assert!(!entry.fetch_ok);
assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
}
#[tokio::test]
async fn concurrent_fetches_for_same_pubkey_serialize() {
let _guard = TEST_GLOBALS_LOCK.lock().await;
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.remove(&pk);
}
let fetch_counter = Arc::new(AtomicU64::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = fetch_counter.clone();
let handle = tokio::spawn(async move {
get_or_fetch_with_lock(&pk, || async {
counter.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
FetchResult {
relays: vec!["wss://test.example.com".to_string()],
fetch_ok: true,
}
})
.await
});
handles.push(handle);
}
let results = futures_util::future::join_all(handles).await;
for result in &results {
assert!(result.is_ok());
let relays = result.as_ref().unwrap();
assert_eq!(relays, &vec!["wss://test.example.com".to_string()]);
}
assert_eq!(
fetch_counter.load(Ordering::SeqCst),
1,
"Expected exactly 1 fetch for 10 concurrent requests to same pubkey"
);
let locks_after = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after, 0, "Lock entry should be removed after all waiters complete");
}
#[tokio::test]
async fn fetch_locks_do_not_accumulate_after_calls_complete() {
let _guard = TEST_GLOBALS_LOCK.lock().await;
let pk1 = test_pubkey();
let pk2 = test_pubkey();
let pk3 = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.clear();
}
{
let mut locks = FETCH_LOCKS.lock().unwrap();
locks.clear();
}
get_or_fetch_with_lock(&pk1, || async {
FetchResult {
relays: vec!["wss://relay1.example.com".to_string()],
fetch_ok: true,
}
})
.await;
let locks_after_pk1 = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after_pk1, 0, "No lock entries should remain after pk1 call");
get_or_fetch_with_lock(&pk2, || async {
FetchResult {
relays: vec!["wss://relay2.example.com".to_string()],
fetch_ok: true,
}
})
.await;
let locks_after_pk2 = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after_pk2, 0, "No lock entries should remain after pk2 call");
get_or_fetch_with_lock(&pk3, || async {
FetchResult {
relays: vec!["wss://relay3.example.com".to_string()],
fetch_ok: true,
}
})
.await;
let locks_after_pk3 = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(locks_after_pk3, 0, "No lock entries should remain after pk3 call");
}
#[tokio::test]
async fn cancelled_fetch_cleans_up_lock_entry() {
let _guard = TEST_GLOBALS_LOCK.lock().await;
let pk = test_pubkey();
{
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
cache.clear();
}
{
let mut locks = FETCH_LOCKS.lock().unwrap();
locks.clear();
}
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
let task_pk = pk;
let handle = tokio::spawn(async move {
get_or_fetch_with_lock(&task_pk, || async move {
let _ = started_tx.send(());
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
FetchResult { relays: Vec::new(), fetch_ok: false }
})
.await
});
started_rx.await.expect("fetch closure should start before abort");
handle.abort();
let _ = handle.await;
tokio::task::yield_now().await;
let locks_after = {
let locks = FETCH_LOCKS.lock().unwrap();
locks.len()
};
assert_eq!(
locks_after, 0,
"Lock entry should be removed even if fetch task is cancelled"
);
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_rapid_calls_into_one() {
let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
republish_inbox_relays_debounced();
republish_inbox_relays_debounced();
republish_inbox_relays_debounced();
let gen_after = REPUBLISH_GEN.load(Ordering::SeqCst);
assert_eq!(gen_after, gen_before + 3);
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
assert_eq!(pass_after - pass_before, 1);
}
}