use nostr_sdk::prelude::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Evidence {
Fast,
#[default]
Quorum,
Full,
}
#[derive(Clone, Debug, Default)]
pub struct Query {
pub kinds: Vec<u16>,
pub z_tags: Vec<String>,
pub d_tags: Vec<String>,
pub p_tags: Vec<String>,
pub k_tags: Vec<String>,
pub authors: Vec<String>,
pub since: Option<u64>,
pub until: Option<u64>,
pub limit: Option<usize>,
pub evidence: Evidence,
}
impl Query {
pub fn matches(&self, event: &Event) -> bool {
if !self.kinds.is_empty() && !self.kinds.iter().any(|k| Kind::Custom(*k) == event.kind) {
return false;
}
if let Some(since) = self.since {
if event.created_at.as_secs() < since {
return false;
}
}
if let Some(until) = self.until {
if event.created_at.as_secs() > until {
return false;
}
}
if !self.authors.is_empty() && !self.authors.iter().any(|a| *a == event.pubkey.to_hex()) {
return false;
}
if !self.z_tags.is_empty() && !self.matches_single_letter("z", &self.z_tags, event) {
return false;
}
if !self.d_tags.is_empty() && !self.matches_single_letter("d", &self.d_tags, event) {
return false;
}
if !self.p_tags.is_empty() && !self.matches_single_letter("p", &self.p_tags, event) {
return false;
}
if !self.k_tags.is_empty() && !self.matches_single_letter("k", &self.k_tags, event) {
return false;
}
true
}
fn matches_single_letter(&self, name: &str, wanted: &[String], event: &Event) -> bool {
event.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == name && wanted.iter().any(|w| *w == s[1])
})
}
pub fn to_filter(&self) -> Filter {
let mut filter = Filter::new();
if !self.kinds.is_empty() {
filter = filter.kinds(self.kinds.iter().map(|k| Kind::Custom(*k)));
}
if !self.z_tags.is_empty() {
filter = filter
.custom_tags(SingleLetterTag::lowercase(Alphabet::Z), self.z_tags.clone());
}
if !self.d_tags.is_empty() {
filter = filter.identifiers(self.d_tags.clone());
}
if !self.p_tags.is_empty() {
filter = filter
.custom_tags(SingleLetterTag::lowercase(Alphabet::P), self.p_tags.clone());
}
if !self.k_tags.is_empty() {
filter = filter
.custom_tags(SingleLetterTag::lowercase(Alphabet::K), self.k_tags.clone());
}
if !self.authors.is_empty() {
let authors: Vec<PublicKey> =
self.authors.iter().filter_map(|a| PublicKey::from_hex(a).ok()).collect();
if !authors.is_empty() {
filter = filter.authors(authors);
}
}
if let Some(since) = self.since {
filter = filter.since(Timestamp::from_secs(since));
}
if let Some(until) = self.until {
filter = filter.until(Timestamp::from_secs(until));
}
if let Some(limit) = self.limit {
filter = filter.limit(limit);
}
filter
}
}
#[async_trait::async_trait]
pub trait Transport {
async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String>;
async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String>;
async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String>;
async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String>;
}
pub const MAX_PUBLISH_ATTEMPTS: usize = 30;
pub const RESIDUAL_GRACE_MS: u64 = 400;
pub const QUORUM_GRACE_MS: u64 = 2000;
const BREAKER_TRIP_THRESHOLD: u8 = 2;
const BREAKER_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
const TRIPPED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(6);
pub const CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
pub async fn durable_broadcast<'a, F>(
relays: &[String],
max_attempts: usize,
backoff: std::time::Duration,
mut send_round: F,
) -> Result<(), String>
where
F: FnMut(Vec<String>) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send + 'a>>,
{
let mut pending: Vec<String> = Vec::new();
for r in relays {
if !pending.contains(r) {
pending.push(r.clone());
}
}
let total = pending.len();
if total == 0 {
return Err("no relays to broadcast to".to_string());
}
for attempt in 0..max_attempts {
if pending.is_empty() {
break;
}
let acked = send_round(pending.clone()).await;
pending.retain(|r| !acked.contains(r));
if pending.is_empty() || attempt + 1 == max_attempts {
break;
}
if !backoff.is_zero() {
tokio::time::sleep(backoff).await;
}
}
if pending.len() < total {
Ok(()) } else {
Err(format!("no relay accepted the event after {max_attempts} attempts each"))
}
}
pub trait CommunityIngestSink: Send + Sync + 'static {
fn ingest_stragglers(&self, events: Vec<Event>);
}
static INGEST_SINK: std::sync::OnceLock<Box<dyn CommunityIngestSink>> = std::sync::OnceLock::new();
pub fn set_community_ingest_sink(sink: Box<dyn CommunityIngestSink>) {
let _ = INGEST_SINK.set(sink);
}
fn submit_stragglers(events: Vec<Event>) {
if events.is_empty() {
return;
}
if let Some(sink) = INGEST_SINK.get() {
sink.ingest_stragglers(events);
}
}
static WARMED_RELAYS: std::sync::LazyLock<std::sync::Mutex<(u64, std::collections::HashSet<String>)>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashSet::new())));
pub fn forget_warmed_relay(url: &str) {
WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner()).1.remove(url);
}
#[derive(Default)]
struct BreakerEntry {
consecutive_failures: u8,
tripped_until: Option<std::time::Instant>,
}
static RELAY_BREAKER: std::sync::LazyLock<
std::sync::Mutex<(u64, std::collections::HashMap<String, BreakerEntry>)>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashMap::new())));
fn with_breaker_at<R>(
generation: u64,
f: impl FnOnce(&mut std::collections::HashMap<String, BreakerEntry>) -> R,
) -> R {
let mut guard = RELAY_BREAKER.lock().unwrap_or_else(|e| e.into_inner());
if guard.0 != generation {
guard.0 = generation;
guard.1.clear();
}
f(&mut guard.1)
}
fn breaker_tripped(url: &str) -> bool {
breaker_tripped_at(crate::state::current_session_generation(), url)
}
fn breaker_tripped_at(generation: u64, url: &str) -> bool {
with_breaker_at(generation, |map| {
map.get(url)
.and_then(|e| e.tripped_until)
.map_or(false, |t| std::time::Instant::now() < t)
})
}
fn breaker_record(url: &str, success: bool, full_budget: bool) {
breaker_record_at(crate::state::current_session_generation(), url, success, full_budget)
}
fn breaker_record_at(generation: u64, url: &str, success: bool, full_budget: bool) {
with_breaker_at(generation, |map| {
if success {
map.remove(url);
return;
}
if !full_budget {
return;
}
let e = map.entry(url.to_string()).or_default();
e.consecutive_failures = e.consecutive_failures.saturating_add(1);
if e.consecutive_failures >= BREAKER_TRIP_THRESHOLD {
e.tripped_until = Some(std::time::Instant::now() + BREAKER_COOLDOWN);
}
})
}
fn effective_evidence(query: &Query) -> Evidence {
if query.until.is_some() {
Evidence::Full
} else {
query.evidence
}
}
struct PooledPlane {
client: Client,
last_used: std::time::Instant,
}
static PLANE_POOL: std::sync::LazyLock<std::sync::Mutex<(u64, std::collections::HashMap<String, PooledPlane>)>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashMap::new())));
const PLANE_POOL_IDLE_TTL: std::time::Duration = std::time::Duration::from_secs(90);
const PLANE_POOL_MAX: usize = 24;
fn plane_pool_key(plane_pk: &str, relays: &[String]) -> String {
let mut rs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
rs.sort_unstable();
let mut k = String::with_capacity(plane_pk.len() + 1 + rs.iter().map(|r| r.len() + 1).sum::<usize>());
k.push_str(plane_pk);
k.push('|');
k.push_str(&rs.join(","));
k
}
fn disconnect_clients(clients: Vec<Client>) {
if clients.is_empty() {
return;
}
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
for c in clients {
handle.spawn(async move {
let _ = c.disconnect();
});
}
}
Err(_) => drop(clients),
}
}
pub fn clear_plane_pool() {
let drained: Vec<Client> = {
let mut g = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
g.0 = crate::state::current_session_generation();
g.1.drain().map(|(_, p)| p.client).collect()
};
disconnect_clients(drained);
}
fn plane_pool_take(generation: u64, key: &str) -> (Option<Client>, Vec<Client>) {
let mut g = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
let mut evicted: Vec<Client> = Vec::new();
if g.0 != generation {
evicted.extend(g.1.drain().map(|(_, p)| p.client));
g.0 = generation;
}
let now = std::time::Instant::now();
let expired: Vec<String> = g.1.iter()
.filter(|(_, p)| now.duration_since(p.last_used) >= PLANE_POOL_IDLE_TTL)
.map(|(k, _)| k.clone())
.collect();
for k in expired {
if let Some(p) = g.1.remove(&k) {
evicted.push(p.client);
}
}
let hit = g.1.get_mut(key).map(|p| {
p.last_used = now;
p.client.clone()
});
(hit, evicted)
}
fn plane_pool_insert(generation: u64, key: String, client: Client) -> Vec<Client> {
let mut g = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
if g.0 != generation || g.1.contains_key(&key) {
return Vec::new();
}
let mut evicted: Vec<Client> = Vec::new();
if g.1.len() >= PLANE_POOL_MAX {
if let Some(lru_key) = g.1.iter().min_by_key(|(_, p)| p.last_used).map(|(k, _)| k.clone()) {
if let Some(p) = g.1.remove(&lru_key) {
evicted.push(p.client);
}
}
}
g.1.insert(key, PooledPlane { client, last_used: std::time::Instant::now() });
evicted
}
fn demotion_allowed() -> bool {
#[cfg(feature = "tor")]
{
matches!(crate::tor::transport_state(), crate::tor::TorTransportState::Disabled)
}
#[cfg(not(feature = "tor"))]
{
true
}
}
pub async fn fetch_relay_eose(
client: &Client,
url: &str,
filter: Filter,
timeout: std::time::Duration,
) -> Result<Vec<Event>, ()> {
let relay = client.pool().relay(url).await.map_err(|_| ())?;
let mut notifications = relay.notifications();
let sub_id = SubscriptionId::generate();
let auto_close = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(timeout));
relay
.subscribe_with_id(sub_id.clone(), filter, SubscribeOptions::default().close_on(Some(auto_close)))
.await
.map_err(|_| ())?;
let deadline = tokio::time::Instant::now() + timeout;
let mut events: Vec<Event> = Vec::new();
let mut seen: std::collections::HashSet<EventId> = std::collections::HashSet::new();
loop {
let notification = match tokio::time::timeout_at(deadline, notifications.recv()).await {
Ok(Ok(n)) => n,
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return Err(()),
Err(_) => return Err(()), };
match notification {
RelayNotification::Event { subscription_id, event } if subscription_id == sub_id => {
if seen.insert(event.id) {
events.push(*event);
}
}
RelayNotification::Message { message } => match message {
RelayMessage::Event { subscription_id, event } if *subscription_id == sub_id => {
if seen.insert(event.id) {
events.push(event.into_owned());
}
}
RelayMessage::EndOfStoredEvents(id) if *id == sub_id => return Ok(events),
RelayMessage::Closed { subscription_id, .. } if *subscription_id == sub_id => {
return Err(()); }
_ => {}
},
RelayNotification::Shutdown => return Err(()),
_ => {}
}
}
}
pub(crate) struct UnionPlan {
attempted: usize,
successes: usize,
resolved: usize,
evidence: Evidence,
}
impl UnionPlan {
pub(crate) fn new(evidence: Evidence, attempted: usize) -> Self {
Self { attempted, successes: 0, resolved: 0, evidence }
}
pub(crate) fn record(&mut self, success: bool) {
self.resolved += 1;
if success {
self.successes += 1;
}
}
pub(crate) fn satisfied(&self) -> bool {
match self.evidence {
Evidence::Fast => self.successes >= 1,
Evidence::Quorum => self.successes >= (self.attempted / 2) + 1,
Evidence::Full => self.resolved >= self.attempted,
}
}
pub(crate) fn exhausted(&self) -> bool {
self.resolved >= self.attempted
}
pub(crate) fn successes(&self) -> usize {
self.successes
}
pub(crate) fn attempted(&self) -> usize {
self.attempted
}
}
#[cfg(feature = "tor")]
const TOR_READY_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
#[allow(dead_code)]
async fn wait_until_tor_ready<F: Fn() -> bool>(
is_blocked: F,
max_wait: std::time::Duration,
) -> Result<(), String> {
if !is_blocked() {
return Ok(());
}
let deadline = std::time::Instant::now() + max_wait;
while is_blocked() {
if std::time::Instant::now() >= deadline {
return Err("Tor is still connecting. Wait a moment and try again.".to_string());
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
Ok(())
}
pub async fn prune_unneeded_community_relays(candidates: &[String]) {
if candidates.is_empty() {
return;
}
let Some(client) = crate::state::nostr_client() else { return };
let mut still_needed: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Ok(ids) = crate::db::community::list_community_ids() {
for id in ids {
if let Ok(Some(c)) = crate::db::community::load_community(&id) {
for r in &c.relays {
still_needed.insert(r.clone());
}
}
}
}
let pool = client.pool();
let pooled = pool.all_relays().await;
for url in candidates {
if still_needed.contains(url) {
continue;
}
if let Ok(parsed) = nostr_sdk::RelayUrl::parse(url) {
if let Some(relay) = pooled.get(&parsed) {
if relay.flags().has_read() || relay.flags().has_write() {
continue; }
}
let _ = pool.force_remove_relay(parsed).await; forget_warmed_relay(url);
}
}
}
pub struct LiveTransport {
timeout: std::time::Duration,
}
impl Default for LiveTransport {
fn default() -> Self {
Self { timeout: std::time::Duration::from_secs(10) }
}
}
impl LiveTransport {
pub fn new() -> Self {
Self::default()
}
pub fn with_timeout(timeout: std::time::Duration) -> Self {
Self { timeout }
}
async fn warm_client(relays: &[String], connect_timeout: std::time::Duration) -> Result<Client, String> {
if relays.is_empty() {
return Err("community has no relays configured".to_string());
}
#[cfg(feature = "tor")]
wait_until_tor_ready(
|| matches!(crate::tor::transport_state(), crate::tor::TorTransportState::RequiredButInactive),
TOR_READY_WAIT,
).await?;
let client = crate::state::nostr_client().ok_or_else(|| "nostr client not initialized".to_string())?;
let generation = crate::state::current_session_generation();
{
let warmed = WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner());
if warmed.0 == generation && relays.iter().all(|r| warmed.1.contains(r)) {
return Ok(client);
}
}
let mut added_new = false;
let mut succeeded: Vec<&String> = Vec::new();
for url in relays {
let opts = crate::community_relay_options();
match client.pool().add_relay(url.as_str(), opts).await {
Ok(true) => { added_new = true; succeeded.push(url); }
Ok(false) => { succeeded.push(url); }
Err(_) => {}
}
}
if succeeded.is_empty() {
return Err("no valid community relays could be added".to_string());
}
if added_new {
let _ = client.try_connect(connect_timeout).await;
} else {
client.connect().await;
}
{
let mut warmed = WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner());
if warmed.0 != generation {
warmed.0 = generation;
warmed.1.clear();
}
for url in succeeded {
warmed.1.insert(url.clone());
}
}
Ok(client)
}
pub async fn fetch_counted(&self, query: &Query, relays: &[String]) -> Result<(Vec<Event>, usize, usize), String> {
let client = Self::warm_client(relays, self.timeout).await?;
let base_timeout = self.timeout;
let filter = query.to_filter();
let evidence = effective_evidence(query);
let mut targets: Vec<String> = Vec::new();
for r in relays {
if !targets.contains(r) {
targets.push(r.clone());
}
}
if evidence == Evidence::Fast && targets.len() >= 2 {
let alive: Vec<String> =
targets.iter().filter(|r| !breaker_tripped(r)).cloned().collect();
if !alive.is_empty() {
targets = alive;
}
}
if targets.len() <= 1 {
let Some(url) = targets.first() else {
return Err("no valid relay to fetch from".to_string());
};
let res = fetch_relay_eose(&client, url, filter, base_timeout)
.await
.map_err(|_| format!("relay did not answer the fetch: {url}"));
breaker_record(url, res.is_ok(), true);
return res.map(|evs| (evs, 1, 1));
}
fn merge_events(
evs: Vec<Event>,
result: &mut Vec<Event>,
seen: &mut std::collections::HashSet<EventId>,
) {
for e in evs {
if seen.insert(e.id) {
result.push(e);
}
}
}
let demote = evidence == Evidence::Full && demotion_allowed();
use futures_util::stream::{FuturesUnordered, StreamExt};
let mut fetches: FuturesUnordered<_> = targets
.iter()
.map(|r| {
let client = client.clone();
let filter = filter.clone();
let r = r.clone();
let timeout = if demote && breaker_tripped(&r) {
TRIPPED_TIMEOUT.min(base_timeout)
} else {
base_timeout
};
let full_budget = timeout >= base_timeout;
tokio::spawn(async move {
let out = fetch_relay_eose(&client, &r, filter, timeout).await;
(r, full_budget, out)
})
})
.collect();
let mut plan = UnionPlan::new(evidence, targets.len());
let mut result: Vec<Event> = Vec::new();
let mut union_ids: std::collections::HashSet<EventId> = std::collections::HashSet::new();
let mut quorum_deadline: Option<tokio::time::Instant> = None;
let mut quorum_window_closed = false;
while !plan.satisfied() && !plan.exhausted() {
let next = match quorum_deadline {
Some(deadline) => match tokio::time::timeout_at(deadline, fetches.next()).await {
Ok(n) => n,
Err(_) => {
quorum_window_closed = true;
break; }
},
None => fetches.next().await,
};
let Some(joined) = next else { break };
match joined {
Ok((url, full_budget, Ok(evs))) => {
breaker_record(&url, true, full_budget);
merge_events(evs, &mut result, &mut union_ids);
plan.record(true);
if evidence == Evidence::Quorum && quorum_deadline.is_none() {
quorum_deadline = Some(
tokio::time::Instant::now()
+ std::time::Duration::from_millis(QUORUM_GRACE_MS),
);
}
}
Ok((url, full_budget, Err(()))) => {
breaker_record(&url, false, full_budget);
plan.record(false);
}
Err(_) => plan.record(false), }
}
if plan.successes() == 0 {
return Err(format!(
"no relay answered the fetch (0/{} attempted)",
plan.attempted()
));
}
if !fetches.is_empty() && !quorum_window_closed {
let grace = tokio::time::sleep(std::time::Duration::from_millis(RESIDUAL_GRACE_MS));
tokio::pin!(grace);
loop {
tokio::select! {
_ = &mut grace => break,
next = fetches.next() => match next {
Some(Ok((url, full_budget, Ok(evs)))) => {
breaker_record(&url, true, full_budget);
merge_events(evs, &mut result, &mut union_ids);
}
Some(Ok((url, full_budget, Err(())))) => {
breaker_record(&url, false, full_budget);
}
Some(Err(_)) => continue,
None => break,
}
}
}
}
if !fetches.is_empty() {
let seen: std::collections::HashSet<EventId> = result.iter().map(|e| e.id).collect();
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
let mut extra: Vec<Event> = Vec::new();
let mut extra_ids: std::collections::HashSet<EventId> = std::collections::HashSet::new();
while let Some(joined) = fetches.next().await {
if let Ok((url, full_budget, out)) = joined {
breaker_record(&url, out.is_ok(), full_budget);
if let Ok(evs) = out {
for e in evs {
if !seen.contains(&e.id) && extra_ids.insert(e.id) {
extra.push(e);
}
}
}
}
}
if !session.is_valid() {
return;
}
submit_stragglers(extra);
});
}
Ok((result, plan.successes(), plan.attempted()))
}
}
#[async_trait::async_trait]
impl Transport for LiveTransport {
async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
let client = Self::warm_client(relays, self.timeout).await?;
let timeout = self.timeout;
let mut targets: Vec<String> = Vec::new();
for r in relays { if !targets.contains(r) { targets.push(r.clone()); } }
use futures_util::stream::{FuturesUnordered, StreamExt};
let mut sends: FuturesUnordered<_> = targets
.into_iter()
.map(|r| {
let client = client.clone();
let event = event.clone();
tokio::spawn(async move {
matches!(
tokio::time::timeout(timeout, client.send_event_to(vec![r.clone()], &event)).await,
Ok(Ok(out)) if RelayUrl::parse(&r).map(|u| out.success.contains(&u)).unwrap_or(false)
)
})
})
.collect();
while let Some(joined) = sends.next().await {
if matches!(joined, Ok(true)) {
return Ok(()); }
}
Err("no relay accepted the event".to_string())
}
async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
self.fetch_counted(query, relays).await.map(|(events, _successes, _attempted)| events)
}
async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
if relays.is_empty() {
return Ok(Vec::new());
}
#[cfg(feature = "tor")]
wait_until_tor_ready(
|| matches!(crate::tor::transport_state(), crate::tor::TorTransportState::RequiredButInactive),
TOR_READY_WAIT,
).await?;
let mut targets: Vec<String> = relays.iter().filter(|r| !breaker_tripped(r)).cloned().collect();
if targets.is_empty() {
targets = relays.to_vec();
}
let filter = query.to_filter();
let generation = crate::state::current_session_generation();
let key = plane_pool_key(&plane.public_key().to_hex(), &targets);
let (hit, evicted) = plane_pool_take(generation, &key);
disconnect_clients(evicted);
let client = if let Some(c) = hit {
c
} else {
let opts = crate::nostr_client_options().automatic_authentication(true);
let client = nostr_sdk::Client::builder().signer(plane.clone()).opts(opts).build();
for r in &targets {
let _ = client.pool().add_relay(r.clone(), crate::community_relay_options()).await;
}
client.connect().await;
for r in &targets {
let _ = client
.fetch_events_from(vec![r.clone()], filter.clone(), std::time::Duration::from_secs(5))
.await;
}
let ev = plane_pool_insert(generation, key, client.clone());
disconnect_clients(ev);
client
};
let mut result: Vec<Event> = Vec::new();
let mut seen: std::collections::HashSet<EventId> = std::collections::HashSet::new();
let mut successes = 0usize;
for r in &targets {
let res = fetch_relay_eose(&client, r, filter.clone(), self.timeout).await;
breaker_record(r, res.is_ok(), true);
if let Ok(events) = res {
successes += 1;
for e in events {
if seen.insert(e.id) {
result.push(e);
}
}
}
}
if successes == 0 {
return Err(format!("no relay answered the plane fetch (0/{} attempted)", targets.len()));
}
Ok(result)
}
async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
let client = Self::warm_client(relays, self.timeout).await?;
let timeout = self.timeout;
let event = event.clone();
let backoff = std::time::Duration::from_millis(750);
let mut pending: Vec<String> = Vec::new();
for r in relays { if !pending.contains(r) { pending.push(r.clone()); } }
if pending.is_empty() {
return Err("no relays to broadcast to".to_string());
}
let mut acked_any = false;
let _ = tokio::time::timeout(CONFIRM_WINDOW, async {
loop {
let sends = pending.iter().cloned().map(|r| {
let client = &client;
let event = &event;
Box::pin(async move {
match tokio::time::timeout(timeout, client.send_event_to(vec![r.clone()], event)).await {
Ok(Ok(out)) if RelayUrl::parse(&r).map(|u| out.success.contains(&u)).unwrap_or(false) => Ok(r),
_ => Err(()),
}
})
});
if let Ok((winner, _losers)) = futures_util::future::select_ok(sends).await {
acked_any = true;
pending.retain(|r| r != &winner);
break;
}
tokio::time::sleep(backoff).await;
}
})
.await;
if !acked_any {
return Err(format!("no relay accepted the event within {}s", CONFIRM_WINDOW.as_secs()));
}
if pending.is_empty() {
return Ok(()); }
tokio::spawn(async move {
let client_ref = &client;
let event_ref = &event;
let _ = durable_broadcast(&pending, MAX_PUBLISH_ATTEMPTS, backoff, move |round| {
Box::pin(async move {
match tokio::time::timeout(timeout, client_ref.send_event_to(round.clone(), event_ref)).await {
Ok(Ok(output)) => round.into_iter().filter(|p| RelayUrl::parse(p).map(|u| output.success.contains(&u)).unwrap_or(false)).collect(),
_ => Vec::new(),
}
})
})
.await;
});
Ok(())
}
}
#[cfg(test)]
pub(crate) mod memory {
use super::*;
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
pub struct MemoryRelay {
per_relay: Mutex<HashMap<String, Vec<Event>>>,
subscribers: Mutex<Vec<(Query, tokio::sync::mpsc::UnboundedSender<Event>)>>,
}
fn is_ephemeral(kind: u16) -> bool {
(20000..30000).contains(&kind)
}
impl MemoryRelay {
pub fn new() -> Self {
MemoryRelay {
per_relay: Mutex::new(HashMap::new()),
subscribers: Mutex::new(Vec::new()),
}
}
pub fn subscribe(&self, query: Query) -> tokio::sync::mpsc::UnboundedReceiver<Event> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
self.subscribers.lock().unwrap().push((query, tx));
rx
}
fn deliver(&self, event: &Event) {
self.subscribers.lock().unwrap().retain(|(q, tx)| {
if q.matches(event) {
tx.send(event.clone()).is_ok()
} else {
!tx.is_closed()
}
});
}
pub fn inject(&self, event: &Event, relays: &[String]) {
self.deliver(event);
if is_ephemeral(event.kind.as_u16()) {
return; }
let d_tag = |e: &Event| e.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
}).unwrap_or_default();
let k = event.kind.as_u16();
let replaceable = (30000..40000).contains(&k) || (10000..20000).contains(&k) || k == 0 || k == 3;
let coord = (event.kind.as_u16(), event.pubkey, d_tag(event));
let mut map = self.per_relay.lock().unwrap();
for r in relays {
let v = map.entry(r.clone()).or_default();
if replaceable {
v.retain(|e| (e.kind.as_u16(), e.pubkey, d_tag(e)) != coord);
}
v.push(event.clone());
}
}
pub fn count_on(&self, relay: &str) -> usize {
self.per_relay.lock().unwrap().get(relay).map_or(0, |v| v.len())
}
fn apply_deletion(&self, deletion: &Event, relays: &[String]) {
let mut id_targets: HashSet<String> = HashSet::new();
let mut coord_targets: HashSet<String> = HashSet::new();
for t in deletion.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "e" {
id_targets.insert(s[1].clone());
} else if s.len() >= 2 && s[0] == "a" {
coord_targets.insert(s[1].clone());
}
}
let mut map = self.per_relay.lock().unwrap();
for r in relays {
if let Some(events) = map.get_mut(r) {
events.retain(|e| {
if e.pubkey != deletion.pubkey {
return true;
}
if id_targets.contains(&e.id.to_hex()) {
return false;
}
let d = e.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
}).unwrap_or_default();
let coord = format!("{}:{}:{}", e.kind.as_u16(), e.pubkey.to_hex(), d);
!coord_targets.contains(&coord)
});
}
}
}
}
#[async_trait::async_trait]
impl Transport for MemoryRelay {
async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
if event.kind == Kind::EventDeletion {
self.apply_deletion(event, relays);
self.deliver(event);
} else {
self.inject(event, relays);
}
Ok(())
}
async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
self.publish(event, relays).await
}
async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
let map = self.per_relay.lock().unwrap();
let mut seen = HashSet::new();
let mut out = Vec::new();
for r in relays {
if let Some(events) = map.get(r) {
for ev in events {
if is_ephemeral(ev.kind.as_u16()) {
continue;
}
if query.matches(ev) && seen.insert(ev.id) {
out.push(ev.clone());
}
}
}
}
if let Some(limit) = query.limit {
out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
out.truncate(limit);
}
Ok(out)
}
async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
self.fetch(query, relays).await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn tor_gate_passes_immediately_when_not_blocked() {
let start = std::time::Instant::now();
let res = wait_until_tor_ready(|| false, std::time::Duration::from_secs(30)).await;
assert!(res.is_ok());
assert!(start.elapsed() < std::time::Duration::from_secs(1), "must not wait when Tor is ready");
}
#[tokio::test]
async fn tor_gate_errors_honestly_after_timeout_when_perpetually_blocked() {
let res = wait_until_tor_ready(|| true, std::time::Duration::from_millis(300)).await;
let err = res.expect_err("should error when Tor never activates");
assert!(err.to_lowercase().contains("tor"), "error must name Tor, got: {err}");
}
#[tokio::test]
async fn tor_gate_passes_once_circuit_comes_up_mid_wait() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let res = wait_until_tor_ready(
|| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 3,
std::time::Duration::from_secs(5),
).await;
assert!(res.is_ok(), "should succeed once Tor activates within the window");
}
fn evt(kind: u16, z: &str) -> Event {
EventBuilder::new(Kind::Custom(kind), "x")
.tags([Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[z.to_string()],
)])
.sign_with_keys(&Keys::generate())
.unwrap()
}
#[test]
fn query_matches_kind_and_z() {
let e = evt(3300, "abc");
assert!(Query { kinds: vec![3300], z_tags: vec!["abc".into()], since: None, ..Default::default() }.matches(&e));
assert!(!Query { kinds: vec![3301], ..Default::default() }.matches(&e));
assert!(!Query { kinds: vec![], z_tags: vec!["xyz".into()], since: None, ..Default::default() }.matches(&e));
assert!(Query::default().matches(&e), "empty query matches anything");
}
fn evt_at(kind: u16, secs: u64) -> Event {
EventBuilder::new(Kind::Custom(kind), "x")
.custom_created_at(Timestamp::from(secs))
.sign_with_keys(&Keys::generate())
.unwrap()
}
fn evt_z_at(kind: u16, z: &str, secs: u64) -> Event {
EventBuilder::new(Kind::Custom(kind), "x")
.custom_created_at(Timestamp::from(secs))
.tags([Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[z.to_string()],
)])
.sign_with_keys(&Keys::generate())
.unwrap()
}
#[tokio::test]
async fn fetch_pages_with_until_and_limit_newest_first() {
let relay = super::memory::MemoryRelay::new();
let relays = vec!["r1".to_string()];
for s in 1..=5u64 {
relay.inject(&evt_z_at(3300, "pg", s), &relays);
}
let secs = |evs: &[Event]| evs.iter().map(|e| e.created_at.as_secs()).collect::<Vec<_>>();
let latest = relay
.fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], limit: Some(2), ..Default::default() }, &relays)
.await
.unwrap();
assert_eq!(secs(&latest), vec![5, 4]);
let older = relay
.fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], until: Some(3), limit: Some(2), ..Default::default() }, &relays)
.await
.unwrap();
assert_eq!(secs(&older), vec![3, 2]);
let start = relay
.fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], until: Some(1), limit: Some(2), ..Default::default() }, &relays)
.await
.unwrap();
assert_eq!(secs(&start), vec![1]);
}
#[test]
fn to_filter_translates_kinds_z_and_since() {
let q = Query { kinds: vec![3300], z_tags: vec!["abc".into()], since: Some(100), ..Default::default() };
let filter = q.to_filter();
let matching = EventBuilder::new(Kind::Custom(3300), "x")
.custom_created_at(Timestamp::from(150))
.tags([Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
["abc".to_string()],
)])
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(filter.match_event(&matching, MatchEventOptions::new()), "to_filter must accept what matches() accepts");
assert!(q.matches(&matching));
let wrong_kind = EventBuilder::new(Kind::Custom(3301), "x")
.custom_created_at(Timestamp::from(150))
.tags([Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
["abc".to_string()],
)])
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(!filter.match_event(&wrong_kind, MatchEventOptions::new()));
}
fn evt_sl(kind: u16, letter: Alphabet, value: &str) -> Event {
EventBuilder::new(Kind::Custom(kind), "x")
.tags([Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(letter)),
[value.to_string()],
)])
.sign_with_keys(&Keys::generate())
.unwrap()
}
#[test]
fn to_filter_and_matches_agree_on_authors() {
let keys = Keys::generate();
let e = EventBuilder::new(Kind::Custom(1059), "x").sign_with_keys(&keys).unwrap();
let q = Query { kinds: vec![1059], authors: vec![keys.public_key().to_hex()], ..Default::default() };
assert!(q.matches(&e));
assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
let miss = Query { kinds: vec![1059], authors: vec![Keys::generate().public_key().to_hex()], ..Default::default() };
assert!(!miss.matches(&e));
assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
}
#[test]
fn to_filter_and_matches_agree_on_p_tags() {
let recipient = Keys::generate().public_key().to_hex();
let e = evt_sl(1059, Alphabet::P, &recipient);
let q = Query { kinds: vec![1059], p_tags: vec![recipient], ..Default::default() };
assert!(q.matches(&e));
assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
let miss = Query { kinds: vec![1059], p_tags: vec![Keys::generate().public_key().to_hex()], ..Default::default() };
assert!(!miss.matches(&e));
assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
}
#[test]
fn to_filter_and_matches_agree_on_k_tags() {
let e = evt_sl(1059, Alphabet::K, "3311");
let q = Query { kinds: vec![1059], k_tags: vec!["3311".into()], ..Default::default() };
assert!(q.matches(&e));
assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
let miss = Query { kinds: vec![1059], k_tags: vec!["3300".into()], ..Default::default() };
assert!(!miss.matches(&e));
assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
}
#[test]
fn to_filter_empty_kinds_only_constrains_z_and_since() {
let q = Query { kinds: vec![], z_tags: vec!["p".into()], since: None, ..Default::default() };
let filter = q.to_filter();
let e = evt(3300, "p");
assert!(filter.match_event(&e, MatchEventOptions::new()));
assert!(q.matches(&e));
}
#[test]
fn since_is_an_inclusive_lower_bound() {
let below = evt_at(3300, 99);
let exact = evt_at(3300, 100);
let above = evt_at(3300, 101);
let q = Query { kinds: vec![3300], z_tags: vec![], since: Some(100), ..Default::default() };
assert!(!q.matches(&below), "below the floor is excluded");
assert!(q.matches(&exact), "exactly the floor is included");
assert!(q.matches(&above), "above the floor is included");
}
#[test]
fn z_tags_match_as_or_set() {
let e = evt(3300, "p2");
let q = Query { kinds: vec![3300], z_tags: vec!["p1".into(), "p2".into()], since: None, ..Default::default() };
assert!(q.matches(&e));
let miss = Query { kinds: vec![3300], z_tags: vec!["p1".into(), "p3".into()], since: None, ..Default::default() };
assert!(!miss.matches(&e));
}
#[tokio::test]
async fn fetch_unions_and_dedups_across_relays() {
use super::memory::MemoryRelay;
let relay = MemoryRelay::new();
let relays = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
let e = evt(3300, "p");
relay.publish(&e, &relays).await.unwrap();
let got = relay
.fetch(&Query { kinds: vec![3300], z_tags: vec!["p".into()], since: None, ..Default::default() }, &relays)
.await
.unwrap();
assert_eq!(got.len(), 1, "same event on 3 relays dedups to 1");
}
#[tokio::test]
async fn durable_broadcast_retries_only_the_failing_relays_until_they_ack() {
use std::cell::Cell;
let relays = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
let round = Cell::new(0usize);
let r2_round_seen = Cell::new(0usize);
let res = durable_broadcast(&relays, 30, std::time::Duration::ZERO, |pending| {
let n = round.get();
round.set(n + 1);
if n >= 1 {
assert_eq!(pending, vec!["r2".to_string()], "only the failing relay is retried");
r2_round_seen.set(r2_round_seen.get() + 1);
}
Box::pin(async move {
pending.into_iter().filter(|r| r != "r2" || n >= 4).collect()
})
})
.await;
assert!(res.is_ok(), "all relays eventually ACK → Ok");
assert!(round.get() >= 5, "kept retrying r2 across rounds");
}
#[tokio::test]
async fn durable_broadcast_is_ok_if_some_ack_even_when_one_never_does() {
let relays = vec!["r1".to_string(), "r2".to_string()];
let res = durable_broadcast(&relays, 5, std::time::Duration::ZERO, |pending| {
Box::pin(async move { pending.into_iter().filter(|r| r == "r1").collect() })
})
.await;
assert!(res.is_ok(), "≥1 relay accepted → Ok despite a permanently-failing relay");
}
#[tokio::test]
async fn durable_broadcast_errs_only_if_zero_relays_ever_accept() {
let relays = vec!["r1".to_string(), "r2".to_string()];
let res = durable_broadcast(&relays, 5, std::time::Duration::ZERO, |_pending| {
Box::pin(async move { Vec::new() }) })
.await;
assert!(res.is_err(), "zero acceptances after the retry cap → Err");
}
#[tokio::test]
async fn redundancy_self_heals_a_missing_relay() {
use super::memory::MemoryRelay;
let relay = MemoryRelay::new();
let all = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
let e = evt(3300, "p");
relay.inject(&e, &["r2".to_string()]);
assert_eq!(relay.count_on("r1"), 0);
assert_eq!(relay.count_on("r2"), 1);
let got = relay.fetch(&Query { kinds: vec![3300], ..Default::default() }, &all).await.unwrap();
assert_eq!(got.len(), 1);
}
#[tokio::test]
async fn ephemeral_kind_streams_live_but_is_never_stored_or_fetched() {
use super::memory::MemoryRelay;
let relay = MemoryRelay::new();
let relays = vec!["r1".to_string()];
let mut sub = relay.subscribe(Query { kinds: vec![21059], ..Default::default() });
let e = evt(21059, "p");
relay.publish(&e, &relays).await.unwrap();
assert_eq!(relay.count_on("r1"), 0, "ephemeral is never stored");
let got = relay
.fetch(&Query { kinds: vec![21059], ..Default::default() }, &relays)
.await
.unwrap();
assert!(got.is_empty(), "a real relay never serves an ephemeral from a fetch");
assert_eq!(sub.try_recv().unwrap().id, e.id, "but a live subscriber receives it");
}
#[tokio::test]
async fn stored_kind_is_fetchable_and_delivered_live() {
use super::memory::MemoryRelay;
let relay = MemoryRelay::new();
let relays = vec!["r1".to_string()];
let mut sub = relay.subscribe(Query { kinds: vec![1059], ..Default::default() });
let e = evt(1059, "p");
relay.publish(&e, &relays).await.unwrap();
let got = relay
.fetch(&Query { kinds: vec![1059], ..Default::default() }, &relays)
.await
.unwrap();
assert_eq!(got.len(), 1, "stored kind is fetchable");
assert_eq!(sub.try_recv().unwrap().id, e.id, "and delivered to the live subscriber");
}
#[tokio::test]
async fn p_tags_route_a_giftwrap_to_the_matching_subscriber() {
use super::memory::MemoryRelay;
let relay = MemoryRelay::new();
let relays = vec!["r1".to_string()];
let alice = Keys::generate().public_key().to_hex();
let bob = Keys::generate().public_key().to_hex();
let mut sub_alice =
relay.subscribe(Query { kinds: vec![1059], p_tags: vec![alice.clone()], ..Default::default() });
let mut sub_bob =
relay.subscribe(Query { kinds: vec![1059], p_tags: vec![bob.clone()], ..Default::default() });
let wrap = evt_sl(1059, Alphabet::P, &alice);
relay.publish(&wrap, &relays).await.unwrap();
assert_eq!(sub_alice.try_recv().unwrap().id, wrap.id, "addressed recipient gets it live");
assert!(sub_bob.try_recv().is_err(), "a differently-addressed subscriber does not");
let for_alice = relay
.fetch(&Query { kinds: vec![1059], p_tags: vec![alice], ..Default::default() }, &relays)
.await
.unwrap();
assert_eq!(for_alice.len(), 1);
let for_bob = relay
.fetch(&Query { kinds: vec![1059], p_tags: vec![bob], ..Default::default() }, &relays)
.await
.unwrap();
assert!(for_bob.is_empty());
}
#[test]
fn union_plan_fast_satisfied_on_first_success() {
let mut p = UnionPlan::new(Evidence::Fast, 4);
p.record(false);
assert!(!p.satisfied(), "a failure is not evidence");
p.record(true);
assert!(p.satisfied(), "one genuine EOSE satisfies Fast");
assert!(!p.exhausted());
}
#[test]
fn union_plan_quorum_majority_math() {
for (n, need) in [(2usize, 2usize), (3, 2), (4, 3), (5, 3)] {
let mut p = UnionPlan::new(Evidence::Quorum, n);
for _ in 0..need - 1 {
p.record(true);
}
assert!(!p.satisfied(), "{}/{} must not satisfy quorum", need - 1, n);
p.record(true);
assert!(p.satisfied(), "{}/{} satisfies quorum", need, n);
}
}
#[test]
fn union_plan_quorum_failures_never_substitute_for_successes() {
let mut p = UnionPlan::new(Evidence::Quorum, 3);
p.record(true);
p.record(false);
p.record(false);
assert!(!p.satisfied(), "1 success + 2 failures is not a majority");
assert!(p.exhausted(), "all resolved — the degraded path returns best-effort");
assert_eq!(p.successes(), 1);
}
#[test]
fn union_plan_full_requires_every_relay_resolved() {
let mut p = UnionPlan::new(Evidence::Full, 3);
p.record(true);
p.record(true);
assert!(!p.satisfied(), "Full waits for the last relay even after 2 EOSEs");
p.record(false);
assert!(p.satisfied(), "a timeout is a resolution — Full is done");
assert!(p.exhausted());
}
#[test]
fn union_plan_all_dead_is_reportable_not_a_confident_empty() {
let mut p = UnionPlan::new(Evidence::Quorum, 2);
p.record(false);
p.record(false);
assert!(p.exhausted());
assert_eq!(p.successes(), 0, "the caller must map this to Err, never Ok(vec![])");
}
const BREAKER_TEST_GEN: u64 = u64::MAX;
#[test]
fn breaker_trips_only_after_consecutive_full_budget_failures() {
let url = "wss://breaker-test-full-budget.example";
breaker_record_at(BREAKER_TEST_GEN, url, false, true);
assert!(!breaker_tripped_at(BREAKER_TEST_GEN, url), "one failure is below the threshold");
breaker_record_at(BREAKER_TEST_GEN, url, false, true);
assert!(breaker_tripped_at(BREAKER_TEST_GEN, url), "two consecutive full-budget failures trip");
}
#[test]
fn breaker_demoted_budget_failures_never_count() {
let url = "wss://breaker-test-demoted.example";
breaker_record_at(BREAKER_TEST_GEN, url, false, false);
breaker_record_at(BREAKER_TEST_GEN, url, false, false);
breaker_record_at(BREAKER_TEST_GEN, url, false, false);
assert!(
!breaker_tripped_at(BREAKER_TEST_GEN, url),
"demoted-budget failures must not trip (anti-starvation: the post-cooldown probe must stay reachable)"
);
}
#[test]
fn breaker_success_resets_the_entry() {
let url = "wss://breaker-test-reset.example";
breaker_record_at(BREAKER_TEST_GEN, url, false, true);
breaker_record_at(BREAKER_TEST_GEN, url, false, true);
assert!(breaker_tripped_at(BREAKER_TEST_GEN, url));
breaker_record_at(BREAKER_TEST_GEN, url, true, false);
assert!(!breaker_tripped_at(BREAKER_TEST_GEN, url), "any success unconditionally resets");
breaker_record_at(BREAKER_TEST_GEN, url, false, true);
assert!(!breaker_tripped_at(BREAKER_TEST_GEN, url), "and the failure count restarted from zero");
}
#[test]
fn until_forces_full_evidence_and_default_is_quorum() {
assert_eq!(Query::default().evidence, Evidence::Quorum, "unclassified sites get Quorum");
assert_eq!(
effective_evidence(&Query { until: Some(1), evidence: Evidence::Fast, ..Default::default() }),
Evidence::Full,
"a back-page can never ride Fast — the history-start latch needs the full union"
);
assert_eq!(
effective_evidence(&Query { evidence: Evidence::Fast, ..Default::default() }),
Evidence::Fast,
"without `until` the declared tier stands"
);
}
}