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