Skip to main content

vector_core/community/
transport.rs

1//! Transport abstraction for Community events.
2//!
3//! The protocol's send/sync logic is written against this trait, not against the
4//! live Nostr client, so it can be exercised end-to-end by multiple emulated
5//! clients sharing an in-memory relay (no network, fully deterministic). Production
6//! provides an adapter over `NOSTR_CLIENT`; tests use [`MemoryRelay`].
7
8use nostr_sdk::prelude::*;
9
10/// The slice of a relay query the Community protocol needs: event kinds, the `z`
11/// pseudonym tag values, and an optional `since` floor. Production translates
12/// this into a Nostr `Filter`; the in-memory relay matches it directly.
13#[derive(Clone, Debug, Default)]
14pub struct Query {
15    pub kinds: Vec<u16>,
16    /// `z` tag values to match (OR). Empty = match any (no `z` constraint).
17    pub z_tags: Vec<String>,
18    /// `d` tag (identifier) values to match (OR). Empty = no `d` constraint. Used to
19    /// locate addressable events (e.g. a public-invite bundle by its token locator).
20    pub d_tags: Vec<String>,
21    /// `p` tag (recipient pubkey hex) values to match (OR). Empty = no `p` constraint.
22    /// Used to fetch person-addressed giftwraps (direct invites) by recipient.
23    pub p_tags: Vec<String>,
24    /// `k` tag (wrapped-kind) values to match (OR). Empty = no `k` constraint. Narrows
25    /// giftwrap fetches to the inner kind advertised on the wrap.
26    pub k_tags: Vec<String>,
27    /// Author pubkeys (hex) to match (OR). Empty = any author.
28    pub authors: Vec<String>,
29    /// Lower bound on `created_at` (seconds), inclusive.
30    pub since: Option<u64>,
31    /// Upper bound on `created_at` (seconds), inclusive — pages OLDER history (events
32    /// strictly/inclusively before a scroll cursor).
33    pub until: Option<u64>,
34    /// Max events to return (newest-first), the relay-side page cap. `None` = no limit.
35    pub limit: Option<usize>,
36}
37
38impl Query {
39    /// Does `event` satisfy this query?
40    pub fn matches(&self, event: &Event) -> bool {
41        // Compare via `Kind` (not raw u16) so this matches `to_filter`'s
42        // `Kind::Custom` normalization exactly — the live and in-memory paths must
43        // agree even at kind values that nostr maps to named variants.
44        if !self.kinds.is_empty() && !self.kinds.iter().any(|k| Kind::Custom(*k) == event.kind) {
45            return false;
46        }
47        if let Some(since) = self.since {
48            if event.created_at.as_secs() < since {
49                return false;
50            }
51        }
52        if let Some(until) = self.until {
53            if event.created_at.as_secs() > until {
54                return false;
55            }
56        }
57        if !self.authors.is_empty() && !self.authors.iter().any(|a| *a == event.pubkey.to_hex()) {
58            return false;
59        }
60        if !self.z_tags.is_empty() && !self.matches_single_letter("z", &self.z_tags, event) {
61            return false;
62        }
63        if !self.d_tags.is_empty() && !self.matches_single_letter("d", &self.d_tags, event) {
64            return false;
65        }
66        if !self.p_tags.is_empty() && !self.matches_single_letter("p", &self.p_tags, event) {
67            return false;
68        }
69        if !self.k_tags.is_empty() && !self.matches_single_letter("k", &self.k_tags, event) {
70            return false;
71        }
72        true
73    }
74
75    fn matches_single_letter(&self, name: &str, wanted: &[String], event: &Event) -> bool {
76        // ANY occurrence may satisfy the OR-set (an event can carry several `p` tags);
77        // relays match every tag instance, and matches() must agree with to_filter.
78        event.tags.iter().any(|t| {
79            let s = t.as_slice();
80            s.len() >= 2 && s[0] == name && wanted.iter().any(|w| *w == s[1])
81        })
82    }
83
84    /// Translate to a Nostr relay `Filter` for the live client.
85    pub fn to_filter(&self) -> Filter {
86        let mut filter = Filter::new();
87        if !self.kinds.is_empty() {
88            filter = filter.kinds(self.kinds.iter().map(|k| Kind::Custom(*k)));
89        }
90        if !self.z_tags.is_empty() {
91            filter = filter
92                .custom_tags(SingleLetterTag::lowercase(Alphabet::Z), self.z_tags.clone());
93        }
94        if !self.d_tags.is_empty() {
95            filter = filter.identifiers(self.d_tags.clone());
96        }
97        if !self.p_tags.is_empty() {
98            filter = filter
99                .custom_tags(SingleLetterTag::lowercase(Alphabet::P), self.p_tags.clone());
100        }
101        if !self.k_tags.is_empty() {
102            filter = filter
103                .custom_tags(SingleLetterTag::lowercase(Alphabet::K), self.k_tags.clone());
104        }
105        if !self.authors.is_empty() {
106            let authors: Vec<PublicKey> =
107                self.authors.iter().filter_map(|a| PublicKey::from_hex(a).ok()).collect();
108            if !authors.is_empty() {
109                filter = filter.authors(authors);
110            }
111        }
112        if let Some(since) = self.since {
113            filter = filter.since(Timestamp::from_secs(since));
114        }
115        if let Some(until) = self.until {
116            filter = filter.until(Timestamp::from_secs(until));
117        }
118        if let Some(limit) = self.limit {
119            filter = filter.limit(limit);
120        }
121        filter
122    }
123}
124
125/// Publish + fetch over a set of relays. Async to match the live Nostr client (the
126/// whole app is tokio-based and network I/O is async); `async-trait` boxes the
127/// futures as `Send` so impls work inside spawned tasks.
128#[async_trait::async_trait]
129pub trait Transport {
130    /// Publish `event` to every relay in `relays`. Single-attempt: Ok if ≥1 relay ACKs.
131    async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String>;
132    /// Fetch events matching `query` across `relays`, unioned and deduped by id.
133    async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String>;
134
135    /// DURABLE publish for security-critical control events (rekeys, bans, the invite registry, deletes):
136    /// retry **each relay independently** until it ACKs, up to [`MAX_PUBLISH_ATTEMPTS`] times, re-sending
137    /// only the relays that have NOT yet accepted. The already-signed `event` is broadcast as-is — the
138    /// crypto (e.g. a rekey's fresh root) is minted ONCE by the caller; this only hardens the broadcast,
139    /// so a relay blip or a brief local connectivity drop can't leave a rekey/ban under-propagated.
140    /// (Required, not defaulted — a default async-trait method would force a `Sync` bound on every
141    /// generic `T: Transport` caller. The in-memory test relay implements it as a single publish.)
142    async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String>;
143}
144
145/// Per-relay broadcast cap: retry each relay up to this many times before giving up on it (matching the
146/// NIP-17 deletable-DM durability). High enough to ride out a transient relay/local-network blip.
147pub const MAX_PUBLISH_ATTEMPTS: usize = 30;
148
149/// Union grace window: how long past the FIRST response the live transport keeps unioning
150/// slower relays before returning. Long enough for a healthy-but-slower relay on a
151/// high-latency link; short enough that a dead relay doesn't stall every sync.
152pub const FETCH_UNION_GRACE_MS: u64 = 3500;
153
154/// Hard ceiling on the initial confirmation: at least one relay must ACK within this window or the publish
155/// is a failure (we throw rather than spin on a dead/unreachable relay set forever). Once ONE relay accepts,
156/// the slow/ratelimited stragglers are threaded in the background (capped at MAX_PUBLISH_ATTEMPTS).
157pub const CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
158
159/// The retry engine behind a durable broadcast, factored out so it is unit-testable without a live
160/// client. `send_round(pending)` performs ONE broadcast attempt to the given still-pending relays and
161/// returns the subset that ACKed; this retries the rest (with `backoff` between rounds) until every relay
162/// has ACKed or `max_attempts` is reached. Returns `Ok` if at least one relay ever accepted (the event is
163/// durably out there; the fetch-union self-heals the stragglers), `Err` only if ZERO relays accepted
164/// after exhausting the retries. Dedups `relays` first so a duplicated url isn't double-counted.
165pub async fn durable_broadcast<'a, F>(
166    relays: &[String],
167    max_attempts: usize,
168    backoff: std::time::Duration,
169    mut send_round: F,
170) -> Result<(), String>
171where
172    F: FnMut(Vec<String>) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send + 'a>>,
173{
174    let mut pending: Vec<String> = Vec::new();
175    for r in relays {
176        if !pending.contains(r) {
177            pending.push(r.clone());
178        }
179    }
180    let total = pending.len();
181    if total == 0 {
182        return Err("no relays to broadcast to".to_string());
183    }
184    for attempt in 0..max_attempts {
185        if pending.is_empty() {
186            break;
187        }
188        let acked = send_round(pending.clone()).await;
189        pending.retain(|r| !acked.contains(r));
190        if pending.is_empty() || attempt + 1 == max_attempts {
191            break;
192        }
193        if !backoff.is_zero() {
194            tokio::time::sleep(backoff).await;
195        }
196    }
197    if pending.len() < total {
198        Ok(()) // at least one relay accepted; the rest were retried to the cap
199    } else {
200        Err(format!("no relay accepted the event after {max_attempts} attempts each"))
201    }
202}
203
204/// Sink for "straggler" events — ones a SLOWER relay returns after a racing [`LiveTransport::fetch`]
205/// has already handed the caller the first relay's batch. The integrator (src-tauri) registers a
206/// handler that feeds them back through the normal Concord ingest path (`process_incoming` for
207/// content, control/rekey re-fold for authority). The transport stays DUMB: it dedups only by event
208/// id (identical bytes) and forwards everything else. Two relays disagreeing on the latest control
209/// commit are two editions with DIFFERENT ids, so BOTH reach the ingester, where the deterministic
210/// convergence engine (version floors + same-version tiebreakers) decides the winner. The transport
211/// never resolves conflicts itself.
212pub trait CommunityIngestSink: Send + Sync + 'static {
213    fn ingest_stragglers(&self, events: Vec<Event>);
214}
215
216static INGEST_SINK: std::sync::OnceLock<Box<dyn CommunityIngestSink>> = std::sync::OnceLock::new();
217
218/// Register the straggler ingest sink. Call once during app startup (mirrors `set_event_emitter`).
219pub fn set_community_ingest_sink(sink: Box<dyn CommunityIngestSink>) {
220    let _ = INGEST_SINK.set(sink);
221}
222
223fn submit_stragglers(events: Vec<Event>) {
224    if events.is_empty() {
225        return;
226    }
227    if let Some(sink) = INGEST_SINK.get() {
228        sink.ingest_stragglers(events);
229    }
230}
231
232/// Relay urls already added + connected into the shared pool this session, so `warm_client` can
233/// skip the per-call `add_relay` bookkeeping + whole-pool `connect()` sweep once a community's
234/// relays are established (relays auto-reconnect on drop, so the re-kick is redundant once warm).
235/// Keyed by session generation: an account swap bumps the generation, invalidating every entry
236/// (the pool is rebuilt for the new account).
237static WARMED_RELAYS: std::sync::LazyLock<std::sync::Mutex<(u64, std::collections::HashSet<String>)>> =
238    std::sync::LazyLock::new(|| std::sync::Mutex::new((0, std::collections::HashSet::new())));
239
240/// Forget a relay from the warm set — call when a relay is REMOVED from the pool (e.g. pruned after
241/// leaving a community), so a later `warm_client` for another community that shares it doesn't
242/// fast-path-skip the re-add and target a relay the pool no longer holds. Poison-tolerant: the set
243/// is pure optimization state, so a poisoned lock is recovered rather than propagated.
244pub fn forget_warmed_relay(url: &str) {
245    WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner()).1.remove(url);
246}
247
248/// Max time a community network op holds while Tor is enabled-but-not-yet-
249/// bootstrapped. Generous enough for a circuit to land on a normal connection,
250/// bounded so a Tor that never comes up can't hang the op forever.
251#[cfg(feature = "tor")]
252const TOR_READY_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
253
254/// Poll `is_blocked` until it clears or `max_wait` elapses.
255///
256/// When Tor is enabled but its SOCKS proxy isn't up yet, `transport_state()` is
257/// `RequiredButInactive` and every relay is routed to the blackhole proxy, so a
258/// send fails at the TCP layer INSTANTLY — surfacing as a misleading "no relay
259/// accepted the event" with zero network wait. Holding here turns that into
260/// either success (once the circuit lands) or an honest "Tor is still
261/// connecting" error. Generic over the predicate so it is testable without a
262/// live Tor.
263#[allow(dead_code)]
264async fn wait_until_tor_ready<F: Fn() -> bool>(
265    is_blocked: F,
266    max_wait: std::time::Duration,
267) -> Result<(), String> {
268    if !is_blocked() {
269        return Ok(());
270    }
271    let deadline = std::time::Instant::now() + max_wait;
272    while is_blocked() {
273        if std::time::Instant::now() >= deadline {
274            return Err("Tor is still connecting. Wait a moment and try again.".to_string());
275        }
276        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
277    }
278    Ok(())
279}
280
281/// Shed pooled Community relays from `candidates` that no JOINED community still needs. Used by both
282/// the leave path (relays of a community we left) and the invite-preload TTL cleanup (relays an
283/// unsolicited/declined invite warmed but never became a join, #297). Keep rules: a relay is kept if
284/// a remaining joined community lists it, OR it carries READ/WRITE (the user's own chat relays —
285/// Community relays are GOSSIP-only, so never READ/WRITE). A pruned relay re-warms automatically if
286/// its invite is later accepted (the join's subscription re-adds it), so pruning a still-pending
287/// invite's relay is safe.
288pub async fn prune_unneeded_community_relays(candidates: &[String]) {
289    if candidates.is_empty() {
290        return;
291    }
292    let Some(client) = crate::state::nostr_client() else { return };
293
294    let mut still_needed: std::collections::HashSet<String> = std::collections::HashSet::new();
295    if let Ok(ids) = crate::db::community::list_community_ids() {
296        for id in ids {
297            if let Ok(Some(c)) = crate::db::community::load_community(&id) {
298                for r in &c.relays {
299                    still_needed.insert(r.clone());
300                }
301            }
302        }
303    }
304
305    let pool = client.pool();
306    // all_relays(): community relays carry GOSSIP, so they're absent from `relays()` (READ/WRITE only).
307    let pooled = pool.all_relays().await;
308    for url in candidates {
309        if still_needed.contains(url) {
310            continue;
311        }
312        if let Ok(parsed) = nostr_sdk::RelayUrl::parse(url) {
313            if let Some(relay) = pooled.get(&parsed) {
314                if relay.flags().has_read() || relay.flags().has_write() {
315                    continue; // a real chat relay (or an overlap) — never sever
316                }
317            }
318            let _ = pool.force_remove_relay(parsed).await; // plain remove_relay refuses GOSSIP
319            forget_warmed_relay(url);
320        }
321    }
322}
323
324/// Production [`Transport`] over the live Nostr network.
325///
326/// Reuses the app's persistent client (`state::nostr_client`) — already connected to the user's relays,
327/// which ARE the Community's relays — and targets sends/fetches at the Community relay set explicitly. No
328/// per-call cold handshake (a throwaway client paid ~4s of TLS + relay handshake on every op). Any Community
329/// relay the pool doesn't already hold is added idempotently, mirroring the realtime subscription.
330pub struct LiveTransport {
331    timeout: std::time::Duration,
332}
333
334impl Default for LiveTransport {
335    fn default() -> Self {
336        Self { timeout: std::time::Duration::from_secs(10) }
337    }
338}
339
340impl LiveTransport {
341    pub fn new() -> Self {
342        Self::default()
343    }
344
345    pub fn with_timeout(timeout: std::time::Duration) -> Self {
346        Self { timeout }
347    }
348
349    /// Grab the app's persistent client and make sure it's connected to `relays` — the Community's relays
350    /// are the user's own relays, so this is almost always a pure no-op (already in the warm pool). A relay
351    /// the pool doesn't hold yet is added idempotently (mirrors what the realtime subscription does), then
352    /// `connect()` kicks it without disturbing the already-connected majority. Never shut this client down:
353    /// it is shared. Errors only if there is no client yet or every relay url was invalid.
354    async fn warm_client(relays: &[String], connect_timeout: std::time::Duration) -> Result<Client, String> {
355        if relays.is_empty() {
356            return Err("community has no relays configured".to_string());
357        }
358        // Tor gate — runs BEFORE the warmed-cache fast path so a relay warmed
359        // before Tor was toggled on still waits. While Tor is enabled but not yet
360        // bootstrapped, every relay points at the blackhole proxy and a send
361        // fails instantly; hold for the circuit, then fail honestly if it never
362        // comes up (see `wait_until_tor_ready`).
363        #[cfg(feature = "tor")]
364        wait_until_tor_ready(
365            || matches!(crate::tor::transport_state(), crate::tor::TorTransportState::RequiredButInactive),
366            TOR_READY_WAIT,
367        ).await?;
368        let client = crate::state::nostr_client().ok_or_else(|| "nostr client not initialized".to_string())?;
369
370        // Fast path: every one of these relays was already warmed this session → the pool holds and
371        // (auto-)maintains them, so skip the redundant add_relay + connect churn that otherwise runs on
372        // EVERY fetch/publish. Account swaps bump the generation, dropping the cache.
373        let generation = crate::state::current_session_generation();
374        {
375            let warmed = WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner());
376            if warmed.0 == generation && relays.iter().all(|r| warmed.1.contains(r)) {
377                return Ok(client);
378            }
379        }
380
381        // `add_relay` returns Ok(true) if NEWLY added, Ok(false) if the pool already held it.
382        // Community relays join GOSSIP|PING (see `community_relay_options`) so they stay 24/7 warm
383        // without pulling the user's DM/profile traffic onto relays they don't own. An overlap
384        // relay already in the pool as a user relay keeps its READ+WRITE flags (add_relay no-ops).
385        let mut added_new = false;
386        let mut succeeded: Vec<&String> = Vec::new();
387        for url in relays {
388            let opts = crate::community_relay_options();
389            match client.pool().add_relay(url.as_str(), opts).await {
390                Ok(true) => { added_new = true; succeeded.push(url); }
391                Ok(false) => { succeeded.push(url); }
392                Err(_) => {}
393            }
394        }
395        if succeeded.is_empty() {
396            return Err("no valid community relays could be added".to_string());
397        }
398        if added_new {
399            // A relay we weren't already connected to (a Community on non-default relays). `connect()`
400            // returns before sockets are up, so WAIT for it — otherwise the immediate fetch/send reaches
401            // zero relays. Already-connected relays return instantly in `success`, so the warm majority
402            // adds no latency; only the genuinely-new relay's handshake is awaited (bounded).
403            let _ = client.try_connect(connect_timeout).await;
404        } else {
405            // Every relay already warm in the pool — cheap re-kick of any dropped connection, no wait.
406            client.connect().await;
407        }
408
409        // Record the now-connected relays as warmed for this generation so subsequent calls fast-path
410        // (reset the set if the generation advanced under us — a swap mid-warm).
411        {
412            let mut warmed = WARMED_RELAYS.lock().unwrap_or_else(|e| e.into_inner());
413            if warmed.0 != generation {
414                warmed.0 = generation;
415                warmed.1.clear();
416            }
417            for url in succeeded {
418                warmed.1.insert(url.clone());
419            }
420        }
421        Ok(client)
422    }
423}
424
425#[async_trait::async_trait]
426impl Transport for LiveTransport {
427    async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
428        let client = Self::warm_client(relays, self.timeout).await?;
429        let timeout = self.timeout;
430        let mut targets: Vec<String> = Vec::new();
431        for r in relays { if !targets.contains(r) { targets.push(r.clone()); } }
432        // Fan out one send per relay and RETURN on the first ACK — never wait for the slowest relay (a
433        // distant/ratelimited one must not gate a reaction/edit/message). Each send is SPAWNED, so the rest
434        // keep delivering to every relay after we return (dropping a JoinHandle detaches, it doesn't abort).
435        // Single attempt — durable retry is publish_durable's job. The sends only touch relays, no per-account
436        // state, so no SessionGuard is needed.
437        use futures_util::stream::{FuturesUnordered, StreamExt};
438        let mut sends: FuturesUnordered<_> = targets
439            .into_iter()
440            .map(|r| {
441                let client = client.clone();
442                let event = event.clone();
443                tokio::spawn(async move {
444                    matches!(
445                        tokio::time::timeout(timeout, client.send_event_to(vec![r.clone()], &event)).await,
446                        Ok(Ok(out)) if RelayUrl::parse(&r).map(|u| out.success.contains(&u)).unwrap_or(false)
447                    )
448                })
449            })
450            .collect();
451        while let Some(joined) = sends.next().await {
452            if matches!(joined, Ok(true)) {
453                return Ok(()); // first relay ACKed; the others keep delivering in the background
454            }
455        }
456        Err("no relay accepted the event".to_string())
457    }
458
459    async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
460        let client = Self::warm_client(relays, self.timeout).await?;
461        let timeout = self.timeout;
462        let filter = query.to_filter();
463
464        let mut targets: Vec<String> = Vec::new();
465        for r in relays {
466            if !targets.contains(r) {
467                targets.push(r.clone());
468            }
469        }
470
471        // One relay → nothing to race.
472        if targets.len() <= 1 {
473            return client
474                .fetch_events_from(targets, filter, timeout)
475                .await
476                .map(|evs| evs.into_iter().collect())
477                .map_err(|e| e.to_string());
478        }
479
480        // RACE per-relay (mirrors the publish first-ACK race): hand the caller the FIRST relay's
481        // batch the instant it completes, then keep the slower relays running in the BACKGROUND and
482        // feed any events they alone hold back through the ingester. Never wait for the slowest relay.
483        use futures_util::stream::{FuturesUnordered, StreamExt};
484        let mut fetches: FuturesUnordered<_> = targets
485            .into_iter()
486            .map(|r| {
487                let client = client.clone();
488                let filter = filter.clone();
489                tokio::spawn(async move {
490                    client
491                        .fetch_events_from(vec![r], filter, timeout)
492                        .await
493                        .map(|evs| evs.into_iter().collect::<Vec<Event>>())
494                        .unwrap_or_default()
495                })
496            })
497            .collect();
498
499        // UNION every relay that answers — ALWAYS. A single fast-but-shallow relay must never be
500        // the sole input to what comes back: fail-closed folds gap-quarantine on a partial plane
501        // (seats wedge on stale names/roles), the re-founding coverage gate aborts on a missing
502        // head, and the history-start verdict latches scroll-back dead. All three happened live
503        // off the same first-relay-wins race. Back-paging (`until`) drains to completion (its
504        // verdict must be authoritative; bounded by the per-relay timeout); everything else
505        // drains up to a grace window past the FIRST response — so one dead relay costs the
506        // grace, not the full timeout. Relays slower than the grace still background-merge below.
507        let mut result: Vec<Event> = Vec::new();
508        loop {
509            match fetches.next().await {
510                Some(Ok(evs)) => {
511                    result = evs;
512                    break;
513                }
514                Some(Err(_)) => continue, // task join error — try the next relay
515                None => break,            // every relay failed
516            }
517        }
518        let mut union_ids: std::collections::HashSet<EventId> = result.iter().map(|e| e.id).collect();
519        if query.until.is_some() {
520            while let Some(joined) = fetches.next().await {
521                if let Ok(evs) = joined {
522                    for e in evs {
523                        if union_ids.insert(e.id) {
524                            result.push(e);
525                        }
526                    }
527                }
528            }
529        } else if !fetches.is_empty() {
530            let grace = tokio::time::sleep(std::time::Duration::from_millis(FETCH_UNION_GRACE_MS));
531            tokio::pin!(grace);
532            loop {
533                tokio::select! {
534                    _ = &mut grace => break,
535                    next = fetches.next() => match next {
536                        Some(Ok(evs)) => {
537                            for e in evs {
538                                if union_ids.insert(e.id) {
539                                    result.push(e);
540                                }
541                            }
542                        }
543                        Some(Err(_)) => continue,
544                        None => break,
545                    }
546                }
547            }
548        }
549
550        // Background-merge the relays that haven't finished: dedup by event id ONLY (identical bytes)
551        // against what we returned, then hand the rest to the ingester. Conflicting editions carry
552        // distinct ids, so the protocol's convergence engine resolves them — not the transport.
553        if !fetches.is_empty() {
554            let seen: std::collections::HashSet<EventId> = result.iter().map(|e| e.id).collect();
555            // Captured BEFORE the drain spawn: the drain can outlive an account swap, and
556            // stragglers fetched under the prior session must not feed the new one's ingest.
557            let session = crate::state::SessionGuard::capture();
558            tokio::spawn(async move {
559                let mut extra: Vec<Event> = Vec::new();
560                let mut extra_ids: std::collections::HashSet<EventId> = std::collections::HashSet::new();
561                while let Some(joined) = fetches.next().await {
562                    if let Ok(evs) = joined {
563                        for e in evs {
564                            if !seen.contains(&e.id) && extra_ids.insert(e.id) {
565                                extra.push(e);
566                            }
567                        }
568                    }
569                }
570                if !session.is_valid() {
571                    return;
572                }
573                submit_stragglers(extra);
574            });
575        }
576
577        Ok(result)
578    }
579
580    async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
581        // "Durable" = confirm the network has it (≥1 relay ACKs within CONFIRM_WINDOW), then keep bugging the
582        // SLOW/ratelimited stragglers (Damus-style 1-event/min) in the BACKGROUND. If NOTHING ACKs in the
583        // window we throw — a dead relay set is a failure, not an endless wait. Uses the shared warm client
584        // (NEVER shut down here) and targets the community relay set across rounds.
585        let client = Self::warm_client(relays, self.timeout).await?;
586        let timeout = self.timeout;
587        let event = event.clone();
588        let backoff = std::time::Duration::from_millis(750);
589        let mut pending: Vec<String> = Vec::new();
590        for r in relays { if !pending.contains(r) { pending.push(r.clone()); } }
591        if pending.is_empty() {
592            return Err("no relays to broadcast to".to_string());
593        }
594
595        // Phase 1 — CONFIRM: RACE the relays and return the instant ANY one ACKs — never wait for the
596        // slowest. `send_event_to(all)` blocks on the slowest relay (a distant/ratelimited one dominates the
597        // latency), so we fan out one send per relay and take the first winner; the losers are cancelled and
598        // re-sent in the background. Retry rounds within CONFIRM_WINDOW; zero ACKs in the window = failure.
599        let mut acked_any = false;
600        let _ = tokio::time::timeout(CONFIRM_WINDOW, async {
601            loop {
602                let sends = pending.iter().cloned().map(|r| {
603                    let client = &client;
604                    let event = &event;
605                    Box::pin(async move {
606                        match tokio::time::timeout(timeout, client.send_event_to(vec![r.clone()], event)).await {
607                            Ok(Ok(out)) if RelayUrl::parse(&r).map(|u| out.success.contains(&u)).unwrap_or(false) => Ok(r),
608                            _ => Err(()),
609                        }
610                    })
611                });
612                if let Ok((winner, _losers)) = futures_util::future::select_ok(sends).await {
613                    acked_any = true;
614                    pending.retain(|r| r != &winner);
615                    break;
616                }
617                tokio::time::sleep(backoff).await;
618            }
619        })
620        .await;
621
622        if !acked_any {
623            return Err(format!("no relay accepted the event within {}s", CONFIRM_WINDOW.as_secs()));
624        }
625        if pending.is_empty() {
626            return Ok(()); // every relay ACKed during the confirm phase
627        }
628
629        // Phase 2 — BACKGROUND: thread the laggards through a durable publisher (retries each up to
630        // MAX_PUBLISH_ATTEMPTS at a 750ms backoff, so it can't run forever). The caller returns NOW with its
631        // confirmed ACK; 's fetch-union heals anything that never lands. The client is shared — not torn
632        // down — so the spawned task just drops its handle when finished.
633        tokio::spawn(async move {
634            let client_ref = &client;
635            let event_ref = &event;
636            let _ = durable_broadcast(&pending, MAX_PUBLISH_ATTEMPTS, backoff, move |round| {
637                Box::pin(async move {
638                    match tokio::time::timeout(timeout, client_ref.send_event_to(round.clone(), event_ref)).await {
639                        Ok(Ok(output)) => round.into_iter().filter(|p| RelayUrl::parse(p).map(|u| output.success.contains(&u)).unwrap_or(false)).collect(),
640                        _ => Vec::new(),
641                    }
642                })
643            })
644            .await;
645        });
646        Ok(())
647    }
648}
649
650#[cfg(test)]
651pub(crate) mod memory {
652    use super::*;
653    use std::collections::{HashMap, HashSet};
654    use std::sync::Mutex;
655
656    /// An in-memory stand-in for the Community's relay set. Stores events per relay
657    /// url so tests can model partial propagation (a relay that missed an event) and
658    /// verify the redundancy/self-heal property: a fetch across the set unions +
659    /// dedups, so a gap on one relay is covered by its siblings.
660    pub struct MemoryRelay {
661        per_relay: Mutex<HashMap<String, Vec<Event>>>,
662        subscribers: Mutex<Vec<(Query, tokio::sync::mpsc::UnboundedSender<Event>)>>,
663    }
664
665    /// NIP-01 ephemeral range: relays stream these to live subscriptions but never store
666    /// them, so a fetch on a real relay can never return one.
667    fn is_ephemeral(kind: u16) -> bool {
668        (20000..30000).contains(&kind)
669    }
670
671    impl MemoryRelay {
672        pub fn new() -> Self {
673            MemoryRelay {
674                per_relay: Mutex::new(HashMap::new()),
675                subscribers: Mutex::new(Vec::new()),
676            }
677        }
678
679        /// Open a live subscription: every subsequent publish/inject matching `query` is
680        /// delivered — ephemerals included, which stream but are never stored.
681        pub fn subscribe(&self, query: Query) -> tokio::sync::mpsc::UnboundedReceiver<Event> {
682            let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
683            self.subscribers.lock().unwrap().push((query, tx));
684            rx
685        }
686
687        /// Push `event` to every live matching subscriber, pruning closed ones.
688        fn deliver(&self, event: &Event) {
689            self.subscribers.lock().unwrap().retain(|(q, tx)| {
690                if q.matches(event) {
691                    tx.send(event.clone()).is_ok()
692                } else {
693                    !tx.is_closed()
694                }
695            });
696        }
697
698        /// Publish to ONLY a subset of relays — used to simulate a relay missing an
699        /// event (e.g. a dropped rekey) for redundancy tests.
700        pub fn inject(&self, event: &Event, relays: &[String]) {
701            self.deliver(event);
702            if is_ephemeral(event.kind.as_u16()) {
703                return; // live-delivered above, never stored
704            }
705            // Replaceable kinds: parameterized (30000-39999, keyed by (kind, pubkey, d-tag)) AND
706            // standard (10000-19999, plus 0/3, keyed by (kind, pubkey) — the d-tag is "") — a relay keeps
707            // only the latest at that coordinate, so a new event REPLACES the old (NIP-01). This is what
708            // makes a revocation tombstone overwrite a bundle, and a fresh 13302 supersede the last one,
709            // even on relays that ignore deletions — model it so tests match real relay behavior.
710            let d_tag = |e: &Event| e.tags.iter().find_map(|t| {
711                let s = t.as_slice();
712                (s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
713            }).unwrap_or_default();
714            let k = event.kind.as_u16();
715            let replaceable = (30000..40000).contains(&k) || (10000..20000).contains(&k) || k == 0 || k == 3;
716            let coord = (event.kind.as_u16(), event.pubkey, d_tag(event));
717            let mut map = self.per_relay.lock().unwrap();
718            for r in relays {
719                let v = map.entry(r.clone()).or_default();
720                if replaceable {
721                    v.retain(|e| (e.kind.as_u16(), e.pubkey, d_tag(e)) != coord);
722                }
723                v.push(event.clone());
724            }
725        }
726
727        /// How many events a given relay holds (test introspection).
728        pub fn count_on(&self, relay: &str) -> usize {
729            self.per_relay.lock().unwrap().get(relay).map_or(0, |v| v.len())
730        }
731
732        /// Apply a NIP-09 deletion: drop any stored event matched by the deletion's `e`
733        /// tags (by id) OR `a` tags (addressable coordinate `kind:pubkey:d`), AND whose
734        /// author matches the deletion's author (a deleter can only delete their own
735        /// events — same rule strfry enforces).
736        fn apply_deletion(&self, deletion: &Event, relays: &[String]) {
737            let mut id_targets: HashSet<String> = HashSet::new();
738            let mut coord_targets: HashSet<String> = HashSet::new();
739            for t in deletion.tags.iter() {
740                let s = t.as_slice();
741                if s.len() >= 2 && s[0] == "e" {
742                    id_targets.insert(s[1].clone());
743                } else if s.len() >= 2 && s[0] == "a" {
744                    coord_targets.insert(s[1].clone());
745                }
746            }
747            let mut map = self.per_relay.lock().unwrap();
748            for r in relays {
749                if let Some(events) = map.get_mut(r) {
750                    events.retain(|e| {
751                        if e.pubkey != deletion.pubkey {
752                            return true;
753                        }
754                        if id_targets.contains(&e.id.to_hex()) {
755                            return false;
756                        }
757                        // Addressable coordinate "kind:pubkey:d-identifier".
758                        let d = e.tags.iter().find_map(|t| {
759                            let s = t.as_slice();
760                            (s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
761                        }).unwrap_or_default();
762                        let coord = format!("{}:{}:{}", e.kind.as_u16(), e.pubkey.to_hex(), d);
763                        !coord_targets.contains(&coord)
764                    });
765                }
766            }
767        }
768    }
769
770    #[async_trait::async_trait]
771    impl Transport for MemoryRelay {
772        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
773            // Honor NIP-09 so the delete→gone cycle is testable offline.
774            if event.kind == Kind::EventDeletion {
775                self.apply_deletion(event, relays);
776                self.deliver(event);
777            } else {
778                self.inject(event, relays);
779            }
780            Ok(())
781        }
782
783        // The in-memory relay always accepts, so a "durable" publish is just a publish (the retry
784        // engine itself is unit-tested separately via `durable_broadcast`).
785        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
786            self.publish(event, relays).await
787        }
788
789        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
790            let map = self.per_relay.lock().unwrap();
791            let mut seen = HashSet::new();
792            let mut out = Vec::new();
793            for r in relays {
794                if let Some(events) = map.get(r) {
795                    for ev in events {
796                        // Never stored, but guard the read path too: a real relay never
797                        // serves an ephemeral from a fetch, whatever got in.
798                        if is_ephemeral(ev.kind.as_u16()) {
799                            continue;
800                        }
801                        if query.matches(ev) && seen.insert(ev.id) {
802                            out.push(ev.clone());
803                        }
804                    }
805                }
806            }
807            // Apply the relay-side page cap newest-first (matches how relays honor `limit`):
808            // sort by created_at desc, keep the newest `limit`. Mirrors production paging.
809            if let Some(limit) = query.limit {
810                out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
811                out.truncate(limit);
812            }
813            Ok(out)
814        }
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    // ── Tor gate (community publish over a not-yet-bootstrapped Tor) ──────────
823    // Hermetic: drives `wait_until_tor_ready` with an injected predicate, so no
824    // live Tor is needed and the result is deterministic.
825
826    #[tokio::test]
827    async fn tor_gate_passes_immediately_when_not_blocked() {
828        let start = std::time::Instant::now();
829        let res = wait_until_tor_ready(|| false, std::time::Duration::from_secs(30)).await;
830        assert!(res.is_ok());
831        assert!(start.elapsed() < std::time::Duration::from_secs(1), "must not wait when Tor is ready");
832    }
833
834    #[tokio::test]
835    async fn tor_gate_errors_honestly_after_timeout_when_perpetually_blocked() {
836        // The bug: without this gate the send failed INSTANTLY with a misleading
837        // "no relay accepted". Now it waits the window, then names the real cause.
838        let res = wait_until_tor_ready(|| true, std::time::Duration::from_millis(300)).await;
839        let err = res.expect_err("should error when Tor never activates");
840        assert!(err.to_lowercase().contains("tor"), "error must name Tor, got: {err}");
841    }
842
843    #[tokio::test]
844    async fn tor_gate_passes_once_circuit_comes_up_mid_wait() {
845        let calls = std::sync::atomic::AtomicUsize::new(0);
846        // Blocked for the first 3 polls, then Tor becomes ready.
847        let res = wait_until_tor_ready(
848            || calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 3,
849            std::time::Duration::from_secs(5),
850        ).await;
851        assert!(res.is_ok(), "should succeed once Tor activates within the window");
852    }
853
854    fn evt(kind: u16, z: &str) -> Event {
855        EventBuilder::new(Kind::Custom(kind), "x")
856            .tags([Tag::custom(
857                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
858                [z.to_string()],
859            )])
860            .sign_with_keys(&Keys::generate())
861            .unwrap()
862    }
863
864    #[test]
865    fn query_matches_kind_and_z() {
866        let e = evt(3300, "abc");
867        assert!(Query { kinds: vec![3300], z_tags: vec!["abc".into()], since: None, ..Default::default() }.matches(&e));
868        assert!(!Query { kinds: vec![3301], ..Default::default() }.matches(&e));
869        assert!(!Query { kinds: vec![], z_tags: vec!["xyz".into()], since: None, ..Default::default() }.matches(&e));
870        assert!(Query::default().matches(&e), "empty query matches anything");
871    }
872
873    fn evt_at(kind: u16, secs: u64) -> Event {
874        EventBuilder::new(Kind::Custom(kind), "x")
875            .custom_created_at(Timestamp::from(secs))
876            .sign_with_keys(&Keys::generate())
877            .unwrap()
878    }
879
880    /// Build a z-tagged event at a controlled created_at (for deterministic paging tests —
881    /// the real outer events carry wall-clock send time).
882    fn evt_z_at(kind: u16, z: &str, secs: u64) -> Event {
883        EventBuilder::new(Kind::Custom(kind), "x")
884            .custom_created_at(Timestamp::from(secs))
885            .tags([Tag::custom(
886                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
887                [z.to_string()],
888            )])
889            .sign_with_keys(&Keys::generate())
890            .unwrap()
891    }
892
893    #[tokio::test]
894    async fn fetch_pages_with_until_and_limit_newest_first() {
895        // The Discord-style paging mechanism: `limit` caps newest-first; `until` walks older.
896        let relay = super::memory::MemoryRelay::new();
897        let relays = vec!["r1".to_string()];
898        for s in 1..=5u64 {
899            relay.inject(&evt_z_at(3300, "pg", s), &relays);
900        }
901        let secs = |evs: &[Event]| evs.iter().map(|e| e.created_at.as_secs()).collect::<Vec<_>>();
902
903        // Latest page: the two newest (secs 5, 4), newest-first.
904        let latest = relay
905            .fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], limit: Some(2), ..Default::default() }, &relays)
906            .await
907            .unwrap();
908        assert_eq!(secs(&latest), vec![5, 4]);
909
910        // Older page before the cursor (until=3, inclusive): secs 3, 2.
911        let older = relay
912            .fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], until: Some(3), limit: Some(2), ..Default::default() }, &relays)
913            .await
914            .unwrap();
915        assert_eq!(secs(&older), vec![3, 2]);
916
917        // Start of history: until=1 returns only the single oldest event — the signal the
918        // caller uses to mark "no more older" and stop hitting the network.
919        let start = relay
920            .fetch(&Query { kinds: vec![3300], z_tags: vec!["pg".into()], until: Some(1), limit: Some(2), ..Default::default() }, &relays)
921            .await
922            .unwrap();
923        assert_eq!(secs(&start), vec![1]);
924    }
925
926    #[test]
927    fn to_filter_translates_kinds_z_and_since() {
928        // The live/test-parity claim rests on to_filter matching matches(); assert
929        // the Filter directly. An event the Query matches must also pass the Filter.
930        let q = Query { kinds: vec![3300], z_tags: vec!["abc".into()], since: Some(100), ..Default::default() };
931        let filter = q.to_filter();
932        let matching = EventBuilder::new(Kind::Custom(3300), "x")
933            .custom_created_at(Timestamp::from(150))
934            .tags([Tag::custom(
935                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
936                ["abc".to_string()],
937            )])
938            .sign_with_keys(&Keys::generate())
939            .unwrap();
940        assert!(filter.match_event(&matching, MatchEventOptions::new()), "to_filter must accept what matches() accepts");
941        assert!(q.matches(&matching));
942
943        // Wrong kind, wrong z, and too-early all rejected by the same Filter.
944        let wrong_kind = EventBuilder::new(Kind::Custom(3301), "x")
945            .custom_created_at(Timestamp::from(150))
946            .tags([Tag::custom(
947                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
948                ["abc".to_string()],
949            )])
950            .sign_with_keys(&Keys::generate())
951            .unwrap();
952        assert!(!filter.match_event(&wrong_kind, MatchEventOptions::new()));
953    }
954
955    /// Build an event carrying one arbitrary single-letter tag (recipient `p`, wrapped-kind `k`).
956    fn evt_sl(kind: u16, letter: Alphabet, value: &str) -> Event {
957        EventBuilder::new(Kind::Custom(kind), "x")
958            .tags([Tag::custom(
959                TagKind::SingleLetter(SingleLetterTag::lowercase(letter)),
960                [value.to_string()],
961            )])
962            .sign_with_keys(&Keys::generate())
963            .unwrap()
964    }
965
966    #[test]
967    fn to_filter_and_matches_agree_on_authors() {
968        let keys = Keys::generate();
969        let e = EventBuilder::new(Kind::Custom(1059), "x").sign_with_keys(&keys).unwrap();
970        let q = Query { kinds: vec![1059], authors: vec![keys.public_key().to_hex()], ..Default::default() };
971        assert!(q.matches(&e));
972        assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
973        let miss = Query { kinds: vec![1059], authors: vec![Keys::generate().public_key().to_hex()], ..Default::default() };
974        assert!(!miss.matches(&e));
975        assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
976    }
977
978    #[test]
979    fn to_filter_and_matches_agree_on_p_tags() {
980        let recipient = Keys::generate().public_key().to_hex();
981        let e = evt_sl(1059, Alphabet::P, &recipient);
982        let q = Query { kinds: vec![1059], p_tags: vec![recipient], ..Default::default() };
983        assert!(q.matches(&e));
984        assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
985        let miss = Query { kinds: vec![1059], p_tags: vec![Keys::generate().public_key().to_hex()], ..Default::default() };
986        assert!(!miss.matches(&e));
987        assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
988    }
989
990    #[test]
991    fn to_filter_and_matches_agree_on_k_tags() {
992        let e = evt_sl(1059, Alphabet::K, "3311");
993        let q = Query { kinds: vec![1059], k_tags: vec!["3311".into()], ..Default::default() };
994        assert!(q.matches(&e));
995        assert!(q.to_filter().match_event(&e, MatchEventOptions::new()));
996        let miss = Query { kinds: vec![1059], k_tags: vec!["3300".into()], ..Default::default() };
997        assert!(!miss.matches(&e));
998        assert!(!miss.to_filter().match_event(&e, MatchEventOptions::new()));
999    }
1000
1001    #[test]
1002    fn to_filter_empty_kinds_only_constrains_z_and_since() {
1003        // Empty kinds = no kind constraint, matching matches()' behavior.
1004        let q = Query { kinds: vec![], z_tags: vec!["p".into()], since: None, ..Default::default() };
1005        let filter = q.to_filter();
1006        let e = evt(3300, "p");
1007        assert!(filter.match_event(&e, MatchEventOptions::new()));
1008        assert!(q.matches(&e));
1009    }
1010
1011    #[test]
1012    fn since_is_an_inclusive_lower_bound() {
1013        let below = evt_at(3300, 99);
1014        let exact = evt_at(3300, 100);
1015        let above = evt_at(3300, 101);
1016        let q = Query { kinds: vec![3300], z_tags: vec![], since: Some(100), ..Default::default() };
1017        assert!(!q.matches(&below), "below the floor is excluded");
1018        assert!(q.matches(&exact), "exactly the floor is included");
1019        assert!(q.matches(&above), "above the floor is included");
1020    }
1021
1022    #[test]
1023    fn z_tags_match_as_or_set() {
1024        // The whole-channel fetch lists multiple epoch pseudonyms; an event
1025        // tagged with any one of them must match.
1026        let e = evt(3300, "p2");
1027        let q = Query { kinds: vec![3300], z_tags: vec!["p1".into(), "p2".into()], since: None, ..Default::default() };
1028        assert!(q.matches(&e));
1029        let miss = Query { kinds: vec![3300], z_tags: vec!["p1".into(), "p3".into()], since: None, ..Default::default() };
1030        assert!(!miss.matches(&e));
1031    }
1032
1033    #[tokio::test]
1034    async fn fetch_unions_and_dedups_across_relays() {
1035        use super::memory::MemoryRelay;
1036        let relay = MemoryRelay::new();
1037        let relays = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
1038        let e = evt(3300, "p");
1039        relay.publish(&e, &relays).await.unwrap();
1040        let got = relay
1041            .fetch(&Query { kinds: vec![3300], z_tags: vec!["p".into()], since: None, ..Default::default() }, &relays)
1042            .await
1043            .unwrap();
1044        assert_eq!(got.len(), 1, "same event on 3 relays dedups to 1");
1045    }
1046
1047    #[tokio::test]
1048    async fn durable_broadcast_retries_only_the_failing_relays_until_they_ack() {
1049        // r1/r3 ACK on round 1; r2 fails the first 4 rounds then ACKs. The engine must keep re-sending
1050        // ONLY r2 (not the already-ACKed r1/r3) until it lands, and succeed.
1051        use std::cell::Cell;
1052        let relays = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
1053        let round = Cell::new(0usize);
1054        let r2_round_seen = Cell::new(0usize);
1055        let res = durable_broadcast(&relays, 30, std::time::Duration::ZERO, |pending| {
1056            let n = round.get();
1057            round.set(n + 1);
1058            // r2 is only ever retried alone after round 1 — assert we never re-send a relay that ACKed.
1059            if n >= 1 {
1060                assert_eq!(pending, vec!["r2".to_string()], "only the failing relay is retried");
1061                r2_round_seen.set(r2_round_seen.get() + 1);
1062            }
1063            Box::pin(async move {
1064                pending.into_iter().filter(|r| r != "r2" || n >= 4).collect()
1065            })
1066        })
1067        .await;
1068        assert!(res.is_ok(), "all relays eventually ACK → Ok");
1069        assert!(round.get() >= 5, "kept retrying r2 across rounds");
1070    }
1071
1072    #[tokio::test]
1073    async fn durable_broadcast_is_ok_if_some_ack_even_when_one_never_does() {
1074        // r1 ACKs; r2 never does. After exhausting r2's retries, the event is still durably out (r1 has
1075        // it, fetch-union covers r2), so the result is Ok — durability is best-effort per relay.
1076        let relays = vec!["r1".to_string(), "r2".to_string()];
1077        let res = durable_broadcast(&relays, 5, std::time::Duration::ZERO, |pending| {
1078            Box::pin(async move { pending.into_iter().filter(|r| r == "r1").collect() })
1079        })
1080        .await;
1081        assert!(res.is_ok(), "≥1 relay accepted → Ok despite a permanently-failing relay");
1082    }
1083
1084    #[tokio::test]
1085    async fn durable_broadcast_errs_only_if_zero_relays_ever_accept() {
1086        let relays = vec!["r1".to_string(), "r2".to_string()];
1087        let res = durable_broadcast(&relays, 5, std::time::Duration::ZERO, |_pending| {
1088            Box::pin(async move { Vec::new() }) // nobody ever ACKs
1089        })
1090        .await;
1091        assert!(res.is_err(), "zero acceptances after the retry cap → Err");
1092    }
1093
1094    #[tokio::test]
1095    async fn redundancy_self_heals_a_missing_relay() {
1096        use super::memory::MemoryRelay;
1097        let relay = MemoryRelay::new();
1098        let all = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()];
1099        let e = evt(3300, "p");
1100        // Event lands on ONLY r2 (the others "missed" it).
1101        relay.inject(&e, &["r2".to_string()]);
1102        assert_eq!(relay.count_on("r1"), 0);
1103        assert_eq!(relay.count_on("r2"), 1);
1104        // A fetch across the full set still finds it (redundancy).
1105        let got = relay.fetch(&Query { kinds: vec![3300], ..Default::default() }, &all).await.unwrap();
1106        assert_eq!(got.len(), 1);
1107    }
1108
1109    #[tokio::test]
1110    async fn ephemeral_kind_streams_live_but_is_never_stored_or_fetched() {
1111        use super::memory::MemoryRelay;
1112        let relay = MemoryRelay::new();
1113        let relays = vec!["r1".to_string()];
1114        let mut sub = relay.subscribe(Query { kinds: vec![21059], ..Default::default() });
1115        let e = evt(21059, "p");
1116        relay.publish(&e, &relays).await.unwrap();
1117        assert_eq!(relay.count_on("r1"), 0, "ephemeral is never stored");
1118        let got = relay
1119            .fetch(&Query { kinds: vec![21059], ..Default::default() }, &relays)
1120            .await
1121            .unwrap();
1122        assert!(got.is_empty(), "a real relay never serves an ephemeral from a fetch");
1123        assert_eq!(sub.try_recv().unwrap().id, e.id, "but a live subscriber receives it");
1124    }
1125
1126    #[tokio::test]
1127    async fn stored_kind_is_fetchable_and_delivered_live() {
1128        use super::memory::MemoryRelay;
1129        let relay = MemoryRelay::new();
1130        let relays = vec!["r1".to_string()];
1131        let mut sub = relay.subscribe(Query { kinds: vec![1059], ..Default::default() });
1132        let e = evt(1059, "p");
1133        relay.publish(&e, &relays).await.unwrap();
1134        let got = relay
1135            .fetch(&Query { kinds: vec![1059], ..Default::default() }, &relays)
1136            .await
1137            .unwrap();
1138        assert_eq!(got.len(), 1, "stored kind is fetchable");
1139        assert_eq!(sub.try_recv().unwrap().id, e.id, "and delivered to the live subscriber");
1140    }
1141
1142    #[tokio::test]
1143    async fn p_tags_route_a_giftwrap_to_the_matching_subscriber() {
1144        use super::memory::MemoryRelay;
1145        let relay = MemoryRelay::new();
1146        let relays = vec!["r1".to_string()];
1147        let alice = Keys::generate().public_key().to_hex();
1148        let bob = Keys::generate().public_key().to_hex();
1149        let mut sub_alice =
1150            relay.subscribe(Query { kinds: vec![1059], p_tags: vec![alice.clone()], ..Default::default() });
1151        let mut sub_bob =
1152            relay.subscribe(Query { kinds: vec![1059], p_tags: vec![bob.clone()], ..Default::default() });
1153        let wrap = evt_sl(1059, Alphabet::P, &alice);
1154        relay.publish(&wrap, &relays).await.unwrap();
1155        assert_eq!(sub_alice.try_recv().unwrap().id, wrap.id, "addressed recipient gets it live");
1156        assert!(sub_bob.try_recv().is_err(), "a differently-addressed subscriber does not");
1157        // Fetch agrees with the live routing.
1158        let for_alice = relay
1159            .fetch(&Query { kinds: vec![1059], p_tags: vec![alice], ..Default::default() }, &relays)
1160            .await
1161            .unwrap();
1162        assert_eq!(for_alice.len(), 1);
1163        let for_bob = relay
1164            .fetch(&Query { kinds: vec![1059], p_tags: vec![bob], ..Default::default() }, &relays)
1165            .await
1166            .unwrap();
1167        assert!(for_bob.is_empty());
1168    }
1169}