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