Skip to main content

vector_core/community/
transport.rs

1//! Transport abstraction for Community events.
2//!
3//! The protocol's send/sync logic is written against this trait, not against the
4//! live Nostr client, so it can be exercised end-to-end by multiple emulated
5//! clients sharing an in-memory relay (no network, fully deterministic). Production
6//! provides an adapter over `NOSTR_CLIENT`; tests use [`MemoryRelay`].
7
8use nostr_sdk::prelude::*;
9
10/// How much relay coverage a fetch waits to witness before returning.
11///
12/// The community planes distinguish POSITIVE DATA (signed events, hash-chained
13/// editions — safe to act on from any relay; refuse-downgrade floors make stale
14/// or replayed data harmless) from NEGATIVE VERDICTS (conclusions from absence:
15/// "no rotation happened", "history ends here", "coverage complete"). A partial
16/// relay view can only ever STALL consensus — and every stall heals via the
17/// straggler sink, the live subscription, or the next sync — but a written
18/// negative verdict has no healer. Pick the tier by what the caller CONCLUDES
19/// from the result, not by how fast it wants to be.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum Evidence {
22    /// Return at the FIRST successful relay EOSE (+ the residual merge window).
23    /// For positive-data consumers only: never conclude absence from a Fast
24    /// result.
25    Fast,
26    /// Wait for a MAJORITY of attempted relays — time-bounded to
27    /// [`QUORUM_GRACE_MS`] past the first success, so one dead relay can't
28    /// gate a degraded set. A single fast-lying relay can't force an early
29    /// return while its honest peers answer within the window. Note: for a
30    /// 2-relay set the majority is BOTH, so a degraded pair always rides the
31    /// grace bound (first success + 2s), never a dead relay's full timeout.
32    #[default]
33    Quorum,
34    /// Wait for EVERY relay to resolve (EOSE or its per-relay timeout). For
35    /// presence-latches and coverage gates whose correctness depends on the
36    /// union being as complete as the reachable set allows. Forced whenever
37    /// `Query::until` is set (back-page verdicts).
38    Full,
39}
40
41/// The slice of a relay query the Community protocol needs: event kinds, the `z`
42/// pseudonym tag values, and an optional `since` floor. Production translates
43/// this into a Nostr `Filter`; the in-memory relay matches it directly.
44#[derive(Clone, Debug, Default)]
45pub struct Query {
46    pub kinds: Vec<u16>,
47    /// `z` tag values to match (OR). Empty = match any (no `z` constraint).
48    pub z_tags: Vec<String>,
49    /// `d` tag (identifier) values to match (OR). Empty = no `d` constraint. Used to
50    /// locate addressable events (e.g. a public-invite bundle by its token locator).
51    pub d_tags: Vec<String>,
52    /// `p` tag (recipient pubkey hex) values to match (OR). Empty = no `p` constraint.
53    /// Used to fetch person-addressed giftwraps (direct invites) by recipient.
54    pub p_tags: Vec<String>,
55    /// `k` tag (wrapped-kind) values to match (OR). Empty = no `k` constraint. Narrows
56    /// giftwrap fetches to the inner kind advertised on the wrap.
57    pub k_tags: Vec<String>,
58    /// Author pubkeys (hex) to match (OR). Empty = any author.
59    pub authors: Vec<String>,
60    /// Lower bound on `created_at` (seconds), inclusive.
61    pub since: Option<u64>,
62    /// Upper bound on `created_at` (seconds), inclusive — pages OLDER history (events
63    /// strictly/inclusively before a scroll cursor).
64    pub until: Option<u64>,
65    /// Max events to return (newest-first), the relay-side page cap. `None` = no limit.
66    pub limit: Option<usize>,
67    /// Relay-coverage requirement before the fetch may return. Defaults to
68    /// [`Evidence::Quorum`]; opt into [`Evidence::Fast`] only for positive-data
69    /// reads. Ignored by the in-memory test relay (inherently full-coverage).
70    pub evidence: Evidence,
71}
72
73impl Query {
74    /// Does `event` satisfy this query?
75    pub fn matches(&self, event: &Event) -> bool {
76        // Compare via `Kind` (not raw u16) so this matches `to_filter`'s
77        // `Kind::Custom` normalization exactly — the live and in-memory paths must
78        // agree even at kind values that nostr maps to named variants.
79        if !self.kinds.is_empty() && !self.kinds.iter().any(|k| Kind::Custom(*k) == event.kind) {
80            return false;
81        }
82        if let Some(since) = self.since {
83            if event.created_at.as_secs() < since {
84                return false;
85            }
86        }
87        if let Some(until) = self.until {
88            if event.created_at.as_secs() > until {
89                return false;
90            }
91        }
92        if !self.authors.is_empty() && !self.authors.iter().any(|a| *a == event.pubkey.to_hex()) {
93            return false;
94        }
95        if !self.z_tags.is_empty() && !self.matches_single_letter("z", &self.z_tags, event) {
96            return false;
97        }
98        if !self.d_tags.is_empty() && !self.matches_single_letter("d", &self.d_tags, event) {
99            return false;
100        }
101        if !self.p_tags.is_empty() && !self.matches_single_letter("p", &self.p_tags, event) {
102            return false;
103        }
104        if !self.k_tags.is_empty() && !self.matches_single_letter("k", &self.k_tags, event) {
105            return false;
106        }
107        true
108    }
109
110    fn matches_single_letter(&self, name: &str, wanted: &[String], event: &Event) -> bool {
111        // ANY occurrence may satisfy the OR-set (an event can carry several `p` tags);
112        // relays match every tag instance, and matches() must agree with to_filter.
113        event.tags.iter().any(|t| {
114            let s = t.as_slice();
115            s.len() >= 2 && s[0] == name && wanted.iter().any(|w| *w == s[1])
116        })
117    }
118
119    /// Translate to a Nostr relay `Filter` for the live client.
120    pub fn to_filter(&self) -> Filter {
121        let mut filter = Filter::new();
122        if !self.kinds.is_empty() {
123            filter = filter.kinds(self.kinds.iter().map(|k| Kind::Custom(*k)));
124        }
125        if !self.z_tags.is_empty() {
126            filter = filter
127                .custom_tags(SingleLetterTag::lowercase(Alphabet::Z), self.z_tags.clone());
128        }
129        if !self.d_tags.is_empty() {
130            filter = filter.identifiers(self.d_tags.clone());
131        }
132        if !self.p_tags.is_empty() {
133            filter = filter
134                .custom_tags(SingleLetterTag::lowercase(Alphabet::P), self.p_tags.clone());
135        }
136        if !self.k_tags.is_empty() {
137            filter = filter
138                .custom_tags(SingleLetterTag::lowercase(Alphabet::K), self.k_tags.clone());
139        }
140        if !self.authors.is_empty() {
141            let authors: Vec<PublicKey> =
142                self.authors.iter().filter_map(|a| PublicKey::from_hex(a).ok()).collect();
143            if !authors.is_empty() {
144                filter = filter.authors(authors);
145            }
146        }
147        if let Some(since) = self.since {
148            filter = filter.since(Timestamp::from_secs(since));
149        }
150        if let Some(until) = self.until {
151            filter = filter.until(Timestamp::from_secs(until));
152        }
153        if let Some(limit) = self.limit {
154            filter = filter.limit(limit);
155        }
156        filter
157    }
158}
159
160/// Publish + fetch over a set of relays. Async to match the live Nostr client (the
161/// whole app is tokio-based and network I/O is async); `async-trait` boxes the
162/// futures as `Send` so impls work inside spawned tasks.
163#[async_trait::async_trait]
164pub trait Transport {
165    /// Publish `event` to every relay in `relays`. Single-attempt: Ok if ≥1 relay ACKs.
166    async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String>;
167    /// Fetch events matching `query` across `relays`, unioned and deduped by id.
168    async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String>;
169
170    /// Fetch a group PLANE (events authored by `plane`'s pubkey), authenticating
171    /// to AUTH-gating relays AS that plane key. On a relay that requires "the
172    /// author you query must be authenticated" (Ditto), the shared client — authed
173    /// as the USER — can't fetch a plane, so its catch-up REQ is CLOSED and the
174    /// rotation/control is never folded (an offline member wedges at the old
175    /// epoch). This fetches over a connection authed as the plane itself. Required
176    /// (not defaulted — a default async-trait method forces a Sync bound on every
177    /// generic caller); the in-memory test relay just fetches (no auth).
178    async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String>;
179
180    /// DURABLE publish for security-critical control events (rekeys, bans, the invite registry, deletes):
181    /// retry **each relay independently** until it ACKs, up to [`MAX_PUBLISH_ATTEMPTS`] times, re-sending
182    /// only the relays that have NOT yet accepted. The already-signed `event` is broadcast as-is — the
183    /// crypto (e.g. a rekey's fresh root) is minted ONCE by the caller; this only hardens the broadcast,
184    /// so a relay blip or a brief local connectivity drop can't leave a rekey/ban under-propagated.
185    /// (Required, not defaulted — a default async-trait method would force a `Sync` bound on every
186    /// generic `T: Transport` caller. The in-memory test relay implements it as a single publish.)
187    async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String>;
188}
189
190/// Per-relay broadcast cap: retry each relay up to this many times before giving up on it (matching the
191/// NIP-17 deletable-DM durability). High enough to ride out a transient relay/local-network blip.
192pub const MAX_PUBLISH_ATTEMPTS: usize = 30;
193
194/// Residual union window past the moment a fetch's evidence requirement is met:
195/// relays finishing within it still merge synchronously; slower ones background-
196/// merge via the straggler sink.
197pub const RESIDUAL_GRACE_MS: u64 = 400;
198
199/// Time-bound on the Quorum majority wait, measured from the FIRST successful
200/// EOSE. Without it a 2-relay set with one dead relay would ride the dead
201/// relay's full timeout on every fetch; with it a degraded set costs
202/// first-success + this bound, and a healthy set returns at majority
203/// (typically far sooner).
204pub const QUORUM_GRACE_MS: u64 = 2000;
205
206/// Consecutive FULL-BUDGET failures before a relay trips.
207const BREAKER_TRIP_THRESHOLD: u8 = 2;
208
209/// How long a tripped relay stays demoted/skipped before its next fetch becomes
210/// the full-budget half-open probe.
211const BREAKER_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
212
213/// Demoted per-relay timeout for tripped relays on Full drains (N ≥ 2, non-Tor
214/// only) — halves a Full drain's dead-relay tail without shrinking the evidence
215/// denominator.
216const TRIPPED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(6);
217
218/// Hard ceiling on the initial confirmation: at least one relay must ACK within this window or the publish
219/// is a failure (we throw rather than spin on a dead/unreachable relay set forever). Once ONE relay accepts,
220/// the slow/ratelimited stragglers are threaded in the background (capped at MAX_PUBLISH_ATTEMPTS).
221pub const CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
222
223/// The retry engine behind a durable broadcast, factored out so it is unit-testable without a live
224/// client. `send_round(pending)` performs ONE broadcast attempt to the given still-pending relays and
225/// returns the subset that ACKed; this retries the rest (with `backoff` between rounds) until every relay
226/// has ACKed or `max_attempts` is reached. Returns `Ok` if at least one relay ever accepted (the event is
227/// durably out there; the fetch-union self-heals the stragglers), `Err` only if ZERO relays accepted
228/// after exhausting the retries. Dedups `relays` first so a duplicated url isn't double-counted.
229pub async fn durable_broadcast<'a, F>(
230    relays: &[String],
231    max_attempts: usize,
232    backoff: std::time::Duration,
233    mut send_round: F,
234) -> Result<(), String>
235where
236    F: FnMut(Vec<String>) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send + 'a>>,
237{
238    let mut pending: Vec<String> = Vec::new();
239    for r in relays {
240        if !pending.contains(r) {
241            pending.push(r.clone());
242        }
243    }
244    let total = pending.len();
245    if total == 0 {
246        return Err("no relays to broadcast to".to_string());
247    }
248    for attempt in 0..max_attempts {
249        if pending.is_empty() {
250            break;
251        }
252        let acked = send_round(pending.clone()).await;
253        pending.retain(|r| !acked.contains(r));
254        if pending.is_empty() || attempt + 1 == max_attempts {
255            break;
256        }
257        if !backoff.is_zero() {
258            tokio::time::sleep(backoff).await;
259        }
260    }
261    if pending.len() < total {
262        Ok(()) // at least one relay accepted; the rest were retried to the cap
263    } else {
264        Err(format!("no relay accepted the event after {max_attempts} attempts each"))
265    }
266}
267
268/// Sink for "straggler" events — ones a SLOWER relay returns after a racing [`LiveTransport::fetch`]
269/// has already handed the caller the first relay's batch. The integrator (src-tauri) registers a
270/// handler that feeds them back through the normal Concord ingest path (`process_incoming` for
271/// content, control/rekey re-fold for authority). The transport stays DUMB: it dedups only by event
272/// id (identical bytes) and forwards everything else. Two relays disagreeing on the latest control
273/// commit are two editions with DIFFERENT ids, so BOTH reach the ingester, where the deterministic
274/// convergence engine (version floors + same-version tiebreakers) decides the winner. The transport
275/// never resolves conflicts itself.
276pub trait CommunityIngestSink: Send + Sync + 'static {
277    fn ingest_stragglers(&self, events: Vec<Event>);
278}
279
280static INGEST_SINK: std::sync::OnceLock<Box<dyn CommunityIngestSink>> = std::sync::OnceLock::new();
281
282/// Register the straggler ingest sink. Call once during app startup (mirrors `set_event_emitter`).
283pub fn set_community_ingest_sink(sink: Box<dyn CommunityIngestSink>) {
284    let _ = INGEST_SINK.set(sink);
285}
286
287fn submit_stragglers(events: Vec<Event>) {
288    if events.is_empty() {
289        return;
290    }
291    if let Some(sink) = INGEST_SINK.get() {
292        sink.ingest_stragglers(events);
293    }
294}
295
296/// Relay urls already added + connected into the shared pool this session, so `warm_client` can
297/// skip the per-call `add_relay` bookkeeping + whole-pool `connect()` sweep once a community's
298/// relays are established (relays auto-reconnect on drop, so the re-kick is redundant once warm).
299/// Keyed by session generation: an account swap bumps the generation, invalidating every entry
300/// (the pool is rebuilt for the new account).
301static WARMED_RELAYS: std::sync::LazyLock<std::sync::Mutex<(u64, std::collections::HashSet<String>)>> =
302    std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashSet::new())));
303
304/// Forget a relay from the warm set — call when a relay is REMOVED from the pool (e.g. pruned after
305/// leaving a community), so a later `warm_client` for another community that shares it doesn't
306/// fast-path-skip the re-add and target a relay the pool no longer holds. Poison-tolerant: the set
307/// is pure optimization state, so a poisoned lock is recovered rather than propagated.
308pub fn forget_warmed_relay(url: &str) {
309    WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner()).1.remove(url);
310}
311
312/// Per-relay failure tracker behind the fetch circuit breaker. Generation-keyed
313/// like [`WARMED_RELAYS`] (an account swap invalidates every entry). A trip is
314/// driven ONLY by consecutive failures at the relay's FULL timeout budget:
315/// failures at a demoted budget never count (a slow-but-honest relay must be
316/// able to recover via the post-cooldown full-budget probe), and a late EOSE
317/// surfacing in the background drain resets the entry (slow ≠ dead).
318#[derive(Default)]
319struct BreakerEntry {
320    consecutive_failures: u8,
321    tripped_until: Option<std::time::Instant>,
322}
323
324static RELAY_BREAKER: std::sync::LazyLock<
325    std::sync::Mutex<(u64, std::collections::HashMap<String, BreakerEntry>)>,
326> = std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashMap::new())));
327
328/// Run `f` over the breaker map for `generation`, resetting the map if the
329/// generation advanced (pure optimization state — poison-tolerant). Generation
330/// is injected so tests pin a fixed one — other tests bump the REAL session
331/// generation concurrently, and a mid-test bump would wipe the map under us.
332fn with_breaker_at<R>(
333    generation: u64,
334    f: impl FnOnce(&mut std::collections::HashMap<String, BreakerEntry>) -> R,
335) -> R {
336    let mut guard = RELAY_BREAKER.lock().unwrap_or_else(|e| e.into_inner());
337    if guard.0 != generation {
338        guard.0 = generation;
339        guard.1.clear();
340    }
341    f(&mut guard.1)
342}
343
344/// Is `url` inside a trip cooldown right now?
345fn breaker_tripped(url: &str) -> bool {
346    breaker_tripped_at(crate::state::current_session_generation(), url)
347}
348
349fn breaker_tripped_at(generation: u64, url: &str) -> bool {
350    with_breaker_at(generation, |map| {
351        map.get(url)
352            .and_then(|e| e.tripped_until)
353            .map_or(false, |t| std::time::Instant::now() < t)
354    })
355}
356
357/// Record a per-relay fetch outcome. Success resets the entry; a failure counts
358/// toward a trip only when the relay had its full timeout budget.
359fn breaker_record(url: &str, success: bool, full_budget: bool) {
360    breaker_record_at(crate::state::current_session_generation(), url, success, full_budget)
361}
362
363fn breaker_record_at(generation: u64, url: &str, success: bool, full_budget: bool) {
364    with_breaker_at(generation, |map| {
365        if success {
366            map.remove(url);
367            return;
368        }
369        if !full_budget {
370            return;
371        }
372        let e = map.entry(url.to_string()).or_default();
373        e.consecutive_failures = e.consecutive_failures.saturating_add(1);
374        if e.consecutive_failures >= BREAKER_TRIP_THRESHOLD {
375            e.tripped_until = Some(std::time::Instant::now() + BREAKER_COOLDOWN);
376        }
377    })
378}
379
380/// `until` forces Full — a back-page verdict (the history-start latch) trusts
381/// "nothing older than the cursor" only against the completest union the
382/// reachable relay set allows. A floor in the transport, not trust in callers.
383fn effective_evidence(query: &Query) -> Evidence {
384    if query.until.is_some() {
385        Evidence::Full
386    } else {
387        query.evidence
388    }
389}
390
391// ── Plane connection pool (fetch_plane) ─────────────────────────────────────
392// A plane fetch on an AUTH-gating relay needs a connection authed AS the plane
393// key. Re-connecting + re-NIP-42-authing on EVERY page/epoch dominates TTFB on
394// slow relays, so keep the authed connection warm and reuse it. Keyed by (plane
395// pubkey, relay set). Generation-scoped: an account swap holds account A's plane
396// SECRET keys, so the pool MUST clear (also freed by `clear_plane_pool`).
397
398struct PooledPlane {
399    client: Client,
400    last_used: std::time::Instant,
401}
402
403static PLANE_POOL: std::sync::LazyLock<std::sync::Mutex<(u64, std::collections::HashMap<String, PooledPlane>)>> =
404    std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashMap::new())));
405
406/// A pooled connection unused for this long is closed on the next sweep — long
407/// enough to span a community's whole backfill, short enough not to hoard sockets.
408const PLANE_POOL_IDLE_TTL: std::time::Duration = std::time::Duration::from_secs(90);
409/// Hard cap on simultaneously-pooled plane connections (LRU-evicted).
410const PLANE_POOL_MAX: usize = 24;
411
412fn plane_pool_key(plane_pk: &str, relays: &[String]) -> String {
413    let mut rs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
414    rs.sort_unstable();
415    let mut k = String::with_capacity(plane_pk.len() + 1 + rs.iter().map(|r| r.len() + 1).sum::<usize>());
416    k.push_str(plane_pk);
417    k.push('|');
418    k.push_str(&rs.join(","));
419    k
420}
421
422/// Disconnect the given clients off the hot path (never awaited under the lock).
423/// Runtime-guarded: `clear_plane_pool` is public and a bot author may call it from
424/// a non-tokio thread — `disconnect` off a live handle if there is one, else drop
425/// the client (its background task ends on drop).
426fn disconnect_clients(clients: Vec<Client>) {
427    if clients.is_empty() {
428        return;
429    }
430    match tokio::runtime::Handle::try_current() {
431        Ok(handle) => {
432            for c in clients {
433                handle.spawn(async move {
434                    let _ = c.disconnect();
435                });
436            }
437        }
438        Err(_) => drop(clients),
439    }
440}
441
442/// Close + drop every pooled plane connection. Call on account swap — the pooled
443/// clients are authenticated as the swapped-out account's community plane keys.
444pub fn clear_plane_pool() {
445    let drained: Vec<Client> = {
446        let mut g = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
447        // Stamp the LIVE generation so an insert still in flight from the prior
448        // generation (a fetch_plane that captured the old value before the swap)
449        // sees the mismatch and disconnects its client instead of re-pooling one
450        // still authed as the swapped-out account's plane key.
451        g.0 = crate::state::current_session_generation();
452        g.1.drain().map(|(_, p)| p.client).collect()
453    };
454    disconnect_clients(drained);
455}
456
457/// Take a warm pooled client for `key` if one is fresh; also drops entries whose
458/// idle TTL expired and resets the whole pool if the session generation advanced
459/// (a swap). Returns `(Some(client_if_hit), clients_to_disconnect)`.
460fn plane_pool_take(generation: u64, key: &str) -> (Option<Client>, Vec<Client>) {
461    let mut g = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
462    let mut evicted: Vec<Client> = Vec::new();
463    if g.0 != generation {
464        evicted.extend(g.1.drain().map(|(_, p)| p.client));
465        g.0 = generation;
466    }
467    // Sweep idle-expired entries.
468    let now = std::time::Instant::now();
469    let expired: Vec<String> = g.1.iter()
470        .filter(|(_, p)| now.duration_since(p.last_used) >= PLANE_POOL_IDLE_TTL)
471        .map(|(k, _)| k.clone())
472        .collect();
473    for k in expired {
474        if let Some(p) = g.1.remove(&k) {
475            evicted.push(p.client);
476        }
477    }
478    let hit = g.1.get_mut(key).map(|p| {
479        p.last_used = now;
480        p.client.clone()
481    });
482    (hit, evicted)
483}
484
485/// Insert a freshly-built client for `key`, LRU-evicting if over the cap. Returns
486/// ONLY the displaced LRU victim(s) to disconnect — NEVER the just-built `client`.
487/// If we don't pool it (swapped mid-build, or a concurrent miss already pooled
488/// this key), we return nothing: the caller still uses the client for this one
489/// fetch and it closes on drop, so we must not disconnect the connection it's
490/// about to run on.
491fn plane_pool_insert(generation: u64, key: String, client: Client) -> Vec<Client> {
492    let mut g = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
493    // Swapped mid-build, or a concurrent miss already pooled this key — don't pool
494    // ours (the caller uses it once, then it drops).
495    if g.0 != generation || g.1.contains_key(&key) {
496        return Vec::new();
497    }
498    let mut evicted: Vec<Client> = Vec::new();
499    if g.1.len() >= PLANE_POOL_MAX {
500        if let Some(lru_key) = g.1.iter().min_by_key(|(_, p)| p.last_used).map(|(k, _)| k.clone()) {
501            if let Some(p) = g.1.remove(&lru_key) {
502                evicted.push(p.client);
503            }
504        }
505    }
506    g.1.insert(key, PooledPlane { client, last_used: std::time::Instant::now() });
507    evicted
508}
509
510/// Whether Full-drain timeout demotion may apply. Under Tor EVERY relay is
511/// legitimately slow — a first congested pass must not cascade into pool-wide
512/// starvation, so demotion is disabled entirely.
513fn demotion_allowed() -> bool {
514    #[cfg(feature = "tor")]
515    {
516        matches!(crate::tor::transport_state(), crate::tor::TorTransportState::Disabled)
517    }
518    #[cfg(not(feature = "tor"))]
519    {
520        true
521    }
522}
523
524/// Fetch one relay to GENUINE EOSE, or fail. `Client::fetch_events_from` (and
525/// the whole nostr-sdk 0.44 fetch stack) returns `Ok(collected)` on timeout,
526/// disconnect, and relay-CLOSED alike — success does NOT mean EOSE, which would
527/// let a dead relay count as quorum evidence and return confident empties. So
528/// the verdict is read from the relay's own notification stream instead: EOSE =
529/// success (empty included — a quiet coordinate is a legitimate answer);
530/// CLOSED / shutdown / deadline = failure.
531///
532/// Public for diagnostics (the v2 plane probe); production fetches go through
533/// [`Transport::fetch`], which layers the evidence tiers on top.
534pub async fn fetch_relay_eose(
535    client: &Client,
536    url: &str,
537    filter: Filter,
538    timeout: std::time::Duration,
539) -> Result<Vec<Event>, ()> {
540    let relay = client.pool().relay(url).await.map_err(|_| ())?;
541    // Subscribe to notifications BEFORE the REQ so the EOSE can't slip past.
542    let mut notifications = relay.notifications();
543    let sub_id = SubscriptionId::generate();
544    let auto_close = SubscribeAutoCloseOptions::default()
545        .exit_policy(ReqExitPolicy::ExitOnEOSE)
546        .timeout(Some(timeout));
547    relay
548        .subscribe_with_id(sub_id.clone(), filter, SubscribeOptions::default().close_on(Some(auto_close)))
549        .await
550        .map_err(|_| ())?;
551    let deadline = tokio::time::Instant::now() + timeout;
552    let mut events: Vec<Event> = Vec::new();
553    let mut seen: std::collections::HashSet<EventId> = std::collections::HashSet::new();
554    loop {
555        let notification = match tokio::time::timeout_at(deadline, notifications.recv()).await {
556            Ok(Ok(n)) => n,
557            // Lagged: the broadcast skipped messages under a flood — keep
558            // draining; a missed EOSE degrades to the deadline (a failure,
559            // never a false success).
560            Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
561            Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return Err(()),
562            Err(_) => return Err(()), // deadline: timeout is NOT EOSE
563        };
564        match notification {
565            RelayNotification::Event { subscription_id, event } if subscription_id == sub_id => {
566                if seen.insert(event.id) {
567                    events.push(*event);
568                }
569            }
570            RelayNotification::Message { message } => match message {
571                RelayMessage::Event { subscription_id, event } if *subscription_id == sub_id => {
572                    if seen.insert(event.id) {
573                        events.push(event.into_owned());
574                    }
575                }
576                RelayMessage::EndOfStoredEvents(id) if *id == sub_id => return Ok(events),
577                RelayMessage::Closed { subscription_id, .. } if *subscription_id == sub_id => {
578                    return Err(()); // relay refused the REQ (incl. auth-required)
579                }
580                _ => {}
581            },
582            RelayNotification::Shutdown => return Err(()),
583            _ => {}
584        }
585    }
586}
587
588/// Return-timing state machine for a multi-relay fetch: feed it per-relay
589/// outcomes and ask whether the query's [`Evidence`] requirement is met. Pure
590/// sync logic so the quorum math is unit-testable without a client.
591pub(crate) struct UnionPlan {
592    attempted: usize,
593    successes: usize,
594    resolved: usize,
595    evidence: Evidence,
596}
597
598impl UnionPlan {
599    pub(crate) fn new(evidence: Evidence, attempted: usize) -> Self {
600        Self { attempted, successes: 0, resolved: 0, evidence }
601    }
602
603    pub(crate) fn record(&mut self, success: bool) {
604        self.resolved += 1;
605        if success {
606            self.successes += 1;
607        }
608    }
609
610    /// The tier's coverage requirement is met — the fetch may return after the
611    /// residual merge window. Note Full's requirement is all-RESOLVED (a dead
612    /// relay's timeout is a resolution); the zero-success case errors at the
613    /// call site regardless of tier.
614    pub(crate) fn satisfied(&self) -> bool {
615        match self.evidence {
616            Evidence::Fast => self.successes >= 1,
617            Evidence::Quorum => self.successes >= (self.attempted / 2) + 1,
618            Evidence::Full => self.resolved >= self.attempted,
619        }
620    }
621
622    /// Every relay resolved — nothing left to wait for.
623    pub(crate) fn exhausted(&self) -> bool {
624        self.resolved >= self.attempted
625    }
626
627    pub(crate) fn successes(&self) -> usize {
628        self.successes
629    }
630
631    pub(crate) fn attempted(&self) -> usize {
632        self.attempted
633    }
634}
635
636/// Max time a community network op holds while Tor is enabled-but-not-yet-
637/// bootstrapped. Generous enough for a circuit to land on a normal connection,
638/// bounded so a Tor that never comes up can't hang the op forever.
639#[cfg(feature = "tor")]
640const TOR_READY_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
641
642/// Poll `is_blocked` until it clears or `max_wait` elapses.
643///
644/// When Tor is enabled but its SOCKS proxy isn't up yet, `transport_state()` is
645/// `RequiredButInactive` and every relay is routed to the blackhole proxy, so a
646/// send fails at the TCP layer INSTANTLY — surfacing as a misleading "no relay
647/// accepted the event" with zero network wait. Holding here turns that into
648/// either success (once the circuit lands) or an honest "Tor is still
649/// connecting" error. Generic over the predicate so it is testable without a
650/// live Tor.
651#[allow(dead_code)]
652async fn wait_until_tor_ready<F: Fn() -> bool>(
653    is_blocked: F,
654    max_wait: std::time::Duration,
655) -> Result<(), String> {
656    if !is_blocked() {
657        return Ok(());
658    }
659    let deadline = std::time::Instant::now() + max_wait;
660    while is_blocked() {
661        if std::time::Instant::now() >= deadline {
662            return Err("Tor is still connecting. Wait a moment and try again.".to_string());
663        }
664        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
665    }
666    Ok(())
667}
668
669/// Shed pooled Community relays from `candidates` that no JOINED community still needs. Used by both
670/// the leave path (relays of a community we left) and the invite-preload TTL cleanup (relays an
671/// unsolicited/declined invite warmed but never became a join, #297). Keep rules: a relay is kept if
672/// a remaining joined community lists it, OR it carries READ/WRITE (the user's own chat relays —
673/// Community relays are GOSSIP-only, so never READ/WRITE). A pruned relay re-warms automatically if
674/// its invite is later accepted (the join's subscription re-adds it), so pruning a still-pending
675/// invite's relay is safe.
676pub async fn prune_unneeded_community_relays(candidates: &[String]) {
677    if candidates.is_empty() {
678        return;
679    }
680    let Some(client) = crate::state::nostr_client() else { return };
681
682    let mut still_needed: std::collections::HashSet<String> = std::collections::HashSet::new();
683    if let Ok(ids) = crate::db::community::list_community_ids() {
684        for id in ids {
685            if let Ok(Some(c)) = crate::db::community::load_community(&id) {
686                for r in &c.relays {
687                    still_needed.insert(r.clone());
688                }
689            }
690        }
691    }
692
693    let pool = client.pool();
694    // all_relays(): community relays carry GOSSIP, so they're absent from `relays()` (READ/WRITE only).
695    let pooled = pool.all_relays().await;
696    for url in candidates {
697        if still_needed.contains(url) {
698            continue;
699        }
700        if let Ok(parsed) = nostr_sdk::RelayUrl::parse(url) {
701            if let Some(relay) = pooled.get(&parsed) {
702                if relay.flags().has_read() || relay.flags().has_write() {
703                    continue; // a real chat relay (or an overlap) — never sever
704                }
705            }
706            let _ = pool.force_remove_relay(parsed).await; // plain remove_relay refuses GOSSIP
707            forget_warmed_relay(url);
708        }
709    }
710}
711
712/// Production [`Transport`] over the live Nostr network.
713///
714/// Reuses the app's persistent client (`state::nostr_client`) — already connected to the user's relays,
715/// which ARE the Community's relays — and targets sends/fetches at the Community relay set explicitly. No
716/// per-call cold handshake (a throwaway client paid ~4s of TLS + relay handshake on every op). Any Community
717/// relay the pool doesn't already hold is added idempotently, mirroring the realtime subscription.
718pub struct LiveTransport {
719    timeout: std::time::Duration,
720}
721
722impl Default for LiveTransport {
723    fn default() -> Self {
724        Self { timeout: std::time::Duration::from_secs(10) }
725    }
726}
727
728impl LiveTransport {
729    pub fn new() -> Self {
730        Self::default()
731    }
732
733    pub fn with_timeout(timeout: std::time::Duration) -> Self {
734        Self { timeout }
735    }
736
737    /// Grab the app's persistent client and make sure it's connected to `relays` — the Community's relays
738    /// are the user's own relays, so this is almost always a pure no-op (already in the warm pool). A relay
739    /// the pool doesn't hold yet is added idempotently (mirrors what the realtime subscription does), then
740    /// `connect()` kicks it without disturbing the already-connected majority. Never shut this client down:
741    /// it is shared. Errors only if there is no client yet or every relay url was invalid.
742    async fn warm_client(relays: &[String], connect_timeout: std::time::Duration) -> Result<Client, String> {
743        if relays.is_empty() {
744            return Err("community has no relays configured".to_string());
745        }
746        // Tor gate — runs BEFORE the warmed-cache fast path so a relay warmed
747        // before Tor was toggled on still waits. While Tor is enabled but not yet
748        // bootstrapped, every relay points at the blackhole proxy and a send
749        // fails instantly; hold for the circuit, then fail honestly if it never
750        // comes up (see `wait_until_tor_ready`).
751        #[cfg(feature = "tor")]
752        wait_until_tor_ready(
753            || matches!(crate::tor::transport_state(), crate::tor::TorTransportState::RequiredButInactive),
754            TOR_READY_WAIT,
755        ).await?;
756        let client = crate::state::nostr_client().ok_or_else(|| "nostr client not initialized".to_string())?;
757
758        // Fast path: every one of these relays was already warmed this session → the pool holds and
759        // (auto-)maintains them, so skip the redundant add_relay + connect churn that otherwise runs on
760        // EVERY fetch/publish. Account swaps bump the generation, dropping the cache.
761        let generation = crate::state::current_session_generation();
762        {
763            let warmed = WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner());
764            if warmed.0 == generation && relays.iter().all(|r| warmed.1.contains(r)) {
765                return Ok(client);
766            }
767        }
768
769        // `add_relay` returns Ok(true) if NEWLY added, Ok(false) if the pool already held it.
770        // Community relays join GOSSIP|PING (see `community_relay_options`) so they stay 24/7 warm
771        // without pulling the user's DM/profile traffic onto relays they don't own. An overlap
772        // relay already in the pool as a user relay keeps its READ+WRITE flags (add_relay no-ops).
773        let mut added_new = false;
774        let mut succeeded: Vec<&String> = Vec::new();
775        for url in relays {
776            let opts = crate::community_relay_options();
777            match client.pool().add_relay(url.as_str(), opts).await {
778                Ok(true) => { added_new = true; succeeded.push(url); }
779                Ok(false) => { succeeded.push(url); }
780                Err(_) => {}
781            }
782        }
783        if succeeded.is_empty() {
784            return Err("no valid community relays could be added".to_string());
785        }
786        if added_new {
787            // A relay we weren't already connected to (a Community on non-default relays). `connect()`
788            // returns before sockets are up, so WAIT for it — otherwise the immediate fetch/send reaches
789            // zero relays. Already-connected relays return instantly in `success`, so the warm majority
790            // adds no latency; only the genuinely-new relay's handshake is awaited (bounded).
791            let _ = client.try_connect(connect_timeout).await;
792        } else {
793            // Every relay already warm in the pool — cheap re-kick of any dropped connection, no wait.
794            client.connect().await;
795        }
796
797        // Record the now-connected relays as warmed for this generation so subsequent calls fast-path
798        // (reset the set if the generation advanced under us — a swap mid-warm).
799        {
800            let mut warmed = WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner());
801            if warmed.0 != generation {
802                warmed.0 = generation;
803                warmed.1.clear();
804            }
805            for url in succeeded {
806                warmed.1.insert(url.clone());
807            }
808        }
809        Ok(client)
810    }
811
812    /// Coverage-reporting fetch — same engine as [`Transport::fetch`], returning
813    /// `(events, relays_that_EOSEd, relays_attempted)`. The boot control probe
814    /// reads the counts to decide whether its cursor may advance (full coverage
815    /// only — a majority return must not skip a down relay's pending editions).
816    pub async fn fetch_counted(&self, query: &Query, relays: &[String]) -> Result<(Vec<Event>, usize, usize), String> {
817        let client = Self::warm_client(relays, self.timeout).await?;
818        let base_timeout = self.timeout;
819        let filter = query.to_filter();
820
821        let evidence = effective_evidence(query);
822
823        let mut targets: Vec<String> = Vec::new();
824        for r in relays {
825            if !targets.contains(r) {
826                targets.push(r.clone());
827            }
828        }
829
830        // Fast tier: skip tripped relays outright (pure bandwidth save — the
831        // evidence bar is ≥1 success either way, and the union self-heals).
832        // Never skip down to an empty set. Quorum/Full always attempt every
833        // relay so a trip can't shrink their evidence denominator.
834        if evidence == Evidence::Fast && targets.len() >= 2 {
835            let alive: Vec<String> =
836                targets.iter().filter(|r| !breaker_tripped(r)).cloned().collect();
837            if !alive.is_empty() {
838                targets = alive;
839            }
840        }
841
842        // One relay → nothing to race; the sole relay always gets the full
843        // budget (there is nothing to union around).
844        if targets.len() <= 1 {
845            let Some(url) = targets.first() else {
846                return Err("no valid relay to fetch from".to_string());
847            };
848            let res = fetch_relay_eose(&client, url, filter, base_timeout)
849                .await
850                .map_err(|_| format!("relay did not answer the fetch: {url}"));
851            breaker_record(url, res.is_ok(), true);
852            return res.map(|evs| (evs, 1, 1));
853        }
854
855        fn merge_events(
856            evs: Vec<Event>,
857            result: &mut Vec<Event>,
858            seen: &mut std::collections::HashSet<EventId>,
859        ) {
860            for e in evs {
861                if seen.insert(e.id) {
862                    result.push(e);
863                }
864            }
865        }
866
867        // RACE per-relay (mirrors the publish first-ACK race) and union per the
868        // evidence tier. Every relay's outcome is tracked — genuine EOSE vs
869        // error/timeout — so an all-dead pool surfaces as Err, never as a
870        // confident empty answer. Tripped relays keep their place in the
871        // denominator; on Full drains (non-Tor) they run on a demoted budget so
872        // a dead relay's tail shrinks without weakening the union.
873        let demote = evidence == Evidence::Full && demotion_allowed();
874        use futures_util::stream::{FuturesUnordered, StreamExt};
875        let mut fetches: FuturesUnordered<_> = targets
876            .iter()
877            .map(|r| {
878                let client = client.clone();
879                let filter = filter.clone();
880                let r = r.clone();
881                let timeout = if demote && breaker_tripped(&r) {
882                    TRIPPED_TIMEOUT.min(base_timeout)
883                } else {
884                    base_timeout
885                };
886                let full_budget = timeout >= base_timeout;
887                tokio::spawn(async move {
888                    let out = fetch_relay_eose(&client, &r, filter, timeout).await;
889                    (r, full_budget, out)
890                })
891            })
892            .collect();
893
894        let mut plan = UnionPlan::new(evidence, targets.len());
895        let mut result: Vec<Event> = Vec::new();
896        let mut union_ids: std::collections::HashSet<EventId> = std::collections::HashSet::new();
897
898        // Phase 1 — wait for the tier's coverage requirement. Quorum's majority
899        // wait is TIME-BOUNDED from the first success so a dead relay can't
900        // gate a degraded set (a 2-relay community with one relay down must not
901        // ride that relay's timeout on every fetch).
902        let mut quorum_deadline: Option<tokio::time::Instant> = None;
903        let mut quorum_window_closed = false;
904        while !plan.satisfied() && !plan.exhausted() {
905            let next = match quorum_deadline {
906                Some(deadline) => match tokio::time::timeout_at(deadline, fetches.next()).await {
907                    Ok(n) => n,
908                    Err(_) => {
909                        quorum_window_closed = true;
910                        break; // window closed — return with what we hold (≥1 success)
911                    }
912                },
913                None => fetches.next().await,
914            };
915            let Some(joined) = next else { break };
916            match joined {
917                Ok((url, full_budget, Ok(evs))) => {
918                    breaker_record(&url, true, full_budget);
919                    merge_events(evs, &mut result, &mut union_ids);
920                    plan.record(true);
921                    if evidence == Evidence::Quorum && quorum_deadline.is_none() {
922                        quorum_deadline = Some(
923                            tokio::time::Instant::now()
924                                + std::time::Duration::from_millis(QUORUM_GRACE_MS),
925                        );
926                    }
927                }
928                Ok((url, full_budget, Err(()))) => {
929                    breaker_record(&url, false, full_budget);
930                    plan.record(false);
931                }
932                Err(_) => plan.record(false), // task join error — a resolved failure
933            }
934        }
935
936        if plan.successes() == 0 {
937            return Err(format!(
938                "no relay answered the fetch (0/{} attempted)",
939                plan.attempted()
940            ));
941        }
942
943        // Phase 2 — residual union window: relays finishing just behind the
944        // requirement still merge synchronously. Skipped when the quorum window
945        // already expired (that wait subsumes this one).
946        if !fetches.is_empty() && !quorum_window_closed {
947            let grace = tokio::time::sleep(std::time::Duration::from_millis(RESIDUAL_GRACE_MS));
948            tokio::pin!(grace);
949            loop {
950                tokio::select! {
951                    _ = &mut grace => break,
952                    next = fetches.next() => match next {
953                        Some(Ok((url, full_budget, Ok(evs)))) => {
954                            breaker_record(&url, true, full_budget);
955                            merge_events(evs, &mut result, &mut union_ids);
956                        }
957                        Some(Ok((url, full_budget, Err(())))) => {
958                            breaker_record(&url, false, full_budget);
959                        }
960                        Some(Err(_)) => continue,
961                        None => break,
962                    }
963                }
964            }
965        }
966
967        // Background-merge the relays that haven't finished: dedup by event id ONLY (identical bytes)
968        // against what we returned, then hand the rest to the ingester. Conflicting editions carry
969        // distinct ids, so the protocol's convergence engine resolves them — not the transport.
970        if !fetches.is_empty() {
971            let seen: std::collections::HashSet<EventId> = result.iter().map(|e| e.id).collect();
972            // Captured BEFORE the drain spawn: the drain can outlive an account swap, and
973            // stragglers fetched under the prior session must not feed the new one's ingest.
974            let session = crate::state::SessionGuard::capture();
975            tokio::spawn(async move {
976                let mut extra: Vec<Event> = Vec::new();
977                let mut extra_ids: std::collections::HashSet<EventId> = std::collections::HashSet::new();
978                while let Some(joined) = fetches.next().await {
979                    if let Ok((url, full_budget, out)) = joined {
980                        // A late EOSE is still a SUCCESS — slow ≠ dead; without
981                        // this a relay slower than the residual window could
982                        // never un-trip.
983                        breaker_record(&url, out.is_ok(), full_budget);
984                        if let Ok(evs) = out {
985                            for e in evs {
986                                if !seen.contains(&e.id) && extra_ids.insert(e.id) {
987                                    extra.push(e);
988                                }
989                            }
990                        }
991                    }
992                }
993                if !session.is_valid() {
994                    return;
995                }
996                submit_stragglers(extra);
997            });
998        }
999
1000        Ok((result, plan.successes(), plan.attempted()))
1001    }
1002}
1003
1004#[async_trait::async_trait]
1005impl Transport for LiveTransport {
1006    async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
1007        let client = Self::warm_client(relays, self.timeout).await?;
1008        let timeout = self.timeout;
1009        let mut targets: Vec<String> = Vec::new();
1010        for r in relays { if !targets.contains(r) { targets.push(r.clone()); } }
1011        // Fan out one send per relay and RETURN on the first ACK — never wait for the slowest relay (a
1012        // distant/ratelimited one must not gate a reaction/edit/message). Each send is SPAWNED, so the rest
1013        // keep delivering to every relay after we return (dropping a JoinHandle detaches, it doesn't abort).
1014        // Single attempt — durable retry is publish_durable's job. The sends only touch relays, no per-account
1015        // state, so no SessionGuard is needed.
1016        use futures_util::stream::{FuturesUnordered, StreamExt};
1017        let mut sends: FuturesUnordered<_> = targets
1018            .into_iter()
1019            .map(|r| {
1020                let client = client.clone();
1021                let event = event.clone();
1022                tokio::spawn(async move {
1023                    matches!(
1024                        tokio::time::timeout(timeout, client.send_event_to(vec![r.clone()], &event)).await,
1025                        Ok(Ok(out)) if RelayUrl::parse(&r).map(|u| out.success.contains(&u)).unwrap_or(false)
1026                    )
1027                })
1028            })
1029            .collect();
1030        while let Some(joined) = sends.next().await {
1031            if matches!(joined, Ok(true)) {
1032                return Ok(()); // first relay ACKed; the others keep delivering in the background
1033            }
1034        }
1035        Err("no relay accepted the event".to_string())
1036    }
1037
1038    async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
1039        self.fetch_counted(query, relays).await.map(|(events, _successes, _attempted)| events)
1040    }
1041
1042    async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
1043        if relays.is_empty() {
1044            return Ok(Vec::new());
1045        }
1046        #[cfg(feature = "tor")]
1047        wait_until_tor_ready(
1048            || matches!(crate::tor::transport_state(), crate::tor::TorTransportState::RequiredButInactive),
1049            TOR_READY_WAIT,
1050        ).await?;
1051        // Skip relays the shared breaker already knows are dead — don't even pay
1052        // their connect handshake + warmup + timeout (the v1 sweep / DM negentropy
1053        // trip them early, so by the time a v2 backfill runs they're usually
1054        // marked). Keep all if that would leave none — a slow fetch beats no fetch.
1055        let mut targets: Vec<String> = relays.iter().filter(|r| !breaker_tripped(r)).cloned().collect();
1056        if targets.is_empty() {
1057            targets = relays.to_vec();
1058        }
1059        let filter = query.to_filter();
1060        let generation = crate::state::current_session_generation();
1061        let key = plane_pool_key(&plane.public_key().to_hex(), &targets);
1062
1063        // Reuse a warm, already-authed pooled connection if one exists — this is
1064        // the win: the NIP-42 handshake happens ONCE, not per page/epoch.
1065        let (hit, evicted) = plane_pool_take(generation, &key);
1066        disconnect_clients(evicted);
1067        let client = if let Some(c) = hit {
1068            c
1069        } else {
1070            // Cold: a dedicated connection authed AS the plane key (a NIP-42
1071            // connection holds ONE identity; the shared client's is the user's).
1072            let opts = crate::nostr_client_options().automatic_authentication(true);
1073            let client = nostr_sdk::Client::builder().signer(plane.clone()).opts(opts).build();
1074            // Community relay options (GOSSIP|PING + Tor-aware ConnectionMode): a
1075            // bare add_relay leaves ConnectionMode::Direct, so under active Tor the
1076            // plane fetch — and the NIP-42 auth AS the plane key — would connect
1077            // direct and tie the user's IP to community membership.
1078            for r in &targets {
1079                let _ = client.pool().add_relay(r.clone(), crate::community_relay_options()).await;
1080            }
1081            client.connect().await;
1082            // Warmup with the gated filter shape triggers each relay's NIP-42
1083            // challenge so auto-auth completes ONCE here; pooled reuses skip it.
1084            for r in &targets {
1085                let _ = client
1086                    .fetch_events_from(vec![r.clone()], filter.clone(), std::time::Duration::from_secs(5))
1087                    .await;
1088            }
1089            let ev = plane_pool_insert(generation, key, client.clone());
1090            disconnect_clients(ev);
1091            client
1092        };
1093
1094        let mut result: Vec<Event> = Vec::new();
1095        let mut seen: std::collections::HashSet<EventId> = std::collections::HashSet::new();
1096        let mut successes = 0usize;
1097        for r in &targets {
1098            let res = fetch_relay_eose(&client, r, filter.clone(), self.timeout).await;
1099            // Feed the shared breaker so this auth path both benefits from AND
1100            // contributes to the pool-wide dead-relay knowledge.
1101            breaker_record(r, res.is_ok(), true);
1102            if let Ok(events) = res {
1103                successes += 1;
1104                for e in events {
1105                    if seen.insert(e.id) {
1106                        result.push(e);
1107                    }
1108                }
1109            }
1110        }
1111        // Zero EOSE = every relay refused/timed out — an honest transient failure,
1112        // NOT a "the plane is empty" verdict (a genuine empty plane EOSEs with no
1113        // events, which counts as a success). A confident-empty here could mask a
1114        // rotation from a caller that concludes absence.
1115        if successes == 0 {
1116            return Err(format!("no relay answered the plane fetch (0/{} attempted)", targets.len()));
1117        }
1118        // The client stays POOLED (not disconnected) for the next page/epoch/community.
1119        Ok(result)
1120    }
1121
1122    async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
1123        // "Durable" = confirm the network has it (≥1 relay ACKs within CONFIRM_WINDOW), then keep bugging the
1124        // SLOW/ratelimited stragglers (Damus-style 1-event/min) in the BACKGROUND. If NOTHING ACKs in the
1125        // window we throw — a dead relay set is a failure, not an endless wait. Uses the shared warm client
1126        // (NEVER shut down here) and targets the community relay set across rounds.
1127        let client = Self::warm_client(relays, self.timeout).await?;
1128        let timeout = self.timeout;
1129        let event = event.clone();
1130        let backoff = std::time::Duration::from_millis(750);
1131        let mut pending: Vec<String> = Vec::new();
1132        for r in relays { if !pending.contains(r) { pending.push(r.clone()); } }
1133        if pending.is_empty() {
1134            return Err("no relays to broadcast to".to_string());
1135        }
1136
1137        // Phase 1 — CONFIRM: RACE the relays and return the instant ANY one ACKs — never wait for the
1138        // slowest. `send_event_to(all)` blocks on the slowest relay (a distant/ratelimited one dominates the
1139        // latency), so we fan out one send per relay and take the first winner; the losers are cancelled and
1140        // re-sent in the background. Retry rounds within CONFIRM_WINDOW; zero ACKs in the window = failure.
1141        let mut acked_any = false;
1142        let _ = tokio::time::timeout(CONFIRM_WINDOW, async {
1143            loop {
1144                let sends = pending.iter().cloned().map(|r| {
1145                    let client = &client;
1146                    let event = &event;
1147                    Box::pin(async move {
1148                        match tokio::time::timeout(timeout, client.send_event_to(vec![r.clone()], event)).await {
1149                            Ok(Ok(out)) if RelayUrl::parse(&r).map(|u| out.success.contains(&u)).unwrap_or(false) => Ok(r),
1150                            _ => Err(()),
1151                        }
1152                    })
1153                });
1154                if let Ok((winner, _losers)) = futures_util::future::select_ok(sends).await {
1155                    acked_any = true;
1156                    pending.retain(|r| r != &winner);
1157                    break;
1158                }
1159                tokio::time::sleep(backoff).await;
1160            }
1161        })
1162        .await;
1163
1164        if !acked_any {
1165            return Err(format!("no relay accepted the event within {}s", CONFIRM_WINDOW.as_secs()));
1166        }
1167        if pending.is_empty() {
1168            return Ok(()); // every relay ACKed during the confirm phase
1169        }
1170
1171        // Phase 2 — BACKGROUND: thread the laggards through a durable publisher (retries each up to
1172        // MAX_PUBLISH_ATTEMPTS at a 750ms backoff, so it can't run forever). The caller returns NOW with its
1173        // confirmed ACK; 's fetch-union heals anything that never lands. The client is shared — not torn
1174        // down — so the spawned task just drops its handle when finished.
1175        tokio::spawn(async move {
1176            let client_ref = &client;
1177            let event_ref = &event;
1178            let _ = durable_broadcast(&pending, MAX_PUBLISH_ATTEMPTS, backoff, move |round| {
1179                Box::pin(async move {
1180                    match tokio::time::timeout(timeout, client_ref.send_event_to(round.clone(), event_ref)).await {
1181                        Ok(Ok(output)) => round.into_iter().filter(|p| RelayUrl::parse(p).map(|u| output.success.contains(&u)).unwrap_or(false)).collect(),
1182                        _ => Vec::new(),
1183                    }
1184                })
1185            })
1186            .await;
1187        });
1188        Ok(())
1189    }
1190}
1191
1192#[cfg(test)]
1193pub(crate) mod memory {
1194    use super::*;
1195    use std::collections::{HashMap, HashSet};
1196    use std::sync::Mutex;
1197
1198    /// An in-memory stand-in for the Community's relay set. Stores events per relay
1199    /// url so tests can model partial propagation (a relay that missed an event) and
1200    /// verify the redundancy/self-heal property: a fetch across the set unions +
1201    /// dedups, so a gap on one relay is covered by its siblings.
1202    pub struct MemoryRelay {
1203        per_relay: Mutex<HashMap<String, Vec<Event>>>,
1204        subscribers: Mutex<Vec<(Query, tokio::sync::mpsc::UnboundedSender<Event>)>>,
1205    }
1206
1207    /// NIP-01 ephemeral range: relays stream these to live subscriptions but never store
1208    /// them, so a fetch on a real relay can never return one.
1209    fn is_ephemeral(kind: u16) -> bool {
1210        (20000..30000).contains(&kind)
1211    }
1212
1213    impl MemoryRelay {
1214        pub fn new() -> Self {
1215            MemoryRelay {
1216                per_relay: Mutex::new(HashMap::new()),
1217                subscribers: Mutex::new(Vec::new()),
1218            }
1219        }
1220
1221        /// Open a live subscription: every subsequent publish/inject matching `query` is
1222        /// delivered — ephemerals included, which stream but are never stored.
1223        pub fn subscribe(&self, query: Query) -> tokio::sync::mpsc::UnboundedReceiver<Event> {
1224            let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1225            self.subscribers.lock().unwrap().push((query, tx));
1226            rx
1227        }
1228
1229        /// Push `event` to every live matching subscriber, pruning closed ones.
1230        fn deliver(&self, event: &Event) {
1231            self.subscribers.lock().unwrap().retain(|(q, tx)| {
1232                if q.matches(event) {
1233                    tx.send(event.clone()).is_ok()
1234                } else {
1235                    !tx.is_closed()
1236                }
1237            });
1238        }
1239
1240        /// Publish to ONLY a subset of relays — used to simulate a relay missing an
1241        /// event (e.g. a dropped rekey) for redundancy tests.
1242        pub fn inject(&self, event: &Event, relays: &[String]) {
1243            self.deliver(event);
1244            if is_ephemeral(event.kind.as_u16()) {
1245                return; // live-delivered above, never stored
1246            }
1247            // Replaceable kinds: parameterized (30000-39999, keyed by (kind, pubkey, d-tag)) AND
1248            // standard (10000-19999, plus 0/3, keyed by (kind, pubkey) — the d-tag is "") — a relay keeps
1249            // only the latest at that coordinate, so a new event REPLACES the old (NIP-01). This is what
1250            // makes a revocation tombstone overwrite a bundle, and a fresh 13302 supersede the last one,
1251            // even on relays that ignore deletions — model it so tests match real relay behavior.
1252            let d_tag = |e: &Event| e.tags.iter().find_map(|t| {
1253                let s = t.as_slice();
1254                (s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
1255            }).unwrap_or_default();
1256            let k = event.kind.as_u16();
1257            let replaceable = (30000..40000).contains(&k) || (10000..20000).contains(&k) || k == 0 || k == 3;
1258            let coord = (event.kind.as_u16(), event.pubkey, d_tag(event));
1259            let mut map = self.per_relay.lock().unwrap();
1260            for r in relays {
1261                let v = map.entry(r.clone()).or_default();
1262                if replaceable {
1263                    v.retain(|e| (e.kind.as_u16(), e.pubkey, d_tag(e)) != coord);
1264                }
1265                v.push(event.clone());
1266            }
1267        }
1268
1269        /// How many events a given relay holds (test introspection).
1270        pub fn count_on(&self, relay: &str) -> usize {
1271            self.per_relay.lock().unwrap().get(relay).map_or(0, |v| v.len())
1272        }
1273
1274        /// Apply a NIP-09 deletion: drop any stored event matched by the deletion's `e`
1275        /// tags (by id) OR `a` tags (addressable coordinate `kind:pubkey:d`), AND whose
1276        /// author matches the deletion's author (a deleter can only delete their own
1277        /// events — same rule strfry enforces).
1278        fn apply_deletion(&self, deletion: &Event, relays: &[String]) {
1279            let mut id_targets: HashSet<String> = HashSet::new();
1280            let mut coord_targets: HashSet<String> = HashSet::new();
1281            for t in deletion.tags.iter() {
1282                let s = t.as_slice();
1283                if s.len() >= 2 && s[0] == "e" {
1284                    id_targets.insert(s[1].clone());
1285                } else if s.len() >= 2 && s[0] == "a" {
1286                    coord_targets.insert(s[1].clone());
1287                }
1288            }
1289            let mut map = self.per_relay.lock().unwrap();
1290            for r in relays {
1291                if let Some(events) = map.get_mut(r) {
1292                    events.retain(|e| {
1293                        if e.pubkey != deletion.pubkey {
1294                            return true;
1295                        }
1296                        if id_targets.contains(&e.id.to_hex()) {
1297                            return false;
1298                        }
1299                        // Addressable coordinate "kind:pubkey:d-identifier".
1300                        let d = e.tags.iter().find_map(|t| {
1301                            let s = t.as_slice();
1302                            (s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
1303                        }).unwrap_or_default();
1304                        let coord = format!("{}:{}:{}", e.kind.as_u16(), e.pubkey.to_hex(), d);
1305                        !coord_targets.contains(&coord)
1306                    });
1307                }
1308            }
1309        }
1310    }
1311
1312    #[async_trait::async_trait]
1313    impl Transport for MemoryRelay {
1314        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
1315            // Honor NIP-09 so the delete→gone cycle is testable offline.
1316            if event.kind == Kind::EventDeletion {
1317                self.apply_deletion(event, relays);
1318                self.deliver(event);
1319            } else {
1320                self.inject(event, relays);
1321            }
1322            Ok(())
1323        }
1324
1325        // The in-memory relay always accepts, so a "durable" publish is just a publish (the retry
1326        // engine itself is unit-tested separately via `durable_broadcast`).
1327        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
1328            self.publish(event, relays).await
1329        }
1330
1331        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
1332            let map = self.per_relay.lock().unwrap();
1333            let mut seen = HashSet::new();
1334            let mut out = Vec::new();
1335            for r in relays {
1336                if let Some(events) = map.get(r) {
1337                    for ev in events {
1338                        // Never stored, but guard the read path too: a real relay never
1339                        // serves an ephemeral from a fetch, whatever got in.
1340                        if is_ephemeral(ev.kind.as_u16()) {
1341                            continue;
1342                        }
1343                        if query.matches(ev) && seen.insert(ev.id) {
1344                            out.push(ev.clone());
1345                        }
1346                    }
1347                }
1348            }
1349            // Apply the relay-side page cap newest-first (matches how relays honor `limit`):
1350            // sort by created_at desc, keep the newest `limit`. Mirrors production paging.
1351            if let Some(limit) = query.limit {
1352                out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
1353                out.truncate(limit);
1354            }
1355            Ok(out)
1356        }
1357
1358        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
1359            // No auth in the in-memory relay — a plane fetch is just a fetch.
1360            self.fetch(query, relays).await
1361        }
1362    }
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368
1369    // ── Tor gate (community publish over a not-yet-bootstrapped Tor) ──────────
1370    // Hermetic: drives `wait_until_tor_ready` with an injected predicate, so no
1371    // live Tor is needed and the result is deterministic.
1372
1373    #[tokio::test]
1374    async fn tor_gate_passes_immediately_when_not_blocked() {
1375        let start = std::time::Instant::now();
1376        let res = wait_until_tor_ready(|| false, std::time::Duration::from_secs(30)).await;
1377        assert!(res.is_ok());
1378        assert!(start.elapsed() < std::time::Duration::from_secs(1), "must not wait when Tor is ready");
1379    }
1380
1381    #[tokio::test]
1382    async fn tor_gate_errors_honestly_after_timeout_when_perpetually_blocked() {
1383        // The bug: without this gate the send failed INSTANTLY with a misleading
1384        // "no relay accepted". Now it waits the window, then names the real cause.
1385        let res = wait_until_tor_ready(|| true, std::time::Duration::from_millis(300)).await;
1386        let err = res.expect_err("should error when Tor never activates");
1387        assert!(err.to_lowercase().contains("tor"), "error must name Tor, got: {err}");
1388    }
1389
1390    #[tokio::test]
1391    async fn tor_gate_passes_once_circuit_comes_up_mid_wait() {
1392        let calls = std::sync::atomic::AtomicUsize::new(0);
1393        // Blocked for the first 3 polls, then Tor becomes ready.
1394        let res = wait_until_tor_ready(
1395            || calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 3,
1396            std::time::Duration::from_secs(5),
1397        ).await;
1398        assert!(res.is_ok(), "should succeed once Tor activates within the window");
1399    }
1400
1401    fn evt(kind: u16, z: &str) -> Event {
1402        EventBuilder::new(Kind::Custom(kind), "x")
1403            .tags([Tag::custom(
1404                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
1405                [z.to_string()],
1406            )])
1407            .sign_with_keys(&Keys::generate())
1408            .unwrap()
1409    }
1410
1411    #[test]
1412    fn query_matches_kind_and_z() {
1413        let e = evt(3300, "abc");
1414        assert!(Query { kinds: vec![3300], z_tags: vec!["abc".into()], since: None, ..Default::default() }.matches(&e));
1415        assert!(!Query { kinds: vec![3301], ..Default::default() }.matches(&e));
1416        assert!(!Query { kinds: vec![], z_tags: vec!["xyz".into()], since: None, ..Default::default() }.matches(&e));
1417        assert!(Query::default().matches(&e), "empty query matches anything");
1418    }
1419
1420    fn evt_at(kind: u16, secs: u64) -> Event {
1421        EventBuilder::new(Kind::Custom(kind), "x")
1422            .custom_created_at(Timestamp::from(secs))
1423            .sign_with_keys(&Keys::generate())
1424            .unwrap()
1425    }
1426
1427    /// Build a z-tagged event at a controlled created_at (for deterministic paging tests —
1428    /// the real outer events carry wall-clock send time).
1429    fn evt_z_at(kind: u16, z: &str, secs: u64) -> Event {
1430        EventBuilder::new(Kind::Custom(kind), "x")
1431            .custom_created_at(Timestamp::from(secs))
1432            .tags([Tag::custom(
1433                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
1434                [z.to_string()],
1435            )])
1436            .sign_with_keys(&Keys::generate())
1437            .unwrap()
1438    }
1439
1440    #[tokio::test]
1441    async fn fetch_pages_with_until_and_limit_newest_first() {
1442        // The Discord-style paging mechanism: `limit` caps newest-first; `until` walks older.
1443        let relay = super::memory::MemoryRelay::new();
1444        let relays = vec!["r1".to_string()];
1445        for s in 1..=5u64 {
1446            relay.inject(&evt_z_at(3300, "pg", s), &relays);
1447        }
1448        let secs = |evs: &[Event]| evs.iter().map(|e| e.created_at.as_secs()).collect::<Vec<_>>();
1449
1450        // Latest page: the two newest (secs 5, 4), newest-first.
1451        let latest = relay
1452            .fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], limit: Some(2), ..Default::default() }, &relays)
1453            .await
1454            .unwrap();
1455        assert_eq!(secs(&latest), vec![5, 4]);
1456
1457        // Older page before the cursor (until=3, inclusive): secs 3, 2.
1458        let older = relay
1459            .fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], until: Some(3), limit: Some(2), ..Default::default() }, &relays)
1460            .await
1461            .unwrap();
1462        assert_eq!(secs(&older), vec![3, 2]);
1463
1464        // Start of history: until=1 returns only the single oldest event — the signal the
1465        // caller uses to mark "no more older" and stop hitting the network.
1466        let start = relay
1467            .fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], until: Some(1), limit: Some(2), ..Default::default() }, &relays)
1468            .await
1469            .unwrap();
1470        assert_eq!(secs(&start), vec![1]);
1471    }
1472
1473    #[test]
1474    fn to_filter_translates_kinds_z_and_since() {
1475        // The live/test-parity claim rests on to_filter matching matches(); assert
1476        // the Filter directly. An event the Query matches must also pass the Filter.
1477        let q = Query { kinds: vec![3300], z_tags: vec!["abc".into()], since: Some(100), ..Default::default() };
1478        let filter = q.to_filter();
1479        let matching = EventBuilder::new(Kind::Custom(3300), "x")
1480            .custom_created_at(Timestamp::from(150))
1481            .tags([Tag::custom(
1482                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
1483                ["abc".to_string()],
1484            )])
1485            .sign_with_keys(&Keys::generate())
1486            .unwrap();
1487        assert!(filter.match_event(&matching, MatchEventOptions::new()), "to_filter must accept what matches() accepts");
1488        assert!(q.matches(&matching));
1489
1490        // Wrong kind, wrong z, and too-early all rejected by the same Filter.
1491        let wrong_kind = EventBuilder::new(Kind::Custom(3301), "x")
1492            .custom_created_at(Timestamp::from(150))
1493            .tags([Tag::custom(
1494                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
1495                ["abc".to_string()],
1496            )])
1497            .sign_with_keys(&Keys::generate())
1498            .unwrap();
1499        assert!(!filter.match_event(&wrong_kind, MatchEventOptions::new()));
1500    }
1501
1502    /// Build an event carrying one arbitrary single-letter tag (recipient `p`, wrapped-kind `k`).
1503    fn evt_sl(kind: u16, letter: Alphabet, value: &str) -> Event {
1504        EventBuilder::new(Kind::Custom(kind), "x")
1505            .tags([Tag::custom(
1506                TagKind::SingleLetter(SingleLetterTag::lowercase(letter)),
1507                [value.to_string()],
1508            )])
1509            .sign_with_keys(&Keys::generate())
1510            .unwrap()
1511    }
1512
1513    #[test]
1514    fn to_filter_and_matches_agree_on_authors() {
1515        let keys = Keys::generate();
1516        let e = EventBuilder::new(Kind::Custom(1059), "x").sign_with_keys(&keys).unwrap();
1517        let q = Query { kinds: vec![1059], authors: vec![keys.public_key().to_hex()], ..Default::default() };
1518        assert!(q.matches(&e));
1519        assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
1520        let miss = Query { kinds: vec![1059], authors: vec![Keys::generate().public_key().to_hex()], ..Default::default() };
1521        assert!(!miss.matches(&e));
1522        assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
1523    }
1524
1525    #[test]
1526    fn to_filter_and_matches_agree_on_p_tags() {
1527        let recipient = Keys::generate().public_key().to_hex();
1528        let e = evt_sl(1059, Alphabet::P, &recipient);
1529        let q = Query { kinds: vec![1059], p_tags: vec![recipient], ..Default::default() };
1530        assert!(q.matches(&e));
1531        assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
1532        let miss = Query { kinds: vec![1059], p_tags: vec![Keys::generate().public_key().to_hex()], ..Default::default() };
1533        assert!(!miss.matches(&e));
1534        assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
1535    }
1536
1537    #[test]
1538    fn to_filter_and_matches_agree_on_k_tags() {
1539        let e = evt_sl(1059, Alphabet::K, "3311");
1540        let q = Query { kinds: vec![1059], k_tags: vec!["3311".into()], ..Default::default() };
1541        assert!(q.matches(&e));
1542        assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
1543        let miss = Query { kinds: vec![1059], k_tags: vec!["3300".into()], ..Default::default() };
1544        assert!(!miss.matches(&e));
1545        assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
1546    }
1547
1548    #[test]
1549    fn to_filter_empty_kinds_only_constrains_z_and_since() {
1550        // Empty kinds = no kind constraint, matching matches()' behavior.
1551        let q = Query { kinds: vec![], z_tags: vec!["p".into()], since: None, ..Default::default() };
1552        let filter = q.to_filter();
1553        let e = evt(3300, "p");
1554        assert!(filter.match_event(&e, MatchEventOptions::new()));
1555        assert!(q.matches(&e));
1556    }
1557
1558    #[test]
1559    fn since_is_an_inclusive_lower_bound() {
1560        let below = evt_at(3300, 99);
1561        let exact = evt_at(3300, 100);
1562        let above = evt_at(3300, 101);
1563        let q = Query { kinds: vec![3300], z_tags: vec![], since: Some(100), ..Default::default() };
1564        assert!(!q.matches(&below), "below the floor is excluded");
1565        assert!(q.matches(&exact), "exactly the floor is included");
1566        assert!(q.matches(&above), "above the floor is included");
1567    }
1568
1569    #[test]
1570    fn z_tags_match_as_or_set() {
1571        // The whole-channel fetch lists multiple epoch pseudonyms; an event
1572        // tagged with any one of them must match.
1573        let e = evt(3300, "p2");
1574        let q = Query { kinds: vec![3300], z_tags: vec!["p1".into(), "p2".into()], since: None, ..Default::default() };
1575        assert!(q.matches(&e));
1576        let miss = Query { kinds: vec![3300], z_tags: vec!["p1".into(), "p3".into()], since: None, ..Default::default() };
1577        assert!(!miss.matches(&e));
1578    }
1579
1580    #[tokio::test]
1581    async fn fetch_unions_and_dedups_across_relays() {
1582        use super::memory::MemoryRelay;
1583        let relay = MemoryRelay::new();
1584        let relays = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
1585        let e = evt(3300, "p");
1586        relay.publish(&e, &relays).await.unwrap();
1587        let got = relay
1588            .fetch(&Query { kinds: vec![3300], z_tags: vec!["p".into()], since: None, ..Default::default() }, &relays)
1589            .await
1590            .unwrap();
1591        assert_eq!(got.len(), 1, "same event on 3 relays dedups to 1");
1592    }
1593
1594    #[tokio::test]
1595    async fn durable_broadcast_retries_only_the_failing_relays_until_they_ack() {
1596        // r1/r3 ACK on round 1; r2 fails the first 4 rounds then ACKs. The engine must keep re-sending
1597        // ONLY r2 (not the already-ACKed r1/r3) until it lands, and succeed.
1598        use std::cell::Cell;
1599        let relays = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
1600        let round = Cell::new(0usize);
1601        let r2_round_seen = Cell::new(0usize);
1602        let res = durable_broadcast(&relays, 30, std::time::Duration::ZERO, |pending| {
1603            let n = round.get();
1604            round.set(n + 1);
1605            // r2 is only ever retried alone after round 1 — assert we never re-send a relay that ACKed.
1606            if n >= 1 {
1607                assert_eq!(pending, vec!["r2".to_string()], "only the failing relay is retried");
1608                r2_round_seen.set(r2_round_seen.get() + 1);
1609            }
1610            Box::pin(async move {
1611                pending.into_iter().filter(|r| r != "r2" || n >= 4).collect()
1612            })
1613        })
1614        .await;
1615        assert!(res.is_ok(), "all relays eventually ACK → Ok");
1616        assert!(round.get() >= 5, "kept retrying r2 across rounds");
1617    }
1618
1619    #[tokio::test]
1620    async fn durable_broadcast_is_ok_if_some_ack_even_when_one_never_does() {
1621        // r1 ACKs; r2 never does. After exhausting r2's retries, the event is still durably out (r1 has
1622        // it, fetch-union covers r2), so the result is Ok — durability is best-effort per relay.
1623        let relays = vec!["r1".to_string(), "r2".to_string()];
1624        let res = durable_broadcast(&relays, 5, std::time::Duration::ZERO, |pending| {
1625            Box::pin(async move { pending.into_iter().filter(|r| r == "r1").collect() })
1626        })
1627        .await;
1628        assert!(res.is_ok(), "≥1 relay accepted → Ok despite a permanently-failing relay");
1629    }
1630
1631    #[tokio::test]
1632    async fn durable_broadcast_errs_only_if_zero_relays_ever_accept() {
1633        let relays = vec!["r1".to_string(), "r2".to_string()];
1634        let res = durable_broadcast(&relays, 5, std::time::Duration::ZERO, |_pending| {
1635            Box::pin(async move { Vec::new() }) // nobody ever ACKs
1636        })
1637        .await;
1638        assert!(res.is_err(), "zero acceptances after the retry cap → Err");
1639    }
1640
1641    #[tokio::test]
1642    async fn redundancy_self_heals_a_missing_relay() {
1643        use super::memory::MemoryRelay;
1644        let relay = MemoryRelay::new();
1645        let all = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
1646        let e = evt(3300, "p");
1647        // Event lands on ONLY r2 (the others "missed" it).
1648        relay.inject(&e, &["r2".to_string()]);
1649        assert_eq!(relay.count_on("r1"), 0);
1650        assert_eq!(relay.count_on("r2"), 1);
1651        // A fetch across the full set still finds it (redundancy).
1652        let got = relay.fetch(&Query { kinds: vec![3300], ..Default::default() }, &all).await.unwrap();
1653        assert_eq!(got.len(), 1);
1654    }
1655
1656    #[tokio::test]
1657    async fn ephemeral_kind_streams_live_but_is_never_stored_or_fetched() {
1658        use super::memory::MemoryRelay;
1659        let relay = MemoryRelay::new();
1660        let relays = vec!["r1".to_string()];
1661        let mut sub = relay.subscribe(Query { kinds: vec![21059], ..Default::default() });
1662        let e = evt(21059, "p");
1663        relay.publish(&e, &relays).await.unwrap();
1664        assert_eq!(relay.count_on("r1"), 0, "ephemeral is never stored");
1665        let got = relay
1666            .fetch(&Query { kinds: vec![21059], ..Default::default() }, &relays)
1667            .await
1668            .unwrap();
1669        assert!(got.is_empty(), "a real relay never serves an ephemeral from a fetch");
1670        assert_eq!(sub.try_recv().unwrap().id, e.id, "but a live subscriber receives it");
1671    }
1672
1673    #[tokio::test]
1674    async fn stored_kind_is_fetchable_and_delivered_live() {
1675        use super::memory::MemoryRelay;
1676        let relay = MemoryRelay::new();
1677        let relays = vec!["r1".to_string()];
1678        let mut sub = relay.subscribe(Query { kinds: vec![1059], ..Default::default() });
1679        let e = evt(1059, "p");
1680        relay.publish(&e, &relays).await.unwrap();
1681        let got = relay
1682            .fetch(&Query { kinds: vec![1059], ..Default::default() }, &relays)
1683            .await
1684            .unwrap();
1685        assert_eq!(got.len(), 1, "stored kind is fetchable");
1686        assert_eq!(sub.try_recv().unwrap().id, e.id, "and delivered to the live subscriber");
1687    }
1688
1689    #[tokio::test]
1690    async fn p_tags_route_a_giftwrap_to_the_matching_subscriber() {
1691        use super::memory::MemoryRelay;
1692        let relay = MemoryRelay::new();
1693        let relays = vec!["r1".to_string()];
1694        let alice = Keys::generate().public_key().to_hex();
1695        let bob = Keys::generate().public_key().to_hex();
1696        let mut sub_alice =
1697            relay.subscribe(Query { kinds: vec![1059], p_tags: vec![alice.clone()], ..Default::default() });
1698        let mut sub_bob =
1699            relay.subscribe(Query { kinds: vec![1059], p_tags: vec![bob.clone()], ..Default::default() });
1700        let wrap = evt_sl(1059, Alphabet::P, &alice);
1701        relay.publish(&wrap, &relays).await.unwrap();
1702        assert_eq!(sub_alice.try_recv().unwrap().id, wrap.id, "addressed recipient gets it live");
1703        assert!(sub_bob.try_recv().is_err(), "a differently-addressed subscriber does not");
1704        // Fetch agrees with the live routing.
1705        let for_alice = relay
1706            .fetch(&Query { kinds: vec![1059], p_tags: vec![alice], ..Default::default() }, &relays)
1707            .await
1708            .unwrap();
1709        assert_eq!(for_alice.len(), 1);
1710        let for_bob = relay
1711            .fetch(&Query { kinds: vec![1059], p_tags: vec![bob], ..Default::default() }, &relays)
1712            .await
1713            .unwrap();
1714        assert!(for_bob.is_empty());
1715    }
1716
1717    // ── UnionPlan: the evidence tiers' return-timing math ────────────────────
1718
1719    #[test]
1720    fn union_plan_fast_satisfied_on_first_success() {
1721        let mut p = UnionPlan::new(Evidence::Fast, 4);
1722        p.record(false);
1723        assert!(!p.satisfied(), "a failure is not evidence");
1724        p.record(true);
1725        assert!(p.satisfied(), "one genuine EOSE satisfies Fast");
1726        assert!(!p.exhausted());
1727    }
1728
1729    #[test]
1730    fn union_plan_quorum_majority_math() {
1731        // (attempted, successes needed): majority = attempted/2 + 1
1732        for (n, need) in [(2usize, 2usize), (3, 2), (4, 3), (5, 3)] {
1733            let mut p = UnionPlan::new(Evidence::Quorum, n);
1734            for _ in 0..need - 1 {
1735                p.record(true);
1736            }
1737            assert!(!p.satisfied(), "{}/{} must not satisfy quorum", need - 1, n);
1738            p.record(true);
1739            assert!(p.satisfied(), "{}/{} satisfies quorum", need, n);
1740        }
1741    }
1742
1743    #[test]
1744    fn union_plan_quorum_failures_never_substitute_for_successes() {
1745        let mut p = UnionPlan::new(Evidence::Quorum, 3);
1746        p.record(true);
1747        p.record(false);
1748        p.record(false);
1749        assert!(!p.satisfied(), "1 success + 2 failures is not a majority");
1750        assert!(p.exhausted(), "all resolved — the degraded path returns best-effort");
1751        assert_eq!(p.successes(), 1);
1752    }
1753
1754    #[test]
1755    fn union_plan_full_requires_every_relay_resolved() {
1756        let mut p = UnionPlan::new(Evidence::Full, 3);
1757        p.record(true);
1758        p.record(true);
1759        assert!(!p.satisfied(), "Full waits for the last relay even after 2 EOSEs");
1760        p.record(false);
1761        assert!(p.satisfied(), "a timeout is a resolution — Full is done");
1762        assert!(p.exhausted());
1763    }
1764
1765    #[test]
1766    fn union_plan_all_dead_is_reportable_not_a_confident_empty() {
1767        let mut p = UnionPlan::new(Evidence::Quorum, 2);
1768        p.record(false);
1769        p.record(false);
1770        assert!(p.exhausted());
1771        assert_eq!(p.successes(), 0, "the caller must map this to Err, never Ok(vec![])");
1772    }
1773
1774    // ── Circuit breaker: trip/reset rules ────────────────────────────────────
1775    // Pinned generation + unique urls per test: the breaker is one global map,
1776    // and other tests bump the REAL session generation concurrently (which
1777    // would wipe it mid-assertion via the production accessors).
1778
1779    const BREAKER_TEST_GEN: u64 = u64::MAX;
1780
1781    #[test]
1782    fn breaker_trips_only_after_consecutive_full_budget_failures() {
1783        let url = "wss://breaker-test-full-budget.example";
1784        breaker_record_at(BREAKER_TEST_GEN, url, false, true);
1785        assert!(!breaker_tripped_at(BREAKER_TEST_GEN, url), "one failure is below the threshold");
1786        breaker_record_at(BREAKER_TEST_GEN, url, false, true);
1787        assert!(breaker_tripped_at(BREAKER_TEST_GEN, url), "two consecutive full-budget failures trip");
1788    }
1789
1790    #[test]
1791    fn breaker_demoted_budget_failures_never_count() {
1792        let url = "wss://breaker-test-demoted.example";
1793        breaker_record_at(BREAKER_TEST_GEN, url, false, false);
1794        breaker_record_at(BREAKER_TEST_GEN, url, false, false);
1795        breaker_record_at(BREAKER_TEST_GEN, url, false, false);
1796        assert!(
1797            !breaker_tripped_at(BREAKER_TEST_GEN, url),
1798            "demoted-budget failures must not trip (anti-starvation: the post-cooldown probe must stay reachable)"
1799        );
1800    }
1801
1802    #[test]
1803    fn breaker_success_resets_the_entry() {
1804        let url = "wss://breaker-test-reset.example";
1805        breaker_record_at(BREAKER_TEST_GEN, url, false, true);
1806        breaker_record_at(BREAKER_TEST_GEN, url, false, true);
1807        assert!(breaker_tripped_at(BREAKER_TEST_GEN, url));
1808        // A late EOSE (e.g. via the background drain) proves slow ≠ dead.
1809        breaker_record_at(BREAKER_TEST_GEN, url, true, false);
1810        assert!(!breaker_tripped_at(BREAKER_TEST_GEN, url), "any success unconditionally resets");
1811        breaker_record_at(BREAKER_TEST_GEN, url, false, true);
1812        assert!(!breaker_tripped_at(BREAKER_TEST_GEN, url), "and the failure count restarted from zero");
1813    }
1814
1815    // ── The evidence floor ───────────────────────────────────────────────────
1816
1817    #[test]
1818    fn until_forces_full_evidence_and_default_is_quorum() {
1819        assert_eq!(Query::default().evidence, Evidence::Quorum, "unclassified sites get Quorum");
1820        assert_eq!(
1821            effective_evidence(&Query { until: Some(1), evidence: Evidence::Fast, ..Default::default() }),
1822            Evidence::Full,
1823            "a back-page can never ride Fast — the history-start latch needs the full union"
1824        );
1825        assert_eq!(
1826            effective_evidence(&Query { evidence: Evidence::Fast, ..Default::default() }),
1827            Evidence::Fast,
1828            "without `until` the declared tier stands"
1829        );
1830    }
1831}