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